@checkstack/catalog-backend 1.2.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b995afb: Make `catalog-system` and `catalog-group` plugin-backed reactive entities via the Model-B entity state machine.
8
+
9
+ Catalog defines a `catalog-system` entity `{ name, description, metadata }` and a `catalog-group` entity `{ name, metadata }`. The `systems` / `groups` tables are BOTH authoritative AND the entities' current-state storage - there is no framework `entity_state` row for a catalog system/group. `defineEntity` is given plugin `read` accessors (`EntityService.getManySystemEntityStates` / `getManyGroupEntityStates`) that project the reactive subsets straight off those tables, and every reactive-state write goes through `handle.mutate` / `handle.remove`: `apply` performs the REAL `systems` / `groups` write (the plugin's own db/tx) and returns the new state; the framework snapshots `prev` via `read` BEFORE the write, appends the transition log, and emits `ENTITY_CHANGED` AFTER the write commits. Covered sites: create-system, update-system, delete-system (tombstone), create-group, update-group, delete-group (tombstone), and the `system.update_metadata` automation action. Create sites pre-generate the id so the handle is keyed on it and the create's `prev` snapshot reads the not-yet-existing row as absent; `EntityService.createSystem` / `createGroup` accept an optional pre-generated `id` (server-owned either way).
10
+
11
+ Change -> trigger-event derivers reproduce the existing qualified events (emitting the TRIGGER event ids automations match on, not the dotted hook ids):
12
+
13
+ - `catalog-system`: create -> `catalog.created`; tombstone -> `catalog.deleted`; field update -> `catalog.updated`.
14
+ - `catalog-group`: create -> `catalog.group.created`; tombstone -> `catalog.group.deleted` (a pure group update fires nothing).
15
+
16
+ Mirrors are diff-suppressed (a save-with-no-diff stays a no-op). The `catalog.system.*` / `catalog.group.*` cross-plugin hooks are removed in the same effort (see the healthcheck/catalog hook-removal changeset); cross-plugin consumers (incident, dependency, slo, healthcheck) read via `onEntityChanged`.
17
+
18
+ BREAKING CHANGES (behavior): none for trigger-event consumers - the same qualified trigger events still fire via the change derivers, and `onEntityChanged` consumers see the same change event. The only observable change is internal: catalog current state is read from the `systems` / `groups` tables instead of `entity_state`, and writes route through the entity handle. The `system.update_metadata` action's race-deleted ("disappeared mid-update") path now drives a no-op entity write (the framework diffs it as no change) before returning failure, instead of skipping the write entirely; no event fires either way.
19
+
20
+ - b995afb: Close a run-secret masking gap on run-originated catalog entity writes (security).
21
+
22
+ `writeCatalogSystemEntity` / `writeCatalogGroupEntity` had no `opts` parameter, so the `system.update_metadata` automation action (which has the dispatch `runId` in scope) could not forward it. Catalog `metadata` is `z.record(z.string(), z.unknown())` — the only reactive catalog field that can carry an arbitrary secret string — so a run-resolved secret merged into metadata would land UNMASKED in both the `entity_transitions` rows and the cluster-wide `ENTITY_CHANGED` event.
23
+
24
+ The catalog entity writers now accept `opts?: EntityMutationOpts` and forward it into `handle.mutate` / `handle.remove` (mirroring maintenance/slo), and `system.update_metadata` passes `opts: { runId }`. Run-resolved secrets in metadata are now masked in both the emit and the transition rows.
25
+
26
+ - b995afb: Remove the now-unused healthcheck + catalog entity hooks; rely on the reactive entities + change derivers (reactive automation engine Phase 4, final step of §10.3 / §10.4).
27
+
28
+ Now that every cross-plugin consumer (slo, dependency, incident, and healthcheck's own catalog-cleanup) reads these domains via `onEntityChanged`, the producers stop emitting the entity-change hooks and the trigger registrations become entity-driven (fired by the entity change deriver via Stage-1 routing, with a no-op `setup` so they stay in the editor's trigger catalog).
29
+
30
+ - **healthcheck**: stops emitting `healthcheck.system.degraded` / `.healthy` / `.health_changed` from the queue executor (the `health` entity mirror is the single source of truth). Its own `catalog.system.deleted` consumer switched to `onEntityChanged({ kind: "catalog-system" })` on tombstones (work-queue delivery preserved). The directional/umbrella triggers are now entity-driven.
31
+ - **catalog**: stops emitting `catalog.system.created` / `.updated` / `.deleted` and `catalog.group.created` / `.deleted` from the router + the `system.update_metadata` action (the `catalog-system` / `catalog-group` mirrors are authoritative). The system triggers are now entity-driven.
32
+
33
+ CORRECTNESS FIX (also affects the earlier healthcheck/catalog Phase-4 steps in this branch): the change derivers now emit the TRIGGER qualifiedIds that automations actually store in `trigger.event` and that Stage-1 routing matches on (`findEnabledByTriggerEvent`), NOT the dotted hook ids. Healthcheck triggers use underscore ids, so the deriver emits `healthcheck.system_degraded` / `system_healthy` / `system_health_changed` (not `healthcheck.system.degraded`). Catalog system triggers use ids `created`/`updated`/`deleted`, so the deriver emits `catalog.created` / `catalog.updated` / `catalog.deleted` (not `catalog.system.created`). Without this fix the migrated automations would never fire.
34
+
35
+ BREAKING CHANGES:
36
+
37
+ - `healthcheck.system.degraded` / `healthcheck.system.healthy` / `healthcheck.system.health_changed` cross-plugin hooks are removed. The reactive `health` entity drives the matching trigger events (`healthcheck.system_degraded` / `_healthy` / `_health_changed`), so existing automations keep firing. Kept healthcheck hooks: `assignment.changed`, `check.completed`, `check.failed`, `flapping_detected`.
38
+ - `catalog.system.created` / `.updated` / `.deleted` and `catalog.group.created` / `.deleted` cross-plugin hooks are removed. The reactive `catalog-system` / `catalog-group` entities drive the matching trigger events (`catalog.created` / `.updated` / `.deleted`); cross-plugin cleanup reactors subscribe to the `catalog-system` tombstone via `onEntityChanged`. `catalogHooks` / `healthCheckHooks` remain exported (the removed members are gone) for a stable import surface.
39
+
40
+ - b995afb: Restore the documented domain payload fields on entity-driven automation triggers.
41
+
42
+ Migrated triggers declare domain-named `payloadSchema`s (incident `incidentId`; health `systemId` / `previousStatus`; catalog `systemId` / `changedFields`; dependency `dependencyId`), but Stage-2 dispatch built `trigger.payload` from the generic entity-change shape (`{ kind, id, prev, next, delta, ...next }`). Operator filters and templates reading `trigger.payload.incidentId` / `.systemId` / `.previousStatus` silently resolved to `undefined` — a regression vs the legacy hook payloads.
43
+
44
+ Changes:
45
+
46
+ - `@checkstack/automation-backend`: `registerChangeDeriver` now accepts an optional per-kind `toPayload(changed) => Record<string, unknown>` mapper (at most one per kind; a second distinct mapper throws). Stage-2's `changedToPayload` uses the registered mapper to build `trigger.payload` so it matches the kind's declared `payloadSchema`, falling back to the generic change shape for kinds without a mapper. New exported type `EntityChangePayloadMapper`.
47
+ - `@checkstack/incident-backend`, `@checkstack/healthcheck-backend`, `@checkstack/catalog-backend`, `@checkstack/dependency-backend`: implement and register a `toPayload` for each entity-driven kind so `trigger.payload` carries the legacy domain keys again.
48
+
49
+ Descriptive incident payload fields not derivable from the reactive entity state (`title`, `description`, `createdAt`, `resolvedAt`) are now OPTIONAL on the incident trigger `payloadSchema`s — they were always absent from an entity-driven payload.
50
+
51
+ ### Patch Changes
52
+
53
+ - b995afb: Extract a shared `withEntityWrite` / `withEntityRemove` guard for PLUGIN-BACKED (Model B) reactive entities and refactor the per-domain copies onto it.
54
+
55
+ Every plugin-backed domain (incident, catalog, dependency, maintenance, slo, satellite) reimplemented the same "no handle wired → run the plugin write directly; handle wired → route through `handle.mutate` / `handle.remove`" guard, varying only in the id-key name. `@checkstack/automation-backend` now exports `withEntityWrite` / `withEntityRemove` (from the entity barrel) and each domain's thin, well-named wrappers (`writeIncidentEntity`, `writeMaintenanceEntity`, satellite's `mirror`, …) delegate to it, so the branch lives in exactly one place. Behavior is unchanged.
56
+
57
+ `writeHealthEntity` (healthcheck-backend) is intentionally NOT migrated onto the helper — it is genuinely bespoke (closure-captured durable state, distinct rethrow-vs-fail-soft branches, a per-system serializer, and it returns the computed state). SLO keeps its fail-soft `onError` wrapper around the shared guard.
58
+
59
+ - Updated dependencies [270ef29]
60
+ - Updated dependencies [b995afb]
61
+ - Updated dependencies [b995afb]
62
+ - Updated dependencies [b995afb]
63
+ - Updated dependencies [270ef29]
64
+ - Updated dependencies [270ef29]
65
+ - Updated dependencies [270ef29]
66
+ - Updated dependencies [270ef29]
67
+ - Updated dependencies [270ef29]
68
+ - Updated dependencies [270ef29]
69
+ - Updated dependencies [270ef29]
70
+ - Updated dependencies [270ef29]
71
+ - Updated dependencies [b995afb]
72
+ - Updated dependencies [b995afb]
73
+ - Updated dependencies [270ef29]
74
+ - Updated dependencies [b995afb]
75
+ - Updated dependencies [270ef29]
76
+ - Updated dependencies [b995afb]
77
+ - Updated dependencies [b995afb]
78
+ - Updated dependencies [270ef29]
79
+ - Updated dependencies [b995afb]
80
+ - Updated dependencies [b995afb]
81
+ - Updated dependencies [270ef29]
82
+ - Updated dependencies [b995afb]
83
+ - Updated dependencies [b995afb]
84
+ - Updated dependencies [b995afb]
85
+ - Updated dependencies [b995afb]
86
+ - Updated dependencies [270ef29]
87
+ - Updated dependencies [270ef29]
88
+ - Updated dependencies [270ef29]
89
+ - Updated dependencies [270ef29]
90
+ - Updated dependencies [270ef29]
91
+ - Updated dependencies [270ef29]
92
+ - Updated dependencies [270ef29]
93
+ - Updated dependencies [270ef29]
94
+ - Updated dependencies [b995afb]
95
+ - Updated dependencies [b995afb]
96
+ - @checkstack/backend-api@0.19.0
97
+ - @checkstack/automation-backend@0.3.0
98
+ - @checkstack/gitops-common@0.5.0
99
+ - @checkstack/gitops-backend@0.4.0
100
+ - @checkstack/auth-backend@0.4.32
101
+ - @checkstack/cache-api@0.3.7
102
+ - @checkstack/command-backend@0.1.32
103
+ - @checkstack/cache-utils@0.2.12
104
+
3
105
  ## 1.2.0
4
106
 
5
107
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,28 +15,28 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/backend-api": "0.17.1",
19
- "@checkstack/automation-backend": "0.1.0",
20
- "@checkstack/cache-api": "0.3.5",
21
- "@checkstack/cache-utils": "0.2.10",
22
- "@checkstack/auth-common": "0.7.1",
23
- "@checkstack/catalog-common": "2.2.2",
24
- "@checkstack/command-backend": "0.1.30",
25
- "@checkstack/auth-backend": "0.4.30",
26
- "@checkstack/gitops-backend": "0.3.6",
27
- "@checkstack/gitops-common": "0.4.1",
28
- "@checkstack/notification-common": "1.2.0",
18
+ "@checkstack/backend-api": "0.18.0",
19
+ "@checkstack/automation-backend": "0.2.0",
20
+ "@checkstack/cache-api": "0.3.6",
21
+ "@checkstack/cache-utils": "0.2.11",
22
+ "@checkstack/auth-common": "0.7.2",
23
+ "@checkstack/catalog-common": "2.2.3",
24
+ "@checkstack/command-backend": "0.1.31",
25
+ "@checkstack/auth-backend": "0.4.31",
26
+ "@checkstack/gitops-backend": "0.3.7",
27
+ "@checkstack/gitops-common": "0.4.2",
28
+ "@checkstack/notification-common": "1.2.1",
29
29
  "@orpc/server": "^1.13.2",
30
30
  "drizzle-orm": "^0.45.0",
31
31
  "hono": "^4.12.14",
32
32
  "uuid": "^14.0.0",
33
33
  "zod": "^4.2.1",
34
- "@checkstack/common": "0.11.0"
34
+ "@checkstack/common": "0.12.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@checkstack/drizzle-helper": "0.0.5",
38
- "@checkstack/scripts": "0.3.3",
39
- "@checkstack/test-utils-backend": "0.1.30",
38
+ "@checkstack/scripts": "0.3.4",
39
+ "@checkstack/test-utils-backend": "0.1.31",
40
40
  "@checkstack/tsconfig": "0.0.7",
41
41
  "@types/bun": "^1.3.5",
42
42
  "@types/node": "^20.0.0",
@@ -22,7 +22,6 @@ import {
22
22
  } from "./automations";
23
23
  import type { EntityService } from "./services/entity-service";
24
24
  import type { createCatalogCache } from "./cache";
25
- import { catalogHooks } from "./hooks";
26
25
 
27
26
  const logger = createMockLogger() as Logger;
28
27
 
@@ -57,7 +56,10 @@ describe("catalog triggers", () => {
57
56
  const parsed = systemCreatedTrigger.payloadSchema.safeParse(payload);
58
57
  expect(parsed.success).toBe(true);
59
58
  expect(systemCreatedTrigger.contextKey?.(payload)).toBe("sys-1");
60
- expect(systemCreatedTrigger.hook).toBe(catalogHooks.systemCreated);
59
+ // Entity-driven now (§10.4): no hook, a no-op setup keeps it in the
60
+ // editor catalog; the `catalog-system` deriver fires `catalog.created`.
61
+ expect(systemCreatedTrigger.hook).toBeUndefined();
62
+ expect(typeof systemCreatedTrigger.setup).toBe("function");
61
63
  });
62
64
 
63
65
  it("requires changedFields on the systemUpdated payload", () => {
@@ -128,7 +130,10 @@ interface FakeSystemRow {
128
130
  interface ActionFixture {
129
131
  service: EntityService;
130
132
  cache: ReturnType<typeof createCatalogCache>;
131
- emitHookMock: ReturnType<typeof mock>;
133
+ mutateMock: ReturnType<typeof mock>;
134
+ /** Reactive states returned by each `apply()` the action drove. */
135
+ mutateResults: unknown[];
136
+ getSystemEntity: () => never;
132
137
  updateSystemMock: ReturnType<typeof mock>;
133
138
  getSystemMock: ReturnType<typeof mock>;
134
139
  invalidateTopologyMock: ReturnType<typeof mock>;
@@ -168,12 +173,30 @@ function makeFixture(args: {
168
173
  invalidateContacts: mock(async () => {}),
169
174
  } as unknown as ReturnType<typeof createCatalogCache>;
170
175
 
171
- const emitHookMock = mock(async () => {});
176
+ // The action now drives its write through `handle.mutate({ id, apply })`:
177
+ // it runs `apply()` (the real `updateSystem`) once and records the id + the
178
+ // resulting reactive state, mirroring the framework handle.
179
+ const mutateResults: unknown[] = [];
180
+ const mutateMock = mock(
181
+ async (input: {
182
+ id: string;
183
+ opts?: { runId?: string };
184
+ apply: () => Promise<unknown>;
185
+ }) => {
186
+ const next = await input.apply();
187
+ mutateResults.push(next);
188
+ return next;
189
+ },
190
+ );
191
+ const getSystemEntity = () =>
192
+ ({ kind: "catalog-system", mutate: mutateMock }) as never;
172
193
 
173
194
  return {
174
195
  service,
175
196
  cache,
176
- emitHookMock,
197
+ mutateMock,
198
+ mutateResults,
199
+ getSystemEntity,
177
200
  updateSystemMock,
178
201
  getSystemMock,
179
202
  invalidateTopologyMock,
@@ -192,7 +215,7 @@ describe("catalog.system.update_metadata", () => {
192
215
  const [action] = createCatalogActions({
193
216
  entityService: fx.service,
194
217
  cache: fx.cache,
195
- emitHook: fx.emitHookMock as never,
218
+ getSystemEntity: fx.getSystemEntity,
196
219
  });
197
220
 
198
221
  const result = await action!.execute({
@@ -228,14 +251,55 @@ describe("catalog.system.update_metadata", () => {
228
251
  });
229
252
 
230
253
  expect(fx.invalidateTopologyMock).toHaveBeenCalledTimes(1);
231
- expect(fx.emitHookMock).toHaveBeenCalledTimes(1);
232
- const emitCall = fx.emitHookMock.mock.calls[0]!;
233
- expect(emitCall[0]).toBe(catalogHooks.systemUpdated);
234
- expect(emitCall[1]).toEqual({
235
- systemId: "sys-1",
236
- systemName: "API",
237
- changedFields: ["metadata"],
254
+ // The old `systemUpdated` hook emission was replaced by driving the edit
255
+ // through the reactive `catalog-system` entity via `handle.mutate` (§10.4):
256
+ // the handle is keyed by system id, and `apply` returns the resulting
257
+ // reactive state.
258
+ expect(fx.mutateMock).toHaveBeenCalledTimes(1);
259
+ const mutateCall = fx.mutateMock.mock.calls[0]![0] as { id: string };
260
+ expect(mutateCall.id).toBe("sys-1");
261
+ expect(fx.mutateResults[0]).toEqual({
262
+ name: "API",
263
+ description: null,
264
+ metadata: {
265
+ tier: "platinum",
266
+ region: "eu-central-1",
267
+ owner: "platform",
268
+ },
269
+ });
270
+ });
271
+
272
+ // Fix 3 (security): the action resolves config (including `metadata` values)
273
+ // against the run scope, which can contain run-resolved secrets. It MUST pass
274
+ // `opts: { runId }` into the entity write so the framework handle masks any
275
+ // such secret in the `entity_transitions` rows + the cluster-wide
276
+ // `ENTITY_CHANGED` (the masking itself is proven end-to-end in
277
+ // automation-backend's mutate-handle masking test).
278
+ it("forwards the dispatch runId to handle.mutate (secret-masking choke point)", async () => {
279
+ const fx = makeFixture({
280
+ initialRow: { id: "sys-1", name: "API", metadata: {} },
281
+ });
282
+ const [action] = createCatalogActions({
283
+ entityService: fx.service,
284
+ cache: fx.cache,
285
+ getSystemEntity: fx.getSystemEntity,
286
+ });
287
+
288
+ await action!.execute({
289
+ ...ctxBase,
290
+ consumedArtifacts: {},
291
+ config: {
292
+ systemId: "sys-1",
293
+ strategy: "merge",
294
+ metadata: { token: "resolved-secret-value" },
295
+ } as never,
238
296
  });
297
+
298
+ expect(fx.mutateMock).toHaveBeenCalledTimes(1);
299
+ const mutateCall = fx.mutateMock.mock.calls[0]![0] as {
300
+ opts?: { runId?: string };
301
+ };
302
+ expect(mutateCall.opts?.runId).toBe("run-1");
239
303
  });
240
304
 
241
305
  it("replaces the whole metadata object when strategy=replace", async () => {
@@ -249,7 +313,7 @@ describe("catalog.system.update_metadata", () => {
249
313
  const [action] = createCatalogActions({
250
314
  entityService: fx.service,
251
315
  cache: fx.cache,
252
- emitHook: fx.emitHookMock as never,
316
+ getSystemEntity: fx.getSystemEntity,
253
317
  });
254
318
 
255
319
  const result = await action!.execute({
@@ -285,7 +349,7 @@ describe("catalog.system.update_metadata", () => {
285
349
  const [action] = createCatalogActions({
286
350
  entityService: fx.service,
287
351
  cache: fx.cache,
288
- emitHook: fx.emitHookMock as never,
352
+ getSystemEntity: fx.getSystemEntity,
289
353
  });
290
354
 
291
355
  const result = await action!.execute({
@@ -311,7 +375,7 @@ describe("catalog.system.update_metadata", () => {
311
375
  const [action] = createCatalogActions({
312
376
  entityService: fx.service,
313
377
  cache: fx.cache,
314
- emitHook: fx.emitHookMock as never,
378
+ getSystemEntity: fx.getSystemEntity,
315
379
  });
316
380
 
317
381
  const result = await action!.execute({
@@ -329,7 +393,8 @@ describe("catalog.system.update_metadata", () => {
329
393
  expect(result.error).toMatch(/System not found/);
330
394
  expect(fx.updateSystemMock).not.toHaveBeenCalled();
331
395
  expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
332
- expect(fx.emitHookMock).not.toHaveBeenCalled();
396
+ // The probe failed, so no entity write was driven.
397
+ expect(fx.mutateMock).not.toHaveBeenCalled();
333
398
  });
334
399
 
335
400
  it("returns failure if updateSystem returns undefined (race-deleted mid-update)", async () => {
@@ -347,7 +412,7 @@ describe("catalog.system.update_metadata", () => {
347
412
  const [action] = createCatalogActions({
348
413
  entityService: fx.service,
349
414
  cache: fx.cache,
350
- emitHook: fx.emitHookMock as never,
415
+ getSystemEntity: fx.getSystemEntity,
351
416
  });
352
417
 
353
418
  const result = await action!.execute({
@@ -364,6 +429,14 @@ describe("catalog.system.update_metadata", () => {
364
429
  if (result.success) return;
365
430
  expect(result.error).toMatch(/disappeared mid-update/);
366
431
  expect(fx.invalidateTopologyMock).not.toHaveBeenCalled();
367
- expect(fx.emitHookMock).not.toHaveBeenCalled();
432
+ // The probe found the row, so the write was driven — but `apply` fell
433
+ // back to the pre-write state, so the framework handle would diff it as a
434
+ // no-op (no spurious `catalog.updated`). The action still reports failure.
435
+ expect(fx.mutateMock).toHaveBeenCalledTimes(1);
436
+ expect(fx.mutateResults[0]).toEqual({
437
+ name: "API",
438
+ description: null,
439
+ metadata: {},
440
+ });
368
441
  });
369
442
  });
@@ -20,15 +20,22 @@
20
20
  * hook so downstream automations + cache subscribers see the change.
21
21
  */
22
22
  import { z } from "zod";
23
- import { Versioned, type Hook } from "@checkstack/backend-api";
23
+ import { Versioned } from "@checkstack/backend-api";
24
24
  import type {
25
25
  ActionDefinition,
26
26
  TriggerDefinition,
27
27
  } from "@checkstack/automation-backend";
28
-
29
- import { catalogHooks } from "./hooks";
28
+ import {
29
+ makeEntityDrivenTriggerSetup,
30
+ type EntityHandle,
31
+ } from "@checkstack/automation-backend";
30
32
  import type { EntityService } from "./services/entity-service";
31
33
  import type { createCatalogCache } from "./cache";
34
+ import {
35
+ toCatalogSystemState,
36
+ writeCatalogSystemEntity,
37
+ type CatalogSystemState,
38
+ } from "./catalog-entity";
32
39
 
33
40
  // ─── Payload schemas — match the hook payloads exactly ─────────────────
34
41
 
@@ -50,6 +57,10 @@ const systemDeletedPayloadSchema = z.object({
50
57
 
51
58
  // ─── Triggers ──────────────────────────────────────────────────────────
52
59
 
60
+ // These triggers are ENTITY-DRIVEN (§10.4): the `catalog-system` entity's
61
+ // change deriver fires `catalog.created/.updated/.deleted` via Stage-1
62
+ // routing, so they no longer subscribe to a hook. A no-op `setup` keeps
63
+ // them in the editor's trigger catalog without re-introducing a hook.
53
64
  export const systemCreatedTrigger: TriggerDefinition<
54
65
  z.infer<typeof systemCreatedPayloadSchema>
55
66
  > = {
@@ -59,7 +70,9 @@ export const systemCreatedTrigger: TriggerDefinition<
59
70
  category: "Catalog",
60
71
  icon: "Activity",
61
72
  payloadSchema: systemCreatedPayloadSchema,
62
- hook: catalogHooks.systemCreated,
73
+ setup: makeEntityDrivenTriggerSetup<
74
+ z.infer<typeof systemCreatedPayloadSchema>
75
+ >(),
63
76
  contextKey: (p) => p.systemId,
64
77
  };
65
78
 
@@ -72,7 +85,9 @@ export const systemUpdatedTrigger: TriggerDefinition<
72
85
  category: "Catalog",
73
86
  icon: "Activity",
74
87
  payloadSchema: systemUpdatedPayloadSchema,
75
- hook: catalogHooks.systemUpdated,
88
+ setup: makeEntityDrivenTriggerSetup<
89
+ z.infer<typeof systemUpdatedPayloadSchema>
90
+ >(),
76
91
  contextKey: (p) => p.systemId,
77
92
  };
78
93
 
@@ -85,7 +100,9 @@ export const systemDeletedTrigger: TriggerDefinition<
85
100
  category: "Catalog",
86
101
  icon: "Activity",
87
102
  payloadSchema: systemDeletedPayloadSchema,
88
- hook: catalogHooks.systemDeleted,
103
+ setup: makeEntityDrivenTriggerSetup<
104
+ z.infer<typeof systemDeletedPayloadSchema>
105
+ >(),
89
106
  contextKey: (p) => p.systemId,
90
107
  };
91
108
 
@@ -134,11 +151,12 @@ export interface CatalogActionDeps {
134
151
  entityService: EntityService;
135
152
  cache: ReturnType<typeof createCatalogCache>;
136
153
  /**
137
- * `emitHook` bound during `afterPluginsReady`. Required so the
138
- * action fires `systemUpdated` downstream without it, other
139
- * automations waiting on the trigger wouldn't see the change.
154
+ * Resolver for the reactive `catalog-system` entity (§10.4). The
155
+ * `update_metadata` action mirrors its edit here; the entity's change
156
+ * deriver fires the `catalog.updated` trigger event downstream (the old
157
+ * `systemUpdated` hook emission was removed).
140
158
  */
141
- emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
159
+ getSystemEntity?: () => EntityHandle<CatalogSystemState> | undefined;
142
160
  }
143
161
 
144
162
  export function createCatalogActions(
@@ -159,7 +177,7 @@ export function createCatalogActions(
159
177
  schema: systemUpdateMetadataConfigSchema,
160
178
  }),
161
179
  produces: "catalog.system_record",
162
- execute: async ({ config, logger }) => {
180
+ execute: async ({ config, logger, runId }) => {
163
181
  const existing = await deps.entityService.getSystem(config.systemId);
164
182
  if (!existing) {
165
183
  return {
@@ -175,9 +193,39 @@ export function createCatalogActions(
175
193
  ? config.metadata
176
194
  : { ...existingMetadata, ...config.metadata };
177
195
 
178
- const updated = await deps.entityService.updateSystem(config.systemId, {
179
- metadata: nextMetadata,
196
+ // Drive the update through the reactive `catalog-system` entity (§10.4);
197
+ // the REAL `updateSystem` runs INSIDE `apply`, so `prev` is snapshotted
198
+ // before the write and the deriver fires `catalog.updated`. If the row
199
+ // was race-deleted mid-update, `apply` falls back to the pre-write
200
+ // state so the diff is a no-op (no spurious `catalog.updated`), and the
201
+ // failure is surfaced from the captured `updated`.
202
+ // Capture the post-write row in a holder so TS control-flow tracks the
203
+ // assignment made inside the async `apply` closure (a plain `let`
204
+ // mutated in a closure is invisible to CFA and would narrow to `never`).
205
+ const captured: {
206
+ row: Awaited<ReturnType<typeof deps.entityService.updateSystem>> | null;
207
+ } = { row: null };
208
+ await writeCatalogSystemEntity({
209
+ handle: deps.getSystemEntity?.(),
210
+ systemId: config.systemId,
211
+ // Run-secret masking choke point: this action resolves config
212
+ // (including `metadata` values) against the run scope, which can
213
+ // contain run-resolved secrets. Passing `runId` masks any such secret
214
+ // in the `entity_transitions` rows + the cluster-wide `ENTITY_CHANGED`.
215
+ opts: { runId },
216
+ apply: async () => {
217
+ const row = await deps.entityService.updateSystem(config.systemId, {
218
+ metadata: nextMetadata,
219
+ });
220
+ captured.row = row ?? null;
221
+ return toCatalogSystemState({
222
+ name: row?.name ?? existing.name,
223
+ description: row?.description ?? existing.description,
224
+ metadata: row ? nextMetadata : existingMetadata,
225
+ });
226
+ },
180
227
  });
228
+ const updated = captured.row;
181
229
  if (!updated) {
182
230
  return {
183
231
  success: false,
@@ -186,11 +234,6 @@ export function createCatalogActions(
186
234
  }
187
235
 
188
236
  await deps.cache.invalidateTopology();
189
- await deps.emitHook(catalogHooks.systemUpdated, {
190
- systemId: updated.id,
191
- systemName: updated.name,
192
- changedFields: ["metadata"],
193
- });
194
237
 
195
238
  logger.info(
196
239
  `Automation updated metadata on system ${updated.id} (${config.strategy})`,