@checkstack/incident-backend 1.4.0 → 1.6.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,69 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ IncidentApi,
6
+ incidentAccess,
7
+ pluginMetadata,
8
+ type IncidentWithSystems,
9
+ } from "@checkstack/incident-common";
10
+ import type { AiProposalPreview } from "@checkstack/ai-common";
11
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
12
+
13
+ /** Input for `incident.resolve`: the incident id plus an optional message. */
14
+ export const IncidentResolveInputSchema = z.object({
15
+ id: z.string(),
16
+ message: z.string().optional(),
17
+ });
18
+ export type IncidentResolveInput = z.infer<typeof IncidentResolveInputSchema>;
19
+
20
+ /** Output returned once a human applies the resolution (the resolved incident). */
21
+ export interface IncidentResolveApplyResult {
22
+ incident: IncidentWithSystems;
23
+ }
24
+
25
+ /**
26
+ * `incident.resolve` - mark an incident resolved by id, optionally with a final
27
+ * status-update message.
28
+ *
29
+ * `effect: "mutate"` - resolving is a non-destructive status change, so it
30
+ * auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
31
+ * returns the captured payload for human review WITHOUT mutating; `execute`
32
+ * (reached only via `apply`) resolves the incident. The underlying RPC uses the
33
+ * USER-SCOPED client passed at call time, so handler-side authorization is
34
+ * enforced exactly as a direct UI/RPC call.
35
+ */
36
+ export function createIncidentResolveTool(): RegisteredAiTool<
37
+ IncidentResolveInput,
38
+ IncidentResolveApplyResult
39
+ > {
40
+ const dryRun = async ({
41
+ input,
42
+ }: {
43
+ input: IncidentResolveInput;
44
+ principal: AuthUser;
45
+ rpcClient: RpcClient;
46
+ }): Promise<AiProposalPreview<IncidentResolveInput>> => {
47
+ return {
48
+ summary: `Resolve incident ${input.id}${input.message ? ` with message "${input.message}"` : ""}.`,
49
+ payload: input,
50
+ };
51
+ };
52
+
53
+ return {
54
+ name: "incident.resolve",
55
+ description:
56
+ "Mark an incident resolved by id, optionally with a final status-update message. Never resolves directly; a person must approve unless the conversation is in auto mode. Find the id with the incident read tools first.",
57
+ effect: "mutate",
58
+ input: IncidentResolveInputSchema,
59
+ requiredAccessRules: [
60
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
61
+ ],
62
+ dryRun,
63
+ async execute({ input, rpcClient }) {
64
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
65
+ const incident = await incidentClient.resolveIncident(input);
66
+ return { incident };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import type { IncidentDetail } from "@checkstack/incident-common";
4
+ import { createIncidentUpdateTool } from "./incident-update";
5
+
6
+ const principal: AuthUser = {
7
+ type: "user",
8
+ id: "u1",
9
+ accessRules: ["incident.incident.manage"],
10
+ };
11
+
12
+ const existing: IncidentDetail = {
13
+ id: "inc1",
14
+ title: "Old title",
15
+ description: "old desc",
16
+ status: "investigating",
17
+ severity: "minor",
18
+ suppressNotifications: false,
19
+ systemIds: ["sys1"],
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ updates: [],
23
+ links: [],
24
+ };
25
+
26
+ function fakeRpcClient({
27
+ getIncident,
28
+ updateIncident,
29
+ }: {
30
+ getIncident: ReturnType<typeof mock>;
31
+ updateIncident: ReturnType<typeof mock>;
32
+ }): RpcClient {
33
+ return {
34
+ forPlugin: () => ({ getIncident, updateIncident }),
35
+ } as unknown as RpcClient;
36
+ }
37
+
38
+ describe("incident.update tool", () => {
39
+ test("declares mutate effect + the manage rule", () => {
40
+ const tool = createIncidentUpdateTool();
41
+ expect(tool.name).toBe("incident.update");
42
+ expect(tool.effect).toBe("mutate");
43
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
44
+ expect(typeof tool.dryRun).toBe("function");
45
+ });
46
+
47
+ test("dryRun fetches existing, returns a diff, and NEVER updates", async () => {
48
+ const getIncident = mock(() => Promise.resolve(existing));
49
+ const updateIncident = mock(() => Promise.resolve(existing));
50
+ const rpcClient = fakeRpcClient({ getIncident, updateIncident });
51
+ const tool = createIncidentUpdateTool();
52
+ const preview = await tool.dryRun!({
53
+ input: { id: "inc1", severity: "critical" },
54
+ principal,
55
+ rpcClient,
56
+ });
57
+ expect(getIncident).toHaveBeenCalledWith({ id: "inc1" });
58
+ expect(updateIncident).not.toHaveBeenCalled();
59
+ expect(preview.diff).toBeDefined();
60
+ expect(preview.summary).toContain("critical");
61
+ });
62
+
63
+ test("dryRun throws a clear error when the id is unknown", async () => {
64
+ const rpcClient = fakeRpcClient({
65
+ getIncident: mock(() => Promise.resolve(null)),
66
+ updateIncident: mock(),
67
+ });
68
+ const tool = createIncidentUpdateTool();
69
+ await expect(
70
+ tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
71
+ ).rejects.toThrow(/No incident found/);
72
+ });
73
+
74
+ test("execute (apply) updates via updateIncident", async () => {
75
+ const input = { id: "inc1", title: "New title" };
76
+ const updated = { ...existing, title: "New title" };
77
+ const updateIncident = mock(() => Promise.resolve(updated));
78
+ const rpcClient = fakeRpcClient({
79
+ getIncident: mock(() => Promise.resolve(existing)),
80
+ updateIncident,
81
+ });
82
+ const tool = createIncidentUpdateTool();
83
+ const result = await tool.execute({ input, principal, rpcClient });
84
+ expect(updateIncident).toHaveBeenCalledWith(input);
85
+ expect(result.incident).toEqual(updated);
86
+ });
87
+ });
@@ -0,0 +1,94 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ IncidentApi,
5
+ incidentAccess,
6
+ pluginMetadata,
7
+ UpdateIncidentInputSchema,
8
+ type UpdateIncidentInput,
9
+ type IncidentWithSystems,
10
+ } from "@checkstack/incident-common";
11
+ import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+
14
+ /** Output returned once a human applies the update (the updated incident). */
15
+ export interface IncidentUpdateApplyResult {
16
+ incident: IncidentWithSystems;
17
+ }
18
+
19
+ /**
20
+ * `incident.update` - edit an existing incident's metadata (title, description,
21
+ * severity, suppressNotifications, affected systems) by id. Only the provided
22
+ * fields change.
23
+ *
24
+ * `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
25
+ * and is confirm-gated in APPROVE mode. `dryRun` fetches the live incident,
26
+ * computes a before -> after diff over the updatable fields, and rejects an
27
+ * unknown id with a self-correcting error; `execute` performs the update. The
28
+ * underlying RPC uses the USER-SCOPED client passed at call time, so
29
+ * handler-side authorization is enforced exactly as a direct UI/RPC call.
30
+ */
31
+ export function createIncidentUpdateTool(): RegisteredAiTool<
32
+ UpdateIncidentInput,
33
+ IncidentUpdateApplyResult
34
+ > {
35
+ const dryRun = async ({
36
+ input,
37
+ rpcClient,
38
+ }: {
39
+ input: UpdateIncidentInput;
40
+ principal: AuthUser;
41
+ rpcClient: RpcClient;
42
+ }): Promise<AiProposalPreview<UpdateIncidentInput>> => {
43
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
44
+ const existing = await incidentClient.getIncident({ id: input.id });
45
+ if (!existing) {
46
+ throw new Error(
47
+ `No incident found with id "${input.id}". List incidents first to get a valid id.`,
48
+ );
49
+ }
50
+ // before -> after over the updatable subset only (the full incident also
51
+ // carries status/timestamps/updates/links that are not part of an update).
52
+ const before = {
53
+ title: existing.title,
54
+ description: existing.description,
55
+ severity: existing.severity,
56
+ suppressNotifications: existing.suppressNotifications,
57
+ systemIds: existing.systemIds,
58
+ };
59
+ const after = {
60
+ title: input.title ?? before.title,
61
+ description:
62
+ input.description === undefined
63
+ ? before.description
64
+ : (input.description ?? undefined),
65
+ severity: input.severity ?? before.severity,
66
+ suppressNotifications:
67
+ input.suppressNotifications ?? before.suppressNotifications,
68
+ systemIds: input.systemIds ?? before.systemIds,
69
+ };
70
+ const diff = computeFieldDiff({ before, after });
71
+ return {
72
+ summary: `Update incident "${after.title}" (severity ${after.severity}, ${after.systemIds.length} system(s)).`,
73
+ payload: input,
74
+ diff,
75
+ };
76
+ };
77
+
78
+ return {
79
+ name: "incident.update",
80
+ description:
81
+ "Update an existing incident by id with the provided fields (title, description, severity, suppressNotifications, systemIds). Only provided fields change. Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the incident read tools first.",
82
+ effect: "mutate",
83
+ input: UpdateIncidentInputSchema,
84
+ requiredAccessRules: [
85
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
86
+ ],
87
+ dryRun,
88
+ async execute({ input, rpcClient }) {
89
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
90
+ const incident = await incidentClient.updateIncident(input);
91
+ return { incident };
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,33 @@
1
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
2
+ import { createIncidentCreateTool } from "./incident-create";
3
+ import { createIncidentUpdateTool } from "./incident-update";
4
+ import { createIncidentDeleteTool } from "./incident-delete";
5
+ import { createIncidentAddUpdateTool } from "./incident-add-update";
6
+ import { createIncidentResolveTool } from "./incident-resolve";
7
+ import { createIncidentAddLinkTool } from "./incident-add-link";
8
+ import { createIncidentRemoveLinkTool } from "./incident-remove-link";
9
+
10
+ /**
11
+ * The incident plugin's AI tools, registered into the AI registry via
12
+ * `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
13
+ * ai-backend. This is the canonical pattern any plugin (first- or third-party)
14
+ * uses to contribute AI tools without ai-backend depending on it.
15
+ *
16
+ * create/update/addUpdate/resolve/addLink are `mutate` (auto-applies in AUTO
17
+ * mode, confirm-gated in APPROVE mode); delete/removeLink are `destructive`, so
18
+ * always confirm-gated. They all go through the USER-SCOPED client passed at
19
+ * call time, so handler-side authorization is enforced exactly as a direct
20
+ * UI/RPC call; the resolver gate + the propose/apply re-check at propose AND
21
+ * apply time are the additional authorization authority.
22
+ */
23
+ export function buildIncidentAiTools(): RegisteredAiTool[] {
24
+ return [
25
+ createIncidentCreateTool(),
26
+ createIncidentUpdateTool(),
27
+ createIncidentDeleteTool(),
28
+ createIncidentAddUpdateTool(),
29
+ createIncidentResolveTool(),
30
+ createIncidentAddLinkTool(),
31
+ createIncidentRemoveLinkTool(),
32
+ ];
33
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildProjectedTool, deferredProjectionExecute } from "@checkstack/ai-backend";
3
+ import { qualifyAccessRuleId } from "@checkstack/common";
4
+ import {
5
+ incidentContract,
6
+ incidentAccess,
7
+ pluginMetadata,
8
+ } from "@checkstack/incident-common";
9
+
10
+ describe("incident AI projection (incident.list)", () => {
11
+ const tool = buildProjectedTool({
12
+ procedure: incidentContract.listIncidents,
13
+ sourcePluginMetadata: pluginMetadata,
14
+ procedureKey: "listIncidents",
15
+ name: "incident.list",
16
+ description: "List incidents with optional status/system filters. Read-only.",
17
+ effect: "read",
18
+ execute: deferredProjectionExecute,
19
+ });
20
+
21
+ test("projects to a read-only tool named incident.list", () => {
22
+ expect(tool.name).toBe("incident.list");
23
+ expect(tool.effect).toBe("read");
24
+ });
25
+
26
+ test("carries the source procedure's own qualified access rules", () => {
27
+ // Derived from listIncidents' own access metadata, NOT the broad
28
+ // ai.chat.read rule.
29
+ const expected = qualifyAccessRuleId(
30
+ pluginMetadata,
31
+ incidentAccess.incident.read,
32
+ );
33
+
34
+ expect(tool.requiredAccessRules.length).toBeGreaterThan(0);
35
+ expect(tool.requiredAccessRules).toEqual([expected]);
36
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
37
+ });
38
+ });
@@ -5,6 +5,7 @@
5
5
  * `core/automation-backend` cover registration validity.
6
6
  */
7
7
  import { describe, it, expect, mock } from "bun:test";
8
+ import type { RpcClient } from "@checkstack/backend-api";
8
9
  import { SYSTEM_ACTOR } from "@checkstack/common";
9
10
  import { createMockLogger } from "@checkstack/test-utils-backend";
10
11
 
@@ -48,6 +49,7 @@ const actionContext = {
48
49
  getService: async <T,>(): Promise<T> => {
49
50
  throw new Error("not used");
50
51
  },
52
+ rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
51
53
  };
52
54
 
53
55
  describe("incident automation actions", () => {
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import * as schema from "./schema";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import {
4
+ aiToolExtensionPoint,
5
+ aiToolProjectionExtensionPoint,
6
+ deferredProjectionExecute,
7
+ } from "@checkstack/ai-backend";
3
8
  import {
4
9
  incidentAccessRules,
5
10
  incidentAccess,
@@ -42,6 +47,7 @@ import {
42
47
  incidentArtifactType,
43
48
  incidentTriggers,
44
49
  } from "./automations";
50
+ import { buildIncidentAiTools } from "./ai/register-ai-tools";
45
51
 
46
52
  // =============================================================================
47
53
  // Plugin Definition
@@ -127,6 +133,7 @@ export default createBackendPlugin({
127
133
  rpcClient: coreServices.rpcClient,
128
134
  signalService: coreServices.signalService,
129
135
  cacheManager: coreServices.cacheManager,
136
+ advisoryLock: coreServices.advisoryLock,
130
137
  },
131
138
  init: async ({
132
139
  logger,
@@ -135,6 +142,7 @@ export default createBackendPlugin({
135
142
  rpcClient,
136
143
  signalService,
137
144
  cacheManager,
145
+ advisoryLock,
138
146
  }) => {
139
147
  logger.debug("🔧 Initializing Incident Backend...");
140
148
 
@@ -144,6 +152,7 @@ export default createBackendPlugin({
144
152
 
145
153
  const service = new IncidentService(
146
154
  database as SafeDatabase<typeof schema>,
155
+ advisoryLock,
147
156
  );
148
157
  // Publish the service for the PLUGIN-BACKED entity `read` accessor
149
158
  // (defined in register()). Mutations only run from here onward.
@@ -177,6 +186,44 @@ export default createBackendPlugin({
177
186
  automationActions.registerAction(action, pluginMetadata);
178
187
  }
179
188
 
189
+ // Register this plugin's AI tools (create/update/delete/addUpdate/
190
+ // resolve/addLink/removeLink) into the AI registry via the extension
191
+ // point - owned here, not in ai-backend.
192
+ const aiToolExt = env.getExtensionPoint(aiToolExtensionPoint);
193
+ for (const tool of buildIncidentAiTools()) {
194
+ aiToolExt.registerTool(tool, pluginMetadata);
195
+ }
196
+
197
+ // Expose this plugin's read-only AI projection (`incident.list`) via
198
+ // the AI projection extension point. ai-backend collects its routing in
199
+ // afterPluginsReady and never imports incident-common.
200
+ const aiProjectionExt = env.getExtensionPoint(
201
+ aiToolProjectionExtensionPoint,
202
+ );
203
+ aiProjectionExt.expose({
204
+ procedure: incidentContract.listIncidents,
205
+ sourcePluginMetadata: pluginMetadata,
206
+ procedureKey: "listIncidents",
207
+ name: "incident.list",
208
+ description:
209
+ "List incidents with optional status/system filters. Read-only.",
210
+ effect: "read",
211
+ execute: deferredProjectionExecute,
212
+ });
213
+
214
+ // Expose a read-only projection of `getIncident` so the model can pull
215
+ // one incident's full timeline (updates) + links to ground its actions.
216
+ aiProjectionExt.expose({
217
+ procedure: incidentContract.getIncident,
218
+ sourcePluginMetadata: pluginMetadata,
219
+ procedureKey: "getIncident",
220
+ name: "incident.get",
221
+ description:
222
+ "Get one incident with its full timeline (updates) and links. Read-only.",
223
+ effect: "read",
224
+ execute: deferredProjectionExecute,
225
+ });
226
+
180
227
  // Register "Create Incident" command in the command palette
181
228
  registerSearchProvider({
182
229
  pluginMetadata,
@@ -208,9 +255,14 @@ export default createBackendPlugin({
208
255
  // associations) + register subscription specs. Per-system /
209
256
  // per-group notification group lifecycle is fully owned by
210
257
  // notification-backend now — incident never touches it.
211
- afterPluginsReady: async ({ database, logger, rpcClient }) => {
258
+ afterPluginsReady: async ({
259
+ database,
260
+ logger,
261
+ rpcClient,
262
+ advisoryLock,
263
+ }) => {
212
264
  const typedDb = database as SafeDatabase<typeof schema>;
213
- const service = new IncidentService(typedDb);
265
+ const service = new IncidentService(typedDb, advisoryLock);
214
266
  const notificationClient = rpcClient.forPlugin(NotificationApi);
215
267
 
216
268
  await Promise.all([
package/src/schema.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  timestamp,
6
6
  primaryKey,
7
7
  boolean,
8
+ uniqueIndex,
8
9
  } from "drizzle-orm/pg-core";
9
10
 
10
11
  /**
@@ -77,12 +78,22 @@ export const incidentUpdates = pgTable("incident_updates", {
77
78
  * Hotlinks attached to an incident — e.g. a Jira ticket, runbook, or chat
78
79
  * thread. Free-form URL + optional human label.
79
80
  */
80
- export const incidentLinks = pgTable("incident_links", {
81
- id: text("id").primaryKey(),
82
- incidentId: text("incident_id")
83
- .notNull()
84
- .references(() => incidents.id, { onDelete: "cascade" }),
85
- label: text("label"),
86
- url: text("url").notNull(),
87
- createdAt: timestamp("created_at").defaultNow().notNull(),
88
- });
81
+ export const incidentLinks = pgTable(
82
+ "incident_links",
83
+ {
84
+ id: text("id").primaryKey(),
85
+ incidentId: text("incident_id")
86
+ .notNull()
87
+ .references(() => incidents.id, { onDelete: "cascade" }),
88
+ label: text("label"),
89
+ url: text("url").notNull(),
90
+ createdAt: timestamp("created_at").defaultNow().notNull(),
91
+ },
92
+ (t) => ({
93
+ // The same URL may be attached to an incident only once.
94
+ incidentUrlUnique: uniqueIndex("incident_links_incident_url_unique").on(
95
+ t.incidentId,
96
+ t.url,
97
+ ),
98
+ }),
99
+ );
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import type { AdvisoryLockService } from "@checkstack/backend-api";
2
3
  import { IncidentService } from "./service";
3
4
  import {
4
5
  incidents,
@@ -7,6 +8,40 @@ import {
7
8
  incidentLinks,
8
9
  } from "./schema";
9
10
 
11
+ /**
12
+ * In-memory {@link AdvisoryLockService} that faithfully serializes
13
+ * `withXactLock` calls per key (a racing call on the same key cannot run its
14
+ * `fn` until the prior call's `fn` settles) — modelling `pg_advisory_xact_lock`
15
+ * without a real connection. Different keys are independent.
16
+ */
17
+ function makeFakeAdvisoryLock(): AdvisoryLockService {
18
+ const tails = new Map<string, Promise<unknown>>();
19
+ return {
20
+ tryAcquire: async () => ({ release: async () => {} }),
21
+ withXactLock<T>({
22
+ key,
23
+ fn,
24
+ }: {
25
+ key: string;
26
+ fn: () => Promise<T>;
27
+ }): Promise<T> {
28
+ const prior = tails.get(key) ?? Promise.resolve();
29
+ const result = prior.then(
30
+ () => fn(),
31
+ () => fn(),
32
+ );
33
+ tails.set(
34
+ key,
35
+ result.then(
36
+ () => undefined,
37
+ () => undefined,
38
+ ),
39
+ );
40
+ return result;
41
+ },
42
+ };
43
+ }
44
+
10
45
  /**
11
46
  * Programmable mock DB that records each `select(...).from(...).where(...)`
12
47
  * (and optional `.limit(...)`) chain and returns a configurable row array
@@ -48,7 +83,7 @@ describe("IncidentService.hasActiveIncidentWithSuppression", () => {
48
83
 
49
84
  const setup = (resultsByCall: unknown[][]) => {
50
85
  dbHelper = createProgrammableSelectDb(resultsByCall);
51
- service = new IncidentService(dbHelper.db as never);
86
+ service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
52
87
  };
53
88
 
54
89
  beforeEach(() => {
@@ -134,7 +169,7 @@ describe("IncidentService.hasActiveIncidentWithSuppression", () => {
134
169
  describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () => {
135
170
  it("returns {} for an empty id set without querying", async () => {
136
171
  const dbHelper = createProgrammableSelectDb([]);
137
- const service = new IncidentService(dbHelper.db as never);
172
+ const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
138
173
  expect(await service.getManyEntityStates([])).toEqual({});
139
174
  expect(dbHelper.getCallCount()).toBe(0);
140
175
  });
@@ -153,7 +188,7 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
153
188
  { incidentId: "inc-2", systemId: "sys-c" },
154
189
  ],
155
190
  ]);
156
- const service = new IncidentService(dbHelper.db as never);
191
+ const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
157
192
  const out = await service.getManyEntityStates(["inc-1", "inc-2", "inc-x"]);
158
193
  expect(out).toEqual({
159
194
  "inc-1": {
@@ -172,7 +207,7 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
172
207
  // incidents query returns nothing → no second query.
173
208
  [],
174
209
  ]);
175
- const service = new IncidentService(dbHelper.db as never);
210
+ const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
176
211
  expect(await service.getManyEntityStates(["ghost"])).toEqual({});
177
212
  expect(dbHelper.getCallCount()).toBe(1);
178
213
  });
@@ -182,7 +217,7 @@ describe("IncidentService.getManyEntityStates (plugin-backed entity read)", () =
182
217
  [{ id: "inc-1", status: "monitoring", severity: "critical" }],
183
218
  [], // no junction rows
184
219
  ]);
185
- const service = new IncidentService(dbHelper.db as never);
220
+ const service = new IncidentService(dbHelper.db as never, makeFakeAdvisoryLock());
186
221
  const out = await service.getManyEntityStates(["inc-1"]);
187
222
  expect(out["inc-1"]).toEqual({
188
223
  status: "monitoring",
@@ -266,6 +301,12 @@ function createDedupFakeDb() {
266
301
  // The lock key is embedded in the SQL the helper runs via tx.execute.
267
302
  let lockKey = "default";
268
303
  const tx = {
304
+ // A transaction handle exposes the same query surface as `db`, so a
305
+ // service method that wraps its writes in `db.transaction` can issue
306
+ // select/insert against the same backing store (e.g. createIncident,
307
+ // which now commits the incident + its system links atomically).
308
+ select: buildSelect(),
309
+ insert: buildInsert(),
269
310
  execute: async (sqlObj: unknown) => {
270
311
  // Drizzle sql`` carries the interpolated key in its params; the
271
312
  // helper interpolates exactly one param (the lock key).
@@ -300,7 +341,7 @@ function createDedupFakeDb() {
300
341
  describe("IncidentService.createIncidentDedupedForSystem (M3)", () => {
301
342
  it("two concurrent dedupe creates for one system open exactly ONE incident", async () => {
302
343
  const { db, store } = createDedupFakeDb();
303
- const service = new IncidentService(db as never);
344
+ const service = new IncidentService(db as never, makeFakeAdvisoryLock());
304
345
 
305
346
  const input = {
306
347
  title: "Down",