@checkstack/incident-backend 1.11.0 → 1.12.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,353 @@
1
+ {
2
+ "id": "ff7f73e0-3d6b-47a4-b759-000739af4ee6",
3
+ "prevId": "ad7ffe14-5ac3-4028-895f-efa6c8026073",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.incident_links": {
8
+ "name": "incident_links",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "incident_id": {
18
+ "name": "incident_id",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "label": {
24
+ "name": "label",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": false
28
+ },
29
+ "url": {
30
+ "name": "url",
31
+ "type": "text",
32
+ "primaryKey": false,
33
+ "notNull": true
34
+ },
35
+ "visibility": {
36
+ "name": "visibility",
37
+ "type": "incident_content_visibility",
38
+ "typeSchema": "public",
39
+ "primaryKey": false,
40
+ "notNull": true,
41
+ "default": "'public'"
42
+ },
43
+ "created_at": {
44
+ "name": "created_at",
45
+ "type": "timestamp",
46
+ "primaryKey": false,
47
+ "notNull": true,
48
+ "default": "now()"
49
+ }
50
+ },
51
+ "indexes": {
52
+ "incident_links_incident_url_unique": {
53
+ "name": "incident_links_incident_url_unique",
54
+ "columns": [
55
+ {
56
+ "expression": "incident_id",
57
+ "isExpression": false,
58
+ "asc": true,
59
+ "nulls": "last"
60
+ },
61
+ {
62
+ "expression": "url",
63
+ "isExpression": false,
64
+ "asc": true,
65
+ "nulls": "last"
66
+ }
67
+ ],
68
+ "isUnique": true,
69
+ "concurrently": false,
70
+ "method": "btree",
71
+ "with": {}
72
+ }
73
+ },
74
+ "foreignKeys": {
75
+ "incident_links_incident_id_incidents_id_fk": {
76
+ "name": "incident_links_incident_id_incidents_id_fk",
77
+ "tableFrom": "incident_links",
78
+ "tableTo": "incidents",
79
+ "columnsFrom": [
80
+ "incident_id"
81
+ ],
82
+ "columnsTo": [
83
+ "id"
84
+ ],
85
+ "onDelete": "cascade",
86
+ "onUpdate": "no action"
87
+ }
88
+ },
89
+ "compositePrimaryKeys": {},
90
+ "uniqueConstraints": {},
91
+ "policies": {},
92
+ "checkConstraints": {},
93
+ "isRLSEnabled": false
94
+ },
95
+ "public.incident_systems": {
96
+ "name": "incident_systems",
97
+ "schema": "",
98
+ "columns": {
99
+ "incident_id": {
100
+ "name": "incident_id",
101
+ "type": "text",
102
+ "primaryKey": false,
103
+ "notNull": true
104
+ },
105
+ "system_id": {
106
+ "name": "system_id",
107
+ "type": "text",
108
+ "primaryKey": false,
109
+ "notNull": true
110
+ }
111
+ },
112
+ "indexes": {},
113
+ "foreignKeys": {
114
+ "incident_systems_incident_id_incidents_id_fk": {
115
+ "name": "incident_systems_incident_id_incidents_id_fk",
116
+ "tableFrom": "incident_systems",
117
+ "tableTo": "incidents",
118
+ "columnsFrom": [
119
+ "incident_id"
120
+ ],
121
+ "columnsTo": [
122
+ "id"
123
+ ],
124
+ "onDelete": "cascade",
125
+ "onUpdate": "no action"
126
+ }
127
+ },
128
+ "compositePrimaryKeys": {
129
+ "incident_systems_incident_id_system_id_pk": {
130
+ "name": "incident_systems_incident_id_system_id_pk",
131
+ "columns": [
132
+ "incident_id",
133
+ "system_id"
134
+ ]
135
+ }
136
+ },
137
+ "uniqueConstraints": {},
138
+ "policies": {},
139
+ "checkConstraints": {},
140
+ "isRLSEnabled": false
141
+ },
142
+ "public.incident_updates": {
143
+ "name": "incident_updates",
144
+ "schema": "",
145
+ "columns": {
146
+ "id": {
147
+ "name": "id",
148
+ "type": "text",
149
+ "primaryKey": true,
150
+ "notNull": true
151
+ },
152
+ "incident_id": {
153
+ "name": "incident_id",
154
+ "type": "text",
155
+ "primaryKey": false,
156
+ "notNull": true
157
+ },
158
+ "message": {
159
+ "name": "message",
160
+ "type": "text",
161
+ "primaryKey": false,
162
+ "notNull": true
163
+ },
164
+ "status_change": {
165
+ "name": "status_change",
166
+ "type": "incident_status",
167
+ "typeSchema": "public",
168
+ "primaryKey": false,
169
+ "notNull": false
170
+ },
171
+ "visibility": {
172
+ "name": "visibility",
173
+ "type": "incident_content_visibility",
174
+ "typeSchema": "public",
175
+ "primaryKey": false,
176
+ "notNull": true,
177
+ "default": "'public'"
178
+ },
179
+ "created_at": {
180
+ "name": "created_at",
181
+ "type": "timestamp",
182
+ "primaryKey": false,
183
+ "notNull": true,
184
+ "default": "now()"
185
+ },
186
+ "edited_at": {
187
+ "name": "edited_at",
188
+ "type": "timestamp",
189
+ "primaryKey": false,
190
+ "notNull": false
191
+ },
192
+ "edit_history": {
193
+ "name": "edit_history",
194
+ "type": "jsonb",
195
+ "primaryKey": false,
196
+ "notNull": true,
197
+ "default": "'[]'::jsonb"
198
+ },
199
+ "created_by": {
200
+ "name": "created_by",
201
+ "type": "text",
202
+ "primaryKey": false,
203
+ "notNull": false
204
+ }
205
+ },
206
+ "indexes": {},
207
+ "foreignKeys": {
208
+ "incident_updates_incident_id_incidents_id_fk": {
209
+ "name": "incident_updates_incident_id_incidents_id_fk",
210
+ "tableFrom": "incident_updates",
211
+ "tableTo": "incidents",
212
+ "columnsFrom": [
213
+ "incident_id"
214
+ ],
215
+ "columnsTo": [
216
+ "id"
217
+ ],
218
+ "onDelete": "cascade",
219
+ "onUpdate": "no action"
220
+ }
221
+ },
222
+ "compositePrimaryKeys": {},
223
+ "uniqueConstraints": {},
224
+ "policies": {},
225
+ "checkConstraints": {},
226
+ "isRLSEnabled": false
227
+ },
228
+ "public.incidents": {
229
+ "name": "incidents",
230
+ "schema": "",
231
+ "columns": {
232
+ "id": {
233
+ "name": "id",
234
+ "type": "text",
235
+ "primaryKey": true,
236
+ "notNull": true
237
+ },
238
+ "title": {
239
+ "name": "title",
240
+ "type": "text",
241
+ "primaryKey": false,
242
+ "notNull": true
243
+ },
244
+ "description": {
245
+ "name": "description",
246
+ "type": "text",
247
+ "primaryKey": false,
248
+ "notNull": false
249
+ },
250
+ "status": {
251
+ "name": "status",
252
+ "type": "incident_status",
253
+ "typeSchema": "public",
254
+ "primaryKey": false,
255
+ "notNull": true,
256
+ "default": "'investigating'"
257
+ },
258
+ "severity": {
259
+ "name": "severity",
260
+ "type": "incident_severity",
261
+ "typeSchema": "public",
262
+ "primaryKey": false,
263
+ "notNull": true,
264
+ "default": "'major'"
265
+ },
266
+ "suppress_notifications": {
267
+ "name": "suppress_notifications",
268
+ "type": "boolean",
269
+ "primaryKey": false,
270
+ "notNull": true,
271
+ "default": false
272
+ },
273
+ "health_override": {
274
+ "name": "health_override",
275
+ "type": "incident_health_override",
276
+ "typeSchema": "public",
277
+ "primaryKey": false,
278
+ "notNull": false
279
+ },
280
+ "created_at": {
281
+ "name": "created_at",
282
+ "type": "timestamp",
283
+ "primaryKey": false,
284
+ "notNull": true,
285
+ "default": "now()"
286
+ },
287
+ "updated_at": {
288
+ "name": "updated_at",
289
+ "type": "timestamp",
290
+ "primaryKey": false,
291
+ "notNull": true,
292
+ "default": "now()"
293
+ }
294
+ },
295
+ "indexes": {},
296
+ "foreignKeys": {},
297
+ "compositePrimaryKeys": {},
298
+ "uniqueConstraints": {},
299
+ "policies": {},
300
+ "checkConstraints": {},
301
+ "isRLSEnabled": false
302
+ }
303
+ },
304
+ "enums": {
305
+ "public.incident_health_override": {
306
+ "name": "incident_health_override",
307
+ "schema": "public",
308
+ "values": [
309
+ "degraded",
310
+ "unhealthy"
311
+ ]
312
+ },
313
+ "public.incident_severity": {
314
+ "name": "incident_severity",
315
+ "schema": "public",
316
+ "values": [
317
+ "minor",
318
+ "major",
319
+ "critical"
320
+ ]
321
+ },
322
+ "public.incident_status": {
323
+ "name": "incident_status",
324
+ "schema": "public",
325
+ "values": [
326
+ "investigating",
327
+ "identified",
328
+ "fixing",
329
+ "monitoring",
330
+ "resolved"
331
+ ]
332
+ },
333
+ "public.incident_content_visibility": {
334
+ "name": "incident_content_visibility",
335
+ "schema": "public",
336
+ "values": [
337
+ "public",
338
+ "logged_in",
339
+ "internal"
340
+ ]
341
+ }
342
+ },
343
+ "schemas": {},
344
+ "sequences": {},
345
+ "roles": {},
346
+ "policies": {},
347
+ "views": {},
348
+ "_meta": {
349
+ "columns": {},
350
+ "schemas": {},
351
+ "tables": {}
352
+ }
353
+ }
@@ -36,6 +36,20 @@
36
36
  "when": 1783287429703,
37
37
  "tag": "0004_flaky_malcolm_colcord",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1783498751737,
44
+ "tag": "0005_real_leper_queen",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "7",
50
+ "when": 1783535507970,
51
+ "tag": "0006_last_princess_powerful",
52
+ "breakpoints": true
39
53
  }
40
54
  ]
41
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,23 +14,23 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.9",
17
+ "@checkstack/ai-backend": "0.10.10",
18
18
  "@checkstack/ai-common": "0.6.6",
19
- "@checkstack/backend-api": "0.31.0",
19
+ "@checkstack/backend-api": "0.31.1",
20
20
  "@checkstack/cache-api": "0.3.19",
21
21
  "@checkstack/cache-utils": "0.2.24",
22
- "@checkstack/incident-common": "1.9.0",
23
- "@checkstack/catalog-common": "2.6.3",
24
- "@checkstack/catalog-backend": "1.6.9",
25
- "@checkstack/notification-common": "1.5.3",
22
+ "@checkstack/incident-common": "1.10.0",
23
+ "@checkstack/catalog-common": "2.7.0",
24
+ "@checkstack/catalog-backend": "1.7.0",
25
+ "@checkstack/notification-common": "1.6.0",
26
26
  "@checkstack/auth-common": "0.13.0",
27
- "@checkstack/command-backend": "0.2.21",
27
+ "@checkstack/command-backend": "0.2.22",
28
28
  "@checkstack/signal-common": "0.2.17",
29
- "@checkstack/status-page-backend": "0.4.8",
30
- "@checkstack/status-page-common": "0.5.3",
31
- "@checkstack/integration-backend": "0.7.3",
29
+ "@checkstack/status-page-backend": "0.5.0",
30
+ "@checkstack/status-page-common": "0.6.0",
31
+ "@checkstack/integration-backend": "0.7.4",
32
32
  "@checkstack/integration-common": "0.9.8",
33
- "@checkstack/automation-backend": "0.11.0",
33
+ "@checkstack/automation-backend": "0.11.1",
34
34
  "@checkstack/automation-common": "0.10.0",
35
35
  "@checkstack/common": "0.22.0",
36
36
  "drizzle-orm": "^0.45.0",
@@ -41,7 +41,7 @@
41
41
  "devDependencies": {
42
42
  "@checkstack/drizzle-helper": "0.0.6",
43
43
  "@checkstack/scripts": "0.7.3",
44
- "@checkstack/test-utils-backend": "0.1.55",
44
+ "@checkstack/test-utils-backend": "0.1.56",
45
45
  "@checkstack/tsconfig": "0.0.7",
46
46
  "@types/bun": "^1.0.0",
47
47
  "@types/pg": "^8.20.0",
@@ -50,6 +50,7 @@ describe("incident.addLink tool", () => {
50
50
  incidentId: input.incidentId,
51
51
  label: input.label ?? null,
52
52
  url: input.url,
53
+ visibility: "public" as const,
53
54
  createdAt: new Date(),
54
55
  };
55
56
  const addLink = mock(() => Promise.resolve(created));
@@ -45,7 +45,12 @@ describe("incident.addUpdate tool", () => {
45
45
  });
46
46
 
47
47
  test("execute (apply) posts via addUpdate", async () => {
48
- const created = { id: "upd1", ...input, createdAt: new Date() };
48
+ const created = {
49
+ id: "upd1",
50
+ ...input,
51
+ visibility: "public" as const,
52
+ createdAt: new Date(),
53
+ };
49
54
  const addUpdate = mock(() => Promise.resolve(created));
50
55
  const rpcClient = fakeRpcClient({ addUpdate });
51
56
  const tool = createIncidentAddUpdateTool();
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createIncidentDeleteUpdateTool } from "./incident-delete-update";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["incident.incident.manage"],
9
+ };
10
+
11
+ function fakeRpcClient({
12
+ deleteUpdate,
13
+ }: {
14
+ deleteUpdate: ReturnType<typeof mock>;
15
+ }): RpcClient {
16
+ return {
17
+ forPlugin: () => ({ deleteUpdate }),
18
+ } as unknown as RpcClient;
19
+ }
20
+
21
+ describe("incident.deleteUpdate tool", () => {
22
+ test("declares destructive effect + the manage rule", () => {
23
+ const tool = createIncidentDeleteUpdateTool();
24
+ expect(tool.name).toBe("incident.deleteUpdate");
25
+ expect(tool.effect).toBe("destructive");
26
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
27
+ expect(typeof tool.dryRun).toBe("function");
28
+ });
29
+
30
+ test("dryRun returns a payload and NEVER deletes the update", async () => {
31
+ const deleteUpdate = mock(() => Promise.resolve({ success: true }));
32
+ const rpcClient = fakeRpcClient({ deleteUpdate });
33
+ const tool = createIncidentDeleteUpdateTool();
34
+ const preview = await tool.dryRun!({
35
+ input: { id: "upd1", incidentId: "inc1" },
36
+ principal,
37
+ rpcClient,
38
+ });
39
+ expect(deleteUpdate).not.toHaveBeenCalled();
40
+ expect(preview.summary).toContain("permanent");
41
+ expect(preview.payload).toEqual({ id: "upd1", incidentId: "inc1" });
42
+ });
43
+
44
+ test("execute (apply) deletes via deleteUpdate with {id, incidentId}", async () => {
45
+ const deleteUpdate = mock(() => Promise.resolve({ success: true }));
46
+ const rpcClient = fakeRpcClient({ deleteUpdate });
47
+ const tool = createIncidentDeleteUpdateTool();
48
+ const result = await tool.execute({
49
+ input: { id: "upd1", incidentId: "inc1" },
50
+ principal,
51
+ rpcClient,
52
+ });
53
+ expect(deleteUpdate).toHaveBeenCalledWith({
54
+ id: "upd1",
55
+ incidentId: "inc1",
56
+ });
57
+ expect(result).toEqual({ id: "upd1", removed: true });
58
+ });
59
+ });
@@ -0,0 +1,73 @@
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
+ } from "@checkstack/incident-common";
9
+ import type { AiProposalPreview } from "@checkstack/ai-common";
10
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
+
12
+ /** Input for `incident.deleteUpdate`: the update id + its owning incident. */
13
+ export const IncidentDeleteUpdateInputSchema = z.object({
14
+ id: z.string(),
15
+ incidentId: z.string(),
16
+ });
17
+ export type IncidentDeleteUpdateInput = z.infer<
18
+ typeof IncidentDeleteUpdateInputSchema
19
+ >;
20
+
21
+ /** Output returned once a human applies the update removal. */
22
+ export interface IncidentDeleteUpdateApplyResult {
23
+ id: string;
24
+ removed: true;
25
+ }
26
+
27
+ /**
28
+ * `incident.deleteUpdate` - delete a published status update by id.
29
+ *
30
+ * `effect: "destructive"` - deleting an update is irreversible, so it ALWAYS
31
+ * routes through the propose/apply confirm card in BOTH permission modes (it can
32
+ * never auto-apply). `dryRun` returns the captured payload for human review
33
+ * WITHOUT mutating; `execute` (reached only via `apply`) deletes the update. The
34
+ * underlying RPC uses the USER-SCOPED client passed at call time, so
35
+ * handler-side authorization is enforced exactly as a direct UI/RPC call.
36
+ */
37
+ export function createIncidentDeleteUpdateTool(): RegisteredAiTool<
38
+ IncidentDeleteUpdateInput,
39
+ IncidentDeleteUpdateApplyResult
40
+ > {
41
+ const dryRun = async ({
42
+ input,
43
+ }: {
44
+ input: IncidentDeleteUpdateInput;
45
+ principal: AuthUser;
46
+ rpcClient: RpcClient;
47
+ }): Promise<AiProposalPreview<IncidentDeleteUpdateInput>> => {
48
+ return {
49
+ summary: `Delete update ${input.id} from incident ${input.incidentId}. This is permanent.`,
50
+ payload: { id: input.id, incidentId: input.incidentId },
51
+ };
52
+ };
53
+
54
+ return {
55
+ name: "incident.deleteUpdate",
56
+ description:
57
+ "Delete a published status update from an incident by update id and its owning incidentId. DESTRUCTIVE and irreversible. Never deletes directly; a person must approve the confirmation. Find the update id and incidentId by reading the incident's details first.",
58
+ effect: "destructive",
59
+ input: IncidentDeleteUpdateInputSchema,
60
+ requiredAccessRules: [
61
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
62
+ ],
63
+ dryRun,
64
+ async execute({ input, rpcClient }) {
65
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
66
+ await incidentClient.deleteUpdate({
67
+ id: input.id,
68
+ incidentId: input.incidentId,
69
+ });
70
+ return { id: input.id, removed: true };
71
+ },
72
+ };
73
+ }
@@ -6,6 +6,7 @@ import { createIncidentAddUpdateTool } from "./incident-add-update";
6
6
  import { createIncidentResolveTool } from "./incident-resolve";
7
7
  import { createIncidentAddLinkTool } from "./incident-add-link";
8
8
  import { createIncidentRemoveLinkTool } from "./incident-remove-link";
9
+ import { createIncidentDeleteUpdateTool } from "./incident-delete-update";
9
10
 
10
11
  /**
11
12
  * The incident plugin's AI tools, registered into the AI registry via
@@ -29,5 +30,6 @@ export function buildIncidentAiTools(): RegisteredAiTool[] {
29
30
  createIncidentResolveTool(),
30
31
  createIncidentAddLinkTool(),
31
32
  createIncidentRemoveLinkTool(),
33
+ createIncidentDeleteUpdateTool(),
32
34
  ];
33
35
  }
@@ -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