@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/trail.ts ADDED
@@ -0,0 +1,1351 @@
1
+ import type { z } from 'zod';
2
+
3
+ import { ValidationError } from './errors.js';
4
+ import type {
5
+ ActivationEntry,
6
+ ActivationEntrySpec,
7
+ ActivationSource,
8
+ ActivationSourceRef,
9
+ } from './activation-source.js';
10
+ import {
11
+ isActivationEntrySpec,
12
+ isActivationSource,
13
+ } from './activation-source.js';
14
+ import type { AnyEntity } from './entity.js';
15
+ import type {
16
+ FieldOverride,
17
+ CliCommandPathInput,
18
+ TrailCliRendering,
19
+ } from './derive.js';
20
+ import type { Layer } from './layer.js';
21
+ import type { Result } from './result.js';
22
+ import type { AnyResource } from './resource.js';
23
+ import type { AnySignal } from './signal.js';
24
+ import {
25
+ createLateBoundSignalMarker,
26
+ getLateBoundSignalRef,
27
+ isBoundLateBoundSignal,
28
+ } from './signal-ref.js';
29
+ import type { TrailsError } from './errors.js';
30
+ import type {
31
+ ComposeTrailContext,
32
+ Detour,
33
+ Implementation,
34
+ PermitRequirement,
35
+ TrailContext,
36
+ } from './types.js';
37
+ import { zodToJsonSchema } from './validation.js';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Trail example
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export interface TrailExampleSignalAssertion {
44
+ /** Signal contract object or stable signal ID expected during the example. */
45
+ readonly signal: AnySignal | string;
46
+ /** Exact payload assertion for the fired signal. */
47
+ readonly payload?: unknown | undefined;
48
+ /** Partial payload assertion; declared fields must match, extras ignored. */
49
+ readonly payloadMatch?: unknown | undefined;
50
+ /** Number of matching fired signals expected. Defaults to one. */
51
+ readonly times?: number | undefined;
52
+ }
53
+
54
+ /**
55
+ * A named example for documentation and testing.
56
+ *
57
+ * The `input` field accepts `Partial<I>` so that fields with schema defaults
58
+ * (e.g. `z.number().default(20)`) can be omitted from examples. The schema
59
+ * fills in defaults at validation time.
60
+ */
61
+ export interface TrailExample<I, O> {
62
+ /** Human-readable name */
63
+ readonly name: string;
64
+ /** Optional description of what this example demonstrates */
65
+ readonly description?: string | undefined;
66
+ /** The input value — fields with schema defaults may be omitted */
67
+ readonly input: Partial<I>;
68
+ /** Expected output for success-path examples (deep equality) */
69
+ readonly expected?: O | undefined;
70
+ /** Partial output assertion — declared fields must match, others ignored */
71
+ readonly expectedMatch?: Partial<O> | undefined;
72
+ /** Error class name for error-path examples */
73
+ readonly error?: string | undefined;
74
+ /** Signal fires expected while executing this example. */
75
+ readonly signals?: readonly TrailExampleSignalAssertion[] | undefined;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Implementation input — merges composeInput when declared
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * The input type received by a trail's implementation function.
84
+ *
85
+ * When a trail declares `composeInput`, the runtime merges those fields into
86
+ * the input object before calling implementation. This type makes the compiler aware
87
+ * of the merged shape so developers can access composeInput fields without a
88
+ * cast. Falls back to plain `I` when `CI` is `never` (the default).
89
+ */
90
+ export type ImplementationInput<I, CI> = [CI] extends [never] ? I : I & CI;
91
+
92
+ type TrailRef = string | { readonly id: string };
93
+
94
+ type ComposeSchemaOutput<TSchema extends z.ZodType | undefined> =
95
+ TSchema extends z.ZodType ? z.output<TSchema> : never;
96
+
97
+ type SchemaOwnedTrailSpec<
98
+ TInputSchema extends z.ZodType,
99
+ O,
100
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
101
+ C extends readonly TrailRef[] | undefined = undefined,
102
+ > = Omit<
103
+ TrailSpec<
104
+ z.output<TInputSchema>,
105
+ O,
106
+ ComposeSchemaOutput<TComposeInputSchema>,
107
+ C
108
+ >,
109
+ | 'implementation'
110
+ | 'composeInput'
111
+ | 'detours'
112
+ | 'examples'
113
+ | 'input'
114
+ | 'versions'
115
+ > & {
116
+ /** Zod schema for validating caller input and materializing implementation input. */
117
+ readonly input: TInputSchema;
118
+ /** The pure function receives schema-materialized input after validation/defaults. */
119
+ readonly implementation: Implementation<
120
+ ImplementationInput<
121
+ z.output<TInputSchema>,
122
+ ComposeSchemaOutput<TComposeInputSchema>
123
+ >,
124
+ O,
125
+ ComposeContextFor<C>
126
+ >;
127
+ /** Named examples use caller-side input; schema defaults may be omitted. */
128
+ readonly examples?:
129
+ | readonly TrailExample<z.input<TInputSchema>, O>[]
130
+ | undefined;
131
+ /** Recovery paths see the same materialized input as the implementation. */
132
+ readonly detours?:
133
+ | readonly Detour<z.output<TInputSchema>, O, TrailsError>[]
134
+ | undefined;
135
+ /** Composition-only schema, merged internally for ctx.compose() calls. */
136
+ readonly composeInput?: TComposeInputSchema | undefined;
137
+ /** Explicit historical trail versions. Current stays top-level. */
138
+ readonly versions?: TrailVersions<z.output<TInputSchema>, O> | undefined;
139
+ };
140
+
141
+ type SchemaOwnedOutputTrailSpec<
142
+ TInputSchema extends z.ZodType,
143
+ TOutputSchema extends z.ZodType,
144
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
145
+ C extends readonly TrailRef[] | undefined = undefined,
146
+ > = Omit<
147
+ SchemaOwnedTrailSpec<
148
+ TInputSchema,
149
+ z.output<TOutputSchema>,
150
+ TComposeInputSchema,
151
+ C
152
+ >,
153
+ 'output'
154
+ > & {
155
+ readonly output: TOutputSchema;
156
+ };
157
+
158
+ type SchemaOwnedTrail<
159
+ TInputSchema extends z.ZodType,
160
+ O,
161
+ TComposeInputSchema extends z.ZodType | undefined,
162
+ > = Trail<
163
+ z.output<TInputSchema>,
164
+ O,
165
+ ComposeSchemaOutput<TComposeInputSchema>
166
+ > & {
167
+ readonly input: TInputSchema;
168
+ readonly composeInput?: TComposeInputSchema | undefined;
169
+ };
170
+
171
+ type LegacyOutputTrailSpec<
172
+ I,
173
+ O,
174
+ CI,
175
+ C extends readonly TrailRef[] | undefined,
176
+ > = Omit<TrailSpec<I, O, CI, C>, 'implementation' | 'output'> & {
177
+ readonly implementation: Implementation<
178
+ ImplementationInput<I, CI>,
179
+ NoInfer<O>,
180
+ ComposeContextFor<C>
181
+ >;
182
+ readonly output: z.ZodType<O>;
183
+ };
184
+
185
+ type LegacyOutputlessTrailSpec<
186
+ I,
187
+ O,
188
+ CI,
189
+ C extends readonly TrailRef[] | undefined,
190
+ > = Omit<TrailSpec<I, O, CI, C>, 'implementation' | 'output'> & {
191
+ readonly implementation: Implementation<
192
+ ImplementationInput<I, CI>,
193
+ O,
194
+ ComposeContextFor<C>
195
+ >;
196
+ readonly output?: undefined;
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Trail versioning
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** Contract pair represented by a version entry. */
204
+ export interface VersionContract<I = unknown, O = unknown> {
205
+ readonly input: I;
206
+ readonly output: O;
207
+ }
208
+
209
+ export interface TrailVersionDeprecatedStatus {
210
+ readonly migration?: readonly string[] | undefined;
211
+ readonly note?: string | undefined;
212
+ readonly state: 'deprecated';
213
+ readonly successor?: number | undefined;
214
+ readonly [key: string]: unknown;
215
+ }
216
+
217
+ export interface TrailVersionArchivedStatus {
218
+ readonly reason?: string | undefined;
219
+ readonly state: 'archived';
220
+ readonly [key: string]: unknown;
221
+ }
222
+
223
+ /** Shared lifecycle metadata for historical version entries. */
224
+ export type TrailVersionStatus =
225
+ | TrailVersionArchivedStatus
226
+ | TrailVersionDeprecatedStatus;
227
+
228
+ /** Shared base for version entries. Historical entries never inherit schemas. */
229
+ export interface VersionEntry<
230
+ TContract extends VersionContract = VersionContract,
231
+ > {
232
+ readonly examples?:
233
+ | readonly TrailExample<TContract['input'], TContract['output']>[]
234
+ | undefined;
235
+ readonly input: z.ZodType<TContract['input']>;
236
+ readonly marker?: never;
237
+ readonly output: z.ZodType<TContract['output']>;
238
+ readonly status?: TrailVersionStatus | undefined;
239
+ }
240
+
241
+ export type TrailVersionTransposeInput<VersionInput, CurrentInput> = (value: {
242
+ readonly input: VersionInput;
243
+ }) => CurrentInput | Promise<CurrentInput>;
244
+
245
+ export type TrailVersionTransposeOutput<CurrentOutput, VersionOutput> =
246
+ (value: {
247
+ readonly output: CurrentOutput;
248
+ }) => VersionOutput | Promise<VersionOutput>;
249
+
250
+ export interface TrailVersionTranspose<
251
+ VersionInput,
252
+ VersionOutput,
253
+ CurrentInput,
254
+ CurrentOutput,
255
+ > {
256
+ readonly input: TrailVersionTransposeInput<VersionInput, CurrentInput>;
257
+ readonly output: TrailVersionTransposeOutput<CurrentOutput, VersionOutput>;
258
+ }
259
+
260
+ export interface TrailVersionRevisionEntry<
261
+ VersionInput = unknown,
262
+ VersionOutput = unknown,
263
+ CurrentInput = unknown,
264
+ CurrentOutput = unknown,
265
+ > extends VersionEntry<VersionContract<VersionInput, VersionOutput>> {
266
+ readonly implementation?: never;
267
+ readonly composeInput?: never;
268
+ readonly composes?: never;
269
+ readonly detours?: never;
270
+ readonly kind?: never;
271
+ readonly resources?: never;
272
+ readonly transpose?:
273
+ | TrailVersionTranspose<
274
+ VersionInput,
275
+ VersionOutput,
276
+ CurrentInput,
277
+ CurrentOutput
278
+ >
279
+ | undefined;
280
+ }
281
+
282
+ export interface TrailVersionForkEntry<
283
+ VersionInput = unknown,
284
+ VersionOutput = unknown,
285
+ ComposeInput = never,
286
+ > extends VersionEntry<VersionContract<VersionInput, VersionOutput>> {
287
+ readonly implementation: Implementation<
288
+ ImplementationInput<VersionInput, ComposeInput>,
289
+ VersionOutput
290
+ >;
291
+ readonly composes?: readonly (string | AnyTrail)[] | undefined;
292
+ readonly composeInput?: z.ZodType<ComposeInput> | undefined;
293
+ readonly detours?:
294
+ | readonly Detour<VersionInput, VersionOutput, TrailsError>[]
295
+ | undefined;
296
+ readonly kind?: never;
297
+ readonly resources?: readonly AnyResource[] | undefined;
298
+ readonly transpose?: never;
299
+ }
300
+
301
+ export type TrailVersionEntry<
302
+ VersionInput = unknown,
303
+ VersionOutput = unknown,
304
+ CurrentInput = unknown,
305
+ CurrentOutput = unknown,
306
+ ComposeInput = never,
307
+ > =
308
+ | TrailVersionRevisionEntry<
309
+ VersionInput,
310
+ VersionOutput,
311
+ CurrentInput,
312
+ CurrentOutput
313
+ >
314
+ | TrailVersionForkEntry<VersionInput, VersionOutput, ComposeInput>;
315
+
316
+ export type TrailVersionEntryKind = 'revision' | 'fork';
317
+
318
+ export type TrailVersions<
319
+ CurrentInput = unknown,
320
+ CurrentOutput = unknown,
321
+ > = Readonly<
322
+ Record<
323
+ number,
324
+ TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
325
+ >
326
+ >;
327
+
328
+ /**
329
+ * Spec for {@link forkVersion}: a fork entry whose implementation signature is owned
330
+ * by the entry's own schemas instead of falling back to `unknown`.
331
+ */
332
+ export interface TrailVersionForkSpec<
333
+ TInputSchema extends z.ZodType,
334
+ TOutputSchema extends z.ZodType,
335
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
336
+ > {
337
+ /** The historical implementation, typed by this entry's schemas. */
338
+ readonly implementation: Implementation<
339
+ ImplementationInput<
340
+ z.output<TInputSchema>,
341
+ ComposeSchemaOutput<TComposeInputSchema>
342
+ >,
343
+ z.output<TOutputSchema>
344
+ >;
345
+ readonly composeInput?: TComposeInputSchema | undefined;
346
+ readonly composes?: readonly (string | AnyTrail)[] | undefined;
347
+ readonly detours?:
348
+ | readonly Detour<
349
+ z.output<TInputSchema>,
350
+ z.output<TOutputSchema>,
351
+ TrailsError
352
+ >[]
353
+ | undefined;
354
+ readonly examples?:
355
+ | readonly TrailExample<z.input<TInputSchema>, z.output<TOutputSchema>>[]
356
+ | undefined;
357
+ readonly input: TInputSchema;
358
+ readonly output: TOutputSchema;
359
+ readonly resources?: readonly AnyResource[] | undefined;
360
+ readonly status?: TrailVersionStatus | undefined;
361
+ }
362
+
363
+ /**
364
+ * Author a fork version entry with a implementation typed by the entry's own schemas.
365
+ *
366
+ * `TrailVersions` fixes every entry's generics to `unknown`, so a fork implementation
367
+ * written inline receives `unknown` input and authors end up re-parsing the
368
+ * already-validated value just to narrow it. This helper threads the entry's
369
+ * `input`/`output` schemas into the implementation signature and erases the generics
370
+ * on the way out. The erasure is sound because the fork pipeline validates
371
+ * raw input against this entry's own `input` schema before dispatching to
372
+ * the entry implementation (see `createForkTrailVersion` in execute.ts).
373
+ *
374
+ * @example
375
+ * ```ts
376
+ * const gearV1Input = z.object({ name: z.string(), weightOz: z.number() });
377
+ * const gearV1Output = z.object({ id: z.string(), weightOz: z.number() });
378
+ *
379
+ * const gearCreate = trail('gear.create', {
380
+ * // ... current v2 contract ...
381
+ * version: 2,
382
+ * versions: {
383
+ * 1: forkVersion({
384
+ * implementation: (input) =>
385
+ * // input is { name: string; weightOz: number } — no re-parse
386
+ * Result.ok({ id: input.name, weightOz: input.weightOz }),
387
+ * input: gearV1Input,
388
+ * output: gearV1Output,
389
+ * }),
390
+ * },
391
+ * });
392
+ * ```
393
+ */
394
+ export const forkVersion = <
395
+ TInputSchema extends z.ZodType,
396
+ TOutputSchema extends z.ZodType,
397
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
398
+ >(
399
+ spec: TrailVersionForkSpec<TInputSchema, TOutputSchema, TComposeInputSchema>
400
+ ): TrailVersionForkEntry =>
401
+ // Erasing the per-entry generics narrows function parameters, which TS
402
+ // cannot express without a conversion. Safe: the fork pipeline re-validates
403
+ // input against `spec.input` before the implementation runs.
404
+ spec as unknown as TrailVersionForkEntry;
405
+
406
+ export const getTrailVersionEntryKind = (
407
+ entry: TrailVersionEntry
408
+ ): TrailVersionEntryKind => {
409
+ const raw = entry as unknown as Record<string, unknown>;
410
+ return typeof raw['implementation'] === 'function' ? 'fork' : 'revision';
411
+ };
412
+
413
+ export const isArchivedTrailVersionEntry = (
414
+ entry: Pick<TrailVersionEntry, 'status'>
415
+ ): boolean => entry.status?.state === 'archived';
416
+
417
+ export const isDeprecatedTrailVersionEntry = (
418
+ entry: Pick<TrailVersionEntry, 'status'>
419
+ ): boolean => entry.status?.state === 'deprecated';
420
+
421
+ export const isLiveTrailVersionEntry = (
422
+ entry: Pick<TrailVersionEntry, 'status'>
423
+ ): boolean => entry.status === undefined || entry.status.state === 'deprecated';
424
+
425
+ export const hasDeprecatedTrailVersionGuidance = (
426
+ status: Pick<TrailVersionDeprecatedStatus, 'migration' | 'note' | 'successor'>
427
+ ): boolean =>
428
+ status.successor !== undefined ||
429
+ (Array.isArray(status.migration) && status.migration.length > 0) ||
430
+ (typeof status.note === 'string' && status.note.trim().length > 0);
431
+
432
+ export const deriveSupportedTrailVersions = (
433
+ trail: Pick<AnyTrail, 'version' | 'versions'>
434
+ ): readonly number[] => {
435
+ if (trail.version === undefined) {
436
+ return [];
437
+ }
438
+
439
+ const supported = new Set<number>([trail.version]);
440
+ for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
441
+ if (isLiveTrailVersionEntry(entry)) {
442
+ supported.add(Number(rawVersion));
443
+ }
444
+ }
445
+
446
+ return Object.freeze([...supported].toSorted((a, b) => a - b));
447
+ };
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Trail spec
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /** Everything needed to define a trail (minus the id) */
454
+ type ComposeContextFor<C extends readonly TrailRef[] | undefined> =
455
+ C extends undefined ? TrailContext : ComposeTrailContext;
456
+
457
+ export interface TrailSpec<
458
+ I,
459
+ O,
460
+ CI = never,
461
+ C extends readonly TrailRef[] | undefined = undefined,
462
+ > {
463
+ /** Zod schema for validating input */
464
+ readonly input: z.ZodType<I>;
465
+ /** Zod schema for validating output (optional — some trails are fire-and-forget) */
466
+ readonly output?: z.ZodType<O> | undefined;
467
+ /** The pure function that does the work (sync or async authoring) */
468
+ readonly implementation: Implementation<
469
+ ImplementationInput<I, CI>,
470
+ O,
471
+ ComposeContextFor<C>
472
+ >;
473
+ /** Human-readable description */
474
+ readonly description?: string | undefined;
475
+ /** Declared operational shape for governance, derivation, and agent guidance. */
476
+ readonly pattern?: string | undefined;
477
+ /** Named examples for docs and testing */
478
+ readonly examples?: readonly TrailExample<I, O>[] | undefined;
479
+ /** What this trail does to the world: read, write (default), or destroy */
480
+ readonly intent?: 'read' | 'write' | 'destroy' | undefined;
481
+ /** Trail is idempotent (safe to retry) */
482
+ readonly idempotent?: boolean | undefined;
483
+ /**
484
+ * Trail explicitly supports dry-run execution semantics.
485
+ *
486
+ * This is a declaration for governance, derivation, and surface tooling. It
487
+ * does not change runtime behavior by itself; the active invocation signal is
488
+ * `TrailContext.dryRun`.
489
+ */
490
+ readonly dryRun?: boolean | undefined;
491
+ /** Whether surfaces expose this trail by default. */
492
+ readonly visibility?: TrailVisibility | undefined;
493
+ /** Arbitrary meta for tooling and filtering */
494
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
495
+ /** Recovery paths activated when implementation fails with a matching error class. */
496
+ readonly detours?: readonly Detour<I, O, TrailsError>[] | undefined;
497
+ /**
498
+ * Typed layers attached at trail scope.
499
+ *
500
+ * Layers declared here wrap this trail's implementation on every execution,
501
+ * regardless of which surface invokes it. The execution pipeline composes
502
+ * trail-scope layers innermost — closer to the implementation than surface-scope
503
+ * or topo-scope layers — so the final order is
504
+ * `topo → surface → trail → implementation` (outermost-first).
505
+ *
506
+ * Layers are typed and inspectable. Omit `input` for surface-invisible
507
+ * wrappers that do not render any fields.
508
+ */
509
+ readonly layers?: readonly Layer[] | undefined;
510
+ /** Per-field overrides for deriveFields() (labels, hints, options) */
511
+ readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
512
+ /** CLI rendering metadata for canonical command path overrides and aliases. */
513
+ readonly cli?: CliCommandPathInput | TrailCliRendering | undefined;
514
+ /** Entities this trail operates on. */
515
+ readonly entities?: readonly AnyEntity[] | undefined;
516
+ /** IDs or trail objects of downstream trails this trail may invoke via ctx.compose() */
517
+ readonly composes?: C;
518
+ /**
519
+ * Composition-only input schema — merged with `input` for `ctx.compose()` calls,
520
+ * invisible to public surfaces (CLI, MCP, HTTP).
521
+ *
522
+ * Fields here are available in the implementation but are not derived into CLI flags,
523
+ * MCP tool parameters, or HTTP request bodies. Use for data that only makes
524
+ * sense when one trail composes another (e.g. `forkedFrom`).
525
+ */
526
+ readonly composeInput?: z.ZodType<CI> | undefined;
527
+ /** Resources this trail may access via resource.from(ctx) */
528
+ readonly resources?: readonly AnyResource[] | undefined;
529
+ /**
530
+ * Signals this trail fires via `ctx.fire()`.
531
+ *
532
+ * Accepts either a string id or a `Signal` value. Both forms are
533
+ * normalized to the signal's id at trail definition time, so
534
+ * `trail.fires` is always `readonly string[]`.
535
+ *
536
+ * Note: `composes` also accepts trail objects (normalized to IDs),
537
+ * following the same pattern as signal references here.
538
+ */
539
+ readonly fires?: readonly (string | AnySignal)[] | undefined;
540
+ /**
541
+ * Activation sources that can invoke this trail.
542
+ *
543
+ * Bare strings and `Signal` values are signal-source shorthand. Object form
544
+ * preserves the source kind and per-source metadata for the activation graph.
545
+ */
546
+ readonly on?:
547
+ | readonly (ActivationEntrySpec | ActivationSourceRef)[]
548
+ | undefined;
549
+ /** Auth requirement: scopes object, 'public', or omitted (undeclared) */
550
+ readonly permit?: PermitRequirement | undefined;
551
+ /** Primary input fields and their order. CLI derives as positional args. */
552
+ readonly args?: readonly string[] | false | undefined;
553
+ /** Current trail version number. Omit for current-only unversioned trails. */
554
+ readonly version?: number | undefined;
555
+ /** Explicit historical trail versions. Current stays top-level. */
556
+ readonly versions?: TrailVersions<I, O> | undefined;
557
+ /** Version markers are derived into the resolved graph, not authored. */
558
+ readonly marker?: never;
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Trail (the frozen runtime object)
563
+ // ---------------------------------------------------------------------------
564
+
565
+ /** Intent describes what a trail does to the world. */
566
+ export const intentValues = Object.freeze([
567
+ 'read',
568
+ 'write',
569
+ 'destroy',
570
+ ] as const);
571
+
572
+ export type Intent = (typeof intentValues)[number];
573
+
574
+ /** Whether surfaces expose a trail by default. */
575
+ export type TrailVisibility = 'public' | 'internal';
576
+
577
+ /** A fully-defined trail — the unit of work in the Trails system */
578
+ export interface Trail<I, O, CI = never> extends Omit<
579
+ TrailSpec<I, O, CI, readonly TrailRef[] | undefined>,
580
+ | 'args'
581
+ | 'implementation'
582
+ | 'entities'
583
+ | 'composes'
584
+ | 'composeInput'
585
+ | 'detours'
586
+ | 'fires'
587
+ | 'intent'
588
+ | 'layers'
589
+ | 'on'
590
+ | 'resources'
591
+ > {
592
+ readonly kind: 'trail';
593
+ readonly id: string;
594
+ readonly implementation: Implementation<ImplementationInput<I, CI>, O>;
595
+ /** Entities this trail operates on (always present, default []). */
596
+ readonly entities: readonly AnyEntity[];
597
+ /** IDs of downstream trails this trail may invoke via ctx.compose() (always present, default []) */
598
+ readonly composes: readonly string[];
599
+ /** Composition-only input schema, merged with `input` for ctx.compose() calls (optional) */
600
+ readonly composeInput?: z.ZodType<CI> | undefined;
601
+ /** Recovery paths activated when implementation fails with a matching error (always present, default []). */
602
+ readonly detours: readonly Detour<I, O, TrailsError>[];
603
+ /**
604
+ * Typed layers attached at trail scope (always present, default []).
605
+ *
606
+ * Composed innermost in the layer chain — closest to the implementation. The final
607
+ * composition order is `topo → surface → trail → implementation` (outermost-first).
608
+ */
609
+ readonly layers: readonly Layer[];
610
+ /** Resources this trail may access via resource.from(ctx) (always present, default []) */
611
+ readonly resources: readonly AnyResource[];
612
+ /** IDs of signals this trail fires via ctx.fire() (always present, default []) */
613
+ readonly fires: readonly string[];
614
+ /**
615
+ * IDs of signal sources that activate this trail (always present, default []).
616
+ * Non-signal activation sources live in `activationSources`.
617
+ */
618
+ readonly on: readonly string[];
619
+ /** Normalized activation source entries declared through `on` (always present, default []). */
620
+ readonly activationSources: readonly ActivationEntry[];
621
+ /** What this trail does to the world (always present, default 'write') */
622
+ readonly intent: Intent;
623
+ /** Whether surfaces expose this trail by default (always present, default 'public'). */
624
+ readonly visibility: TrailVisibility;
625
+ /** Primary input fields and their order (always present, default undefined) */
626
+ readonly args?: readonly string[] | false | undefined;
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // Factory
631
+ // ---------------------------------------------------------------------------
632
+
633
+ const normalizeSignalRef = (entry: string | AnySignal): string => {
634
+ if (typeof entry === 'string') {
635
+ return entry;
636
+ }
637
+
638
+ const ref = getLateBoundSignalRef(entry);
639
+ if (!ref) {
640
+ return entry.id;
641
+ }
642
+
643
+ // Bound refs preserve an explicit resource choice. Authored refs always
644
+ // become markers, even when a table name contains the scope separator.
645
+ // Ownership is a contract fact; do not infer it from signal ID grammar.
646
+ if (isBoundLateBoundSignal(entry)) {
647
+ return entry.id;
648
+ }
649
+
650
+ return createLateBoundSignalMarker(ref, entry.id);
651
+ };
652
+
653
+ const freezeActivationSource = (source: ActivationSource): ActivationSource =>
654
+ Object.freeze({
655
+ ...source,
656
+ ...(source.meta === undefined
657
+ ? {}
658
+ : { meta: Object.freeze({ ...source.meta }) }),
659
+ });
660
+
661
+ const shouldPreserveSignalSource = (source: ActivationSource): boolean =>
662
+ source.kind === 'signal' &&
663
+ (!('payload' in source) ||
664
+ 'input' in source ||
665
+ 'parse' in source ||
666
+ 'cron' in source ||
667
+ 'timezone' in source);
668
+
669
+ const normalizeActivationSource = (
670
+ source: ActivationSourceRef
671
+ ): ActivationSource => {
672
+ if (typeof source === 'string') {
673
+ return freezeActivationSource({ id: source, kind: 'signal' });
674
+ }
675
+
676
+ if (isActivationSource(source) && shouldPreserveSignalSource(source)) {
677
+ return freezeActivationSource({
678
+ ...source,
679
+ id: normalizeSignalRef(source.id),
680
+ kind: 'signal',
681
+ });
682
+ }
683
+
684
+ if (isActivationSource(source) && source.kind !== 'signal') {
685
+ return freezeActivationSource(source);
686
+ }
687
+
688
+ return freezeActivationSource({
689
+ id: normalizeSignalRef(source as string | AnySignal),
690
+ kind: 'signal',
691
+ });
692
+ };
693
+
694
+ const normalizeActivationEntry = (
695
+ entry: ActivationEntrySpec | ActivationSourceRef
696
+ ): ActivationEntry => {
697
+ const source = isActivationEntrySpec(entry) ? entry.source : entry;
698
+ const normalized: ActivationEntry = {
699
+ source: normalizeActivationSource(source),
700
+ ...(isActivationEntrySpec(entry) && entry.meta !== undefined
701
+ ? { meta: Object.freeze({ ...entry.meta }) }
702
+ : {}),
703
+ ...(isActivationEntrySpec(entry) && entry.where !== undefined
704
+ ? { where: entry.where }
705
+ : {}),
706
+ };
707
+
708
+ return Object.freeze(normalized);
709
+ };
710
+
711
+ const normalizeActivationSources = (
712
+ entries: readonly (ActivationEntrySpec | ActivationSourceRef)[]
713
+ ): readonly ActivationEntry[] =>
714
+ Object.freeze(entries.map((entry) => normalizeActivationEntry(entry)));
715
+
716
+ const extractSignalActivationIds = (
717
+ activations: readonly ActivationEntry[]
718
+ ): readonly string[] =>
719
+ Object.freeze(
720
+ activations
721
+ .filter((entry) => entry.source.kind === 'signal')
722
+ .map((entry) => entry.source.id)
723
+ );
724
+
725
+ /** Normalize a composes entry — trail objects are reduced to their id. */
726
+ const normalizeComposeRef = (entry: TrailRef): string =>
727
+ typeof entry === 'string' ? entry : entry.id;
728
+
729
+ const assertVersionNumber = (
730
+ trailId: string,
731
+ label: string,
732
+ version: number
733
+ ): void => {
734
+ if (!Number.isSafeInteger(version) || version <= 0) {
735
+ throw new ValidationError(
736
+ `Trail "${trailId}" ${label} must be a positive integer`
737
+ );
738
+ }
739
+ };
740
+
741
+ const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
742
+ Object.hasOwn(value, key);
743
+
744
+ const ORDER_INSENSITIVE_SCHEMA_ARRAY_KEYS = new Set([
745
+ 'allOf',
746
+ 'anyOf',
747
+ 'enum',
748
+ 'oneOf',
749
+ 'required',
750
+ 'type',
751
+ ]);
752
+
753
+ const canonicalizeVersionSchema = (
754
+ value: unknown,
755
+ parentKey?: string
756
+ ): unknown => {
757
+ if (Array.isArray(value)) {
758
+ const items = value.map((item) => canonicalizeVersionSchema(item));
759
+ return parentKey !== undefined &&
760
+ ORDER_INSENSITIVE_SCHEMA_ARRAY_KEYS.has(parentKey)
761
+ ? items.toSorted((left, right) =>
762
+ JSON.stringify(left).localeCompare(JSON.stringify(right))
763
+ )
764
+ : items;
765
+ }
766
+ if (value !== null && typeof value === 'object') {
767
+ const sorted: Record<string, unknown> = {};
768
+ for (const key of Object.keys(value).toSorted()) {
769
+ sorted[key] = canonicalizeVersionSchema(
770
+ (value as Record<string, unknown>)[key],
771
+ key
772
+ );
773
+ }
774
+ return sorted;
775
+ }
776
+ return value;
777
+ };
778
+
779
+ const schemasMatch = (left: z.ZodType, right: z.ZodType): boolean =>
780
+ JSON.stringify(canonicalizeVersionSchema(zodToJsonSchema(left))) ===
781
+ JSON.stringify(canonicalizeVersionSchema(zodToJsonSchema(right)));
782
+
783
+ const assertZodSchema = (
784
+ trailId: string,
785
+ version: number,
786
+ entry: Record<string, unknown>,
787
+ field: 'input' | 'output'
788
+ ): void => {
789
+ if (!hasOwn(entry, field) || entry[field] === undefined) {
790
+ throw new ValidationError(
791
+ `Trail "${trailId}" version ${version} must declare explicit ${field}`
792
+ );
793
+ }
794
+ };
795
+
796
+ const normalizeVersionStatusMigration = (
797
+ trailId: string,
798
+ version: number,
799
+ migration: unknown
800
+ ): readonly string[] | undefined => {
801
+ if (migration === undefined) {
802
+ return undefined;
803
+ }
804
+ if (!Array.isArray(migration)) {
805
+ throw new ValidationError(
806
+ `Trail "${trailId}" version ${version} status.migration must be an array`
807
+ );
808
+ }
809
+
810
+ return Object.freeze(
811
+ migration.map((step, index) => {
812
+ if (typeof step !== 'string' || step.trim().length === 0) {
813
+ throw new ValidationError(
814
+ `Trail "${trailId}" version ${version} status.migration[${index}] must be a non-empty string`
815
+ );
816
+ }
817
+ return step;
818
+ })
819
+ );
820
+ };
821
+
822
+ const normalizeVersionStatus = (
823
+ trailId: string,
824
+ version: number,
825
+ status: unknown
826
+ ): TrailVersionStatus | undefined => {
827
+ if (status === undefined) {
828
+ return undefined;
829
+ }
830
+ if (typeof status !== 'object' || status === null || Array.isArray(status)) {
831
+ throw new ValidationError(
832
+ `Trail "${trailId}" version ${version} status must be an object`
833
+ );
834
+ }
835
+
836
+ const raw = status as Record<string, unknown>;
837
+ if (raw['state'] !== 'deprecated' && raw['state'] !== 'archived') {
838
+ throw new ValidationError(
839
+ `Trail "${trailId}" version ${version} status.state must be "deprecated" or "archived"`
840
+ );
841
+ }
842
+ if (raw['state'] === 'deprecated') {
843
+ if (raw['successor'] !== undefined) {
844
+ assertVersionNumber(
845
+ trailId,
846
+ `version ${version} status.successor`,
847
+ raw['successor'] as number
848
+ );
849
+ }
850
+ if (raw['note'] !== undefined) {
851
+ if (typeof raw['note'] !== 'string') {
852
+ throw new ValidationError(
853
+ `Trail "${trailId}" version ${version} status.note must be a string`
854
+ );
855
+ }
856
+ if (raw['note'].trim().length === 0) {
857
+ throw new ValidationError(
858
+ `Trail "${trailId}" version ${version} status.note must be a non-empty string`
859
+ );
860
+ }
861
+ }
862
+ const migration = normalizeVersionStatusMigration(
863
+ trailId,
864
+ version,
865
+ raw['migration']
866
+ );
867
+ const normalized = Object.freeze({
868
+ ...raw,
869
+ ...(migration === undefined ? {} : { migration }),
870
+ state: 'deprecated',
871
+ }) as TrailVersionDeprecatedStatus;
872
+ if (!hasDeprecatedTrailVersionGuidance(normalized)) {
873
+ throw new ValidationError(
874
+ `Trail "${trailId}" version ${version} deprecated status must declare successor, migration, or note guidance`
875
+ );
876
+ }
877
+ return normalized;
878
+ }
879
+
880
+ if (
881
+ raw['reason'] !== undefined &&
882
+ (typeof raw['reason'] !== 'string' || raw['reason'].trim().length === 0)
883
+ ) {
884
+ throw new ValidationError(
885
+ `Trail "${trailId}" version ${version} status.reason must be a non-empty string`
886
+ );
887
+ }
888
+
889
+ return Object.freeze({ ...raw, state: 'archived' }) as TrailVersionStatus;
890
+ };
891
+
892
+ const normalizeVersionExamples = (
893
+ trailId: string,
894
+ version: number,
895
+ examples: unknown
896
+ ): readonly TrailExample<unknown, unknown>[] | undefined => {
897
+ if (examples === undefined) {
898
+ return undefined;
899
+ }
900
+ if (!Array.isArray(examples)) {
901
+ throw new ValidationError(
902
+ `Trail "${trailId}" version ${version} examples must be an array`
903
+ );
904
+ }
905
+
906
+ return Object.freeze([...examples]) as readonly TrailExample<
907
+ unknown,
908
+ unknown
909
+ >[];
910
+ };
911
+
912
+ const normalizeTranspose = (
913
+ trailId: string,
914
+ version: number,
915
+ transpose: unknown
916
+ ): TrailVersionTranspose<unknown, unknown, unknown, unknown> | undefined => {
917
+ if (transpose === undefined) {
918
+ return undefined;
919
+ }
920
+ if (
921
+ typeof transpose !== 'object' ||
922
+ transpose === null ||
923
+ Array.isArray(transpose)
924
+ ) {
925
+ throw new ValidationError(
926
+ `Trail "${trailId}" version ${version} transpose must be an object`
927
+ );
928
+ }
929
+
930
+ const raw = transpose as Record<string, unknown>;
931
+ if (
932
+ typeof raw['input'] !== 'function' ||
933
+ typeof raw['output'] !== 'function'
934
+ ) {
935
+ throw new ValidationError(
936
+ `Trail "${trailId}" version ${version} transpose must define input and output functions`
937
+ );
938
+ }
939
+
940
+ return Object.freeze({
941
+ input: raw['input'],
942
+ output: raw['output'],
943
+ }) as TrailVersionTranspose<unknown, unknown, unknown, unknown>;
944
+ };
945
+
946
+ const assertRevisionOwnsNoRuntimeFields = (
947
+ trailId: string,
948
+ version: number,
949
+ entry: Record<string, unknown>
950
+ ): void => {
951
+ const forbidden = ['composeInput', 'composes', 'resources', 'detours'];
952
+ const declared = forbidden.filter((field) => hasOwn(entry, field));
953
+ if (declared.length > 0) {
954
+ throw new ValidationError(
955
+ `Trail "${trailId}" version ${version} is a revision and cannot declare ${declared.join(', ')}`
956
+ );
957
+ }
958
+ };
959
+
960
+ const normalizeVersionEntry = <CurrentInput, CurrentOutput>(
961
+ trailId: string,
962
+ version: number,
963
+ currentInput: z.ZodType<CurrentInput>,
964
+ currentOutput: z.ZodType<CurrentOutput> | undefined,
965
+ entry: TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
966
+ ): TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput> => {
967
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
968
+ throw new ValidationError(
969
+ `Trail "${trailId}" version ${version} must be an object`
970
+ );
971
+ }
972
+
973
+ const raw = entry as unknown as Record<string, unknown>;
974
+ assertZodSchema(trailId, version, raw, 'input');
975
+ assertZodSchema(trailId, version, raw, 'output');
976
+
977
+ if (hasOwn(raw, 'kind')) {
978
+ throw new ValidationError(
979
+ `Trail "${trailId}" version ${version} must not author kind; it is derived`
980
+ );
981
+ }
982
+ if (hasOwn(raw, 'marker')) {
983
+ throw new ValidationError(
984
+ `Trail "${trailId}" version ${version} must not author marker; it is derived`
985
+ );
986
+ }
987
+
988
+ const hasImplementation = typeof raw['implementation'] === 'function';
989
+ const hasTranspose = raw['transpose'] !== undefined;
990
+ if (hasImplementation && hasTranspose) {
991
+ throw new ValidationError(
992
+ `Trail "${trailId}" version ${version} cannot declare both implementation and transpose`
993
+ );
994
+ }
995
+
996
+ const base = {
997
+ ...(raw['examples'] === undefined
998
+ ? {}
999
+ : {
1000
+ examples: normalizeVersionExamples(trailId, version, raw['examples']),
1001
+ }),
1002
+ input: raw['input'],
1003
+ output: raw['output'],
1004
+ ...(raw['status'] === undefined
1005
+ ? {}
1006
+ : { status: normalizeVersionStatus(trailId, version, raw['status']) }),
1007
+ };
1008
+
1009
+ if (hasImplementation) {
1010
+ return Object.freeze({
1011
+ ...base,
1012
+ implementation: async (input: unknown, ctx: TrailContext) =>
1013
+ await (raw['implementation'] as Implementation<unknown, unknown>)(
1014
+ input,
1015
+ ctx
1016
+ ),
1017
+ ...(raw['composeInput'] === undefined
1018
+ ? {}
1019
+ : { composeInput: raw['composeInput'] }),
1020
+ composes: Object.freeze(
1021
+ (
1022
+ (raw['composes'] as readonly (string | AnyTrail)[] | undefined) ?? []
1023
+ ).map(normalizeComposeRef)
1024
+ ),
1025
+ detours: Object.freeze([
1026
+ ...(((raw['detours'] as readonly Detour<
1027
+ unknown,
1028
+ unknown,
1029
+ TrailsError
1030
+ >[]) ?? []) as readonly Detour<unknown, unknown, TrailsError>[]),
1031
+ ]),
1032
+ resources: Object.freeze([
1033
+ ...(((raw['resources'] as readonly AnyResource[]) ??
1034
+ []) as readonly AnyResource[]),
1035
+ ]),
1036
+ }) as TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>;
1037
+ }
1038
+
1039
+ assertRevisionOwnsNoRuntimeFields(trailId, version, raw);
1040
+ const inputMatchesCurrent = schemasMatch(
1041
+ raw['input'] as z.ZodType,
1042
+ currentInput
1043
+ );
1044
+ const outputMatchesCurrent =
1045
+ currentOutput === undefined ||
1046
+ schemasMatch(raw['output'] as z.ZodType, currentOutput);
1047
+ if (!hasTranspose && (!inputMatchesCurrent || !outputMatchesCurrent)) {
1048
+ throw new ValidationError(
1049
+ `Trail "${trailId}" version ${version} changes schema and must declare transpose`
1050
+ );
1051
+ }
1052
+
1053
+ return Object.freeze({
1054
+ ...base,
1055
+ ...(hasTranspose
1056
+ ? { transpose: normalizeTranspose(trailId, version, raw['transpose']) }
1057
+ : {}),
1058
+ }) as TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>;
1059
+ };
1060
+
1061
+ const normalizeTrailVersions = <CurrentInput, CurrentOutput>(
1062
+ trailId: string,
1063
+ currentInput: z.ZodType<CurrentInput>,
1064
+ currentOutput: z.ZodType<CurrentOutput> | undefined,
1065
+ currentVersion: number | undefined,
1066
+ versions: TrailVersions<CurrentInput, CurrentOutput> | undefined
1067
+ ): TrailVersions<CurrentInput, CurrentOutput> | undefined => {
1068
+ if (currentVersion === undefined) {
1069
+ if (versions !== undefined) {
1070
+ throw new ValidationError(
1071
+ `Trail "${trailId}" declares versions without a current version`
1072
+ );
1073
+ }
1074
+ return undefined;
1075
+ }
1076
+
1077
+ assertVersionNumber(trailId, 'version', currentVersion);
1078
+
1079
+ if (versions === undefined) {
1080
+ return undefined;
1081
+ }
1082
+ if (
1083
+ typeof versions !== 'object' ||
1084
+ versions === null ||
1085
+ Array.isArray(versions)
1086
+ ) {
1087
+ throw new ValidationError(`Trail "${trailId}" versions must be an object`);
1088
+ }
1089
+
1090
+ const normalized: Record<
1091
+ number,
1092
+ TrailVersionEntry<unknown, unknown, CurrentInput, CurrentOutput>
1093
+ > = {};
1094
+ for (const [rawVersion, entry] of Object.entries(versions)) {
1095
+ const historicalVersion = Number(rawVersion);
1096
+ if (`${historicalVersion}` !== rawVersion) {
1097
+ throw new ValidationError(
1098
+ `Trail "${trailId}" versions key "${rawVersion}" must be a positive integer`
1099
+ );
1100
+ }
1101
+ assertVersionNumber(trailId, `versions.${rawVersion}`, historicalVersion);
1102
+ if (historicalVersion === currentVersion) {
1103
+ throw new ValidationError(
1104
+ `Trail "${trailId}" version ${historicalVersion} is current and must stay top-level`
1105
+ );
1106
+ }
1107
+ if (historicalVersion > currentVersion) {
1108
+ throw new ValidationError(
1109
+ `Trail "${trailId}" version ${historicalVersion} must be less than the current version (${currentVersion})`
1110
+ );
1111
+ }
1112
+ normalized[historicalVersion] = normalizeVersionEntry(
1113
+ trailId,
1114
+ historicalVersion,
1115
+ currentInput,
1116
+ currentOutput,
1117
+ entry
1118
+ );
1119
+ }
1120
+
1121
+ const knownVersions = new Set([
1122
+ currentVersion,
1123
+ ...Object.keys(normalized).map(Number),
1124
+ ]);
1125
+ for (const [rawVersion, entry] of Object.entries(normalized)) {
1126
+ if (
1127
+ entry.status?.state === 'deprecated' &&
1128
+ entry.status.successor !== undefined &&
1129
+ (!knownVersions.has(entry.status.successor) ||
1130
+ entry.status.successor === Number(rawVersion))
1131
+ ) {
1132
+ throw new ValidationError(
1133
+ `Trail "${trailId}" version ${rawVersion} status.successor must reference the current version or another known historical version`
1134
+ );
1135
+ }
1136
+ }
1137
+
1138
+ return Object.freeze(normalized);
1139
+ };
1140
+
1141
+ /** Freeze and normalize all collection fields from a trail spec. */
1142
+ const normalizeCollections = <
1143
+ I,
1144
+ O,
1145
+ CI,
1146
+ C extends readonly TrailRef[] | undefined,
1147
+ >(
1148
+ spec: TrailSpec<I, O, CI, C>
1149
+ ): {
1150
+ readonly args: readonly string[] | false | undefined;
1151
+ readonly activationSources: readonly ActivationEntry[];
1152
+ readonly entities: readonly AnyEntity[];
1153
+ readonly detours: readonly Detour<I, O, TrailsError>[];
1154
+ readonly fires: readonly string[];
1155
+ readonly layers: readonly Layer[];
1156
+ readonly on: readonly string[];
1157
+ readonly resources: readonly AnyResource[];
1158
+ } => {
1159
+ const activationSources = normalizeActivationSources(spec.on ?? []);
1160
+ return {
1161
+ activationSources,
1162
+ args: Array.isArray(spec.args) ? Object.freeze([...spec.args]) : spec.args,
1163
+ detours: Object.freeze([...(spec.detours ?? [])]),
1164
+ entities: Object.freeze([...(spec.entities ?? [])]),
1165
+ fires: Object.freeze((spec.fires ?? []).map(normalizeSignalRef)),
1166
+ layers: Object.freeze([...(spec.layers ?? [])]),
1167
+ on: extractSignalActivationIds(activationSources),
1168
+ resources: Object.freeze([...(spec.resources ?? [])]),
1169
+ };
1170
+ };
1171
+
1172
+ /**
1173
+ * Create a trail definition.
1174
+ *
1175
+ * Returns a frozen object with `kind: "trail"` and all spec fields.
1176
+ * The trail is inert until handed to a runner.
1177
+ *
1178
+ * @example
1179
+ * ```typescript
1180
+ * // ID as first argument (recommended for human authoring)
1181
+ * const show = trail("entity.show", {
1182
+ * input: z.object({ name: z.string() }),
1183
+ * implementation: (input) => Result.ok(entity),
1184
+ * });
1185
+ *
1186
+ * // Full spec object (for programmatic generation)
1187
+ * const show = trail({
1188
+ * id: "entity.show",
1189
+ * input: z.object({ name: z.string() }),
1190
+ * implementation: (input) => Result.ok(entity),
1191
+ * });
1192
+ * ```
1193
+ */
1194
+ export function trail<
1195
+ const TInputSchema extends z.ZodType,
1196
+ const TOutputSchema extends z.ZodType,
1197
+ const TComposeInputSchema extends z.ZodType | undefined = undefined,
1198
+ const C extends readonly TrailRef[] | undefined = undefined,
1199
+ >(
1200
+ id: string,
1201
+ spec: SchemaOwnedOutputTrailSpec<
1202
+ TInputSchema,
1203
+ TOutputSchema,
1204
+ TComposeInputSchema,
1205
+ C
1206
+ >
1207
+ ): SchemaOwnedTrail<TInputSchema, z.output<TOutputSchema>, TComposeInputSchema>;
1208
+ export function trail<
1209
+ const TInputSchema extends z.ZodType,
1210
+ const TOutputSchema extends z.ZodType,
1211
+ const TComposeInputSchema extends z.ZodType | undefined = undefined,
1212
+ const C extends readonly TrailRef[] | undefined = undefined,
1213
+ >(
1214
+ spec: SchemaOwnedOutputTrailSpec<
1215
+ TInputSchema,
1216
+ TOutputSchema,
1217
+ TComposeInputSchema,
1218
+ C
1219
+ > & {
1220
+ readonly id: string;
1221
+ }
1222
+ ): SchemaOwnedTrail<TInputSchema, z.output<TOutputSchema>, TComposeInputSchema>;
1223
+ export function trail<
1224
+ const TInputSchema extends z.ZodType,
1225
+ O,
1226
+ const TComposeInputSchema extends z.ZodType | undefined = undefined,
1227
+ const C extends readonly TrailRef[] | undefined = undefined,
1228
+ >(
1229
+ id: string,
1230
+ spec: SchemaOwnedTrailSpec<TInputSchema, O, TComposeInputSchema, C>
1231
+ ): SchemaOwnedTrail<TInputSchema, O, TComposeInputSchema>;
1232
+ export function trail<
1233
+ const TInputSchema extends z.ZodType,
1234
+ O,
1235
+ const TComposeInputSchema extends z.ZodType | undefined = undefined,
1236
+ const C extends readonly TrailRef[] | undefined = undefined,
1237
+ >(
1238
+ spec: SchemaOwnedTrailSpec<TInputSchema, O, TComposeInputSchema, C> & {
1239
+ readonly id: string;
1240
+ }
1241
+ ): SchemaOwnedTrail<TInputSchema, O, TComposeInputSchema>;
1242
+ export function trail<
1243
+ I,
1244
+ O,
1245
+ CI = never,
1246
+ const C extends readonly TrailRef[] | undefined = undefined,
1247
+ >(
1248
+ id: string,
1249
+ spec:
1250
+ | LegacyOutputTrailSpec<I, O, CI, C>
1251
+ | LegacyOutputlessTrailSpec<I, O, CI, C>
1252
+ ): Trail<I, O, CI>;
1253
+ export function trail<
1254
+ I,
1255
+ O,
1256
+ CI = never,
1257
+ const C extends readonly TrailRef[] | undefined = undefined,
1258
+ >(
1259
+ spec:
1260
+ | (LegacyOutputTrailSpec<I, O, CI, C> & { readonly id: string })
1261
+ | (LegacyOutputlessTrailSpec<I, O, CI, C> & { readonly id: string })
1262
+ ): Trail<I, O, CI>;
1263
+ export function trail<
1264
+ I,
1265
+ O,
1266
+ CI = never,
1267
+ const C extends readonly TrailRef[] | undefined = undefined,
1268
+ >(
1269
+ idOrSpec: string | (TrailSpec<I, O, CI, C> & { readonly id: string }),
1270
+ maybeSpec?: TrailSpec<I, O, CI, C>
1271
+ ): Trail<I, O, CI> {
1272
+ const resolved =
1273
+ typeof idOrSpec === 'string'
1274
+ ? { id: idOrSpec, spec: maybeSpec }
1275
+ : { id: idOrSpec.id, spec: idOrSpec };
1276
+
1277
+ if (!resolved.spec) {
1278
+ throw new TypeError('trail() requires a spec when an id is provided');
1279
+ }
1280
+ const rawSpec = resolved.spec as unknown as Record<string, unknown>;
1281
+ if (hasOwn(rawSpec, 'contours')) {
1282
+ throw new ValidationError(
1283
+ `Trail "${resolved.id}" uses retired "contours"; use "entities" instead`
1284
+ );
1285
+ }
1286
+
1287
+ if (hasOwn(rawSpec, 'marker')) {
1288
+ throw new ValidationError(
1289
+ `Trail "${resolved.id}" must not author marker; it is derived`
1290
+ );
1291
+ }
1292
+
1293
+ const {
1294
+ implementation,
1295
+ composeInput,
1296
+ composes: rawComposes,
1297
+ intent: rawIntent,
1298
+ visibility: rawVisibility,
1299
+ // Destructure away fields handled by normalizeCollections
1300
+ args: _a,
1301
+ entities: _c,
1302
+ detours: _d,
1303
+ fires: _f,
1304
+ layers: _l,
1305
+ on: _o,
1306
+ resources: _r,
1307
+ version: rawVersion,
1308
+ versions: rawVersions,
1309
+ ...spec
1310
+ } = resolved.spec;
1311
+ const collections = normalizeCollections(resolved.spec);
1312
+ const versions = normalizeTrailVersions<I, O>(
1313
+ resolved.id,
1314
+ resolved.spec.input,
1315
+ resolved.spec.output,
1316
+ rawVersion,
1317
+ rawVersions
1318
+ );
1319
+
1320
+ return Object.freeze({
1321
+ ...spec,
1322
+ ...collections,
1323
+ composeInput,
1324
+ composes: Object.freeze((rawComposes ?? []).map(normalizeComposeRef)),
1325
+ id: resolved.id,
1326
+ implementation: async (
1327
+ input: ImplementationInput<I, CI>,
1328
+ ctx: TrailContext
1329
+ ) => await implementation(input, ctx as ComposeContextFor<C>),
1330
+ intent: rawIntent ?? 'write',
1331
+ kind: 'trail' as const,
1332
+ ...(rawVersion === undefined ? {} : { version: rawVersion }),
1333
+ ...(versions === undefined ? {} : { versions }),
1334
+ visibility: rawVisibility ?? 'public',
1335
+ });
1336
+ }
1337
+
1338
+ // Re-export types that callers of trail() will need
1339
+ // The Omit+override avoids a TypeScript limitation where ImplementationInput's conditional type
1340
+ // makes Trail<any, any, any> structurally incompatible with Trail<I, O, never>.
1341
+ /* oxlint-disable no-explicit-any -- existential type for heterogeneous collections */
1342
+ export type AnyTrail = Omit<
1343
+ Trail<any, any, never>,
1344
+ 'implementation' | 'composeInput'
1345
+ > & {
1346
+ readonly implementation: Implementation<any, any>;
1347
+ readonly composeInput?: z.ZodType<any> | undefined;
1348
+ };
1349
+ /* oxlint-enable no-explicit-any */
1350
+
1351
+ export type { Implementation, TrailContext, Result };