@ontrails/core 0.2.0

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