@checkstack/healthcheck-backend 1.11.1 → 1.13.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,332 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0cac684: Align the health-check run-history gates end to end. The history surfaces had a
8
+ three-way drift: the route allowed `configuration.read`, the page required
9
+ manage capability, and the procedures required the standalone
10
+ `healthcheck.details` rule - so global read-rule holders reached a page that
11
+ denied them, and team-scoped managers passed the page gate but got 403s from
12
+ every data call.
13
+
14
+ Detailed run history is now a MANAGER surface everywhere, with system owners
15
+ included: access requires global `configuration.manage`, a team manage grant
16
+ on the CONFIGURATION, or manage access to the SYSTEM - a system's owning team
17
+ sees every run of that system, whoever owns the configuration.
18
+
19
+ - Routes, pages, drawer links, and the anomaly/health signals gate on the
20
+ manage capability (with `catalog.system` as the parent type); the drawer and
21
+ chart hook check the caller's grant on the specific configuration OR system.
22
+ - All three history procedures (`getDetailedHistory`,
23
+ `getDetailedAggregatedHistory`, `getRunById`) are authorized in the handler
24
+ via a shared fail-closed module (`history-access.ts`) - the triple-OR is not
25
+ expressible with the declarative instanceAccess modes. `getRunById`
26
+ authorizes against the fetched run's own configuration/system, and answers
27
+ `undefined` for unauthorized callers so run ids don't leak existence.
28
+ - The feed (`getDetailedHistory`) scopes team callers to runs of their
29
+ configurations UNION runs of their systems, with correct pagination totals.
30
+
31
+ BREAKING CHANGES:
32
+
33
+ - The standalone `healthcheck.details` access rule is REMOVED. Roles that held
34
+ `details` without `configuration.manage` lose access to detailed run data;
35
+ grant them the manage rule (or a team grant on the configuration/system)
36
+ instead. Stale role rows referencing the removed rule are inert.
37
+ - `getDetailedAggregatedHistory` is `authenticated` (was `public`); anonymous
38
+ callers could never pass its access rule anyway.
39
+
40
+ ### Patch Changes
41
+
42
+ - Updated dependencies [0cac684]
43
+ - Updated dependencies [0cac684]
44
+ - Updated dependencies [0cac684]
45
+ - @checkstack/ai-backend@0.10.3
46
+ - @checkstack/gitops-common@0.7.0
47
+ - @checkstack/healthcheck-common@1.11.0
48
+ - @checkstack/automation-backend@0.10.5
49
+ - @checkstack/catalog-backend@1.6.3
50
+ - @checkstack/incident-backend@1.9.1
51
+ - @checkstack/sdk@0.119.1
52
+ - @checkstack/gitops-backend@0.5.16
53
+ - @checkstack/satellite-backend@0.7.6
54
+ - @checkstack/backend-api@0.27.1
55
+ - @checkstack/script-packages-backend@0.3.20
56
+ - @checkstack/command-backend@0.2.16
57
+ - @checkstack/secrets-backend@0.2.16
58
+ - @checkstack/status-page-backend@0.4.3
59
+
60
+ ## 1.12.0
61
+
62
+ ### Minor Changes
63
+
64
+ - 52c55bf: Anomaly baselines are now per-environment, so the env-scoped
65
+ `HealthCheckDrawer` shows the clicked env's baseline (not a cross-env
66
+ one). Closes the follow-up noted in `healthcheck-per-env-rollup`.
67
+
68
+ ## What changed
69
+
70
+ - **`anomaly_baselines`** now carries a nullable `environment_id`
71
+ column, and its unique constraint grew to
72
+ `(systemId, configurationId, environmentId, fieldPath)` with
73
+ `NULLS NOT DISTINCT` — so there is exactly one baseline per
74
+ `(system, config, env, path)` tuple, and the env-less slice (`NULL`)
75
+ stays a single row (the pre-feature cross-env baseline, preserved as
76
+ the env-less row until the next analyzer tick rewrites per-env rows).
77
+ Existing rows backfill to `environment_id = NULL` with no data work.
78
+ - **Baseline analyzer** (`jobs/baseline-analyzer.ts`) now fans out per
79
+ environment within each assignment: runs are grouped by
80
+ `environmentId` (null = env-less), stats are computed per env, and
81
+ the upsert targets the 4-tuple. The cache key gained an env segment
82
+ (`baseline:${config}:${system}:${env ?? "<none>"}:${path}`) and the
83
+ `ANOMALY_BASELINE_UPDATED` signal payload now carries `environmentId`.
84
+ Previously the analyzer computed one cross-env batch per assignment.
85
+ - **Inline detector** (`detector.ts`) resolves the per-env baseline:
86
+ the lookup matches `environmentId` when present or `IS NULL` for the
87
+ env-less slice, and the cache key matches the analyzer's env segment.
88
+ `environmentId` is threaded from the `checkCompleted` hook (see
89
+ below); it defaults to `null` (env-less) so a caller that omits it
90
+ resolves the env-less baseline rather than failing.
91
+ - **`getAnomalyBaselines` RPC** now accepts an optional
92
+ `environmentId: string | null` filter and surfaces `environmentId` on
93
+ every `AnomalyBaselineDto`. Tristate semantics, mirroring
94
+ `getHistory`: `undefined` → all envs (no predicate), `null` → env-less
95
+ slice (`IS NULL`), a string → that env. The service predicate is at
96
+ the DB layer.
97
+ - **`HealthCheckDrawer`** threads `item.environmentId` (already on its
98
+ props) into the baselines query, so the drawer's anomaly overlay
99
+ resolves server-side to the clicked env's baseline only — matching the
100
+ env-scoping already applied to its history table and charts. The
101
+ latency chart tolerates the new field (it picks the single
102
+ `"latencyMs"` baseline, which the env filter guarantees is unique).
103
+ - **`getRunsForAnalysis`** (healthcheck) now returns `environmentId`
104
+ on each run so the analyzer can group by env. Additive optional
105
+ field; only the analyzer consumes it.
106
+ - **`checkCompleted` / `checkFailed` hooks** (healthcheck) now carry
107
+ `environmentId: string | null` on their payloads, sourced from the
108
+ per-env execution loop. Only the anomaly detector subscribes to
109
+ `checkCompleted` (it was updated); the failure-path emit (rollup
110
+ error) passes `null`.
111
+
112
+ ## Notes
113
+
114
+ - Anomaly _rows_ (`anomalies` table) remain cross-env by design in this
115
+ step — only baselines are env-scoped, matching the scoped task. A
116
+ detector run for env A and env B's normal value still share one
117
+ `(system, config, path)` anomaly row; env-scoping the anomalies table
118
+ is tracked as a separate follow-up so this change stays focused on
119
+ the drawer's baseline overlay.
120
+ - The `checkCompleted` / `checkFailed` payload change is technically
121
+ breaking for hook subscribers that destructure the payload, but the
122
+ only in-tree subscriber (the anomaly plugin) was updated in lockstep.
123
+ External webhook subscribers receive an additional field and are not
124
+ affected unless they reject unknown keys (uncommon).
125
+ - Migration `0006_sad_retro_girl.sql` drops + recreates the unique
126
+ constraint with `NULLS NOT DISTINCT` and adds the column. It applies
127
+ cleanly to fresh and already-populated DBs (existing NULL-env rows
128
+ remain unique under the new key).
129
+
130
+ - d9f4654: Fix team-scoped health-check management being invisible. Health-check
131
+ configuration team grants are keyed on `healthcheck.healthcheck` (the RPC
132
+ middleware derives the grant key from the configuration access rule's
133
+ `resource`, and that rule is `accessPair("healthcheck", ...)`), but the frontend
134
+ capability gate, the route `manageCapability`, and the Teams grant-name resolver
135
+ all declared `healthcheck.configuration`. Because the two never matched, a user
136
+ who could manage a health check via a team grant (without the global manage
137
+ rule) saw none of the health-check management surfaces, and health-check grant
138
+ names did not resolve in the Teams admin UI.
139
+
140
+ `healthCheckResourceTypes.configuration` now resolves to `healthcheck.healthcheck`
141
+ (with a regression test pinning it to the middleware's grant key), the resolver
142
+ registers under the same type, and the create/edit/assignments routes gain the
143
+ `manageCapability` they were missing so team-scoped health-check managers (and,
144
+ for create/assign, system managers) can reach them. This is a non-breaking fix:
145
+ no stored access-rule id or grant key changes.
146
+
147
+ - 21e0d88: Paused health-check configurations no longer contribute to their systems'
148
+ health aggregate, pausing one now closes any open SLO downtime event it was
149
+ keeping open, and the system overview's "Health Checks" list renders a
150
+ "Paused" pill for paused checks instead of their stale run-evaluated status.
151
+
152
+ Previously, pausing a configuration only skipped execution — its stale
153
+ failing runs inside the evaluation window kept the system's rollup status
154
+ `degraded`/`unhealthy`, which in turn kept any open SLO downtime event open
155
+ until those runs aged out, and the system overview list still showed the
156
+ paused check as "Unhealthy". Now:
157
+
158
+ - `getSystemHealthStatus` excludes paused configurations from the worst-
159
+ wins aggregate, so a system whose only failing check is paused reads
160
+ healthy (and paused checks no longer drive the system's red badge).
161
+ - The `pauseConfiguration` RPC recomputes the rollup `health` entity for
162
+ every system the config is enabled-assigned to. If the recomputed
163
+ aggregate transitions degraded → healthy, the existing `HEALTH_ENTITY_KIND`
164
+ "recovered" edge fires and the SLO engine closes the open downtime event
165
+ at the pause time. If the system stays degraded (other failing checks),
166
+ the event correctly stays open.
167
+ - `resumeConfiguration` intentionally does NOT recompute. The next actual
168
+ run drives any degraded transition: if the check still fails, a fresh
169
+ downtime event opens (the previous one was closed on pause, so the
170
+ `handleSystemDown` idempotent guard doesn't suppress it); if it now
171
+ passes, no event opens. This avoids fabricating a downtime from stale
172
+ last-known state when the underlying condition may have been fixed
173
+ during the pause.
174
+ - `getSystemHealthOverview` now returns a `paused` boolean per check. The
175
+ system overview's "Health Checks" list renders a "Paused" pill (unknown
176
+ tone) for paused checks instead of the run-evaluated status, while still
177
+ showing the pre-pause sparkline for context. Paused checks only appear
178
+ under the "All" filter tab, not "Failing" or "Healthy".
179
+
180
+ - 52c55bf: Per-environment health semantics: rollup no longer masks sibling outages,
181
+ and notifications + automation windows are env-scoped.
182
+
183
+ ## The bug
184
+
185
+ When a `(system, configuration)` assignment fanned out to multiple
186
+ environments and only some of them failed, the system rollup could
187
+ read **healthy** (masking a permanently-failing env), or **flap**
188
+ healthy↔degraded/unhealthy tick-by-tick whenever env insertion order
189
+ drifted, because the rollup derivation flattened every env's runs into
190
+ one `timestamp DESC` list and handed the interleaved list to the
191
+ threshold evaluator. The default `consecutive` mode walks newest-first
192
+ and breaks the streak on the first interleaving env, so the rollup
193
+ collapsed to whichever single env's status the most recent run landed
194
+ on. Each flap fired an escalation/recovery notification + a
195
+ `system_health_changed` trigger event.
196
+
197
+ ## What changed
198
+
199
+ - **`getSystemHealthStatus(systemId)` rollup** now groups the latest
200
+ run window by `environmentId`, evaluates the threshold window PER
201
+ ENVIRONMENT, and takes worst-wins across envs within each association
202
+ (unhealthy > degraded > healthy) before worst-wins across associations.
203
+ This is stable regardless of env insertion order or multi-pod racing.
204
+ For a single-env (or env-less-only) assignment this reduces to the
205
+ pre-existing flat-window behavior. Per-env and env-less slices
206
+ (`environmentId: string` / `null`) are unchanged.
207
+ - **`getSystemHealthOverview`** now groups runs per `(configurationId,
208
+ environmentId)`, evaluates each env's slice on its own monotonic run
209
+ window, and worst-wins across envs — mirroring `getSystemHealthStatus`.
210
+ The response carries `environmentId` on every `recentRuns[]` entry,
211
+ and adds `perEnvironment[]` per check (one entry per env with its own
212
+ `status` and env-scoped `recentRuns`) so a frontend can render one
213
+ row per `(check, environment)` pair, surfacing per-env outages the
214
+ rollup intentionally hides in the aggregate view. The top-level
215
+ `recentRuns[]` and `status` keep their pre-existing shape for
216
+ backwards compatibility (single-env checks are unchanged).
217
+ - **`HealthCheckSystemOverview`** (frontend) now flattens multi-env
218
+ assignments into one row per `(check, environment)` — each row carries
219
+ the check name, an env pill (resolved via the same
220
+ `getSystemEnvironments` query the drawer already uses), the per-env
221
+ status, sparkline, and last-run. With the "Failing"/"Healthy" filter
222
+ now scoped per env, a permanently-failing environment surfaces as its
223
+ own failing row beside its healthy sibling, instead of being masked by
224
+ the rollup's worst-wins / latest-wins. Single-env and env-less
225
+ assignments render the historical single row (no env pill). Clicking
226
+ any env row opens the check-level drawer, scoped to that env via the
227
+ server-side env filter on the queries below — the drawer's run
228
+ history table, charts, and tiles all see only the (check, environment)
229
+ pair the operator clicked, never a mixed-env pool.
230
+ - **`getHistory`, `getDetailedHistory`, `getRunStats`,
231
+ `getAggregatedHistory`, and `getDetailedAggregatedHistory`** now accept
232
+ an optional `environmentId: string | null` input that filters
233
+ server-side at the DB layer (`environment_id = $X` for an env, `IS
234
+ NULL` for the env-less slice, no predicate when omitted). The drawer's
235
+ charts and Recent Runs table pass the clicked row's `environmentId`
236
+ so the pagination, totals, and buckets reflect only that env — a
237
+ client-side filter would double-paginate and miscount totals; the
238
+ filter is at the DB so the data is honest end-to-end. The aggregated
239
+ history applies the env filter to all three tiers the cross-tier
240
+ aggregation engine reads (raw `health_check_runs` + hourly and daily
241
+ `health_check_aggregates`), since both tables are env-keyed. Single-env
242
+ and env-less rows omit the filter, so historical callers are
243
+ unchanged.
244
+ - **Anomaly baselines are NOT yet env-scoped** — `anomaly_baselines` is
245
+ keyed on `(systemId, configurationId, fieldPath)` with no
246
+ `environmentId` column, and the detector computes a single baseline
247
+ across all envs of an assignment. Scoping the drawer's anomaly overlay
248
+ per env needs a schema migration + a per-env detector rewrite, and is
249
+ tracked as a follow-up. The drawer continues to show the cross-env
250
+ baseline next to the (now env-scoped) history + charts.
251
+ - **`system_health_changed` / `system_degraded` / `system_healthy`
252
+ triggers** now partition by `(systemId, environmentId)` instead of
253
+ the bare `systemId` when the trigger fires from a per-env change.
254
+ Two failing environments of one system now fire two distinct events
255
+ with independent flapping/dwell/dedup windows — operators can author
256
+ per-env automations and get per-env notifications. A bare rollup
257
+ transition (`environmentId` absent) partitions on `systemId` alone,
258
+ so existing recipes that read only `payload.systemId` keep working.
259
+ - **`notifyStateChange`** now accepts `environmentId` +
260
+ `environmentName`. Per-env notifications get an env-qualified title
261
+ (`"System health critical (prod): ..."`) and body, and an
262
+ env-qualified collapse key (`systemHealthCollapseKey(systemId, envId)`)
263
+ so two failing envs render as two independent cards instead of
264
+ merging into one. Suppression checks (maintenance/incident) remain
265
+ system-scoped.
266
+
267
+ ## Notes
268
+
269
+ - Each failing env now fires its own `system_health_changed` event with
270
+ its own partition — this is the documented migration away from the
271
+ bug-report flapping cadence into a per-env flap cadence. Operators
272
+ with existing `window:` / `dwell:` recipes on `system_health_changed`
273
+ may see different refire cadence per env (one flapping env no longer
274
+ drowns out its steady sibling). To opt back into the pooled
275
+ historical behavior, an automation recipe can override its own
276
+ `partitionBy: (p) => p.systemId`.
277
+ - `SYSTEM_STATUS_CHANGED` remains rollup-only (one broadcast per tick
278
+ on the rollup status transition): it drives low-noise cache
279
+ invalidation for `SystemHealthBadge` and `DependencyBadge`, and the
280
+ per-env trigger events above already cover per-env automation needs.
281
+
282
+ - d2d49cf: Show the environment for fanned-out runs in the dashboard Recent Activity feed.
283
+ The `healthcheck.run.completed` signal now carries optional `environmentId` and
284
+ `environmentName` fields, populated at the two per-environment fan-out broadcast
285
+ sites in the run executor. The Dashboard "Recent activity" terminal feed renders
286
+ the environment name inline (`system (config) @ env -> status`) when a run was
287
+ fanned out to an environment. Runs that are not environment-scoped omit both
288
+ fields and render exactly as before, so their behavior is unchanged.
289
+
290
+ ### Patch Changes
291
+
292
+ - Updated dependencies [52c55bf]
293
+ - Updated dependencies [d1b71b6]
294
+ - Updated dependencies [7c18b25]
295
+ - Updated dependencies [d9f4654]
296
+ - Updated dependencies [21e0d88]
297
+ - Updated dependencies [52c55bf]
298
+ - Updated dependencies [e430fbe]
299
+ - Updated dependencies [eab80e3]
300
+ - Updated dependencies [53666a7]
301
+ - Updated dependencies [d2d49cf]
302
+ - Updated dependencies [0d912a3]
303
+ - @checkstack/healthcheck-common@1.10.0
304
+ - @checkstack/notification-common@1.5.0
305
+ - @checkstack/ai-backend@0.10.2
306
+ - @checkstack/common@0.19.0
307
+ - @checkstack/backend-api@0.27.0
308
+ - @checkstack/incident-common@1.7.0
309
+ - @checkstack/incident-backend@1.9.0
310
+ - @checkstack/maintenance-common@1.8.0
311
+ - @checkstack/catalog-common@2.6.0
312
+ - @checkstack/status-page-common@0.5.0
313
+ - @checkstack/catalog-backend@1.6.2
314
+ - @checkstack/sdk@0.118.1
315
+ - @checkstack/satellite-backend@0.7.5
316
+ - @checkstack/automation-backend@0.10.4
317
+ - @checkstack/script-packages-backend@0.3.19
318
+ - @checkstack/ai-common@0.6.3
319
+ - @checkstack/cache-api@0.3.16
320
+ - @checkstack/cache-utils@0.2.21
321
+ - @checkstack/command-backend@0.2.15
322
+ - @checkstack/gitops-backend@0.5.15
323
+ - @checkstack/gitops-common@0.6.8
324
+ - @checkstack/queue-api@0.3.16
325
+ - @checkstack/secrets-backend@0.2.15
326
+ - @checkstack/secrets-common@0.2.8
327
+ - @checkstack/signal-common@0.2.14
328
+ - @checkstack/status-page-backend@0.4.2
329
+
3
330
  ## 1.11.1
4
331
 
5
332
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.26.1",
18
- "@checkstack/ai-backend": "0.10.1",
19
- "@checkstack/ai-common": "0.6.2",
20
- "@checkstack/script-packages-backend": "0.3.18",
21
- "@checkstack/cache-api": "0.3.15",
22
- "@checkstack/cache-utils": "0.2.20",
23
- "@checkstack/catalog-backend": "1.6.1",
24
- "@checkstack/catalog-common": "2.5.0",
25
- "@checkstack/command-backend": "0.2.14",
26
- "@checkstack/common": "0.18.0",
27
- "@checkstack/gitops-backend": "0.5.14",
28
- "@checkstack/gitops-common": "0.6.7",
29
- "@checkstack/healthcheck-common": "1.9.0",
30
- "@checkstack/secrets-common": "0.2.7",
31
- "@checkstack/secrets-backend": "0.2.14",
32
- "@checkstack/incident-backend": "1.8.7",
33
- "@checkstack/incident-common": "1.6.4",
34
- "@checkstack/automation-backend": "0.10.3",
35
- "@checkstack/maintenance-common": "1.7.4",
36
- "@checkstack/notification-common": "1.4.2",
37
- "@checkstack/queue-api": "0.3.15",
38
- "@checkstack/satellite-backend": "0.7.4",
39
- "@checkstack/sdk": "0.117.0",
40
- "@checkstack/signal-common": "0.2.13",
41
- "@checkstack/status-page-backend": "0.4.1",
42
- "@checkstack/status-page-common": "0.4.1",
17
+ "@checkstack/backend-api": "0.27.1",
18
+ "@checkstack/ai-backend": "0.10.3",
19
+ "@checkstack/ai-common": "0.6.3",
20
+ "@checkstack/script-packages-backend": "0.3.20",
21
+ "@checkstack/cache-api": "0.3.16",
22
+ "@checkstack/cache-utils": "0.2.21",
23
+ "@checkstack/catalog-backend": "1.6.3",
24
+ "@checkstack/catalog-common": "2.6.0",
25
+ "@checkstack/command-backend": "0.2.16",
26
+ "@checkstack/common": "0.19.0",
27
+ "@checkstack/gitops-backend": "0.5.16",
28
+ "@checkstack/gitops-common": "0.7.0",
29
+ "@checkstack/healthcheck-common": "1.11.0",
30
+ "@checkstack/secrets-common": "0.2.8",
31
+ "@checkstack/secrets-backend": "0.2.16",
32
+ "@checkstack/incident-backend": "1.9.1",
33
+ "@checkstack/incident-common": "1.7.0",
34
+ "@checkstack/automation-backend": "0.10.5",
35
+ "@checkstack/maintenance-common": "1.8.0",
36
+ "@checkstack/notification-common": "1.5.0",
37
+ "@checkstack/queue-api": "0.3.16",
38
+ "@checkstack/satellite-backend": "0.7.6",
39
+ "@checkstack/sdk": "0.119.1",
40
+ "@checkstack/signal-common": "0.2.14",
41
+ "@checkstack/status-page-backend": "0.4.3",
42
+ "@checkstack/status-page-common": "0.5.0",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "drizzle-orm": "^0.45.0",
45
45
  "hono": "^4.12.25",
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "devDependencies": {
53
53
  "@checkstack/drizzle-helper": "0.0.6",
54
- "@checkstack/scripts": "0.6.5",
55
- "@checkstack/test-utils-backend": "0.1.48",
54
+ "@checkstack/scripts": "0.7.0",
55
+ "@checkstack/test-utils-backend": "0.1.50",
56
56
  "@checkstack/tsconfig": "0.0.7",
57
57
  "@types/bun": "^1.0.0",
58
58
  "@types/tdigest": "^0.1.5",
@@ -67,7 +67,7 @@ describe("healthcheck triggers", () => {
67
67
  });
68
68
 
69
69
 
70
- it("extracts systemId as the contextKey on all three", () => {
70
+ it("extracts systemId as the contextKey on all three (bare rollup, no environmentId)", () => {
71
71
  const degradedOrChanged = {
72
72
  systemId: "sys-1",
73
73
  previousStatus: "healthy",
@@ -89,6 +89,48 @@ describe("healthcheck triggers", () => {
89
89
  "sys-1",
90
90
  );
91
91
  });
92
+
93
+ it("partitions the contextKey per-(system, environment) when environmentId is present", () => {
94
+ // Per-env partition: two failing envs of one system share a SYSTEM
95
+ // but NOT a flapping/dwell/dedup window — automations get N distinct
96
+ // `system_health_changed` events with independent partitions. The bare
97
+ // rollup change (no environmentId) keys on the bare systemId alone so
98
+ // existing recipes keep working.
99
+ const prodSpread = {
100
+ systemId: "sys-1",
101
+ environmentId: "prod",
102
+ previousStatus: "healthy",
103
+ newStatus: "unhealthy",
104
+ healthyChecks: 1,
105
+ totalChecks: 2,
106
+ timestamp: "2026-05-29T11:00:00Z",
107
+ } as const;
108
+ const stagingSpread = {
109
+ ...prodSpread,
110
+ environmentId: "staging",
111
+ } as const;
112
+ const rollup = {
113
+ systemId: "sys-1",
114
+ previousStatus: "degraded",
115
+ newStatus: "unhealthy",
116
+ healthyChecks: 1,
117
+ totalChecks: 2,
118
+ timestamp: "2026-05-29T11:00:00Z",
119
+ } as const;
120
+
121
+ expect(systemHealthChangedTrigger.contextKey?.(prodSpread)).toBe(
122
+ "sys-1::prod",
123
+ );
124
+ expect(systemHealthChangedTrigger.contextKey?.(stagingSpread)).toBe(
125
+ "sys-1::staging",
126
+ );
127
+ // Rollup stays system-scoped.
128
+ expect(systemHealthChangedTrigger.contextKey?.(rollup)).toBe("sys-1");
129
+ // The directional triggers use the SAME partition function (they share
130
+ // the schema's { systemId, environmentId? } fields by structural type).
131
+ expect(systemDegradedTrigger.contextKey?.(prodSpread)).toBe("sys-1::prod");
132
+ expect(systemHealthyTrigger.contextKey?.(prodSpread)).toBe("sys-1::prod");
133
+ });
92
134
  });
93
135
 
94
136
  describe("assignmentArtifactType", () => {
@@ -90,6 +90,24 @@ const checkFailedPayloadSchema = z.object({
90
90
 
91
91
  // ─── Triggers ──────────────────────────────────────────────────────────
92
92
 
93
+ // Per-(system, environment) partition key for all entity-driven system-health
94
+ // triggers. Carrying `environmentId` here — NOT just on the payload — makes
95
+ // the automation engine's dwell / flapping / dedup windows partition per-env:
96
+ // one environment flapping doesn't get drowned out by the steady sibling,
97
+ // and N failing envs fire N distinct `system_health_changed` events with
98
+ // independent partitions (see the changeset "Make healthcheck triggers
99
+ // env-scoped"). `payload.systemId` stays the bare systemId, so existing
100
+ // recipes that read only `systemId` keep working. A bare rollup change
101
+ // (per `environmentId = null`) partitions on the bare systemId alone —
102
+ // the historical system-scoped window. The three trigger payloads all
103
+ // expose `{ systemId, environmentId? }`, so the partition function is shared
104
+ // via an inline closure that's structural-compatible with each.
105
+ function systemHealthPartitionKey<
106
+ P extends { systemId: string; environmentId?: string },
107
+ >(p: P): string {
108
+ return p.environmentId ? `${p.systemId}::${p.environmentId}` : p.systemId;
109
+ }
110
+
93
111
  export const systemDegradedTrigger: TriggerDefinition<
94
112
  z.infer<typeof systemDegradedPayloadSchema>
95
113
  > = {
@@ -105,7 +123,8 @@ export const systemDegradedTrigger: TriggerDefinition<
105
123
  setup: makeEntityDrivenTriggerSetup<
106
124
  z.infer<typeof systemDegradedPayloadSchema>
107
125
  >(),
108
- contextKey: (p) => p.systemId,
126
+ // Per-(system, env); bare systemId for the rollup change (env-less).
127
+ contextKey: (p) => systemHealthPartitionKey(p),
109
128
  contextKeyLabel: "system",
110
129
  };
111
130
 
@@ -122,7 +141,7 @@ export const systemHealthyTrigger: TriggerDefinition<
122
141
  setup: makeEntityDrivenTriggerSetup<
123
142
  z.infer<typeof systemHealthyPayloadSchema>
124
143
  >(),
125
- contextKey: (p) => p.systemId,
144
+ contextKey: (p) => systemHealthPartitionKey(p),
126
145
  contextKeyLabel: "system",
127
146
  };
128
147
 
@@ -140,7 +159,7 @@ export const systemHealthChangedTrigger: TriggerDefinition<
140
159
  setup: makeEntityDrivenTriggerSetup<
141
160
  z.infer<typeof systemHealthChangedPayloadSchema>
142
161
  >(),
143
- contextKey: (p) => p.systemId,
162
+ contextKey: (p) => systemHealthPartitionKey(p),
144
163
  contextKeyLabel: "system",
145
164
  };
146
165