@ontrails/core 1.0.0-beta.15 → 1.0.0-beta.16

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 (348) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +61 -16
  3. package/package.json +9 -8
  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/context.ts +4 -0
  10. package/src/{internal/cross-batch.ts → cross-batch.ts} +3 -11
  11. package/src/detours.ts +8 -0
  12. package/src/draft.ts +4 -0
  13. package/src/error-projection.ts +51 -0
  14. package/src/errors.ts +192 -39
  15. package/src/execute.ts +443 -44
  16. package/src/fire.ts +965 -91
  17. package/src/index.ts +285 -38
  18. package/src/layer-projection.ts +192 -0
  19. package/src/layer.ts +32 -1
  20. package/src/observe.ts +361 -0
  21. package/src/permits.ts +12 -0
  22. package/src/redaction/patterns.ts +6 -3
  23. package/src/resilience.ts +8 -8
  24. package/src/resource-config.ts +477 -44
  25. package/src/resource.ts +21 -1
  26. package/src/result.ts +18 -0
  27. package/src/run.ts +2 -2
  28. package/src/schedule-runtime.ts +689 -0
  29. package/src/schedule.ts +324 -0
  30. package/src/serialization.ts +90 -57
  31. package/src/signal-diagnostics.ts +633 -0
  32. package/src/{internal/signal-ref.ts → signal-ref.ts} +1 -1
  33. package/src/signal.ts +37 -0
  34. package/src/store/accessor-protocol.ts +5 -5
  35. package/src/structured-examples.ts +245 -0
  36. package/src/surface-derivation.ts +91 -0
  37. package/src/surface-filter.ts +7 -13
  38. package/src/topo.ts +386 -6
  39. package/src/tracing.ts +345 -0
  40. package/src/trail.ts +168 -22
  41. package/src/trails/derive-trail.ts +1 -1
  42. package/src/trails/ingest.ts +7 -15
  43. package/src/{internal/trails-db.ts → trails-db.ts} +42 -21
  44. package/src/transport-error-map.ts +146 -28
  45. package/src/types.ts +82 -28
  46. package/src/validate-established-topo.ts +6 -0
  47. package/src/validate-topo.ts +212 -5
  48. package/src/validation.ts +58 -1
  49. package/src/webhook.ts +276 -0
  50. package/src/{internal/zod-wrappers.ts → zod-wrappers.ts} +0 -6
  51. package/.turbo/turbo-build.log +0 -1
  52. package/.turbo/turbo-lint.log +0 -3
  53. package/.turbo/turbo-typecheck.log +0 -1
  54. package/dist/blob-ref.d.ts +0 -20
  55. package/dist/blob-ref.d.ts.map +0 -1
  56. package/dist/blob-ref.js +0 -22
  57. package/dist/blob-ref.js.map +0 -1
  58. package/dist/branded.d.ts +0 -36
  59. package/dist/branded.d.ts.map +0 -1
  60. package/dist/branded.js +0 -89
  61. package/dist/branded.js.map +0 -1
  62. package/dist/collections.d.ts +0 -31
  63. package/dist/collections.d.ts.map +0 -1
  64. package/dist/collections.js +0 -60
  65. package/dist/collections.js.map +0 -1
  66. package/dist/context.d.ts +0 -22
  67. package/dist/context.d.ts.map +0 -1
  68. package/dist/context.js +0 -37
  69. package/dist/context.js.map +0 -1
  70. package/dist/contour.d.ts +0 -81
  71. package/dist/contour.d.ts.map +0 -1
  72. package/dist/contour.js +0 -176
  73. package/dist/contour.js.map +0 -1
  74. package/dist/cross-schema.d.ts +0 -18
  75. package/dist/cross-schema.d.ts.map +0 -1
  76. package/dist/cross-schema.js +0 -29
  77. package/dist/cross-schema.js.map +0 -1
  78. package/dist/derive.d.ts +0 -44
  79. package/dist/derive.d.ts.map +0 -1
  80. package/dist/derive.js +0 -149
  81. package/dist/derive.js.map +0 -1
  82. package/dist/draft.d.ts +0 -28
  83. package/dist/draft.d.ts.map +0 -1
  84. package/dist/draft.js +0 -157
  85. package/dist/draft.js.map +0 -1
  86. package/dist/errors.d.ts +0 -141
  87. package/dist/errors.d.ts.map +0 -1
  88. package/dist/errors.js +0 -185
  89. package/dist/errors.js.map +0 -1
  90. package/dist/execute.d.ts +0 -52
  91. package/dist/execute.d.ts.map +0 -1
  92. package/dist/execute.js +0 -511
  93. package/dist/execute.js.map +0 -1
  94. package/dist/fetch.d.ts +0 -15
  95. package/dist/fetch.d.ts.map +0 -1
  96. package/dist/fetch.js +0 -102
  97. package/dist/fetch.js.map +0 -1
  98. package/dist/fire.d.ts +0 -43
  99. package/dist/fire.d.ts.map +0 -1
  100. package/dist/fire.js +0 -185
  101. package/dist/fire.js.map +0 -1
  102. package/dist/guards.d.ts +0 -17
  103. package/dist/guards.d.ts.map +0 -1
  104. package/dist/guards.js +0 -25
  105. package/dist/guards.js.map +0 -1
  106. package/dist/index.d.ts +0 -59
  107. package/dist/index.d.ts.map +0 -1
  108. package/dist/index.js +0 -61
  109. package/dist/index.js.map +0 -1
  110. package/dist/internal/cross-batch.d.ts +0 -44
  111. package/dist/internal/cross-batch.d.ts.map +0 -1
  112. package/dist/internal/cross-batch.js +0 -58
  113. package/dist/internal/cross-batch.js.map +0 -1
  114. package/dist/internal/fork-ctx.d.ts +0 -41
  115. package/dist/internal/fork-ctx.d.ts.map +0 -1
  116. package/dist/internal/fork-ctx.js +0 -47
  117. package/dist/internal/fork-ctx.js.map +0 -1
  118. package/dist/internal/signal-ref.d.ts +0 -15
  119. package/dist/internal/signal-ref.d.ts.map +0 -1
  120. package/dist/internal/signal-ref.js +0 -41
  121. package/dist/internal/signal-ref.js.map +0 -1
  122. package/dist/internal/topo-saves.d.ts +0 -53
  123. package/dist/internal/topo-saves.d.ts.map +0 -1
  124. package/dist/internal/topo-saves.js +0 -449
  125. package/dist/internal/topo-saves.js.map +0 -1
  126. package/dist/internal/topo-snapshots.d.ts +0 -57
  127. package/dist/internal/topo-snapshots.d.ts.map +0 -1
  128. package/dist/internal/topo-snapshots.js +0 -372
  129. package/dist/internal/topo-snapshots.js.map +0 -1
  130. package/dist/internal/topo-store-read.d.ts +0 -70
  131. package/dist/internal/topo-store-read.d.ts.map +0 -1
  132. package/dist/internal/topo-store-read.js +0 -221
  133. package/dist/internal/topo-store-read.js.map +0 -1
  134. package/dist/internal/topo-store.d.ts +0 -26
  135. package/dist/internal/topo-store.d.ts.map +0 -1
  136. package/dist/internal/topo-store.js +0 -621
  137. package/dist/internal/topo-store.js.map +0 -1
  138. package/dist/internal/tracing.d.ts +0 -98
  139. package/dist/internal/tracing.d.ts.map +0 -1
  140. package/dist/internal/tracing.js +0 -102
  141. package/dist/internal/tracing.js.map +0 -1
  142. package/dist/internal/trails-db.d.ts +0 -16
  143. package/dist/internal/trails-db.d.ts.map +0 -1
  144. package/dist/internal/trails-db.js +0 -118
  145. package/dist/internal/trails-db.js.map +0 -1
  146. package/dist/internal/zod-wrappers.d.ts +0 -40
  147. package/dist/internal/zod-wrappers.d.ts.map +0 -1
  148. package/dist/internal/zod-wrappers.js +0 -59
  149. package/dist/internal/zod-wrappers.js.map +0 -1
  150. package/dist/layer.d.ts +0 -23
  151. package/dist/layer.d.ts.map +0 -1
  152. package/dist/layer.js +0 -21
  153. package/dist/layer.js.map +0 -1
  154. package/dist/path-security.d.ts +0 -28
  155. package/dist/path-security.d.ts.map +0 -1
  156. package/dist/path-security.js +0 -63
  157. package/dist/path-security.js.map +0 -1
  158. package/dist/patterns/bulk.d.ts +0 -15
  159. package/dist/patterns/bulk.d.ts.map +0 -1
  160. package/dist/patterns/bulk.js +0 -14
  161. package/dist/patterns/bulk.js.map +0 -1
  162. package/dist/patterns/change.d.ts +0 -10
  163. package/dist/patterns/change.d.ts.map +0 -1
  164. package/dist/patterns/change.js +0 -10
  165. package/dist/patterns/change.js.map +0 -1
  166. package/dist/patterns/date-range.d.ts +0 -10
  167. package/dist/patterns/date-range.d.ts.map +0 -1
  168. package/dist/patterns/date-range.js +0 -10
  169. package/dist/patterns/date-range.js.map +0 -1
  170. package/dist/patterns/index.d.ts +0 -9
  171. package/dist/patterns/index.d.ts.map +0 -1
  172. package/dist/patterns/index.js +0 -9
  173. package/dist/patterns/index.js.map +0 -1
  174. package/dist/patterns/pagination.d.ts +0 -18
  175. package/dist/patterns/pagination.d.ts.map +0 -1
  176. package/dist/patterns/pagination.js +0 -18
  177. package/dist/patterns/pagination.js.map +0 -1
  178. package/dist/patterns/progress.d.ts +0 -11
  179. package/dist/patterns/progress.d.ts.map +0 -1
  180. package/dist/patterns/progress.js +0 -11
  181. package/dist/patterns/progress.js.map +0 -1
  182. package/dist/patterns/sorting.d.ts +0 -13
  183. package/dist/patterns/sorting.d.ts.map +0 -1
  184. package/dist/patterns/sorting.js +0 -10
  185. package/dist/patterns/sorting.js.map +0 -1
  186. package/dist/patterns/status.d.ts +0 -15
  187. package/dist/patterns/status.d.ts.map +0 -1
  188. package/dist/patterns/status.js +0 -9
  189. package/dist/patterns/status.js.map +0 -1
  190. package/dist/patterns/timestamps.d.ts +0 -10
  191. package/dist/patterns/timestamps.d.ts.map +0 -1
  192. package/dist/patterns/timestamps.js +0 -10
  193. package/dist/patterns/timestamps.js.map +0 -1
  194. package/dist/redaction/index.d.ts +0 -4
  195. package/dist/redaction/index.d.ts.map +0 -1
  196. package/dist/redaction/index.js +0 -3
  197. package/dist/redaction/index.js.map +0 -1
  198. package/dist/redaction/patterns.d.ts +0 -9
  199. package/dist/redaction/patterns.d.ts.map +0 -1
  200. package/dist/redaction/patterns.js +0 -39
  201. package/dist/redaction/patterns.js.map +0 -1
  202. package/dist/redaction/redactor.d.ts +0 -27
  203. package/dist/redaction/redactor.d.ts.map +0 -1
  204. package/dist/redaction/redactor.js +0 -89
  205. package/dist/redaction/redactor.js.map +0 -1
  206. package/dist/resilience.d.ts +0 -34
  207. package/dist/resilience.d.ts.map +0 -1
  208. package/dist/resilience.js +0 -164
  209. package/dist/resilience.js.map +0 -1
  210. package/dist/resource-config.d.ts +0 -22
  211. package/dist/resource-config.d.ts.map +0 -1
  212. package/dist/resource-config.js +0 -210
  213. package/dist/resource-config.js.map +0 -1
  214. package/dist/resource.d.ts +0 -74
  215. package/dist/resource.d.ts.map +0 -1
  216. package/dist/resource.js +0 -61
  217. package/dist/resource.js.map +0 -1
  218. package/dist/result.d.ts +0 -57
  219. package/dist/result.d.ts.map +0 -1
  220. package/dist/result.js +0 -156
  221. package/dist/result.js.map +0 -1
  222. package/dist/run.d.ts +0 -28
  223. package/dist/run.d.ts.map +0 -1
  224. package/dist/run.js +0 -35
  225. package/dist/run.js.map +0 -1
  226. package/dist/serialization.d.ts +0 -27
  227. package/dist/serialization.d.ts.map +0 -1
  228. package/dist/serialization.js +0 -154
  229. package/dist/serialization.js.map +0 -1
  230. package/dist/signal.d.ts +0 -33
  231. package/dist/signal.d.ts.map +0 -1
  232. package/dist/signal.js +0 -17
  233. package/dist/signal.js.map +0 -1
  234. package/dist/store/accessor-protocol.d.ts +0 -51
  235. package/dist/store/accessor-protocol.d.ts.map +0 -1
  236. package/dist/store/accessor-protocol.js +0 -2
  237. package/dist/store/accessor-protocol.js.map +0 -1
  238. package/dist/store/index.d.ts +0 -2
  239. package/dist/store/index.d.ts.map +0 -1
  240. package/dist/store/index.js +0 -2
  241. package/dist/store/index.js.map +0 -1
  242. package/dist/surface-filter.d.ts +0 -13
  243. package/dist/surface-filter.d.ts.map +0 -1
  244. package/dist/surface-filter.js +0 -76
  245. package/dist/surface-filter.js.map +0 -1
  246. package/dist/topo-store.d.ts +0 -62
  247. package/dist/topo-store.d.ts.map +0 -1
  248. package/dist/topo-store.js +0 -319
  249. package/dist/topo-store.js.map +0 -1
  250. package/dist/topo.d.ts +0 -39
  251. package/dist/topo.d.ts.map +0 -1
  252. package/dist/topo.js +0 -241
  253. package/dist/topo.js.map +0 -1
  254. package/dist/trail.d.ts +0 -163
  255. package/dist/trail.d.ts.map +0 -1
  256. package/dist/trail.js +0 -75
  257. package/dist/trail.js.map +0 -1
  258. package/dist/trails/derive-trail.d.ts +0 -66
  259. package/dist/trails/derive-trail.d.ts.map +0 -1
  260. package/dist/trails/derive-trail.js +0 -378
  261. package/dist/trails/derive-trail.js.map +0 -1
  262. package/dist/trails/index.d.ts +0 -5
  263. package/dist/trails/index.d.ts.map +0 -1
  264. package/dist/trails/index.js +0 -3
  265. package/dist/trails/index.js.map +0 -1
  266. package/dist/trails/ingest.d.ts +0 -24
  267. package/dist/trails/ingest.d.ts.map +0 -1
  268. package/dist/trails/ingest.js +0 -63
  269. package/dist/trails/ingest.js.map +0 -1
  270. package/dist/transport-error-map.d.ts +0 -106
  271. package/dist/transport-error-map.d.ts.map +0 -1
  272. package/dist/transport-error-map.js +0 -24
  273. package/dist/transport-error-map.js.map +0 -1
  274. package/dist/type-checks.test-d.d.ts +0 -71
  275. package/dist/type-checks.test-d.d.ts.map +0 -1
  276. package/dist/type-checks.test-d.js +0 -12
  277. package/dist/type-checks.test-d.js.map +0 -1
  278. package/dist/type-utils.d.ts +0 -33
  279. package/dist/type-utils.d.ts.map +0 -1
  280. package/dist/type-utils.js +0 -12
  281. package/dist/type-utils.js.map +0 -1
  282. package/dist/types.d.ts +0 -177
  283. package/dist/types.d.ts.map +0 -1
  284. package/dist/types.js +0 -3
  285. package/dist/types.js.map +0 -1
  286. package/dist/validate-established-topo.d.ts +0 -76
  287. package/dist/validate-established-topo.d.ts.map +0 -1
  288. package/dist/validate-established-topo.js +0 -43
  289. package/dist/validate-established-topo.js.map +0 -1
  290. package/dist/validate-topo.d.ts +0 -24
  291. package/dist/validate-topo.d.ts.map +0 -1
  292. package/dist/validate-topo.js +0 -193
  293. package/dist/validate-topo.js.map +0 -1
  294. package/dist/validation.d.ts +0 -27
  295. package/dist/validation.d.ts.map +0 -1
  296. package/dist/validation.js +0 -165
  297. package/dist/validation.js.map +0 -1
  298. package/dist/workspace.d.ts +0 -25
  299. package/dist/workspace.d.ts.map +0 -1
  300. package/dist/workspace.js +0 -57
  301. package/dist/workspace.js.map +0 -1
  302. package/src/__tests__/blob-ref.test.ts +0 -103
  303. package/src/__tests__/branded.test.ts +0 -148
  304. package/src/__tests__/collections.test.ts +0 -126
  305. package/src/__tests__/context.test.ts +0 -77
  306. package/src/__tests__/contour.test.ts +0 -263
  307. package/src/__tests__/derive-trail.test.ts +0 -668
  308. package/src/__tests__/derive.test.ts +0 -260
  309. package/src/__tests__/errors.test.ts +0 -427
  310. package/src/__tests__/execute.test.ts +0 -1563
  311. package/src/__tests__/fetch.test.ts +0 -217
  312. package/src/__tests__/fire.test.ts +0 -1056
  313. package/src/__tests__/fork-ctx.test.ts +0 -83
  314. package/src/__tests__/guards.test.ts +0 -102
  315. package/src/__tests__/ingest.test.ts +0 -191
  316. package/src/__tests__/layer.test.ts +0 -125
  317. package/src/__tests__/path-security.test.ts +0 -114
  318. package/src/__tests__/patterns.test.ts +0 -273
  319. package/src/__tests__/redaction.test.ts +0 -244
  320. package/src/__tests__/resilience.test.ts +0 -246
  321. package/src/__tests__/resource.test.ts +0 -204
  322. package/src/__tests__/result.test.ts +0 -155
  323. package/src/__tests__/run.test.ts +0 -181
  324. package/src/__tests__/serialization.test.ts +0 -401
  325. package/src/__tests__/service-config.test.ts +0 -224
  326. package/src/__tests__/signal.test.ts +0 -82
  327. package/src/__tests__/surface-filter.test.ts +0 -196
  328. package/src/__tests__/topo-store-read.test.ts +0 -256
  329. package/src/__tests__/topo-store.test.ts +0 -869
  330. package/src/__tests__/topo.test.ts +0 -573
  331. package/src/__tests__/trail-permit.test.ts +0 -60
  332. package/src/__tests__/trail.test.ts +0 -426
  333. package/src/__tests__/trails-db.test.ts +0 -198
  334. package/src/__tests__/transport-error-map.test.ts +0 -82
  335. package/src/__tests__/type-utils.test.ts +0 -118
  336. package/src/__tests__/validate-topo.test.ts +0 -611
  337. package/src/__tests__/validation.test.ts +0 -336
  338. package/src/__tests__/workspace.test.ts +0 -183
  339. package/src/__tests__/zod-wrappers.test.ts +0 -62
  340. package/src/internal/topo-snapshots.ts +0 -517
  341. package/src/internal/topo-store-read.ts +0 -481
  342. package/src/internal/topo-store.ts +0 -1192
  343. package/src/internal/tracing.ts +0 -187
  344. package/src/topo-store.ts +0 -510
  345. package/src/type-checks.test-d.ts +0 -106
  346. package/tsconfig.json +0 -9
  347. package/tsconfig.tests.json +0 -10
  348. package/tsconfig.tsbuildinfo +0 -1
package/src/topo.ts CHANGED
@@ -4,10 +4,22 @@
4
4
 
5
5
  import type { AnyContour } from './contour.js';
6
6
  import { ValidationError } from './errors.js';
7
+ import type { ActivationEntry } from './activation-source.js';
7
8
  import {
8
9
  getLateBoundSignalRef,
9
10
  parseLateBoundSignalMarker,
10
- } from './internal/signal-ref.js';
11
+ } from './signal-ref.js';
12
+ import type { Layer } from './layer.js';
13
+ import {
14
+ hasObserveCapabilities,
15
+ isLogger,
16
+ isLogSink,
17
+ isObserveConfig,
18
+ isObserveInput,
19
+ isTraceSink,
20
+ normalizeObserve,
21
+ } from './observe.js';
22
+ import type { ObserveConfig, TopoOptions } from './observe.js';
11
23
  import type { AnySignal } from './signal.js';
12
24
  import type { AnyResource } from './resource.js';
13
25
  import { isResource } from './resource.js';
@@ -31,6 +43,16 @@ export interface Topo {
31
43
  readonly trails: ReadonlyMap<string, AnyTrail>;
32
44
  readonly signals: ReadonlyMap<string, AnySignal>;
33
45
  readonly resources: ReadonlyMap<string, AnyResource>;
46
+ readonly observe?: ObserveConfig | undefined;
47
+ /**
48
+ * Typed layers attached at topo scope (always present, default `[]`).
49
+ *
50
+ * The CLI/MCP/HTTP surfaces forward these into `executeTrail` as
51
+ * `topoLayers`, where they are composed outermost in the layer chain.
52
+ * The final composition order is `topo → surface → trail → blaze`
53
+ * (outermost-first).
54
+ */
55
+ readonly layers: readonly Layer[];
34
56
  readonly count: number;
35
57
  readonly contourCount: number;
36
58
  readonly resourceCount: number;
@@ -72,7 +94,9 @@ const createTopo = (
72
94
  contours: ReadonlyMap<string, AnyContour>,
73
95
  trails: ReadonlyMap<string, AnyTrail>,
74
96
  signals: ReadonlyMap<string, AnySignal>,
75
- resources: ReadonlyMap<string, AnyResource>
97
+ resources: ReadonlyMap<string, AnyResource>,
98
+ observe: ObserveConfig | undefined,
99
+ layers: readonly Layer[]
76
100
  ): Topo => ({
77
101
  contourCount: contours.size,
78
102
  contourIds(): string[] {
@@ -121,6 +145,8 @@ const createTopo = (
121
145
  ...(identity.description !== undefined && {
122
146
  description: identity.description,
123
147
  }),
148
+ ...(observe !== undefined && { observe }),
149
+ layers,
124
150
  resourceCount: resources.size,
125
151
  resourceIds(): string[] {
126
152
  return [...resources.keys()];
@@ -287,6 +313,38 @@ const resolveTrailSignalIds = (
287
313
  return { changed, ids };
288
314
  };
289
315
 
316
+ const resolveTrailActivationSources = (
317
+ trailId: string,
318
+ activations: readonly ActivationEntry[],
319
+ lateBoundSignalIdsByToken: ReadonlyMap<string, readonly string[]>
320
+ ): { changed: boolean; activations: readonly ActivationEntry[] } => {
321
+ let changed = false;
322
+ const resolved = Object.freeze(
323
+ activations.map((entry) => {
324
+ if (entry.source.kind !== 'signal') {
325
+ return entry;
326
+ }
327
+
328
+ const resolvedId = resolveLateBoundSignalId(
329
+ trailId,
330
+ entry.source.id,
331
+ lateBoundSignalIdsByToken
332
+ );
333
+ if (resolvedId === entry.source.id) {
334
+ return entry;
335
+ }
336
+
337
+ changed = true;
338
+ return Object.freeze({
339
+ ...entry,
340
+ source: Object.freeze({ ...entry.source, id: resolvedId }),
341
+ });
342
+ })
343
+ );
344
+
345
+ return { activations: resolved, changed };
346
+ };
347
+
290
348
  const finalizeTrailSignals = (
291
349
  trails: ReadonlyMap<string, AnyTrail>,
292
350
  resources: ReadonlyMap<string, AnyResource>
@@ -305,8 +363,17 @@ const finalizeTrailSignals = (
305
363
  trail.on ?? [],
306
364
  lateBoundSignalIdsByToken
307
365
  );
366
+ const resolvedActivationSources = resolveTrailActivationSources(
367
+ trail.id,
368
+ trail.activationSources ?? [],
369
+ lateBoundSignalIdsByToken
370
+ );
308
371
 
309
- if (!resolvedFires.changed && !resolvedOn.changed) {
372
+ if (
373
+ !resolvedFires.changed &&
374
+ !resolvedOn.changed &&
375
+ !resolvedActivationSources.changed
376
+ ) {
310
377
  finalized.set(trail.id, trail);
311
378
  continue;
312
379
  }
@@ -315,6 +382,7 @@ const finalizeTrailSignals = (
315
382
  trail.id,
316
383
  Object.freeze({
317
384
  ...trail,
385
+ activationSources: resolvedActivationSources.activations,
318
386
  fires: resolvedFires.ids,
319
387
  on: resolvedOn.ids,
320
388
  })
@@ -427,14 +495,320 @@ const registerModuleValues = (
427
495
  }
428
496
  };
429
497
 
430
- export const topo = (
498
+ const TOPO_OPTION_KEYS = ['layers', 'observe'] as const;
499
+ const TOPO_OPTION_KEY_SET: ReadonlySet<string> = new Set(TOPO_OPTION_KEYS);
500
+
501
+ /**
502
+ * Brand symbol applied by `topo.options()`. The presence of this symbol
503
+ * marks an object as an explicit `TopoOptions` payload, which is the
504
+ * unambiguous way to disambiguate a trailing options object from a
505
+ * trailing module export. Use `topo.options()` whenever a module might
506
+ * legitimately export only fields whose names collide with topo options
507
+ * (for example a module whose sole export is `observe`).
508
+ */
509
+ const TOPO_OPTIONS_BRAND: unique symbol = Symbol('trails.topo.options');
510
+
511
+ const hasOptionsBrand = (value: object): boolean =>
512
+ (value as { [TOPO_OPTIONS_BRAND]?: true })[TOPO_OPTIONS_BRAND] === true;
513
+
514
+ const looksLikeTopoOptionsShape = (value: object): boolean => {
515
+ const keys = Object.keys(value);
516
+ if (keys.length === 0) {
517
+ return false;
518
+ }
519
+ return keys.every((key) => TOPO_OPTION_KEY_SET.has(key));
520
+ };
521
+
522
+ const detectUnknownOptionKeys = (value: object): readonly string[] => {
523
+ const unknown: string[] = [];
524
+ for (const key of Object.keys(value)) {
525
+ if (!TOPO_OPTION_KEY_SET.has(key)) {
526
+ unknown.push(key);
527
+ }
528
+ }
529
+ return unknown;
530
+ };
531
+
532
+ const hasRegistrableKind = (value: unknown): boolean => {
533
+ if (typeof value !== 'object' || value === null) {
534
+ return false;
535
+ }
536
+ const { kind } = value as { kind?: unknown };
537
+ return (
538
+ kind === 'contour' ||
539
+ kind === 'trail' ||
540
+ kind === 'signal' ||
541
+ kind === 'resource'
542
+ );
543
+ };
544
+
545
+ /**
546
+ * When a branded `topo.options()` payload carries a bare `LogSink` in the
547
+ * `observe` slot, rewrite it to the explicit `{ log: sink }` form. The brand
548
+ * already signals "this is options, not a module"; once that is settled, a
549
+ * bare `LogSink` unambiguously names a log target and should not be rejected
550
+ * as ambiguous downstream.
551
+ *
552
+ * Bare `TraceSink` values (no `name` field) are left untouched — they already
553
+ * round-trip through `normalizeObserve` via the `isTraceSink` fallthrough.
554
+ * Already-disambiguated shapes (`{ log }`, `{ trace }`, `Logger`,
555
+ * `ObserveCapable`, etc.) are also left untouched.
556
+ */
557
+ const disambiguateBrandedObserve = (options: TopoOptions): TopoOptions => {
558
+ const { observe } = options;
559
+ if (
560
+ observe === undefined ||
561
+ isLogger(observe) ||
562
+ isObserveConfig(observe) ||
563
+ hasObserveCapabilities(observe)
564
+ ) {
565
+ return options;
566
+ }
567
+ if (isLogSink(observe)) {
568
+ return { ...options, observe: { log: observe } };
569
+ }
570
+ return options;
571
+ };
572
+
573
+ /**
574
+ * Decide whether the trailing argument should be treated as a
575
+ * `TopoOptions` payload, a module export, or rejected as ambiguous.
576
+ *
577
+ * Resolution rules (in order):
578
+ * 1. Branded via `topo.options()` → always options. Unknown option
579
+ * keys throw, and downstream `normalizeObserve` rejects malformed
580
+ * values. A bare `LogSink` (`{ name, write }`) in the `observe`
581
+ * slot is auto-routed to `{ log: sink }` so the brand is the
582
+ * complete escape hatch the docs promise.
583
+ * 2. The shape does not look like `TopoOptions` (mixed keys or no
584
+ * keys) → module.
585
+ * 3. The trailing arg is a registrable module export
586
+ * (`kind: 'trail' | 'contour' | …`) under a known option key →
587
+ * module. Preserves the "module exporting a single trail named
588
+ * `observe`" case that the warden and existing apps rely on.
589
+ * 4. The `observe` value is a bare `LogSink` or `TraceSink` (a sink
590
+ * shape that could equally plausibly be a module export named
591
+ * `observe`) → throw, since the call is genuinely ambiguous.
592
+ * `topo.options()` exists to disambiguate.
593
+ * 5. The `observe` value is otherwise a recognizable `ObserveInput`
594
+ * (`Logger`, `ObserveConfig`, or `ObserveCapable`) → options.
595
+ * Those shapes carry enough structure that they cannot be
596
+ * mistaken for a generic module export.
597
+ * 6. Otherwise (non-sink helper object, function, primitive, etc.)
598
+ * → module. The non-registrable export is silently ignored, the
599
+ * same as any other unrecognized value in a module record.
600
+ */
601
+ const classifyTrailingArgument = (
602
+ value: unknown
603
+ ):
604
+ | { readonly kind: 'options'; readonly options: TopoOptions }
605
+ | { readonly kind: 'module' }
606
+ | { readonly kind: 'invalid'; readonly message: string } => {
607
+ if (typeof value !== 'object' || value === null) {
608
+ return { kind: 'module' };
609
+ }
610
+
611
+ if (hasOptionsBrand(value)) {
612
+ const unknown = detectUnknownOptionKeys(value);
613
+ if (unknown.length > 0) {
614
+ return {
615
+ kind: 'invalid',
616
+ message: `topo.options() received unknown option keys: ${unknown
617
+ .map((key) => `"${key}"`)
618
+ .join(', ')}. Expected one of: ${TOPO_OPTION_KEYS.map(
619
+ (key) => `"${key}"`
620
+ ).join(', ')}.`,
621
+ };
622
+ }
623
+ // Branding via `topo.options()` is the documented escape hatch for
624
+ // disambiguating bare-sink shorthand. Route a bare `LogSink` into the
625
+ // explicit `{ log: sink }` slot before handing off to `normalizeObserve`,
626
+ // which would otherwise reject it as ambiguous (a LogSink shape matches
627
+ // both `isLogSink` and `isTraceSink`). A bare TraceSink (no `name`) does
628
+ // not need rewriting because `normalizeObserve` already routes it via
629
+ // the `isTraceSink` fallthrough.
630
+ return {
631
+ kind: 'options',
632
+ options: disambiguateBrandedObserve(value as TopoOptions),
633
+ };
634
+ }
635
+
636
+ if (!looksLikeTopoOptionsShape(value)) {
637
+ return { kind: 'module' };
638
+ }
639
+
640
+ // The shape matches `TopoOptions`. Decide whether the values are
641
+ // valid options, a registrable module export, or a non-registrable
642
+ // helper that should be treated as a (silently ignored) module
643
+ // export.
644
+ const observeValue = (value as TopoOptions).observe;
645
+ const layersValue = (value as TopoOptions).layers;
646
+
647
+ // A module export named `layers` would be a non-registrable array, but
648
+ // an explicit `topo.options({ layers: [...] })` is the documented escape
649
+ // hatch for that disambiguation. Without the brand, accept the trailing
650
+ // argument as options when `layers` is an array and `observe` is either
651
+ // unset or already an unambiguous option shape — this mirrors how
652
+ // `observe`-only options are accepted today.
653
+ if (Array.isArray(layersValue) && observeValue === undefined) {
654
+ return { kind: 'options', options: value as TopoOptions };
655
+ }
656
+
657
+ if (hasRegistrableKind(observeValue)) {
658
+ return { kind: 'module' };
659
+ }
660
+ // Unambiguous option shapes: `Logger`, `ObserveConfig`, and
661
+ // `ObserveCapable` carry enough structure that they cannot be
662
+ // confused with a generic module export. Check these first so the
663
+ // sink-ambiguity guard below does not accidentally reject an
664
+ // `ObserveCapable` whose underlying shape happens to also satisfy
665
+ // `isLogSink` / `isTraceSink`.
666
+ if (
667
+ isLogger(observeValue) ||
668
+ isObserveConfig(observeValue) ||
669
+ hasObserveCapabilities(observeValue)
670
+ ) {
671
+ return { kind: 'options', options: value as TopoOptions };
672
+ }
673
+ // Bare sink shapes (`{ write }` / `{ name, write }`) are genuinely
674
+ // ambiguous — they may equally plausibly be a module export named
675
+ // `observe`. Refuse to guess; require `topo.options()` to make the
676
+ // intent explicit.
677
+ if (isLogSink(observeValue) || isTraceSink(observeValue)) {
678
+ return {
679
+ kind: 'invalid',
680
+ message:
681
+ 'topo() received a trailing argument shaped like `{ observe: sink }` that is ambiguous: ' +
682
+ 'the value matches both a TopoOptions sink and a non-registrable module export. ' +
683
+ 'Wrap the options with `topo.options({ observe: sink })` to disambiguate.',
684
+ };
685
+ }
686
+ if (isObserveInput(observeValue)) {
687
+ // Catch-all for any future `ObserveInput` variant added to the
688
+ // type. Today this branch is unreachable given the guards above.
689
+ return { kind: 'options', options: value as TopoOptions };
690
+ }
691
+ // Non-registrable, non-sink helper. Treat as a module export; the
692
+ // unrecognized value is silently ignored during registration, matching
693
+ // the behavior of any other non-registrable export.
694
+ return { kind: 'module' };
695
+ };
696
+
697
+ const splitTopoArguments = (
698
+ modulesOrOptions: readonly (Record<string, unknown> | TopoOptions)[]
699
+ ): {
700
+ readonly modules: readonly Record<string, unknown>[];
701
+ readonly options: TopoOptions | undefined;
702
+ } => {
703
+ // A branded `topo.options(...)` payload is an explicit user signal and must
704
+ // appear last. If it shows up in a non-trailing position the caller almost
705
+ // certainly intended it as options but lost the configuration silently.
706
+ // Reject it so the misconfiguration is visible at construction.
707
+ for (let i = 0; i < modulesOrOptions.length - 1; i += 1) {
708
+ const arg = modulesOrOptions[i];
709
+ if (typeof arg === 'object' && arg !== null && hasOptionsBrand(arg)) {
710
+ throw new ValidationError(
711
+ `topo.options(...) must be the final argument to topo(); received at position ${i + 2} of ${modulesOrOptions.length + 1}.`
712
+ );
713
+ }
714
+ }
715
+
716
+ const last = modulesOrOptions.at(-1);
717
+ const classification = classifyTrailingArgument(last);
718
+
719
+ if (classification.kind === 'invalid') {
720
+ throw new ValidationError(classification.message);
721
+ }
722
+ if (classification.kind === 'options') {
723
+ return {
724
+ modules: modulesOrOptions.slice(0, -1) as Record<string, unknown>[],
725
+ options: classification.options,
726
+ };
727
+ }
728
+ return {
729
+ modules: modulesOrOptions as readonly Record<string, unknown>[],
730
+ options: undefined,
731
+ };
732
+ };
733
+
734
+ /**
735
+ * Brand a plain `TopoOptions` payload so `topo()` treats the trailing
736
+ * argument as options unambiguously, regardless of which keys it
737
+ * contains. Useful when a module export shape would otherwise collide
738
+ * with the inline shorthand (e.g. a module exporting only `observe`).
739
+ *
740
+ * @example
741
+ * ```ts
742
+ * topo('app', userTrails, topo.options({ observe: traceSink }));
743
+ * ```
744
+ */
745
+ const describeNonPlainObject = (value: unknown): string => {
746
+ if (value === null) {
747
+ return 'null';
748
+ }
749
+ if (Array.isArray(value)) {
750
+ return 'array';
751
+ }
752
+ return typeof value;
753
+ };
754
+
755
+ const brandTopoOptions = (options: TopoOptions): TopoOptions => {
756
+ // Reject non-plain-object inputs up front. `{ ...options }` happily
757
+ // accepts `null`, `undefined`, primitives, and arrays, silently
758
+ // producing an empty branded payload that callers would then assume
759
+ // carried real options. Throwing here mirrors the strict handling
760
+ // applied to other malformed options elsewhere in the classifier.
761
+ if (
762
+ typeof options !== 'object' ||
763
+ options === null ||
764
+ Array.isArray(options)
765
+ ) {
766
+ throw new ValidationError(
767
+ `topo.options() expects a plain options object; received ${describeNonPlainObject(
768
+ options
769
+ )}`
770
+ );
771
+ }
772
+ // Return a fresh object rather than mutating the caller's payload.
773
+ // Mutating in place breaks frozen / non-extensible inputs (for example
774
+ // `topo.options(Object.freeze({ observe: sink }))`), which would throw
775
+ // a `TypeError` from `Object.defineProperty` even though the value is
776
+ // a valid `TopoOptions` shape.
777
+ const branded = { ...options };
778
+ Object.defineProperty(branded, TOPO_OPTIONS_BRAND, {
779
+ configurable: false,
780
+ enumerable: false,
781
+ value: true,
782
+ writable: false,
783
+ });
784
+ return branded;
785
+ };
786
+
787
+ interface TopoFn {
788
+ (
789
+ nameOrIdentity: string | TopoIdentity,
790
+ ...modulesOrOptions: (Record<string, unknown> | TopoOptions)[]
791
+ ): Topo;
792
+ /**
793
+ * Brand a plain `TopoOptions` payload so `topo()` treats the trailing
794
+ * argument as options unambiguously, regardless of key shape. Use this
795
+ * when a module export shape might otherwise collide with the inline
796
+ * options shorthand.
797
+ */
798
+ readonly options: (options: TopoOptions) => TopoOptions;
799
+ }
800
+
801
+ const topoImpl = (
431
802
  nameOrIdentity: string | TopoIdentity,
432
- ...modules: Record<string, unknown>[]
803
+ ...modulesOrOptions: (Record<string, unknown> | TopoOptions)[]
433
804
  ): Topo => {
434
805
  const identity: TopoIdentity =
435
806
  typeof nameOrIdentity === 'string'
436
807
  ? { name: nameOrIdentity }
437
808
  : nameOrIdentity;
809
+ const { modules, options } = splitTopoArguments(modulesOrOptions);
810
+ const observe = normalizeObserve(options?.observe);
811
+ const layers = Object.freeze([...(options?.layers ?? [])]);
438
812
 
439
813
  const contours = new Map<string, AnyContour>();
440
814
  const trails = new Map<string, AnyTrail>();
@@ -450,6 +824,12 @@ export const topo = (
450
824
  contours,
451
825
  finalizeTrailSignals(trails, resources),
452
826
  signals,
453
- resources
827
+ resources,
828
+ observe,
829
+ layers
454
830
  );
455
831
  };
832
+
833
+ export const topo: TopoFn = Object.assign(topoImpl, {
834
+ options: brandTopoOptions,
835
+ });