@checkstack/incident-backend 1.3.0 → 1.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.
@@ -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;
@@ -64,6 +127,7 @@ export default createBackendPlugin({
64
127
  rpcClient: coreServices.rpcClient,
65
128
  signalService: coreServices.signalService,
66
129
  cacheManager: coreServices.cacheManager,
130
+ advisoryLock: coreServices.advisoryLock,
67
131
  },
68
132
  init: async ({
69
133
  logger,
@@ -72,6 +136,7 @@ export default createBackendPlugin({
72
136
  rpcClient,
73
137
  signalService,
74
138
  cacheManager,
139
+ advisoryLock,
75
140
  }) => {
76
141
  logger.debug("🔧 Initializing Incident Backend...");
77
142
 
@@ -81,7 +146,11 @@ export default createBackendPlugin({
81
146
 
82
147
  const service = new IncidentService(
83
148
  database as SafeDatabase<typeof schema>,
149
+ advisoryLock,
84
150
  );
151
+ // Publish the service for the PLUGIN-BACKED entity `read` accessor
152
+ // (defined in register()). Mutations only run from here onward.
153
+ incidentServiceRef = service;
85
154
  const cache = createIncidentCache({ cacheManager, logger });
86
155
  incidentCache = cache;
87
156
  const router = createRouter(
@@ -92,6 +161,7 @@ export default createBackendPlugin({
92
161
  authClient,
93
162
  logger,
94
163
  cache,
164
+ () => incidentEntity,
95
165
  );
96
166
  rpc.registerRouter(router, incidentContract);
97
167
 
@@ -103,7 +173,10 @@ export default createBackendPlugin({
103
173
  const automationActions = env.getExtensionPoint(
104
174
  automationActionExtensionPoint,
105
175
  );
106
- for (const action of createIncidentActions({ service })) {
176
+ for (const action of createIncidentActions({
177
+ service,
178
+ getIncidentEntity: () => incidentEntity,
179
+ })) {
107
180
  automationActions.registerAction(action, pluginMetadata);
108
181
  }
109
182
 
@@ -138,9 +211,14 @@ export default createBackendPlugin({
138
211
  // associations) + register subscription specs. Per-system /
139
212
  // per-group notification group lifecycle is fully owned by
140
213
  // notification-backend now — incident never touches it.
141
- afterPluginsReady: async ({ database, logger, onHook, rpcClient }) => {
214
+ afterPluginsReady: async ({
215
+ database,
216
+ logger,
217
+ rpcClient,
218
+ advisoryLock,
219
+ }) => {
142
220
  const typedDb = database as SafeDatabase<typeof schema>;
143
- const service = new IncidentService(typedDb);
221
+ const service = new IncidentService(typedDb, advisoryLock);
144
222
  const notificationClient = rpcClient.forPlugin(NotificationApi);
145
223
 
146
224
  await Promise.all([
@@ -152,17 +230,27 @@ export default createBackendPlugin({
152
230
  ),
153
231
  ]);
154
232
 
155
- onHook(
156
- catalogHooks.systemDeleted,
157
- async (payload) => {
233
+ // React to catalog system deletion (tombstone) via the reactive
234
+ // `catalog-system` entity instead of the (being-removed)
235
+ // `system.deleted` hook (§10.1). `work-queue` delivery preserved:
236
+ // association cleanup is a side-effecting write that must run once
237
+ // per cluster, not per-instance.
238
+ onEntityChanged({
239
+ kind: CATALOG_SYSTEM_ENTITY_KIND,
240
+ handler: async (change) => {
241
+ if (change.next !== null) return; // tombstone only
242
+ const systemId = change.id;
158
243
  logger.debug(
159
- `Cleaning up incident associations for deleted system: ${payload.systemId}`,
244
+ `Cleaning up incident associations for deleted system: ${systemId}`,
160
245
  );
161
- await service.removeSystemAssociations(payload.systemId);
162
- await incidentCache?.invalidateSystem(payload.systemId);
246
+ await service.removeSystemAssociations(systemId);
247
+ await incidentCache?.invalidateSystem(systemId);
163
248
  },
164
- { mode: "work-queue", workerGroup: "incident-system-cleanup" },
165
- );
249
+ delivery: {
250
+ mode: "work-queue",
251
+ workerGroup: "incident-system-cleanup",
252
+ },
253
+ });
166
254
 
167
255
  logger.debug("✅ Incident Backend afterPluginsReady complete.");
168
256
  },