@checkstack/incident-backend 1.3.0 → 1.4.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.
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import type {
3
+ EntityChanged,
4
+ EntityHandle,
5
+ } from "@checkstack/automation-backend";
6
+ import { SYSTEM_ACTOR } from "@checkstack/common";
7
+
8
+ import {
9
+ INCIDENT_ENTITY_KIND,
10
+ INCIDENT_TRIGGER_EVENTS,
11
+ IncidentEntityStateSchema,
12
+ createIncidentEntityRead,
13
+ deriveIncidentTriggerEvents,
14
+ incidentChangeToPayload,
15
+ removeIncidentEntity,
16
+ toIncidentEntityState,
17
+ writeIncidentEntity,
18
+ type IncidentEntityState,
19
+ } from "./incident-entity";
20
+ import {
21
+ incidentCreatedTrigger,
22
+ incidentResolvedTrigger,
23
+ incidentUpdatedTrigger,
24
+ } from "./automations";
25
+ import type { IncidentService } from "./service";
26
+
27
+ function change(overrides: Partial<EntityChanged> = {}): EntityChanged {
28
+ return {
29
+ kind: INCIDENT_ENTITY_KIND,
30
+ id: "inc-1",
31
+ prev: { status: "investigating", severity: "major", systemIds: ["a"] },
32
+ next: { status: "monitoring", severity: "major", systemIds: ["a"] },
33
+ delta: { status: "monitoring" },
34
+ changedFields: ["status"],
35
+ actor: SYSTEM_ACTOR,
36
+ occurredAt: new Date().toISOString(),
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ describe("deriveIncidentTriggerEvents", () => {
42
+ it("create → incident.created", () => {
43
+ expect(
44
+ deriveIncidentTriggerEvents(
45
+ change({
46
+ prev: null,
47
+ next: { status: "investigating", severity: "minor", systemIds: ["a"] },
48
+ }),
49
+ ),
50
+ ).toEqual([INCIDENT_TRIGGER_EVENTS.created]);
51
+ });
52
+
53
+ it("transition to resolved → incident.resolved", () => {
54
+ expect(
55
+ deriveIncidentTriggerEvents(
56
+ change({
57
+ prev: { status: "monitoring", severity: "major", systemIds: ["a"] },
58
+ next: { status: "resolved", severity: "major", systemIds: ["a"] },
59
+ }),
60
+ ),
61
+ ).toEqual([INCIDENT_TRIGGER_EVENTS.resolved]);
62
+ });
63
+
64
+ it("non-resolve field change → incident.updated", () => {
65
+ expect(deriveIncidentTriggerEvents(change())).toEqual([
66
+ INCIDENT_TRIGGER_EVENTS.updated,
67
+ ]);
68
+ });
69
+
70
+ it("reopen (resolved → investigating) → incident.updated", () => {
71
+ expect(
72
+ deriveIncidentTriggerEvents(
73
+ change({
74
+ prev: { status: "resolved", severity: "major", systemIds: ["a"] },
75
+ next: { status: "investigating", severity: "major", systemIds: ["a"] },
76
+ }),
77
+ ),
78
+ ).toEqual([INCIDENT_TRIGGER_EVENTS.updated]);
79
+ });
80
+
81
+ it("tombstone → no event (no incident.deleted)", () => {
82
+ expect(deriveIncidentTriggerEvents(change({ next: null }))).toEqual([]);
83
+ });
84
+ });
85
+
86
+ describe("incidentChangeToPayload — payloadSchema parity", () => {
87
+ it("a create payload validates against the created trigger's payloadSchema", () => {
88
+ const payload = incidentChangeToPayload(
89
+ change({
90
+ prev: null,
91
+ next: { status: "investigating", severity: "minor", systemIds: ["a", "b"] },
92
+ delta: { status: "investigating" },
93
+ changedFields: ["status", "severity", "systemIds"],
94
+ }),
95
+ );
96
+ const parsed = incidentCreatedTrigger.payloadSchema.parse(payload);
97
+ expect(parsed.incidentId).toBe("inc-1");
98
+ expect(parsed.status).toBe("investigating");
99
+ expect(parsed.severity).toBe("minor");
100
+ expect(parsed.systemIds).toEqual(["a", "b"]);
101
+ });
102
+
103
+ it("a status-flip payload validates against the updated trigger's payloadSchema and carries statusChange", () => {
104
+ const payload = incidentChangeToPayload(change());
105
+ const parsed = incidentUpdatedTrigger.payloadSchema.parse(payload);
106
+ expect(parsed.incidentId).toBe("inc-1");
107
+ expect(parsed.status).toBe("monitoring");
108
+ // status was in changedFields → statusChange mirrors the new status.
109
+ expect(parsed.statusChange).toBe("monitoring");
110
+ });
111
+
112
+ it("a resolve payload validates against the resolved trigger's payloadSchema", () => {
113
+ const payload = incidentChangeToPayload(
114
+ change({
115
+ prev: { status: "monitoring", severity: "major", systemIds: ["a"] },
116
+ next: { status: "resolved", severity: "major", systemIds: ["a"] },
117
+ }),
118
+ );
119
+ const parsed = incidentResolvedTrigger.payloadSchema.parse(payload);
120
+ expect(parsed.incidentId).toBe("inc-1");
121
+ expect(parsed.severity).toBe("major");
122
+ expect(parsed.systemIds).toEqual(["a"]);
123
+ });
124
+
125
+ it("omits statusChange when status did not change", () => {
126
+ const payload = incidentChangeToPayload(
127
+ change({
128
+ delta: { severity: "critical" },
129
+ changedFields: ["severity"],
130
+ next: { status: "monitoring", severity: "critical", systemIds: ["a"] },
131
+ }),
132
+ );
133
+ expect("statusChange" in payload).toBe(false);
134
+ });
135
+ });
136
+
137
+ describe("IncidentEntityStateSchema", () => {
138
+ it("parses the reactive subset", () => {
139
+ const parsed = IncidentEntityStateSchema.parse({
140
+ status: "investigating",
141
+ severity: "critical",
142
+ systemIds: ["a", "b"],
143
+ });
144
+ expect(parsed.systemIds).toEqual(["a", "b"]);
145
+ });
146
+ });
147
+
148
+ describe("toIncidentEntityState", () => {
149
+ it("projects the reactive subset off a full incident", () => {
150
+ expect(
151
+ toIncidentEntityState({
152
+ status: "monitoring",
153
+ severity: "major",
154
+ systemIds: ["a", "b"],
155
+ }),
156
+ ).toEqual({ status: "monitoring", severity: "major", systemIds: ["a", "b"] });
157
+ });
158
+ });
159
+
160
+ describe("createIncidentEntityRead", () => {
161
+ it("routes the batched read straight to the service (plugin-backed)", async () => {
162
+ const seen: ReadonlyArray<string>[] = [];
163
+ const service = {
164
+ async getManyEntityStates(ids: ReadonlyArray<string>) {
165
+ seen.push(ids);
166
+ return {
167
+ "inc-1": {
168
+ status: "investigating" as const,
169
+ severity: "minor" as const,
170
+ systemIds: ["a"],
171
+ },
172
+ };
173
+ },
174
+ } as unknown as IncidentService;
175
+ const read = createIncidentEntityRead(service);
176
+ const out = await read(["inc-1", "inc-2"]);
177
+ expect(seen).toEqual([["inc-1", "inc-2"]]);
178
+ expect(out["inc-1"]).toEqual({
179
+ status: "investigating",
180
+ severity: "minor",
181
+ systemIds: ["a"],
182
+ });
183
+ });
184
+ });
185
+
186
+ describe("writeIncidentEntity", () => {
187
+ it("drives the write through handle.mutate keyed by incident id", async () => {
188
+ const calls: Array<{ id: string; next: IncidentEntityState }> = [];
189
+ const handle = {
190
+ kind: INCIDENT_ENTITY_KIND,
191
+ async mutate(input: {
192
+ id: string;
193
+ apply: () => Promise<IncidentEntityState>;
194
+ }) {
195
+ const next = await input.apply();
196
+ calls.push({ id: input.id, next });
197
+ return next;
198
+ },
199
+ } as unknown as EntityHandle<IncidentEntityState>;
200
+
201
+ let applied = false;
202
+ await writeIncidentEntity({
203
+ handle,
204
+ incidentId: "inc-9",
205
+ apply: async () => {
206
+ applied = true;
207
+ return { status: "monitoring", severity: "critical", systemIds: ["a"] };
208
+ },
209
+ });
210
+ expect(applied).toBe(true);
211
+ expect(calls).toEqual([
212
+ {
213
+ id: "inc-9",
214
+ next: { status: "monitoring", severity: "critical", systemIds: ["a"] },
215
+ },
216
+ ]);
217
+ });
218
+
219
+ it("still runs the plugin write when no handle is wired", async () => {
220
+ let applied = false;
221
+ await writeIncidentEntity({
222
+ handle: undefined,
223
+ incidentId: "inc-9",
224
+ apply: async () => {
225
+ applied = true;
226
+ return { status: "investigating", severity: "minor", systemIds: [] };
227
+ },
228
+ });
229
+ expect(applied).toBe(true);
230
+ });
231
+ });
232
+
233
+ describe("removeIncidentEntity", () => {
234
+ it("tombstones via handle.remove({ apply })", async () => {
235
+ const removed: string[] = [];
236
+ let deleted = false;
237
+ const handle = {
238
+ kind: INCIDENT_ENTITY_KIND,
239
+ async remove(input: { id: string; apply: () => Promise<void> }) {
240
+ await input.apply();
241
+ removed.push(input.id);
242
+ },
243
+ } as unknown as EntityHandle<IncidentEntityState>;
244
+ await removeIncidentEntity({
245
+ handle,
246
+ incidentId: "inc-9",
247
+ apply: async () => {
248
+ deleted = true;
249
+ },
250
+ });
251
+ expect(deleted).toBe(true);
252
+ expect(removed).toEqual(["inc-9"]);
253
+ });
254
+
255
+ it("still runs the delete when no handle is wired", async () => {
256
+ let deleted = false;
257
+ await removeIncidentEntity({
258
+ handle: undefined,
259
+ incidentId: "inc-9",
260
+ apply: async () => {
261
+ deleted = true;
262
+ },
263
+ });
264
+ expect(deleted).toBe(true);
265
+ });
266
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The reactive `incident` entity (reactive automation engine §10.1).
3
+ *
4
+ * Model B PLUGIN-BACKED entity: the `incidents` + `incident_systems` tables
5
+ * are authoritative AND ARE the entity's current-state storage — there is NO
6
+ * framework `entity_state` row for an incident. `defineEntity({ read })` makes
7
+ * that plugin state reactive: every reactive-state write goes through
8
+ * `handle.mutate`, whose `apply()` performs the REAL `incidents`/junction write
9
+ * via the incident service (the plugin's own db/tx) and returns the resulting
10
+ * `{ status, severity, systemIds }`. The framework snapshots `prev` via `read`,
11
+ * appends the transition log (its own db), and emits `ENTITY_CHANGED`. The
12
+ * change → trigger-event deriver reproduces `incident.created` / `.updated` /
13
+ * `.resolved` so automations keep firing.
14
+ */
15
+ import { z } from "zod";
16
+ import {
17
+ IncidentSeverityEnum,
18
+ IncidentStatusEnum,
19
+ } from "@checkstack/incident-common";
20
+ import type {
21
+ EntityChangeDeriver,
22
+ EntityChangePayloadMapper,
23
+ EntityHandle,
24
+ EntityMutationOpts,
25
+ EntityRead,
26
+ } from "@checkstack/automation-backend";
27
+ import {
28
+ withEntityRemove,
29
+ withEntityWrite,
30
+ } from "@checkstack/automation-backend";
31
+
32
+ import type { IncidentService } from "./service";
33
+
34
+ export const INCIDENT_ENTITY_KIND = "incident";
35
+
36
+ export const IncidentEntityStateSchema = z.object({
37
+ status: IncidentStatusEnum,
38
+ severity: IncidentSeverityEnum,
39
+ systemIds: z.array(z.string()),
40
+ });
41
+
42
+ export type IncidentEntityState = z.infer<typeof IncidentEntityStateSchema>;
43
+
44
+ export const INCIDENT_TRIGGER_EVENTS = {
45
+ created: "incident.created",
46
+ updated: "incident.updated",
47
+ resolved: "incident.resolved",
48
+ } as const;
49
+
50
+ function readStatus(state: Record<string, unknown> | null): string | null {
51
+ if (state === null) return null;
52
+ const status = state["status"];
53
+ return typeof status === "string" ? status : null;
54
+ }
55
+
56
+ /**
57
+ * `incident` change → trigger events.
58
+ *
59
+ * - create (`prev === null`) → `incident.created`
60
+ * - transition TO `resolved` (and not already resolved) → `incident.resolved`
61
+ * - any other field change → `incident.updated`
62
+ * - tombstone (`next === null`, from `deleteIncident`) → no event (there is
63
+ * no `incident.deleted` trigger event)
64
+ *
65
+ * NOTE (deviation): the old `addUpdate`-with-status=resolved path emitted
66
+ * BOTH `incident.updated` and `incident.resolved`; the deriver fires only
67
+ * `incident.resolved` on a resolution (matching the dedicated
68
+ * `resolveIncident` / `resolveAutoIncident` paths, which only emitted
69
+ * `incident.resolved`). A resolution is no longer also surfaced as a generic
70
+ * `incident.updated` — automations meant to react to resolution should use
71
+ * the `incident.resolved` trigger.
72
+ */
73
+ export const deriveIncidentTriggerEvents: EntityChangeDeriver = (changed) => {
74
+ if (changed.prev === null && changed.next !== null) {
75
+ return [INCIDENT_TRIGGER_EVENTS.created];
76
+ }
77
+ if (changed.next === null) {
78
+ return [];
79
+ }
80
+ const prevStatus = readStatus(changed.prev);
81
+ const nextStatus = readStatus(changed.next);
82
+ if (nextStatus === "resolved" && prevStatus !== "resolved") {
83
+ return [INCIDENT_TRIGGER_EVENTS.resolved];
84
+ }
85
+ return [INCIDENT_TRIGGER_EVENTS.updated];
86
+ };
87
+
88
+ function readField(
89
+ state: Record<string, unknown> | null,
90
+ field: string,
91
+ ): unknown {
92
+ return state === null ? undefined : state[field];
93
+ }
94
+
95
+ /**
96
+ * Map an `incident` entity change to the domain-named `trigger.payload` the
97
+ * incident triggers declare via `payloadSchema` (`incidentId`, `systemIds`,
98
+ * `severity`, `status`, `statusChange`). This restores the keys operators read
99
+ * (`trigger.payload.incidentId` etc.) that the generic change shape omits.
100
+ *
101
+ * The reactive `incident` entity state is only `{ status, severity, systemIds }`,
102
+ * so the schemas' descriptive fields (`title`, `description`, `createdAt`,
103
+ * `resolvedAt`) are NOT available from a change and are declared OPTIONAL on
104
+ * those schemas. `statusChange` is populated when the status field changed (the
105
+ * `updated` trigger's hint), so a status flip carries the new status both as
106
+ * `status` and `statusChange`.
107
+ */
108
+ export const incidentChangeToPayload: EntityChangePayloadMapper = (changed) => {
109
+ const next = changed.next;
110
+ const statusChanged = changed.changedFields.includes("status");
111
+ const nextStatus = readField(next, "status");
112
+ return {
113
+ incidentId: changed.id,
114
+ systemIds: Array.isArray(readField(next, "systemIds"))
115
+ ? (readField(next, "systemIds") as unknown[])
116
+ : [],
117
+ severity: readField(next, "severity"),
118
+ status: nextStatus,
119
+ ...(statusChanged && typeof nextStatus === "string"
120
+ ? { statusChange: nextStatus }
121
+ : {}),
122
+ };
123
+ };
124
+
125
+ /**
126
+ * Build the PLUGIN-BACKED `read` accessor for the `incident` entity. Routes
127
+ * straight to the service's batched authoritative read — no framework storage.
128
+ */
129
+ export function createIncidentEntityRead(
130
+ service: IncidentService,
131
+ ): EntityRead<IncidentEntityState> {
132
+ return (ids) => service.getManyEntityStates(ids);
133
+ }
134
+
135
+ /**
136
+ * Project a service incident shape onto the reactive `{ status, severity,
137
+ * systemIds }` subset. The router's service writes return the full incident;
138
+ * this is the `apply()` return for `handle.mutate`.
139
+ */
140
+ export function toIncidentEntityState(incident: {
141
+ status: IncidentEntityState["status"];
142
+ severity: IncidentEntityState["severity"];
143
+ systemIds: string[];
144
+ }): IncidentEntityState {
145
+ return {
146
+ status: incident.status,
147
+ severity: incident.severity,
148
+ systemIds: incident.systemIds,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Drive a reactive-state incident write through `handle.mutate` (§10.1).
154
+ * `apply` performs the REAL `incidents`/junction write via the service (the
155
+ * plugin's own db/tx) and returns the new reactive state. The framework
156
+ * snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
157
+ * (the deriver turns that into `incident.created/.updated/.resolved`).
158
+ *
159
+ * When no handle is available (tests construct the router without one), the
160
+ * write still runs — the entity reactivity is layered on top, never required
161
+ * for the underlying write to succeed.
162
+ */
163
+ export async function writeIncidentEntity(args: {
164
+ handle: EntityHandle<IncidentEntityState> | undefined;
165
+ incidentId: string;
166
+ /**
167
+ * Mutation context (actor / runId). When the write originates inside a
168
+ * dispatch run, pass `opts: { runId }` so a run-resolved secret that lands
169
+ * in the reactive state is masked in the transition rows + `ENTITY_CHANGED`.
170
+ */
171
+ opts?: EntityMutationOpts;
172
+ apply: () => Promise<IncidentEntityState>;
173
+ }): Promise<void> {
174
+ const { handle, incidentId, opts, apply } = args;
175
+ await withEntityWrite({ handle, id: incidentId, opts, apply });
176
+ }
177
+
178
+ /**
179
+ * Drive an incident tombstone through `handle.remove` (§10.1). `apply`
180
+ * performs the REAL delete via the service; the framework records the
181
+ * tombstone transition and emits a tombstone change (the deriver fires
182
+ * nothing — there is no `incident.deleted` trigger event). Without a handle,
183
+ * the delete still runs.
184
+ */
185
+ export async function removeIncidentEntity(args: {
186
+ handle: EntityHandle<IncidentEntityState> | undefined;
187
+ incidentId: string;
188
+ apply: () => Promise<void>;
189
+ }): Promise<void> {
190
+ const { handle, incidentId, apply } = args;
191
+ await withEntityRemove({ handle, id: incidentId, apply });
192
+ }
package/src/index.ts CHANGED
@@ -12,7 +12,10 @@ import {
12
12
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
13
13
  import {
14
14
  automationActionExtensionPoint,
15
+ automationArtifactTypeExtensionPoint,
15
16
  automationTriggerExtensionPoint,
17
+ entityExtensionPoint,
18
+ type EntityHandle,
16
19
  } from "@checkstack/automation-backend";
17
20
  import {
18
21
  NotificationApi,
@@ -22,16 +25,39 @@ import { IncidentService } from "./service";
22
25
  import { createRouter } from "./router";
23
26
  import { CatalogApi } from "@checkstack/catalog-common";
24
27
  import { AuthApi } from "@checkstack/auth-common";
25
- import { catalogHooks } from "@checkstack/catalog-backend";
28
+ import { CATALOG_SYSTEM_ENTITY_KIND } from "@checkstack/catalog-backend";
29
+ import {
30
+ INCIDENT_ENTITY_KIND,
31
+ IncidentEntityStateSchema,
32
+ createIncidentEntityRead,
33
+ deriveIncidentTriggerEvents,
34
+ incidentChangeToPayload,
35
+ type IncidentEntityState,
36
+ } from "./incident-entity";
26
37
  import { registerSearchProvider } from "@checkstack/command-backend";
27
38
  import { resolveRoute } from "@checkstack/common";
28
39
  import { createIncidentCache } from "./cache";
29
- import { createIncidentActions, incidentTriggers } from "./automations";
40
+ import {
41
+ createIncidentActions,
42
+ incidentArtifactType,
43
+ incidentTriggers,
44
+ } from "./automations";
30
45
 
31
46
  // =============================================================================
32
47
  // Plugin Definition
33
48
  // =============================================================================
34
49
 
50
+ // Reactive `incident` entity handle (§10.1). Defined in register(); mutated
51
+ // from the router onward.
52
+ let incidentEntity: EntityHandle<IncidentEntityState> | undefined;
53
+
54
+ // The incident service is created in init() (it needs the resolved database),
55
+ // but the PLUGIN-BACKED entity `read` accessor must be supplied at
56
+ // `defineEntity` time in register(). This holder bridges the two: the `read`
57
+ // closure resolves the service lazily, and init() sets it before any mutation
58
+ // runs (the registry only mutates from init() onward).
59
+ let incidentServiceRef: IncidentService | undefined;
60
+
35
61
  export default createBackendPlugin({
36
62
  metadata: pluginMetadata,
37
63
  register(env) {
@@ -41,10 +67,10 @@ export default createBackendPlugin({
41
67
  incidentGroupSubscription,
42
68
  ]);
43
69
 
44
- // Register hooks as automation triggers — buffered until the
45
- // automation plugin's `register()` runs and the extension point
46
- // resolves. Triggers expose `contextKey` so wait_for_trigger can
47
- // match resume events back to the originating incident.
70
+ // Register triggers — buffered until the automation plugin's
71
+ // `register()` runs and the extension point resolves. Triggers expose
72
+ // `contextKey` so wait_for_trigger can match resume events back to the
73
+ // originating incident.
48
74
  const automationTriggers = env.getExtensionPoint(
49
75
  automationTriggerExtensionPoint,
50
76
  );
@@ -52,6 +78,43 @@ export default createBackendPlugin({
52
78
  automationTriggers.registerTrigger(trigger, pluginMetadata);
53
79
  }
54
80
 
81
+ // ─── Reactive `incident` entity (§10.1) ────────────────────────────
82
+ // PLUGIN-BACKED (Model B): the `incidents` + `incident_systems` tables ARE
83
+ // the current-state storage. `read` routes straight to the service's
84
+ // batched authoritative read — no framework `entity_state` row, so no
85
+ // `indexes` (those only apply to store-backed kinds). The `read` closure
86
+ // resolves the service set by init() (mutations only happen from init on).
87
+ const entityPoint = env.getExtensionPoint(entityExtensionPoint);
88
+ incidentEntity = entityPoint.defineEntity<IncidentEntityState>({
89
+ kind: INCIDENT_ENTITY_KIND,
90
+ state: IncidentEntityStateSchema,
91
+ read: (ids) => {
92
+ const svc = incidentServiceRef;
93
+ if (!svc) {
94
+ throw new Error(
95
+ "incident entity read before init: service not yet resolved",
96
+ );
97
+ }
98
+ return createIncidentEntityRead(svc)(ids);
99
+ },
100
+ });
101
+ entityPoint.registerChangeDeriver({
102
+ kind: INCIDENT_ENTITY_KIND,
103
+ derive: deriveIncidentTriggerEvents,
104
+ toPayload: incidentChangeToPayload,
105
+ });
106
+ const onEntityChanged = entityPoint.onEntityChanged;
107
+
108
+ // Register the `incident` artifact type so `incident.create` can
109
+ // `produces` it and the close/update actions can `consumes` it.
110
+ const automationArtifactTypes = env.getExtensionPoint(
111
+ automationArtifactTypeExtensionPoint,
112
+ );
113
+ automationArtifactTypes.registerArtifactType(
114
+ incidentArtifactType,
115
+ pluginMetadata,
116
+ );
117
+
55
118
  let incidentCache:
56
119
  | ReturnType<typeof createIncidentCache>
57
120
  | undefined;
@@ -82,6 +145,9 @@ export default createBackendPlugin({
82
145
  const service = new IncidentService(
83
146
  database as SafeDatabase<typeof schema>,
84
147
  );
148
+ // Publish the service for the PLUGIN-BACKED entity `read` accessor
149
+ // (defined in register()). Mutations only run from here onward.
150
+ incidentServiceRef = service;
85
151
  const cache = createIncidentCache({ cacheManager, logger });
86
152
  incidentCache = cache;
87
153
  const router = createRouter(
@@ -92,6 +158,7 @@ export default createBackendPlugin({
92
158
  authClient,
93
159
  logger,
94
160
  cache,
161
+ () => incidentEntity,
95
162
  );
96
163
  rpc.registerRouter(router, incidentContract);
97
164
 
@@ -103,7 +170,10 @@ export default createBackendPlugin({
103
170
  const automationActions = env.getExtensionPoint(
104
171
  automationActionExtensionPoint,
105
172
  );
106
- for (const action of createIncidentActions({ service })) {
173
+ for (const action of createIncidentActions({
174
+ service,
175
+ getIncidentEntity: () => incidentEntity,
176
+ })) {
107
177
  automationActions.registerAction(action, pluginMetadata);
108
178
  }
109
179
 
@@ -138,7 +208,7 @@ export default createBackendPlugin({
138
208
  // associations) + register subscription specs. Per-system /
139
209
  // per-group notification group lifecycle is fully owned by
140
210
  // notification-backend now — incident never touches it.
141
- afterPluginsReady: async ({ database, logger, onHook, rpcClient }) => {
211
+ afterPluginsReady: async ({ database, logger, rpcClient }) => {
142
212
  const typedDb = database as SafeDatabase<typeof schema>;
143
213
  const service = new IncidentService(typedDb);
144
214
  const notificationClient = rpcClient.forPlugin(NotificationApi);
@@ -152,17 +222,27 @@ export default createBackendPlugin({
152
222
  ),
153
223
  ]);
154
224
 
155
- onHook(
156
- catalogHooks.systemDeleted,
157
- async (payload) => {
225
+ // React to catalog system deletion (tombstone) via the reactive
226
+ // `catalog-system` entity instead of the (being-removed)
227
+ // `system.deleted` hook (§10.1). `work-queue` delivery preserved:
228
+ // association cleanup is a side-effecting write that must run once
229
+ // per cluster, not per-instance.
230
+ onEntityChanged({
231
+ kind: CATALOG_SYSTEM_ENTITY_KIND,
232
+ handler: async (change) => {
233
+ if (change.next !== null) return; // tombstone only
234
+ const systemId = change.id;
158
235
  logger.debug(
159
- `Cleaning up incident associations for deleted system: ${payload.systemId}`,
236
+ `Cleaning up incident associations for deleted system: ${systemId}`,
160
237
  );
161
- await service.removeSystemAssociations(payload.systemId);
162
- await incidentCache?.invalidateSystem(payload.systemId);
238
+ await service.removeSystemAssociations(systemId);
239
+ await incidentCache?.invalidateSystem(systemId);
163
240
  },
164
- { mode: "work-queue", workerGroup: "incident-system-cleanup" },
165
- );
241
+ delivery: {
242
+ mode: "work-queue",
243
+ workerGroup: "incident-system-cleanup",
244
+ },
245
+ });
166
246
 
167
247
  logger.debug("✅ Incident Backend afterPluginsReady complete.");
168
248
  },