@ontrails/core 1.0.0-beta.2 → 1.0.0-beta.21

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