@checkstack/dependency-backend 1.1.6 → 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.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The reactive `dependency-edge` entity (reactive automation engine §10.5).
3
+ *
4
+ * Model B PLUGIN-BACKED entity: the `dependencies` table is authoritative AND
5
+ * IS the entity's current-state storage — there is NO framework `entity_state`
6
+ * row for a dependency edge. `defineEntity({ read })` makes that plugin state
7
+ * reactive: every reactive-state write goes through `handle.mutate`, whose
8
+ * `apply()` performs the REAL `dependencies` write via the `DependencyService`
9
+ * (the plugin's own db/tx) and returns the resulting reactive subset
10
+ * `{ sourceSystemId, targetSystemId, impactType, transitive }`. The framework
11
+ * snapshots `prev` via `read`, appends the transition log (its own db), and
12
+ * emits `ENTITY_CHANGED`. The change → trigger-event deriver reproduces
13
+ * `dependency.created/.updated/.deleted` so automations keep firing.
14
+ *
15
+ * `dependency_derived_states` (the propagation cursor) and the
16
+ * `dependency.impact_propagated` notification stay NON-reactive (§5):
17
+ * derived per-system state is reachable via the `health` entity, and
18
+ * impact propagation is a fan-out signal, not a single mutable field.
19
+ */
20
+ import { z } from "zod";
21
+ import { ImpactTypeSchema } from "@checkstack/dependency-common";
22
+ import type {
23
+ EntityChangeDeriver,
24
+ EntityChangePayloadMapper,
25
+ EntityHandle,
26
+ EntityRead,
27
+ } from "@checkstack/automation-backend";
28
+ import {
29
+ withEntityRemove,
30
+ withEntityWrite,
31
+ } from "@checkstack/automation-backend";
32
+
33
+ import type { DependencyService } from "./services/dependency-service";
34
+
35
+ export const DEPENDENCY_EDGE_ENTITY_KIND = "dependency-edge";
36
+
37
+ export const DependencyEdgeStateSchema = z.object({
38
+ sourceSystemId: z.string(),
39
+ targetSystemId: z.string(),
40
+ impactType: ImpactTypeSchema,
41
+ transitive: z.boolean(),
42
+ });
43
+
44
+ export type DependencyEdgeState = z.infer<typeof DependencyEdgeStateSchema>;
45
+
46
+ export const DEPENDENCY_TRIGGER_EVENTS = {
47
+ created: "dependency.created",
48
+ updated: "dependency.updated",
49
+ deleted: "dependency.deleted",
50
+ } as const;
51
+
52
+ /**
53
+ * `dependency-edge` change → trigger events. Create (`prev === null`),
54
+ * tombstone (`next === null`), or a field update map to the matching
55
+ * lifecycle event (the handle suppresses no-op diffs, so an update always
56
+ * carries a real change).
57
+ */
58
+ export const deriveDependencyTriggerEvents: EntityChangeDeriver = (changed) => {
59
+ if (changed.prev === null && changed.next !== null) {
60
+ return [DEPENDENCY_TRIGGER_EVENTS.created];
61
+ }
62
+ if (changed.next === null) {
63
+ return [DEPENDENCY_TRIGGER_EVENTS.deleted];
64
+ }
65
+ return [DEPENDENCY_TRIGGER_EVENTS.updated];
66
+ };
67
+
68
+ /**
69
+ * Map a `dependency-edge` change to the domain-named `trigger.payload` the
70
+ * dependency triggers declare via `payloadSchema` (`dependencyId`,
71
+ * `sourceSystemId`, `targetSystemId`, `impactType`). Restores the keys
72
+ * operators read (`trigger.payload.dependencyId`, `.sourceSystemId`, …) that
73
+ * the generic change shape omits.
74
+ *
75
+ * `dependencyId` is the entity id. The edge fields are read from `next`, or
76
+ * from `prev` on a tombstone (`deleted`), so a delete trigger still carries
77
+ * the removed edge's endpoints. `impactType` is omitted on a delete (the
78
+ * `deleted` schema does not declare it).
79
+ */
80
+ export const dependencyChangeToPayload: EntityChangePayloadMapper = (
81
+ changed,
82
+ ) => {
83
+ const source = changed.next ?? changed.prev;
84
+ const impactType = source?.["impactType"];
85
+ return {
86
+ dependencyId: changed.id,
87
+ sourceSystemId: source?.["sourceSystemId"],
88
+ targetSystemId: source?.["targetSystemId"],
89
+ ...(impactType === undefined ? {} : { impactType }),
90
+ };
91
+ };
92
+
93
+ /**
94
+ * Build the PLUGIN-BACKED `read` accessor for the `dependency-edge` entity.
95
+ * Routes straight to the service's batched authoritative read over the
96
+ * `dependencies` table — no framework storage.
97
+ */
98
+ export function createDependencyEntityRead(
99
+ service: DependencyService,
100
+ ): EntityRead<DependencyEdgeState> {
101
+ return (ids) => service.getManyEntityStates(ids);
102
+ }
103
+
104
+ /**
105
+ * Project a dependency row onto the reactive `{ sourceSystemId,
106
+ * targetSystemId, impactType, transitive }` subset. The router/action service
107
+ * writes return the full dependency; this is the `apply()` return for
108
+ * `handle.mutate`.
109
+ */
110
+ export function toDependencyEdgeState(dependency: {
111
+ sourceSystemId: string;
112
+ targetSystemId: string;
113
+ impactType: DependencyEdgeState["impactType"];
114
+ transitive: boolean;
115
+ }): DependencyEdgeState {
116
+ return {
117
+ sourceSystemId: dependency.sourceSystemId,
118
+ targetSystemId: dependency.targetSystemId,
119
+ impactType: dependency.impactType,
120
+ transitive: dependency.transitive,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Drive a reactive-state `dependency-edge` write through `handle.mutate`
126
+ * (§10.5). `apply` performs the REAL `dependencies` write via the service
127
+ * (the plugin's own db/tx) and returns the new reactive state. The framework
128
+ * snapshots `prev`, appends the transition log, and emits `ENTITY_CHANGED`
129
+ * (the deriver turns that into `dependency.created/.updated`).
130
+ *
131
+ * When no handle is available (tests construct the router without one), the
132
+ * write still runs — the entity reactivity is layered on top, never required
133
+ * for the underlying write to succeed.
134
+ */
135
+ export async function writeDependencyEdge(args: {
136
+ handle: EntityHandle<DependencyEdgeState> | undefined;
137
+ dependencyId: string;
138
+ apply: () => Promise<DependencyEdgeState>;
139
+ }): Promise<void> {
140
+ const { handle, dependencyId, apply } = args;
141
+ await withEntityWrite({ handle, id: dependencyId, apply });
142
+ }
143
+
144
+ /**
145
+ * Drive a dependency-edge tombstone through `handle.remove` (§10.5). `apply`
146
+ * performs the REAL delete via the service; the framework records the
147
+ * tombstone transition and emits a tombstone change (the deriver fires
148
+ * `dependency.deleted`). Without a handle, the delete still runs.
149
+ */
150
+ export async function removeDependencyEdge(args: {
151
+ handle: EntityHandle<DependencyEdgeState> | undefined;
152
+ dependencyId: string;
153
+ apply: () => Promise<void>;
154
+ }): Promise<void> {
155
+ const { handle, dependencyId, apply } = args;
156
+ await withEntityRemove({ handle, id: dependencyId, apply });
157
+ }
package/src/hooks.ts CHANGED
@@ -1,36 +1,43 @@
1
1
  import { createHook } from "@checkstack/backend-api";
2
+ import type { DerivedState } from "@checkstack/dependency-common";
2
3
 
3
4
  /**
4
5
  * Dependency hooks for cross-plugin communication.
5
6
  * Other plugins can subscribe to these hooks to react to dependency changes.
7
+ *
8
+ * `impactType` and the derived-state fields carry the canonical
9
+ * `ImpactType` / `DerivedState` enum values, so automation triggers
10
+ * built on these hooks can offer the known values for `==` comparisons
11
+ * in the editor.
6
12
  */
7
13
  export const dependencyHooks = {
8
- /**
9
- * Emitted when a dependency is created.
10
- */
11
- dependencyCreated: createHook<{
12
- dependencyId: string;
13
- sourceSystemId: string;
14
- targetSystemId: string;
15
- impactType: string;
16
- }>("dependency.created"),
17
-
18
- /**
19
- * Emitted when a dependency is updated.
20
- */
21
- dependencyUpdated: createHook<{
22
- dependencyId: string;
23
- sourceSystemId: string;
24
- targetSystemId: string;
25
- impactType: string;
26
- }>("dependency.updated"),
14
+ // The `dependency.created` / `.updated` / `.deleted` hooks were removed in
15
+ // Phase 4 (§10.5): dependency edges are now the reactive `dependency-edge`
16
+ // entity, whose change deriver fires the matching `dependency.created` /
17
+ // `.updated` / `.deleted` trigger events through Stage-1 routing. The
18
+ // `impact_propagated` hook below is KEPT — it is a derived fan-out signal
19
+ // (per-downstream deltas), not a single mutable entity field.
27
20
 
28
21
  /**
29
- * Emitted when a dependency is deleted.
22
+ * Emitted when an upstream system's state change has propagated
23
+ * through the dependency graph and produced derived-state changes
24
+ * on one or more downstream systems. Carries the list of affected
25
+ * downstream systems with their previous and new derived states so
26
+ * subscribers don't have to re-query the graph.
27
+ *
28
+ * Fires only when at least one downstream state actually changed —
29
+ * upstream events that don't move any downstream's derived state
30
+ * are silent. Emitted from `evaluateAndNotifyDownstream` once per
31
+ * upstream event (deduplicated by `sourceSystemId`, not by
32
+ * downstream).
30
33
  */
31
- dependencyDeleted: createHook<{
32
- dependencyId: string;
34
+ impactPropagated: createHook<{
33
35
  sourceSystemId: string;
34
- targetSystemId: string;
35
- }>("dependency.deleted"),
36
+ affectedSystems: Array<{
37
+ systemId: string;
38
+ previousState: DerivedState | null;
39
+ newState: DerivedState | null;
40
+ }>;
41
+ timestamp: string;
42
+ }>("dependency.impact_propagated"),
36
43
  } as const;
package/src/index.ts CHANGED
@@ -8,18 +8,42 @@ import {
8
8
  dependencyGroupSubscription,
9
9
  } from "@checkstack/dependency-common";
10
10
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
11
+ import {
12
+ automationActionExtensionPoint,
13
+ automationArtifactTypeExtensionPoint,
14
+ automationTriggerExtensionPoint,
15
+ entityExtensionPoint,
16
+ type EntityHandle,
17
+ } from "@checkstack/automation-backend";
11
18
  import { DependencyService } from "./services/dependency-service";
12
19
  import { WarningEvaluationService } from "./services/warning-evaluation-service";
13
20
  import type { SystemStatus } from "./services/warning-evaluation-service";
14
21
  import { createRouter } from "./router";
22
+ import {
23
+ createDependencyActions,
24
+ dependencyArtifactType,
25
+ dependencyTriggers,
26
+ } from "./automations";
27
+ import { dependencyHooks } from "./hooks";
15
28
  import { CatalogApi } from "@checkstack/catalog-common";
16
29
  import { HealthCheckApi } from "@checkstack/healthcheck-common";
17
30
  import { MaintenanceApi } from "@checkstack/maintenance-common";
18
31
  import { IncidentApi } from "@checkstack/incident-common";
19
32
  import { NotificationApi } from "@checkstack/notification-common";
20
- import { catalogHooks } from "@checkstack/catalog-backend";
21
- import { healthCheckHooks } from "@checkstack/healthcheck-backend";
33
+ import { CATALOG_SYSTEM_ENTITY_KIND } from "@checkstack/catalog-backend";
34
+ import {
35
+ HEALTH_ENTITY_KIND,
36
+ classifyHealthChange,
37
+ } from "@checkstack/healthcheck-backend";
22
38
  import { evaluateAndNotifyDownstream } from "./notifications";
39
+ import {
40
+ DEPENDENCY_EDGE_ENTITY_KIND,
41
+ DependencyEdgeStateSchema,
42
+ createDependencyEntityRead,
43
+ dependencyChangeToPayload,
44
+ deriveDependencyTriggerEvents,
45
+ type DependencyEdgeState,
46
+ } from "./dependency-entity";
23
47
  import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
24
48
  import { registerDependencyGitOpsKinds } from "./dependency-gitops-kinds";
25
49
 
@@ -27,6 +51,17 @@ import { registerDependencyGitOpsKinds } from "./dependency-gitops-kinds";
27
51
  // Plugin Definition
28
52
  // =============================================================================
29
53
 
54
+ // Reactive `dependency-edge` entity handle (§10.5). Defined in register();
55
+ // mutated from the router/actions onward.
56
+ let dependencyEntity: EntityHandle<DependencyEdgeState> | undefined;
57
+
58
+ // The DependencyService is created in init() (it needs the resolved
59
+ // database), but the PLUGIN-BACKED entity `read` accessor must be supplied at
60
+ // `defineEntity` time in register(). This holder bridges the two: the `read`
61
+ // closure resolves the service lazily, and init() sets it before any mutation
62
+ // runs (the registry only mutates from init() onward).
63
+ let dependencyServiceRef: DependencyService | undefined;
64
+
30
65
  export default createBackendPlugin({
31
66
  metadata: pluginMetadata,
32
67
  register(env) {
@@ -36,6 +71,51 @@ export default createBackendPlugin({
36
71
  dependencyGroupSubscription,
37
72
  ]);
38
73
 
74
+ // ─── Automation Platform: triggers + artifact type ─────────────────
75
+ const automationTriggers = env.getExtensionPoint(
76
+ automationTriggerExtensionPoint,
77
+ );
78
+ for (const trigger of dependencyTriggers) {
79
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
80
+ }
81
+ env
82
+ .getExtensionPoint(automationArtifactTypeExtensionPoint)
83
+ .registerArtifactType(dependencyArtifactType, pluginMetadata);
84
+
85
+ // ─── Reactive `dependency-edge` entity (§10.5) ─────────────────────
86
+ // PLUGIN-BACKED (Model B): the `dependencies` table IS the current-state
87
+ // storage. `read` routes straight to the service's batched authoritative
88
+ // read — no framework `entity_state` row, so no `indexes` (those only
89
+ // apply to store-backed kinds). The `read` closure resolves the service
90
+ // set by init() (mutations only happen from init on).
91
+ const entityPoint = env.getExtensionPoint(entityExtensionPoint);
92
+ dependencyEntity = entityPoint.defineEntity<DependencyEdgeState>({
93
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
94
+ state: DependencyEdgeStateSchema,
95
+ read: (ids) => {
96
+ const svc = dependencyServiceRef;
97
+ if (!svc) {
98
+ throw new Error(
99
+ "dependency entity read before init: service not yet resolved",
100
+ );
101
+ }
102
+ return createDependencyEntityRead(svc)(ids);
103
+ },
104
+ });
105
+ entityPoint.registerChangeDeriver({
106
+ kind: DEPENDENCY_EDGE_ENTITY_KIND,
107
+ derive: deriveDependencyTriggerEvents,
108
+ toPayload: dependencyChangeToPayload,
109
+ });
110
+ const onEntityChanged = entityPoint.onEntityChanged;
111
+ // The propagation cursor is internal bookkeeping (§5): derived
112
+ // per-system state is reachable via the `health` entity, not this table.
113
+ entityPoint.declareNonReactiveState({
114
+ table: "dependency_derived_states",
115
+ reason: "bookkeeping",
116
+ note: "Propagation cursor for downstream impact evaluation. Derived per-system state is reachable via the health entity.",
117
+ });
118
+
39
119
  // ─── GitOps Entity Kind Registration ─────────────────────────────
40
120
  let gitopsService: DependencyService | undefined;
41
121
  const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
@@ -66,6 +146,9 @@ export default createBackendPlugin({
66
146
  database as SafeDatabase<typeof schema>,
67
147
  );
68
148
  gitopsService = service;
149
+ // Publish the service for the PLUGIN-BACKED entity `read` accessor
150
+ // (defined in register()). Mutations only run from here onward.
151
+ dependencyServiceRef = service;
69
152
  const warningService = new WarningEvaluationService();
70
153
 
71
154
  const router = createRouter({
@@ -75,6 +158,7 @@ export default createBackendPlugin({
75
158
  catalogClient,
76
159
  healthCheckClient,
77
160
  logger,
161
+ getDependencyEntity: () => dependencyEntity,
78
162
  });
79
163
  rpc.registerRouter(router, dependencyContract);
80
164
 
@@ -84,13 +168,39 @@ export default createBackendPlugin({
84
168
  database,
85
169
  rpcClient,
86
170
  logger,
87
- onHook,
171
+ emitHook,
88
172
  signalService,
89
173
  }) => {
174
+ // Bound callback that fires `dependency.impact_propagated`
175
+ // when `evaluateAndNotifyDownstream` reports actual downstream
176
+ // state transitions. Local so we don't pass the full
177
+ // `emitHook` into helper modules that should only know about
178
+ // the one hook they fire.
179
+ const emitImpactPropagated = (event: {
180
+ sourceSystemId: string;
181
+ affectedSystems: Array<{
182
+ systemId: string;
183
+ previousState: string | null;
184
+ newState: string | null;
185
+ }>;
186
+ timestamp: string;
187
+ }) => emitHook(dependencyHooks.impactPropagated, event);
90
188
  const typedDb = database as SafeDatabase<typeof schema>;
91
189
  const service = new DependencyService(typedDb);
92
190
  const warningService = new WarningEvaluationService();
93
191
 
192
+ // Register automation actions now that `emitHook` is available.
193
+ const automationActions = env.getExtensionPoint(
194
+ automationActionExtensionPoint,
195
+ );
196
+ for (const action of createDependencyActions({
197
+ service,
198
+ emitHook,
199
+ getDependencyEntity: () => dependencyEntity,
200
+ })) {
201
+ automationActions.registerAction(action, pluginMetadata);
202
+ }
203
+
94
204
  const catalogClient = rpcClient.forPlugin(CatalogApi);
95
205
  const healthCheckClient = rpcClient.forPlugin(HealthCheckApi);
96
206
  const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
@@ -158,27 +268,42 @@ export default createBackendPlugin({
158
268
  return statuses;
159
269
  }
160
270
 
161
- // Subscribe to catalog system deletion to clean up dependencies
162
- onHook(
163
- catalogHooks.systemDeleted,
164
- async (payload) => {
271
+ // Cross-plugin consumers now react to the reactive `catalog-system`
272
+ // / `health` ENTITY changes via `onEntityChanged` instead of the
273
+ // (being-removed) catalog/healthcheck hooks (§10.5). All keep
274
+ // `work-queue` delivery: cleanup + downstream-propagation are
275
+ // side-effecting writes that must run exactly once per cluster, not
276
+ // per-instance (broadcast would re-run them N times).
277
+
278
+ // Subscribe to catalog system deletion (tombstone) to clean up edges
279
+ onEntityChanged({
280
+ kind: CATALOG_SYSTEM_ENTITY_KIND,
281
+ handler: async (change) => {
282
+ if (change.next !== null) return; // tombstone only
283
+ const systemId = change.id;
165
284
  logger.debug(
166
- `Cleaning up dependencies for deleted system: ${payload.systemId}`,
285
+ `Cleaning up dependencies for deleted system: ${systemId}`,
167
286
  );
168
- await service.removeSystemDependencies(payload.systemId);
287
+ await service.removeSystemDependencies(systemId);
169
288
  },
170
- { mode: "work-queue", workerGroup: "dependency-system-cleanup" },
171
- );
289
+ delivery: {
290
+ mode: "work-queue",
291
+ workerGroup: "dependency-system-cleanup",
292
+ },
293
+ });
172
294
 
173
- // Subscribe to health check state changes to notify downstream dependents
174
- onHook(
175
- healthCheckHooks.systemDegraded,
176
- async (payload) => {
295
+ // Upstream health DEGRADED evaluate downstream dependents
296
+ onEntityChanged({
297
+ kind: HEALTH_ENTITY_KIND,
298
+ handler: async (change) => {
299
+ const { systemId, degraded, previousStatus, newStatus } =
300
+ classifyHealthChange(change);
301
+ if (!degraded) return;
177
302
  logger.debug(
178
- `Upstream ${payload.systemId} degraded (${payload.previousStatus} → ${payload.newStatus}), evaluating downstream dependencies`,
303
+ `Upstream ${systemId} degraded (${previousStatus} → ${newStatus}), evaluating downstream dependencies`,
179
304
  );
180
305
  await evaluateAndNotifyDownstream({
181
- changedSystemId: payload.systemId,
306
+ changedSystemId: systemId,
182
307
  db: typedDb,
183
308
  dependencyService: service,
184
309
  warningService,
@@ -189,22 +314,26 @@ export default createBackendPlugin({
189
314
  incidentClient,
190
315
  signalService,
191
316
  logger,
317
+ emitImpactPropagated,
192
318
  });
193
319
  },
194
- {
320
+ delivery: {
195
321
  mode: "work-queue",
196
322
  workerGroup: "dependency-notification-evaluator",
197
323
  },
198
- );
324
+ });
199
325
 
200
- onHook(
201
- healthCheckHooks.systemHealthy,
202
- async (payload) => {
326
+ // Upstream health RECOVERED → evaluate downstream dependents
327
+ onEntityChanged({
328
+ kind: HEALTH_ENTITY_KIND,
329
+ handler: async (change) => {
330
+ const { systemId, recovered } = classifyHealthChange(change);
331
+ if (!recovered) return;
203
332
  logger.debug(
204
- `Upstream ${payload.systemId} recovered, evaluating downstream dependencies`,
333
+ `Upstream ${systemId} recovered, evaluating downstream dependencies`,
205
334
  );
206
335
  await evaluateAndNotifyDownstream({
207
- changedSystemId: payload.systemId,
336
+ changedSystemId: systemId,
208
337
  db: typedDb,
209
338
  dependencyService: service,
210
339
  warningService,
@@ -215,13 +344,14 @@ export default createBackendPlugin({
215
344
  incidentClient,
216
345
  signalService,
217
346
  logger,
347
+ emitImpactPropagated,
218
348
  });
219
349
  },
220
- {
350
+ delivery: {
221
351
  mode: "work-queue",
222
352
  workerGroup: "dependency-notification-recovery",
223
353
  },
224
- );
354
+ });
225
355
 
226
356
  logger.debug("✅ Dependency Backend afterPluginsReady complete.");
227
357
  },
@@ -190,6 +190,7 @@ export async function evaluateAndNotifyDownstream({
190
190
  incidentClient,
191
191
  signalService,
192
192
  logger,
193
+ emitImpactPropagated,
193
194
  }: {
194
195
  changedSystemId: string;
195
196
  db: Db;
@@ -204,6 +205,21 @@ export async function evaluateAndNotifyDownstream({
204
205
  incidentClient: InferClient<typeof IncidentApi>;
205
206
  signalService: SignalService;
206
207
  logger: Logger;
208
+ /**
209
+ * Optional callback fired when at least one downstream system's
210
+ * derived state actually changed. Wired in `afterPluginsReady` to
211
+ * emit `dependencyHooks.impactPropagated`; left undefined in tests
212
+ * + stripped-down harnesses to keep them allocation-free.
213
+ */
214
+ emitImpactPropagated?: (event: {
215
+ sourceSystemId: string;
216
+ affectedSystems: Array<{
217
+ systemId: string;
218
+ previousState: string | null;
219
+ newState: string | null;
220
+ }>;
221
+ timestamp: string;
222
+ }) => Promise<void>;
207
223
  }): Promise<void> {
208
224
  try {
209
225
  // 1. Find all downstream systems that depend on the changed system
@@ -309,6 +325,11 @@ export async function evaluateAndNotifyDownstream({
309
325
  // 6. Evaluate state changes and collect systems that need notification
310
326
  const changedSystemIds: string[] = [];
311
327
  const systemsToNotify: SystemNotificationEntry[] = [];
328
+ const impactPropagatedAffected: Array<{
329
+ systemId: string;
330
+ previousState: string | null;
331
+ newState: string | null;
332
+ }> = [];
312
333
 
313
334
  for (const systemId of downstreamIds) {
314
335
  const currentWarning = warningMap.get(systemId);
@@ -320,6 +341,16 @@ export async function evaluateAndNotifyDownstream({
320
341
  continue;
321
342
  }
322
343
 
344
+ // Always record every derived-state transition for the
345
+ // automation hook — including ones suppressed for end-user
346
+ // notifications, since an operator may want to react to
347
+ // propagation regardless of suppression.
348
+ impactPropagatedAffected.push({
349
+ systemId,
350
+ previousState: previousState ?? null,
351
+ newState: currentState ?? null,
352
+ });
353
+
323
354
  // State changed — update DB first
324
355
  await (currentState
325
356
  ? db
@@ -389,6 +420,26 @@ export async function evaluateAndNotifyDownstream({
389
420
  affectedSystemIds: changedSystemIds,
390
421
  });
391
422
  }
423
+
424
+ // 9. Fire the `dependency.impact_propagated` automation hook
425
+ // exactly once per upstream event, with the full set of
426
+ // downstream state transitions. Best-effort — failures are
427
+ // logged but never propagated up so a misbehaving subscriber
428
+ // can't disrupt notifications or signal broadcasts.
429
+ if (emitImpactPropagated && impactPropagatedAffected.length > 0) {
430
+ try {
431
+ await emitImpactPropagated({
432
+ sourceSystemId: changedSystemId,
433
+ affectedSystems: impactPropagatedAffected,
434
+ timestamp: new Date().toISOString(),
435
+ });
436
+ } catch (error) {
437
+ logger.error(
438
+ `Failed to emit dependency.impact_propagated hook for upstream ${changedSystemId}:`,
439
+ error,
440
+ );
441
+ }
442
+ }
392
443
  } catch (error) {
393
444
  // Don't crash the hook handler
394
445
  logger.error(