@checkstack/healthcheck-backend 1.14.0 → 1.16.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/src/service.ts CHANGED
@@ -12,7 +12,9 @@ import {
12
12
  type CollectorConfigEntry,
13
13
  type HealthcheckSignalStatuses,
14
14
  type RunStats,
15
+ stripEphemeralFields,
15
16
  } from "@checkstack/healthcheck-common";
17
+ import { evaluateCollectorAssertionOutcomes } from "./collector-assertions";
16
18
  import { summarizeRuns, type StatRun } from "./run-stats.logic";
17
19
  import type { ConfigService } from "@checkstack/backend-api";
18
20
  import type { InferClient } from "@checkstack/common";
@@ -57,12 +59,25 @@ import type {
57
59
  import {
58
60
  aggregateCollectorData,
59
61
  extractLatencies,
62
+ foldRunAssertionStats,
60
63
  mergeTieredBuckets,
61
64
  reaggregateBuckets,
62
65
  countStatuses,
63
66
  calculateLatencyStats,
64
67
  type NormalizedBucket,
65
68
  } from "./aggregation-utils";
69
+ import { ASSERTIONS_AGG_KEY } from "@checkstack/healthcheck-common";
70
+ import {
71
+ extractConfigurationSecrets,
72
+ mergeConfigurationSecrets,
73
+ deleteConfigurationSecrets,
74
+ pruneOrphanedConfigurationSecrets,
75
+ redactSecretFields,
76
+ listPopulatedSecretKeys,
77
+ healthcheckConfigLockKey,
78
+ type GetCollectorSchema,
79
+ type HealthCheckSecretsDeps,
80
+ } from "./config-secrets";
66
81
 
67
82
  // Drizzle type helper - uses SafeDatabase to prevent relational query API usage
68
83
  type Db = SafeDatabase<typeof schema>;
@@ -118,8 +133,26 @@ export class HealthCheckService {
118
133
  * test constructions), `systemName` falls back to the `systemId`.
119
134
  */
120
135
  private catalogClient?: CatalogClient,
136
+ /**
137
+ * Optional — the secrets channel for config credentials. When present,
138
+ * every write extracts inline `x-secret` values into internal secrets
139
+ * (the stored row holds only markers / `${{ secrets.* }}` references)
140
+ * and updates merge blank/absent secrets from the stored row. MUST be
141
+ * provided on every construction that serves writes (router, gitops);
142
+ * kept optional only for read-path and test constructions.
143
+ */
144
+ private secrets?: HealthCheckSecretsDeps,
121
145
  ) {}
122
146
 
147
+ /** Schema lookup for a collector entry, or undefined when unregistered. */
148
+ private getCollectorSchema: GetCollectorSchema = (collectorId) =>
149
+ this.collectorRegistry.getCollector(collectorId)?.collector.config.schema;
150
+
151
+ /** Schema lookup for a strategy, or undefined when unregistered. */
152
+ private getStrategySchema(strategyId: string) {
153
+ return this.registry.getStrategy(strategyId)?.config.schema;
154
+ }
155
+
123
156
  /**
124
157
  * Resolve the platform-wide notification policy defaults. Returns
125
158
  * the compile-time defaults when no `configService` was provided or
@@ -163,18 +196,57 @@ export class HealthCheckService {
163
196
  async createConfiguration(
164
197
  data: CreateHealthCheckConfiguration,
165
198
  ): Promise<HealthCheckConfiguration> {
166
- const [config] = await this.db
167
- .insert(healthCheckConfigurations)
168
- .values({
169
- name: data.name,
170
- strategyId: data.strategyId,
199
+ // Generate the id up front: secret extraction keys internal secrets by
200
+ // configuration id, and the raw values must never reach the row insert.
201
+ const id = crypto.randomUUID();
202
+ let configToStore = data.config;
203
+ let collectorsToStore = data.collectors;
204
+
205
+ // Gate extraction on `this.secrets` alone, NOT on the strategy being
206
+ // registered: extractConfigurationSecrets handles an undefined strategy
207
+ // schema (passing the strategy config through untouched) while STILL
208
+ // extracting every registered collector's secrets. Gating on strategySchema
209
+ // would store a registered collector's inline secret as plaintext at rest
210
+ // when the strategy is unregistered - the leak updateConfiguration avoids.
211
+ const strategySchema = this.getStrategySchema(data.strategyId);
212
+ if (this.secrets) {
213
+ const extracted = await extractConfigurationSecrets({
214
+ configurationId: id,
215
+ strategySchema,
171
216
  config: data.config,
172
- collectors: data.collectors ?? undefined,
173
- intervalSeconds: data.intervalSeconds,
174
- isTemplate: false, // Defaulting for now
175
- })
176
- .returning();
177
- return this.mapConfig(config);
217
+ collectors: data.collectors,
218
+ getCollectorSchema: this.getCollectorSchema,
219
+ internalSecrets: this.secrets.internalSecrets,
220
+ });
221
+ configToStore = extracted.config;
222
+ collectorsToStore = extracted.collectors;
223
+ }
224
+
225
+ try {
226
+ const [config] = await this.db
227
+ .insert(healthCheckConfigurations)
228
+ .values({
229
+ id,
230
+ name: data.name,
231
+ strategyId: data.strategyId,
232
+ config: configToStore,
233
+ collectors: collectorsToStore ?? undefined,
234
+ intervalSeconds: data.intervalSeconds,
235
+ isTemplate: false, // Defaulting for now
236
+ })
237
+ .returning();
238
+ return this.mapConfig(config);
239
+ } catch (error) {
240
+ // The insert never committed, so the just-extracted internal secrets are
241
+ // now orphaned (a config id that owns no row). Delete them so a failed
242
+ // create leaves nothing dangling in the store.
243
+ await this.cleanupExtractedSecrets({
244
+ id,
245
+ config: configToStore,
246
+ collectors: collectorsToStore,
247
+ });
248
+ throw error;
249
+ }
178
250
  }
179
251
 
180
252
  async getConfiguration(
@@ -187,25 +259,303 @@ export class HealthCheckService {
187
259
  return config ? this.mapConfig(config) : undefined;
188
260
  }
189
261
 
262
+ /**
263
+ * Redact a configuration for a UI/AI read: every `x-secret` field is
264
+ * removed from the strategy config and each collector config. Stored
265
+ * values, `${{ secrets.* }}` references, and even internal markers never
266
+ * leave the backend; the editor treats the absent field as
267
+ * "keep existing" (`keepExistingSecretFields`).
268
+ */
269
+ redactConfiguration(
270
+ configuration: HealthCheckConfiguration,
271
+ ): HealthCheckConfiguration {
272
+ // FAIL CLOSED on an unregistered strategy/collector (plugin uninstalled):
273
+ // without its schema we cannot know which fields are secret, so return an
274
+ // empty config rather than a possibly-secret-bearing one.
275
+ const strategySchema = this.getStrategySchema(configuration.strategyId);
276
+ const config = strategySchema
277
+ ? redactSecretFields({ schema: strategySchema, config: configuration.config })
278
+ : {};
279
+ const collectors = configuration.collectors?.map((entry) => {
280
+ const schema = this.getCollectorSchema(entry.collectorId);
281
+ if (!schema) return { ...entry, config: {} };
282
+ return { ...entry, config: redactSecretFields({ schema, config: entry.config }) };
283
+ });
284
+ // Tell the editor which secret fields ACTUALLY have a stored value (computed
285
+ // from the pre-redaction config), so a never-set optional secret does not
286
+ // show a misleading "a secret is stored" hint / Clear affordance.
287
+ const configuredSecrets = {
288
+ strategy: strategySchema
289
+ ? listPopulatedSecretKeys({
290
+ schema: strategySchema,
291
+ config: configuration.config,
292
+ })
293
+ : [],
294
+ collectors: Object.fromEntries(
295
+ (configuration.collectors ?? []).map((entry) => {
296
+ const schema = this.getCollectorSchema(entry.collectorId);
297
+ return [
298
+ entry.id,
299
+ schema
300
+ ? listPopulatedSecretKeys({ schema, config: entry.config })
301
+ : [],
302
+ ];
303
+ }),
304
+ ),
305
+ };
306
+ return { ...configuration, config, collectors, configuredSecrets };
307
+ }
308
+
309
+ async getConfigurationRedacted(
310
+ id: string,
311
+ ): Promise<HealthCheckConfiguration | undefined> {
312
+ const configuration = await this.getConfiguration(id);
313
+ return configuration ? this.redactConfiguration(configuration) : undefined;
314
+ }
315
+
316
+ async getConfigurationsRedacted(): Promise<HealthCheckConfiguration[]> {
317
+ const configurations = await this.getConfigurations();
318
+ return configurations.map((c) => this.redactConfiguration(c));
319
+ }
320
+
190
321
  async updateConfiguration(
191
322
  id: string,
192
323
  data: UpdateHealthCheckConfiguration,
324
+ options?: {
325
+ /**
326
+ * Whether a blank/absent `x-secret` field means "keep the stored value"
327
+ * (the UI editor round-trips the REDACTED config, so this is `true` by
328
+ * default). GitOps is DECLARATIVE - the authored source is the whole
329
+ * truth, so an omitted secret field means "not set". Pass `false` there
330
+ * so absent secrets are removed rather than silently re-restored.
331
+ */
332
+ mergeSecrets?: boolean;
333
+ },
193
334
  ): Promise<HealthCheckConfiguration | undefined> {
194
- const [config] = await this.db
195
- .update(healthCheckConfigurations)
196
- .set({
197
- ...data,
198
- updatedAt: new Date(),
199
- })
200
- .where(eq(healthCheckConfigurations.id, id))
201
- .returning();
202
- return config ? this.mapConfig(config) : undefined;
335
+ const mergeSecrets = options?.mergeSecrets ?? true;
336
+
337
+ const applyUpdate = async (): Promise<
338
+ HealthCheckConfiguration | undefined
339
+ > => {
340
+ let body = data;
341
+ // Deferred until AFTER the row is written: deletes internal secrets
342
+ // orphaned by this edit (a cleared / declaratively-removed field, an
343
+ // inline secret swapped for a reference, a removed collector). Post-write
344
+ // means a crash can never leave the row pointing at a marker whose secret
345
+ // we already deleted (a dangling marker fails closed at run time).
346
+ let pruneOrphans: (() => Promise<void>) | undefined;
347
+
348
+ if (this.secrets && (data.config || data.collectors)) {
349
+ // The editor round-trips the REDACTED config, so blank/absent secrets
350
+ // mean "keep existing": restore them from the stored row BEFORE
351
+ // extraction (restored markers pass through extraction untouched).
352
+ const stored = await this.getConfiguration(id);
353
+ if (stored) {
354
+ const strategyId = data.strategyId ?? stored.strategyId;
355
+ const strategySchema = this.getStrategySchema(strategyId);
356
+
357
+ // Preserve an UNREGISTERED collector entry's stored config: a read
358
+ // redacts it to `{}` (fail-closed), and the editor round-trips that
359
+ // back - never persist the `{}` over the stored config.
360
+ const storedById = new Map(
361
+ (stored.collectors ?? []).map((entry) => [entry.id, entry]),
362
+ );
363
+ const sanitizedCollectors = data.collectors?.map((entry) => {
364
+ if (this.getCollectorSchema(entry.collectorId)) return entry;
365
+ const storedEntry = storedById.get(entry.id);
366
+ return storedEntry
367
+ ? { ...entry, config: storedEntry.config }
368
+ : entry;
369
+ });
370
+
371
+ // Strategy config: registered -> merge/extract (or wholesale for
372
+ // gitops); UNREGISTERED -> preserve the stored config (we have no
373
+ // schema to merge/extract/validate it). REGISTERED COLLECTORS are
374
+ // processed via their OWN schemas regardless of strategy
375
+ // registration, so a registered collector under an unregistered
376
+ // strategy still extracts its secrets (no plaintext-at-rest, no wipe).
377
+ const incomingStrategyConfig = strategySchema
378
+ ? (data.config ?? stored.config)
379
+ : stored.config;
380
+
381
+ // UI keeps existing secrets (blank = keep); GitOps applies the
382
+ // authored config wholesale so an omitted secret is genuinely removed.
383
+ const merged = mergeSecrets
384
+ ? mergeConfigurationSecrets({
385
+ strategySchema,
386
+ incomingConfig: incomingStrategyConfig,
387
+ storedConfig: stored.config,
388
+ incomingCollectors: sanitizedCollectors,
389
+ storedCollectors: stored.collectors,
390
+ getCollectorSchema: this.getCollectorSchema,
391
+ })
392
+ : { config: incomingStrategyConfig, collectors: sanitizedCollectors };
393
+
394
+ const extracted = await extractConfigurationSecrets({
395
+ configurationId: id,
396
+ strategySchema,
397
+ config: merged.config,
398
+ collectors: merged.collectors,
399
+ getCollectorSchema: this.getCollectorSchema,
400
+ internalSecrets: this.secrets.internalSecrets,
401
+ });
402
+ body = {
403
+ ...data,
404
+ ...(data.config ? { config: extracted.config } : {}),
405
+ ...(data.collectors ? { collectors: extracted.collectors } : {}),
406
+ };
407
+
408
+ // A partial update only changes provided fields, so the effective new
409
+ // state of an omitted field is the stored one (unchanged, no orphan).
410
+ const newConfig = data.config ? extracted.config : stored.config;
411
+ const newCollectors = data.collectors
412
+ ? extracted.collectors
413
+ : stored.collectors;
414
+ const secrets = this.secrets;
415
+ pruneOrphans = async () => {
416
+ await pruneOrphanedConfigurationSecrets({
417
+ configurationId: id,
418
+ oldConfig: stored.config,
419
+ newConfig,
420
+ oldCollectors: stored.collectors,
421
+ newCollectors,
422
+ internalSecrets: secrets.internalSecrets,
423
+ });
424
+ };
425
+ }
426
+ }
427
+
428
+ const [config] = await this.db
429
+ .update(healthCheckConfigurations)
430
+ .set({
431
+ ...body,
432
+ updatedAt: new Date(),
433
+ })
434
+ .where(eq(healthCheckConfigurations.id, id))
435
+ .returning();
436
+
437
+ // The row now holds the new markers; delete the internal secrets this
438
+ // edit orphaned. Post-write so a dangling marker can never outlive its
439
+ // secret.
440
+ if (config) await pruneOrphans?.();
441
+
442
+ return config ? this.mapConfig(config) : undefined;
443
+ };
444
+
445
+ // Serialize the read-modify-write-prune per config id so a concurrent
446
+ // writer to the SAME id (e.g. a UI edit racing a GitOps reconcile across
447
+ // pods) cannot have its just-written secret deleted by this writer's stale
448
+ // orphan-prune, which would leave a dangling marker. Only needed on the
449
+ // secret-bearing path and only when a cluster-wide lock is wired.
450
+ const lock = this.secrets?.advisoryLock;
451
+ if (lock && (data.config || data.collectors)) {
452
+ return lock.withXactLock({
453
+ key: healthcheckConfigLockKey(id),
454
+ fn: applyUpdate,
455
+ });
456
+ }
457
+ return applyUpdate();
458
+ }
459
+
460
+ /**
461
+ * Restore a stored config's secrets into a proposed (redacted) config so it
462
+ * can be deep-validated as an UPDATE without spuriously failing a
463
+ * required-secret check. Mirrors the merge `updateConfiguration` does
464
+ * before persisting, so validate and apply agree; the restored values are
465
+ * used only for validation and never returned to the caller. Returns the
466
+ * proposed config unchanged when the id is unknown or secrets are not wired.
467
+ */
468
+ async restoreSecretsForValidation({
469
+ existingConfigurationId,
470
+ strategyId,
471
+ config,
472
+ collectors,
473
+ }: {
474
+ existingConfigurationId: string;
475
+ strategyId: string;
476
+ config: Record<string, unknown>;
477
+ collectors: CollectorConfigEntry[] | undefined;
478
+ }): Promise<{
479
+ config: Record<string, unknown>;
480
+ collectors: CollectorConfigEntry[] | undefined;
481
+ }> {
482
+ const strategySchema = this.getStrategySchema(strategyId);
483
+ const stored = await this.getConfiguration(existingConfigurationId);
484
+ if (!strategySchema || !stored) return { config, collectors };
485
+ return mergeConfigurationSecrets({
486
+ strategySchema,
487
+ incomingConfig: config,
488
+ storedConfig: stored.config,
489
+ incomingCollectors: collectors,
490
+ storedCollectors: stored.collectors,
491
+ getCollectorSchema: this.getCollectorSchema,
492
+ });
203
493
  }
204
494
 
205
495
  async deleteConfiguration(id: string): Promise<void> {
206
- await this.db
207
- .delete(healthCheckConfigurations)
208
- .where(eq(healthCheckConfigurations.id, id));
496
+ const applyDelete = async (): Promise<void> => {
497
+ // Clean up the internal secrets the stored markers point at BEFORE the
498
+ // row disappears (the markers are the only index into them). Schema-free,
499
+ // so an uninstalled strategy/collector plugin does NOT leave its secrets
500
+ // orphaned - marker enumeration does not depend on the plugin being loaded.
501
+ if (this.secrets) {
502
+ const stored = await this.getConfiguration(id);
503
+ if (stored) {
504
+ await deleteConfigurationSecrets({
505
+ configurationId: id,
506
+ config: stored.config,
507
+ collectors: stored.collectors,
508
+ internalSecrets: this.secrets.internalSecrets,
509
+ });
510
+ }
511
+ }
512
+ await this.db
513
+ .delete(healthCheckConfigurations)
514
+ .where(eq(healthCheckConfigurations.id, id));
515
+ };
516
+
517
+ // Serialize delete under the SAME per-config lock updateConfiguration uses,
518
+ // so a concurrent update on this id cannot interleave: a delete reading the
519
+ // pre-update row would clean up the OLD markers and drop the row while the
520
+ // update writes a fresh internal secret, orphaning it. Delete and update of
521
+ // one id must be mutually exclusive.
522
+ const lock = this.secrets?.advisoryLock;
523
+ if (lock) {
524
+ return lock.withXactLock({
525
+ key: healthcheckConfigLockKey(id),
526
+ fn: applyDelete,
527
+ });
528
+ }
529
+ return applyDelete();
530
+ }
531
+
532
+ /**
533
+ * Delete internal secrets extracted for a config whose row insert did NOT
534
+ * commit (a rolled-back create), so a failed create never orphans the
535
+ * secrets it extracted. Best-effort: swallows cleanup errors so the original
536
+ * insert error is the one surfaced.
537
+ */
538
+ private async cleanupExtractedSecrets({
539
+ id,
540
+ config,
541
+ collectors,
542
+ }: {
543
+ id: string;
544
+ config: Record<string, unknown>;
545
+ collectors: CollectorConfigEntry[] | undefined;
546
+ }): Promise<void> {
547
+ if (!this.secrets) return;
548
+ try {
549
+ await deleteConfigurationSecrets({
550
+ configurationId: id,
551
+ config,
552
+ collectors,
553
+ internalSecrets: this.secrets.internalSecrets,
554
+ });
555
+ } catch {
556
+ // Ignore: the create already failed; surfacing a cleanup error would mask
557
+ // the real cause. The orphan (if any) is a harmless encrypted blob.
558
+ }
209
559
  }
210
560
 
211
561
  async pauseConfiguration(id: string): Promise<void> {
@@ -330,32 +680,70 @@ export class HealthCheckService {
330
680
  const versionedThresholds: VersionedStateThresholds | undefined =
331
681
  stateThresholds_ ? stateThresholds.create(stateThresholds_) : undefined;
332
682
 
333
- const created = await this.db.transaction(async (tx) => {
334
- const [config] = await tx
335
- .insert(healthCheckConfigurations)
336
- .values({
337
- name: configuration.name,
338
- strategyId: configuration.strategyId,
339
- config: configuration.config,
340
- collectors: configuration.collectors ?? undefined,
341
- intervalSeconds: configuration.intervalSeconds,
342
- isTemplate: false,
343
- })
344
- .returning();
345
-
346
- await tx.insert(systemHealthChecks).values({
347
- systemId,
348
- configurationId: config.id,
349
- enabled,
350
- stateThresholds: versionedThresholds,
351
- satelliteIds: satelliteIds ?? undefined,
352
- environmentIds: environmentIdsValue,
353
- includeLocal,
354
- notificationPolicy: notificationPolicy ?? undefined,
683
+ // Extract inline secrets into the encrypted internal store BEFORE insert -
684
+ // identical to createConfiguration. Without this, the first-check wizard
685
+ // and the AI propose tool (both createAndAssign callers) would persist
686
+ // credentials as plaintext. The id is generated up front so the internal
687
+ // secrets can be keyed by it.
688
+ const id = crypto.randomUUID();
689
+ let configToStore = configuration.config;
690
+ let collectorsToStore = configuration.collectors;
691
+ // Gate on `this.secrets` alone (see createConfiguration): gating on
692
+ // strategySchema would leave a registered collector's inline secret as
693
+ // plaintext at rest when the strategy is unregistered.
694
+ const strategySchema = this.getStrategySchema(configuration.strategyId);
695
+ if (this.secrets) {
696
+ const extracted = await extractConfigurationSecrets({
697
+ configurationId: id,
698
+ strategySchema,
699
+ config: configuration.config,
700
+ collectors: configuration.collectors,
701
+ getCollectorSchema: this.getCollectorSchema,
702
+ internalSecrets: this.secrets.internalSecrets,
355
703
  });
704
+ configToStore = extracted.config;
705
+ collectorsToStore = extracted.collectors;
706
+ }
356
707
 
357
- return config;
358
- });
708
+ let created: typeof healthCheckConfigurations.$inferSelect;
709
+ try {
710
+ created = await this.db.transaction(async (tx) => {
711
+ const [config] = await tx
712
+ .insert(healthCheckConfigurations)
713
+ .values({
714
+ id,
715
+ name: configuration.name,
716
+ strategyId: configuration.strategyId,
717
+ config: configToStore,
718
+ collectors: collectorsToStore ?? undefined,
719
+ intervalSeconds: configuration.intervalSeconds,
720
+ isTemplate: false,
721
+ })
722
+ .returning();
723
+
724
+ await tx.insert(systemHealthChecks).values({
725
+ systemId,
726
+ configurationId: config.id,
727
+ enabled,
728
+ stateThresholds: versionedThresholds,
729
+ satelliteIds: satelliteIds ?? undefined,
730
+ environmentIds: environmentIdsValue,
731
+ includeLocal,
732
+ notificationPolicy: notificationPolicy ?? undefined,
733
+ });
734
+
735
+ return config;
736
+ });
737
+ } catch (error) {
738
+ // The transaction rolled back, so no row owns the secrets we just
739
+ // extracted; delete them so a failed create-and-assign never orphans them.
740
+ await this.cleanupExtractedSecrets({
741
+ id,
742
+ config: configToStore,
743
+ collectors: collectorsToStore,
744
+ });
745
+ throw error;
746
+ }
359
747
 
360
748
  return this.mapConfig(created);
361
749
  }
@@ -487,6 +875,18 @@ export class HealthCheckService {
487
875
  return Promise.all(rows.map((r) => this.mapConfig(r.config)));
488
876
  }
489
877
 
878
+ /**
879
+ * System-scoped configuration list for UI reads - REDACTED, like
880
+ * `getConfigurationsRedacted`. Any `x-secret` field is stripped before the
881
+ * config leaves the backend.
882
+ */
883
+ async getSystemConfigurationsRedacted(
884
+ systemId: string,
885
+ ): Promise<HealthCheckConfiguration[]> {
886
+ const configurations = await this.getSystemConfigurations(systemId);
887
+ return configurations.map((c) => this.redactConfiguration(c));
888
+ }
889
+
490
890
  /**
491
891
  * Get system associations with their threshold configurations.
492
892
  */
@@ -1755,9 +2155,16 @@ export class HealthCheckService {
1755
2155
  );
1756
2156
  }
1757
2157
 
2158
+ // Per-assertion pass/fail counts (platform-owned, sibling of
2159
+ // `collectors` — see assertion-analytics in healthcheck-common).
2160
+ const assertionStats = foldRunAssertionStats(bucket.runs);
2161
+
1758
2162
  aggregatedResult = {
1759
2163
  ...strategyResult,
1760
2164
  ...(collectorsAggregated ? { collectors: collectorsAggregated } : {}),
2165
+ ...(assertionStats === undefined
2166
+ ? {}
2167
+ : { [ASSERTIONS_AGG_KEY]: assertionStats }),
1761
2168
  };
1762
2169
  }
1763
2170
 
@@ -2025,6 +2432,15 @@ export class HealthCheckService {
2025
2432
  * Ingest a health check result from a satellite.
2026
2433
  * Stores the run with source attribution (sourceId + sourceLabel)
2027
2434
  * and triggers incremental aggregation to keep charts/availability current.
2435
+ *
2436
+ * Assertions are evaluated HERE, on the core, not on the satellite: the
2437
+ * satellite never held the assertion semantics, so historically
2438
+ * satellite-executed checks silently skipped assertions entirely.
2439
+ * Evaluating at ingest fixes that for every satellite version with no
2440
+ * wire-protocol change. Caveat: buffered results are evaluated against the
2441
+ * configuration CURRENT at ingest time. Ephemeral result fields (e.g. raw
2442
+ * HTTP bodies) are needed for JSONPath assertions and are stripped right
2443
+ * after evaluation, matching what the local executor stores.
2028
2444
  */
2029
2445
  async ingestSatelliteResult(props: {
2030
2446
  configId: string;
@@ -2036,20 +2452,77 @@ export class HealthCheckService {
2036
2452
  sourceId: string;
2037
2453
  sourceLabel: string;
2038
2454
  }) {
2039
- const {
2040
- configId,
2041
- systemId,
2042
- status,
2043
- latencyMs,
2044
- result,
2045
- sourceId,
2046
- sourceLabel,
2047
- } = props;
2455
+ const { configId, systemId, latencyMs, result, sourceId, sourceLabel } =
2456
+ props;
2048
2457
 
2049
2458
  const resultRecord = result
2050
2459
  ? ({ ...result } as Record<string, unknown>)
2051
2460
  : {};
2052
2461
 
2462
+ let status = props.status;
2463
+ const metadata = resultRecord.metadata as
2464
+ | Record<string, unknown>
2465
+ | undefined;
2466
+ const collectorsMeta = metadata?.collectors as
2467
+ | Record<string, Record<string, unknown>>
2468
+ | undefined;
2469
+ if (collectorsMeta && Object.keys(collectorsMeta).length > 0) {
2470
+ const [configRow] = await this.db
2471
+ .select({ collectors: healthCheckConfigurations.collectors })
2472
+ .from(healthCheckConfigurations)
2473
+ .where(eq(healthCheckConfigurations.id, configId));
2474
+ const entries: CollectorConfigEntry[] = configRow?.collectors ?? [];
2475
+
2476
+ let firstFailure: string | undefined;
2477
+ const nextCollectorsMeta: Record<string, Record<string, unknown>> = {
2478
+ ...collectorsMeta,
2479
+ };
2480
+ for (const entry of entries) {
2481
+ const entryResult = nextCollectorsMeta[entry.id];
2482
+ if (!entryResult || typeof entryResult !== "object") continue;
2483
+
2484
+ let evaluated: Record<string, unknown> = { ...entryResult };
2485
+ if (entry.assertions?.length) {
2486
+ const evaluation = evaluateCollectorAssertionOutcomes({
2487
+ assertions: entry.assertions,
2488
+ result: evaluated,
2489
+ });
2490
+ evaluated._assertions = evaluation.outcomes;
2491
+ evaluated._assertionFailed = evaluation.firstFailureMessage;
2492
+ if (
2493
+ evaluation.firstFailureMessage !== undefined &&
2494
+ firstFailure === undefined
2495
+ ) {
2496
+ firstFailure = evaluation.firstFailureMessage;
2497
+ }
2498
+ }
2499
+
2500
+ // Parity with the local executor: satellites send raw results, so
2501
+ // ephemeral fields (assertable but never persisted) get stripped
2502
+ // here, AFTER assertions ran against them.
2503
+ const registered = this.collectorRegistry.getCollector(
2504
+ entry.collectorId,
2505
+ );
2506
+ if (registered) {
2507
+ evaluated = stripEphemeralFields(
2508
+ evaluated,
2509
+ registered.collector.result.schema,
2510
+ );
2511
+ }
2512
+ nextCollectorsMeta[entry.id] = evaluated;
2513
+ }
2514
+
2515
+ resultRecord.metadata = { ...metadata, collectors: nextCollectorsMeta };
2516
+
2517
+ // Mirror the local executor: a failed assertion downgrades a run the
2518
+ // satellite reported healthy.
2519
+ if (firstFailure !== undefined && status === "healthy") {
2520
+ status = "unhealthy";
2521
+ resultRecord.status = status;
2522
+ resultRecord.message = `Check failed: Assertion failed: ${firstFailure}`;
2523
+ }
2524
+ }
2525
+
2053
2526
  // Atomic: the run row and the hourly-aggregate increment it feeds must
2054
2527
  // commit together. Without the transaction a failure on the (non-idempotent
2055
2528
  // `runCount + 1`) aggregate left a committed run that the aggregate never