@ontrails/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
@@ -0,0 +1,725 @@
1
+ /**
2
+ * Structural validation for a Topo graph.
3
+ *
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
+ */
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-derivation.js';
15
+ import type { AnyEntity } from './entity.js';
16
+ import { getEntityReferences } from './entity.js';
17
+ import { ValidationError } from './errors.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';
24
+ import { Result } from './result.js';
25
+ import type { Topo } from './topo.js';
26
+ import type { AnyTrail, TrailVersionForkEntry } from './trail.js';
27
+ import {
28
+ getTrailVersionEntryKind,
29
+ isArchivedTrailVersionEntry,
30
+ } from './trail.js';
31
+ import { validateInput } from './validation.js';
32
+ import { validateWebhookSource } from './webhook.js';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Issue shape
36
+ // ---------------------------------------------------------------------------
37
+
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;
76
+ readonly trailId: string;
77
+ readonly rule: string;
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;
84
+ }
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
+
112
+ // ---------------------------------------------------------------------------
113
+ // Validators
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const WHITE = 0;
117
+ const GRAY = 1;
118
+ const BLACK = 2;
119
+
120
+ /** Build an adjacency list and initial color map from trails with compositions. */
121
+ const buildComposeGraph = (
122
+ trails: ReadonlyMap<string, AnyTrail>
123
+ ): {
124
+ graph: Map<string, readonly string[]>;
125
+ color: Map<string, number>;
126
+ } => {
127
+ const graph = new Map<string, readonly string[]>();
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]);
146
+ }
147
+ }
148
+ const color = new Map<string, number>();
149
+ for (const id of graph.keys()) {
150
+ color.set(id, WHITE);
151
+ }
152
+ return { color, graph };
153
+ };
154
+
155
+ /** Detect multi-node cycles in the trail composing graph via DFS. */
156
+ const detectComposeCycles = (
157
+ trails: ReadonlyMap<string, AnyTrail>
158
+ ): TopoDiagnostic[] => {
159
+ const issues: TopoDiagnostic[] = [];
160
+ const { color, graph } = buildComposeGraph(trails);
161
+
162
+ const dfs = (node: string, path: string[]): void => {
163
+ color.set(node, GRAY);
164
+ for (const next of graph.get(node) ?? []) {
165
+ if (!graph.has(next)) {
166
+ continue;
167
+ }
168
+ const c = color.get(next) ?? WHITE;
169
+ if (c === GRAY) {
170
+ const cycle = [...path.slice(path.indexOf(next)), next];
171
+ issues.push({
172
+ message: `Cycle detected: ${cycle.join(' → ')}`,
173
+ rule: 'compose-cycle',
174
+ trailId: next,
175
+ });
176
+ } else if (c === WHITE) {
177
+ dfs(next, [...path, next]);
178
+ }
179
+ }
180
+ color.set(node, BLACK);
181
+ };
182
+
183
+ for (const id of graph.keys()) {
184
+ if (color.get(id) === WHITE) {
185
+ dfs(id, [id]);
186
+ }
187
+ }
188
+ return issues;
189
+ };
190
+
191
+ const checkComposes = (
192
+ trails: ReadonlyMap<string, AnyTrail>,
193
+ topo: Topo
194
+ ): TopoDiagnostic[] => {
195
+ const issues: TopoDiagnostic[] = [];
196
+ for (const [id, trail] of trails) {
197
+ for (const composedId of trail.composes) {
198
+ if (composedId === id) {
199
+ issues.push({
200
+ message: `Trail composes itself`,
201
+ rule: 'no-self-compose',
202
+ trailId: id,
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
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ return issues;
328
+ };
329
+
330
+ const checkOneExample = (
331
+ id: string,
332
+ example: {
333
+ name: string;
334
+ input: unknown;
335
+ expected?: unknown | undefined;
336
+ error?: string | undefined;
337
+ },
338
+ inputSchema: { safeParse: (data: unknown) => { success: boolean } },
339
+ hasOutput: boolean,
340
+ label = `Example "${example.name}"`
341
+ ): TopoDiagnostic[] => {
342
+ const issues: TopoDiagnostic[] = [];
343
+ const result = validateInput(inputSchema as AnyTrail['input'], example.input);
344
+ if (result.isErr() && example.error !== 'ValidationError') {
345
+ issues.push({
346
+ message: `${label} input does not parse against schema`,
347
+ rule: 'example-input-valid',
348
+ trailId: id,
349
+ });
350
+ }
351
+ if (example.expected !== undefined && !hasOutput) {
352
+ issues.push({
353
+ message: `${label} has expected output but trail has no output schema`,
354
+ rule: 'output-schema-present',
355
+ trailId: id,
356
+ });
357
+ }
358
+ return issues;
359
+ };
360
+
361
+ const checkVersionExamples = (id: string, trail: AnyTrail): TopoDiagnostic[] =>
362
+ Object.entries(trail.versions ?? {}).flatMap(([version, entry]) => {
363
+ if (isArchivedTrailVersionEntry(entry)) {
364
+ return [];
365
+ }
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
+ }
389
+ }
390
+ issues.push(...checkVersionExamples(id, trail));
391
+ }
392
+ return issues;
393
+ };
394
+
395
+ const checkSignalOrigins = (
396
+ signals: ReadonlyMap<string, AnySignal>,
397
+ topo: Topo
398
+ ): TopoDiagnostic[] => {
399
+ const issues: TopoDiagnostic[] = [];
400
+ for (const [id, evt] of signals) {
401
+ if (!evt.from) {
402
+ continue;
403
+ }
404
+ for (const originId of evt.from) {
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) {
532
+ issues.push({
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,
571
+ trailId: id,
572
+ });
573
+ }
574
+ }
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
+
671
+ return issues;
672
+ };
673
+
674
+ // ---------------------------------------------------------------------------
675
+ // Public API
676
+ // ---------------------------------------------------------------------------
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
+
693
+ /**
694
+ * Validate the structural integrity of a Topo graph.
695
+ *
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
699
+ * `Result.err(ValidationError)` with all issues in the error context.
700
+ */
701
+ export const validateTopo = (topo: Topo): Result<void, ValidationError> => {
702
+ const issues = [
703
+ ...checkComposes(topo.trails, topo),
704
+ ...checkResources(topo.trails, topo),
705
+ ...checkEntityReferences(topo.entities, topo),
706
+ ...checkExamples(topo.trails),
707
+ ...checkSignalOrigins(topo.signals, topo),
708
+ ...checkSignalReferences(topo.trails, topo.signals),
709
+ ...checkActivationSources(topo.trails),
710
+ ...checkActivationSourceInputCompatibility(topo.trails, topo.signals),
711
+ ];
712
+
713
+ if (issues.length === 0) {
714
+ return Result.ok();
715
+ }
716
+
717
+ return Result.err(
718
+ new ValidationError(
719
+ `Topo validation failed with ${issues.length} issue(s)`,
720
+ {
721
+ context: { issues },
722
+ }
723
+ )
724
+ );
725
+ };