@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/fire.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Signal emission and auto-activation.
3
3
  *
4
- * `createFireFn(topo, producerCtx?, executor)` returns a `FireFn` bound to
5
- * a topo. Calling `fire(signalId, payload)` looks up the signal, validates
6
- * the payload against its schema, finds every trail with the signal in its
7
- * `on:` array, and invokes each consumer via the supplied executor.
4
+ * `createFireFn(topo, producerCtx?, executor, producerTrailId?)` returns a
5
+ * `FireFn` bound to a topo. Calling `fire(signal, payload)` looks up the
6
+ * signal, validates the payload against its schema, finds every trail with
7
+ * the signal in its `on:` array, and invokes each consumer via the supplied
8
+ * executor.
8
9
  *
9
10
  * The `executor` parameter is an indirection that lets `execute.ts` pass in
10
11
  * `executeTrail` without `fire.ts` importing it directly — keeping the two
@@ -18,16 +19,57 @@
18
19
  * producer logger as a child tagged with `signalId` and `consumerId` when
19
20
  * `logger.child` exists.
20
21
  *
21
- * Error semantics match the fire-and-forget framing: producers get
22
- * `Result.ok(undefined)` unless the signal id is unknown or the payload
23
- * fails schema validation. Consumer errors are logged via the producer's
24
- * logger but do NOT propagate back to the producer. Consumers that need
25
- * transactional coupling should use `crosses:`.
22
+ * Error semantics match the fire-and-forget framing: producer-facing
23
+ * `ctx.fire()` resolves after dispatch is initiated and without a value.
24
+ * Unknown signals, invalid payloads, guard suppression, and consumer errors
25
+ * are logged/diagnosed but do NOT propagate back to the producer. Consumers
26
+ * that need transactional coupling should use `crosses:`.
26
27
  */
27
28
 
28
- import { NotFoundError, ValidationError } from './errors.js';
29
+ import type { z } from 'zod';
30
+
31
+ import {
32
+ buildActivationProvenanceTraceAttrs,
33
+ getActivationProvenance,
34
+ withActivationProvenance,
35
+ } from './activation-provenance.js';
36
+ import type { ActivationProvenance } from './activation-provenance.js';
37
+ import type { ActivationWhereSpec } from './activation-source.js';
38
+ import { getActivationWherePredicate } from './activation-source.js';
39
+ import { activationSourceKey } from './activation-source-projection.js';
40
+ import { NotFoundError, TrailsError, ValidationError } from './errors.js';
29
41
  import { forkCtx } from './internal/fork-ctx.js';
42
+ import {
43
+ OBSERVE_LOGGER_CONTEXT_KEY,
44
+ OBSERVE_LOGGER_METADATA_KEY,
45
+ } from './observe.js';
46
+ import {
47
+ getTraceContext,
48
+ getTraceSink,
49
+ isTracingDisabled,
50
+ writeActivationTraceRecord,
51
+ writeSignalTraceRecord,
52
+ } from './tracing.js';
53
+ import type { SignalTraceRecordName, TraceSink } from './tracing.js';
30
54
  import { Result } from './result.js';
55
+ import type { AnySignal } from './signal.js';
56
+ import {
57
+ createSignalFireSuppressedDiagnostic,
58
+ createSignalHandlerFailedDiagnostic,
59
+ createSignalHandlerRejectedDiagnostic,
60
+ createSignalInvalidDiagnostic,
61
+ createSignalPredicateFailedDiagnostic,
62
+ createSignalUnknownDiagnostic,
63
+ recordSignalDiagnostic,
64
+ signalDiagnosticCauseFromUnknown,
65
+ summarizeSignalPayload,
66
+ } from './signal-diagnostics.js';
67
+ import type {
68
+ SignalDiagnostic,
69
+ SignalDiagnosticSchemaIssue,
70
+ SignalInvalidDiagnostic,
71
+ SignalPayloadSummary,
72
+ } from './signal-diagnostics.js';
31
73
  import type { Topo } from './topo.js';
32
74
  import type { AnyTrail } from './trail.js';
33
75
  import type { FireFn, Logger, TrailContextInit } from './types.js';
@@ -43,7 +85,75 @@ type MutableConsumerContext = {
43
85
  -readonly [K in keyof Partial<TrailContextInit>]: Partial<TrailContextInit>[K];
44
86
  };
45
87
 
88
+ interface ConsumerActivation {
89
+ readonly trail: AnyTrail;
90
+ readonly wheres: readonly ActivationWhereSpec[];
91
+ }
92
+
46
93
  const FIRE_STACK_KEY = '__trails_fire_stack';
94
+ const FIRE_PENDING_DISPATCHES_KEY = '__trails_fire_pending_dispatches';
95
+
96
+ const frameworkFireFns = new WeakSet<FireFn>();
97
+
98
+ export const isFrameworkFireFn = (fire: FireFn | undefined): boolean =>
99
+ fire !== undefined && frameworkFireFns.has(fire);
100
+
101
+ type FireDispatchTracker = Set<Promise<void>>;
102
+
103
+ const getFireDispatchTracker = (
104
+ ctx: Pick<TrailContextInit, 'extensions'> | undefined
105
+ ): FireDispatchTracker | undefined => {
106
+ const tracker = ctx?.extensions?.[FIRE_PENDING_DISPATCHES_KEY];
107
+ return tracker instanceof Set ? (tracker as FireDispatchTracker) : undefined;
108
+ };
109
+
110
+ export const withFireDispatchTracking = <T extends TrailContextInit>(
111
+ ctx: T
112
+ ): T => {
113
+ if (getFireDispatchTracker(ctx) !== undefined) {
114
+ return ctx;
115
+ }
116
+
117
+ return {
118
+ ...ctx,
119
+ extensions: {
120
+ ...ctx.extensions,
121
+ [FIRE_PENDING_DISPATCHES_KEY]: new Set<Promise<void>>(),
122
+ },
123
+ };
124
+ };
125
+
126
+ const trackFireDispatch = (
127
+ ctx: Pick<TrailContextInit, 'extensions'> | undefined,
128
+ dispatch: Promise<void>
129
+ ): void => {
130
+ const tracker = getFireDispatchTracker(ctx);
131
+ if (tracker === undefined) {
132
+ return;
133
+ }
134
+ tracker.add(dispatch);
135
+ const untrack = async (): Promise<void> => {
136
+ try {
137
+ await dispatch;
138
+ } finally {
139
+ tracker.delete(dispatch);
140
+ }
141
+ };
142
+ void untrack();
143
+ };
144
+
145
+ export const waitForPendingFireDispatches = async (
146
+ ctx: Pick<TrailContextInit, 'extensions'>
147
+ ): Promise<void> => {
148
+ const tracker = getFireDispatchTracker(ctx);
149
+ if (tracker === undefined) {
150
+ return;
151
+ }
152
+
153
+ while (tracker.size > 0) {
154
+ await Promise.allSettled(tracker);
155
+ }
156
+ };
47
157
 
48
158
  /**
49
159
  * Maximum depth for signal fan-out chains.
@@ -63,7 +173,8 @@ const getFireStack = (
63
173
 
64
174
  /** Binds a per-consumer `fire` onto a mutable consumer context. */
65
175
  type ConsumerFireBinder = (
66
- consumerCtx: MutableConsumerContext
176
+ consumerCtx: MutableConsumerContext,
177
+ consumerId: string
67
178
  ) => MutableConsumerContext;
68
179
 
69
180
  const deriveConsumerLogger = (
@@ -78,66 +189,583 @@ const deriveConsumerEnv = (
78
189
  ): TrailContextInit['env'] =>
79
190
  producerCtx?.env ? { ...producerCtx.env } : undefined;
80
191
 
192
+ const readExistingObserveMetadata = (
193
+ producerCtx: TrailContextInit | undefined
194
+ ): Record<string, unknown> => {
195
+ const value = producerCtx?.extensions?.[OBSERVE_LOGGER_METADATA_KEY];
196
+ return value !== null && typeof value === 'object'
197
+ ? (value as Record<string, unknown>)
198
+ : {};
199
+ };
200
+
201
+ const deriveConsumerObserveMetadata = (
202
+ producerCtx: TrailContextInit | undefined,
203
+ signalId: string,
204
+ consumerId: string
205
+ ): Record<string, unknown> | undefined => {
206
+ // Only carry observe metadata forward when the producer ctx is using a
207
+ // topo-managed observe logger. Otherwise we'd attach signal fan-out fields
208
+ // to caller-supplied loggers that didn't opt into the structured contract.
209
+ if (producerCtx?.extensions?.[OBSERVE_LOGGER_CONTEXT_KEY] !== true) {
210
+ return undefined;
211
+ }
212
+ return {
213
+ ...readExistingObserveMetadata(producerCtx),
214
+ consumerId,
215
+ signalId,
216
+ };
217
+ };
218
+
81
219
  const deriveConsumerExtensions = (
82
220
  producerCtx: TrailContextInit | undefined,
83
- signalId: string
84
- ): TrailContextInit['extensions'] => ({
85
- ...producerCtx?.extensions,
86
- [FIRE_STACK_KEY]: [...getFireStack(producerCtx), signalId],
87
- });
221
+ signalId: string,
222
+ consumerId: string
223
+ ): TrailContextInit['extensions'] => {
224
+ const { [FIRE_PENDING_DISPATCHES_KEY]: _pending, ...extensions } =
225
+ producerCtx?.extensions ?? {};
226
+ const observeMetadata = deriveConsumerObserveMetadata(
227
+ producerCtx,
228
+ signalId,
229
+ consumerId
230
+ );
231
+ return {
232
+ ...extensions,
233
+ [FIRE_STACK_KEY]: [...getFireStack(producerCtx), signalId],
234
+ ...(observeMetadata === undefined
235
+ ? {}
236
+ : { [OBSERVE_LOGGER_METADATA_KEY]: observeMetadata }),
237
+ };
238
+ };
88
239
 
89
240
  const deriveConsumerCtx = (
90
241
  producerCtx: TrailContextInit | undefined,
91
242
  signalId: string,
92
- consumerId: string
243
+ consumerId: string,
244
+ activation: ActivationProvenance
93
245
  ): MutableConsumerContext =>
94
- producerCtx
95
- ? forkCtx(producerCtx as MutableConsumerContext, {
96
- env: deriveConsumerEnv(producerCtx),
97
- extensions: deriveConsumerExtensions(producerCtx, signalId),
98
- logger: deriveConsumerLogger(producerCtx, signalId, consumerId),
99
- })
100
- : {};
246
+ withActivationProvenance(
247
+ producerCtx
248
+ ? forkCtx(producerCtx as MutableConsumerContext, {
249
+ env: deriveConsumerEnv(producerCtx),
250
+ extensions: deriveConsumerExtensions(
251
+ producerCtx,
252
+ signalId,
253
+ consumerId
254
+ ),
255
+ logger: deriveConsumerLogger(producerCtx, signalId, consumerId),
256
+ })
257
+ : {},
258
+ activation
259
+ );
260
+
261
+ interface FireDiagnosticMetadata {
262
+ readonly activation: ActivationProvenance;
263
+ readonly producerTrailId?: string | undefined;
264
+ readonly runId?: string | undefined;
265
+ readonly traceId?: string | undefined;
266
+ }
267
+
268
+ interface SignalTraceAttrsInput {
269
+ readonly consumerIds?: readonly string[] | undefined;
270
+ readonly errorName?: string | undefined;
271
+ readonly handlerTrailId?: string | undefined;
272
+ readonly payload?: SignalPayloadSummary | undefined;
273
+ readonly producerTrailId?: string | undefined;
274
+ readonly runId?: string | undefined;
275
+ readonly schemaIssues?: readonly SignalDiagnosticSchemaIssue[] | undefined;
276
+ readonly signalId: string;
277
+ readonly activation?: ActivationProvenance | undefined;
278
+ }
279
+
280
+ const deriveFireDiagnosticMetadata = (
281
+ producerCtx: TrailContextInit | undefined,
282
+ producerTrailId: string | undefined,
283
+ signalId: string
284
+ ): FireDiagnosticMetadata => {
285
+ const trace = producerCtx ? getTraceContext(producerCtx) : undefined;
286
+ const parent = getActivationProvenance(producerCtx);
287
+ const fireId = Bun.randomUUIDv7();
288
+ return {
289
+ activation: {
290
+ fireId,
291
+ ...(parent?.fireId === undefined ? {} : { parentFireId: parent.fireId }),
292
+ rootFireId: parent?.rootFireId ?? fireId,
293
+ source: {
294
+ id: signalId,
295
+ kind: 'signal',
296
+ ...(producerTrailId === undefined ? {} : { producerTrailId }),
297
+ },
298
+ },
299
+ producerTrailId,
300
+ runId: trace?.spanId,
301
+ traceId: trace?.traceId,
302
+ };
303
+ };
304
+
305
+ const recordRuntimeSignalDiagnostic = async (
306
+ producerCtx: TrailContextInit | undefined,
307
+ diagnostic: SignalDiagnostic
308
+ ): Promise<boolean> => {
309
+ const record = await recordSignalDiagnostic(producerCtx, diagnostic);
310
+ if (record.promoted) {
311
+ producerCtx?.logger?.warn('Signal diagnostic promoted by strict mode', {
312
+ code: diagnostic.code,
313
+ producerTrailId: diagnostic.producerTrailId,
314
+ runId: diagnostic.runId,
315
+ signalId: diagnostic.signalId,
316
+ traceId: diagnostic.traceId,
317
+ });
318
+ }
319
+ return record.promoted;
320
+ };
321
+
322
+ const deriveSignalErrorCategory = (error: unknown): string =>
323
+ error instanceof TrailsError ? error.category : 'internal';
324
+
325
+ const signalIssuePathLabel = (issue: SignalDiagnosticSchemaIssue): string => {
326
+ if (issue.path.length === 0) {
327
+ return '$';
328
+ }
329
+ return issue.path.map(String).join('.');
330
+ };
331
+
332
+ const addPayloadSummaryAttrs = (
333
+ attrs: Record<string, unknown>,
334
+ payload: SignalPayloadSummary
335
+ ): void => {
336
+ attrs['trails.signal.payload.byte_length'] = payload.byteLength;
337
+ attrs['trails.signal.payload.digest'] = payload.digest;
338
+ attrs['trails.signal.payload.redacted'] = payload.redacted;
339
+ attrs['trails.signal.payload.shape'] = payload.shape;
340
+ if (payload.topLevelEntryCount !== undefined) {
341
+ attrs['trails.signal.payload.top_level_entry_count'] =
342
+ payload.topLevelEntryCount;
343
+ }
344
+ };
345
+
346
+ const buildSignalTraceAttrs = (
347
+ input: SignalTraceAttrsInput
348
+ ): Readonly<Record<string, unknown>> => {
349
+ const attrs: Record<string, unknown> = {
350
+ ...buildActivationProvenanceTraceAttrs(input.activation),
351
+ 'trails.signal.id': input.signalId,
352
+ };
353
+
354
+ if (input.producerTrailId !== undefined) {
355
+ attrs['trails.signal.producer_trail.id'] = input.producerTrailId;
356
+ }
357
+ if (input.runId !== undefined) {
358
+ attrs['trails.signal.run.id'] = input.runId;
359
+ }
360
+ if (input.handlerTrailId !== undefined) {
361
+ attrs['trails.signal.handler_trail.id'] = input.handlerTrailId;
362
+ }
363
+ if (input.consumerIds !== undefined) {
364
+ attrs['trails.signal.consumer_count'] = input.consumerIds.length;
365
+ attrs['trails.signal.consumer_ids'] = input.consumerIds
366
+ .toSorted()
367
+ .join(',');
368
+ }
369
+ if (input.errorName !== undefined) {
370
+ attrs['trails.signal.error.name'] = input.errorName;
371
+ }
372
+ if (input.payload !== undefined) {
373
+ addPayloadSummaryAttrs(attrs, input.payload);
374
+ }
375
+ if (input.schemaIssues !== undefined) {
376
+ const issuePaths = input.schemaIssues.map(signalIssuePathLabel);
377
+ attrs['trails.signal.schema_issue_count'] = input.schemaIssues.length;
378
+ attrs['trails.signal.schema_issue_paths'] = issuePaths.join(',');
379
+ }
380
+
381
+ return attrs;
382
+ };
383
+
384
+ const recordSignalLifecycleTrace = async (
385
+ producerCtx: TrailContextInit | undefined,
386
+ name: SignalTraceRecordName,
387
+ attrs: Readonly<Record<string, unknown>>,
388
+ status?: Parameters<typeof writeSignalTraceRecord>[3],
389
+ errorCategory?: string | undefined,
390
+ sink?: TraceSink | undefined
391
+ ): Promise<void> => {
392
+ if (producerCtx === undefined) {
393
+ return;
394
+ }
395
+ await writeSignalTraceRecord(
396
+ producerCtx,
397
+ name,
398
+ attrs,
399
+ status,
400
+ errorCategory,
401
+ sink
402
+ );
403
+ };
404
+
405
+ const recordActivationGuardTrace = async (
406
+ producerCtx: TrailContextInit | undefined,
407
+ diagnosticMetadata: FireDiagnosticMetadata,
408
+ reason: 'cycle' | 'depth',
409
+ signalId: string,
410
+ fireStack: readonly string[],
411
+ limit?: number | undefined,
412
+ sink?: TraceSink | undefined
413
+ ): Promise<void> => {
414
+ const attrs: Record<string, unknown> = {
415
+ ...buildActivationProvenanceTraceAttrs(diagnosticMetadata.activation),
416
+ 'trails.activation.guard.fire_stack': fireStack.join(','),
417
+ 'trails.activation.guard.reason': reason,
418
+ 'trails.signal.id': signalId,
419
+ };
420
+ if (limit !== undefined) {
421
+ attrs['trails.activation.guard.limit'] = limit;
422
+ }
423
+ await writeActivationTraceRecord(
424
+ 'activation.cycle_detected',
425
+ attrs,
426
+ 'ok',
427
+ undefined,
428
+ producerCtx === undefined ? undefined : getTraceContext(producerCtx),
429
+ sink
430
+ );
431
+ };
432
+
433
+ const activationEntriesForSignal = (
434
+ trail: AnyTrail,
435
+ signalId: string
436
+ ): readonly ConsumerActivation[] => {
437
+ const activations = new Map<string, readonly ActivationWhereSpec[]>();
438
+ for (const activation of trail.activationSources ?? []) {
439
+ if (
440
+ activation.source.kind !== 'signal' ||
441
+ activation.source.id !== signalId
442
+ ) {
443
+ continue;
444
+ }
445
+ const key = activationSourceKey(activation.source);
446
+ if (activation.where === undefined) {
447
+ activations.set(key, []);
448
+ continue;
449
+ }
450
+
451
+ const wheres = activations.get(key);
452
+ if (wheres === undefined) {
453
+ activations.set(key, [activation.where]);
454
+ } else if (wheres.length > 0) {
455
+ activations.set(key, [...wheres, activation.where]);
456
+ }
457
+ }
458
+ if (activations.size > 0) {
459
+ return [...activations.values()].map((wheres) => ({
460
+ trail,
461
+ wheres,
462
+ }));
463
+ }
464
+ return trail.on.includes(signalId) ? [{ trail, wheres: [] }] : [];
465
+ };
466
+
467
+ const listConsumerActivations = (
468
+ topo: Topo,
469
+ signalId: string
470
+ ): readonly ConsumerActivation[] =>
471
+ topo.list().flatMap((trail) => activationEntriesForSignal(trail, signalId));
472
+
473
+ const consumerTrailsFromActivations = (
474
+ activations: readonly ConsumerActivation[]
475
+ ): readonly AnyTrail[] => [
476
+ ...new Map(
477
+ activations.map((activation) => [activation.trail.id, activation.trail])
478
+ ).values(),
479
+ ];
480
+
481
+ const hasActiveSignalTraceContext = (
482
+ producerCtx: TrailContextInit | undefined,
483
+ sink: TraceSink
484
+ ): boolean =>
485
+ producerCtx !== undefined &&
486
+ getTraceContext(producerCtx) !== undefined &&
487
+ !isTracingDisabled(sink);
488
+
489
+ const summarizeSignalPayloadForTrace = (
490
+ producerCtx: TrailContextInit | undefined,
491
+ payload: unknown,
492
+ sink: TraceSink
493
+ ): SignalPayloadSummary | undefined => {
494
+ if (!hasActiveSignalTraceContext(producerCtx, sink)) {
495
+ return undefined;
496
+ }
497
+ try {
498
+ return summarizeSignalPayload(payload);
499
+ } catch (error) {
500
+ producerCtx?.logger?.debug('Signal payload summary skipped', {
501
+ error: error instanceof Error ? error.message : String(error),
502
+ });
503
+ return undefined;
504
+ }
505
+ };
506
+
507
+ const recordPredicateTrace = async (
508
+ producerCtx: TrailContextInit | undefined,
509
+ name:
510
+ | 'signal.handler.predicate_failed'
511
+ | 'signal.handler.predicate_matched'
512
+ | 'signal.handler.predicate_skipped',
513
+ input: {
514
+ readonly diagnosticMetadata: FireDiagnosticMetadata;
515
+ readonly errorCategory?: string | undefined;
516
+ readonly errorName?: string | undefined;
517
+ readonly handlerTrailId: string;
518
+ readonly payloadSummary?: SignalPayloadSummary | undefined;
519
+ readonly signalId: string;
520
+ readonly status?: Parameters<typeof recordSignalLifecycleTrace>[3];
521
+ readonly traceSink: TraceSink;
522
+ }
523
+ ): Promise<void> => {
524
+ await recordSignalLifecycleTrace(
525
+ producerCtx,
526
+ name,
527
+ buildSignalTraceAttrs({
528
+ activation: input.diagnosticMetadata.activation,
529
+ errorName: input.errorName,
530
+ handlerTrailId: input.handlerTrailId,
531
+ payload: input.payloadSummary,
532
+ producerTrailId: input.diagnosticMetadata.producerTrailId,
533
+ runId: input.diagnosticMetadata.runId,
534
+ signalId: input.signalId,
535
+ }),
536
+ input.status,
537
+ input.errorCategory,
538
+ input.traceSink
539
+ );
540
+ };
541
+
542
+ const shouldInvokeConsumer = async (
543
+ activation: ConsumerActivation,
544
+ payload: unknown,
545
+ payloadSummary: SignalPayloadSummary | undefined,
546
+ signalId: string,
547
+ producerCtx: TrailContextInit | undefined,
548
+ diagnosticMetadata: FireDiagnosticMetadata,
549
+ logger: Logger | undefined,
550
+ traceSink: TraceSink
551
+ ): Promise<boolean> => {
552
+ if (activation.wheres.length === 0) {
553
+ return true;
554
+ }
555
+
556
+ for (const where of activation.wheres) {
557
+ const predicate = getActivationWherePredicate(where);
558
+ if (predicate === undefined) {
559
+ return true;
560
+ }
561
+
562
+ try {
563
+ const matched = await predicate(payload);
564
+ await recordPredicateTrace(
565
+ producerCtx,
566
+ matched
567
+ ? 'signal.handler.predicate_matched'
568
+ : 'signal.handler.predicate_skipped',
569
+ {
570
+ diagnosticMetadata,
571
+ handlerTrailId: activation.trail.id,
572
+ payloadSummary,
573
+ signalId,
574
+ traceSink,
575
+ }
576
+ );
577
+ if (matched) {
578
+ return true;
579
+ }
580
+ } catch (error) {
581
+ const cause = signalDiagnosticCauseFromUnknown(error);
582
+ const diagnostic = createSignalPredicateFailedDiagnostic({
583
+ ...diagnosticMetadata,
584
+ cause: error,
585
+ handlerTrailId: activation.trail.id,
586
+ payload,
587
+ signalId,
588
+ });
589
+ await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
590
+ await recordPredicateTrace(
591
+ producerCtx,
592
+ 'signal.handler.predicate_failed',
593
+ {
594
+ diagnosticMetadata,
595
+ errorCategory: deriveSignalErrorCategory(error),
596
+ errorName: cause.name,
597
+ handlerTrailId: activation.trail.id,
598
+ payloadSummary,
599
+ signalId,
600
+ status: 'err',
601
+ traceSink,
602
+ }
603
+ );
604
+ logger?.warn('Signal activation predicate failed', {
605
+ consumerId: activation.trail.id,
606
+ error: cause.message,
607
+ signalId,
608
+ });
609
+ }
610
+ }
611
+
612
+ return false;
613
+ };
101
614
 
102
615
  /**
103
616
  * Fan out a validated signal payload to its consumer trails.
104
617
  *
105
618
  * @remarks
106
- * Consumers fan out in parallel by design. Signal delivery is fire-and-forget
107
- * notification, not ordered orchestration; if one consumer depends on another,
108
- * the dependency belongs in `crosses:` instead of sibling signal sequencing.
619
+ * Signal delivery is fire-and-forget notification, not ordered orchestration;
620
+ * if one consumer depends on another, the dependency belongs in `crosses:`
621
+ * instead of sibling signal sequencing.
109
622
  *
110
- * `Promise.allSettled` preserves failure isolation and waits for every branch
111
- * to settle. Each consumer gets its own derived context so sibling branches do
112
- * not share mutable top-level state while they overlap. Re-entrant suppression
113
- * elsewhere in this module is still based on signal-id membership in the
114
- * current fire stack: it prevents infinite loops, but it can over-suppress
115
- * legitimate diamond re-fires. Per-path provenance is a documented future
116
- * direction rather than part of the pre-v1 runtime contract.
623
+ * `Promise.allSettled` preserves failure isolation for the background
624
+ * completion task. Producer-facing `ctx.fire()` starts this task but does not
625
+ * wait for every consumer to complete before resolving. Each consumer gets its
626
+ * own derived context so sibling fan-out branches do not share mutable
627
+ * top-level state while they overlap. Re-entrant suppression elsewhere in this
628
+ * module is still based on signal-id membership in the current fire stack: it
629
+ * prevents infinite loops, but it can over-suppress legitimate diamond
630
+ * re-fires. Activation provenance records each fire boundary so downstream
631
+ * traces and diagnostics can still reconstruct the chain that activated a
632
+ * consumer.
117
633
  */
118
634
  const fanOutToConsumers = async (
119
- consumers: readonly AnyTrail[],
635
+ activations: readonly ConsumerActivation[],
120
636
  payload: unknown,
121
637
  signalId: string,
122
638
  producerCtx: TrailContextInit | undefined,
639
+ diagnosticMetadata: FireDiagnosticMetadata,
123
640
  bindFire: ConsumerFireBinder,
124
641
  executor: ConsumerExecutor,
125
- logger: Logger | undefined
642
+ logger: Logger | undefined,
643
+ traceSink: TraceSink
126
644
  ): Promise<void> => {
645
+ const payloadSummary = summarizeSignalPayloadForTrace(
646
+ producerCtx,
647
+ payload,
648
+ traceSink
649
+ );
127
650
  const settled = await Promise.allSettled(
128
- consumers.map(async (consumer) => {
651
+ activations.map(async (activation) => {
652
+ const consumer = activation.trail;
653
+ const shouldInvoke = await shouldInvokeConsumer(
654
+ activation,
655
+ payload,
656
+ payloadSummary,
657
+ signalId,
658
+ producerCtx,
659
+ diagnosticMetadata,
660
+ logger,
661
+ traceSink
662
+ );
663
+ if (!shouldInvoke) {
664
+ return consumer.id;
665
+ }
129
666
  const consumerCtx = bindFire(
130
- deriveConsumerCtx(producerCtx, signalId, consumer.id)
667
+ deriveConsumerCtx(
668
+ producerCtx,
669
+ signalId,
670
+ consumer.id,
671
+ diagnosticMetadata.activation
672
+ ),
673
+ consumer.id
131
674
  );
132
- const consumerResult = await executor(consumer, payload, consumerCtx);
133
- if (consumerResult.isErr()) {
134
- (consumerCtx.logger ?? logger)?.warn('Signal consumer failed', {
135
- consumerId: consumer.id,
136
- error: consumerResult.error.message,
675
+ await recordSignalLifecycleTrace(
676
+ producerCtx,
677
+ 'signal.handler.invoked',
678
+ buildSignalTraceAttrs({
679
+ activation: diagnosticMetadata.activation,
680
+ handlerTrailId: consumer.id,
681
+ payload: payloadSummary,
682
+ producerTrailId: diagnosticMetadata.producerTrailId,
683
+ runId: diagnosticMetadata.runId,
684
+ signalId,
685
+ }),
686
+ undefined,
687
+ undefined,
688
+ traceSink
689
+ );
690
+ try {
691
+ const consumerResult = await executor(consumer, payload, consumerCtx);
692
+ if (consumerResult.isErr()) {
693
+ const cause = signalDiagnosticCauseFromUnknown(consumerResult.error);
694
+ const diagnostic = createSignalHandlerFailedDiagnostic({
695
+ ...diagnosticMetadata,
696
+ cause: consumerResult.error,
697
+ handlerTrailId: consumer.id,
698
+ payload,
699
+ signalId,
700
+ });
701
+ await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
702
+ await recordSignalLifecycleTrace(
703
+ producerCtx,
704
+ 'signal.handler.failed',
705
+ buildSignalTraceAttrs({
706
+ activation: diagnosticMetadata.activation,
707
+ errorName: cause.name,
708
+ handlerTrailId: consumer.id,
709
+ payload: payloadSummary,
710
+ producerTrailId: diagnosticMetadata.producerTrailId,
711
+ runId: diagnosticMetadata.runId,
712
+ signalId,
713
+ }),
714
+ 'err',
715
+ deriveSignalErrorCategory(consumerResult.error),
716
+ traceSink
717
+ );
718
+ (consumerCtx.logger ?? logger)?.warn('Signal consumer failed', {
719
+ consumerId: consumer.id,
720
+ error: consumerResult.error.message,
721
+ signalId,
722
+ });
723
+ return consumer.id;
724
+ }
725
+ await recordSignalLifecycleTrace(
726
+ producerCtx,
727
+ 'signal.handler.completed',
728
+ buildSignalTraceAttrs({
729
+ activation: diagnosticMetadata.activation,
730
+ handlerTrailId: consumer.id,
731
+ payload: payloadSummary,
732
+ producerTrailId: diagnosticMetadata.producerTrailId,
733
+ runId: diagnosticMetadata.runId,
734
+ signalId,
735
+ }),
736
+ undefined,
737
+ undefined,
738
+ traceSink
739
+ );
740
+ return consumer.id;
741
+ } catch (error) {
742
+ const cause = signalDiagnosticCauseFromUnknown(error);
743
+ const diagnostic = createSignalHandlerRejectedDiagnostic({
744
+ ...diagnosticMetadata,
745
+ cause: error,
746
+ handlerTrailId: consumer.id,
747
+ payload,
137
748
  signalId,
138
749
  });
750
+ await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
751
+ await recordSignalLifecycleTrace(
752
+ producerCtx,
753
+ 'signal.handler.failed',
754
+ buildSignalTraceAttrs({
755
+ activation: diagnosticMetadata.activation,
756
+ errorName: cause.name,
757
+ handlerTrailId: consumer.id,
758
+ payload: payloadSummary,
759
+ producerTrailId: diagnosticMetadata.producerTrailId,
760
+ runId: diagnosticMetadata.runId,
761
+ signalId,
762
+ }),
763
+ 'err',
764
+ deriveSignalErrorCategory(error),
765
+ traceSink
766
+ );
767
+ throw error;
139
768
  }
140
- return consumer.id;
141
769
  })
142
770
  );
143
771
  for (const [index, entry] of settled.entries()) {
@@ -149,7 +777,7 @@ const fanOutToConsumers = async (
149
777
  // unexpectedly. Log at debug to preserve provenance without propagating
150
778
  // the failure to the producer (fire-and-forget semantics).
151
779
  logger?.debug('Signal consumer rejected unexpectedly', {
152
- consumerId: consumers[index]?.id,
780
+ consumerId: activations[index]?.trail.id,
153
781
  error:
154
782
  entry.reason instanceof Error
155
783
  ? entry.reason.message
@@ -159,51 +787,170 @@ const fanOutToConsumers = async (
159
787
  }
160
788
  };
161
789
 
162
- const resolveFireDispatch = (
163
- topo: Topo,
790
+ const recordInvalidSignalDiagnostic = async (
791
+ producerCtx: TrailContextInit | undefined,
792
+ diagnostic: SignalInvalidDiagnostic
793
+ ): Promise<boolean> =>
794
+ await recordRuntimeSignalDiagnostic(producerCtx, diagnostic);
795
+
796
+ const createInvalidPayloadError = (
164
797
  signalId: string,
798
+ message: string,
799
+ diagnostic: SignalInvalidDiagnostic,
800
+ promoted: boolean
801
+ ): ValidationError =>
802
+ new ValidationError(`Invalid payload for signal "${signalId}": ${message}`, {
803
+ context: {
804
+ diagnosticCode: diagnostic.code,
805
+ promoted,
806
+ schemaIssues: diagnostic.schemaIssues,
807
+ signalId,
808
+ },
809
+ });
810
+
811
+ const PAYLOAD_SCHEMA_READ_ERROR_MESSAGE =
812
+ 'Payload schema validation could not read the payload safely';
813
+
814
+ type SignalPayloadParseResult =
815
+ | {
816
+ readonly data: unknown;
817
+ readonly success: true;
818
+ }
819
+ | {
820
+ readonly issues: readonly z.core.$ZodIssue[];
821
+ readonly message: string;
822
+ readonly success: false;
823
+ };
824
+
825
+ const unreadablePayloadIssue = (): z.core.$ZodIssue =>
826
+ ({
827
+ code: 'custom',
828
+ message: PAYLOAD_SCHEMA_READ_ERROR_MESSAGE,
829
+ path: [],
830
+ }) as z.core.$ZodIssue;
831
+
832
+ const safeParseSignalPayload = (
833
+ signal: AnySignal,
165
834
  payload: unknown
166
- ): Result<
167
- { readonly consumers: readonly AnyTrail[]; readonly payload: unknown },
168
- Error
835
+ ): SignalPayloadParseResult => {
836
+ try {
837
+ const parsed = signal.payload.safeParse(payload);
838
+ if (parsed.success) {
839
+ return { data: parsed.data, success: true };
840
+ }
841
+ return {
842
+ issues: parsed.error.issues,
843
+ message: parsed.error.message,
844
+ success: false,
845
+ };
846
+ } catch {
847
+ return {
848
+ issues: [unreadablePayloadIssue()],
849
+ message: PAYLOAD_SCHEMA_READ_ERROR_MESSAGE,
850
+ success: false,
851
+ };
852
+ }
853
+ };
854
+
855
+ const resolveFireDispatch = async (
856
+ topo: Topo,
857
+ signalId: string,
858
+ payload: unknown,
859
+ producerCtx: TrailContextInit | undefined,
860
+ diagnosticMetadata: FireDiagnosticMetadata,
861
+ traceSink: TraceSink
862
+ ): Promise<
863
+ Result<
864
+ {
865
+ readonly activations: readonly ConsumerActivation[];
866
+ readonly consumers: readonly AnyTrail[];
867
+ readonly payload: unknown;
868
+ },
869
+ Error
870
+ >
169
871
  > => {
170
872
  const signal = topo.signals.get(signalId);
171
873
  if (signal === undefined) {
874
+ await recordRuntimeSignalDiagnostic(
875
+ producerCtx,
876
+ createSignalUnknownDiagnostic({
877
+ ...diagnosticMetadata,
878
+ signalId,
879
+ })
880
+ );
172
881
  return Result.err(
173
882
  new NotFoundError(`Signal "${signalId}" not found in topo "${topo.name}"`)
174
883
  );
175
884
  }
176
- const parsed = signal.payload.safeParse(payload);
885
+ const parsed = safeParseSignalPayload(signal, payload);
177
886
  if (!parsed.success) {
887
+ const diagnostic = createSignalInvalidDiagnostic({
888
+ ...diagnosticMetadata,
889
+ payload,
890
+ schemaIssues: parsed.issues,
891
+ signalId,
892
+ });
893
+ const promoted = await recordInvalidSignalDiagnostic(
894
+ producerCtx,
895
+ diagnostic
896
+ );
897
+ await recordSignalLifecycleTrace(
898
+ producerCtx,
899
+ 'signal.invalid',
900
+ buildSignalTraceAttrs({
901
+ activation: diagnostic.activation,
902
+ payload: diagnostic.payload,
903
+ producerTrailId: diagnostic.producerTrailId,
904
+ runId: diagnostic.runId,
905
+ schemaIssues: diagnostic.schemaIssues,
906
+ signalId,
907
+ }),
908
+ 'err',
909
+ 'validation',
910
+ traceSink
911
+ );
178
912
  return Result.err(
179
- new ValidationError(
180
- `Invalid payload for signal "${signalId}": ${parsed.error.message}`
181
- )
913
+ createInvalidPayloadError(signalId, parsed.message, diagnostic, promoted)
182
914
  );
183
915
  }
916
+ const activations = listConsumerActivations(topo, signalId);
184
917
  return Result.ok({
185
- consumers: topo.list().filter((trail) => trail.on.includes(signalId)),
918
+ activations,
919
+ consumers: consumerTrailsFromActivations(activations),
186
920
  payload: parsed.data,
187
921
  });
188
922
  };
189
923
 
190
924
  const resolveSignalId = (signalOrId: unknown): Result<string, Error> => {
191
- if (typeof signalOrId === 'string') {
192
- return Result.ok(signalOrId);
193
- }
194
925
  if (
195
926
  typeof signalOrId === 'object' &&
196
927
  signalOrId !== null &&
928
+ 'kind' in signalOrId &&
929
+ (signalOrId as { kind: unknown }).kind === 'signal' &&
197
930
  'id' in signalOrId &&
198
931
  typeof (signalOrId as { id: unknown }).id === 'string'
199
932
  ) {
200
- return Result.ok((signalOrId as { id: string }).id);
933
+ return Result.ok((signalOrId as AnySignal).id);
201
934
  }
202
- return Result.err(
203
- new ValidationError(
204
- 'ctx.fire() requires a signal id string or a Signal value'
205
- )
206
- );
935
+ if (typeof signalOrId === 'string') {
936
+ return Result.err(
937
+ new ValidationError(
938
+ 'ctx.fire() requires a Signal value; string signal ids are not part of the public fire API'
939
+ )
940
+ );
941
+ }
942
+ return Result.err(new ValidationError('ctx.fire() requires a Signal value'));
943
+ };
944
+
945
+ const logFireError = (
946
+ logger: Logger | undefined,
947
+ signalId: string | undefined,
948
+ error: Error
949
+ ): void => {
950
+ logger?.warn('Signal fire skipped', {
951
+ error: error.message,
952
+ signalId,
953
+ });
207
954
  };
208
955
 
209
956
  /**
@@ -218,9 +965,15 @@ const resolveSignalId = (signalOrId: unknown): Result<string, Error> => {
218
965
  export const createFireFn = (
219
966
  topo: Topo,
220
967
  producerCtx: TrailContextInit | undefined,
221
- executor: ConsumerExecutor
968
+ executor: ConsumerExecutor,
969
+ producerTrailId?: string | undefined
222
970
  ): FireFn => {
223
- const bindConsumerFire: ConsumerFireBinder = (consumerCtx) => ({
971
+ const trackedProducerCtx =
972
+ producerCtx === undefined
973
+ ? undefined
974
+ : withFireDispatchTracking(producerCtx);
975
+ const traceSink = topo.observe?.trace ?? getTraceSink();
976
+ const bindConsumerFire: ConsumerFireBinder = (consumerCtx, consumerId) => ({
224
977
  ...consumerCtx,
225
978
  // Pre-bind fire on the consumer ctx as a safety net for direct
226
979
  // executeTrail calls that skip the topo-aware path. In the normal
@@ -228,50 +981,138 @@ export const createFireFn = (
228
981
  // the fully-traced ctx before the blaze runs, so this assignment
229
982
  // is superseded — but keeping it makes consumerCtx self-sufficient
230
983
  // for any caller that inspects it pre-execution.
231
- fire: createFireFn(topo, consumerCtx as TrailContextInit, executor),
984
+ fire: createFireFn(
985
+ topo,
986
+ consumerCtx as TrailContextInit,
987
+ executor,
988
+ consumerId
989
+ ),
232
990
  });
233
991
 
234
992
  const dispatchFire = async (
235
993
  signalId: string,
236
- payload: unknown
994
+ payload: unknown,
995
+ diagnosticMetadata: FireDiagnosticMetadata
237
996
  ): Promise<Result<void, Error>> => {
238
- const dispatch = resolveFireDispatch(topo, signalId, payload);
997
+ const dispatch = await resolveFireDispatch(
998
+ topo,
999
+ signalId,
1000
+ payload,
1001
+ trackedProducerCtx,
1002
+ diagnosticMetadata,
1003
+ traceSink
1004
+ );
239
1005
  if (dispatch.isErr()) {
240
1006
  return Result.err(dispatch.error);
241
1007
  }
242
- await fanOutToConsumers(
243
- dispatch.value.consumers,
244
- dispatch.value.payload,
245
- signalId,
246
- producerCtx,
247
- bindConsumerFire,
248
- executor,
249
- producerCtx?.logger
1008
+ await recordSignalLifecycleTrace(
1009
+ trackedProducerCtx,
1010
+ 'signal.fired',
1011
+ buildSignalTraceAttrs({
1012
+ activation: diagnosticMetadata.activation,
1013
+ consumerIds: dispatch.value.consumers.map((consumer) => consumer.id),
1014
+ payload: summarizeSignalPayloadForTrace(
1015
+ trackedProducerCtx,
1016
+ dispatch.value.payload,
1017
+ traceSink
1018
+ ),
1019
+ producerTrailId: diagnosticMetadata.producerTrailId,
1020
+ runId: diagnosticMetadata.runId,
1021
+ signalId,
1022
+ }),
1023
+ undefined,
1024
+ undefined,
1025
+ traceSink
250
1026
  );
1027
+ const completion = (async (): Promise<void> => {
1028
+ try {
1029
+ await fanOutToConsumers(
1030
+ dispatch.value.activations,
1031
+ dispatch.value.payload,
1032
+ signalId,
1033
+ trackedProducerCtx,
1034
+ diagnosticMetadata,
1035
+ bindConsumerFire,
1036
+ executor,
1037
+ trackedProducerCtx?.logger,
1038
+ traceSink
1039
+ );
1040
+ } catch (error: unknown) {
1041
+ trackedProducerCtx?.logger?.debug(
1042
+ 'Signal dispatch completion failed unexpectedly',
1043
+ {
1044
+ error: error instanceof Error ? error.message : String(error),
1045
+ signalId,
1046
+ }
1047
+ );
1048
+ }
1049
+ })();
1050
+ trackFireDispatch(trackedProducerCtx, completion);
251
1051
  return Result.ok();
252
1052
  };
253
1053
 
254
1054
  /** Return an early Result if the fire should be suppressed, or null to proceed. */
255
- const guardFire = (
1055
+ const guardFire = async (
256
1056
  signalId: string,
257
- stack: readonly string[]
258
- ): Result<void, Error> | null => {
1057
+ stack: readonly string[],
1058
+ diagnosticMetadata: FireDiagnosticMetadata
1059
+ ): Promise<Result<void, Error> | null> => {
259
1060
  if (stack.length >= MAX_FIRE_DEPTH) {
260
- producerCtx?.logger?.warn(
1061
+ trackedProducerCtx?.logger?.warn(
261
1062
  'Signal fan-out depth limit reached — skipping fire',
262
1063
  { depth: stack.length, signalId }
263
1064
  );
1065
+ await recordRuntimeSignalDiagnostic(
1066
+ trackedProducerCtx,
1067
+ createSignalFireSuppressedDiagnostic({
1068
+ ...diagnosticMetadata,
1069
+ fireStack: [...stack],
1070
+ limit: MAX_FIRE_DEPTH,
1071
+ reason: 'depth',
1072
+ signalId,
1073
+ })
1074
+ );
1075
+ await recordActivationGuardTrace(
1076
+ trackedProducerCtx,
1077
+ diagnosticMetadata,
1078
+ 'depth',
1079
+ signalId,
1080
+ stack,
1081
+ MAX_FIRE_DEPTH,
1082
+ traceSink
1083
+ );
264
1084
  return Result.ok();
265
1085
  }
266
1086
  if (stack.includes(signalId)) {
267
- producerCtx?.logger?.debug('Signal fan-out suppressed due to cycle', {
268
- fireStack: [...stack],
269
- signalId,
270
- });
271
- producerCtx?.logger?.warn(
1087
+ trackedProducerCtx?.logger?.debug(
1088
+ 'Signal fan-out suppressed due to cycle',
1089
+ {
1090
+ fireStack: [...stack],
1091
+ signalId,
1092
+ }
1093
+ );
1094
+ trackedProducerCtx?.logger?.warn(
272
1095
  'Signal cycle detected — skipping re-entrant fire',
273
1096
  { fireStack: [...stack], signalId }
274
1097
  );
1098
+ await recordRuntimeSignalDiagnostic(
1099
+ trackedProducerCtx,
1100
+ createSignalFireSuppressedDiagnostic({
1101
+ ...diagnosticMetadata,
1102
+ fireStack: [...stack],
1103
+ reason: 'cycle',
1104
+ signalId,
1105
+ })
1106
+ );
1107
+ await recordActivationGuardTrace(
1108
+ trackedProducerCtx,
1109
+ diagnosticMetadata,
1110
+ 'cycle',
1111
+ signalId,
1112
+ stack,
1113
+ undefined,
1114
+ traceSink
1115
+ );
275
1116
  return Result.ok();
276
1117
  }
277
1118
  return null;
@@ -280,16 +1121,49 @@ export const createFireFn = (
280
1121
  const fireImpl: FireFn = async (
281
1122
  signalOrId: unknown,
282
1123
  payload: unknown
283
- ): Promise<Result<void, Error>> => {
1124
+ ): Promise<void> => {
284
1125
  const resolved = resolveSignalId(signalOrId);
285
1126
  if (resolved.isErr()) {
286
- return Result.err(resolved.error);
1127
+ logFireError(
1128
+ trackedProducerCtx?.logger,
1129
+ typeof signalOrId === 'string' ? signalOrId : undefined,
1130
+ resolved.error
1131
+ );
1132
+ return;
287
1133
  }
288
- const suppressed = guardFire(resolved.value, getFireStack(producerCtx));
1134
+ const diagnosticMetadata = deriveFireDiagnosticMetadata(
1135
+ trackedProducerCtx,
1136
+ producerTrailId,
1137
+ resolved.value
1138
+ );
1139
+ const suppressed = await guardFire(
1140
+ resolved.value,
1141
+ getFireStack(trackedProducerCtx),
1142
+ diagnosticMetadata
1143
+ );
289
1144
  if (suppressed) {
290
- return suppressed;
1145
+ if (suppressed.isErr()) {
1146
+ logFireError(
1147
+ trackedProducerCtx?.logger,
1148
+ resolved.value,
1149
+ suppressed.error
1150
+ );
1151
+ }
1152
+ return;
1153
+ }
1154
+ const dispatched = await dispatchFire(
1155
+ resolved.value,
1156
+ payload,
1157
+ diagnosticMetadata
1158
+ );
1159
+ if (dispatched.isErr()) {
1160
+ logFireError(
1161
+ trackedProducerCtx?.logger,
1162
+ resolved.value,
1163
+ dispatched.error
1164
+ );
291
1165
  }
292
- return await dispatchFire(resolved.value, payload);
293
1166
  };
1167
+ frameworkFireFns.add(fireImpl);
294
1168
  return fireImpl;
295
1169
  };