@ontrails/core 1.0.0-beta.4 → 1.0.0-beta.42

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 (247) hide show
  1. package/CHANGELOG.md +414 -12
  2. package/README.md +112 -28
  3. package/package.json +11 -1
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +227 -0
  7. package/src/activation-source.ts +93 -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 +59 -11
  13. package/src/derive.ts +271 -19
  14. package/src/detours.ts +8 -0
  15. package/src/diagnostics.ts +21 -0
  16. package/src/draft.ts +350 -0
  17. package/src/entity.ts +346 -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 +1172 -0
  22. package/src/glob.ts +81 -0
  23. package/src/index.ts +568 -32
  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 +11 -3
  30. package/src/permits.ts +12 -0
  31. package/src/queue.ts +163 -0
  32. package/src/redaction/patterns.ts +6 -3
  33. package/src/resilience.ts +11 -11
  34. package/src/resource-config.ts +804 -0
  35. package/src/resource.ts +194 -0
  36. package/src/result.ts +18 -0
  37. package/src/run.ts +76 -0
  38. package/src/runtime-builtins.ts +69 -0
  39. package/src/schedule-runtime.ts +689 -0
  40. package/src/schedule.ts +326 -0
  41. package/src/serialization.ts +92 -55
  42. package/src/sha256.ts +136 -0
  43. package/src/signal-diagnostics.ts +633 -0
  44. package/src/signal-ref.ts +111 -0
  45. package/src/signal.ts +104 -0
  46. package/src/store/accessor-protocol.ts +56 -0
  47. package/src/store/index.ts +4 -0
  48. package/src/structured-examples.ts +248 -0
  49. package/src/surface-derivation.ts +91 -0
  50. package/src/surface-filter.ts +101 -0
  51. package/src/surface-overlay.ts +694 -0
  52. package/src/surface-versioning.ts +42 -0
  53. package/src/topo.ts +762 -38
  54. package/src/tracing.ts +346 -0
  55. package/src/trail-id-glob.ts +15 -0
  56. package/src/trail.ts +1244 -34
  57. package/src/trails/derive-trail.ts +835 -0
  58. package/src/trails/index.ts +9 -0
  59. package/src/trails/ingest.ts +152 -0
  60. package/src/trails-db.ts +212 -0
  61. package/src/transport-error-map.ts +174 -0
  62. package/src/type-utils.ts +87 -0
  63. package/src/types.ts +254 -12
  64. package/src/validate-established-topo.ts +73 -0
  65. package/src/validate-topo.ts +569 -55
  66. package/src/validation.ts +152 -4
  67. package/src/version-marker.ts +716 -0
  68. package/src/version-resolution.ts +308 -0
  69. package/src/version-runtime.ts +120 -0
  70. package/src/webhook.ts +461 -0
  71. package/src/workspace.ts +183 -16
  72. package/src/zod-wrappers.ts +72 -0
  73. package/.turbo/turbo-build.log +0 -1
  74. package/.turbo/turbo-lint.log +0 -3
  75. package/.turbo/turbo-typecheck.log +0 -1
  76. package/dist/adapters.d.ts +0 -39
  77. package/dist/adapters.d.ts.map +0 -1
  78. package/dist/adapters.js +0 -2
  79. package/dist/adapters.js.map +0 -1
  80. package/dist/blob-ref.d.ts +0 -20
  81. package/dist/blob-ref.d.ts.map +0 -1
  82. package/dist/blob-ref.js +0 -22
  83. package/dist/blob-ref.js.map +0 -1
  84. package/dist/branded.d.ts +0 -36
  85. package/dist/branded.d.ts.map +0 -1
  86. package/dist/branded.js +0 -89
  87. package/dist/branded.js.map +0 -1
  88. package/dist/collections.d.ts +0 -31
  89. package/dist/collections.d.ts.map +0 -1
  90. package/dist/collections.js +0 -60
  91. package/dist/collections.js.map +0 -1
  92. package/dist/context.d.ts +0 -10
  93. package/dist/context.d.ts.map +0 -1
  94. package/dist/context.js +0 -15
  95. package/dist/context.js.map +0 -1
  96. package/dist/derive.d.ts +0 -33
  97. package/dist/derive.d.ts.map +0 -1
  98. package/dist/derive.js +0 -125
  99. package/dist/derive.js.map +0 -1
  100. package/dist/errors.d.ts +0 -83
  101. package/dist/errors.d.ts.map +0 -1
  102. package/dist/errors.js +0 -142
  103. package/dist/errors.js.map +0 -1
  104. package/dist/event.d.ts +0 -45
  105. package/dist/event.d.ts.map +0 -1
  106. package/dist/event.js +0 -17
  107. package/dist/event.js.map +0 -1
  108. package/dist/fetch.d.ts +0 -15
  109. package/dist/fetch.d.ts.map +0 -1
  110. package/dist/fetch.js +0 -102
  111. package/dist/fetch.js.map +0 -1
  112. package/dist/guards.d.ts +0 -17
  113. package/dist/guards.d.ts.map +0 -1
  114. package/dist/guards.js +0 -25
  115. package/dist/guards.js.map +0 -1
  116. package/dist/health.d.ts +0 -18
  117. package/dist/health.d.ts.map +0 -1
  118. package/dist/health.js +0 -5
  119. package/dist/health.js.map +0 -1
  120. package/dist/index.d.ts +0 -34
  121. package/dist/index.d.ts.map +0 -1
  122. package/dist/index.js +0 -38
  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 -16
  197. package/dist/topo.d.ts.map +0 -1
  198. package/dist/topo.js +0 -66
  199. package/dist/topo.js.map +0 -1
  200. package/dist/trail.d.ts +0 -89
  201. package/dist/trail.d.ts.map +0 -1
  202. package/dist/trail.js +0 -18
  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 -158
  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__/job.test.ts +0 -98
  230. package/src/__tests__/layer.test.ts +0 -212
  231. package/src/__tests__/path-security.test.ts +0 -114
  232. package/src/__tests__/patterns.test.ts +0 -273
  233. package/src/__tests__/redaction.test.ts +0 -244
  234. package/src/__tests__/resilience.test.ts +0 -246
  235. package/src/__tests__/result.test.ts +0 -155
  236. package/src/__tests__/serialization.test.ts +0 -401
  237. package/src/__tests__/topo.test.ts +0 -181
  238. package/src/__tests__/trail.test.ts +0 -217
  239. package/src/__tests__/validate-topo.test.ts +0 -278
  240. package/src/__tests__/validation.test.ts +0 -283
  241. package/src/__tests__/workspace.test.ts +0 -183
  242. package/src/adapters.ts +0 -68
  243. package/src/event.ts +0 -77
  244. package/src/health.ts +0 -23
  245. package/src/job.ts +0 -20
  246. package/tsconfig.json +0 -9
  247. package/tsconfig.tsbuildinfo +0 -1
@@ -1,28 +1,114 @@
1
1
  /**
2
2
  * Structural validation for a Topo graph.
3
3
  *
4
- * Checks trail follow references, example input validity, event origin
5
- * references, and output schema completeness. Returns a Result with all
6
- * issues collected into a single ValidationError.
4
+ * Checks trail composing references, example input validity, signal origin
5
+ * references, activation source kinds, and output schema completeness. Returns
6
+ * a Result with all issues collected into a single ValidationError.
7
7
  */
8
8
 
9
+ import type { ActivationSchemaIssue } from './activation-source-compatibility.js';
10
+ import { getActivationSourceInputCompatibilityIssues } from './activation-source-compatibility.js';
11
+ import {
12
+ activationSourceDeclarationSignature,
13
+ activationSourceKey,
14
+ } from './activation-source-projection.js';
15
+ import type { AnyEntity } from './entity.js';
16
+ import { getEntityReferences } from './entity.js';
9
17
  import { ValidationError } from './errors.js';
10
- import type { AnyEvent } from './event.js';
18
+ import type { ActivationEntry } from './activation-source.js';
19
+ import { isKnownActivationSourceKind } from './activation-source.js';
20
+ import { isDraftId } from './draft.js';
21
+ import { validateQueueSource } from './queue.js';
22
+ import type { AnySignal } from './signal.js';
23
+ import { validateScheduleSource } from './schedule.js';
11
24
  import { Result } from './result.js';
12
25
  import type { Topo } from './topo.js';
13
- import type { AnyTrail } from './trail.js';
26
+ import type { AnyTrail, TrailVersionForkEntry } from './trail.js';
27
+ import {
28
+ getTrailVersionEntryKind,
29
+ isArchivedTrailVersionEntry,
30
+ } from './trail.js';
14
31
  import { validateInput } from './validation.js';
32
+ import { validateWebhookSource } from './webhook.js';
15
33
 
16
34
  // ---------------------------------------------------------------------------
17
35
  // Issue shape
18
36
  // ---------------------------------------------------------------------------
19
37
 
20
- export interface TopoIssue {
38
+ export type TopoDiagnosticCode = 'topo.missing-reference';
39
+
40
+ export type TopoReferenceKind =
41
+ | 'compose'
42
+ | 'entity-reference'
43
+ | 'resource'
44
+ | 'signal-fire'
45
+ | 'signal-on'
46
+ | 'signal-origin';
47
+
48
+ export type TopoReferenceOwnerKind =
49
+ | 'entity'
50
+ | 'signal'
51
+ | 'trail'
52
+ | 'trail-version';
53
+
54
+ /**
55
+ * Stable machine-readable payload for dangling topo references.
56
+ *
57
+ * Consumers such as Regrade should branch on `code` and `reference` instead
58
+ * of parsing `message`, which remains a human-readable diagnostic.
59
+ */
60
+ export interface TopoMissingReference {
61
+ readonly fromId: string;
62
+ readonly fromKind: TopoReferenceOwnerKind;
63
+ readonly fromTrailId?: string;
64
+ readonly missingId: string;
65
+ readonly referenceKind: TopoReferenceKind;
66
+ readonly version?: number;
67
+ }
68
+
69
+ export interface TopoDiagnostic {
70
+ /**
71
+ * Stable machine-readable code. Human-facing messages may change for
72
+ * clarity; downstream automation should depend on this code and the typed
73
+ * payload fields.
74
+ */
75
+ readonly code?: TopoDiagnosticCode;
21
76
  readonly trailId: string;
22
77
  readonly rule: string;
23
78
  readonly message: string;
79
+ readonly inputPath?: readonly (string | number)[];
80
+ readonly reference?: TopoMissingReference;
81
+ readonly schemaIssues?: readonly TopoSchemaIssue[];
82
+ readonly sourceId?: string;
83
+ readonly sourceKind?: string;
24
84
  }
25
85
 
86
+ /**
87
+ * @deprecated Use {@link TopoDiagnostic}. Kept as a source-compatible alias
88
+ * during the v1 vocabulary cutover.
89
+ */
90
+ export interface TopoIssue extends TopoDiagnostic {
91
+ readonly trailId: TopoDiagnostic['trailId'];
92
+ }
93
+
94
+ export type TopoSchemaIssue = ActivationSchemaIssue;
95
+
96
+ const isTopoDiagnostic = (value: unknown): value is TopoDiagnostic =>
97
+ typeof value === 'object' &&
98
+ value !== null &&
99
+ typeof (value as { message?: unknown }).message === 'string' &&
100
+ typeof (value as { rule?: unknown }).rule === 'string' &&
101
+ typeof (value as { trailId?: unknown }).trailId === 'string';
102
+
103
+ const missingReferenceDiagnostic = (
104
+ issue: Omit<TopoDiagnostic, 'code' | 'reference'> & {
105
+ readonly reference: TopoMissingReference;
106
+ }
107
+ ): TopoDiagnostic => ({
108
+ ...issue,
109
+ code: 'topo.missing-reference',
110
+ });
111
+
26
112
  // ---------------------------------------------------------------------------
27
113
  // Validators
28
114
  // ---------------------------------------------------------------------------
@@ -31,17 +117,32 @@ const WHITE = 0;
31
117
  const GRAY = 1;
32
118
  const BLACK = 2;
33
119
 
34
- /** Build an adjacency list and initial color map from trails with follow. */
35
- const buildFollowGraph = (
120
+ /** Build an adjacency list and initial color map from trails with compositions. */
121
+ const buildComposeGraph = (
36
122
  trails: ReadonlyMap<string, AnyTrail>
37
123
  ): {
38
124
  graph: Map<string, readonly string[]>;
39
125
  color: Map<string, number>;
40
126
  } => {
41
127
  const graph = new Map<string, readonly string[]>();
42
- for (const [id, t] of trails) {
43
- if (t.follow.length > 0) {
44
- graph.set(id, t.follow);
128
+ for (const [id, trail] of trails) {
129
+ const composedIds = new Set<string>(trail.composes);
130
+ for (const entry of Object.values(trail.versions ?? {})) {
131
+ if (
132
+ isArchivedTrailVersionEntry(entry) ||
133
+ getTrailVersionEntryKind(entry) !== 'fork'
134
+ ) {
135
+ continue;
136
+ }
137
+
138
+ const fork = entry as TrailVersionForkEntry;
139
+ for (const composed of fork.composes ?? []) {
140
+ composedIds.add(typeof composed === 'string' ? composed : composed.id);
141
+ }
142
+ }
143
+
144
+ if (composedIds.size > 0) {
145
+ graph.set(id, [...composedIds]);
45
146
  }
46
147
  }
47
148
  const color = new Map<string, number>();
@@ -51,12 +152,12 @@ const buildFollowGraph = (
51
152
  return { color, graph };
52
153
  };
53
154
 
54
- /** Detect multi-node cycles in the trail follow graph via DFS. */
55
- const detectFollowCycles = (
155
+ /** Detect multi-node cycles in the trail composing graph via DFS. */
156
+ const detectComposeCycles = (
56
157
  trails: ReadonlyMap<string, AnyTrail>
57
- ): TopoIssue[] => {
58
- const issues: TopoIssue[] = [];
59
- const { color, graph } = buildFollowGraph(trails);
158
+ ): TopoDiagnostic[] => {
159
+ const issues: TopoDiagnostic[] = [];
160
+ const { color, graph } = buildComposeGraph(trails);
60
161
 
61
162
  const dfs = (node: string, path: string[]): void => {
62
163
  color.set(node, GRAY);
@@ -69,7 +170,7 @@ const detectFollowCycles = (
69
170
  const cycle = [...path.slice(path.indexOf(next)), next];
70
171
  issues.push({
71
172
  message: `Cycle detected: ${cycle.join(' → ')}`,
72
- rule: 'follow-cycle',
173
+ rule: 'compose-cycle',
73
174
  trailId: next,
74
175
  });
75
176
  } else if (c === WHITE) {
@@ -87,29 +188,142 @@ const detectFollowCycles = (
87
188
  return issues;
88
189
  };
89
190
 
90
- const checkFollows = (
191
+ const checkComposes = (
91
192
  trails: ReadonlyMap<string, AnyTrail>,
92
193
  topo: Topo
93
- ): TopoIssue[] => {
94
- const issues: TopoIssue[] = [];
194
+ ): TopoDiagnostic[] => {
195
+ const issues: TopoDiagnostic[] = [];
95
196
  for (const [id, trail] of trails) {
96
- for (const followId of trail.follow) {
97
- if (followId === id) {
98
- issues.push({
99
- message: `Trail follows itself`,
100
- rule: 'no-self-follow',
101
- trailId: id,
102
- });
103
- } else if (!topo.has(followId)) {
197
+ for (const composedId of trail.composes) {
198
+ if (composedId === id) {
104
199
  issues.push({
105
- message: `Follows "${followId}" which is not in the topo`,
106
- rule: 'follow-exists',
200
+ message: `Trail composes itself`,
201
+ rule: 'no-self-compose',
107
202
  trailId: id,
108
203
  });
204
+ } else if (!topo.has(composedId) && !isDraftId(composedId)) {
205
+ issues.push(
206
+ missingReferenceDiagnostic({
207
+ message: `Composes "${composedId}" which is not in the topo`,
208
+ reference: {
209
+ fromId: id,
210
+ fromKind: 'trail',
211
+ fromTrailId: id,
212
+ missingId: composedId,
213
+ referenceKind: 'compose',
214
+ },
215
+ rule: 'compose-exists',
216
+ trailId: id,
217
+ })
218
+ );
219
+ }
220
+ }
221
+ for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
222
+ if (
223
+ isArchivedTrailVersionEntry(entry) ||
224
+ getTrailVersionEntryKind(entry) !== 'fork'
225
+ ) {
226
+ continue;
227
+ }
228
+
229
+ const version = Number(rawVersion);
230
+ const fork = entry as TrailVersionForkEntry;
231
+ for (const composed of fork.composes ?? []) {
232
+ const composedId =
233
+ typeof composed === 'string' ? composed : composed.id;
234
+ if (composedId === id) {
235
+ issues.push({
236
+ message: `Trail version ${version} composes itself`,
237
+ rule: 'no-self-compose',
238
+ trailId: id,
239
+ });
240
+ } else if (!topo.has(composedId) && !isDraftId(composedId)) {
241
+ issues.push(
242
+ missingReferenceDiagnostic({
243
+ message: `Version ${version} composes "${composedId}" which is not in the topo`,
244
+ reference: {
245
+ fromId: id,
246
+ fromKind: 'trail-version',
247
+ fromTrailId: id,
248
+ missingId: composedId,
249
+ referenceKind: 'compose',
250
+ version,
251
+ },
252
+ rule: 'compose-exists',
253
+ trailId: id,
254
+ })
255
+ );
256
+ }
257
+ }
258
+ }
259
+ }
260
+ issues.push(...detectComposeCycles(trails));
261
+ return issues;
262
+ };
263
+
264
+ const checkResources = (
265
+ trails: ReadonlyMap<string, AnyTrail>,
266
+ topo: Topo
267
+ ): TopoDiagnostic[] => {
268
+ const issues: TopoDiagnostic[] = [];
269
+
270
+ for (const [id, trail] of trails) {
271
+ for (const declaredResource of trail.resources) {
272
+ if (
273
+ !topo.hasResource(declaredResource.id) &&
274
+ !isDraftId(declaredResource.id)
275
+ ) {
276
+ issues.push(
277
+ missingReferenceDiagnostic({
278
+ message: `Resource "${declaredResource.id}" is not in the topo`,
279
+ reference: {
280
+ fromId: id,
281
+ fromKind: 'trail',
282
+ fromTrailId: id,
283
+ missingId: declaredResource.id,
284
+ referenceKind: 'resource',
285
+ },
286
+ rule: 'resource-exists',
287
+ trailId: id,
288
+ })
289
+ );
290
+ }
291
+ }
292
+ for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
293
+ if (
294
+ isArchivedTrailVersionEntry(entry) ||
295
+ getTrailVersionEntryKind(entry) !== 'fork'
296
+ ) {
297
+ continue;
298
+ }
299
+
300
+ const version = Number(rawVersion);
301
+ const fork = entry as TrailVersionForkEntry;
302
+ for (const declaredResource of fork.resources ?? []) {
303
+ if (
304
+ !topo.hasResource(declaredResource.id) &&
305
+ !isDraftId(declaredResource.id)
306
+ ) {
307
+ issues.push(
308
+ missingReferenceDiagnostic({
309
+ message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
310
+ reference: {
311
+ fromId: id,
312
+ fromKind: 'trail-version',
313
+ fromTrailId: id,
314
+ missingId: declaredResource.id,
315
+ referenceKind: 'resource',
316
+ version,
317
+ },
318
+ rule: 'resource-exists',
319
+ trailId: id,
320
+ })
321
+ );
322
+ }
109
323
  }
110
324
  }
111
325
  }
112
- issues.push(...detectFollowCycles(trails));
326
+
113
327
  return issues;
114
328
  };
115
329
 
@@ -122,20 +336,21 @@ const checkOneExample = (
122
336
  error?: string | undefined;
123
337
  },
124
338
  inputSchema: { safeParse: (data: unknown) => { success: boolean } },
125
- hasOutput: boolean
126
- ): TopoIssue[] => {
127
- const issues: TopoIssue[] = [];
339
+ hasOutput: boolean,
340
+ label = `Example "${example.name}"`
341
+ ): TopoDiagnostic[] => {
342
+ const issues: TopoDiagnostic[] = [];
128
343
  const result = validateInput(inputSchema as AnyTrail['input'], example.input);
129
344
  if (result.isErr() && example.error !== 'ValidationError') {
130
345
  issues.push({
131
- message: `Example "${example.name}" input does not parse against schema`,
346
+ message: `${label} input does not parse against schema`,
132
347
  rule: 'example-input-valid',
133
348
  trailId: id,
134
349
  });
135
350
  }
136
351
  if (example.expected !== undefined && !hasOutput) {
137
352
  issues.push({
138
- message: `Example "${example.name}" has expected output but trail has no output schema`,
353
+ message: `${label} has expected output but trail has no output schema`,
139
354
  rule: 'output-schema-present',
140
355
  trailId: id,
141
356
  });
@@ -143,38 +358,316 @@ const checkOneExample = (
143
358
  return issues;
144
359
  };
145
360
 
146
- const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
147
- const issues: TopoIssue[] = [];
148
- for (const [id, trail] of trails) {
149
- if (!trail.examples) {
150
- continue;
361
+ const checkVersionExamples = (id: string, trail: AnyTrail): TopoDiagnostic[] =>
362
+ Object.entries(trail.versions ?? {}).flatMap(([version, entry]) => {
363
+ if (isArchivedTrailVersionEntry(entry)) {
364
+ return [];
151
365
  }
152
- for (const example of trail.examples) {
153
- issues.push(...checkOneExample(id, example, trail.input, !!trail.output));
366
+
367
+ return (entry.examples ?? []).flatMap((example) =>
368
+ checkOneExample(
369
+ id,
370
+ example,
371
+ entry.input,
372
+ true,
373
+ `Example "${example.name}" on version ${version}`
374
+ )
375
+ );
376
+ });
377
+
378
+ const checkExamples = (
379
+ trails: ReadonlyMap<string, AnyTrail>
380
+ ): TopoDiagnostic[] => {
381
+ const issues: TopoDiagnostic[] = [];
382
+ for (const [id, trail] of trails) {
383
+ if (trail.examples) {
384
+ for (const example of trail.examples) {
385
+ issues.push(
386
+ ...checkOneExample(id, example, trail.input, !!trail.output)
387
+ );
388
+ }
154
389
  }
390
+ issues.push(...checkVersionExamples(id, trail));
155
391
  }
156
392
  return issues;
157
393
  };
158
394
 
159
- const checkEventOrigins = (
160
- events: ReadonlyMap<string, AnyEvent>,
395
+ const checkSignalOrigins = (
396
+ signals: ReadonlyMap<string, AnySignal>,
161
397
  topo: Topo
162
- ): TopoIssue[] => {
163
- const issues: TopoIssue[] = [];
164
- for (const [id, evt] of events) {
398
+ ): TopoDiagnostic[] => {
399
+ const issues: TopoDiagnostic[] = [];
400
+ for (const [id, evt] of signals) {
165
401
  if (!evt.from) {
166
402
  continue;
167
403
  }
168
404
  for (const originId of evt.from) {
169
- if (!topo.has(originId)) {
405
+ if (!topo.has(originId) && !isDraftId(originId)) {
406
+ issues.push(
407
+ missingReferenceDiagnostic({
408
+ message: `Signal origin "${originId}" is not in the topo`,
409
+ reference: {
410
+ fromId: id,
411
+ fromKind: 'signal',
412
+ missingId: originId,
413
+ referenceKind: 'signal-origin',
414
+ },
415
+ rule: 'signal-origin-exists',
416
+ trailId: id,
417
+ })
418
+ );
419
+ }
420
+ }
421
+ }
422
+ return issues;
423
+ };
424
+
425
+ const checkSignalReferences = (
426
+ trails: ReadonlyMap<string, AnyTrail>,
427
+ signals: ReadonlyMap<string, AnySignal>
428
+ ): TopoDiagnostic[] => {
429
+ const issues: TopoDiagnostic[] = [];
430
+
431
+ for (const [id, trail] of trails) {
432
+ for (const signalId of trail.fires ?? []) {
433
+ if (!signals.has(signalId) && !isDraftId(signalId)) {
434
+ issues.push(
435
+ missingReferenceDiagnostic({
436
+ message: `Trail fires signal "${signalId}" which is not in the topo`,
437
+ reference: {
438
+ fromId: id,
439
+ fromKind: 'trail',
440
+ fromTrailId: id,
441
+ missingId: signalId,
442
+ referenceKind: 'signal-fire',
443
+ },
444
+ rule: 'signal-fire-exists',
445
+ trailId: id,
446
+ })
447
+ );
448
+ }
449
+ }
450
+
451
+ for (const signalId of trail.on ?? []) {
452
+ if (!signals.has(signalId) && !isDraftId(signalId)) {
453
+ issues.push(
454
+ missingReferenceDiagnostic({
455
+ message: `Trail declares on signal "${signalId}" which is not in the topo`,
456
+ reference: {
457
+ fromId: id,
458
+ fromKind: 'trail',
459
+ fromTrailId: id,
460
+ missingId: signalId,
461
+ referenceKind: 'signal-on',
462
+ },
463
+ rule: 'signal-on-exists',
464
+ trailId: id,
465
+ })
466
+ );
467
+ }
468
+ }
469
+ }
470
+
471
+ return issues;
472
+ };
473
+
474
+ const checkActivationSources = (
475
+ trails: ReadonlyMap<string, AnyTrail>
476
+ ): TopoDiagnostic[] => {
477
+ const issues: TopoDiagnostic[] = [];
478
+ const sourceDeclarations = new Map<
479
+ string,
480
+ {
481
+ readonly signature: string;
482
+ readonly trailId: string;
483
+ }
484
+ >();
485
+ const trailSourceEdges = new Set<string>();
486
+
487
+ for (const [id, trail] of trails) {
488
+ for (const activation of trail.activationSources ?? []) {
489
+ if (!isKnownActivationSourceKind(activation.source.kind)) {
490
+ issues.push({
491
+ message: `Trail declares on source "${activation.source.id}" with unsupported source kind "${activation.source.kind}"`,
492
+ rule: 'activation-source-kind-known',
493
+ trailId: id,
494
+ });
495
+ continue;
496
+ }
497
+
498
+ const sourceKey = activationSourceKey(activation.source);
499
+ const edgeKey = `${id}\0${sourceKey}`;
500
+ if (trailSourceEdges.has(edgeKey)) {
501
+ issues.push({
502
+ message: `Trail declares activation source "${activation.source.id}" (${activation.source.kind}) more than once. Keep one source-to-trail activation edge, or split distinct activation behavior into distinct source ids.`,
503
+ rule: 'activation-source-edge-unique',
504
+ sourceId: activation.source.id,
505
+ sourceKind: activation.source.kind,
506
+ trailId: id,
507
+ });
508
+ } else {
509
+ trailSourceEdges.add(edgeKey);
510
+ }
511
+
512
+ if (!isDraftId(activation.source.id)) {
513
+ const signature = activationSourceDeclarationSignature(
514
+ activation.source
515
+ );
516
+ const previous = sourceDeclarations.get(sourceKey);
517
+ if (previous === undefined) {
518
+ sourceDeclarations.set(sourceKey, { signature, trailId: id });
519
+ } else if (previous.signature !== signature) {
520
+ issues.push({
521
+ message: `Activation source "${activation.source.id}" (${activation.source.kind}) is declared with conflicting source options by trails "${previous.trailId}" and "${id}". Use one canonical source declaration per source id, or give distinct source configurations distinct ids.`,
522
+ rule: 'activation-source-definition-unique',
523
+ sourceId: activation.source.id,
524
+ sourceKind: activation.source.kind,
525
+ trailId: id,
526
+ });
527
+ }
528
+ }
529
+
530
+ const queueIssues = validateQueueSource(activation.source);
531
+ for (const issue of queueIssues) {
170
532
  issues.push({
171
- message: `Event origin "${originId}" is not in the topo`,
172
- rule: 'event-origin-exists',
533
+ inputPath: [issue.field],
534
+ message: `Trail declares queue source "${activation.source.id}" with invalid ${issue.field}: ${issue.message}`,
535
+ rule: 'activation-queue-valid',
536
+ schemaIssues: [
537
+ { code: issue.field, message: issue.message, path: [issue.field] },
538
+ ],
539
+ sourceId: activation.source.id,
540
+ sourceKind: activation.source.kind,
541
+ trailId: id,
542
+ });
543
+ }
544
+
545
+ const scheduleIssues = validateScheduleSource(activation.source);
546
+ for (const issue of scheduleIssues) {
547
+ issues.push({
548
+ inputPath: [issue.field],
549
+ message: `Trail declares schedule source "${activation.source.id}" with invalid ${issue.field}: ${issue.message}`,
550
+ rule: 'activation-schedule-valid',
551
+ schemaIssues: [
552
+ { code: issue.field, message: issue.message, path: [issue.field] },
553
+ ],
554
+ sourceId: activation.source.id,
555
+ sourceKind: activation.source.kind,
556
+ trailId: id,
557
+ });
558
+ }
559
+
560
+ const webhookIssues = validateWebhookSource(activation.source);
561
+ for (const issue of webhookIssues) {
562
+ issues.push({
563
+ inputPath: [issue.field],
564
+ message: `Trail declares webhook source "${activation.source.id}" with invalid ${issue.field}: ${issue.message}`,
565
+ rule: 'activation-webhook-valid',
566
+ schemaIssues: [
567
+ { code: issue.field, message: issue.message, path: [issue.field] },
568
+ ],
569
+ sourceId: activation.source.id,
570
+ sourceKind: activation.source.kind,
173
571
  trailId: id,
174
572
  });
175
573
  }
176
574
  }
177
575
  }
576
+
577
+ return issues;
578
+ };
579
+
580
+ const issuePathText = (path: readonly (string | number)[]): string =>
581
+ path.length > 0 ? path.join('.') : '<root>';
582
+
583
+ const createSourceCompatibilityIssue = (
584
+ trailId: string,
585
+ activation: ActivationEntry,
586
+ schemaIssues: readonly TopoSchemaIssue[]
587
+ ): TopoDiagnostic => {
588
+ const [firstIssue] = schemaIssues;
589
+ const inputPath = firstIssue?.path ?? Object.freeze([]);
590
+ return {
591
+ inputPath,
592
+ message: `Activation source "${activation.source.id}" (${activation.source.kind}) does not satisfy trail input at ${issuePathText(inputPath)}: ${firstIssue?.message ?? 'source payload is incompatible with trail input'}`,
593
+ rule: 'activation-source-input-compatible',
594
+ schemaIssues,
595
+ sourceId: activation.source.id,
596
+ sourceKind: activation.source.kind,
597
+ trailId,
598
+ };
599
+ };
600
+
601
+ const checkSourcePayloadCompatibility = (
602
+ trail: AnyTrail,
603
+ activation: ActivationEntry,
604
+ signals: ReadonlyMap<string, AnySignal>
605
+ ): TopoDiagnostic | undefined => {
606
+ if (
607
+ !isKnownActivationSourceKind(activation.source.kind) ||
608
+ isDraftId(activation.source.id)
609
+ ) {
610
+ return undefined;
611
+ }
612
+
613
+ const schemaIssues = getActivationSourceInputCompatibilityIssues(
614
+ trail.input,
615
+ activation.source,
616
+ signals
617
+ );
618
+ if (!schemaIssues) {
619
+ return undefined;
620
+ }
621
+
622
+ return schemaIssues.length > 0
623
+ ? createSourceCompatibilityIssue(trail.id, activation, schemaIssues)
624
+ : undefined;
625
+ };
626
+
627
+ const checkActivationSourceInputCompatibility = (
628
+ trails: ReadonlyMap<string, AnyTrail>,
629
+ signals: ReadonlyMap<string, AnySignal>
630
+ ): TopoDiagnostic[] => {
631
+ const issues: TopoDiagnostic[] = [];
632
+
633
+ for (const trail of trails.values()) {
634
+ for (const activation of trail.activationSources ?? []) {
635
+ const issue = checkSourcePayloadCompatibility(trail, activation, signals);
636
+ if (issue) {
637
+ issues.push(issue);
638
+ }
639
+ }
640
+ }
641
+
642
+ return issues;
643
+ };
644
+
645
+ const checkEntityReferences = (
646
+ entities: ReadonlyMap<string, AnyEntity>,
647
+ topo: Topo
648
+ ): TopoDiagnostic[] => {
649
+ const issues: TopoDiagnostic[] = [];
650
+
651
+ for (const [name, entityDef] of entities) {
652
+ for (const ref of getEntityReferences(entityDef)) {
653
+ if (!topo.hasEntity(ref.entity) && !isDraftId(ref.entity)) {
654
+ issues.push(
655
+ missingReferenceDiagnostic({
656
+ message: `Entity "${name}" references "${ref.entity}" which is not in the topo`,
657
+ reference: {
658
+ fromId: name,
659
+ fromKind: 'entity',
660
+ missingId: ref.entity,
661
+ referenceKind: 'entity-reference',
662
+ },
663
+ rule: 'entity-reference-exists',
664
+ trailId: name,
665
+ })
666
+ );
667
+ }
668
+ }
669
+ }
670
+
178
671
  return issues;
179
672
  };
180
673
 
@@ -182,18 +675,39 @@ const checkEventOrigins = (
182
675
  // Public API
183
676
  // ---------------------------------------------------------------------------
184
677
 
678
+ /**
679
+ * Extract structured topo diagnostics from a validation error.
680
+ *
681
+ * `validateTopo` keeps source compatibility by returning `Result<void,
682
+ * ValidationError>`. Consumers that need machine-readable diagnostics should
683
+ * use this helper instead of parsing the human `message` text.
684
+ */
685
+ export const getTopoDiagnostics = (
686
+ error: ValidationError
687
+ ): readonly TopoDiagnostic[] => {
688
+ const context = error.context as { issues?: unknown } | undefined;
689
+ const issues = context?.issues;
690
+ return Array.isArray(issues) ? issues.filter(isTopoDiagnostic) : [];
691
+ };
692
+
185
693
  /**
186
694
  * Validate the structural integrity of a Topo graph.
187
695
  *
188
- * Checks follow references, example inputs, event origins, and output
189
- * schema presence. Returns `Result.ok()` when no issues are found, or
696
+ * Checks composing references, example inputs, signal origins, activation
697
+ * source kinds, and output schema presence. Returns `Result.ok()` when no
698
+ * issues are found, or
190
699
  * `Result.err(ValidationError)` with all issues in the error context.
191
700
  */
192
701
  export const validateTopo = (topo: Topo): Result<void, ValidationError> => {
193
702
  const issues = [
194
- ...checkFollows(topo.trails, topo),
703
+ ...checkComposes(topo.trails, topo),
704
+ ...checkResources(topo.trails, topo),
705
+ ...checkEntityReferences(topo.entities, topo),
195
706
  ...checkExamples(topo.trails),
196
- ...checkEventOrigins(topo.events, topo),
707
+ ...checkSignalOrigins(topo.signals, topo),
708
+ ...checkSignalReferences(topo.trails, topo.signals),
709
+ ...checkActivationSources(topo.trails),
710
+ ...checkActivationSourceInputCompatibility(topo.trails, topo.signals),
197
711
  ];
198
712
 
199
713
  if (issues.length === 0) {