@checkstack/incident-backend 1.11.0 → 1.13.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.
@@ -324,6 +324,76 @@ describe("incident automation actions", () => {
324
324
  expect(service.resolveIncident).toHaveBeenCalledWith("INC-1", "Fixed");
325
325
  });
326
326
 
327
+ // SLO-1 regression: an automation-driven resolve mutates the incident WITHOUT
328
+ // going through the RPC router, so it must emit `incident.lifecycle.changed`
329
+ // itself — otherwise an override-bearing incident resolved by an automation
330
+ // never triggers SLO downtime reconciliation and its incident-sourced event
331
+ // leaks open.
332
+ it("emits incident.lifecycle.changed on an automation resolve (SLO downtime reconcile)", async () => {
333
+ const resolved = {
334
+ id: "INC-1",
335
+ status: "resolved",
336
+ severity: "critical",
337
+ systemIds: ["sys-1", "sys-2"],
338
+ };
339
+ const service = makeServiceStub({
340
+ resolveIncident: mock(
341
+ async () => resolved,
342
+ ) as unknown as IncidentService["resolveIncident"],
343
+ });
344
+ const emit = mock(async () => {});
345
+ const eventBus = { emit } as unknown as Parameters<
346
+ typeof createIncidentActions
347
+ >[0]["eventBus"];
348
+ const resolveAction = createIncidentActions({ service, eventBus })[1];
349
+
350
+ await resolveAction.execute({
351
+ ...actionContext,
352
+ config: { incidentId: "INC-1", message: "Fixed" } as never,
353
+ });
354
+
355
+ expect(emit).toHaveBeenCalledTimes(1);
356
+ const emitArgs = emit.mock.calls[0] as unknown[] | undefined;
357
+ const hook = emitArgs?.[0] as { id?: string } | undefined;
358
+ const payload = emitArgs?.[1] as
359
+ | { systemIds?: string[]; action?: string }
360
+ | undefined;
361
+ expect(hook?.id).toBe("incident.lifecycle.changed");
362
+ expect(payload?.action).toBe("resolved");
363
+ expect(payload?.systemIds).toEqual(["sys-1", "sys-2"]);
364
+ });
365
+
366
+ it("emits incident.lifecycle.changed on an automation update_status → resolved", async () => {
367
+ const update = {
368
+ id: "upd-3",
369
+ incidentId: "INC-1",
370
+ message: "Status changed to resolved",
371
+ createdAt: new Date(),
372
+ };
373
+ const service = makeServiceStub({
374
+ addUpdate: mock(
375
+ async () => update,
376
+ ) as unknown as IncidentService["addUpdate"],
377
+ });
378
+ const emit = mock(async () => {});
379
+ const eventBus = { emit } as unknown as Parameters<
380
+ typeof createIncidentActions
381
+ >[0]["eventBus"];
382
+ const updateStatusAction = createIncidentActions({ service, eventBus })[3];
383
+
384
+ await updateStatusAction.execute({
385
+ ...actionContext,
386
+ config: { incidentId: "INC-1", status: "resolved" } as never,
387
+ });
388
+
389
+ expect(emit).toHaveBeenCalledTimes(1);
390
+ const payload = (emit.mock.calls[0] as unknown[] | undefined)?.[1] as
391
+ | { systemIds?: string[]; action?: string }
392
+ | undefined;
393
+ expect(payload?.action).toBe("resolved");
394
+ expect(payload?.systemIds).toEqual(["sys-1"]);
395
+ });
396
+
327
397
  // 6(b) regression: an action-driven resolve must route through the reactive
328
398
  // entity (like the RPC router) so it appends an `entity_transitions` row,
329
399
  // emits `ENTITY_CHANGED` (waking `wait_until`), and fires the
@@ -11,7 +11,7 @@
11
11
  * and to match `wait_for_trigger` waits against the same incident.
12
12
  */
13
13
  import { z } from "zod";
14
- import { Versioned } from "@checkstack/backend-api";
14
+ import { Versioned, type EventBus } from "@checkstack/backend-api";
15
15
  import type {
16
16
  ActionDefinition,
17
17
  ArtifactTypeDefinition,
@@ -22,9 +22,11 @@ import { makeEntityDrivenTriggerSetup } from "@checkstack/automation-backend";
22
22
  import {
23
23
  IncidentSeverityEnum,
24
24
  IncidentStatusEnum,
25
+ type IncidentLifecycleAction,
25
26
  } from "@checkstack/incident-common";
26
27
 
27
28
  import type { IncidentService } from "./service";
29
+ import { emitIncidentLifecycleChanged } from "./hooks";
28
30
  import {
29
31
  toIncidentEntityState,
30
32
  writeIncidentEntity,
@@ -259,12 +261,20 @@ export interface IncidentActionDeps {
259
261
  * non-reactively.
260
262
  */
261
263
  getIncidentEntity?: () => EntityHandle<IncidentEntityState> | undefined;
264
+ /**
265
+ * Distributed event bus used to emit `incident.lifecycle.changed` after an
266
+ * automation-driven lifecycle mutation. Automations mutate incidents WITHOUT
267
+ * going through the RPC router, so without this an automation resolving an
268
+ * override-bearing incident would never notify SLO downtime reconciliation and
269
+ * the incident-sourced downtime event would leak open. Undefined in tests.
270
+ */
271
+ eventBus?: EventBus;
262
272
  }
263
273
 
264
274
  export function createIncidentActions(
265
275
  deps: IncidentActionDeps,
266
276
  ): ActionDefinition<unknown, unknown>[] {
267
- const { service, getIncidentEntity } = deps;
277
+ const { service, getIncidentEntity, eventBus } = deps;
268
278
 
269
279
  const createAction: ActionDefinition<
270
280
  z.infer<typeof incidentCreateConfigSchema>,
@@ -339,6 +349,17 @@ export function createIncidentActions(
339
349
  );
340
350
  } else {
341
351
  logger.info(`Automation created incident ${incident.id}`);
352
+ // Only a real create is a lifecycle mutation; a REUSE changed nothing,
353
+ // so SLO already reconciled it at its original creation.
354
+ await emitIncidentLifecycleChanged({
355
+ eventBus,
356
+ logger,
357
+ payload: {
358
+ incidentId: incident.id,
359
+ systemIds: incident.systemIds,
360
+ action: "created",
361
+ },
362
+ });
342
363
  }
343
364
  return {
344
365
  success: true,
@@ -364,6 +385,15 @@ export function createIncidentActions(
364
385
  },
365
386
  });
366
387
  logger.info(`Automation created incident ${incident.id}`);
388
+ await emitIncidentLifecycleChanged({
389
+ eventBus,
390
+ logger,
391
+ payload: {
392
+ incidentId: incident.id,
393
+ systemIds: incident.systemIds,
394
+ action: "created",
395
+ },
396
+ });
367
397
  return {
368
398
  success: true,
369
399
  externalId: incident.id,
@@ -442,6 +472,17 @@ export function createIncidentActions(
442
472
  };
443
473
  }
444
474
  logger.info(`Automation resolved incident ${incident.id}`);
475
+ // A resolve clears any active health override, so SLO must close the
476
+ // incident-forced downtime — the defect this notifier fixes.
477
+ await emitIncidentLifecycleChanged({
478
+ eventBus,
479
+ logger,
480
+ payload: {
481
+ incidentId: incident.id,
482
+ systemIds: incident.systemIds,
483
+ action: "resolved",
484
+ },
485
+ });
445
486
  return {
446
487
  success: true,
447
488
  externalId: incident.id,
@@ -485,7 +526,8 @@ export function createIncidentActions(
485
526
  // is a no-op diff (no event). `opts.runId` masks run-resolved secrets.
486
527
  const captured: {
487
528
  update: Awaited<ReturnType<typeof service.addUpdate>> | null;
488
- } = { update: null };
529
+ systemIds: string[];
530
+ } = { update: null, systemIds: [] };
489
531
  await writeIncidentEntity({
490
532
  handle: getIncidentEntity?.(),
491
533
  incidentId,
@@ -500,6 +542,7 @@ export function createIncidentActions(
500
542
  if (!incident) {
501
543
  throw new Error(`Incident ${incidentId} not found`);
502
544
  }
545
+ captured.systemIds = incident.systemIds;
503
546
  return toIncidentEntityState(incident);
504
547
  },
505
548
  });
@@ -513,6 +556,21 @@ export function createIncidentActions(
513
556
  logger.info(
514
557
  `Automation added update ${update.id} to incident ${incidentId}`,
515
558
  );
559
+ // Only a status-changing update is a lifecycle mutation SLO cares about; a
560
+ // comment-only update changes no health-relevant state. A change to
561
+ // resolved clears any override, so SLO must reconcile.
562
+ if (config.statusChange) {
563
+ await emitIncidentLifecycleChanged({
564
+ eventBus,
565
+ logger,
566
+ payload: {
567
+ incidentId,
568
+ systemIds: captured.systemIds,
569
+ action:
570
+ config.statusChange === "resolved" ? "resolved" : "updated",
571
+ },
572
+ });
573
+ }
516
574
  return {
517
575
  success: true,
518
576
  externalId: update.id,
@@ -553,7 +611,8 @@ export function createIncidentActions(
553
611
  // re-reads post-write state. `opts.runId` masks run-resolved secrets.
554
612
  const captured: {
555
613
  update: Awaited<ReturnType<typeof service.addUpdate>> | null;
556
- } = { update: null };
614
+ systemIds: string[];
615
+ } = { update: null, systemIds: [] };
557
616
  await writeIncidentEntity({
558
617
  handle: getIncidentEntity?.(),
559
618
  incidentId,
@@ -568,6 +627,7 @@ export function createIncidentActions(
568
627
  if (!incident) {
569
628
  throw new Error(`Incident ${incidentId} not found`);
570
629
  }
630
+ captured.systemIds = incident.systemIds;
571
631
  return toIncidentEntityState(incident);
572
632
  },
573
633
  });
@@ -581,6 +641,19 @@ export function createIncidentActions(
581
641
  logger.info(
582
642
  `Automation set incident ${incidentId} status → ${config.status}`,
583
643
  );
644
+ // A status flip is a lifecycle mutation; a flip to resolved clears any
645
+ // override, so SLO must reconcile the affected systems' downtime.
646
+ const action: IncidentLifecycleAction =
647
+ config.status === "resolved" ? "resolved" : "updated";
648
+ await emitIncidentLifecycleChanged({
649
+ eventBus,
650
+ logger,
651
+ payload: {
652
+ incidentId,
653
+ systemIds: captured.systemIds,
654
+ action,
655
+ },
656
+ });
584
657
  return {
585
658
  success: true,
586
659
  externalId: update.id,
package/src/hooks.ts CHANGED
@@ -1,11 +1,61 @@
1
+ import { createHook, type EventBus, type Logger } from "@checkstack/backend-api";
2
+ import {
3
+ INCIDENT_LIFECYCLE_CHANGED_HOOK_ID,
4
+ type IncidentLifecycleChangedPayload,
5
+ } from "@checkstack/incident-common";
6
+
1
7
  /**
2
8
  * Incident cross-plugin hooks.
3
9
  *
4
10
  * The `incident.created` / `.updated` / `.resolved` hooks were removed in
5
11
  * Phase 4 (§10.1): incidents are now the reactive `incident` entity, whose
6
12
  * change deriver fires the matching `incident.created` / `.updated` /
7
- * `.resolved` trigger events through Stage-1 routing. No cross-plugin hook
8
- * remains, so this object is intentionally empty (kept for the stable
9
- * `export { incidentHooks }` surface).
13
+ * `.resolved` trigger events through Stage-1 routing.
14
+ *
15
+ * `lifecycleChanged` is a distinct, lower-level cross-plugin hook: it fires on
16
+ * EVERY incident lifecycle mutation (create, update — including a health
17
+ * override added / changed / cleared —, resolve, delete, and the auto-incident
18
+ * paths), carrying `{ incidentId, systemIds, action }`. Unlike the reactive
19
+ * `incident` entity change (state `{ status, severity, systemIds }`, so an
20
+ * override-only edit emits nothing), this catches override changes, which
21
+ * `@checkstack/slo-backend` needs to open/close incident-forced SLO downtime.
22
+ * The id + payload contract live in `@checkstack/incident-common` so consumers
23
+ * subscribe without depending on this backend.
24
+ */
25
+ export const incidentLifecycleChangedHook =
26
+ createHook<IncidentLifecycleChangedPayload>(
27
+ INCIDENT_LIFECYCLE_CHANGED_HOOK_ID,
28
+ );
29
+
30
+ export const incidentHooks = {
31
+ lifecycleChanged: incidentLifecycleChangedHook,
32
+ } as const;
33
+
34
+ /**
35
+ * Emit `incident.lifecycle.changed` on the distributed event bus. THE single
36
+ * place the hook is fired, so every incident lifecycle path - the RPC router,
37
+ * the bulk paths, AND the automation actions - shares identical guard/failure
38
+ * semantics and can never drift. Best-effort by contract: the incident write is
39
+ * already committed by the time this runs, so a delivery failure must never turn
40
+ * a successful mutation into a client/run error - consumers reconcile on the
41
+ * next lifecycle event. A no-op when no event bus is wired (tests).
10
42
  */
11
- export const incidentHooks = {} as const;
43
+ export async function emitIncidentLifecycleChanged({
44
+ eventBus,
45
+ logger,
46
+ payload,
47
+ }: {
48
+ eventBus: EventBus | undefined;
49
+ logger: Logger;
50
+ payload: IncidentLifecycleChangedPayload;
51
+ }): Promise<void> {
52
+ if (!eventBus) return;
53
+ try {
54
+ await eventBus.emit(incidentLifecycleChangedHook, payload);
55
+ } catch (error) {
56
+ logger.warn(
57
+ `Failed to emit incident.lifecycle.changed hook for incident ${payload.incidentId}; consumers will reconcile on the next lifecycle event.`,
58
+ { error },
59
+ );
60
+ }
61
+ }
package/src/index.ts CHANGED
@@ -145,6 +145,7 @@ export default createBackendPlugin({
145
145
  rpc: coreServices.rpc,
146
146
  rpcClient: coreServices.rpcClient,
147
147
  signalService: coreServices.signalService,
148
+ eventBus: coreServices.eventBus,
148
149
  cacheManager: coreServices.cacheManager,
149
150
  advisoryLock: coreServices.advisoryLock,
150
151
  resourceResolverRegistry: coreServices.resourceResolverRegistry,
@@ -155,6 +156,7 @@ export default createBackendPlugin({
155
156
  rpc,
156
157
  rpcClient,
157
158
  signalService,
159
+ eventBus,
158
160
  cacheManager,
159
161
  advisoryLock,
160
162
  resourceResolverRegistry,
@@ -198,6 +200,7 @@ export default createBackendPlugin({
198
200
  const router = createRouter({
199
201
  service,
200
202
  signalService,
203
+ eventBus,
201
204
  catalogClient,
202
205
  notificationClient,
203
206
  authClient,
@@ -218,6 +221,7 @@ export default createBackendPlugin({
218
221
  for (const action of createIncidentActions({
219
222
  service,
220
223
  getIncidentEntity: () => incidentEntity,
224
+ eventBus,
221
225
  })) {
222
226
  automationActions.registerAction(action, pluginMetadata);
223
227
  }
@@ -317,6 +317,187 @@ describe("notifyAffectedSystems", () => {
317
317
  });
318
318
  });
319
319
 
320
+ describe("update message in body", () => {
321
+ it("appends the escaped update message as a blockquote", async () => {
322
+ await notifyAffectedSystems({
323
+ catalogClient: mockCatalogClient as never,
324
+ notificationClient: mockNotificationClient as never,
325
+ logger: mockLogger as never,
326
+ incidentId: "inc-1",
327
+ incidentTitle: "API Outage",
328
+ systemIds: ["sys-1"],
329
+ action: "updated",
330
+ severity: "minor",
331
+ updateMessage: "Rolled back the bad deploy, monitoring recovery.",
332
+ });
333
+
334
+ const call = (
335
+ mockNotificationClient.notifyForSubscription.mock
336
+ .calls[0] as unknown as [{ body?: string }]
337
+ )[0];
338
+ expect(call?.body).toContain(
339
+ "\n\n> Rolled back the bad deploy, monitoring recovery",
340
+ );
341
+ });
342
+
343
+ it("escapes markdown control characters in the message", async () => {
344
+ await notifyAffectedSystems({
345
+ catalogClient: mockCatalogClient as never,
346
+ notificationClient: mockNotificationClient as never,
347
+ logger: mockLogger as never,
348
+ incidentId: "inc-1",
349
+ incidentTitle: "API Outage",
350
+ systemIds: ["sys-1"],
351
+ action: "updated",
352
+ severity: "minor",
353
+ updateMessage: "See [here](http://evil) **now** `code`",
354
+ });
355
+
356
+ const call = (
357
+ mockNotificationClient.notifyForSubscription.mock
358
+ .calls[0] as unknown as [{ body?: string }]
359
+ )[0];
360
+ // No unescaped link/bold/code syntax survives into the body.
361
+ expect(call?.body).not.toContain("[here](http://evil)");
362
+ expect(call?.body).not.toContain("**now**");
363
+ expect(call?.body).toContain("\\[here\\]");
364
+ expect(call?.body).toContain("\\*\\*now\\*\\*");
365
+ });
366
+
367
+ it("strips non-whitespace control characters (ESC/NUL/BEL/DEL)", async () => {
368
+ await notifyAffectedSystems({
369
+ catalogClient: mockCatalogClient as never,
370
+ notificationClient: mockNotificationClient as never,
371
+ logger: mockLogger as never,
372
+ incidentId: "inc-1",
373
+ incidentTitle: "API Outage",
374
+ systemIds: ["sys-1"],
375
+ action: "updated",
376
+ severity: "minor",
377
+ updateMessage: "before\u001B\u0000\u0007\u007Fafter",
378
+ });
379
+
380
+ const call = (
381
+ mockNotificationClient.notifyForSubscription.mock
382
+ .calls[0] as unknown as [{ body?: string }]
383
+ )[0];
384
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
385
+ expect(blockquoteLine).toBe("beforeafter");
386
+ // No control characters survive into the excerpt.
387
+ expect(/[\u0000-\u001F\u007F-\u009F]/u.test(blockquoteLine)).toBe(
388
+ false,
389
+ );
390
+ });
391
+
392
+ it("escapes HTML-significant < and & so markup cannot be injected", async () => {
393
+ await notifyAffectedSystems({
394
+ catalogClient: mockCatalogClient as never,
395
+ notificationClient: mockNotificationClient as never,
396
+ logger: mockLogger as never,
397
+ incidentId: "inc-1",
398
+ incidentTitle: "API Outage",
399
+ systemIds: ["sys-1"],
400
+ action: "updated",
401
+ severity: "minor",
402
+ updateMessage: "watch <img onerror=x> & <script>",
403
+ });
404
+
405
+ const call = (
406
+ mockNotificationClient.notifyForSubscription.mock
407
+ .calls[0] as unknown as [{ body?: string }]
408
+ )[0];
409
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
410
+ expect(blockquoteLine).not.toContain("<");
411
+ expect(blockquoteLine).toContain("&lt;img");
412
+ expect(blockquoteLine).toContain("&amp;");
413
+ });
414
+
415
+ it("collapses newlines so the message cannot break out of the blockquote", async () => {
416
+ await notifyAffectedSystems({
417
+ catalogClient: mockCatalogClient as never,
418
+ notificationClient: mockNotificationClient as never,
419
+ logger: mockLogger as never,
420
+ incidentId: "inc-1",
421
+ incidentTitle: "API Outage",
422
+ systemIds: ["sys-1"],
423
+ action: "updated",
424
+ severity: "minor",
425
+ updateMessage: "line one\n\nline two\ninjected",
426
+ });
427
+
428
+ const call = (
429
+ mockNotificationClient.notifyForSubscription.mock
430
+ .calls[0] as unknown as [{ body?: string }]
431
+ )[0];
432
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
433
+ expect(blockquoteLine).not.toContain("\n");
434
+ expect(blockquoteLine).toBe("line one line two injected");
435
+ });
436
+
437
+ it("truncates an over-long message to a bounded length", async () => {
438
+ const longMessage = "a".repeat(1000);
439
+ await notifyAffectedSystems({
440
+ catalogClient: mockCatalogClient as never,
441
+ notificationClient: mockNotificationClient as never,
442
+ logger: mockLogger as never,
443
+ incidentId: "inc-1",
444
+ incidentTitle: "API Outage",
445
+ systemIds: ["sys-1"],
446
+ action: "updated",
447
+ severity: "minor",
448
+ updateMessage: longMessage,
449
+ });
450
+
451
+ const call = (
452
+ mockNotificationClient.notifyForSubscription.mock
453
+ .calls[0] as unknown as [{ body?: string }]
454
+ )[0];
455
+ const blockquoteLine = (call?.body ?? "").split("\n\n> ")[1] ?? "";
456
+ expect(blockquoteLine.endsWith("...")).toBe(true);
457
+ // 500 chars + the "..." indicator.
458
+ expect(blockquoteLine.length).toBeLessThanOrEqual(503);
459
+ });
460
+
461
+ it("omits the blockquote entirely for a blank/whitespace message", async () => {
462
+ await notifyAffectedSystems({
463
+ catalogClient: mockCatalogClient as never,
464
+ notificationClient: mockNotificationClient as never,
465
+ logger: mockLogger as never,
466
+ incidentId: "inc-1",
467
+ incidentTitle: "API Outage",
468
+ systemIds: ["sys-1"],
469
+ action: "updated",
470
+ severity: "minor",
471
+ updateMessage: " \n ",
472
+ });
473
+
474
+ const call = (
475
+ mockNotificationClient.notifyForSubscription.mock
476
+ .calls[0] as unknown as [{ body?: string }]
477
+ )[0];
478
+ expect(call?.body).not.toContain("\n\n>");
479
+ });
480
+
481
+ it("omits the blockquote when no message is provided", async () => {
482
+ await notifyAffectedSystems({
483
+ catalogClient: mockCatalogClient as never,
484
+ notificationClient: mockNotificationClient as never,
485
+ logger: mockLogger as never,
486
+ incidentId: "inc-1",
487
+ incidentTitle: "API Outage",
488
+ systemIds: ["sys-1"],
489
+ action: "created",
490
+ severity: "minor",
491
+ });
492
+
493
+ const call = (
494
+ mockNotificationClient.notifyForSubscription.mock
495
+ .calls[0] as unknown as [{ body?: string }]
496
+ )[0];
497
+ expect(call?.body).not.toContain("\n\n>");
498
+ });
499
+ });
500
+
320
501
  describe("error handling", () => {
321
502
  it("logs a warning but does not throw when the notify call fails", async () => {
322
503
  mockNotificationClient.notifyForSubscription.mockRejectedValue(
@@ -7,6 +7,7 @@ import type { Logger } from "@checkstack/backend-api";
7
7
  import type { InferClient } from "@checkstack/common";
8
8
  import { resolveRoute } from "@checkstack/common";
9
9
  import type { NotificationApi } from "@checkstack/notification-common";
10
+ import { buildUpdateMessageSuffix } from "@checkstack/notification-common";
10
11
  import {
11
12
  incidentRoutes,
12
13
  incidentCollapseKey,
@@ -29,6 +30,13 @@ export async function notifyAffectedSystems(props: {
29
30
  systemNames?: Map<string, string>;
30
31
  action: "created" | "updated" | "resolved" | "reopened";
31
32
  severity: string;
33
+ /**
34
+ * The latest incident update's free-text message. When present it is
35
+ * escaped, single-lined, truncated, and appended to the notification body as
36
+ * a blockquote so subscribers see WHAT changed, not just that something did.
37
+ * User-supplied, so it is always sanitized before it reaches a markdown body.
38
+ */
39
+ updateMessage?: string;
32
40
  }): Promise<void> {
33
41
  const {
34
42
  notificationClient,
@@ -39,6 +47,7 @@ export async function notifyAffectedSystems(props: {
39
47
  systemNames,
40
48
  action,
41
49
  severity,
50
+ updateMessage,
42
51
  } = props;
43
52
  void props.catalogClient;
44
53
 
@@ -64,12 +73,14 @@ export async function notifyAffectedSystems(props: {
64
73
  }),
65
74
  );
66
75
 
76
+ const messageSuffix = buildUpdateMessageSuffix({ message: updateMessage });
77
+
67
78
  try {
68
79
  await notificationClient.notifyForSubscription({
69
80
  specId: incidentSystemSubscription.specId,
70
81
  resourceKeys: uniqueSystemIds,
71
82
  title: `Incident ${actionText}: ${incidentTitle}`,
72
- body: `Incident **"${incidentTitle}"** has been ${actionText}.`,
83
+ body: `Incident **"${incidentTitle}"** has been ${actionText}.${messageSuffix}`,
73
84
  importance,
74
85
  action: { label: "View Incident", url: incidentDetailPath },
75
86
  collapseKey: incidentCollapseKey(incidentId),