@checkstack/healthcheck-backend 1.18.0 → 1.20.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +484 -0
  2. package/drizzle/0019_chemical_frightful_four.sql +8 -0
  3. package/drizzle/0020_certain_mordo.sql +2 -0
  4. package/drizzle/meta/0019_snapshot.json +661 -0
  5. package/drizzle/meta/0020_snapshot.json +711 -0
  6. package/drizzle/meta/_journal.json +14 -0
  7. package/package.json +23 -21
  8. package/src/ai/system-signals-contributor.test.ts +33 -9
  9. package/src/ai/system-signals-contributor.ts +38 -16
  10. package/src/cache-test-stub.ts +26 -0
  11. package/src/cache.test.ts +291 -0
  12. package/src/cache.ts +204 -34
  13. package/src/health-notification-content.test.ts +111 -0
  14. package/src/health-notification-content.ts +145 -0
  15. package/src/healthcheck-gitops-kinds.test.ts +14 -0
  16. package/src/healthcheck-gitops-kinds.ts +27 -0
  17. package/src/index.ts +31 -12
  18. package/src/queue-executor.test.ts +13 -26
  19. package/src/queue-executor.ts +125 -112
  20. package/src/retention-job.ts +8 -0
  21. package/src/rollup-consumer.test.ts +19 -8
  22. package/src/router-config-secrets.test.ts +2 -7
  23. package/src/router-create-and-assign.test.ts +2 -7
  24. package/src/router-pause-recompute.test.ts +2 -7
  25. package/src/router.test.ts +3 -8
  26. package/src/router.ts +43 -15
  27. package/src/schema.ts +74 -31
  28. package/src/service-batching.test.ts +8 -0
  29. package/src/service-bulk-counts.it.test.ts +144 -0
  30. package/src/service-bulk-run-stats.it.test.ts +197 -0
  31. package/src/service-ordering.test.ts +6 -2
  32. package/src/service-paused-filter.test.ts +13 -0
  33. package/src/service-rollup-worst-wins.test.ts +209 -145
  34. package/src/service.ts +408 -284
  35. package/src/status-fingerprint.test.ts +92 -0
  36. package/src/status-fingerprint.ts +66 -0
  37. package/src/status-page/rollup.test.ts +40 -0
  38. package/src/status-page/rollup.ts +27 -0
  39. package/src/status-page/widgets.test.ts +387 -0
  40. package/src/status-page/widgets.ts +236 -39
package/CHANGELOG.md CHANGED
@@ -1,5 +1,489 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bd41130: perf(healthcheck): stop recomputing the full system rollup on every check run
8
+
9
+ The queue run executor captured the system-wide rollup health
10
+ (`getSystemHealthStatus(systemId)`) at the start of EVERY check tick - a
11
+ worst-wins aggregate that fans out an N+1 of windowed `health_check_runs` reads
12
+ across every check × environment of the system. That value was only ever
13
+ consumed on the rare catastrophic-failure path (a job that throws before running
14
+ any probe); the normal success/failure paths record their transition from the
15
+ per-environment pre-read and never touched it. Under load this was one of the
16
+ heaviest repeated reads on the hot path.
17
+
18
+ The rollup pre-status is now computed lazily, only inside the catastrophic-
19
+ failure branch that actually uses it. Behavior is unchanged - the catastrophic
20
+ path reads the same pre-tick rollup (it is reached only when the run threw before
21
+ inserting anything, so nothing changed in between) - but every normal check tick
22
+ no longer pays for a full rollup recompute it discards.
23
+
24
+ - bd41130: perf(healthcheck): add system-leading aggregate and config-reverse indexes
25
+
26
+ Add two Postgres indexes (migration 0020) to serve reads that the existing
27
+ keys cannot cover:
28
+
29
+ - `health_check_aggregates_system_bucket_idx` on
30
+ `(system_id, bucket_size, bucket_start)`. The health-state read omits
31
+ `configuration_id`, so the leading-`configuration_id` unique index could
32
+ not be used and the query scanned the aggregates table. This index leads
33
+ with `system_id` so those reads use an index instead.
34
+ - `system_health_checks_config_enabled_idx` on `(configuration_id, enabled)`.
35
+ The reverse lookup in `getSystemIdsForConfiguration` (config-change
36
+ recompute) filters by `configuration_id`, but the primary key leads with
37
+ `system_id` and could not serve it. This index makes the config-scoped
38
+ lookup an index scan.
39
+
40
+ - bd41130: perf(healthcheck): add the missing composite indexes on health_check_runs
41
+
42
+ The status read path reads the last N runs for a (system, check[, environment])
43
+ slice ordered by `timestamp DESC` on every status read AND on every check
44
+ execution, but `health_check_runs` had NO secondary indexes - only its primary
45
+ key. Every such read was a full sequential scan of the (multi-million-row) table
46
+ plus an in-memory sort, so point reads averaged 50-320 ms and dominated total DB
47
+ time. Two composite indexes now back these access patterns:
48
+
49
+ - `health_check_runs_check_recent_idx` (system_id, configuration_id, timestamp) -
50
+ the cross-environment newest-run reads and the retention `DELETE`.
51
+ - `health_check_runs_slice_recent_idx` (system_id, configuration_id,
52
+ environment_id, timestamp) - the env-scoped slice reads, the per-check
53
+ DISTINCT-environment discovery, and the per-env last-healthy `max(timestamp)`
54
+ group-by.
55
+
56
+ Both turn full-table seq-scans into index range scans (Postgres scans the btree
57
+ backward for the `DESC` order).
58
+
59
+ > [!IMPORTANT]
60
+ > Deploy note: the migration builds the indexes with a plain (non-CONCURRENT)
61
+ > `CREATE INDEX`, which briefly locks writes to `health_check_runs` while each
62
+ > index builds (the migrator runs every migration in one transaction, so
63
+ > `CREATE INDEX CONCURRENTLY` is not possible through it). On a very large table
64
+ > you can build them `CONCURRENTLY` by hand (same names) before deploying; the
65
+ > migration uses `IF NOT EXISTS`, so it then no-ops.
66
+
67
+ - bd41130: perf(healthcheck): cache system health status on the shared distributed cache with per-check-vector invalidation
68
+
69
+ The per-system derived health status (`getSystemHealthStatus`) is an N+1 over
70
+ `health_check_runs` across every check × environment, and it backs the highest
71
+ call-count read paths: the dashboard badges, the bulk status endpoint, the
72
+ per-(system, check, environment) matrix the dependency map and status-page
73
+ widgets consume, and the AI system-signals scan. It was only cached for the
74
+ single/bulk rollup, was invalidated UNCONDITIONALLY on every check run (so a
75
+ steady-state healthy system evicted its own cache every tick), the matrix
76
+ endpoint was not cached at all, and the AI signals scan bypassed the cache with
77
+ its own uncached N+1.
78
+
79
+ All four reads now go through a single `HealthCheckCache` facade - built on the
80
+ **platform `CacheManager`** - that is the ONE sanctioned reader AND invalidator
81
+ of a system's status:
82
+
83
+ - **Reads** (`read` / `readBulk` / `readMatrix`) are served read-through, keyed
84
+ per `(system, environment)`, holding the RAW (pre-incident-override) status;
85
+ the router folds incident overrides downstream, so an incident change never
86
+ touches this cache. The matrix reuses the same per-environment entries the
87
+ badge path warms. The AI signals contributor now scans candidate systems from
88
+ the durable table and resolves their statuses through `readBulk`, reusing the
89
+ warm cache instead of a fresh N+1.
90
+ - **Invalidation is change-gated on the per-check status VECTOR**, not the run:
91
+ `reconcile(previous, next)` evicts only when a check actually flipped status
92
+ (or its slice-failure composition changed) - a `statusFingerprint` invariant
93
+ to the volatile `evaluatedAt` / `lastRunAt` / `runsConsidered`. A run that
94
+ leaves the vector unchanged keeps the cache warm. This also catches a per-check
95
+ flip that leaves the rollup enum unchanged (which the reactive `health` entity
96
+ view would miss). A per-environment run that changes its slice evicts BOTH its
97
+ env key AND the system rollup key (the slice feeds the worst-wins rollup), so a
98
+ simultaneous slice swap - one env recovering as another fails, which the
99
+ rollup's own fingerprint is blind to - still refreshes the rollup. Sibling
100
+ environment keys stay warm.
101
+
102
+ Cross-pod coherence comes from the SHARED cache backend, not from an application
103
+ broadcast: with a distributed provider (Redis) an eviction is a `delete` every
104
+ pod sees immediately. On the default in-memory backend the cache is per-pod and
105
+ therefore single-instance-only (the Infrastructure Cache UI now warns about
106
+ this). The cached value is a derivation of the shared `health_check_runs` tables,
107
+ so a miss recomputes the same answer on every pod; the 15s TTL is only a
108
+ natural-refresh safety net.
109
+
110
+ Enforced by design, not convention:
111
+
112
+ - Every status-mutating writer invalidates through the facade: the run executor,
113
+ the router config/assignment/satellite handlers, the system/satellite lifecycle
114
+ hooks, AND the GitOps apply path (create/update/delete/associate/disassociate),
115
+ which writes configs directly on the service rather than through the router and
116
+ would otherwise have stranded a stale status until the TTL.
117
+ - A `checkstack/no-direct-system-status-read` lint rule (error) forbids raw
118
+ `service.getSystemHealthStatus(...)` reads anywhere except the cache facade and
119
+ the executor / entity-compute paths that must read live to detect a transition.
120
+ - A `checkstack/no-direct-health-run-insert` lint rule (error) forbids raw
121
+ `insert(healthCheckRuns)` outside the executor / service run writers.
122
+
123
+ The executor's per-run change-gate reads its pre-run baseline INSIDE the
124
+ per-(system, environment) advisory-lock critical section (not before the probe),
125
+ so a concurrent same-slice run cannot commit between the baseline read and the
126
+ insert and cause the gate to miss a real transition.
127
+
128
+ Behavior is unchanged for readers (same values, strictly fresher than the prior
129
+ 15s-stale-on-quiet-systems behavior). The `getSystemHealthStatus` /
130
+ `getBulkSystemHealthStatus` / `getBulkSystemHealthMatrix` RPC contracts are
131
+ untouched, so cross-plugin callers (dependency, SLO, status-page) need no change.
132
+
133
+ - bd41130: fix(status-page): scope email subscriptions to published environments and author-selected systems
134
+
135
+ Two correctness fixes to status-page email subscriptions:
136
+
137
+ - **Health notifications now respect the page's published environments.** A
138
+ per-environment health transition carries the environment it happened in
139
+ (`originEnvironmentId`, threaded through `notifyForSubscription` ->
140
+ `NotificationAudienceEvent` -> the status-page fan-out). A page that publishes
141
+ a specific environment set is now skipped for a change in an environment it
142
+ does not publish - so a `development` failure never emails a prod-only page's
143
+ subscribers, even for a system that is also shown in prod. Pages publishing all
144
+ environments, and env-less sources (incident, maintenance, whole-system health
145
+ rollup), are unaffected.
146
+ - **Notifications are scoped per category to the widgets the author placed.** The
147
+ send-time fan-out now surfaces a notification only through widgets of its own
148
+ category: a health status change reaches a page only through a HEALTH widget
149
+ (`banner` / `systemHealth` / `groupStatus` / `uptime`, which now implement
150
+ `resolveScopedSystems` and declare `subscriptionCategory: "health"`), an
151
+ incident only through an incident widget, and so on. A page that lists a
152
+ system's incidents but never its health no longer emails health subscribers
153
+ about it, and a health-only page now correctly surfaces its systems for
154
+ subscription. Health widgets also participate in the public subscribe picker.
155
+
156
+ BREAKING CHANGE: on a page publishing a specific environment set, health
157
+ subscribers now only receive changes that occurred in a published environment
158
+ (previously any environment of a surfaced system triggered a notification), and a
159
+ notification is surfaced only by a widget of its own category (previously any
160
+ scoping widget on the page could surface any category). Legacy subscribers (NULL
161
+ categories) and all-environment pages are unchanged; no data migration is needed.
162
+
163
+ ### Patch Changes
164
+
165
+ - Updated dependencies [bd41130]
166
+ - Updated dependencies [bd41130]
167
+ - Updated dependencies [bd41130]
168
+ - Updated dependencies [bd41130]
169
+ - Updated dependencies [bd41130]
170
+ - Updated dependencies [bd41130]
171
+ - Updated dependencies [bd41130]
172
+ - Updated dependencies [bd41130]
173
+ - @checkstack/backend-api@0.32.0
174
+ - @checkstack/cache-utils@0.3.0
175
+ - @checkstack/catalog-backend@1.8.0
176
+ - @checkstack/ai-backend@0.10.11
177
+ - @checkstack/incident-backend@1.13.0
178
+ - @checkstack/notification-common@1.7.0
179
+ - @checkstack/status-page-backend@0.6.0
180
+ - @checkstack/automation-backend@0.11.2
181
+ - @checkstack/command-backend@0.2.23
182
+ - @checkstack/gitops-backend@0.5.23
183
+ - @checkstack/satellite-backend@0.8.5
184
+ - @checkstack/script-packages-backend@0.4.2
185
+ - @checkstack/secrets-backend@0.3.5
186
+ - @checkstack/catalog-common@2.7.1
187
+ - @checkstack/sdk@0.128.1
188
+ - @checkstack/healthcheck-common@1.16.1
189
+ - @checkstack/incident-common@1.10.1
190
+ - @checkstack/maintenance-common@1.10.1
191
+ - @checkstack/status-page-common@0.6.1
192
+
193
+ ## 1.19.0
194
+
195
+ ### Minor Changes
196
+
197
+ - 43e4484: Fix an N+1 in the catalog manager: the per-system "Health Checks" count badge
198
+ fired one `getSystemAssociations` request per system row, each holding a pooled
199
+ Postgres connection that contended with the background health-check run
200
+ executor and could exhaust the pool on large catalogs.
201
+
202
+ - Add `getBulkAssignedHealthCheckCounts({ systemIds })` to healthcheck, which
203
+ returns per-system assignment counts (0 for systems with no assignments) from
204
+ ONE grouped `COUNT(*) ... GROUP BY system_id` query. Read authorization
205
+ matches the per-system endpoint it replaces (`configuration.read` +
206
+ `catalog.system` read via `recordKey`), so a team-scoped user only sees counts
207
+ for systems they may read.
208
+ - `CatalogSystemActionsSlot` now passes `visibleSystemIds` (every system id in
209
+ the row's list) so a per-row filler can bulk-fetch for the whole visible set
210
+ in a single deduped request instead of one request per row. This mirrors how
211
+ `CatalogBrowseHealthSlot` / `SystemSignalsSlot` already pass `systemIds`.
212
+ - The health-check count badge now reads its count from that one deduped bulk
213
+ query. N visible rows cause 1 request instead of N.
214
+
215
+ State & scale: the counts are derived on read from the shared
216
+ `system_health_checks` table, so every pod returns the same answer; no
217
+ process-local or duplicated state is introduced.
218
+
219
+ - 43e4484: Name the failing health check in system-health notifications. The notification
220
+ body now names the check that drove the transition (in addition to the system
221
+ and environment), and a `healthcheck.healthcheck` subject is pushed alongside
222
+ the `catalog.system` subject, deep-linked to the check's run history. Recovery
223
+ notifications stay system-level. Adds a `createHealthcheckSubject` builder to
224
+ `healthcheck-common`.
225
+
226
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
227
+
228
+ - 43e4484: Status pages can now publish only a subset of catalog environments. The page
229
+ builder gains a "Published environments" picker (empty = all environments, the
230
+ backward-compatible default). When a non-empty set is selected, the page omits
231
+ status, incidents, maintenances and uptime for systems that belong to none of
232
+ the selected environments.
233
+
234
+ - Status pages store an optional `publishedEnvironmentIds` set (new nullable
235
+ `published_environment_ids` column; NULL = all environments, so existing pages
236
+ are unchanged) exposed on `StatusPage`, `createStatusPage`, and
237
+ `updateStatusPage`.
238
+ - The scope is threaded onto `WidgetResolveContext.publishedEnvironmentIds` as
239
+ opaque strings and passed identically to `resolvePublic`,
240
+ `resolveScopedSystems`, and `resolveScopedSystemsDetailed` (and the email
241
+ subscribe clamp + fan-out), so what a page shows, offers for subscription, and
242
+ emails about all agree.
243
+ - Health widgets recompute per environment: they read the per-environment health
244
+ matrix and roll up only the selected environments. `getBulkRunStats` and
245
+ `getRunStats` gain an optional `environmentIds` filter so uptime counts only
246
+ runs recorded in the selected environments.
247
+ - Incident and maintenance widgets filter their feed and scope by intersecting
248
+ each item's affected systems with the environment-visible systems. Incidents
249
+ and maintenance windows carry no environment of their own, so a system in
250
+ several environments makes its items visible on a page publishing ANY of them
251
+ (the multi-environment caveat).
252
+
253
+ ### Patch Changes
254
+
255
+ - 43e4484: fix(healthcheck): disabling an environment for an assignment now clears its stale slice from the rollup and overview immediately
256
+
257
+ Disabling an environment for a health-check assignment (removing it from the
258
+ assignment's `environmentIds`) stopped that environment from fanning out, but a
259
+ check that was FAILING there kept dragging the system health rollup/badge to
260
+ unhealthy and kept showing as a live failing row in the system overview. Because
261
+ the rollup is recomputed by an event-driven consumer subscribed to per-env health
262
+ CHANGES, and a disabled env produces no further runs (so no change event fires),
263
+ the stale unhealthy status was never recomputed away - it only cleared
264
+ incidentally, once the disabled env's runs aged out of the bounded run window
265
+ (which needs the assignment's OTHER active environments to produce enough newer
266
+ runs first). With a single active/failing env, it could persist until retention.
267
+
268
+ Scope: this reconciles environments DISABLED/removed ON THE ASSIGNMENT (its
269
+ `systemHealthChecks.environmentIds` selector - switching to Specific and
270
+ deselecting, or None).
271
+
272
+ Fixes:
273
+
274
+ - The rollup aggregation (`getSystemHealthStatus`) and the per-check status in
275
+ `getSystemHealthOverview` now consider only CURRENTLY-EFFECTIVE environment
276
+ slices, derived from the durable `systemHealthChecks.environmentIds` selector
277
+ (catalog-free, identical on every pod). A slice whose environment was disabled
278
+ for the assignment, or the stale env-less slice of a check that now fans out,
279
+ no longer contributes.
280
+
281
+ Known limitation: under an "all-environments" assignment (`environmentIds` is
282
+ `null`), an environment removed only from the system's CATALOG MEMBERSHIP (rather
283
+ than disabled on the assignment) can still contribute to the backend rollup/badge
284
+ until the assignment is re-evaluated, because the rollup read path is
285
+ intentionally catalog-free for horizontal-scale correctness (it must return the
286
+ same answer on every pod without a per-read catalog lookup). This is pre-existing;
287
+ the frontend overview, which can see membership, still orphans such a slice.
288
+
289
+ - Each environment is now windowed by its OWN query in the rollup, instead of a
290
+ single shared `LIMIT` across the mixed-env pool. The old shared window
291
+ truncated per-env evaluation for checks that fan out to many environments (or
292
+ with large threshold windows); every environment now gets its full evaluation
293
+ depth.
294
+ - Changing an assignment's environment set now triggers an immediate rollup
295
+ recompute for that system, so the persisted `health` entity (badge + SLO
296
+ downtime) converges at once rather than waiting for stale runs to age out.
297
+ - The system-overview frontend tucks a slice whose environment was disabled for
298
+ the assignment under "Old checks" (system membership alone could not detect it,
299
+ since the environment is still part of the system). `getSystemHealthOverview`
300
+ now returns each check's `environmentIds` selector to drive this.
301
+
302
+ Shared pure helpers `selectorIncludesEnvironment` / `isEnvSliceEffective` /
303
+ `selectEffectiveEnvKeys` are added to `@checkstack/healthcheck-common` so the
304
+ backend and frontend agree on effective-slice detection.
305
+
306
+ - 43e4484: Batch hot-path scoped-db reads/writes into single transactions to cut per-query round-trips.
307
+
308
+ The scoped-db proxy wraps every standalone query in its own `BEGIN → SET LOCAL search_path → query → COMMIT`, so a path issuing N sequential queries paid N round-trips and checked out a connection N times. These reads/writes now run under one `withScopedTransaction`, collapsing the batch to a single `SET LOCAL` on one connection. Behavior is unchanged:
309
+
310
+ - healthcheck: `getSystemHealthOverview`'s `1 + N·(2+E)` read fan-out.
311
+ - incident/maintenance: `getIncident`/`getMaintenance` (4 reads), `getManyEntityStates`, `listOpenIncidentsBySystem` / `getActiveMaintenancesBySystem`, `getMaintenanceWindowsForRange`; the `list*` / `*ForSystem` per-row `N+1` system lookups collapsed to a single set-based `inArray` read; maintenance `transitionStatus` update+insert made atomic; `addUpdate`/`editUpdate`/`addLink` use `.returning()` instead of a follow-up re-select.
312
+ - ai: `appendMessage`, memory `saveOrUpdate`.
313
+ - notification: `resolveInheritedGroups`.
314
+ - status-page: subscriber `verify` (4 reads) and `unsubscribe` (3 reads).
315
+ - announcement: `getActiveAnnouncements` / `dismissAnnouncement` / `createAnnouncement`.
316
+ - gitops: `upsertProvenance`.
317
+
318
+ - 43e4484: Eliminate N+1 RPC fan-outs in the public status-page widget resolvers.
319
+
320
+ Each of these widgets renders a PUBLIC page, so every per-item RPC was real
321
+ external DB load. Three bulk-by-id endpoints replace the per-item fetches:
322
+
323
+ - `healthcheck-common`: new `getBulkRunStats({ systemIds, startDate, endDate,
324
+ maxBuckets })` -> `{ stats: Record<systemId, RunStats> }`. The `systemHealth`
325
+ widget's uptime column now issues ONE request for all systems instead of one
326
+ `getRunStats` per system. Systems with no runs in the window are omitted, so
327
+ the resolver's output is unchanged.
328
+ - `incident-common`: new `getBulkIncidentUpdates({ incidentIds })` ->
329
+ `{ updates: Record<incidentId, IncidentUpdate[]> }`. The incidents widget now
330
+ fetches every selected incident's update timeline in ONE request instead of
331
+ one `getIncident` per incident.
332
+ - `maintenance-common`: new `getBulkMaintenanceUpdates({ maintenanceIds })` ->
333
+ `{ updates: Record<maintenanceId, MaintenanceUpdate[]> }` (symmetric with the
334
+ incident endpoint) for the maintenance widget.
335
+
336
+ The new update endpoints apply the same per-item audience filter as
337
+ `getIncident` / `getMaintenance`, so internal/logged-in updates and author
338
+ identity never leak to a non-manager caller. Each endpoint is keyed by the
339
+ resource id and gated with the record post-filter (`recordKey`) matching the
340
+ single endpoint's read scope, mirroring `getBulkSystemHealthStatus` /
341
+ `getBulkIncidentsForSystems`. Widget DTO output is unchanged - this is a pure
342
+ request-count optimization.
343
+
344
+ - 43e4484: Status page enhancements:
345
+
346
+ - Group-status widget can collapse its member rows while every member is
347
+ operational (auto-expanding on any issue or maintenance).
348
+ - New "Announcements" status-page widget, contributed fully externally by the
349
+ announcement plugin: it surfaces active `visibility: "all"` announcements
350
+ through a public-safe DTO (title/message/severity/timestamps only) and never
351
+ affects the page status rollup.
352
+ - Incident and maintenance widgets can scope by catalog GROUPS with per-system
353
+ exceptions. Scope is resolved at read time (`(systemIds ∪ members(groupIds)) −
354
+ excludedSystemIds`), so members added to a group later are reflected
355
+ automatically. The builder gets a nested group/system picker.
356
+ - Incident and maintenance items on a public page link to dedicated public
357
+ detail pages, gated server-side to items the page's published widgets actually
358
+ surface (no enumeration, no internal-field leak). The custom-domain public
359
+ bundle gains a minimal in-memory router for the two detail pages.
360
+ - Fix the custom-domain "Cannot connect to Checkstack backend" screen: a
361
+ configured-but-not-servable custom domain now serves the lean public
362
+ "not available" page instead of the admin shell; the public bundle skips the
363
+ cross-origin `/api/config` probe; CORS admits resolved custom domains; the
364
+ request origin is normalized for proxy scheme/port variance; and re-saving an
365
+ unchanged custom domain no longer clears its verification.
366
+ - Anonymous email subscriptions (double opt-in) for incident updates, opt-in per
367
+ status page (`emailSubscriptionsEnabled`, default off): a new
368
+ `status_page_subscribers` table, public subscribe/verify/unsubscribe
369
+ procedures with constant-time responses that fail closed when the page has not
370
+ enabled subscriptions, and team-scoped admin list/remove + an enable toggle in
371
+ the builder. Emails are delivered through a new `sendRawEmail` primitive in
372
+ notification-backend that sends to an arbitrary external address (no auth
373
+ account) via every enabled email strategy (SMTP), with a mandatory unsubscribe
374
+ link.
375
+ - Incident/maintenance update fan-out to subscribers via a new
376
+ `notificationAudienceExtensionPoint` in notification-backend. Every
377
+ notification funnelled through `notifyForSubscription` (incident, maintenance,
378
+ health - all unchanged) now also invokes each registered audience sink exactly
379
+ once, enriched with the affected systems and their catalog groups (resolved
380
+ from notification-backend's own resource-parent graph, never a domain import).
381
+ status-page-backend contributes a sink that, AT SEND TIME, matches each
382
+ notification's affected systems against the systems each published + public +
383
+ email-enabled page currently surfaces in its incident/maintenance widgets
384
+ (honoring group membership and per-system exclusions) and emails that page's
385
+ verified subscribers. Send-time scoping against the live layout is the privacy
386
+ boundary: a page only ever emails about systems its widgets surface right now.
387
+ Because `notifyForSubscription` is a single-pod point RPC, each notification
388
+ fans out exactly once cluster-wide.
389
+ - Subscriber reconcile on page deletion: the subscriber FK is `ON DELETE
390
+ CASCADE` and page deletion also explicitly purges subscribers (invalidating
391
+ pending verify/unsubscribe tokens) - no orphan rows, no post-deletion send.
392
+ Removing all systems from a page or disabling email is intentionally NOT a
393
+ prune: send-time scoping plus the email-enabled gate make those subscribers
394
+ dormant with no data loss, and re-enabling restores the audience without a
395
+ re-subscribe.
396
+ - Send-time scoping is single-source: the fan-out asks each event-feed widget for
397
+ its CURRENT effective system scope (the same live catalog group expansion the
398
+ widget renders from) instead of a parallel copy of group membership, so it can
399
+ never over- or under-deliver relative to what the page shows.
400
+ - `sendRawEmail` in notification-backend is now `userType: "service"` (was an
401
+ authenticated procedure gated on `notification.send`). Sending to an arbitrary
402
+ address is an open-relay / email-bomb primitive, so it is callable only by a
403
+ trusted backend-to-backend caller (the status-page subscriber mailer), never by
404
+ an end user.
405
+ - Incident/maintenance widgets gain an optional per-system PUBLIC label override
406
+ (`systemLabels`), the same override path the system-health widget uses, so the
407
+ public incident/maintenance detail pages present clean labels instead of raw
408
+ catalog names.
409
+ - The anonymous subscribe endpoint adds a coarse per-page quota (max new
410
+ subscribers per rolling hour, counted over durable rows so it holds across
411
+ pods) on top of the per-(page,email) cooldown, capping verification-email
412
+ amplification. The quota is CONFIGURABLE per status page (new nullable
413
+ `email_subscribers_hourly_quota` column; null uses the default of 50, so
414
+ existing pages are unchanged), validated as a positive integer up to 5000,
415
+ editable in the builder next to the email opt-in toggle and gated by the same
416
+ page-manage capability.
417
+ - Email verification is now per-page configurable and backed by a platform-global
418
+ once-per-address registry:
419
+ - New `email_verification_required` column (boolean, default true) on
420
+ `status_pages`, exposed on the admin StatusPage DTO + `updateStatusPage`
421
+ input (same page-manage gate) with a builder toggle. When OFF, a new
422
+ subscriber is created active immediately - no verification email, and the
423
+ address is NOT written to the global registry (the operator's trust choice
424
+ for e.g. an internal page).
425
+ - New `status_page_verified_emails` table: one row per normalized address that
426
+ has completed verification on ANY page. When a verification-required page is
427
+ subscribed by an already-globally-verified address, the row is created active
428
+ immediately and a COURTESY email (with one-click unsubscribe) is sent instead
429
+ of a verification email, so a malicious add is always caught. `verify` upserts
430
+ the address into this registry and activates every other pending row for the
431
+ same address in one update (confirm once, all pages).
432
+ - Fan-out is unchanged: it still gates on the per-row `verified` flag; the
433
+ registry only governs whether a NEW subscribe short-circuits to active.
434
+
435
+ BREAKING CHANGE: `sendRawEmail` is now service-only. Any (non-existent in-tree)
436
+ authenticated caller must invoke it through a trusted service client instead.
437
+
438
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
439
+
440
+ - Updated dependencies [43e4484]
441
+ - Updated dependencies [43e4484]
442
+ - Updated dependencies [43e4484]
443
+ - Updated dependencies [43e4484]
444
+ - Updated dependencies [43e4484]
445
+ - Updated dependencies [43e4484]
446
+ - Updated dependencies [43e4484]
447
+ - Updated dependencies [43e4484]
448
+ - Updated dependencies [43e4484]
449
+ - Updated dependencies [43e4484]
450
+ - Updated dependencies [43e4484]
451
+ - Updated dependencies [43e4484]
452
+ - Updated dependencies [43e4484]
453
+ - Updated dependencies [43e4484]
454
+ - Updated dependencies [43e4484]
455
+ - Updated dependencies [43e4484]
456
+ - Updated dependencies [43e4484]
457
+ - Updated dependencies [43e4484]
458
+ - Updated dependencies [43e4484]
459
+ - Updated dependencies [43e4484]
460
+ - Updated dependencies [43e4484]
461
+ - Updated dependencies [43e4484]
462
+ - Updated dependencies [43e4484]
463
+ - Updated dependencies [43e4484]
464
+ - Updated dependencies [43e4484]
465
+ - Updated dependencies [43e4484]
466
+ - Updated dependencies [43e4484]
467
+ - Updated dependencies [43e4484]
468
+ - @checkstack/ai-backend@0.10.10
469
+ - @checkstack/automation-backend@0.11.1
470
+ - @checkstack/catalog-common@2.7.0
471
+ - @checkstack/catalog-backend@1.7.0
472
+ - @checkstack/healthcheck-common@1.16.0
473
+ - @checkstack/backend-api@0.31.1
474
+ - @checkstack/incident-common@1.10.0
475
+ - @checkstack/incident-backend@1.12.0
476
+ - @checkstack/maintenance-common@1.10.0
477
+ - @checkstack/notification-common@1.6.0
478
+ - @checkstack/status-page-backend@0.5.0
479
+ - @checkstack/gitops-backend@0.5.22
480
+ - @checkstack/secrets-backend@0.3.4
481
+ - @checkstack/status-page-common@0.6.0
482
+ - @checkstack/satellite-backend@0.8.4
483
+ - @checkstack/sdk@0.127.1
484
+ - @checkstack/command-backend@0.2.22
485
+ - @checkstack/script-packages-backend@0.4.1
486
+
3
487
  ## 1.18.0
4
488
 
5
489
  ### Minor Changes
@@ -0,0 +1,8 @@
1
+ -- NOTE: plain (non-CONCURRENT) CREATE INDEX takes a SHARE lock that blocks
2
+ -- writes to health_check_runs while each index builds. The migrator wraps every
3
+ -- migration in one transaction, so CREATE INDEX CONCURRENTLY (which cannot run
4
+ -- inside a transaction) is not possible here. On a very large table you can
5
+ -- pre-build these CONCURRENTLY by hand (outside the migrator, same names) before
6
+ -- deploying; the IF NOT EXISTS below then makes this migration a no-op.
7
+ CREATE INDEX IF NOT EXISTS "health_check_runs_check_recent_idx" ON "health_check_runs" USING btree ("system_id","configuration_id","timestamp");--> statement-breakpoint
8
+ CREATE INDEX IF NOT EXISTS "health_check_runs_slice_recent_idx" ON "health_check_runs" USING btree ("system_id","configuration_id","environment_id","timestamp");
@@ -0,0 +1,2 @@
1
+ CREATE INDEX "health_check_aggregates_system_bucket_idx" ON "health_check_aggregates" USING btree ("system_id","bucket_size","bucket_start");--> statement-breakpoint
2
+ CREATE INDEX "system_health_checks_config_enabled_idx" ON "system_health_checks" USING btree ("configuration_id","enabled");