@checkstack/healthcheck-common 1.4.0 → 1.5.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,88 @@
1
1
  # @checkstack/healthcheck-common
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9dcc848: Plugin-owned AI tools: every domain plugin contributes its own AI tools (chat assistant + automation AI action), and `ai-backend` is platform-only.
8
+
9
+ Every plugin-specific AI tool is owned by the plugin whose domain it acts on, registered via that plugin's own `aiToolExtensionPoint` / `aiToolProjectionExtensionPoint` from its init - the same path an external plugin author uses. `ai-backend` no longer imports or depends on any capability plugin's `*-common`; the dependency direction is strictly plugin -> ai-platform. Pure helpers (`computeFieldDiff`, capability-summary, `ScriptContextKind`) live in `@checkstack/ai-common`.
10
+
11
+ Tools shipped:
12
+
13
+ - Health checks and automations: full CRUD - `healthcheck.propose` / `automation.propose` and `*.update` (`mutate`, deep-validated) and `*.delete` (`destructive`, always confirm-gated). `healthcheck.propose`'s dry-run calls the new deep `validateConfiguration` so propose-time validation matches apply-time. Assertions are validated against the collector's result schema and the canonical operator vocabulary. Capability-catalog tools (`ai.listCapabilities`, `ai.getCapabilitySchema`), script context tools (`ai.getScriptContext`, `ai.testScript`), and notify-subscriber tools (`healthcheck.notifySystemSubscribers` / `...GroupSubscribers`).
14
+ - Catalog: `catalog.createSystem` / `updateSystem` / `createGroup` / `updateGroup` (`mutate`), `catalog.deleteSystem` / `deleteGroup` (`destructive`), membership tools (`mutate`), plus `catalog.listSystems` / `listGroups` read projections.
15
+ - Incident: `incident.create` / `update` / `addUpdate` / `resolve` / `addLink` (`mutate`), `incident.delete` / `removeLink` (`destructive`), and `incident.get` / `incident.list` read projections.
16
+ - Maintenance: `maintenance.create` / `update` / `addUpdate` / `close` / `addLink` (`mutate`), `maintenance.delete` / `removeLink` (`destructive`), and `maintenance.list` / `get` read projections.
17
+ - Read projections for SLO (`slo.listObjectives`), dependency (`dependency.list`), incident (`incident.list`), healthcheck (`healthcheck.status`), and anomaly (`anomaly.explain`), each gated by the source procedure's own access rule and routed as the principal.
18
+ - Documentation grounding: `ai.searchDocs` / `ai.getDoc` over a build-time bundled docs index (BM25-ish ranking), so the assistant grounds how-to answers in Checkstack's own docs offline.
19
+ - URL introspection: `ai.probeUrl`, an SSRF-guarded read tool the assistant uses to inspect a real endpoint before drafting a health check. Update tools compute a before -> after field diff rendered on the confirm card (approve mode) or an "Applied" card (auto mode), so a change is never silent.
20
+
21
+ `ai_analyze` automation action (automation-backend, with an editor connection picker + audited tool calls): runs a bounded AI agent on the run context as the automation's `runAs` service account, so it can never exceed that identity's permissions; destructive tools are never offered; mutating tools auto-apply through the service account's client. Produces an `automation.analysis` artifact downstream actions can branch on. The agent loop is exposed as a headless `aiAgentRunnerRef` service so automation-backend can drive it without depending on ai-backend.
22
+
23
+ `notification.notifyForSubscription` is now callable by user / application principals holding `notification.send` (previously service-only). Every tool routes through the user-scoped client, so handler-side authorization is enforced exactly as a direct UI/RPC action; the resolver gate plus the propose/apply re-check at propose AND apply are the additional authority. A systemic authz regression test asserts every registered tool falls into exactly one safe authorization category.
24
+
25
+ A new `ai_transport` enum value `automation` records the AI action's tool calls in the `ai_tool_calls` audit log. No new durable state beyond that; each tool is a thin, deterministic wrapper over an existing RPC, so every pod behaves identically.
26
+
27
+ This is a beta minor.
28
+
29
+ - 9dcc848: Add environments as a first-class catalog primitive, with per-environment health-check fan-out, config templating, per-environment reactive health, and script run-context exposure.
30
+
31
+ - Catalog primitive: an environment is a sibling of groups - a named, instance-global record carrying free-form custom fields (baseUrl, region, tier, ...) that any system can belong to many-to-many. New `environments` + `systems_environments` tables, `EnvironmentSchema` + create/update schemas, `EntityService` environment CRUD and membership joins, RPC endpoints gated by a new `catalogAccess.environment` access rule, a GitOps `Environment` kind + `System.environments` extension, and frontend management (an `EnvironmentEditor`, an Environments management panel, and a per-system environment picker). The Environments card's Add/Edit/Delete affordances are gated on `catalogAccess.environment.manage`.
32
+ - Per-environment fan-out: run identity becomes `(systemId, configurationId, environmentId)`. Runs, aggregates, and state transitions gain a nullable `environmentId`. The health-check assignment gains an `environmentIds` selector with three modes (All / Specific / None; `null` and `[]` are distinct). The queue executor resolves the effective environment set via the catalog `resolveSystemEnvironments` read and executes one isolated run per environment.
33
+ - Config templating: a new `x-templatable` config-field marker renders a string field through the template engine at execute time, against `{ environment, check, system }`. A shared `renderTemplatableConfig` and a `renderTemplatePreview` helper (re-exported from `@checkstack/template-engine`) keep editor previews identical to the run-time render. The HTTP collector's `url`, `headers[].value`, and `body` are templatable, rendered per environment (the strategy client build moves inside the per-env loop); the `url`'s `.url()` validation moves post-render. Secrets resolve before templating; a field marked both secret and `x-templatable` is rejected at plugin load. `DynamicForm` shows a live "Preview" line, and the catalog `EnvironmentPreviewPicker` ("Preview as: <environment>") drives it in the collector editor (only when the schema has a templatable field).
34
+ - Script run-context: `CollectorRunContext` gains an optional `environment` field (`{ id, name, fields }`, metadata only). Shell collectors receive `CHECKSTACK_ENV_ID` / `_NAME` / `CHECKSTACK_ENV_<FIELD>` vars; inline TS collectors read `globalThis.context.environment`; the editor test panel mirrors both. The env-less path is unchanged.
35
+ - Per-environment reactive health (see BREAKING below), env-keyed read/write paths, env-qualified serialization locks, an optional `trigger.payload.environmentId`, per-environment isolation, and an `ENVIRONMENT_RESOLUTION_FAILED` signal when catalog resolution degrades to a single env-less run.
36
+
37
+ BREAKING CHANGES: the reactive `health` entity's id-shape and cardinality change. It now encodes two views: per-environment (id `"<systemId>::<environmentId>"`) and a system rollup (id `"<systemId>"`, the worst status across environments + env-less runs). The rollup PRESERVES the pre-existing system-level contract - dashboards, status badges, and automations referencing health by `systemId` keep working without re-authoring - but the entity's contract surface changed (new id-shape, higher cardinality, new payload field), so it is flagged breaking. `getBulkHealthState` parses env-qualified ids and keys results by the original id.
38
+
39
+ State and scale: membership and custom fields live only in catalog Postgres and are re-read every tick via the cross-plugin RPC; env-keyed health reads from shared `health_check_runs` / aggregates / transitions (compute-on-read). Every pod resolves the same effective set and the same per-environment health. No pod-local environment state.
40
+
41
+ Also: `unwrapSchema` in `zod-config.ts` loops instead of single-pass-stripping so multi-layer wrappers (`.optional().default()`) still resolve `x-templatable` meta. The env-less `{{ environment.* }}` run notice logs at `debug` (a legitimate recurring configuration), while the post-render HTTP `.url()` check still fails a genuinely-broken empty render with a clear "Rendered URL is invalid" error.
42
+
43
+ This is a beta minor.
44
+
45
+ - 9dcc848: Add a deep `validateConfiguration` RPC to the health-check plugin so propose-time validation matches apply-time validation.
46
+
47
+ - `validateConfiguration` (`@checkstack/healthcheck-common`): a new mutation procedure gated by `healthcheck.healthcheck.manage`, taking a proposed configuration (reusing the create skeleton) and returning `{ valid, errors: [{ path, message }] }`, mirroring automation's `validateDefinition`. It persists nothing.
48
+ - Shared deep validation (`@checkstack/healthcheck-backend`): `collectConfigurationIssues` resolves strategy + collectors by fully-qualified id then migrate-then-validate-strict each config via `parseStrictAssumingV1`. The GitOps reconcile path is refactored to call the same `validateVersionedConfigStrict`, so create / gitops-apply / the new RPC share one implementation.
49
+ - `healthcheck.propose`'s dry-run (`@checkstack/ai-backend`) now calls `validateConfiguration` as its validation authority, so a wrong config type or a typo'd key surfaces at propose time, bringing it to the same deep-validate level `automation.propose` already has.
50
+
51
+ State and scale: no durable state; `validateConfiguration` is a pure read against the in-process registries plus zod validation, identical on every pod.
52
+
53
+ This is a beta minor.
54
+
55
+ ### Patch Changes
56
+
57
+ - 9dcc848: Input-validation and error-mapping hardening found by a fuzzing pass against the built container.
58
+
59
+ - backend: a Postgres driver error caused by bad client input no longer surfaces as a `500`. The `/api` and `/rest` dispatchers now map the relevant SQLSTATE classes to the correct status - `22P02`/`22003`/`22001`/`22007` (malformed/out-of-range/over-long/bad-date value), `23502`/`23503`/`23514` (missing/dangling/check-failed) to `400`, and `23505` (unique violation) to `409` - and log them at `warn` (client mistake), not `error`. The client-facing message is generic so column/constraint names are never leaked; genuine unknown faults still log at `error` and 500. Previously a `where id = $1` with a non-uuid `$1` (or an over-long string, or a foreign-key miss in `addSystemToGroup`) reached the driver and 500'd, making routine probing look like a server outage and burying real 500s.
60
+ - slo-common: **fixes a stored cluster-wide DoS.** `windowDays` was accepted up to `2^53`, but the SLO engine derives window boundaries with `Date(now - windowDays * 86_400_000)` - a large value overflows past the max representable `Date` and yields `Invalid Date`. That objective committed fine, then every subsequent read of the system's objectives threw `RangeError: Invalid time value` during serialization (a 500 readable by anyone with SLO read access, on any pod). `windowDays` is now bounded to 1..3650 days at the contract, the GitOps `kind: SLO` spec, and the update path via a single shared `SloWindowDaysSchema`, so the poison row can never be created.
61
+ - slo-common + healthcheck-common: SLO `getDailySnapshots` and the healthcheck history endpoints (`getHistory`, `getDetailedHistory`, `getAggregatedHistory`, `getDetailedAggregatedHistory`, `getRunsForAnalysis`) declared their `startDate`/`endDate` params as `z.date()`, which a `/rest/...` string param can never satisfy - so those endpoints 400'd on the entire REST surface. They now use `z.coerce.date()`, accepting both the REST string shape and the native RPC `Date`.
62
+ - healthcheck-common: `intervalSeconds` was `z.number().min(1)` with no `.int()` and no upper bound, so a fractional or out-of-range value reached the DB and failed at insert (the column is a 32-bit int). It is now `.int().min(1).max(2_592_000)` (1 second .. 30 days), applied to both create and update (the update schema is the create partial).
63
+ - catalog-common: system/group/environment names were bare `z.string()` (environment was `.min(1)` only), so empty, whitespace-only, and 100KB+ names reached the DB - the huge ones surfaced as 500s when parameter binding blew up. Names are now `trim().min(1).max(200)` via a shared schema.
64
+
65
+ **BREAKING:** `getSystemContacts` is now `userType: "authenticated"` (was `"public"`). System contacts carry PII (user id, name, email); the public read leaked them to anonymous status-page visitors. Anonymous callers now receive `401` for this one endpoint; the system detail page already renders "No contacts assigned" for anonymous viewers, so the UI degrades gracefully. All other catalog reads remain public.
66
+
67
+ - catalog-frontend: the system detail page skips the `getSystemContacts` request entirely for anonymous viewers (it would now `401`) and falls back to the empty state.
68
+
69
+ This is a beta release: the breaking contact-visibility change ships as a minor bump per the beta versioning policy, not a major.
70
+
71
+ - Updated dependencies [9dcc848]
72
+ - Updated dependencies [9dcc848]
73
+ - Updated dependencies [9dcc848]
74
+ - Updated dependencies [9dcc848]
75
+ - Updated dependencies [9dcc848]
76
+ - Updated dependencies [9dcc848]
77
+ - Updated dependencies [9dcc848]
78
+ - Updated dependencies [9dcc848]
79
+ - Updated dependencies [9dcc848]
80
+ - Updated dependencies [9dcc848]
81
+ - @checkstack/notification-common@1.3.0
82
+ - @checkstack/catalog-common@2.3.0
83
+ - @checkstack/common@0.13.0
84
+ - @checkstack/signal-common@0.2.6
85
+
3
86
  ## 1.4.0
4
87
 
5
88
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-common",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -84,3 +84,24 @@ export const SYSTEM_STATUS_CHANGED = createSignal({
84
84
  newStatus: z.enum(["healthy", "degraded", "unhealthy"]),
85
85
  }),
86
86
  });
87
+
88
+ /**
89
+ * Broadcast when the executor FAILED to resolve a system's environments from
90
+ * the catalog at run time and DEGRADED to a single env-less run (fail-open).
91
+ *
92
+ * This is the durable-misconfig / catalog-outage observability signal: a
93
+ * `logger.warn` alone is easy to miss, so this counter-style signal makes the
94
+ * degradation observable (dashboards / alerts can count it). The check still
95
+ * runs (env-less) — this signals that per-environment fan-out was skipped for
96
+ * this tick, NOT that the check failed.
97
+ */
98
+ export const ENVIRONMENT_RESOLUTION_FAILED = createSignal({
99
+ pluginMetadata,
100
+ event: "environment.resolution_failed",
101
+ payloadSchema: z.object({
102
+ systemId: z.string(),
103
+ configurationId: z.string(),
104
+ /** The error message that caused the fall-back to an env-less run. */
105
+ error: z.string(),
106
+ }),
107
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ZodType } from "zod";
3
+ import { healthCheckContract } from "./rpc-contract";
4
+
5
+ /**
6
+ * Guards the REST-compatibility fix: history date params were `z.date()`, which
7
+ * a `/rest/...` string param can never satisfy, so every REST history call
8
+ * 400'd. `z.coerce.date()` accepts both the REST string shape and the native RPC
9
+ * Date shape.
10
+ */
11
+ function inputSchemaFor(procName: keyof typeof healthCheckContract): ZodType {
12
+ const proc = healthCheckContract[procName] as unknown as Record<
13
+ string,
14
+ unknown
15
+ >;
16
+ const orpc = proc["~orpc"] as { inputSchema?: ZodType };
17
+ if (!orpc.inputSchema) throw new Error(`${String(procName)} has no input`);
18
+ return orpc.inputSchema;
19
+ }
20
+
21
+ describe("history endpoints coerce string date params (REST compatibility)", () => {
22
+ test("getAggregatedHistory accepts ISO date strings", () => {
23
+ const parsed = inputSchemaFor("getAggregatedHistory").safeParse({
24
+ systemId: "sys-1",
25
+ configurationId: "cfg-1",
26
+ startDate: "2026-01-01T00:00:00.000Z",
27
+ endDate: "2026-02-01T00:00:00.000Z",
28
+ });
29
+ expect(parsed.success).toBe(true);
30
+ if (parsed.success) {
31
+ const data = parsed.data as { startDate: Date };
32
+ expect(data.startDate).toBeInstanceOf(Date);
33
+ }
34
+ });
35
+
36
+ test("getHistory accepts ISO date strings on its optional date params", () => {
37
+ const parsed = inputSchemaFor("getHistory").safeParse({
38
+ systemId: "sys-1",
39
+ startDate: "2026-01-01T00:00:00.000Z",
40
+ sortOrder: "desc",
41
+ });
42
+ expect(parsed.success).toBe(true);
43
+ });
44
+ });
@@ -8,6 +8,8 @@ import {
8
8
  HealthCheckConfigurationSchema,
9
9
  CreateHealthCheckConfigurationSchema,
10
10
  UpdateHealthCheckConfigurationSchema,
11
+ ValidateConfigurationInputSchema,
12
+ ValidateConfigurationResultSchema,
11
13
  AssociateHealthCheckSchema,
12
14
  HealthCheckRunSchema,
13
15
  HealthCheckRunPublicSchema,
@@ -182,6 +184,21 @@ export const healthCheckContract = {
182
184
  .input(CreateHealthCheckConfigurationSchema)
183
185
  .output(HealthCheckConfigurationSchema),
184
186
 
187
+ /**
188
+ * Deep-validate a proposed health-check configuration WITHOUT persisting it.
189
+ * Runs the SAME strategy/collector resolution + migrate-then-validate-strict
190
+ * logic the create / gitops-apply path uses, so propose-time errors match
191
+ * apply-time errors. Gated by `configuration.manage` (the privilege the
192
+ * create form requires); the mirror of automation's `validateDefinition`.
193
+ */
194
+ validateConfiguration: proc({
195
+ operationType: "mutation",
196
+ userType: "authenticated",
197
+ access: [healthCheckAccess.configuration.manage],
198
+ })
199
+ .input(ValidateConfigurationInputSchema)
200
+ .output(ValidateConfigurationResultSchema),
201
+
185
202
  updateConfiguration: proc({
186
203
  operationType: "mutation",
187
204
  userType: "authenticated",
@@ -248,6 +265,11 @@ export const healthCheckContract = {
248
265
  stateThresholds: StateThresholdsSchema.optional(),
249
266
  /** IDs of satellites assigned to execute this health check */
250
267
  satelliteIds: z.array(z.string()).optional(),
268
+ /**
269
+ * Per-assignment environment selector. null = all current
270
+ * environments; [] = opt out (env-less); non-empty = those ids.
271
+ */
272
+ environmentIds: z.array(z.string()).nullable().optional(),
251
273
  /** Whether to also run this check locally on the core (default: true) */
252
274
  includeLocal: z.boolean(),
253
275
  /** Per-association notification policy (omitted = platform defaults) */
@@ -356,8 +378,8 @@ export const healthCheckContract = {
356
378
  z.object({
357
379
  systemId: z.string().optional(),
358
380
  configurationId: z.string().optional(),
359
- startDate: z.date().optional(),
360
- endDate: z.date().optional(),
381
+ startDate: z.coerce.date().optional(),
382
+ endDate: z.coerce.date().optional(),
361
383
  /** Filter by source: "local" = core only, satellite UUID = specific satellite, undefined = all */
362
384
  sourceFilter: z.string().optional(),
363
385
  /** Restrict runs to the listed statuses. Omitted/empty = no filter. */
@@ -383,8 +405,8 @@ export const healthCheckContract = {
383
405
  z.object({
384
406
  systemId: z.string().optional(),
385
407
  configurationId: z.string().optional(),
386
- startDate: z.date().optional(),
387
- endDate: z.date().optional(),
408
+ startDate: z.coerce.date().optional(),
409
+ endDate: z.coerce.date().optional(),
388
410
  /** Filter by source: "local" = core only, satellite UUID = specific satellite, undefined = all */
389
411
  sourceFilter: z.string().optional(),
390
412
  /** Restrict runs to the listed statuses. Omitted/empty = no filter. */
@@ -422,8 +444,8 @@ export const healthCheckContract = {
422
444
  z.object({
423
445
  systemId: z.string(),
424
446
  configurationId: z.string(),
425
- startDate: z.date(),
426
- endDate: z.date(),
447
+ startDate: z.coerce.date(),
448
+ endDate: z.coerce.date(),
427
449
  /** Target number of data points (default: 500). Bucket interval is calculated as (endDate - startDate) / targetPoints */
428
450
  targetPoints: z.number().min(10).max(2000).default(500),
429
451
  }),
@@ -445,8 +467,8 @@ export const healthCheckContract = {
445
467
  z.object({
446
468
  systemId: z.string(),
447
469
  configurationId: z.string(),
448
- startDate: z.date(),
449
- endDate: z.date(),
470
+ startDate: z.coerce.date(),
471
+ endDate: z.coerce.date(),
450
472
  /** Filter by source: "local" = core only, satellite UUID = specific satellite, undefined = all */
451
473
  sourceFilter: z.string().optional(),
452
474
  /** Target number of data points (default: 500). Bucket interval is calculated as (endDate - startDate) / targetPoints */
@@ -623,7 +645,7 @@ export const healthCheckContract = {
623
645
  })
624
646
  .input(
625
647
  z.object({
626
- startDate: z.date(),
648
+ startDate: z.coerce.date(),
627
649
  limitPerAssignment: z.number().optional().default(200),
628
650
  }),
629
651
  )
package/src/schemas.ts CHANGED
@@ -97,7 +97,9 @@ export const CreateHealthCheckConfigurationSchema = z.object({
97
97
  name: z.string().min(1),
98
98
  strategyId: z.string().min(1),
99
99
  config: z.record(z.string(), z.unknown()),
100
- intervalSeconds: z.number().min(1),
100
+ // Bounded: a non-integer or out-of-range value previously reached the DB and
101
+ // failed at insert (the column is a 32-bit int). 1 second .. 30 days.
102
+ intervalSeconds: z.number().int().min(1).max(2_592_000),
101
103
  /** Optional collector configurations */
102
104
  collectors: z.array(CollectorConfigEntrySchema).optional(),
103
105
  });
@@ -106,6 +108,39 @@ export type CreateHealthCheckConfiguration = z.infer<
106
108
  typeof CreateHealthCheckConfigurationSchema
107
109
  >;
108
110
 
111
+ /**
112
+ * Input for the `validateConfiguration` RPC: a proposed (not-yet-persisted)
113
+ * health-check configuration. Reuses the create skeleton so the same
114
+ * name/strategyId/config/intervalSeconds/collectors shape is validated at
115
+ * propose time as is at apply time.
116
+ */
117
+ export const ValidateConfigurationInputSchema =
118
+ CreateHealthCheckConfigurationSchema;
119
+
120
+ export type ValidateConfigurationInput = z.infer<
121
+ typeof ValidateConfigurationInputSchema
122
+ >;
123
+
124
+ /**
125
+ * Result of `validateConfiguration`: `valid` plus a flat list of structured
126
+ * issues. `path`s are dot-joinable for display, e.g. `config.url` or
127
+ * `collectors.0.config.path`. Mirrors automation's `validateDefinition`
128
+ * result so consumers (the AI propose tool, the UI) handle both identically.
129
+ */
130
+ export const ValidateConfigurationResultSchema = z.object({
131
+ valid: z.boolean(),
132
+ errors: z.array(
133
+ z.object({
134
+ path: z.array(z.union([z.string(), z.number()])),
135
+ message: z.string(),
136
+ }),
137
+ ),
138
+ });
139
+
140
+ export type ValidateConfigurationResult = z.infer<
141
+ typeof ValidateConfigurationResultSchema
142
+ >;
143
+
109
144
  export const UpdateHealthCheckConfigurationSchema =
110
145
  CreateHealthCheckConfigurationSchema.partial();
111
146
 
@@ -245,6 +280,14 @@ export const AssociateHealthCheckSchema = z.object({
245
280
  stateThresholds: StateThresholdsSchema.optional(),
246
281
  /** IDs of satellites assigned to execute this health check */
247
282
  satelliteIds: z.array(z.string()).optional(),
283
+ /**
284
+ * Per-assignment environment selector for per-environment fan-out.
285
+ * `null`/omitted = all environments the system currently belongs to;
286
+ * non-empty array = exactly those (intersected with current membership);
287
+ * empty array `[]` = opt out (run once with no environment). `null` and
288
+ * `[]` are semantically distinct.
289
+ */
290
+ environmentIds: z.array(z.string()).nullable().optional(),
248
291
  /** Whether to also run this check locally on the core instance (default: true) */
249
292
  includeLocal: z.boolean().default(true),
250
293
  /** Per-association notification policy. Defaults applied when omitted. */
@@ -267,6 +310,11 @@ export const HealthCheckRunSchema = z.object({
267
310
  result: z.record(z.string(), z.unknown()),
268
311
  timestamp: z.date(),
269
312
  latencyMs: z.number().optional(),
313
+ /**
314
+ * Environment this run executed for (per-environment fan-out). undefined =
315
+ * env-less run (the opt-out / no-membership case).
316
+ */
317
+ environmentId: z.string().optional(),
270
318
  /** Source ID for result attribution (null = local core, UUID = satellite) */
271
319
  sourceId: z.string().optional(),
272
320
  /** Human-readable source label (e.g. "Local" or "EU West (eu-west-1)") */
@@ -307,6 +355,11 @@ export const HealthCheckRunPublicSchema = z.object({
307
355
  status: HealthCheckStatusSchema,
308
356
  timestamp: z.date(),
309
357
  latencyMs: z.number().optional(),
358
+ /**
359
+ * Environment this run executed for (per-environment fan-out). undefined =
360
+ * env-less run (the opt-out / no-membership case).
361
+ */
362
+ environmentId: z.string().optional(),
310
363
  /** Source ID for result attribution (null = local core, UUID = satellite) */
311
364
  sourceId: z.string().optional(),
312
365
  /** Human-readable source label (e.g. "Local" or "EU West (eu-west-1)") */