mcp-event-intelligence 0.4.0 → 0.5.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 (40) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +102 -14
  3. package/SECURITY.md +4 -4
  4. package/bin/mcp-event-intelligence.mjs +3 -3
  5. package/dist/src/composite/engine.d.ts +6 -0
  6. package/dist/src/composite/engine.js +599 -10
  7. package/dist/src/composite/matching.js +45 -0
  8. package/dist/src/intelligenceProtocol/activationSchemas.d.ts +2 -0
  9. package/dist/src/intelligenceProtocol/activationSchemas.js +1 -0
  10. package/dist/src/intelligenceProtocol/index.d.ts +9 -2
  11. package/dist/src/intelligenceProtocol/index.js +5 -1
  12. package/dist/src/intelligenceProtocol/operatorRegistry.d.ts +567 -0
  13. package/dist/src/intelligenceProtocol/operatorRegistry.js +477 -0
  14. package/dist/src/intelligenceProtocol/patternV2Schemas.d.ts +188 -0
  15. package/dist/src/intelligenceProtocol/patternV2Schemas.js +801 -0
  16. package/dist/src/intelligenceProtocol/schemas.d.ts +4 -0
  17. package/dist/src/intelligenceProtocol/schemas.js +2 -0
  18. package/dist/src/intelligenceProtocol/triggerPlanSchemas.d.ts +177 -4
  19. package/dist/src/intelligenceProtocol/triggerPlanSchemas.js +37 -4
  20. package/dist/src/intelligenceProtocol/triggerSchemas.d.ts +256 -0
  21. package/dist/src/intelligenceProtocol/triggerSchemas.js +161 -2
  22. package/dist/src/mcpEvents/consumer.d.ts +1 -0
  23. package/dist/src/mcpEvents/consumer.js +1 -0
  24. package/dist/src/patternV2/evaluator.d.ts +43 -0
  25. package/dist/src/patternV2/evaluator.js +927 -0
  26. package/dist/src/patternV2/legacyCompiler.d.ts +3 -0
  27. package/dist/src/patternV2/legacyCompiler.js +184 -0
  28. package/package.json +5 -2
  29. package/scripts/host-integration.d.mts +7 -0
  30. package/scripts/host-integration.mjs +9 -0
  31. package/scripts/lib/activation-hydrator.mjs +1 -0
  32. package/scripts/lib/composite-event-consumer.mjs +2 -0
  33. package/scripts/lib/derived-event-coordinator.mjs +75 -1
  34. package/scripts/lib/mcp-events-client.mjs +62 -24
  35. package/scripts/lib/mcp-events-compatibility.mjs +54 -0
  36. package/scripts/lib/trigger-control-plane.mjs +154 -0
  37. package/scripts/lib/trigger-inspector.mjs +15 -0
  38. package/scripts/lib/trigger-planner.mjs +190 -12
  39. package/scripts/mcp-stdio-server.mjs +38 -53
  40. package/server.json +4 -4
@@ -1,10 +1,75 @@
1
1
  import { SemanticConditionEngine } from '../semantic/conditionEngine.js';
2
+ import { evaluatePatternV2, patternV2CandidateSignature, } from '../patternV2/evaluator.js';
2
3
  import { sha256Hex } from '../intelligenceProtocol/canonical.js';
3
4
  import { COMPOSITE_TRIGGER_PROTOCOL_VERSION, COMPOSITE_TRIGGER_SCHEMA_VERSION, CompositeTriggerDefinitionSchema, CorrelatableEventSchema, TriggerMatchRecordSchema, } from '../intelligenceProtocol/triggerSchemas.js';
4
5
  import { evaluateTemporalConditions, } from './temporal.js';
5
6
  import { asSourceEvent, clauseMatches, deterministicKeyForClause, expressionSatisfied, matchesCorrelationKey, sameEventIdentity, semanticSource, } from './matching.js';
6
7
  import { assertNoDerivedEventCycle } from './derivedGraph.js';
7
8
  import { CompositeTriggerRuntimeSupport, } from './runtimeSupport.js';
9
+ function clauseAccumulatesOccurrences(definition, clauseId) {
10
+ if (definition.correlation?.semantic)
11
+ return true;
12
+ if (definition.expression.kind === 'count' &&
13
+ definition.expression.ref === clauseId) {
14
+ return true;
15
+ }
16
+ return definition.temporal.some((condition) => (condition.kind === 'threshold' ||
17
+ condition.kind === 'rate' ||
18
+ condition.kind === 'distinct' ||
19
+ condition.kind === 'debounce') &&
20
+ condition.ref === clauseId);
21
+ }
22
+ function getByPath(source, path) {
23
+ let current = source;
24
+ for (const part of path.split('.')) {
25
+ if (current === null ||
26
+ typeof current !== 'object' ||
27
+ Array.isArray(current))
28
+ return undefined;
29
+ current = current[part];
30
+ }
31
+ return current;
32
+ }
33
+ function sourceEventOrder(left, right) {
34
+ return (Date.parse(left.occurredAt) - Date.parse(right.occurredAt) ||
35
+ left.sourceEventId.localeCompare(right.sourceEventId) ||
36
+ left.clauseId.localeCompare(right.clauseId));
37
+ }
38
+ function stablePatternValue(value) {
39
+ if (Array.isArray(value))
40
+ return value.map(stablePatternValue);
41
+ if (value && typeof value === 'object') {
42
+ return Object.fromEntries(Object.keys(value)
43
+ .sort()
44
+ .map((key) => [
45
+ key,
46
+ stablePatternValue(value[key]),
47
+ ]));
48
+ }
49
+ return value;
50
+ }
51
+ function patternPhysicalIdentity(event) {
52
+ return [
53
+ event.serverId ?? '-',
54
+ event.sourceEventId,
55
+ JSON.stringify(stablePatternValue(event.subscriptionArguments ?? {})),
56
+ ].join(':');
57
+ }
58
+ function prospectiveEventWindow(record, event, withinMs) {
59
+ const eventTimes = [
60
+ ...record.sourceEvents.map((source) => Date.parse(source.occurredAt)),
61
+ Date.parse(event.occurredAt),
62
+ ];
63
+ const openedMs = Math.min(...eventTimes);
64
+ const latestMs = Math.max(...eventTimes);
65
+ const spanMs = latestMs - openedMs;
66
+ return {
67
+ openedAt: new Date(openedMs).toISOString(),
68
+ expiresAt: new Date(openedMs + withinMs).toISOString(),
69
+ spanMs,
70
+ fits: spanMs <= withinMs,
71
+ };
72
+ }
8
73
  export class CompositeTriggerEngine {
9
74
  store;
10
75
  evaluator;
@@ -36,6 +101,38 @@ export class CompositeTriggerEngine {
36
101
  const matchingClauses = definition.clauses.filter((clause) => clauseMatches(clause, event));
37
102
  if (matchingClauses.length === 0)
38
103
  continue;
104
+ if (definition.patternV2) {
105
+ const replayed = this.store
106
+ .listTriggerMatches(definition.triggerId)
107
+ .filter((record) => record.triggerVersion === definition.version &&
108
+ record.sourceEvents.some((source) => sameEventIdentity(source, event)))
109
+ .sort((a, b) => {
110
+ const rank = (status) => status === 'fired'
111
+ ? 4
112
+ : status === 'emitted'
113
+ ? 3
114
+ : status === 'matched'
115
+ ? 2
116
+ : status === 'partial'
117
+ ? 1
118
+ : 0;
119
+ return (rank(b.status) - rank(a.status) ||
120
+ Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
121
+ })[0];
122
+ if (replayed) {
123
+ results.push({
124
+ triggerId: definition.triggerId,
125
+ match: replayed,
126
+ matched: replayed.status === 'matched' ||
127
+ replayed.status === 'emitted' ||
128
+ replayed.status === 'fired',
129
+ fired: replayed.status === 'fired',
130
+ });
131
+ continue;
132
+ }
133
+ results.push(...await this.applyPatternV2(definition, matchingClauses, event));
134
+ continue;
135
+ }
39
136
  const replayed = this.store
40
137
  .listTriggerMatches(definition.triggerId)
41
138
  .find((record) => record.sourceEvents.some((source) => sameEventIdentity(source, event)));
@@ -126,6 +223,11 @@ export class CompositeTriggerEngine {
126
223
  match.status === 'expired') {
127
224
  return [];
128
225
  }
226
+ if (definition.patternV2 &&
227
+ match.patternState?.version === '2' &&
228
+ match.patternState.role === 'buffer') {
229
+ return this.evaluatePatternV2Buffer(definition, match, new Date(event.occurredAt));
230
+ }
129
231
  if (Date.parse(event.occurredAt) > Date.parse(match.expiresAt)) {
130
232
  const expired = TriggerMatchRecordSchema.parse({
131
233
  ...match,
@@ -147,43 +249,506 @@ export class CompositeTriggerEngine {
147
249
  const result = await this.finalizeEligible(definition, match, new Date(event.occurredAt));
148
250
  return [result];
149
251
  }
252
+ patternPartitionKey(definition, matchingClauses, event) {
253
+ const dimensions = definition.patternV2?.partitionBy ?? [];
254
+ if (!dimensions.length)
255
+ return null;
256
+ const clauseIds = new Set(matchingClauses.map((clause) => clause.id));
257
+ const components = [];
258
+ for (const dimension of dimensions) {
259
+ const fields = dimension.fields.filter((field) => clauseIds.has(field.ref));
260
+ if (!fields.length) {
261
+ throw new Error(`Pattern partition ${dimension.key} has no field for incoming event ${event.name}`);
262
+ }
263
+ const values = fields.map((field) => getByPath(event.data, field.path));
264
+ const first = values[0];
265
+ if (first === undefined ||
266
+ first === null ||
267
+ !['string', 'number', 'boolean'].includes(typeof first)) {
268
+ throw new Error(`Pattern partition ${dimension.key} resolved to a non-scalar value`);
269
+ }
270
+ if (values.some((value) => typeof value !== typeof first ||
271
+ value !== first)) {
272
+ throw new Error(`Pattern partition ${dimension.key} is ambiguous for incoming event ${event.name}`);
273
+ }
274
+ components.push({
275
+ key: dimension.key,
276
+ type: typeof first,
277
+ value: first,
278
+ });
279
+ }
280
+ return JSON.stringify(components);
281
+ }
282
+ async patternBufferId(definition, key) {
283
+ return `tm_${(await sha256Hex([
284
+ definition.triggerId,
285
+ definition.version,
286
+ key ?? '-',
287
+ 'pattern-v2-buffer',
288
+ ].join(':'))).slice(0, 24)}`;
289
+ }
290
+ patternDecisionRecord(item, evaluatedAt) {
291
+ const decision = item.decision;
292
+ return {
293
+ nodeId: item.nodeId,
294
+ decision: {
295
+ evaluator: decision.evaluator,
296
+ outcome: decision.outcome,
297
+ probability: decision.probability,
298
+ matched: decision.matched,
299
+ shouldEscalate: decision.shouldEscalate,
300
+ inputFields: decision.condition.input,
301
+ ...(decision.metadata
302
+ ? { providerEvidence: decision.metadata }
303
+ : {}),
304
+ evaluatedAt,
305
+ },
306
+ };
307
+ }
308
+ async applyPatternV2(definition, matchingClauses, event) {
309
+ const pattern = definition.patternV2;
310
+ if (!pattern)
311
+ return [];
312
+ const now = this.now();
313
+ const nowIso = now.toISOString();
314
+ const key = this.patternPartitionKey(definition, matchingClauses, event);
315
+ const bufferId = await this.patternBufferId(definition, key);
316
+ const existing = this.latestMatch(bufferId);
317
+ let sourceEvents = existing?.sourceEvents
318
+ ? [...existing.sourceEvents]
319
+ : [];
320
+ for (const clause of matchingClauses) {
321
+ const duplicate = sourceEvents.some((source) => source.clauseId === clause.id &&
322
+ sameEventIdentity(source, event));
323
+ if (!duplicate) {
324
+ sourceEvents.push(asSourceEvent(clause.id, event));
325
+ }
326
+ }
327
+ sourceEvents.sort(sourceEventOrder);
328
+ const previousMaxObserved = existing?.patternState
329
+ ?.maxObservedOccurredAt
330
+ ? Date.parse(existing.patternState.maxObservedOccurredAt)
331
+ : Number.NEGATIVE_INFINITY;
332
+ const maxObserved = Math.max(previousMaxObserved, Date.parse(event.occurredAt), ...sourceEvents
333
+ .map((source) => Date.parse(source.occurredAt))
334
+ .filter(Number.isFinite));
335
+ const allowedLatenessMs = definition.eventTime?.allowedLatenessMs ?? 0;
336
+ const watermarkMs = maxObserved - allowedLatenessMs;
337
+ const retentionFloor = watermarkMs - definition.withinMs;
338
+ sourceEvents = sourceEvents.filter((source) => Date.parse(source.occurredAt) >= retentionFloor);
339
+ const bufferedPhysicalEvents = new Set(sourceEvents.map(patternPhysicalIdentity)).size;
340
+ if (bufferedPhysicalEvents > pattern.execution.maxBufferedEvents) {
341
+ await this.store.appendAudit({
342
+ auditId: `audit_${(await sha256Hex(`${definition.triggerId}:${definition.version}:pattern_buffer_overflow:${key ?? '-'}:${nowIso}`)).slice(0, 24)}`,
343
+ traceId: event.traceId,
344
+ timestamp: nowIso,
345
+ kind: 'event.buffer_overflow',
346
+ entityType: 'trigger',
347
+ entityId: definition.triggerId,
348
+ details: {
349
+ triggerVersion: definition.version,
350
+ patternVersion: '2',
351
+ correlationKey: key,
352
+ bufferedEvents: bufferedPhysicalEvents,
353
+ maxBufferedEvents: pattern.execution.maxBufferedEvents,
354
+ },
355
+ });
356
+ const error = new Error(`Pattern v2 buffer exceeded maxBufferedEvents=${pattern.execution.maxBufferedEvents}`);
357
+ error.code =
358
+ 'PATTERN_V2_BUFFER_LIMIT_EXCEEDED';
359
+ throw error;
360
+ }
361
+ if (Date.parse(event.occurredAt) < retentionFloor &&
362
+ !existing?.sourceEvents.some((source) => sameEventIdentity(source, event))) {
363
+ await this.store.appendAudit({
364
+ auditId: `audit_${(await sha256Hex(`${definition.triggerId}:${definition.version}:pattern_late_event_dropped:${event.sourceEventId}:${nowIso}`)).slice(0, 24)}`,
365
+ traceId: event.traceId,
366
+ timestamp: nowIso,
367
+ kind: 'event.late_dropped',
368
+ entityType: 'trigger',
369
+ entityId: definition.triggerId,
370
+ details: {
371
+ triggerVersion: definition.version,
372
+ patternVersion: '2',
373
+ sourceEventId: event.sourceEventId,
374
+ occurredAt: event.occurredAt,
375
+ watermarkAt: new Date(watermarkMs).toISOString(),
376
+ retentionFloor: new Date(retentionFloor).toISOString(),
377
+ },
378
+ });
379
+ return [{
380
+ triggerId: definition.triggerId,
381
+ match: existing ?? null,
382
+ matched: false,
383
+ fired: false,
384
+ }];
385
+ }
386
+ const openedAt = sourceEvents[0]?.occurredAt ?? event.occurredAt;
387
+ const buffer = TriggerMatchRecordSchema.parse({
388
+ protocolVersion: COMPOSITE_TRIGGER_PROTOCOL_VERSION,
389
+ schemaVersion: COMPOSITE_TRIGGER_SCHEMA_VERSION,
390
+ matchId: bufferId,
391
+ triggerId: definition.triggerId,
392
+ triggerVersion: definition.version,
393
+ status: 'partial',
394
+ correlationKey: key,
395
+ openedAt,
396
+ expiresAt: new Date(Date.parse(openedAt) + definition.withinMs).toISOString(),
397
+ updatedAt: nowIso,
398
+ sourceEvents,
399
+ correlationDecision: null,
400
+ patternState: {
401
+ version: '2',
402
+ role: 'buffer',
403
+ maxObservedOccurredAt: new Date(maxObserved).toISOString(),
404
+ semanticDecisions: [],
405
+ },
406
+ firedWakeId: null,
407
+ derivedEventIds: [],
408
+ });
409
+ await this.store.appendTriggerMatch(buffer);
410
+ return this.evaluatePatternV2Buffer(definition, buffer, now);
411
+ }
412
+ async evaluatePatternV2Buffer(definition, buffer, evaluationNow) {
413
+ const pattern = definition.patternV2;
414
+ if (!pattern)
415
+ return [];
416
+ const nowIso = evaluationNow.toISOString();
417
+ const evaluation = await evaluatePatternV2({
418
+ definition: pattern,
419
+ events: buffer.sourceEvents,
420
+ evaluator: this.evaluator,
421
+ now: evaluationNow,
422
+ allowedLatenessMs: definition.eventTime?.allowedLatenessMs ?? 0,
423
+ });
424
+ for (const semantic of evaluation.semanticTrace) {
425
+ await this.support.auditMatch(buffer, 'trigger.correlation', {
426
+ patternVersion: '2',
427
+ semanticNodeId: semantic.nodeId,
428
+ candidateSignature: semantic.candidateSignature,
429
+ evaluator: semantic.decision.evaluator,
430
+ outcome: semantic.decision.outcome,
431
+ probability: semantic.decision.probability,
432
+ matched: semantic.decision.matched,
433
+ shouldEscalate: semantic.decision.shouldEscalate,
434
+ inputFields: semantic.decision.condition.input,
435
+ ...(semantic.decision.metadata
436
+ ? { providerEvidence: semantic.decision.metadata }
437
+ : {}),
438
+ });
439
+ }
440
+ await this.support.cancelDeadlinesForMatch(buffer.matchId);
441
+ if (evaluation.pending.length) {
442
+ const deadlines = evaluation.pending
443
+ .filter((candidate) => candidate.pendingUntil)
444
+ .map((candidate) => ({
445
+ conditionId: `pattern-v2:${patternV2CandidateSignature(candidate)}`,
446
+ dueAt: candidate.pendingUntil,
447
+ }));
448
+ await this.support.scheduleDeadlines(definition, buffer, deadlines);
449
+ }
450
+ const previouslyEmitted = new Set(this.store
451
+ .listTriggerMatches(definition.triggerId)
452
+ .filter((record) => record.triggerVersion === definition.version &&
453
+ record.patternState?.version === '2' &&
454
+ record.patternState.role === 'match' &&
455
+ record.patternState.signature)
456
+ .map((record) => record.patternState.signature));
457
+ const fresh = evaluation.matches
458
+ .map((candidate, rank) => ({ candidate, rank }))
459
+ .filter(({ candidate }) => candidate.events.length > 0 &&
460
+ !previouslyEmitted.has(patternV2CandidateSignature(candidate)))
461
+ .sort((left, right) => left.candidate.startIndex - right.candidate.startIndex ||
462
+ left.rank - right.rank)
463
+ .map(({ candidate }) => candidate);
464
+ const selected = [];
465
+ const occupied = new Set();
466
+ let selectionLimit = pattern.selection.maxMatchesPerEvent;
467
+ const hasEffect = Boolean(definition.target || definition.derivedEvent);
468
+ if (hasEffect) {
469
+ const triggerState = this.store.getTriggerState?.(definition.triggerId, definition.version) ?? null;
470
+ const lifecycle = definition.lifecycle ?? {};
471
+ if (lifecycle.oneShot || lifecycle.completeOnGoal) {
472
+ selectionLimit = Math.min(selectionLimit, 1);
473
+ }
474
+ if (lifecycle.maxFirings !== undefined) {
475
+ selectionLimit = Math.min(selectionLimit, Math.max(0, lifecycle.maxFirings - (triggerState?.fireCount ?? 0)));
476
+ }
477
+ if ((lifecycle.cooldownMs ?? 0) > 0) {
478
+ selectionLimit = Math.min(selectionLimit, 1);
479
+ }
480
+ }
481
+ const indexed = [...buffer.sourceEvents].sort(sourceEventOrder);
482
+ const indexByIdentity = new Map(indexed.map((source, index) => [
483
+ patternPhysicalIdentity(source),
484
+ index,
485
+ ]));
486
+ let skipPastLastThrough = -1;
487
+ let skipBefore = -1;
488
+ const skipStarts = new Set();
489
+ const bindingBoundary = (candidate, ref, edge) => {
490
+ const events = [...(candidate.bindings[ref] ?? [])]
491
+ .sort(sourceEventOrder);
492
+ const event = edge === 'first' ? events[0] : events.at(-1);
493
+ if (!event)
494
+ return null;
495
+ return indexByIdentity.get(patternPhysicalIdentity(event)) ?? null;
496
+ };
497
+ for (const candidate of fresh) {
498
+ if (selected.length >= selectionLimit)
499
+ break;
500
+ if (candidate.startIndex <= skipPastLastThrough)
501
+ continue;
502
+ if (candidate.startIndex < skipBefore)
503
+ continue;
504
+ if (skipStarts.has(candidate.startIndex))
505
+ continue;
506
+ const identities = candidate.events.map(patternPhysicalIdentity);
507
+ if (pattern.selection.overlap === 'disallow' &&
508
+ identities.some((identity) => occupied.has(identity))) {
509
+ continue;
510
+ }
511
+ selected.push(candidate);
512
+ identities.forEach((identity) => occupied.add(identity));
513
+ const afterMatch = pattern.selection.afterMatch;
514
+ if (afterMatch === 'skipPastLast') {
515
+ skipPastLastThrough = Math.max(skipPastLastThrough, candidate.endIndex);
516
+ }
517
+ else if (afterMatch === 'skipToNext') {
518
+ skipStarts.add(candidate.startIndex);
519
+ }
520
+ else if (typeof afterMatch === 'object') {
521
+ const boundary = bindingBoundary(candidate, afterMatch.ref, afterMatch.kind === 'skipToFirst' ? 'first' : 'last');
522
+ if (boundary !== null) {
523
+ skipBefore = Math.max(skipBefore, boundary);
524
+ }
525
+ }
526
+ }
527
+ const results = [];
528
+ for (const candidate of selected) {
529
+ const signature = patternV2CandidateSignature(candidate);
530
+ const matchId = `tm_${(await sha256Hex([
531
+ definition.triggerId,
532
+ definition.version,
533
+ buffer.correlationKey ?? '-',
534
+ signature,
535
+ 'pattern-v2-match',
536
+ ].join(':'))).slice(0, 24)}`;
537
+ const openedAt = candidate.events[0]?.occurredAt ?? buffer.openedAt;
538
+ const semanticDecisions = candidate.semanticDecisions.map((item) => this.patternDecisionRecord(item, nowIso));
539
+ const lastSemantic = semanticDecisions.at(-1)?.decision ?? null;
540
+ const matched = TriggerMatchRecordSchema.parse({
541
+ protocolVersion: COMPOSITE_TRIGGER_PROTOCOL_VERSION,
542
+ schemaVersion: COMPOSITE_TRIGGER_SCHEMA_VERSION,
543
+ matchId,
544
+ triggerId: definition.triggerId,
545
+ triggerVersion: definition.version,
546
+ status: 'matched',
547
+ correlationKey: buffer.correlationKey,
548
+ openedAt,
549
+ expiresAt: new Date(Date.parse(openedAt) + definition.withinMs).toISOString(),
550
+ updatedAt: nowIso,
551
+ sourceEvents: candidate.events,
552
+ correlationDecision: lastSemantic,
553
+ patternState: {
554
+ version: '2',
555
+ role: 'match',
556
+ signature,
557
+ semanticDecisions,
558
+ },
559
+ firedWakeId: null,
560
+ derivedEventIds: [],
561
+ });
562
+ await this.store.appendTriggerMatch(matched);
563
+ await this.support.auditMatch(matched, 'trigger.matched', {
564
+ patternVersion: '2',
565
+ signature,
566
+ semanticEvaluations: evaluation.semanticEvaluations,
567
+ truncated: evaluation.truncated,
568
+ sourceEventIds: matched.sourceEvents.map((source) => source.sourceEventId),
569
+ });
570
+ results.push({
571
+ triggerId: definition.triggerId,
572
+ match: matched,
573
+ matched: true,
574
+ fired: false,
575
+ });
576
+ }
577
+ if (selected.length) {
578
+ const selectedIndexes = selected.flatMap((candidate) => [
579
+ candidate.startIndex,
580
+ candidate.endIndex,
581
+ ]).filter((index) => index >= 0);
582
+ const firstStart = selectedIndexes.length
583
+ ? Math.min(...selected.map((candidate) => candidate.startIndex))
584
+ : -1;
585
+ const lastEnd = selectedIndexes.length
586
+ ? Math.max(...selected.map((candidate) => candidate.endIndex))
587
+ : -1;
588
+ let retained = [...buffer.sourceEvents];
589
+ const shouldDropByIndex = (index) => {
590
+ const afterMatch = pattern.selection.afterMatch;
591
+ if (afterMatch === 'skipPastLast') {
592
+ return index <= lastEnd;
593
+ }
594
+ if (afterMatch === 'skipToNext') {
595
+ return index <= firstStart;
596
+ }
597
+ if (typeof afterMatch === 'object') {
598
+ const boundaries = selected
599
+ .map((candidate) => bindingBoundary(candidate, afterMatch.ref, afterMatch.kind === 'skipToFirst' ? 'first' : 'last'))
600
+ .filter((value) => value !== null);
601
+ if (!boundaries.length)
602
+ return false;
603
+ return index < Math.max(...boundaries);
604
+ }
605
+ return false;
606
+ };
607
+ const consumed = pattern.selection.overlap === 'disallow'
608
+ ? new Set(selected
609
+ .flatMap((candidate) => candidate.events)
610
+ .map(patternPhysicalIdentity))
611
+ : new Set();
612
+ retained = indexed.filter((source, index) => !shouldDropByIndex(index) &&
613
+ !consumed.has(patternPhysicalIdentity(source)));
614
+ const openedAt = retained[0]?.occurredAt ?? evaluationNow.toISOString();
615
+ const updatedBuffer = TriggerMatchRecordSchema.parse({
616
+ ...buffer,
617
+ openedAt,
618
+ expiresAt: new Date(Date.parse(openedAt) + definition.withinMs).toISOString(),
619
+ updatedAt: nowIso,
620
+ sourceEvents: retained,
621
+ });
622
+ await this.store.appendTriggerMatch(updatedBuffer);
623
+ }
624
+ if (!results.length) {
625
+ await this.support.auditMatch(buffer, 'trigger.partial', {
626
+ reason: 'pattern_v2_wait',
627
+ patternVersion: '2',
628
+ pendingCandidates: evaluation.pending.length,
629
+ semanticEvaluations: evaluation.semanticEvaluations,
630
+ truncated: evaluation.truncated,
631
+ });
632
+ return [{
633
+ triggerId: definition.triggerId,
634
+ match: buffer,
635
+ matched: false,
636
+ fired: false,
637
+ }];
638
+ }
639
+ return results;
640
+ }
150
641
  async applyEvent(definition, clause, event) {
151
642
  const nowIso = this.now().toISOString();
152
643
  const eventTime = Date.parse(event.occurredAt);
153
644
  const key = deterministicKeyForClause(definition, clause.id, event);
154
- const existing = this.store
645
+ const relatedMatches = this.store
155
646
  .listTriggerMatches(definition.triggerId)
156
647
  .filter((record) => record.triggerVersion === definition.version &&
157
- (record.status === 'partial' || record.status === 'matched') &&
158
- matchesCorrelationKey(record, key))
648
+ matchesCorrelationKey(record, key));
649
+ const activeMatches = relatedMatches.filter((record) => record.status === 'partial' || record.status === 'matched');
650
+ const alreadyMatched = activeMatches
651
+ .filter((record) => record.status === 'matched')
159
652
  .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
160
- if (existing?.status === 'matched') {
653
+ if (alreadyMatched) {
161
654
  return {
162
655
  triggerId: definition.triggerId,
163
- match: existing,
656
+ match: alreadyMatched,
164
657
  matched: true,
165
658
  fired: false,
166
659
  };
167
660
  }
168
- let record = existing ?? await this.newMatch(definition, key, event);
169
- if (Date.parse(record.expiresAt) < eventTime) {
661
+ const partials = activeMatches.filter((record) => record.status === 'partial');
662
+ const observedEventTimes = relatedMatches.flatMap((record) => record.sourceEvents.map((source) => Date.parse(source.occurredAt)));
663
+ const maxObservedEventTime = Math.max(eventTime, ...observedEventTimes.filter(Number.isFinite));
664
+ const allowedLatenessMs = definition.eventTime?.allowedLatenessMs ?? 0;
665
+ const watermarkMs = maxObservedEventTime - allowedLatenessMs;
666
+ // The watermark is the point before which new standalone windows are too
667
+ // late to open. Existing partials are retained until their event-time
668
+ // expiry falls strictly behind it, allowing a late event to complete a
669
+ // still-valid in-window pattern.
670
+ const staleIds = new Set();
671
+ for (const candidate of partials) {
672
+ if (Date.parse(candidate.expiresAt) >= watermarkMs)
673
+ continue;
674
+ staleIds.add(candidate.matchId);
170
675
  const expired = TriggerMatchRecordSchema.parse({
171
- ...record,
676
+ ...candidate,
172
677
  status: 'expired',
173
678
  updatedAt: nowIso,
174
679
  });
175
680
  await this.store.appendTriggerMatch(expired);
176
681
  await this.support.cancelDeadlinesForMatch(expired.matchId);
177
682
  await this.support.auditMatch(expired, 'trigger.expired', {
178
- reason: 'window_expired_before_event',
683
+ reason: 'event_time_watermark_advanced',
684
+ watermarkAt: new Date(watermarkMs).toISOString(),
685
+ allowedLatenessMs,
686
+ incomingSourceEventId: event.sourceEventId,
687
+ incomingOccurredAt: event.occurredAt,
688
+ });
689
+ }
690
+ const accumulatesOccurrences = clauseAccumulatesOccurrences(definition, clause.id);
691
+ const compatible = partials
692
+ .filter((record) => !staleIds.has(record.matchId))
693
+ .map((record) => ({
694
+ record,
695
+ window: prospectiveEventWindow(record, event, definition.withinMs),
696
+ hasClause: record.sourceEvents.some((source) => source.clauseId === clause.id),
697
+ }))
698
+ .filter(({ window }) => window.fits)
699
+ .sort((a, b) => (accumulatesOccurrences
700
+ ? 0
701
+ : Number(a.hasClause) - Number(b.hasClause)) ||
702
+ a.window.spanMs - b.window.spanMs ||
703
+ Date.parse(b.record.updatedAt) - Date.parse(a.record.updatedAt));
704
+ let record = compatible[0]?.record;
705
+ if (record &&
706
+ !accumulatesOccurrences &&
707
+ compatible[0]?.hasClause) {
708
+ // Repeated non-aggregating occurrences represent alternate paths through
709
+ // the same partial match. Branch the candidate so the new occurrence can
710
+ // advance the expression without destroying the pre-existing window.
711
+ record = await this.branchMatch(definition, record, event);
712
+ }
713
+ if (!record && eventTime < watermarkMs) {
714
+ await this.store.appendAudit({
715
+ auditId: `audit_${(await sha256Hex(`${definition.triggerId}:${definition.version}:late_event_dropped:${event.sourceEventId}:${nowIso}`)).slice(0, 24)}`,
716
+ traceId: event.traceId,
717
+ timestamp: nowIso,
718
+ kind: 'event.late_dropped',
719
+ entityType: 'trigger',
720
+ entityId: definition.triggerId,
721
+ details: {
722
+ triggerVersion: definition.version,
723
+ sourceEventId: event.sourceEventId,
724
+ occurredAt: event.occurredAt,
725
+ receivedAt: event.receivedAt ?? null,
726
+ watermarkAt: new Date(watermarkMs).toISOString(),
727
+ allowedLatenessMs,
728
+ },
179
729
  });
180
- record = await this.newMatch(definition, key, event);
730
+ return {
731
+ triggerId: definition.triggerId,
732
+ match: null,
733
+ matched: false,
734
+ fired: false,
735
+ };
181
736
  }
737
+ record ??= await this.newMatch(definition, key, event);
182
738
  const duplicate = record.sourceEvents.some((candidate) => sameEventIdentity(candidate, event));
183
739
  if (!duplicate) {
740
+ const window = prospectiveEventWindow(record, event, definition.withinMs);
741
+ if (!window.fits) {
742
+ // This can only happen for a newly-created incompatible candidate race.
743
+ // Keep the existing partial intact and seed a fresh event-time window.
744
+ record = await this.newMatch(definition, key, event);
745
+ }
746
+ const selectedWindow = prospectiveEventWindow(record, event, definition.withinMs);
184
747
  record = TriggerMatchRecordSchema.parse({
185
748
  ...record,
186
749
  correlationKey: record.correlationKey ?? key,
750
+ openedAt: selectedWindow.openedAt,
751
+ expiresAt: selectedWindow.expiresAt,
187
752
  updatedAt: nowIso,
188
753
  sourceEvents: [
189
754
  ...record.sourceEvents,
@@ -196,6 +761,12 @@ export class CompositeTriggerEngine {
196
761
  await this.support.auditMatch(record, 'trigger.partial', {
197
762
  clauseId: clause.id,
198
763
  sourceEventId: event.sourceEventId,
764
+ occurredAt: event.occurredAt,
765
+ receivedAt: event.receivedAt ?? null,
766
+ eventTimeWindow: {
767
+ openedAt: record.openedAt,
768
+ expiresAt: record.expiresAt,
769
+ },
199
770
  });
200
771
  return {
201
772
  triggerId: definition.triggerId,
@@ -315,6 +886,24 @@ export class CompositeTriggerEngine {
315
886
  fired: false,
316
887
  };
317
888
  }
889
+ async branchMatch(definition, source, event) {
890
+ const matchId = `tm_${(await sha256Hex([
891
+ definition.triggerId,
892
+ definition.version,
893
+ source.matchId,
894
+ event.sourceEventId,
895
+ 'branch',
896
+ ].join(':'))).slice(0, 24)}`;
897
+ return TriggerMatchRecordSchema.parse({
898
+ ...source,
899
+ matchId,
900
+ status: 'partial',
901
+ updatedAt: this.now().toISOString(),
902
+ correlationDecision: null,
903
+ firedWakeId: null,
904
+ derivedEventIds: [],
905
+ });
906
+ }
318
907
  async newMatch(definition, key, event) {
319
908
  const openedAt = event.occurredAt;
320
909
  const expiresAt = new Date(Date.parse(openedAt) + definition.withinMs).toISOString();