@ontrails/core 1.0.0-beta.30 → 1.0.0-beta.39

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.
package/src/trail.ts CHANGED
@@ -315,6 +315,84 @@ export type TrailVersions<
315
315
  >
316
316
  >;
317
317
 
318
+ /**
319
+ * Spec for {@link forkVersion}: a fork entry whose blaze signature is owned
320
+ * by the entry's own schemas instead of falling back to `unknown`.
321
+ */
322
+ export interface TrailVersionForkSpec<
323
+ TInputSchema extends z.ZodType,
324
+ TOutputSchema extends z.ZodType,
325
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
326
+ > {
327
+ /** The historical blaze, typed by this entry's schemas. */
328
+ readonly blaze: Implementation<
329
+ BlazeInput<
330
+ z.output<TInputSchema>,
331
+ ComposeSchemaOutput<TComposeInputSchema>
332
+ >,
333
+ z.output<TOutputSchema>
334
+ >;
335
+ readonly composeInput?: TComposeInputSchema | undefined;
336
+ readonly composes?: readonly (string | AnyTrail)[] | undefined;
337
+ readonly detours?:
338
+ | readonly Detour<
339
+ z.output<TInputSchema>,
340
+ z.output<TOutputSchema>,
341
+ TrailsError
342
+ >[]
343
+ | undefined;
344
+ readonly examples?:
345
+ | readonly TrailExample<z.input<TInputSchema>, z.output<TOutputSchema>>[]
346
+ | undefined;
347
+ readonly input: TInputSchema;
348
+ readonly output: TOutputSchema;
349
+ readonly resources?: readonly AnyResource[] | undefined;
350
+ readonly status?: TrailVersionStatus | undefined;
351
+ }
352
+
353
+ /**
354
+ * Author a fork version entry with a blaze typed by the entry's own schemas.
355
+ *
356
+ * `TrailVersions` fixes every entry's generics to `unknown`, so a fork blaze
357
+ * written inline receives `unknown` input and authors end up re-parsing the
358
+ * already-validated value just to narrow it. This helper threads the entry's
359
+ * `input`/`output` schemas into the blaze signature and erases the generics
360
+ * on the way out. The erasure is sound because the fork pipeline validates
361
+ * raw input against this entry's own `input` schema before dispatching to
362
+ * the entry blaze (see `createForkTrailVersion` in execute.ts).
363
+ *
364
+ * @example
365
+ * ```ts
366
+ * const gearV1Input = z.object({ name: z.string(), weightOz: z.number() });
367
+ * const gearV1Output = z.object({ id: z.string(), weightOz: z.number() });
368
+ *
369
+ * const gearCreate = trail('gear.create', {
370
+ * // ... current v2 contract ...
371
+ * version: 2,
372
+ * versions: {
373
+ * 1: forkVersion({
374
+ * blaze: (input) =>
375
+ * // input is { name: string; weightOz: number } — no re-parse
376
+ * Result.ok({ id: input.name, weightOz: input.weightOz }),
377
+ * input: gearV1Input,
378
+ * output: gearV1Output,
379
+ * }),
380
+ * },
381
+ * });
382
+ * ```
383
+ */
384
+ export const forkVersion = <
385
+ TInputSchema extends z.ZodType,
386
+ TOutputSchema extends z.ZodType,
387
+ TComposeInputSchema extends z.ZodType | undefined = undefined,
388
+ >(
389
+ spec: TrailVersionForkSpec<TInputSchema, TOutputSchema, TComposeInputSchema>
390
+ ): TrailVersionForkEntry =>
391
+ // Erasing the per-entry generics narrows function parameters, which TS
392
+ // cannot express without a conversion. Safe: the fork pipeline re-validates
393
+ // input against `spec.input` before the blaze runs.
394
+ spec as unknown as TrailVersionForkEntry;
395
+
318
396
  export const getTrailVersionEntryKind = (
319
397
  entry: TrailVersionEntry
320
398
  ): TrailVersionEntryKind => {
package/src/trails-db.ts CHANGED
@@ -1,10 +1,19 @@
1
- import { Database } from 'bun:sqlite';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync } from 'node:fs';
4
- import { homedir } from 'node:os';
5
- import { basename, dirname, join, resolve } from 'node:path';
1
+ import type { Database } from 'bun:sqlite';
6
2
 
7
3
  import { NotFoundError } from './errors.js';
4
+ import { loadRuntimeBuiltin } from './runtime-builtins.js';
5
+ import { sha256Hex } from './sha256.js';
6
+
7
+ // Altitude ruling (TRL-1198, ADR-0051 lens): trails-db stays core-owned
8
+ // shared framework infrastructure (ADR-0014) and stays on the barrel —
9
+ // topographer, tracing, warden, wayfinder, and the operator app all
10
+ // consume it from `@ontrails/core`. What it may NOT do is assume runtime
11
+ // capabilities eagerly: `bun:sqlite` and the node builtins load lazily at
12
+ // first use so the barrel's module graph stays execution-portable.
13
+ const sqlite = () => loadRuntimeBuiltin('bun:sqlite');
14
+ const fs = () => loadRuntimeBuiltin('node:fs');
15
+ const os = () => loadRuntimeBuiltin('node:os');
16
+ const nodePath = () => loadRuntimeBuiltin('node:path');
8
17
 
9
18
  const TRAILS_DIR = '.trails';
10
19
  const TRAILS_DB_FILE = 'trails.db';
@@ -46,7 +55,7 @@ interface SchemaVersionRow {
46
55
  }
47
56
 
48
57
  const deriveRootDir = (rootDir?: string): string =>
49
- resolve(rootDir ?? process.cwd());
58
+ nodePath().resolve(rootDir ?? process.cwd());
50
59
 
51
60
  const sanitizeProjectKeyName = (name: string): string => {
52
61
  const normalized = name.replaceAll(/[^a-zA-Z0-9._-]+/g, '-');
@@ -54,33 +63,30 @@ const sanitizeProjectKeyName = (name: string): string => {
54
63
  };
55
64
 
56
65
  const projectHash = (rootDir: string): string =>
57
- createHash('sha256')
58
- .update(rootDir)
59
- .digest('hex')
60
- .slice(0, PROJECT_KEY_HASH_LENGTH);
66
+ sha256Hex(rootDir).slice(0, PROJECT_KEY_HASH_LENGTH);
61
67
 
62
68
  export const deriveTrailsProjectKey = (
63
69
  options?: TrailsDbLocationOptions
64
70
  ): string => {
65
71
  const rootDir = deriveRootDir(options?.rootDir);
66
- return `${sanitizeProjectKeyName(basename(rootDir))}-${projectHash(rootDir)}`;
72
+ return `${sanitizeProjectKeyName(nodePath().basename(rootDir))}-${projectHash(rootDir)}`;
67
73
  };
68
74
 
69
75
  export const deriveTrailsStateHome = (
70
76
  options?: TrailsDbLocationOptions
71
77
  ): string => {
72
78
  const env = options?.env ?? process.env;
73
- return resolve(
79
+ return nodePath().resolve(
74
80
  env['TRAILS_STATE_HOME'] ??
75
81
  env['XDG_STATE_HOME'] ??
76
- join(homedir(), '.local', 'state')
82
+ nodePath().join(os().homedir(), '.local', 'state')
77
83
  );
78
84
  };
79
85
 
80
86
  export const deriveTrailsStateDir = (
81
87
  options?: TrailsDbLocationOptions
82
88
  ): string =>
83
- join(
89
+ nodePath().join(
84
90
  deriveTrailsStateHome(options),
85
91
  TRAILS_STORE_DIR,
86
92
  TRAILS_PROJECTS_DIR,
@@ -88,15 +94,15 @@ export const deriveTrailsStateDir = (
88
94
  );
89
95
 
90
96
  export const deriveTrailsDir = (options?: TrailsDbLocationOptions): string =>
91
- join(deriveRootDir(options?.rootDir), TRAILS_DIR);
97
+ nodePath().join(deriveRootDir(options?.rootDir), TRAILS_DIR);
92
98
 
93
99
  export const deriveTrailsDbPath = (options?: TrailsDbLocationOptions): string =>
94
100
  options?.path
95
- ? resolve(options.path)
96
- : join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
101
+ ? nodePath().resolve(options.path)
102
+ : nodePath().join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
97
103
 
98
104
  const ensureDbParentDir = (dbPath: string): void => {
99
- mkdirSync(dirname(dbPath), { recursive: true });
105
+ fs().mkdirSync(nodePath().dirname(dbPath), { recursive: true });
100
106
  };
101
107
 
102
108
  /**
@@ -108,7 +114,7 @@ const ensureDbParentDir = (dbPath: string): void => {
108
114
  */
109
115
  export const ensureTrailsWorkspace = (rootDir: string): void => {
110
116
  const trailsDir = deriveTrailsDir({ rootDir });
111
- mkdirSync(trailsDir, { recursive: true });
117
+ fs().mkdirSync(trailsDir, { recursive: true });
112
118
  };
113
119
 
114
120
  const initializeWritePragmas = (db: Database): void => {
@@ -168,7 +174,7 @@ export const openWriteTrailsDb = (
168
174
 
169
175
  ensureDbParentDir(dbPath);
170
176
 
171
- const db = new Database(dbPath, { create: true });
177
+ const db = new (sqlite().Database)(dbPath, { create: true });
172
178
  initializeWritePragmas(db);
173
179
  ensureSchemaVersionTable(db);
174
180
  return db;
@@ -178,12 +184,12 @@ export const openReadTrailsDb = (
178
184
  options?: TrailsDbLocationOptions
179
185
  ): Database => {
180
186
  const dbPath = deriveTrailsDbPath(options);
181
- if (!existsSync(dbPath)) {
187
+ if (!fs().existsSync(dbPath)) {
182
188
  throw new NotFoundError(
183
189
  `Trails database not found at "${dbPath}". Run a write operation first to initialize it.`
184
190
  );
185
191
  }
186
- const db = new Database(dbPath, { readonly: true });
192
+ const db = new (sqlite().Database)(dbPath, { readonly: true });
187
193
  initializeReadPragmas(db);
188
194
  return db;
189
195
  };
@@ -2,7 +2,7 @@ import { ValidationError } from './errors.js';
2
2
  import { Result } from './result.js';
3
3
  import type { Topo } from './topo.js';
4
4
  import { validateDraftFreeTopo } from './draft.js';
5
- import type { TopoIssue } from './validate-topo.js';
5
+ import type { TopoDiagnostic } from './validate-topo.js';
6
6
  import { validateTopo } from './validate-topo.js';
7
7
 
8
8
  const PROJECTION_BLOCKING_RULES = new Set([
@@ -27,7 +27,7 @@ const keepProjectionBlockingIssues = (
27
27
  }
28
28
 
29
29
  const issues = (
30
- result.error.context as { issues?: readonly TopoIssue[] } | undefined
30
+ result.error.context as { issues?: readonly TopoDiagnostic[] } | undefined
31
31
  )?.issues;
32
32
  const remainingIssues = issues?.filter((issue) =>
33
33
  PROJECTION_BLOCKING_RULES.has(issue.rule)
@@ -34,18 +34,80 @@ import { validateWebhookSource } from './webhook.js';
34
34
  // Issue shape
35
35
  // ---------------------------------------------------------------------------
36
36
 
37
- export interface TopoIssue {
37
+ export type TopoDiagnosticCode = 'topo.missing-reference';
38
+
39
+ export type TopoReferenceKind =
40
+ | 'compose'
41
+ | 'contour-reference'
42
+ | 'resource'
43
+ | 'signal-fire'
44
+ | 'signal-on'
45
+ | 'signal-origin';
46
+
47
+ export type TopoReferenceOwnerKind =
48
+ | 'contour'
49
+ | 'signal'
50
+ | 'trail'
51
+ | 'trail-version';
52
+
53
+ /**
54
+ * Stable machine-readable payload for dangling topo references.
55
+ *
56
+ * Consumers such as Regrade should branch on `code` and `reference` instead
57
+ * of parsing `message`, which remains a human-readable diagnostic.
58
+ */
59
+ export interface TopoMissingReference {
60
+ readonly fromId: string;
61
+ readonly fromKind: TopoReferenceOwnerKind;
62
+ readonly fromTrailId?: string;
63
+ readonly missingId: string;
64
+ readonly referenceKind: TopoReferenceKind;
65
+ readonly version?: number;
66
+ }
67
+
68
+ export interface TopoDiagnostic {
69
+ /**
70
+ * Stable machine-readable code. Human-facing messages may change for
71
+ * clarity; downstream automation should depend on this code and the typed
72
+ * payload fields.
73
+ */
74
+ readonly code?: TopoDiagnosticCode;
38
75
  readonly trailId: string;
39
76
  readonly rule: string;
40
77
  readonly message: string;
41
78
  readonly inputPath?: readonly (string | number)[];
79
+ readonly reference?: TopoMissingReference;
42
80
  readonly schemaIssues?: readonly TopoSchemaIssue[];
43
81
  readonly sourceId?: string;
44
82
  readonly sourceKind?: string;
45
83
  }
46
84
 
85
+ /**
86
+ * @deprecated Use {@link TopoDiagnostic}. Kept as a source-compatible alias
87
+ * during the v1 vocabulary cutover.
88
+ */
89
+ export interface TopoIssue extends TopoDiagnostic {
90
+ readonly trailId: TopoDiagnostic['trailId'];
91
+ }
92
+
47
93
  export type TopoSchemaIssue = ActivationSchemaIssue;
48
94
 
95
+ const isTopoDiagnostic = (value: unknown): value is TopoDiagnostic =>
96
+ typeof value === 'object' &&
97
+ value !== null &&
98
+ typeof (value as { message?: unknown }).message === 'string' &&
99
+ typeof (value as { rule?: unknown }).rule === 'string' &&
100
+ typeof (value as { trailId?: unknown }).trailId === 'string';
101
+
102
+ const missingReferenceDiagnostic = (
103
+ issue: Omit<TopoDiagnostic, 'code' | 'reference'> & {
104
+ readonly reference: TopoMissingReference;
105
+ }
106
+ ): TopoDiagnostic => ({
107
+ ...issue,
108
+ code: 'topo.missing-reference',
109
+ });
110
+
49
111
  // ---------------------------------------------------------------------------
50
112
  // Validators
51
113
  // ---------------------------------------------------------------------------
@@ -92,8 +154,8 @@ const buildComposeGraph = (
92
154
  /** Detect multi-node cycles in the trail composing graph via DFS. */
93
155
  const detectComposeCycles = (
94
156
  trails: ReadonlyMap<string, AnyTrail>
95
- ): TopoIssue[] => {
96
- const issues: TopoIssue[] = [];
157
+ ): TopoDiagnostic[] => {
158
+ const issues: TopoDiagnostic[] = [];
97
159
  const { color, graph } = buildComposeGraph(trails);
98
160
 
99
161
  const dfs = (node: string, path: string[]): void => {
@@ -128,8 +190,8 @@ const detectComposeCycles = (
128
190
  const checkComposes = (
129
191
  trails: ReadonlyMap<string, AnyTrail>,
130
192
  topo: Topo
131
- ): TopoIssue[] => {
132
- const issues: TopoIssue[] = [];
193
+ ): TopoDiagnostic[] => {
194
+ const issues: TopoDiagnostic[] = [];
133
195
  for (const [id, trail] of trails) {
134
196
  for (const composedId of trail.composes) {
135
197
  if (composedId === id) {
@@ -139,11 +201,20 @@ const checkComposes = (
139
201
  trailId: id,
140
202
  });
141
203
  } else if (!topo.has(composedId) && !isDraftId(composedId)) {
142
- issues.push({
143
- message: `Composes "${composedId}" which is not in the topo`,
144
- rule: 'compose-exists',
145
- trailId: id,
146
- });
204
+ issues.push(
205
+ missingReferenceDiagnostic({
206
+ message: `Composes "${composedId}" which is not in the topo`,
207
+ reference: {
208
+ fromId: id,
209
+ fromKind: 'trail',
210
+ fromTrailId: id,
211
+ missingId: composedId,
212
+ referenceKind: 'compose',
213
+ },
214
+ rule: 'compose-exists',
215
+ trailId: id,
216
+ })
217
+ );
147
218
  }
148
219
  }
149
220
  for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
@@ -166,11 +237,21 @@ const checkComposes = (
166
237
  trailId: id,
167
238
  });
168
239
  } else if (!topo.has(composedId) && !isDraftId(composedId)) {
169
- issues.push({
170
- message: `Version ${version} composes "${composedId}" which is not in the topo`,
171
- rule: 'compose-exists',
172
- trailId: id,
173
- });
240
+ issues.push(
241
+ missingReferenceDiagnostic({
242
+ message: `Version ${version} composes "${composedId}" which is not in the topo`,
243
+ reference: {
244
+ fromId: id,
245
+ fromKind: 'trail-version',
246
+ fromTrailId: id,
247
+ missingId: composedId,
248
+ referenceKind: 'compose',
249
+ version,
250
+ },
251
+ rule: 'compose-exists',
252
+ trailId: id,
253
+ })
254
+ );
174
255
  }
175
256
  }
176
257
  }
@@ -182,8 +263,8 @@ const checkComposes = (
182
263
  const checkResources = (
183
264
  trails: ReadonlyMap<string, AnyTrail>,
184
265
  topo: Topo
185
- ): TopoIssue[] => {
186
- const issues: TopoIssue[] = [];
266
+ ): TopoDiagnostic[] => {
267
+ const issues: TopoDiagnostic[] = [];
187
268
 
188
269
  for (const [id, trail] of trails) {
189
270
  for (const declaredResource of trail.resources) {
@@ -191,11 +272,20 @@ const checkResources = (
191
272
  !topo.hasResource(declaredResource.id) &&
192
273
  !isDraftId(declaredResource.id)
193
274
  ) {
194
- issues.push({
195
- message: `Resource "${declaredResource.id}" is not in the topo`,
196
- rule: 'resource-exists',
197
- trailId: id,
198
- });
275
+ issues.push(
276
+ missingReferenceDiagnostic({
277
+ message: `Resource "${declaredResource.id}" is not in the topo`,
278
+ reference: {
279
+ fromId: id,
280
+ fromKind: 'trail',
281
+ fromTrailId: id,
282
+ missingId: declaredResource.id,
283
+ referenceKind: 'resource',
284
+ },
285
+ rule: 'resource-exists',
286
+ trailId: id,
287
+ })
288
+ );
199
289
  }
200
290
  }
201
291
  for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
@@ -213,11 +303,21 @@ const checkResources = (
213
303
  !topo.hasResource(declaredResource.id) &&
214
304
  !isDraftId(declaredResource.id)
215
305
  ) {
216
- issues.push({
217
- message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
218
- rule: 'resource-exists',
219
- trailId: id,
220
- });
306
+ issues.push(
307
+ missingReferenceDiagnostic({
308
+ message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
309
+ reference: {
310
+ fromId: id,
311
+ fromKind: 'trail-version',
312
+ fromTrailId: id,
313
+ missingId: declaredResource.id,
314
+ referenceKind: 'resource',
315
+ version,
316
+ },
317
+ rule: 'resource-exists',
318
+ trailId: id,
319
+ })
320
+ );
221
321
  }
222
322
  }
223
323
  }
@@ -237,8 +337,8 @@ const checkOneExample = (
237
337
  inputSchema: { safeParse: (data: unknown) => { success: boolean } },
238
338
  hasOutput: boolean,
239
339
  label = `Example "${example.name}"`
240
- ): TopoIssue[] => {
241
- const issues: TopoIssue[] = [];
340
+ ): TopoDiagnostic[] => {
341
+ const issues: TopoDiagnostic[] = [];
242
342
  const result = validateInput(inputSchema as AnyTrail['input'], example.input);
243
343
  if (result.isErr() && example.error !== 'ValidationError') {
244
344
  issues.push({
@@ -257,7 +357,7 @@ const checkOneExample = (
257
357
  return issues;
258
358
  };
259
359
 
260
- const checkVersionExamples = (id: string, trail: AnyTrail): TopoIssue[] =>
360
+ const checkVersionExamples = (id: string, trail: AnyTrail): TopoDiagnostic[] =>
261
361
  Object.entries(trail.versions ?? {}).flatMap(([version, entry]) => {
262
362
  if (isArchivedTrailVersionEntry(entry)) {
263
363
  return [];
@@ -274,8 +374,10 @@ const checkVersionExamples = (id: string, trail: AnyTrail): TopoIssue[] =>
274
374
  );
275
375
  });
276
376
 
277
- const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
278
- const issues: TopoIssue[] = [];
377
+ const checkExamples = (
378
+ trails: ReadonlyMap<string, AnyTrail>
379
+ ): TopoDiagnostic[] => {
380
+ const issues: TopoDiagnostic[] = [];
279
381
  for (const [id, trail] of trails) {
280
382
  if (trail.examples) {
281
383
  for (const example of trail.examples) {
@@ -292,19 +394,27 @@ const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
292
394
  const checkSignalOrigins = (
293
395
  signals: ReadonlyMap<string, AnySignal>,
294
396
  topo: Topo
295
- ): TopoIssue[] => {
296
- const issues: TopoIssue[] = [];
397
+ ): TopoDiagnostic[] => {
398
+ const issues: TopoDiagnostic[] = [];
297
399
  for (const [id, evt] of signals) {
298
400
  if (!evt.from) {
299
401
  continue;
300
402
  }
301
403
  for (const originId of evt.from) {
302
404
  if (!topo.has(originId) && !isDraftId(originId)) {
303
- issues.push({
304
- message: `Signal origin "${originId}" is not in the topo`,
305
- rule: 'signal-origin-exists',
306
- trailId: id,
307
- });
405
+ issues.push(
406
+ missingReferenceDiagnostic({
407
+ message: `Signal origin "${originId}" is not in the topo`,
408
+ reference: {
409
+ fromId: id,
410
+ fromKind: 'signal',
411
+ missingId: originId,
412
+ referenceKind: 'signal-origin',
413
+ },
414
+ rule: 'signal-origin-exists',
415
+ trailId: id,
416
+ })
417
+ );
308
418
  }
309
419
  }
310
420
  }
@@ -314,27 +424,45 @@ const checkSignalOrigins = (
314
424
  const checkSignalReferences = (
315
425
  trails: ReadonlyMap<string, AnyTrail>,
316
426
  signals: ReadonlyMap<string, AnySignal>
317
- ): TopoIssue[] => {
318
- const issues: TopoIssue[] = [];
427
+ ): TopoDiagnostic[] => {
428
+ const issues: TopoDiagnostic[] = [];
319
429
 
320
430
  for (const [id, trail] of trails) {
321
431
  for (const signalId of trail.fires ?? []) {
322
432
  if (!signals.has(signalId) && !isDraftId(signalId)) {
323
- issues.push({
324
- message: `Trail fires signal "${signalId}" which is not in the topo`,
325
- rule: 'signal-fire-exists',
326
- trailId: id,
327
- });
433
+ issues.push(
434
+ missingReferenceDiagnostic({
435
+ message: `Trail fires signal "${signalId}" which is not in the topo`,
436
+ reference: {
437
+ fromId: id,
438
+ fromKind: 'trail',
439
+ fromTrailId: id,
440
+ missingId: signalId,
441
+ referenceKind: 'signal-fire',
442
+ },
443
+ rule: 'signal-fire-exists',
444
+ trailId: id,
445
+ })
446
+ );
328
447
  }
329
448
  }
330
449
 
331
450
  for (const signalId of trail.on ?? []) {
332
451
  if (!signals.has(signalId) && !isDraftId(signalId)) {
333
- issues.push({
334
- message: `Trail declares on signal "${signalId}" which is not in the topo`,
335
- rule: 'signal-on-exists',
336
- trailId: id,
337
- });
452
+ issues.push(
453
+ missingReferenceDiagnostic({
454
+ message: `Trail declares on signal "${signalId}" which is not in the topo`,
455
+ reference: {
456
+ fromId: id,
457
+ fromKind: 'trail',
458
+ fromTrailId: id,
459
+ missingId: signalId,
460
+ referenceKind: 'signal-on',
461
+ },
462
+ rule: 'signal-on-exists',
463
+ trailId: id,
464
+ })
465
+ );
338
466
  }
339
467
  }
340
468
  }
@@ -344,8 +472,8 @@ const checkSignalReferences = (
344
472
 
345
473
  const checkActivationSources = (
346
474
  trails: ReadonlyMap<string, AnyTrail>
347
- ): TopoIssue[] => {
348
- const issues: TopoIssue[] = [];
475
+ ): TopoDiagnostic[] => {
476
+ const issues: TopoDiagnostic[] = [];
349
477
  const sourceDeclarations = new Map<
350
478
  string,
351
479
  {
@@ -440,7 +568,7 @@ const createSourceCompatibilityIssue = (
440
568
  trailId: string,
441
569
  activation: ActivationEntry,
442
570
  schemaIssues: readonly TopoSchemaIssue[]
443
- ): TopoIssue => {
571
+ ): TopoDiagnostic => {
444
572
  const [firstIssue] = schemaIssues;
445
573
  const inputPath = firstIssue?.path ?? Object.freeze([]);
446
574
  return {
@@ -458,7 +586,7 @@ const checkSourcePayloadCompatibility = (
458
586
  trail: AnyTrail,
459
587
  activation: ActivationEntry,
460
588
  signals: ReadonlyMap<string, AnySignal>
461
- ): TopoIssue | undefined => {
589
+ ): TopoDiagnostic | undefined => {
462
590
  if (
463
591
  !isKnownActivationSourceKind(activation.source.kind) ||
464
592
  isDraftId(activation.source.id)
@@ -483,8 +611,8 @@ const checkSourcePayloadCompatibility = (
483
611
  const checkActivationSourceInputCompatibility = (
484
612
  trails: ReadonlyMap<string, AnyTrail>,
485
613
  signals: ReadonlyMap<string, AnySignal>
486
- ): TopoIssue[] => {
487
- const issues: TopoIssue[] = [];
614
+ ): TopoDiagnostic[] => {
615
+ const issues: TopoDiagnostic[] = [];
488
616
 
489
617
  for (const trail of trails.values()) {
490
618
  for (const activation of trail.activationSources ?? []) {
@@ -501,17 +629,25 @@ const checkActivationSourceInputCompatibility = (
501
629
  const checkContourReferences = (
502
630
  contours: ReadonlyMap<string, AnyContour>,
503
631
  topo: Topo
504
- ): TopoIssue[] => {
505
- const issues: TopoIssue[] = [];
632
+ ): TopoDiagnostic[] => {
633
+ const issues: TopoDiagnostic[] = [];
506
634
 
507
635
  for (const [name, contourDef] of contours) {
508
636
  for (const ref of getContourReferences(contourDef)) {
509
637
  if (!topo.hasContour(ref.contour) && !isDraftId(ref.contour)) {
510
- issues.push({
511
- message: `Contour "${name}" references "${ref.contour}" which is not in the topo`,
512
- rule: 'contour-reference-exists',
513
- trailId: name,
514
- });
638
+ issues.push(
639
+ missingReferenceDiagnostic({
640
+ message: `Contour "${name}" references "${ref.contour}" which is not in the topo`,
641
+ reference: {
642
+ fromId: name,
643
+ fromKind: 'contour',
644
+ missingId: ref.contour,
645
+ referenceKind: 'contour-reference',
646
+ },
647
+ rule: 'contour-reference-exists',
648
+ trailId: name,
649
+ })
650
+ );
515
651
  }
516
652
  }
517
653
  }
@@ -523,6 +659,21 @@ const checkContourReferences = (
523
659
  // Public API
524
660
  // ---------------------------------------------------------------------------
525
661
 
662
+ /**
663
+ * Extract structured topo diagnostics from a validation error.
664
+ *
665
+ * `validateTopo` keeps source compatibility by returning `Result<void,
666
+ * ValidationError>`. Consumers that need machine-readable diagnostics should
667
+ * use this helper instead of parsing the human `message` text.
668
+ */
669
+ export const getTopoDiagnostics = (
670
+ error: ValidationError
671
+ ): readonly TopoDiagnostic[] => {
672
+ const context = error.context as { issues?: unknown } | undefined;
673
+ const issues = context?.issues;
674
+ return Array.isArray(issues) ? issues.filter(isTopoDiagnostic) : [];
675
+ };
676
+
526
677
  /**
527
678
  * Validate the structural integrity of a Topo graph.
528
679
  *