@softize/opus 20.1.0 → 21.0.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.
@@ -28,6 +28,7 @@ import type { LoadSpec as DslLoadSpec } from '../dsl/loads.ts'
28
28
  import type {
29
29
  ActionContext,
30
30
  ActionDef,
31
+ ActionError,
31
32
  ActionResult,
32
33
  Adapter,
33
34
  AuditRecord,
@@ -136,6 +137,8 @@ export interface RuntimeSetupConfig {
136
137
  validateOutputInDev?: boolean
137
138
  warnUndeclaredEmits?: boolean
138
139
  dedupCacheTtl?: number
140
+ /** Chaves concluídas que o dedup guarda por reaction; acima disso, descarta a mais antiga. Padrão 10.000. */
141
+ dedupCacheMaxEntries?: number
139
142
  defaultRetry?: { attempts: number; backoff?: BackoffSpec }
140
143
  i18n?: {
141
144
  resolver?: (ref: I18nRef, locale?: string) => string
@@ -143,6 +146,8 @@ export interface RuntimeSetupConfig {
143
146
  }
144
147
  maxConcurrentActions?: number
145
148
  maxConcurrentReactions?: number
149
+ /** Saltos de reaction que uma cadeia de eventos pode dar. Padrão 5 (ADR 0027). */
150
+ maxReactionDepth?: number
146
151
  }
147
152
 
148
153
  /**
@@ -157,6 +162,7 @@ export interface ResolvedRuntimeConfig {
157
162
  validateOutputInDev: boolean
158
163
  warnUndeclaredEmits: boolean
159
164
  dedupCacheTtl: number
165
+ dedupCacheMaxEntries: number
160
166
  defaultRetry: { attempts: number; backoff?: BackoffSpec }
161
167
  i18n: {
162
168
  resolver?: (ref: I18nRef, locale?: string) => string
@@ -164,6 +170,7 @@ export interface ResolvedRuntimeConfig {
164
170
  }
165
171
  maxConcurrentActions: number
166
172
  maxConcurrentReactions: number
173
+ maxReactionDepth: number
167
174
  }
168
175
 
169
176
  // =============================================================================
@@ -235,6 +242,7 @@ function applyConfigDefaults(c: RuntimeSetupConfig = {}): ResolvedRuntimeConfig
235
242
  validateOutputInDev: c.validateOutputInDev ?? dev,
236
243
  warnUndeclaredEmits: c.warnUndeclaredEmits ?? dev,
237
244
  dedupCacheTtl: c.dedupCacheTtl ?? 60_000,
245
+ dedupCacheMaxEntries: c.dedupCacheMaxEntries ?? 10_000,
238
246
  defaultRetry: c.defaultRetry ?? {
239
247
  attempts: 3,
240
248
  backoff: { kind: 'exponential', initialMs: 1000 },
@@ -242,9 +250,44 @@ function applyConfigDefaults(c: RuntimeSetupConfig = {}): ResolvedRuntimeConfig
242
250
  i18n,
243
251
  maxConcurrentActions: c.maxConcurrentActions ?? Number.POSITIVE_INFINITY,
244
252
  maxConcurrentReactions: c.maxConcurrentReactions ?? 10,
253
+ maxReactionDepth: c.maxReactionDepth ?? 5,
245
254
  }
246
255
  }
247
256
 
257
+ /**
258
+ * Os limites que o runtime aplica às reactions travariam ou desligariam a entrega com um valor
259
+ * fora do domínio (zero vagas, TTL negativo): falhar na criação é melhor que parar em silêncio.
260
+ */
261
+ function assertReactionLimits(config: ResolvedRuntimeConfig): void {
262
+ const invalid: string[] = []
263
+ if (!isPositiveIntegerOrInfinity(config.maxConcurrentReactions)) {
264
+ invalid.push(`maxConcurrentReactions (${String(config.maxConcurrentReactions)}) precisa ser um inteiro positivo`)
265
+ }
266
+ if (!isPositiveInteger(config.maxReactionDepth)) {
267
+ invalid.push(`maxReactionDepth (${String(config.maxReactionDepth)}) precisa ser um inteiro positivo`)
268
+ }
269
+ if (!isPositiveInteger(config.dedupCacheMaxEntries)) {
270
+ invalid.push(`dedupCacheMaxEntries (${String(config.dedupCacheMaxEntries)}) precisa ser um inteiro positivo`)
271
+ }
272
+ if (!(Number.isFinite(config.dedupCacheTtl) && config.dedupCacheTtl >= 0)) {
273
+ invalid.push(`dedupCacheTtl (${String(config.dedupCacheTtl)}) precisa ser um número de milissegundos maior ou igual a zero`)
274
+ }
275
+ if (invalid.length === 0) return
276
+ throw error({
277
+ code: 'runtime.invalid_config',
278
+ category: 'internal',
279
+ message: `A configuração do runtime tem valores inválidos: ${invalid.join('; ')}.`,
280
+ })
281
+ }
282
+
283
+ function isPositiveInteger(value: number): boolean {
284
+ return Number.isInteger(value) && value >= 1
285
+ }
286
+
287
+ function isPositiveIntegerOrInfinity(value: number): boolean {
288
+ return value === Number.POSITIVE_INFINITY || isPositiveInteger(value)
289
+ }
290
+
248
291
  // =============================================================================
249
292
  // Runtime
250
293
  // =============================================================================
@@ -294,11 +337,19 @@ export class Runtime {
294
337
  */
295
338
  private readonly loadSpecCache = new Map<string, DslLoadSpec>()
296
339
 
340
+ /** Vagas globais de reaction (`config.maxConcurrentReactions`) e as de cada reaction. */
341
+ private readonly reactionSlots: Semaphore
342
+ private readonly reactionSlotsByName = new Map<string, Semaphore>()
343
+ /** Chaves de dedup por reaction; só existe para reactions que declaram `dedup`. */
344
+ private readonly reactionDedup = new Map<string, DedupCache>()
345
+
297
346
  private started = false
298
347
  private readonly disposeCallbacks: Array<() => Promise<void> | void> = []
299
348
 
300
349
  constructor(setup: RuntimeSetup) {
301
350
  this.config = applyConfigDefaults(setup.config)
351
+ assertReactionLimits(this.config)
352
+ this.reactionSlots = new Semaphore(this.config.maxConcurrentReactions)
302
353
  this.logger = setup.logger
303
354
  this.observability = setup.observability
304
355
  this.log = setup.logger ?? new ConsoleLogger({ runtime: 'opus' })
@@ -451,7 +502,12 @@ export class Runtime {
451
502
  message: `Reaction "${reaction.name}" registrada duas vezes`,
452
503
  })
453
504
  }
505
+ assertReactionPolicy(reaction)
454
506
  this.reactions.set(reaction.name, reaction)
507
+ this.reactionSlotsByName.set(reaction.name, new Semaphore(reaction.concurrency ?? 1))
508
+ if (reaction.dedup !== undefined) {
509
+ this.reactionDedup.set(reaction.name, new DedupCache(this.config.dedupCacheMaxEntries))
510
+ }
455
511
  }
456
512
  }
457
513
 
@@ -485,7 +541,10 @@ export class Runtime {
485
541
  // conexão aberta para trás. Sem scheduler, a checagem não roda porque nenhum schedule
486
542
  // executaria: os adapters inicializam e o start falha depois, com
487
543
  // `runtime.eventbus_required` se também faltar event bus, senão `runtime.scheduler_required`.
488
- if (this.scheduler !== undefined) this.assertScheduledActionsExist()
544
+ if (this.scheduler !== undefined) {
545
+ this.assertScheduledActionsExist()
546
+ this.assertScheduledActionsAcceptSystem()
547
+ }
489
548
 
490
549
  for (const adapter of this.allAdapters()) {
491
550
  if (adapter.init !== undefined) await adapter.init(this)
@@ -627,6 +686,10 @@ export class Runtime {
627
686
  }
628
687
 
629
688
  const trace = normalizeTraceContext(spec.ctx.trace)
689
+ // O envelope já é a fonte da identidade do job (o `userId` acima). Só a marca `system`, gravada
690
+ // no enfileiramento a partir de `isSystemActor`, transporta a identidade de sistema; a
691
+ // provenance vem do chamador de `execute()` e não serve para isso (ADR 0028).
692
+ const system = spec.ctx.userId === null && spec.ctx.system === true
630
693
  return this.executeAction(action, spec.input, {
631
694
  ...ctxBase,
632
695
  tenantId: spec.ctx.tenantId,
@@ -640,15 +703,20 @@ export class Runtime {
640
703
  ? { originalProvenance: spec.ctx.originalProvenance }
641
704
  : {}),
642
705
  },
643
- }, 'job', progress)
706
+ }, 'job', progress, system)
644
707
  }
645
708
 
709
+ /**
710
+ * `system` só é verdadeiro quando o próprio runtime inicia a execução pelo sistema — o disparo
711
+ * de um schedule ou o job que ele enfileirou. `execute()` nunca o recebe de fora.
712
+ */
646
713
  private async executeAction(
647
714
  action: ActionDef,
648
715
  input: unknown,
649
716
  ctxBase: ContextBase,
650
717
  execution: 'request' | 'job',
651
718
  progress?: ProgressReporter,
719
+ system = false,
652
720
  ): Promise<ActionResult<unknown>> {
653
721
 
654
722
  const startedAt = performance.now()
@@ -664,22 +732,17 @@ export class Runtime {
664
732
  ctxBase.trace,
665
733
  async (trace) => {
666
734
  const ctx = this.buildActionContext(action, actionId, ctxBase, trace)
735
+ if (system && ctx.user === null) systemContexts.add(ctx)
667
736
 
668
737
  try {
669
738
  // — 1. Validate input ————————————————————————————————————————————————
670
739
  const validatedInput = await this.validate(action.input, input, 'input')
671
740
 
672
- // — 2. Authenticate (public-or-user) ——————————————————————————————————
741
+ // — 2. Authenticate (public, user or opted-in system) ————————————————————
673
742
  // Antes dos loaders: eles consultam dados, e um chamador anônimo não pode nem disparar essas
674
743
  // consultas nem distinguir, pelo erro de um loader, se um registro existe. A autenticação
675
744
  // não depende de nada carregado; a autorização, sim, e por isso vem depois.
676
- if (action.public !== true && ctx.user === null) {
677
- throw error({
678
- code: 'auth.unauthenticated',
679
- category: 'authentication',
680
- message: 'Autenticação necessária',
681
- })
682
- }
745
+ assertAuthenticated(action, ctx)
683
746
 
684
747
  // — 3. Load —————————————————————————————————————————————————————————
685
748
  const loaded = await this.runLoaders(action, ctx, validatedInput)
@@ -724,6 +787,7 @@ export class Runtime {
724
787
  ...(ctxBase.requestId !== undefined ? { requestId: ctxBase.requestId } : {}),
725
788
  ...(trace !== undefined ? { trace } : {}),
726
789
  originalProvenance: ctx.provenance,
790
+ ...(isSystemActor(ctx) ? { system: true as const } : {}),
727
791
  },
728
792
  config: {
729
793
  ...(background.queue !== undefined ? { queue: background.queue } : {}),
@@ -1000,7 +1064,7 @@ export class Runtime {
1000
1064
  return
1001
1065
  }
1002
1066
 
1003
- const domainEvent: DomainEvent = {
1067
+ await this.publish({
1004
1068
  type: event,
1005
1069
  id: generateId(),
1006
1070
  timestamp: new Date().toISOString(),
@@ -1017,24 +1081,29 @@ export class Runtime {
1017
1081
  },
1018
1082
  ...(trace !== undefined ? { trace } : {}),
1019
1083
  ...(meta !== undefined ? { meta: meta as Record<string, unknown> } : {}),
1020
- }
1084
+ })
1085
+ }
1086
+ }
1021
1087
 
1022
- try {
1023
- await this.eventBus.publish(domainEvent)
1024
- } catch (err) {
1025
- if (this.config.emitMode === 'strict') {
1026
- throw error({
1027
- code: 'emit.failed',
1028
- category: 'internal',
1029
- message: `Falha ao publicar o evento "${event}"`,
1030
- cause: String(err),
1031
- })
1032
- }
1033
- this.log.warn('event publish failed (lenient)', {
1034
- event,
1035
- error: String(err),
1088
+ /** Publica no barramento; a falha segue `config.emitMode`, para actions e reactions. */
1089
+ private async publish(domainEvent: DomainEvent): Promise<void> {
1090
+ // Quem chama já tratou a ausência do barramento; reactions só rodam com ele.
1091
+ const bus = this.eventBus as EventBusAdapter
1092
+ try {
1093
+ await bus.publish(domainEvent)
1094
+ } catch (err) {
1095
+ if (this.config.emitMode === 'strict') {
1096
+ throw error({
1097
+ code: 'emit.failed',
1098
+ category: 'internal',
1099
+ message: `Falha ao publicar o evento "${domainEvent.type}"`,
1100
+ cause: String(err),
1036
1101
  })
1037
1102
  }
1103
+ this.log.warn('event publish failed (lenient)', {
1104
+ event: domainEvent.type,
1105
+ error: String(err),
1106
+ })
1038
1107
  }
1039
1108
  }
1040
1109
 
@@ -1264,6 +1333,38 @@ export class Runtime {
1264
1333
  })
1265
1334
  }
1266
1335
 
1336
+ /**
1337
+ * Um schedule ativo cuja action não aceita o sistema falharia em todo disparo com
1338
+ * `auth.system_forbidden`. Como o `schedule_action_not_found`, o erro sai no boot, antes de
1339
+ * inicializar os adapters (ADR 0028).
1340
+ */
1341
+ private assertScheduledActionsAcceptSystem(): void {
1342
+ const refused = [...this.schedules.values()].filter((schedule) => {
1343
+ if (schedule.enabled === false) return false
1344
+ const action = this.actions.get(schedule.action)
1345
+ return action !== undefined && action.public !== true && action.automatable !== true
1346
+ })
1347
+ if (refused.length === 0) return
1348
+ const list = refused
1349
+ .map((schedule) => `"${schedule.name}" → "${schedule.action}"`)
1350
+ .join(', ')
1351
+ throw error({
1352
+ code: 'runtime.schedule_action_not_automatable',
1353
+ category: 'internal',
1354
+ message:
1355
+ `O runtime não iniciou porque ${
1356
+ refused.length === 1
1357
+ ? 'um schedule aponta para uma action que não aceita execução pelo sistema'
1358
+ : `${refused.length} schedules apontam para actions que não aceitam execução pelo sistema`
1359
+ }: ${list}. Declare \`automatable: true\` no contrato da action para que schedules possam ` +
1360
+ 'executá-la (ela continua exigindo usuário nas demais chamadas); para manter o schedule sem ' +
1361
+ 'executar, declare `enabled: false`.',
1362
+ meta: {
1363
+ schedules: refused.map((schedule) => ({ schedule: schedule.name, action: schedule.action })),
1364
+ },
1365
+ })
1366
+ }
1367
+
1267
1368
  private registerSchedule(schedule: ScheduleDef): void {
1268
1369
  if (schedule.enabled === false) {
1269
1370
  this.log.info('schedule disabled', { schedule: schedule.name })
@@ -1282,20 +1383,24 @@ export class Runtime {
1282
1383
  typeof schedule.input === 'function'
1283
1384
  ? await (schedule.input as () => unknown | Promise<unknown>)()
1284
1385
  : (schedule.input ?? {})
1285
- const result = await this.execute(schedule.action, input, {
1386
+ const base: ContextBase = {
1286
1387
  user: null,
1287
1388
  tenantId: null,
1288
1389
  can: () => false,
1289
1390
  provenance: { kind: 'schedule', scheduleName: schedule.name },
1290
- })
1391
+ }
1392
+ const action = this.actions.get(schedule.action)
1393
+ // O start() recusa schedule ativo sem action; o fallback mantém o envelope de erro de sempre.
1394
+ const result = action === undefined
1395
+ ? await this.execute(schedule.action, input, base)
1396
+ : await this.executeAction(action, input, base, 'request', undefined, true)
1291
1397
  if (result.ok) return
1292
1398
  // Ninguém espera o resultado de um disparo: sem este registro, a falha só existiria no
1293
1399
  // audit da action. Action inexistente não chega aqui, porque o start() recusa o schedule.
1294
1400
  this.log.error(
1295
1401
  `O schedule "${schedule.name}" não concluiu a action "${schedule.action}" (${result.error.code}). ` +
1296
1402
  'O agendamento continua ativo e o próximo disparo executa a action de novo. Confira a ' +
1297
- 'mensagem e o audit da action; `auth.unauthenticated` indica que a action agendada precisa ' +
1298
- 'declarar `public: true`.',
1403
+ 'mensagem e o audit da action.',
1299
1404
  {
1300
1405
  schedule: schedule.name,
1301
1406
  action: schedule.action,
@@ -1308,25 +1413,84 @@ export class Runtime {
1308
1413
  }
1309
1414
 
1310
1415
  /**
1311
- * `ctx.emit` de reaction não publica: encadear reactions por evento ainda não é suportado.
1312
- * Fora de produção, o descarte vira aviso para que o evento não pareça ter sido entregue.
1416
+ * `ctx.emit` de reaction publica somente os eventos do `emits` da reaction e leva a causa na
1417
+ * cadeia; a profundidade para em `config.maxReactionDepth` (ADR 0027).
1313
1418
  */
1314
- private buildReactionEmit(reaction: ReactionDef<any>, log: Logger): ReactionContext['emit'] {
1315
- return async (event) => {
1316
- if (!this.config.dev) return
1317
- log.warn(
1318
- `A reaction "${reaction.name}" chamou ctx.emit('${event}'), mas reactions não publicam ` +
1319
- 'eventos: o evento foi descartado e nenhuma reaction ou integração o recebe. Para ' +
1320
- 'anunciar esse fato, emita-o no handler da action que o produz e declare-o em `emits`.',
1321
- { code: 'runtime.reaction_emit_ignored', reaction: reaction.name, event },
1322
- )
1419
+ private buildReactionEmit(
1420
+ reaction: ReactionDef<any>,
1421
+ source: DomainEvent,
1422
+ log: Logger,
1423
+ trace: TraceContext | undefined,
1424
+ ): ReactionContext['emit'] {
1425
+ const declared = reaction.emits ?? []
1426
+ return async (type, data, meta) => {
1427
+ if (!declared.includes(type)) {
1428
+ if (!this.config.warnUndeclaredEmits) return
1429
+ log.warn(
1430
+ `A reaction "${reaction.name}" chamou ctx.emit('${type}'), mas esse evento não está no ` +
1431
+ '`emits` da reaction: o evento foi descartado e nenhuma reaction ou integração o ' +
1432
+ `recebe. Para publicá-lo, acrescente '${type}' ao \`emits\` do binding da reaction.`,
1433
+ { code: 'runtime.reaction_emit_ignored', reaction: reaction.name, event: type, declared },
1434
+ )
1435
+ return
1436
+ }
1437
+
1438
+ const depth = (source.cause?.depth ?? 0) + 1
1439
+ const maxDepth = this.config.maxReactionDepth
1440
+ if (depth > maxDepth) {
1441
+ const message =
1442
+ `A reaction "${reaction.name}" não publicou o evento "${type}" porque a cadeia de ` +
1443
+ `reactions chegaria à profundidade ${depth}, acima do limite de ${maxDepth} ` +
1444
+ '(`config.maxReactionDepth`). Isso costuma indicar um laço: reactions que publicam ' +
1445
+ 'eventos que voltam a acioná-las. Confira o `on` e o `emits` das reactions da cadeia; ' +
1446
+ 'se a cadeia é intencional, aumente o limite.'
1447
+ log.error(message, {
1448
+ code: 'runtime.reaction_chain_too_deep',
1449
+ reaction: reaction.name,
1450
+ event: type,
1451
+ depth,
1452
+ maxDepth,
1453
+ sourceEvent: { id: source.id, type: source.type },
1454
+ origin: source.source,
1455
+ })
1456
+ if (this.config.emitMode === 'strict') {
1457
+ throw error({
1458
+ code: 'runtime.reaction_chain_too_deep',
1459
+ category: 'internal',
1460
+ message,
1461
+ meta: { depth, maxDepth },
1462
+ })
1463
+ }
1464
+ return
1465
+ }
1466
+
1467
+ await this.publish({
1468
+ type,
1469
+ id: generateId(),
1470
+ timestamp: new Date().toISOString(),
1471
+ actor: source.actor,
1472
+ ...(source.tenant !== undefined ? { tenant: source.tenant } : {}),
1473
+ data,
1474
+ source: {
1475
+ ...source.source,
1476
+ ...(meta?.correlation !== undefined ? { correlation: meta.correlation } : {}),
1477
+ },
1478
+ cause: { reaction: reaction.name, eventId: source.id, eventType: source.type, depth },
1479
+ ...(trace !== undefined ? { trace } : {}),
1480
+ ...(meta !== undefined ? { meta: meta as Record<string, unknown> } : {}),
1481
+ })
1323
1482
  }
1324
1483
  }
1325
1484
 
1485
+ /**
1486
+ * Entrega um evento a uma reaction: dedup → vagas → authorize → handler com timeout → retry →
1487
+ * audit (ADR 0026). Nunca rejeita: a falha fica no log e no audit, longe de quem emitiu.
1488
+ */
1326
1489
  private async runReaction(
1327
1490
  reaction: ReactionDef<any>,
1328
1491
  event: DomainEvent,
1329
1492
  ): Promise<void> {
1493
+ const startedAt = performance.now()
1330
1494
  const provenance: Provenance = {
1331
1495
  kind: 'reaction',
1332
1496
  reactionName: reaction.name,
@@ -1338,29 +1502,155 @@ export class Runtime {
1338
1502
  eventType: event.type,
1339
1503
  provenance: 'reaction',
1340
1504
  })
1505
+ const delivery: ReactionDelivery = { attempts: 0, authorized: true }
1506
+
1507
+ // — (a) dedup: a chave é reservada na chegada, antes da fila de vagas ——————————
1508
+ const dedup = this.reactionDedup.get(reaction.name)
1509
+ let dedupKey: string | undefined
1510
+ if (dedup !== undefined) {
1511
+ try {
1512
+ dedupKey = String(reaction.dedup?.(event))
1513
+ } catch (thrown) {
1514
+ const failure = normalizeError(thrown)
1515
+ this.logReactionFailure(reactionLog, failure, delivery)
1516
+ try {
1517
+ await this.auditReaction(reaction, event, provenance, delivery, startedAt, event.trace, failure)
1518
+ } catch (auditError) {
1519
+ // `runReaction` nunca rejeita: quem o chama é o listener do barramento, sem `await`.
1520
+ const auditFailure = normalizeError(auditError)
1521
+ reactionLog.error('reaction audit failed', {
1522
+ code: auditFailure.code,
1523
+ message: auditFailure.message,
1524
+ ...(auditFailure.cause !== undefined ? { cause: auditFailure.cause } : {}),
1525
+ })
1526
+ }
1527
+ return
1528
+ }
1529
+ if (!dedup.claim(dedupKey, Date.now())) {
1530
+ reactionLog.debug(
1531
+ `A reaction "${reaction.name}" descartou o evento "${event.id}": a chave de dedup ` +
1532
+ `"${dedupKey}" já foi processada ou está em processamento.`,
1533
+ {
1534
+ code: 'runtime.reaction_duplicate_skipped',
1535
+ reaction: reaction.name,
1536
+ eventId: event.id,
1537
+ key: dedupKey,
1538
+ },
1539
+ )
1540
+ return
1541
+ }
1542
+ }
1543
+
1544
+ let failure: ActionError | undefined
1341
1545
  try {
1342
1546
  await this.runObserved(
1343
- {
1344
- name: reaction.name,
1345
- kind: 'reaction',
1346
- resultKind: 'void',
1347
- ...(event.trace !== undefined ? { parent: event.trace } : {}),
1348
- attributes: { 'opus.event.type': event.type, 'opus.source.action_id': event.source.actionId },
1349
- },
1350
- event.trace,
1351
- async (trace) => {
1352
- const ctx: ReactionContext = {
1353
- user: event.actor.id !== null ? ({ id: event.actor.id } as User) : null,
1547
+ {
1548
+ name: reaction.name,
1549
+ kind: 'reaction',
1550
+ resultKind: 'void',
1551
+ ...(event.trace !== undefined ? { parent: event.trace } : {}),
1552
+ attributes: { 'opus.event.type': event.type, 'opus.source.action_id': event.source.actionId },
1553
+ },
1554
+ event.trace,
1555
+ async (trace) => {
1556
+ try {
1557
+ await this.deliverReaction(reaction, event, provenance, reactionLog, trace, delivery)
1558
+ } catch (thrown) {
1559
+ // O audit fica dentro do span, com o trace da reaction; a rejeição segue para o span.
1560
+ await this.auditReaction(reaction, event, provenance, delivery, startedAt, trace, normalizeError(thrown))
1561
+ throw thrown
1562
+ }
1563
+ if (delivery.authorized) {
1564
+ await this.auditReaction(reaction, event, provenance, delivery, startedAt, trace)
1565
+ }
1566
+ },
1567
+ )
1568
+ } catch (thrown) {
1569
+ failure = normalizeError(thrown)
1570
+ this.logReactionFailure(reactionLog, failure, delivery)
1571
+ }
1572
+
1573
+ if (dedup !== undefined && dedupKey !== undefined) {
1574
+ // Falha definitiva libera a chave: uma nova entrega do mesmo evento pode tentar de novo.
1575
+ if (failure !== undefined) dedup.release(dedupKey)
1576
+ else dedup.complete(dedupKey, Date.now(), this.config.dedupCacheTtl)
1577
+ }
1578
+ }
1579
+
1580
+ /** (b) vagas, (c) authorize, (d) handler com timeout e (f) retry, dentro do span da entrega. */
1581
+ private async deliverReaction(
1582
+ reaction: ReactionDef<any>,
1583
+ event: DomainEvent,
1584
+ provenance: Provenance,
1585
+ log: Logger,
1586
+ trace: TraceContext | undefined,
1587
+ delivery: ReactionDelivery,
1588
+ ): Promise<void> {
1589
+ const maxAttempts = reaction.retry?.attempts ?? 1
1590
+ for (let attempt = 1; ; attempt += 1) {
1591
+ const controller = new AbortController()
1592
+ const ctx = this.buildReactionContext(reaction, event, provenance, log, trace, controller.signal)
1593
+ const release = await this.acquireReactionSlots(reaction)
1594
+ let delayMs: number
1595
+ try {
1596
+ if (attempt === 1 && reaction.authorize !== undefined) {
1597
+ const allowed = await reaction.authorize(ctx, event)
1598
+ if (!allowed) {
1599
+ delivery.authorized = false
1600
+ return
1601
+ }
1602
+ }
1603
+ delivery.attempts = attempt
1604
+ await runWithTimeout(reaction, () => reaction.handler(ctx, event), controller)
1605
+ return
1606
+ } catch (thrown) {
1607
+ // Falha do authorize não é repetida: nenhuma tentativa do handler começou.
1608
+ if (delivery.attempts < attempt || attempt >= maxAttempts || !isRetriable(thrown)) throw thrown
1609
+ delayMs = backoffDelay(reaction.retry?.backoff, attempt)
1610
+ const failed = normalizeError(thrown)
1611
+ log.warn(
1612
+ `A reaction "${reaction.name}" falhou na tentativa ${attempt} de ${maxAttempts} ` +
1613
+ `(${failed.code}); a próxima tentativa começa em ${delayMs} ms.`,
1614
+ {
1615
+ code: 'runtime.reaction_retry',
1616
+ reaction: reaction.name,
1617
+ eventId: event.id,
1618
+ attempt,
1619
+ attempts: maxAttempts,
1620
+ delayMs,
1621
+ error: failed.code,
1622
+ ...(failed.cause !== undefined ? { cause: failed.cause } : {}),
1623
+ },
1624
+ )
1625
+ } finally {
1626
+ release()
1627
+ }
1628
+ // A espera entre tentativas não ocupa vaga.
1629
+ if (delayMs > 0) await sleep(delayMs)
1630
+ }
1631
+ }
1632
+
1633
+ private buildReactionContext(
1634
+ reaction: ReactionDef<any>,
1635
+ event: DomainEvent,
1636
+ provenance: Provenance,
1637
+ log: Logger,
1638
+ trace: TraceContext | undefined,
1639
+ signal: AbortSignal,
1640
+ ): ReactionContext {
1641
+ const user = event.actor.id !== null ? ({ id: event.actor.id } as User) : null
1642
+ return {
1643
+ user,
1354
1644
  tenantId: event.tenant ?? null,
1355
1645
  db: this.data?.contextExtension().db,
1356
- log: reactionLog,
1357
- emit: this.buildReactionEmit(reaction, reactionLog),
1646
+ log,
1647
+ emit: this.buildReactionEmit(reaction, event, log, trace),
1358
1648
  storage: this.storage ?? null,
1359
1649
  cache: this.cache ?? null,
1360
1650
  // Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
1361
1651
  // padrão — nada de LLM disparado por evento chamando action gated sem gate explícito).
1362
1652
  ai: this.bindAi({
1363
- user: event.actor.id !== null ? ({ id: event.actor.id } as User) : null,
1653
+ user,
1364
1654
  tenantId: event.tenant ?? null,
1365
1655
  can: async () => false,
1366
1656
  meta: {},
@@ -1369,24 +1659,58 @@ export class Runtime {
1369
1659
  provenance,
1370
1660
  ...(trace !== undefined ? { trace } : {}),
1371
1661
  meta: {},
1662
+ signal,
1372
1663
  }
1664
+ }
1373
1665
 
1374
- if (reaction.authorize !== undefined) {
1375
- const allowed = await reaction.authorize(ctx, event)
1376
- if (!allowed) return
1377
- }
1378
- await reaction.handler(ctx, event)
1379
- },
1380
- )
1381
- } catch (err) {
1382
- const actionError = normalizeError(err)
1383
- reactionLog.error('reaction failed', {
1384
- code: actionError.code,
1385
- message: actionError.message,
1386
- // Erro não tratado tem microcopy fixa; o texto original está em `cause`.
1387
- ...(actionError.cause !== undefined ? { cause: actionError.cause } : {}),
1388
- })
1666
+ /** Vaga da reaction primeiro, global depois: quem espera a própria vaga não segura a global. */
1667
+ private async acquireReactionSlots(reaction: ReactionDef<any>): Promise<() => void> {
1668
+ const own = this.reactionSlotsByName.get(reaction.name) as Semaphore
1669
+ const releaseOwn = await own.acquire()
1670
+ const releaseGlobal = await this.reactionSlots.acquire()
1671
+ return () => {
1672
+ releaseGlobal()
1673
+ releaseOwn()
1674
+ }
1675
+ }
1676
+
1677
+ /** (e) Um registro por entrega, com a identidade do evento e sem o payload (ADR 0026). */
1678
+ private async auditReaction(
1679
+ reaction: ReactionDef<any>,
1680
+ event: DomainEvent,
1681
+ provenance: Provenance,
1682
+ delivery: ReactionDelivery,
1683
+ startedAt: number,
1684
+ trace: TraceContext | undefined,
1685
+ failure?: ActionError,
1686
+ ): Promise<void> {
1687
+ const user = event.actor.id !== null ? ({ id: event.actor.id } as User) : null
1688
+ const record: AuditRecord = {
1689
+ id: generateId(),
1690
+ timestamp: new Date().toISOString(),
1691
+ action: reaction.name,
1692
+ outcome: failure === undefined ? 'success' : 'error',
1693
+ durationMs: performance.now() - startedAt,
1694
+ provenance,
1695
+ actor: actorFromProvenance(provenance, user),
1696
+ tenant: event.tenant ?? null,
1697
+ input: { event: { id: event.id, type: event.type, source: event.source } },
1698
+ severity: failure === undefined ? 'info' : severityForError(failure),
1699
+ ...(trace !== undefined ? { traceContext: trace } : {}),
1700
+ meta: { kind: 'reaction', attempts: delivery.attempts },
1389
1701
  }
1702
+ if (failure !== undefined) record.error = failure
1703
+ await this.audit.emit(record)
1704
+ }
1705
+
1706
+ private logReactionFailure(log: Logger, failure: ActionError, delivery: ReactionDelivery): void {
1707
+ log.error('reaction failed', {
1708
+ code: failure.code,
1709
+ message: failure.message,
1710
+ attempts: delivery.attempts,
1711
+ // Erro não tratado tem microcopy fixa; o texto original está em `cause`.
1712
+ ...(failure.cause !== undefined ? { cause: failure.cause } : {}),
1713
+ })
1390
1714
  }
1391
1715
 
1392
1716
  /** Observabilidade nunca repete nem substitui o resultado do callback. */
@@ -1437,6 +1761,210 @@ async function settledSuccessfully<T>(promise: Promise<T>): Promise<boolean> {
1437
1761
 
1438
1762
  // ContextBase moveu pra types.ts (compartilhado entre core e adapters).
1439
1763
 
1764
+ // =============================================================================
1765
+ // Identidade de sistema (ADR 0028)
1766
+ // =============================================================================
1767
+
1768
+ /**
1769
+ * Contextos que o próprio runtime criou para uma execução do sistema. Um conjunto interno, e não
1770
+ * a provenance, decide `isSystemActor`: qualquer código servidor pode montar uma provenance
1771
+ * `schedule`, mas só o runtime acrescenta um contexto aqui.
1772
+ */
1773
+ const systemContexts = new WeakSet<object>()
1774
+
1775
+ /**
1776
+ * Diz se a action está executando como o sistema: disparada por um schedule, ou no job que ele
1777
+ * enfileirou, sem usuário. Use no `authorize` de uma action `automatable` para aceitar o sistema
1778
+ * de forma explícita: `authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')`.
1779
+ * Passe o próprio `ctx` recebido: uma cópia, como `{ ...ctx }`, não é reconhecida.
1780
+ */
1781
+ export function isSystemActor(ctx: ActionContext): boolean {
1782
+ return systemContexts.has(ctx)
1783
+ }
1784
+
1785
+ /**
1786
+ * Marca um contexto como execução do sistema. Exportado fora do index público, só para o harness
1787
+ * de `testing` aplicar a mesma regra no `runAction`.
1788
+ */
1789
+ export function markSystemActor(ctx: ActionContext): void {
1790
+ if (ctx.user === null) systemContexts.add(ctx)
1791
+ }
1792
+
1793
+ /**
1794
+ * O gate de autenticação do `execute` e do harness: sem usuário, a action precisa ser pública ou,
1795
+ * quando o sistema a executa, `automatable`. Exportado fora do index público pelo mesmo motivo.
1796
+ */
1797
+ export function assertAuthenticated(
1798
+ action: { public?: boolean; automatable?: boolean },
1799
+ ctx: ActionContext,
1800
+ ): void {
1801
+ if (action.public === true || ctx.user !== null) return
1802
+ if (!isSystemActor(ctx)) {
1803
+ throw error({
1804
+ code: 'auth.unauthenticated',
1805
+ category: 'authentication',
1806
+ message: 'Autenticação necessária',
1807
+ })
1808
+ }
1809
+ if (action.automatable !== true) {
1810
+ throw error({
1811
+ code: 'auth.system_forbidden',
1812
+ category: 'authorization',
1813
+ message: 'Esta action não aceita execução pelo sistema',
1814
+ })
1815
+ }
1816
+ }
1817
+
1818
+ // =============================================================================
1819
+ // Política de entrega das reactions (ADR 0026)
1820
+ // =============================================================================
1821
+
1822
+ interface ReactionDelivery {
1823
+ /** Tentativas do handler que começaram. */
1824
+ attempts: number
1825
+ /** Falso quando o `authorize` recusou o evento: nada a auditar. */
1826
+ authorized: boolean
1827
+ }
1828
+
1829
+ /** Vagas em ordem de chegada. Ao liberar, a vaga passa direto para o próximo da fila. */
1830
+ class Semaphore {
1831
+ private active = 0
1832
+ private readonly waiting: Array<() => void> = []
1833
+
1834
+ constructor(private readonly limit: number) {}
1835
+
1836
+ async acquire(): Promise<() => void> {
1837
+ if (this.active < this.limit) {
1838
+ this.active += 1
1839
+ } else {
1840
+ await new Promise<void>((resolve) => this.waiting.push(resolve))
1841
+ }
1842
+ let released = false
1843
+ return () => {
1844
+ if (released) return
1845
+ released = true
1846
+ const next = this.waiting.shift()
1847
+ if (next !== undefined) next()
1848
+ else this.active -= 1
1849
+ }
1850
+ }
1851
+ }
1852
+
1853
+ /**
1854
+ * Chaves de dedup de uma reaction: ocupadas da chegada até o fim da entrega e, depois de um
1855
+ * sucesso, até o TTL vencer. Com TTL constante, a ordem de inserção é a ordem de expiração, e a
1856
+ * limpeza para na primeira chave ainda válida. As chaves concluídas têm teto
1857
+ * (`config.dedupCacheMaxEntries`): acima dele, a mais antiga sai antes do TTL, e um evento com essa
1858
+ * chave volta a ser aceito. As chaves em andamento não entram no teto.
1859
+ */
1860
+ class DedupCache {
1861
+ private readonly inFlight = new Set<string>()
1862
+ private readonly completed = new Map<string, number>()
1863
+
1864
+ constructor(private readonly maxEntries: number) {}
1865
+
1866
+ claim(key: string, now: number): boolean {
1867
+ this.prune(now)
1868
+ if (this.inFlight.has(key) || this.completed.has(key)) return false
1869
+ this.inFlight.add(key)
1870
+ return true
1871
+ }
1872
+
1873
+ complete(key: string, now: number, ttl: number): void {
1874
+ this.inFlight.delete(key)
1875
+ if (ttl <= 0) return
1876
+ this.completed.set(key, now + ttl)
1877
+ while (this.completed.size > this.maxEntries) {
1878
+ const oldest = this.completed.keys().next().value as string
1879
+ this.completed.delete(oldest)
1880
+ }
1881
+ }
1882
+
1883
+ release(key: string): void {
1884
+ this.inFlight.delete(key)
1885
+ }
1886
+
1887
+ private prune(now: number): void {
1888
+ for (const [key, expiresAt] of this.completed) {
1889
+ if (expiresAt > now) return
1890
+ this.completed.delete(key)
1891
+ }
1892
+ }
1893
+ }
1894
+
1895
+ /**
1896
+ * Valores fora do domínio travariam a reaction (zero vagas) ou a desligariam (zero tentativas):
1897
+ * o registro falha com o nome da reaction e o campo.
1898
+ */
1899
+ function assertReactionPolicy(reaction: ReactionDef<any>): void {
1900
+ const invalid: string[] = []
1901
+ if (reaction.concurrency !== undefined && !isPositiveInteger(reaction.concurrency)) {
1902
+ invalid.push(`concurrency (${String(reaction.concurrency)}) precisa ser um inteiro positivo`)
1903
+ }
1904
+ if (reaction.timeout !== undefined && !(Number.isFinite(reaction.timeout) && reaction.timeout > 0)) {
1905
+ invalid.push(`timeout (${String(reaction.timeout)}) precisa ser um número de milissegundos maior que zero`)
1906
+ }
1907
+ if (reaction.retry !== undefined && !isPositiveInteger(reaction.retry.attempts)) {
1908
+ invalid.push(`retry.attempts (${String(reaction.retry.attempts)}) precisa ser um inteiro positivo`)
1909
+ }
1910
+ if (invalid.length === 0) return
1911
+ throw error({
1912
+ code: 'runtime.invalid_reaction_policy',
1913
+ category: 'internal',
1914
+ message: `A reaction "${reaction.name}" declara uma política de entrega inválida: ${invalid.join('; ')}.`,
1915
+ meta: { reaction: reaction.name },
1916
+ })
1917
+ }
1918
+
1919
+ /** Um `ActionError` pode vetar a repetição; exceções comuns e timeout são repetidos. */
1920
+ function isRetriable(thrown: unknown): boolean {
1921
+ return isActionError(thrown) ? thrown.retriable !== false : true
1922
+ }
1923
+
1924
+ function backoffDelay(spec: BackoffSpec | undefined, failedAttempt: number): number {
1925
+ if (spec === undefined) return 0
1926
+ if (spec.kind === 'fixed') return spec.delayMs
1927
+ const delay = spec.initialMs * (spec.multiplier ?? 2) ** (failedAttempt - 1)
1928
+ return spec.maxMs !== undefined ? Math.min(delay, spec.maxMs) : delay
1929
+ }
1930
+
1931
+ function sleep(ms: number): Promise<void> {
1932
+ return new Promise((resolve) => setTimeout(resolve, ms))
1933
+ }
1934
+
1935
+ /**
1936
+ * Corre o handler contra o `timeout` da reaction. Ao vencer, aborta o sinal do contexto e rejeita
1937
+ * a tentativa; o handler que ignora o sinal continua em segundo plano, sem segurar a vaga.
1938
+ */
1939
+ async function runWithTimeout(
1940
+ reaction: ReactionDef<any>,
1941
+ run: () => Promise<void> | void,
1942
+ controller: AbortController,
1943
+ ): Promise<void> {
1944
+ const execution = Promise.resolve().then(run)
1945
+ const timeoutMs = reaction.timeout
1946
+ if (timeoutMs === undefined) return execution
1947
+ let timer: ReturnType<typeof setTimeout> | undefined
1948
+ const expired = new Promise<never>((_, reject) => {
1949
+ timer = setTimeout(() => {
1950
+ const timeout = error({
1951
+ code: 'runtime.reaction_timeout',
1952
+ category: 'internal',
1953
+ retriable: true,
1954
+ message: `A reaction "${reaction.name}" não concluiu em ${timeoutMs} ms`,
1955
+ meta: { timeoutMs },
1956
+ })
1957
+ controller.abort(timeout)
1958
+ reject(timeout)
1959
+ }, timeoutMs)
1960
+ })
1961
+ try {
1962
+ await Promise.race([execution, expired])
1963
+ } finally {
1964
+ clearTimeout(timer)
1965
+ }
1966
+ }
1967
+
1440
1968
  /**
1441
1969
  * Resolve provenance do ContextBase, com default `{ kind: 'system', source: 'unknown' }`
1442
1970
  * quando caller não passa. Server adapter / scheduler / runtime devem sempre