@ontrails/core 1.0.0-beta.3 → 1.0.0-beta.32

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