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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +283 -5
  2. package/README.md +113 -33
  3. package/package.json +11 -1
  4. package/src/activation-provenance.ts +111 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-projection.ts +221 -0
  7. package/src/activation-source.ts +91 -0
  8. package/src/blob-ref.ts +51 -0
  9. package/src/branded.ts +1 -1
  10. package/src/compose-batch.ts +69 -0
  11. package/src/compose-schema.ts +36 -0
  12. package/src/context.ts +44 -10
  13. package/src/contour.ts +346 -0
  14. package/src/derive.ts +59 -18
  15. package/src/detours.ts +8 -0
  16. package/src/draft.ts +342 -0
  17. package/src/error-projection.ts +51 -0
  18. package/src/errors.ts +309 -48
  19. package/src/execute.ts +1577 -0
  20. package/src/fire.ts +1169 -0
  21. package/src/index.ts +477 -32
  22. package/src/internal/fork-ctx.ts +69 -0
  23. package/src/layer-projection.ts +193 -0
  24. package/src/layer.ts +43 -6
  25. package/src/observe.ts +361 -0
  26. package/src/path-security.ts +1 -1
  27. package/src/permits.ts +12 -0
  28. package/src/redaction/patterns.ts +6 -3
  29. package/src/resilience.ts +11 -11
  30. package/src/resource-config.ts +792 -0
  31. package/src/resource.ts +194 -0
  32. package/src/result.ts +36 -4
  33. package/src/run.ts +76 -0
  34. package/src/schedule-runtime.ts +689 -0
  35. package/src/schedule.ts +326 -0
  36. package/src/serialization.ts +119 -39
  37. package/src/signal-diagnostics.ts +633 -0
  38. package/src/signal-ref.ts +87 -0
  39. package/src/signal.ts +104 -0
  40. package/src/store/accessor-protocol.ts +56 -0
  41. package/src/store/index.ts +4 -0
  42. package/src/structured-examples.ts +248 -0
  43. package/src/surface-derivation.ts +91 -0
  44. package/src/surface-filter.ts +176 -0
  45. package/src/surface-versioning.ts +42 -0
  46. package/src/topo.ts +769 -57
  47. package/src/tracing.ts +345 -0
  48. package/src/trail.ts +1161 -29
  49. package/src/trails/derive-trail.ts +842 -0
  50. package/src/trails/index.ts +9 -0
  51. package/src/trails/ingest.ts +139 -0
  52. package/src/trails-db.ts +208 -0
  53. package/src/transport-error-map.ts +174 -0
  54. package/src/type-utils.ts +87 -0
  55. package/src/types.ts +254 -12
  56. package/src/validate-established-topo.ts +69 -0
  57. package/src/validate-topo.ts +442 -35
  58. package/src/validation.ts +152 -4
  59. package/src/version-marker.ts +716 -0
  60. package/src/version-resolution.ts +308 -0
  61. package/src/version-runtime.ts +120 -0
  62. package/src/webhook.ts +278 -0
  63. package/src/workspace.ts +1 -1
  64. package/src/zod-wrappers.ts +72 -0
  65. package/.turbo/turbo-build.log +0 -1
  66. package/.turbo/turbo-lint.log +0 -3
  67. package/.turbo/turbo-typecheck.log +0 -1
  68. package/dist/adapters.d.ts +0 -39
  69. package/dist/adapters.d.ts.map +0 -1
  70. package/dist/adapters.js +0 -2
  71. package/dist/adapters.js.map +0 -1
  72. package/dist/blob-ref.d.ts +0 -20
  73. package/dist/blob-ref.d.ts.map +0 -1
  74. package/dist/blob-ref.js +0 -22
  75. package/dist/blob-ref.js.map +0 -1
  76. package/dist/branded.d.ts +0 -36
  77. package/dist/branded.d.ts.map +0 -1
  78. package/dist/branded.js +0 -89
  79. package/dist/branded.js.map +0 -1
  80. package/dist/collections.d.ts +0 -31
  81. package/dist/collections.d.ts.map +0 -1
  82. package/dist/collections.js +0 -60
  83. package/dist/collections.js.map +0 -1
  84. package/dist/context.d.ts +0 -10
  85. package/dist/context.d.ts.map +0 -1
  86. package/dist/context.js +0 -15
  87. package/dist/context.js.map +0 -1
  88. package/dist/derive.d.ts +0 -33
  89. package/dist/derive.d.ts.map +0 -1
  90. package/dist/derive.js +0 -122
  91. package/dist/derive.js.map +0 -1
  92. package/dist/errors.d.ts +0 -83
  93. package/dist/errors.d.ts.map +0 -1
  94. package/dist/errors.js +0 -142
  95. package/dist/errors.js.map +0 -1
  96. package/dist/event.d.ts +0 -45
  97. package/dist/event.d.ts.map +0 -1
  98. package/dist/event.js +0 -17
  99. package/dist/event.js.map +0 -1
  100. package/dist/fetch.d.ts +0 -15
  101. package/dist/fetch.d.ts.map +0 -1
  102. package/dist/fetch.js +0 -102
  103. package/dist/fetch.js.map +0 -1
  104. package/dist/guards.d.ts +0 -17
  105. package/dist/guards.d.ts.map +0 -1
  106. package/dist/guards.js +0 -25
  107. package/dist/guards.js.map +0 -1
  108. package/dist/health.d.ts +0 -18
  109. package/dist/health.d.ts.map +0 -1
  110. package/dist/health.js +0 -5
  111. package/dist/health.js.map +0 -1
  112. package/dist/hike.d.ts +0 -36
  113. package/dist/hike.d.ts.map +0 -1
  114. package/dist/hike.js +0 -20
  115. package/dist/hike.js.map +0 -1
  116. package/dist/index.d.ts +0 -34
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/index.js +0 -38
  119. package/dist/index.js.map +0 -1
  120. package/dist/job.d.ts +0 -24
  121. package/dist/job.d.ts.map +0 -1
  122. package/dist/job.js +0 -17
  123. package/dist/job.js.map +0 -1
  124. package/dist/layer.d.ts +0 -17
  125. package/dist/layer.d.ts.map +0 -1
  126. package/dist/layer.js +0 -21
  127. package/dist/layer.js.map +0 -1
  128. package/dist/path-security.d.ts +0 -28
  129. package/dist/path-security.d.ts.map +0 -1
  130. package/dist/path-security.js +0 -63
  131. package/dist/path-security.js.map +0 -1
  132. package/dist/patterns/bulk.d.ts +0 -15
  133. package/dist/patterns/bulk.d.ts.map +0 -1
  134. package/dist/patterns/bulk.js +0 -14
  135. package/dist/patterns/bulk.js.map +0 -1
  136. package/dist/patterns/change.d.ts +0 -10
  137. package/dist/patterns/change.d.ts.map +0 -1
  138. package/dist/patterns/change.js +0 -10
  139. package/dist/patterns/change.js.map +0 -1
  140. package/dist/patterns/date-range.d.ts +0 -10
  141. package/dist/patterns/date-range.d.ts.map +0 -1
  142. package/dist/patterns/date-range.js +0 -10
  143. package/dist/patterns/date-range.js.map +0 -1
  144. package/dist/patterns/index.d.ts +0 -9
  145. package/dist/patterns/index.d.ts.map +0 -1
  146. package/dist/patterns/index.js +0 -9
  147. package/dist/patterns/index.js.map +0 -1
  148. package/dist/patterns/pagination.d.ts +0 -18
  149. package/dist/patterns/pagination.d.ts.map +0 -1
  150. package/dist/patterns/pagination.js +0 -18
  151. package/dist/patterns/pagination.js.map +0 -1
  152. package/dist/patterns/progress.d.ts +0 -11
  153. package/dist/patterns/progress.d.ts.map +0 -1
  154. package/dist/patterns/progress.js +0 -11
  155. package/dist/patterns/progress.js.map +0 -1
  156. package/dist/patterns/sorting.d.ts +0 -13
  157. package/dist/patterns/sorting.d.ts.map +0 -1
  158. package/dist/patterns/sorting.js +0 -10
  159. package/dist/patterns/sorting.js.map +0 -1
  160. package/dist/patterns/status.d.ts +0 -15
  161. package/dist/patterns/status.d.ts.map +0 -1
  162. package/dist/patterns/status.js +0 -9
  163. package/dist/patterns/status.js.map +0 -1
  164. package/dist/patterns/timestamps.d.ts +0 -10
  165. package/dist/patterns/timestamps.d.ts.map +0 -1
  166. package/dist/patterns/timestamps.js +0 -10
  167. package/dist/patterns/timestamps.js.map +0 -1
  168. package/dist/redaction/index.d.ts +0 -4
  169. package/dist/redaction/index.d.ts.map +0 -1
  170. package/dist/redaction/index.js +0 -3
  171. package/dist/redaction/index.js.map +0 -1
  172. package/dist/redaction/patterns.d.ts +0 -9
  173. package/dist/redaction/patterns.d.ts.map +0 -1
  174. package/dist/redaction/patterns.js +0 -39
  175. package/dist/redaction/patterns.js.map +0 -1
  176. package/dist/redaction/redactor.d.ts +0 -27
  177. package/dist/redaction/redactor.d.ts.map +0 -1
  178. package/dist/redaction/redactor.js +0 -89
  179. package/dist/redaction/redactor.js.map +0 -1
  180. package/dist/resilience.d.ts +0 -34
  181. package/dist/resilience.d.ts.map +0 -1
  182. package/dist/resilience.js +0 -164
  183. package/dist/resilience.js.map +0 -1
  184. package/dist/result.d.ts +0 -57
  185. package/dist/result.d.ts.map +0 -1
  186. package/dist/result.js +0 -145
  187. package/dist/result.js.map +0 -1
  188. package/dist/serialization.d.ts +0 -27
  189. package/dist/serialization.d.ts.map +0 -1
  190. package/dist/serialization.js +0 -115
  191. package/dist/serialization.js.map +0 -1
  192. package/dist/topo.d.ts +0 -18
  193. package/dist/topo.d.ts.map +0 -1
  194. package/dist/topo.js +0 -74
  195. package/dist/topo.js.map +0 -1
  196. package/dist/trail.d.ts +0 -83
  197. package/dist/trail.d.ts.map +0 -1
  198. package/dist/trail.js +0 -16
  199. package/dist/trail.js.map +0 -1
  200. package/dist/types.d.ts +0 -46
  201. package/dist/types.d.ts.map +0 -1
  202. package/dist/types.js +0 -2
  203. package/dist/types.js.map +0 -1
  204. package/dist/validate-topo.d.ts +0 -24
  205. package/dist/validate-topo.d.ts.map +0 -1
  206. package/dist/validate-topo.js +0 -108
  207. package/dist/validate-topo.js.map +0 -1
  208. package/dist/validation.d.ts +0 -27
  209. package/dist/validation.d.ts.map +0 -1
  210. package/dist/validation.js +0 -134
  211. package/dist/validation.js.map +0 -1
  212. package/dist/workspace.d.ts +0 -25
  213. package/dist/workspace.d.ts.map +0 -1
  214. package/dist/workspace.js +0 -57
  215. package/dist/workspace.js.map +0 -1
  216. package/src/__tests__/blob-ref.test.ts +0 -103
  217. package/src/__tests__/branded.test.ts +0 -148
  218. package/src/__tests__/collections.test.ts +0 -126
  219. package/src/__tests__/context.test.ts +0 -66
  220. package/src/__tests__/derive.test.ts +0 -159
  221. package/src/__tests__/errors.test.ts +0 -309
  222. package/src/__tests__/event.test.ts +0 -82
  223. package/src/__tests__/fetch.test.ts +0 -217
  224. package/src/__tests__/guards.test.ts +0 -102
  225. package/src/__tests__/hike.test.ts +0 -117
  226. package/src/__tests__/job.test.ts +0 -98
  227. package/src/__tests__/layer.test.ts +0 -224
  228. package/src/__tests__/path-security.test.ts +0 -114
  229. package/src/__tests__/patterns.test.ts +0 -273
  230. package/src/__tests__/redaction.test.ts +0 -244
  231. package/src/__tests__/resilience.test.ts +0 -246
  232. package/src/__tests__/result.test.ts +0 -155
  233. package/src/__tests__/serialization.test.ts +0 -236
  234. package/src/__tests__/topo.test.ts +0 -184
  235. package/src/__tests__/trail.test.ts +0 -179
  236. package/src/__tests__/validate-topo.test.ts +0 -201
  237. package/src/__tests__/validation.test.ts +0 -283
  238. package/src/__tests__/workspace.test.ts +0 -183
  239. package/src/adapters.ts +0 -68
  240. package/src/event.ts +0 -77
  241. package/src/health.ts +0 -23
  242. package/src/hike.ts +0 -77
  243. package/src/job.ts +0 -20
  244. package/tsconfig.json +0 -9
  245. package/tsconfig.tsbuildinfo +0 -1
package/src/derive.ts CHANGED
@@ -1,20 +1,29 @@
1
1
  /**
2
2
  * Schema-driven field derivation for @ontrails/core
3
3
  *
4
- * Introspects Zod v4 schemas to produce a surface-agnostic Field[] descriptor
5
- * that UI layers (CLI prompts, web forms, etc.) can consume.
4
+ * Introspects Zod v4 schemas to produce a runtime-agnostic Field[] descriptor
5
+ * that UI consumers (CLI prompts, web forms, etc.) can consume.
6
6
  */
7
7
 
8
8
  import type { z } from 'zod';
9
9
 
10
+ import { ValidationError } from './errors.js';
11
+
10
12
  // ---------------------------------------------------------------------------
11
13
  // Public types
12
14
  // ---------------------------------------------------------------------------
13
15
 
14
- /** A surface-agnostic field descriptor derived from a Zod schema. */
16
+ /** A runtime-agnostic field descriptor derived from a Zod schema. */
15
17
  export interface Field {
16
18
  readonly name: string;
17
- readonly type: 'string' | 'number' | 'boolean' | 'enum' | 'multiselect';
19
+ readonly type:
20
+ | 'string'
21
+ | 'number'
22
+ | 'boolean'
23
+ | 'enum'
24
+ | 'multiselect'
25
+ | 'string[]'
26
+ | 'number[]';
18
27
  readonly label: string;
19
28
  readonly required: boolean;
20
29
  readonly default?: unknown | undefined;
@@ -133,15 +142,25 @@ interface DerivedFieldType {
133
142
  type: Field['type'];
134
143
  }
135
144
 
136
- const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
145
+ const fieldTypeByDef: Record<
146
+ string,
147
+ (s: ZodInternals) => DerivedFieldType | null
148
+ > = {
137
149
  array: (s) => {
138
150
  const element = s._zod.def['element'] as unknown as ZodInternals;
139
- const elementType = element._zod.def['type'] as string;
151
+ const { inner } = unwrap(element);
152
+ const elementType = inner._zod.def['type'] as string;
140
153
  if (elementType === 'enum') {
141
- const entries = element._zod.def['entries'] as Record<string, string>;
154
+ const entries = inner._zod.def['entries'] as Record<string, string>;
142
155
  return { options: Object.values(entries), type: 'multiselect' };
143
156
  }
144
- return { options: undefined, type: 'string' };
157
+ if (elementType !== 'number' && elementType !== 'string') {
158
+ return null;
159
+ }
160
+ return {
161
+ options: undefined,
162
+ type: elementType === 'number' ? 'number[]' : 'string[]',
163
+ };
145
164
  },
146
165
  boolean: () => ({ options: undefined, type: 'boolean' }),
147
166
  enum: (s) => {
@@ -153,10 +172,10 @@ const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
153
172
  };
154
173
 
155
174
  /** Derive field type and raw options from the unwrapped Zod def. */
156
- const deriveFieldType = (s: ZodInternals): DerivedFieldType => {
175
+ const deriveFieldType = (s: ZodInternals): DerivedFieldType | null => {
157
176
  const defType = s._zod.def['type'] as string;
158
177
  const derive = fieldTypeByDef[defType];
159
- return derive ? derive(s) : { options: undefined, type: 'string' };
178
+ return derive ? derive(s) : null;
160
179
  };
161
180
 
162
181
  /** Build options array, merging with overrides when present. */
@@ -179,29 +198,49 @@ const buildOptions = (
179
198
  });
180
199
  };
181
200
 
201
+ /**
202
+ * Derive the canonical ordered CLI path from a trail ID.
203
+ *
204
+ * @throws {ValidationError} if the trail ID contains empty segments (e.g. consecutive dots).
205
+ */
206
+ export const deriveCliPath = (trailId: string): string[] => {
207
+ const segments = trailId.split('.');
208
+ const emptyIndex = segments.findIndex((s) => s.length === 0);
209
+ if (emptyIndex !== -1) {
210
+ throw new ValidationError(
211
+ `Trail ID "${trailId}" contains an empty segment at position ${emptyIndex}`
212
+ );
213
+ }
214
+ return segments;
215
+ };
216
+
182
217
  // ---------------------------------------------------------------------------
183
218
  // Public API
184
219
  // ---------------------------------------------------------------------------
185
220
 
186
- /**
187
- * Derive a surface-agnostic Field[] from a Zod object schema.
188
- *
189
- * Uses Zod v4's `_zod.def` for introspection. Returns fields sorted by name.
190
- */
191
221
  /** Derive a single field from a shape entry. */
192
222
  const deriveField = (
193
223
  key: string,
194
224
  value: ZodInternals,
195
225
  overrides?: Record<string, FieldOverride>
196
- ): Field => {
226
+ ): Field | null => {
197
227
  const { inner, required, defaultValue, description } = unwrap(value);
198
- const { type, options: rawOptions } = deriveFieldType(inner);
228
+ const derived = deriveFieldType(inner);
229
+ if (!derived) {
230
+ return null;
231
+ }
232
+ const { type, options: rawOptions } = derived;
199
233
  const override = overrides?.[key];
200
234
  const label = override?.label ?? description ?? humanize(key);
201
235
  const options = buildOptions(rawOptions, override?.options);
202
236
  return { default: defaultValue, label, name: key, options, required, type };
203
237
  };
204
238
 
239
+ /**
240
+ * Derive a runtime-agnostic Field[] from a Zod object schema.
241
+ *
242
+ * Uses Zod v4's `_zod.def` for introspection. Returns fields sorted by name.
243
+ */
205
244
  export const deriveFields = (
206
245
  schema: z.ZodType,
207
246
  overrides?: Record<string, FieldOverride>
@@ -219,5 +258,7 @@ export const deriveFields = (
219
258
  const fields = Object.entries(shape).map(([key, value]) =>
220
259
  deriveField(key, value, overrides)
221
260
  );
222
- return fields.toSorted((a, b) => a.name.localeCompare(b.name));
261
+ return fields
262
+ .filter((field): field is Field => field !== null)
263
+ .toSorted((a, b) => a.name.localeCompare(b.name));
223
264
  };
package/src/detours.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Hard upper bound for detour recovery attempts.
3
+ *
4
+ * Execution and derived surface/topo projections both clamp declared detour
5
+ * attempts to this value so runtime behavior and inspectable contracts stay in
6
+ * lockstep.
7
+ */
8
+ export const DETOUR_MAX_ATTEMPTS_CAP = 5;
package/src/draft.ts ADDED
@@ -0,0 +1,342 @@
1
+ import type { AnyContour } from './contour.js';
2
+ import { getContourReferences } from './contour.js';
3
+ import { ValidationError } from './errors.js';
4
+ import type { AnySignal } from './signal.js';
5
+ import type { AnyResource } from './resource.js';
6
+ import { Result } from './result.js';
7
+ import type { Topo } from './topo.js';
8
+ import type { AnyTrail } from './trail.js';
9
+
10
+ export const DRAFT_ID_PREFIX = '_draft.';
11
+
12
+ export type DraftDependencyKind =
13
+ | 'compose'
14
+ | 'contour'
15
+ | 'resource'
16
+ | 'replaced-by'
17
+ | 'schema-reference'
18
+ | 'signal-fire'
19
+ | 'signal-on'
20
+ | 'signal-from';
21
+
22
+ export interface DraftDependency {
23
+ readonly fromId: string;
24
+ readonly kind: DraftDependencyKind;
25
+ readonly toId: string;
26
+ }
27
+
28
+ export interface DraftFinding {
29
+ readonly id: string;
30
+ readonly kind: 'contour' | 'resource' | 'signal' | 'trail' | 'unknown';
31
+ readonly message: string;
32
+ readonly rule: 'draft-contamination' | 'draft-id';
33
+ readonly via?: DraftDependencyKind | undefined;
34
+ readonly dependsOn?: string | undefined;
35
+ }
36
+
37
+ export interface DraftReport {
38
+ readonly contaminatedIds: ReadonlySet<string>;
39
+ readonly declaredDraftIds: ReadonlySet<string>;
40
+ readonly dependencies: readonly DraftDependency[];
41
+ readonly findings: readonly DraftFinding[];
42
+ }
43
+
44
+ interface DraftReason {
45
+ readonly dependsOn: string;
46
+ readonly via: DraftDependencyKind;
47
+ }
48
+
49
+ type TopoNode = AnyContour | AnyResource | AnySignal | AnyTrail;
50
+
51
+ const replacedByTarget = (value: TopoNode): string | undefined => {
52
+ const raw = value as unknown as { replacedBy?: unknown };
53
+ return typeof raw.replacedBy === 'string' ? raw.replacedBy : undefined;
54
+ };
55
+
56
+ export const isDraftId = (id: string): boolean =>
57
+ id.startsWith(DRAFT_ID_PREFIX);
58
+
59
+ const dependenciesFromIds = (
60
+ fromId: string,
61
+ toIds: readonly string[],
62
+ kind: DraftDependencyKind
63
+ ): DraftDependency[] =>
64
+ toIds.map((toId) => ({
65
+ fromId,
66
+ kind,
67
+ toId,
68
+ }));
69
+
70
+ const dependencyFromTarget = (
71
+ fromId: string,
72
+ toId: string | undefined,
73
+ kind: DraftDependencyKind
74
+ ): DraftDependency[] =>
75
+ toId === undefined
76
+ ? []
77
+ : [
78
+ {
79
+ fromId,
80
+ kind,
81
+ toId,
82
+ },
83
+ ];
84
+
85
+ const trailDependencies = (trail: AnyTrail): DraftDependency[] => [
86
+ ...dependenciesFromIds(
87
+ trail.id,
88
+ (trail.contours ?? []).map((contour) => contour.name),
89
+ 'contour'
90
+ ),
91
+ ...dependenciesFromIds(trail.id, trail.composes, 'compose'),
92
+ ...dependenciesFromIds(
93
+ trail.id,
94
+ trail.resources.map(({ id }) => id),
95
+ 'resource'
96
+ ),
97
+ ...dependenciesFromIds(trail.id, trail.fires ?? [], 'signal-fire'),
98
+ ...dependenciesFromIds(trail.id, trail.on ?? [], 'signal-on'),
99
+ ...dependencyFromTarget(trail.id, replacedByTarget(trail), 'replaced-by'),
100
+ ];
101
+
102
+ const contourDependencies = (contour: AnyContour): DraftDependency[] =>
103
+ dependenciesFromIds(
104
+ contour.name,
105
+ getContourReferences(contour).map((reference) => reference.contour),
106
+ 'schema-reference'
107
+ );
108
+
109
+ const signalDependencies = (signal: AnySignal): DraftDependency[] => [
110
+ ...dependenciesFromIds(signal.id, signal.from ?? [], 'signal-from'),
111
+ ...dependencyFromTarget(signal.id, replacedByTarget(signal), 'replaced-by'),
112
+ ];
113
+
114
+ const resourceDependencies = (resource: AnyResource): DraftDependency[] =>
115
+ dependencyFromTarget(resource.id, replacedByTarget(resource), 'replaced-by');
116
+
117
+ const nodeKind = (
118
+ id: string,
119
+ contours: ReadonlyMap<string, AnyContour>,
120
+ trails: ReadonlyMap<string, AnyTrail>,
121
+ signals: ReadonlyMap<string, AnySignal>,
122
+ resources: ReadonlyMap<string, AnyResource>
123
+ ): DraftFinding['kind'] => {
124
+ if (contours.has(id)) {
125
+ return 'contour';
126
+ }
127
+ if (trails.has(id)) {
128
+ return 'trail';
129
+ }
130
+ if (signals.has(id)) {
131
+ return 'signal';
132
+ }
133
+ if (resources.has(id)) {
134
+ return 'resource';
135
+ }
136
+ return 'unknown';
137
+ };
138
+
139
+ const displayKind = (kind: DraftFinding['kind']): string =>
140
+ kind === 'unknown' ? 'Node' : kind[0]?.toUpperCase() + kind.slice(1);
141
+
142
+ const draftIdsFromKeys = (keys: Iterable<string>): string[] =>
143
+ [...keys].filter(isDraftId);
144
+
145
+ const collectDeclaredDraftIds = (topo: Topo): ReadonlySet<string> =>
146
+ new Set([
147
+ ...draftIdsFromKeys(topo.contours.keys()),
148
+ ...draftIdsFromKeys(topo.trails.keys()),
149
+ ...draftIdsFromKeys(topo.signals.keys()),
150
+ ...draftIdsFromKeys(topo.resources.keys()),
151
+ ]);
152
+
153
+ const findingForDraftId = (
154
+ id: string,
155
+ kind: DraftFinding['kind']
156
+ ): DraftFinding => ({
157
+ id,
158
+ kind,
159
+ message: `${displayKind(id ? kind : 'unknown')} "${id}" is draft and cannot appear in the established graph.`,
160
+ rule: 'draft-id',
161
+ });
162
+
163
+ const findingForContamination = (
164
+ id: string,
165
+ kind: DraftFinding['kind'],
166
+ reason: DraftReason
167
+ ): DraftFinding => {
168
+ const dependencyLabel = isDraftId(reason.dependsOn)
169
+ ? `draft "${reason.dependsOn}"`
170
+ : `draft-contaminated "${reason.dependsOn}"`;
171
+
172
+ return {
173
+ dependsOn: reason.dependsOn,
174
+ id,
175
+ kind,
176
+ message:
177
+ `Established ${kind} "${id}" depends on ${dependencyLabel} ` +
178
+ `via ${reason.via} and cannot appear in the established graph.`,
179
+ rule: 'draft-contamination',
180
+ via: reason.via,
181
+ };
182
+ };
183
+
184
+ const contaminationReason = (
185
+ dependency: DraftDependency,
186
+ contaminatedIds: ReadonlySet<string>
187
+ ): DraftReason | undefined => {
188
+ if (contaminatedIds.has(dependency.fromId)) {
189
+ return undefined;
190
+ }
191
+
192
+ if (!isDraftId(dependency.toId) && !contaminatedIds.has(dependency.toId)) {
193
+ return undefined;
194
+ }
195
+
196
+ return {
197
+ dependsOn: dependency.toId,
198
+ via: dependency.kind,
199
+ };
200
+ };
201
+
202
+ const markContaminatedDependency = (
203
+ dependency: DraftDependency,
204
+ contaminatedIds: Set<string>,
205
+ reasons: Map<string, DraftReason>
206
+ ): boolean => {
207
+ const reason = contaminationReason(dependency, contaminatedIds);
208
+
209
+ if (reason === undefined) {
210
+ return false;
211
+ }
212
+
213
+ contaminatedIds.add(dependency.fromId);
214
+ reasons.set(dependency.fromId, reason);
215
+ return true;
216
+ };
217
+
218
+ const propagateContaminatedIds = (
219
+ declaredDraftIds: ReadonlySet<string>,
220
+ dependencies: readonly DraftDependency[]
221
+ ): { contaminatedIds: Set<string>; reasons: Map<string, DraftReason> } => {
222
+ const contaminatedIds = new Set<string>(declaredDraftIds);
223
+ const reasons = new Map<string, DraftReason>();
224
+
225
+ const visit = (): void => {
226
+ if (
227
+ dependencies.some((dependency) =>
228
+ markContaminatedDependency(dependency, contaminatedIds, reasons)
229
+ )
230
+ ) {
231
+ visit();
232
+ }
233
+ };
234
+
235
+ visit();
236
+ return { contaminatedIds, reasons };
237
+ };
238
+
239
+ const contaminationFindingForId = (
240
+ id: string,
241
+ declaredDraftIds: ReadonlySet<string>,
242
+ reasons: ReadonlyMap<string, DraftReason>,
243
+ contours: ReadonlyMap<string, AnyContour>,
244
+ trails: ReadonlyMap<string, AnyTrail>,
245
+ signals: ReadonlyMap<string, AnySignal>,
246
+ resources: ReadonlyMap<string, AnyResource>
247
+ ): DraftFinding | undefined => {
248
+ if (declaredDraftIds.has(id)) {
249
+ return undefined;
250
+ }
251
+
252
+ const reason = reasons.get(id);
253
+ if (reason === undefined) {
254
+ return undefined;
255
+ }
256
+
257
+ return findingForContamination(
258
+ id,
259
+ nodeKind(id, contours, trails, signals, resources),
260
+ reason
261
+ );
262
+ };
263
+
264
+ const collectFindings = (
265
+ declaredDraftIds: ReadonlySet<string>,
266
+ contaminatedIds: ReadonlySet<string>,
267
+ reasons: ReadonlyMap<string, DraftReason>,
268
+ contours: ReadonlyMap<string, AnyContour>,
269
+ trails: ReadonlyMap<string, AnyTrail>,
270
+ signals: ReadonlyMap<string, AnySignal>,
271
+ resources: ReadonlyMap<string, AnyResource>
272
+ ): DraftFinding[] => [
273
+ ...[...declaredDraftIds]
274
+ .toSorted()
275
+ .map((id) =>
276
+ findingForDraftId(id, nodeKind(id, contours, trails, signals, resources))
277
+ ),
278
+ ...[...contaminatedIds].toSorted().flatMap((id) => {
279
+ const finding = contaminationFindingForId(
280
+ id,
281
+ declaredDraftIds,
282
+ reasons,
283
+ contours,
284
+ trails,
285
+ signals,
286
+ resources
287
+ );
288
+
289
+ return finding === undefined ? [] : [finding];
290
+ }),
291
+ ];
292
+
293
+ const collectDependencies = (topo: Topo): DraftDependency[] => [
294
+ ...[...topo.contours.values()].flatMap(contourDependencies),
295
+ ...[...topo.trails.values()].flatMap(trailDependencies),
296
+ ...[...topo.signals.values()].flatMap(signalDependencies),
297
+ ...[...topo.resources.values()].flatMap(resourceDependencies),
298
+ ];
299
+
300
+ export const deriveDraftReport = (topo: Topo): DraftReport => {
301
+ const declaredDraftIds = collectDeclaredDraftIds(topo);
302
+ const dependencies = collectDependencies(topo);
303
+ const { contaminatedIds, reasons } = propagateContaminatedIds(
304
+ declaredDraftIds,
305
+ dependencies
306
+ );
307
+ const findings = collectFindings(
308
+ declaredDraftIds,
309
+ contaminatedIds,
310
+ reasons,
311
+ topo.contours,
312
+ topo.trails,
313
+ topo.signals,
314
+ topo.resources
315
+ );
316
+
317
+ return {
318
+ contaminatedIds,
319
+ declaredDraftIds,
320
+ dependencies,
321
+ findings,
322
+ };
323
+ };
324
+
325
+ export const validateDraftFreeTopo = (
326
+ topo: Topo
327
+ ): Result<void, ValidationError> => {
328
+ const analysis = deriveDraftReport(topo);
329
+
330
+ if (analysis.findings.length === 0) {
331
+ return Result.ok();
332
+ }
333
+
334
+ return Result.err(
335
+ new ValidationError(
336
+ `Established topo validation failed with ${analysis.findings.length} draft issue(s)`,
337
+ {
338
+ context: { issues: analysis.findings },
339
+ }
340
+ )
341
+ );
342
+ };
@@ -0,0 +1,51 @@
1
+ import type { ErrorCategory } from './errors.js';
2
+ import { isTrailsError } from './errors.js';
3
+ import { createRedactor } from './redaction/index.js';
4
+
5
+ const errorRedactor = createRedactor();
6
+
7
+ export const INTERNAL_ERROR_PUBLIC_MESSAGE = 'Internal server error';
8
+
9
+ export interface ErrorDiagnosticsProjection {
10
+ readonly category?: ErrorCategory | undefined;
11
+ readonly context?: Record<string, unknown> | undefined;
12
+ readonly message: string;
13
+ readonly name: string;
14
+ readonly retryable?: boolean | undefined;
15
+ readonly stack?: string | undefined;
16
+ }
17
+
18
+ export const redactErrorString = (value: string): string =>
19
+ errorRedactor.redact(value);
20
+
21
+ export const redactErrorContext = (
22
+ context: Record<string, unknown> | undefined
23
+ ): Record<string, unknown> | undefined =>
24
+ context === undefined ? undefined : errorRedactor.redactObject(context);
25
+
26
+ export const redactErrorStack = (
27
+ stack: string | undefined
28
+ ): string | undefined =>
29
+ stack === undefined ? undefined : redactErrorString(stack);
30
+
31
+ export const projectErrorDiagnostics = (
32
+ error: Error
33
+ ): ErrorDiagnosticsProjection => {
34
+ const context = isTrailsError(error)
35
+ ? redactErrorContext(error.context)
36
+ : undefined;
37
+ const stack = redactErrorStack(error.stack);
38
+
39
+ return {
40
+ ...(isTrailsError(error)
41
+ ? {
42
+ category: error.category,
43
+ retryable: error.retryable,
44
+ }
45
+ : {}),
46
+ ...(context === undefined ? {} : { context }),
47
+ message: redactErrorString(error.message),
48
+ name: error.name || error.constructor.name || 'Error',
49
+ ...(stack === undefined ? {} : { stack }),
50
+ };
51
+ };