@ontrails/core 1.0.0-beta.18 → 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/trail.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { z } from 'zod';
2
2
 
3
+ import { ValidationError } from './errors.js';
3
4
  import type {
4
5
  ActivationEntry,
5
6
  ActivationEntrySpec,
@@ -22,11 +23,13 @@ import {
22
23
  } from './signal-ref.js';
23
24
  import type { TrailsError } from './errors.js';
24
25
  import type {
26
+ ComposeTrailContext,
25
27
  Detour,
26
28
  Implementation,
27
29
  PermitRequirement,
28
30
  TrailContext,
29
31
  } from './types.js';
32
+ import { zodToJsonSchema } from './validation.js';
30
33
 
31
34
  // ---------------------------------------------------------------------------
32
35
  // Trail example
@@ -68,31 +71,213 @@ export interface TrailExample<I, O> {
68
71
  }
69
72
 
70
73
  // ---------------------------------------------------------------------------
71
- // Blaze input — merges crossInput when declared
74
+ // Blaze input — merges composeInput when declared
72
75
  // ---------------------------------------------------------------------------
73
76
 
74
77
  /**
75
78
  * The input type received by a trail's blaze function.
76
79
  *
77
- * When a trail declares `crossInput`, the runtime merges those fields into
80
+ * When a trail declares `composeInput`, the runtime merges those fields into
78
81
  * the input object before calling blaze. This type makes the compiler aware
79
- * of the merged shape so developers can access crossInput fields without a
82
+ * of the merged shape so developers can access composeInput fields without a
80
83
  * cast. Falls back to plain `I` when `CI` is `never` (the default).
81
84
  */
82
85
  export type BlazeInput<I, CI> = [CI] extends [never] ? I : I & CI;
83
86
 
87
+ type TrailRef = string | { readonly id: string };
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Trail versioning
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Contract pair represented by a version entry. */
94
+ export interface VersionContract<I = unknown, O = unknown> {
95
+ readonly input: I;
96
+ readonly output: O;
97
+ }
98
+
99
+ export interface TrailVersionDeprecatedStatus {
100
+ readonly migration?: readonly string[] | undefined;
101
+ readonly note?: string | undefined;
102
+ readonly state: 'deprecated';
103
+ readonly successor?: number | undefined;
104
+ readonly [key: string]: unknown;
105
+ }
106
+
107
+ export interface TrailVersionArchivedStatus {
108
+ readonly reason?: string | undefined;
109
+ readonly state: 'archived';
110
+ readonly [key: string]: unknown;
111
+ }
112
+
113
+ /** Shared lifecycle metadata for historical version entries. */
114
+ export type TrailVersionStatus =
115
+ | TrailVersionArchivedStatus
116
+ | TrailVersionDeprecatedStatus;
117
+
118
+ /** Shared base for version entries. Historical entries never inherit schemas. */
119
+ export interface VersionEntry<
120
+ TContract extends VersionContract = VersionContract,
121
+ > {
122
+ readonly examples?:
123
+ | readonly TrailExample<TContract['input'], TContract['output']>[]
124
+ | undefined;
125
+ readonly input: z.ZodType<TContract['input']>;
126
+ readonly marker?: never;
127
+ readonly output: z.ZodType<TContract['output']>;
128
+ readonly status?: TrailVersionStatus | undefined;
129
+ }
130
+
131
+ export type TrailVersionTransposeInput<VersionInput, CurrentInput> = (value: {
132
+ readonly input: VersionInput;
133
+ }) => CurrentInput | Promise<CurrentInput>;
134
+
135
+ export type TrailVersionTransposeOutput<CurrentOutput, VersionOutput> =
136
+ (value: {
137
+ readonly output: CurrentOutput;
138
+ }) => VersionOutput | Promise<VersionOutput>;
139
+
140
+ export interface TrailVersionTranspose<
141
+ VersionInput,
142
+ VersionOutput,
143
+ CurrentInput,
144
+ CurrentOutput,
145
+ > {
146
+ readonly input: TrailVersionTransposeInput<VersionInput, CurrentInput>;
147
+ readonly output: TrailVersionTransposeOutput<CurrentOutput, VersionOutput>;
148
+ }
149
+
150
+ export interface TrailVersionRevisionEntry<
151
+ VersionInput = unknown,
152
+ VersionOutput = unknown,
153
+ CurrentInput = unknown,
154
+ CurrentOutput = unknown,
155
+ > extends VersionEntry<VersionContract<VersionInput, VersionOutput>> {
156
+ readonly blaze?: never;
157
+ readonly composeInput?: never;
158
+ readonly composes?: never;
159
+ readonly detours?: never;
160
+ readonly kind?: never;
161
+ readonly resources?: never;
162
+ readonly transpose?:
163
+ | TrailVersionTranspose<
164
+ VersionInput,
165
+ VersionOutput,
166
+ CurrentInput,
167
+ CurrentOutput
168
+ >
169
+ | undefined;
170
+ }
171
+
172
+ export interface TrailVersionForkEntry<
173
+ VersionInput = unknown,
174
+ VersionOutput = unknown,
175
+ ComposeInput = never,
176
+ > extends VersionEntry<VersionContract<VersionInput, VersionOutput>> {
177
+ readonly blaze: Implementation<
178
+ BlazeInput<VersionInput, ComposeInput>,
179
+ VersionOutput
180
+ >;
181
+ readonly composes?: readonly (string | AnyTrail)[] | undefined;
182
+ readonly composeInput?: z.ZodType<ComposeInput> | undefined;
183
+ readonly detours?:
184
+ | readonly Detour<VersionInput, VersionOutput, TrailsError>[]
185
+ | undefined;
186
+ readonly kind?: never;
187
+ readonly resources?: readonly AnyResource[] | undefined;
188
+ readonly transpose?: never;
189
+ }
190
+
191
+ export type TrailVersionEntry<
192
+ VersionInput = unknown,
193
+ VersionOutput = unknown,
194
+ CurrentInput = unknown,
195
+ CurrentOutput = unknown,
196
+ ComposeInput = never,
197
+ > =
198
+ | TrailVersionRevisionEntry<
199
+ VersionInput,
200
+ VersionOutput,
201
+ CurrentInput,
202
+ CurrentOutput
203
+ >
204
+ | TrailVersionForkEntry<VersionInput, VersionOutput, ComposeInput>;
205
+
206
+ export type TrailVersionEntryKind = 'revision' | 'fork';
207
+
208
+ export type TrailVersions<
209
+ CurrentInput = unknown,
210
+ CurrentOutput = unknown,
211
+ > = Readonly<
212
+ Record<
213
+ number,
214
+ TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
215
+ >
216
+ >;
217
+
218
+ export const getTrailVersionEntryKind = (
219
+ entry: TrailVersionEntry
220
+ ): TrailVersionEntryKind => {
221
+ const raw = entry as unknown as Record<string, unknown>;
222
+ return typeof raw['blaze'] === 'function' ? 'fork' : 'revision';
223
+ };
224
+
225
+ export const isArchivedTrailVersionEntry = (
226
+ entry: Pick<TrailVersionEntry, 'status'>
227
+ ): boolean => entry.status?.state === 'archived';
228
+
229
+ export const isDeprecatedTrailVersionEntry = (
230
+ entry: Pick<TrailVersionEntry, 'status'>
231
+ ): boolean => entry.status?.state === 'deprecated';
232
+
233
+ export const isLiveTrailVersionEntry = (
234
+ entry: Pick<TrailVersionEntry, 'status'>
235
+ ): boolean => entry.status === undefined || entry.status.state === 'deprecated';
236
+
237
+ export const hasDeprecatedTrailVersionGuidance = (
238
+ status: Pick<TrailVersionDeprecatedStatus, 'migration' | 'note' | 'successor'>
239
+ ): boolean =>
240
+ status.successor !== undefined ||
241
+ (Array.isArray(status.migration) && status.migration.length > 0) ||
242
+ (typeof status.note === 'string' && status.note.trim().length > 0);
243
+
244
+ export const deriveSupportedTrailVersions = (
245
+ trail: Pick<AnyTrail, 'version' | 'versions'>
246
+ ): readonly number[] => {
247
+ if (trail.version === undefined) {
248
+ return [];
249
+ }
250
+
251
+ const supported = new Set<number>([trail.version]);
252
+ for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
253
+ if (isLiveTrailVersionEntry(entry)) {
254
+ supported.add(Number(rawVersion));
255
+ }
256
+ }
257
+
258
+ return Object.freeze([...supported].toSorted((a, b) => a - b));
259
+ };
260
+
84
261
  // ---------------------------------------------------------------------------
85
262
  // Trail spec
86
263
  // ---------------------------------------------------------------------------
87
264
 
88
265
  /** Everything needed to define a trail (minus the id) */
89
- export interface TrailSpec<I, O, CI = never> {
266
+ type ComposeContextFor<C extends readonly TrailRef[] | undefined> =
267
+ C extends undefined ? TrailContext : ComposeTrailContext;
268
+
269
+ export interface TrailSpec<
270
+ I,
271
+ O,
272
+ CI = never,
273
+ C extends readonly TrailRef[] | undefined = undefined,
274
+ > {
90
275
  /** Zod schema for validating input */
91
276
  readonly input: z.ZodType<I>;
92
277
  /** Zod schema for validating output (optional — some trails are fire-and-forget) */
93
278
  readonly output?: z.ZodType<O> | undefined;
94
279
  /** The pure function that does the work (sync or async authoring) */
95
- readonly blaze: Implementation<BlazeInput<I, CI>, O>;
280
+ readonly blaze: Implementation<BlazeInput<I, CI>, O, ComposeContextFor<C>>;
96
281
  /** Human-readable description */
97
282
  readonly description?: string | undefined;
98
283
  /** Declared operational shape for governance, derivation, and agent guidance. */
@@ -134,17 +319,17 @@ export interface TrailSpec<I, O, CI = never> {
134
319
  readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
135
320
  /** Contours this trail operates on. */
136
321
  readonly contours?: readonly AnyContour[] | undefined;
137
- /** IDs or trail objects of downstream trails this trail may invoke via ctx.cross() */
138
- readonly crosses?: readonly (string | AnyTrail)[] | undefined;
322
+ /** IDs or trail objects of downstream trails this trail may invoke via ctx.compose() */
323
+ readonly composes?: C;
139
324
  /**
140
- * Composition-only input schema — merged with `input` for `ctx.cross()` calls,
325
+ * Composition-only input schema — merged with `input` for `ctx.compose()` calls,
141
326
  * invisible to public surfaces (CLI, MCP, HTTP).
142
327
  *
143
328
  * Fields here are available in the blaze but are not derived into CLI flags,
144
329
  * MCP tool parameters, or HTTP request bodies. Use for data that only makes
145
- * sense when one trail crosses another (e.g. `forkedFrom`).
330
+ * sense when one trail composes another (e.g. `forkedFrom`).
146
331
  */
147
- readonly crossInput?: z.ZodType<CI> | undefined;
332
+ readonly composeInput?: z.ZodType<CI> | undefined;
148
333
  /** Resources this trail may access via resource.from(ctx) */
149
334
  readonly resources?: readonly AnyResource[] | undefined;
150
335
  /**
@@ -154,7 +339,7 @@ export interface TrailSpec<I, O, CI = never> {
154
339
  * normalized to the signal's id at trail definition time, so
155
340
  * `trail.fires` is always `readonly string[]`.
156
341
  *
157
- * Note: `crosses` also accepts trail objects (normalized to IDs),
342
+ * Note: `composes` also accepts trail objects (normalized to IDs),
158
343
  * following the same pattern as signal references here.
159
344
  */
160
345
  readonly fires?: readonly (string | AnySignal)[] | undefined;
@@ -171,6 +356,12 @@ export interface TrailSpec<I, O, CI = never> {
171
356
  readonly permit?: PermitRequirement | undefined;
172
357
  /** Primary input fields and their order. CLI projects as positional args. */
173
358
  readonly args?: readonly string[] | false | undefined;
359
+ /** Current trail version number. Omit for current-only unversioned trails. */
360
+ readonly version?: number | undefined;
361
+ /** Explicit historical trail versions. Current stays top-level. */
362
+ readonly versions?: TrailVersions<I, O> | undefined;
363
+ /** Version markers are projected into the resolved graph, not authored. */
364
+ readonly marker?: never;
174
365
  }
175
366
 
176
367
  // ---------------------------------------------------------------------------
@@ -191,12 +382,12 @@ export type TrailVisibility = 'public' | 'internal';
191
382
 
192
383
  /** A fully-defined trail — the unit of work in the Trails system */
193
384
  export interface Trail<I, O, CI = never> extends Omit<
194
- TrailSpec<I, O, CI>,
385
+ TrailSpec<I, O, CI, readonly TrailRef[] | undefined>,
195
386
  | 'args'
196
387
  | 'blaze'
197
388
  | 'contours'
198
- | 'crosses'
199
- | 'crossInput'
389
+ | 'composes'
390
+ | 'composeInput'
200
391
  | 'detours'
201
392
  | 'fires'
202
393
  | 'intent'
@@ -209,10 +400,10 @@ export interface Trail<I, O, CI = never> extends Omit<
209
400
  readonly blaze: Implementation<BlazeInput<I, CI>, O>;
210
401
  /** Contours this trail operates on (always present, default []). */
211
402
  readonly contours: readonly AnyContour[];
212
- /** IDs of downstream trails this trail may invoke via ctx.cross() (always present, default []) */
213
- readonly crosses: readonly string[];
214
- /** Composition-only input schema, merged with `input` for ctx.cross() calls (optional) */
215
- readonly crossInput?: z.ZodType<CI> | undefined;
403
+ /** IDs of downstream trails this trail may invoke via ctx.compose() (always present, default []) */
404
+ readonly composes: readonly string[];
405
+ /** Composition-only input schema, merged with `input` for ctx.compose() calls (optional) */
406
+ readonly composeInput?: z.ZodType<CI> | undefined;
216
407
  /** Recovery paths activated when blaze fails with a matching error (always present, default []). */
217
408
  readonly detours: readonly Detour<I, O, TrailsError>[];
218
409
  /**
@@ -358,13 +549,427 @@ const extractSignalActivationIds = (
358
549
  .map((entry) => entry.source.id)
359
550
  );
360
551
 
361
- /** Normalize a crosses entry — trail objects are reduced to their id. */
362
- const normalizeCrossRef = (entry: string | AnyTrail): string =>
552
+ /** Normalize a composes entry — trail objects are reduced to their id. */
553
+ const normalizeComposeRef = (entry: TrailRef): string =>
363
554
  typeof entry === 'string' ? entry : entry.id;
364
555
 
556
+ const assertVersionNumber = (
557
+ trailId: string,
558
+ label: string,
559
+ version: number
560
+ ): void => {
561
+ if (!Number.isSafeInteger(version) || version <= 0) {
562
+ throw new ValidationError(
563
+ `Trail "${trailId}" ${label} must be a positive integer`
564
+ );
565
+ }
566
+ };
567
+
568
+ const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
569
+ Object.hasOwn(value, key);
570
+
571
+ const ORDER_INSENSITIVE_SCHEMA_ARRAY_KEYS = new Set([
572
+ 'allOf',
573
+ 'anyOf',
574
+ 'enum',
575
+ 'oneOf',
576
+ 'required',
577
+ 'type',
578
+ ]);
579
+
580
+ const canonicalizeVersionSchema = (
581
+ value: unknown,
582
+ parentKey?: string
583
+ ): unknown => {
584
+ if (Array.isArray(value)) {
585
+ const items = value.map((item) => canonicalizeVersionSchema(item));
586
+ return parentKey !== undefined &&
587
+ ORDER_INSENSITIVE_SCHEMA_ARRAY_KEYS.has(parentKey)
588
+ ? items.toSorted((left, right) =>
589
+ JSON.stringify(left).localeCompare(JSON.stringify(right))
590
+ )
591
+ : items;
592
+ }
593
+ if (value !== null && typeof value === 'object') {
594
+ const sorted: Record<string, unknown> = {};
595
+ for (const key of Object.keys(value).toSorted()) {
596
+ sorted[key] = canonicalizeVersionSchema(
597
+ (value as Record<string, unknown>)[key],
598
+ key
599
+ );
600
+ }
601
+ return sorted;
602
+ }
603
+ return value;
604
+ };
605
+
606
+ const schemasMatch = (left: z.ZodType, right: z.ZodType): boolean =>
607
+ JSON.stringify(canonicalizeVersionSchema(zodToJsonSchema(left))) ===
608
+ JSON.stringify(canonicalizeVersionSchema(zodToJsonSchema(right)));
609
+
610
+ const assertZodSchema = (
611
+ trailId: string,
612
+ version: number,
613
+ entry: Record<string, unknown>,
614
+ field: 'input' | 'output'
615
+ ): void => {
616
+ if (!hasOwn(entry, field) || entry[field] === undefined) {
617
+ throw new ValidationError(
618
+ `Trail "${trailId}" version ${version} must declare explicit ${field}`
619
+ );
620
+ }
621
+ };
622
+
623
+ const normalizeVersionStatusMigration = (
624
+ trailId: string,
625
+ version: number,
626
+ migration: unknown
627
+ ): readonly string[] | undefined => {
628
+ if (migration === undefined) {
629
+ return undefined;
630
+ }
631
+ if (!Array.isArray(migration)) {
632
+ throw new ValidationError(
633
+ `Trail "${trailId}" version ${version} status.migration must be an array`
634
+ );
635
+ }
636
+
637
+ return Object.freeze(
638
+ migration.map((step, index) => {
639
+ if (typeof step !== 'string' || step.trim().length === 0) {
640
+ throw new ValidationError(
641
+ `Trail "${trailId}" version ${version} status.migration[${index}] must be a non-empty string`
642
+ );
643
+ }
644
+ return step;
645
+ })
646
+ );
647
+ };
648
+
649
+ const normalizeVersionStatus = (
650
+ trailId: string,
651
+ version: number,
652
+ status: unknown
653
+ ): TrailVersionStatus | undefined => {
654
+ if (status === undefined) {
655
+ return undefined;
656
+ }
657
+ if (typeof status !== 'object' || status === null || Array.isArray(status)) {
658
+ throw new ValidationError(
659
+ `Trail "${trailId}" version ${version} status must be an object`
660
+ );
661
+ }
662
+
663
+ const raw = status as Record<string, unknown>;
664
+ if (raw['state'] !== 'deprecated' && raw['state'] !== 'archived') {
665
+ throw new ValidationError(
666
+ `Trail "${trailId}" version ${version} status.state must be "deprecated" or "archived"`
667
+ );
668
+ }
669
+ if (raw['state'] === 'deprecated') {
670
+ if (raw['successor'] !== undefined) {
671
+ assertVersionNumber(
672
+ trailId,
673
+ `version ${version} status.successor`,
674
+ raw['successor'] as number
675
+ );
676
+ }
677
+ if (raw['note'] !== undefined) {
678
+ if (typeof raw['note'] !== 'string') {
679
+ throw new ValidationError(
680
+ `Trail "${trailId}" version ${version} status.note must be a string`
681
+ );
682
+ }
683
+ if (raw['note'].trim().length === 0) {
684
+ throw new ValidationError(
685
+ `Trail "${trailId}" version ${version} status.note must be a non-empty string`
686
+ );
687
+ }
688
+ }
689
+ const migration = normalizeVersionStatusMigration(
690
+ trailId,
691
+ version,
692
+ raw['migration']
693
+ );
694
+ const normalized = Object.freeze({
695
+ ...raw,
696
+ ...(migration === undefined ? {} : { migration }),
697
+ state: 'deprecated',
698
+ }) as TrailVersionDeprecatedStatus;
699
+ if (!hasDeprecatedTrailVersionGuidance(normalized)) {
700
+ throw new ValidationError(
701
+ `Trail "${trailId}" version ${version} deprecated status must declare successor, migration, or note guidance`
702
+ );
703
+ }
704
+ return normalized;
705
+ }
706
+
707
+ if (
708
+ raw['reason'] !== undefined &&
709
+ (typeof raw['reason'] !== 'string' || raw['reason'].trim().length === 0)
710
+ ) {
711
+ throw new ValidationError(
712
+ `Trail "${trailId}" version ${version} status.reason must be a non-empty string`
713
+ );
714
+ }
715
+
716
+ return Object.freeze({ ...raw, state: 'archived' }) as TrailVersionStatus;
717
+ };
718
+
719
+ const normalizeVersionExamples = (
720
+ trailId: string,
721
+ version: number,
722
+ examples: unknown
723
+ ): readonly TrailExample<unknown, unknown>[] | undefined => {
724
+ if (examples === undefined) {
725
+ return undefined;
726
+ }
727
+ if (!Array.isArray(examples)) {
728
+ throw new ValidationError(
729
+ `Trail "${trailId}" version ${version} examples must be an array`
730
+ );
731
+ }
732
+
733
+ return Object.freeze([...examples]) as readonly TrailExample<
734
+ unknown,
735
+ unknown
736
+ >[];
737
+ };
738
+
739
+ const normalizeTranspose = (
740
+ trailId: string,
741
+ version: number,
742
+ transpose: unknown
743
+ ): TrailVersionTranspose<unknown, unknown, unknown, unknown> | undefined => {
744
+ if (transpose === undefined) {
745
+ return undefined;
746
+ }
747
+ if (
748
+ typeof transpose !== 'object' ||
749
+ transpose === null ||
750
+ Array.isArray(transpose)
751
+ ) {
752
+ throw new ValidationError(
753
+ `Trail "${trailId}" version ${version} transpose must be an object`
754
+ );
755
+ }
756
+
757
+ const raw = transpose as Record<string, unknown>;
758
+ if (
759
+ typeof raw['input'] !== 'function' ||
760
+ typeof raw['output'] !== 'function'
761
+ ) {
762
+ throw new ValidationError(
763
+ `Trail "${trailId}" version ${version} transpose must define input and output functions`
764
+ );
765
+ }
766
+
767
+ return Object.freeze({
768
+ input: raw['input'],
769
+ output: raw['output'],
770
+ }) as TrailVersionTranspose<unknown, unknown, unknown, unknown>;
771
+ };
772
+
773
+ const assertRevisionOwnsNoRuntimeFields = (
774
+ trailId: string,
775
+ version: number,
776
+ entry: Record<string, unknown>
777
+ ): void => {
778
+ const forbidden = ['composeInput', 'composes', 'resources', 'detours'];
779
+ const declared = forbidden.filter((field) => hasOwn(entry, field));
780
+ if (declared.length > 0) {
781
+ throw new ValidationError(
782
+ `Trail "${trailId}" version ${version} is a revision and cannot declare ${declared.join(', ')}`
783
+ );
784
+ }
785
+ };
786
+
787
+ const normalizeVersionEntry = <CurrentInput, CurrentOutput>(
788
+ trailId: string,
789
+ version: number,
790
+ currentInput: z.ZodType<CurrentInput>,
791
+ currentOutput: z.ZodType<CurrentOutput> | undefined,
792
+ entry: TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
793
+ ): TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput> => {
794
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
795
+ throw new ValidationError(
796
+ `Trail "${trailId}" version ${version} must be an object`
797
+ );
798
+ }
799
+
800
+ const raw = entry as unknown as Record<string, unknown>;
801
+ assertZodSchema(trailId, version, raw, 'input');
802
+ assertZodSchema(trailId, version, raw, 'output');
803
+
804
+ if (hasOwn(raw, 'kind')) {
805
+ throw new ValidationError(
806
+ `Trail "${trailId}" version ${version} must not author kind; it is projected`
807
+ );
808
+ }
809
+ if (hasOwn(raw, 'marker')) {
810
+ throw new ValidationError(
811
+ `Trail "${trailId}" version ${version} must not author marker; it is projected`
812
+ );
813
+ }
814
+
815
+ const hasBlaze = typeof raw['blaze'] === 'function';
816
+ const hasTranspose = raw['transpose'] !== undefined;
817
+ if (hasBlaze && hasTranspose) {
818
+ throw new ValidationError(
819
+ `Trail "${trailId}" version ${version} cannot declare both blaze and transpose`
820
+ );
821
+ }
822
+
823
+ const base = {
824
+ ...(raw['examples'] === undefined
825
+ ? {}
826
+ : {
827
+ examples: normalizeVersionExamples(trailId, version, raw['examples']),
828
+ }),
829
+ input: raw['input'],
830
+ output: raw['output'],
831
+ ...(raw['status'] === undefined
832
+ ? {}
833
+ : { status: normalizeVersionStatus(trailId, version, raw['status']) }),
834
+ };
835
+
836
+ if (hasBlaze) {
837
+ return Object.freeze({
838
+ ...base,
839
+ blaze: async (input: unknown, ctx: TrailContext) =>
840
+ await (raw['blaze'] as Implementation<unknown, unknown>)(input, ctx),
841
+ ...(raw['composeInput'] === undefined
842
+ ? {}
843
+ : { composeInput: raw['composeInput'] }),
844
+ composes: Object.freeze(
845
+ (
846
+ (raw['composes'] as readonly (string | AnyTrail)[] | undefined) ?? []
847
+ ).map(normalizeComposeRef)
848
+ ),
849
+ detours: Object.freeze([
850
+ ...(((raw['detours'] as readonly Detour<
851
+ unknown,
852
+ unknown,
853
+ TrailsError
854
+ >[]) ?? []) as readonly Detour<unknown, unknown, TrailsError>[]),
855
+ ]),
856
+ resources: Object.freeze([
857
+ ...(((raw['resources'] as readonly AnyResource[]) ??
858
+ []) as readonly AnyResource[]),
859
+ ]),
860
+ }) as TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>;
861
+ }
862
+
863
+ assertRevisionOwnsNoRuntimeFields(trailId, version, raw);
864
+ const inputMatchesCurrent = schemasMatch(
865
+ raw['input'] as z.ZodType,
866
+ currentInput
867
+ );
868
+ const outputMatchesCurrent =
869
+ currentOutput === undefined ||
870
+ schemasMatch(raw['output'] as z.ZodType, currentOutput);
871
+ if (!hasTranspose && (!inputMatchesCurrent || !outputMatchesCurrent)) {
872
+ throw new ValidationError(
873
+ `Trail "${trailId}" version ${version} changes schema and must declare transpose`
874
+ );
875
+ }
876
+
877
+ return Object.freeze({
878
+ ...base,
879
+ ...(hasTranspose
880
+ ? { transpose: normalizeTranspose(trailId, version, raw['transpose']) }
881
+ : {}),
882
+ }) as TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>;
883
+ };
884
+
885
+ const normalizeTrailVersions = <CurrentInput, CurrentOutput>(
886
+ trailId: string,
887
+ currentInput: z.ZodType<CurrentInput>,
888
+ currentOutput: z.ZodType<CurrentOutput> | undefined,
889
+ currentVersion: number | undefined,
890
+ versions: TrailVersions<CurrentInput, CurrentOutput> | undefined
891
+ ): TrailVersions<CurrentInput, CurrentOutput> | undefined => {
892
+ if (currentVersion === undefined) {
893
+ if (versions !== undefined) {
894
+ throw new ValidationError(
895
+ `Trail "${trailId}" declares versions without a current version`
896
+ );
897
+ }
898
+ return undefined;
899
+ }
900
+
901
+ assertVersionNumber(trailId, 'version', currentVersion);
902
+
903
+ if (versions === undefined) {
904
+ return undefined;
905
+ }
906
+ if (
907
+ typeof versions !== 'object' ||
908
+ versions === null ||
909
+ Array.isArray(versions)
910
+ ) {
911
+ throw new ValidationError(`Trail "${trailId}" versions must be an object`);
912
+ }
913
+
914
+ const normalized: Record<
915
+ number,
916
+ TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
917
+ > = {};
918
+ for (const [rawVersion, entry] of Object.entries(versions)) {
919
+ const historicalVersion = Number(rawVersion);
920
+ if (`${historicalVersion}` !== rawVersion) {
921
+ throw new ValidationError(
922
+ `Trail "${trailId}" versions key "${rawVersion}" must be a positive integer`
923
+ );
924
+ }
925
+ assertVersionNumber(trailId, `versions.${rawVersion}`, historicalVersion);
926
+ if (historicalVersion === currentVersion) {
927
+ throw new ValidationError(
928
+ `Trail "${trailId}" version ${historicalVersion} is current and must stay top-level`
929
+ );
930
+ }
931
+ if (historicalVersion > currentVersion) {
932
+ throw new ValidationError(
933
+ `Trail "${trailId}" version ${historicalVersion} must be less than the current version (${currentVersion})`
934
+ );
935
+ }
936
+ normalized[historicalVersion] = normalizeVersionEntry(
937
+ trailId,
938
+ historicalVersion,
939
+ currentInput,
940
+ currentOutput,
941
+ entry
942
+ );
943
+ }
944
+
945
+ const knownVersions = new Set([
946
+ currentVersion,
947
+ ...Object.keys(normalized).map(Number),
948
+ ]);
949
+ for (const [rawVersion, entry] of Object.entries(normalized)) {
950
+ if (
951
+ entry.status?.state === 'deprecated' &&
952
+ entry.status.successor !== undefined &&
953
+ (!knownVersions.has(entry.status.successor) ||
954
+ entry.status.successor === Number(rawVersion))
955
+ ) {
956
+ throw new ValidationError(
957
+ `Trail "${trailId}" version ${rawVersion} status.successor must reference the current version or another known historical version`
958
+ );
959
+ }
960
+ }
961
+
962
+ return Object.freeze(normalized);
963
+ };
964
+
365
965
  /** Freeze and normalize all collection fields from a trail spec. */
366
- const normalizeCollections = <I, O, CI>(
367
- spec: TrailSpec<I, O, CI>
966
+ const normalizeCollections = <
967
+ I,
968
+ O,
969
+ CI,
970
+ C extends readonly TrailRef[] | undefined,
971
+ >(
972
+ spec: TrailSpec<I, O, CI, C>
368
973
  ): {
369
974
  readonly args: readonly string[] | false | undefined;
370
975
  readonly activationSources: readonly ActivationEntry[];
@@ -410,16 +1015,26 @@ const normalizeCollections = <I, O, CI>(
410
1015
  * });
411
1016
  * ```
412
1017
  */
413
- export function trail<I, O, CI = never>(
414
- id: string,
415
- spec: TrailSpec<I, O, CI>
416
- ): Trail<I, O, CI>;
417
- export function trail<I, O, CI = never>(
418
- spec: TrailSpec<I, O, CI> & { readonly id: string }
419
- ): Trail<I, O, CI>;
420
- export function trail<I, O, CI = never>(
421
- idOrSpec: string | (TrailSpec<I, O, CI> & { readonly id: string }),
422
- maybeSpec?: TrailSpec<I, O, CI>
1018
+ export function trail<
1019
+ I,
1020
+ O,
1021
+ CI = never,
1022
+ const C extends readonly TrailRef[] | undefined = undefined,
1023
+ >(id: string, spec: TrailSpec<I, O, CI, C>): Trail<I, O, CI>;
1024
+ export function trail<
1025
+ I,
1026
+ O,
1027
+ CI = never,
1028
+ const C extends readonly TrailRef[] | undefined = undefined,
1029
+ >(spec: TrailSpec<I, O, CI, C> & { readonly id: string }): Trail<I, O, CI>;
1030
+ export function trail<
1031
+ I,
1032
+ O,
1033
+ CI = never,
1034
+ const C extends readonly TrailRef[] | undefined = undefined,
1035
+ >(
1036
+ idOrSpec: string | (TrailSpec<I, O, CI, C> & { readonly id: string }),
1037
+ maybeSpec?: TrailSpec<I, O, CI, C>
423
1038
  ): Trail<I, O, CI> {
424
1039
  const resolved =
425
1040
  typeof idOrSpec === 'string'
@@ -429,11 +1044,16 @@ export function trail<I, O, CI = never>(
429
1044
  if (!resolved.spec) {
430
1045
  throw new TypeError('trail() requires a spec when an id is provided');
431
1046
  }
1047
+ if (hasOwn(resolved.spec as unknown as Record<string, unknown>, 'marker')) {
1048
+ throw new ValidationError(
1049
+ `Trail "${resolved.id}" must not author marker; it is projected`
1050
+ );
1051
+ }
432
1052
 
433
1053
  const {
434
1054
  blaze,
435
- crossInput,
436
- crosses: rawCrosses,
1055
+ composeInput,
1056
+ composes: rawComposes,
437
1057
  intent: rawIntent,
438
1058
  visibility: rawVisibility,
439
1059
  // Destructure away fields handled by normalizeCollections
@@ -444,20 +1064,31 @@ export function trail<I, O, CI = never>(
444
1064
  layers: _l,
445
1065
  on: _o,
446
1066
  resources: _r,
1067
+ version: rawVersion,
1068
+ versions: rawVersions,
447
1069
  ...spec
448
1070
  } = resolved.spec;
449
1071
  const collections = normalizeCollections(resolved.spec);
1072
+ const versions = normalizeTrailVersions<I, O>(
1073
+ resolved.id,
1074
+ resolved.spec.input,
1075
+ resolved.spec.output,
1076
+ rawVersion,
1077
+ rawVersions
1078
+ );
450
1079
 
451
1080
  return Object.freeze({
452
1081
  ...spec,
453
1082
  ...collections,
454
1083
  blaze: async (input: BlazeInput<I, CI>, ctx: TrailContext) =>
455
- await blaze(input, ctx),
456
- crossInput,
457
- crosses: Object.freeze((rawCrosses ?? []).map(normalizeCrossRef)),
1084
+ await blaze(input, ctx as ComposeContextFor<C>),
1085
+ composeInput,
1086
+ composes: Object.freeze((rawComposes ?? []).map(normalizeComposeRef)),
458
1087
  id: resolved.id,
459
1088
  intent: rawIntent ?? 'write',
460
1089
  kind: 'trail' as const,
1090
+ ...(rawVersion === undefined ? {} : { version: rawVersion }),
1091
+ ...(versions === undefined ? {} : { versions }),
461
1092
  visibility: rawVisibility ?? 'public',
462
1093
  });
463
1094
  }
@@ -466,8 +1097,12 @@ export function trail<I, O, CI = never>(
466
1097
  // The Omit+override avoids a TypeScript limitation where BlazeInput's conditional type
467
1098
  // makes Trail<any, any, any> structurally incompatible with Trail<I, O, never>.
468
1099
  /* oxlint-disable no-explicit-any -- existential type for heterogeneous collections */
469
- export type AnyTrail = Omit<Trail<any, any, any>, 'blaze'> & {
1100
+ export type AnyTrail = Omit<
1101
+ Trail<any, any, never>,
1102
+ 'blaze' | 'composeInput'
1103
+ > & {
470
1104
  readonly blaze: Implementation<any, any>;
1105
+ readonly composeInput?: z.ZodType<any> | undefined;
471
1106
  };
472
1107
  /* oxlint-enable no-explicit-any */
473
1108