From e7ce1a73d0dd121165110e9898c5343850d641be Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:54:09 +0200 Subject: [PATCH] =?UTF-8?q?docs(alerting):=20Plan=2004=20implementation=20?= =?UTF-8?q?plan=20=E2=80=94=20post-ship=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 atomic commits covering 5 hardening tasks: Task 1-2: @Schema(discriminatorMapping) on AlertCondition, derive polymorphic unions in enums.ts from schema Task 3-7: AgentState / DeploymentStatus / LogLevel / ExecutionStatus enum migrations + @Schema(allowableValues) on JvmMetric Task 8: ContextStartupSmokeTest (unit-tier, no Testcontainers) Task 9-12: AlertTemplateVariables registry + round-trip test + SSOT endpoint + UI consumer Task 13: alerting-editor.spec.ts Playwright spec Each task has bite-sized write-test/red/green/commit steps with exact paths and full code. Pre-flight SQL check and post-flight self-verification scripts included. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-20-alerting-04-hardening.md | 2690 +++++++++++++++++ 1 file changed, 2690 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-alerting-04-hardening.md diff --git a/docs/superpowers/plans/2026-04-20-alerting-04-hardening.md b/docs/superpowers/plans/2026-04-20-alerting-04-hardening.md new file mode 100644 index 00000000..353b955d --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-alerting-04-hardening.md @@ -0,0 +1,2690 @@ +# Alerting — Plan 04: Post-Ship Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the loop on three bug classes from Plan 03 triage — Spring wiring regressions invisible to unit tests, UI↔backend drift on Mustache template variables, and hand-maintained TypeScript enum unions caused by a springdoc polymorphism quirk. Pure hardening; no new product features. + +**Architecture:** Five independent improvements landed as atomic commits. A new sealed-interface discriminator annotation on `AlertCondition` unlocks typed polymorphic schemas. Four condition String fields become proper enums; a fifth (`JvmMetricCondition.metric`) gets an `@Schema(allowableValues)` hint while staying `String` because the evaluator passes it through to the Micrometer metric store. A unit-level context-startup smoke test catches `@Autowired` regressions at `mvn test` time (not just `mvn verify`). A new `GET /api/v1/alerts/template-variables` endpoint makes the backend the single source of truth for Mustache variable metadata; UI consumes it via TanStack Query. A second Playwright spec exercises the editor paths not covered by Plan 03's smoke. + +**Tech Stack:** +- Java 17 + Spring Boot 3.4.3 (existing) +- springdoc-openapi 2.8.6 (existing) +- JUnit 5 + Mockito + Testcontainers (existing) +- React 19 + TanStack Query v5 + CodeMirror 6 (existing) +- openapi-typescript (existing) +- Playwright (existing) + +**Base branch:** `feat/alerting-04-hardening` off `main`. Worktree `.worktrees/alerting-04`. Commit atomically per task. + +**Execution order** (per spec §8): Task 1 → 2 → 3–7 → 8 → 9–12 → 13. Task 1 is the smallest backend-only change that unlocks Task 2's TS-side cleanup. Tasks 3–7 are independent enum migrations; land them in any order. Task 8 is a new unit test, independent. Tasks 9–12 form the template-variables SSOT feature as a single coherent slice (backend registry → controller → UI consumer). Task 13 adds the Playwright spec last because it exercises everything above. + +**CRITICAL process rules (per project CLAUDE.md):** +- Run `gitnexus_impact({target, direction:"upstream"})` before editing any existing Java class. +- Run `gitnexus_detect_changes()` before every commit. +- After any Java controller or DTO change, regenerate the OpenAPI schema via `cd ui && npm run generate-api:live`. +- Update `.claude/rules/app-classes.md` in the same commit that adds `AlertTemplateVariablesController`. +- Update `.claude/rules/core-classes.md` in the same commit that adds `LogLevel`. +- `mvn clean verify` must stay green through every commit. +- `cd ui && npm run typecheck && npm run test && npm run test:e2e` must stay green through every commit. + +--- + +## File Structure + +### New files + +**Backend (`cameleer-server-core/`):** +``` +src/main/java/com/cameleer/server/core/alerting/ +├── LogLevel.java — Task 5 (TRACE..ERROR) +├── TemplateVariableDescriptor.java — Task 9 (path, type, desc, example) +└── AlertTemplateVariables.java — Task 9 (registry constant) +``` + +**Backend (`cameleer-server-app/`):** +``` +src/main/java/com/cameleer/server/app/alerting/ +├── notify/TemplateVariableDto.java — Task 11 (API response DTO) +└── controller/AlertTemplateVariablesController.java — Task 11 + +src/test/java/com/cameleer/server/app/ +└── ContextStartupSmokeTest.java — Task 8 (@SpringBootTest webEnv=NONE) +``` + +**Backend (`cameleer-server-app/` — test resources):** +``` +src/test/resources/ +└── application-test-context-smoke.yml — Task 8 (profile: stubs DB + CH + runtime) +``` + +**Backend (tests):** +``` +src/test/java/com/cameleer/server/app/alerting/notify/ +└── NotificationContextBuilderRegistryTest.java — Task 10 (registry↔builder round-trip) +``` + +**Frontend:** +``` +ui/src/api/queries/ +└── alertTemplateVariables.ts — Task 12 (useTemplateVariables hook) + +ui/src/test/e2e/ +└── alerting-editor.spec.ts — Task 13 (6 cases) +``` + +### Modified files + +**Backend (`cameleer-server-core/`):** +- `src/main/java/com/cameleer/server/core/alerting/AlertCondition.java` — Task 1 (`@Schema(discriminatorProperty, discriminatorMapping)`) +- `src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java` — Task 3 (state: `String` → `AgentState`) +- `src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java` — Task 4 (states: `List` → `List`) +- `src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java` — Task 5 (level: `String` → `LogLevel`) +- `src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java` — Task 6 (nested `ExchangeFilter.status`: `String` → `ExecutionStatus`) +- `src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java` — Task 7 (`@Schema(allowableValues)` on `metric`, stays `String`) + +**Backend (`cameleer-server-app/`):** +- `src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java` — Task 6 (unwrap `ExecutionStatus.name()` at query boundary) +- `src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java` — Task 3 (consume enum instead of string) +- `src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java` — Task 4 (consume enum list) +- `src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java` — Task 5 (consume `LogLevel`) +- `src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java` — Task 10 (refactor to use registry) + +**Frontend:** +- `ui/src/api/schema.d.ts` — regenerated after each backend schema change +- `ui/src/api/openapi.json` — regenerated after each backend schema change +- `ui/src/pages/Alerts/enums.ts` — Task 2 (delete hand-declared unions; derive from schema). Task 3/4/5/6 (label maps for new enum fields). Task 7 (derive JvmMetric from schema). +- `ui/src/components/MustacheEditor/MustacheEditor.tsx` — Task 12 (variables prop) +- `ui/src/components/MustacheEditor/mustache-completion.ts` — Task 12 (variables parameter) +- `ui/src/components/MustacheEditor/mustache-linter.ts` — Task 12 (variables parameter) +- `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx` — Task 12 (wire `useTemplateVariables()`) +- `ui/src/pages/Admin/OutboundConnectionEditor.tsx` (or equivalent) — Task 12 (same) + +**Frontend (deletions):** +- `ui/src/components/MustacheEditor/alert-variables.ts` — Task 12 (deleted) + +**Rule files:** +- `.claude/rules/app-classes.md` — Task 11 (add `AlertTemplateVariablesController` to flat-endpoint allow-list) +- `.claude/rules/core-classes.md` — Task 5 (add `LogLevel` under `alerting/`) + +**Optional Flyway migration (only if pre-flight SQL finds mismatched legacy rows):** +- `cameleer-server-app/src/main/resources/db/migration/V15__alert_condition_enum_repair.sql` — Tasks 3–6 (one-shot repair) + +--- + +## Pre-flight — Verify environment + +- [ ] **Step 0.1: Create the worktree + branch** + +```bash +git fetch origin +git worktree add -b feat/alerting-04-hardening .worktrees/alerting-04 origin/main +cd .worktrees/alerting-04 +``` + +Expected: worktree registered at `.worktrees/alerting-04`, current branch `feat/alerting-04-hardening`. + +- [ ] **Step 0.2: Baseline build + tests green** + +```bash +mvn clean verify -pl cameleer-server-app -am +cd ui && npm ci && npm run typecheck && npm run test +cd .. +``` + +Expected: BUILD SUCCESS and typecheck + Vitest both green. If any fail on main, stop and investigate — Plan 04 assumes a green baseline. + +- [ ] **Step 0.3: Start local docker stack for UI dev iteration** + +```bash +docker compose -f docker-compose.yml up -d postgres clickhouse +docker compose -f docker-compose.yml ps +``` + +Expected: `postgres` and `clickhouse` services running. If ports are blocked, free them per user memory `feedback_local_services.md`. + +- [ ] **Step 0.4: Pre-flight legacy-data check** + +Before any enum migration, confirm existing `alert_rules.condition` JSON values match the upcoming enum vocabularies. + +```bash +PGPASSWORD=cameleer psql -h localhost -U cameleer -d cameleer -c " + SELECT condition_kind, condition->>'level' AS val FROM alert_rules WHERE condition_kind='LOG_PATTERN' + UNION ALL + SELECT condition_kind, condition->>'state' FROM alert_rules WHERE condition_kind='AGENT_STATE' + UNION ALL + SELECT condition_kind, jsonb_array_elements_text(condition->'states') + FROM alert_rules WHERE condition_kind='DEPLOYMENT_STATE' + UNION ALL + SELECT condition_kind, condition->'filter'->>'status' FROM alert_rules WHERE condition_kind='EXCHANGE_MATCH'; +" +``` + +Expected values, all uppercase: +- `LOG_PATTERN` levels: any of `TRACE | DEBUG | INFO | WARN | ERROR` +- `AGENT_STATE` states: any of `LIVE | STALE | DEAD | SHUTDOWN` +- `DEPLOYMENT_STATE` states (array elements): any of `STOPPED | STARTING | RUNNING | DEGRADED | STOPPING | FAILED` +- `EXCHANGE_MATCH` filter.status: any of `RUNNING | COMPLETED | FAILED | ABANDONED` + +If every returned value matches the expected set, **skip the V15 Flyway migration** — the task notes below reference it only conditionally. + +If any value is lowercase, mistyped, or `null`, record the actual values here and add the V15 repair migration step to Task 3/4/5/6 (whichever tasks hit mismatched rows). The migration shape is: + +```sql +-- V15__alert_condition_enum_repair.sql (example — only if needed) +UPDATE alert_rules + SET condition = jsonb_set(condition, '{level}', to_jsonb(UPPER(condition->>'level'))) + WHERE condition_kind = 'LOG_PATTERN' + AND condition->>'level' IS NOT NULL + AND condition->>'level' != UPPER(condition->>'level'); +-- … one block per affected field +``` + +--- + +## Task 1: Springdoc discriminator mapping on `AlertCondition` + +**Why:** UI `schema.d.ts` resolves `AlertCondition`'s subtypes' `kind` to `never` because the generated OpenAPI `discriminator` has no `mapping`. This is the single-annotation root-cause fix (spec §7). + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java` + +**GitNexus preflight:** + +- [ ] **Step 1.1: Impact-check `AlertCondition`** + +Run: `gitnexus_impact({target: "AlertCondition", direction: "upstream"})` + +Expected: direct callers = all 6 condition subtypes + `AlertRuleRequest` + `AlertRuleResponse` + repository layer. No HIGH/CRITICAL warnings expected (annotation-only change). + +- [ ] **Step 1.2: Modify `AlertCondition.java`** + +Edit `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java`: + +```java +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema( + discriminatorProperty = "kind", + discriminatorMapping = { + @DiscriminatorMapping(value = "ROUTE_METRIC", schema = RouteMetricCondition.class), + @DiscriminatorMapping(value = "EXCHANGE_MATCH", schema = ExchangeMatchCondition.class), + @DiscriminatorMapping(value = "AGENT_STATE", schema = AgentStateCondition.class), + @DiscriminatorMapping(value = "DEPLOYMENT_STATE", schema = DeploymentStateCondition.class), + @DiscriminatorMapping(value = "LOG_PATTERN", schema = LogPatternCondition.class), + @DiscriminatorMapping(value = "JVM_METRIC", schema = JvmMetricCondition.class) + } +) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true) +@JsonSubTypes({ + @JsonSubTypes.Type(value = RouteMetricCondition.class, name = "ROUTE_METRIC"), + @JsonSubTypes.Type(value = ExchangeMatchCondition.class, name = "EXCHANGE_MATCH"), + @JsonSubTypes.Type(value = AgentStateCondition.class, name = "AGENT_STATE"), + @JsonSubTypes.Type(value = DeploymentStateCondition.class, name = "DEPLOYMENT_STATE"), + @JsonSubTypes.Type(value = LogPatternCondition.class, name = "LOG_PATTERN"), + @JsonSubTypes.Type(value = JvmMetricCondition.class, name = "JVM_METRIC") +}) +public sealed interface AlertCondition permits + RouteMetricCondition, ExchangeMatchCondition, AgentStateCondition, + DeploymentStateCondition, LogPatternCondition, JvmMetricCondition { + + @JsonProperty("kind") + ConditionKind kind(); + AlertScope scope(); +} +``` + +- [ ] **Step 1.3: Rebuild + regenerate schema** + +```bash +mvn -pl cameleer-server-core install -DskipTests +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +# Wait for :8081 (up to 60s) +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +``` + +- [ ] **Step 1.4: Verify OpenAPI mapping is present** + +```bash +python -c " +import json +d = json.load(open('ui/src/api/openapi.json')) +disc = d['components']['schemas']['AlertCondition']['discriminator'] +assert 'mapping' in disc, 'Expected discriminator.mapping to exist' +assert disc['mapping']['ROUTE_METRIC'].endswith('/RouteMetricCondition') +assert disc['mapping']['EXCHANGE_MATCH'].endswith('/ExchangeMatchCondition') +print('OK: discriminator.mapping has', len(disc['mapping']), 'entries') +" +``` + +Expected: `OK: discriminator.mapping has 6 entries` + +**If the assertion fails** (`mapping` absent): springdoc 2.8.6 did not honor `@DiscriminatorMapping` on a sealed interface. Apply the fallback — see Step 1.4b below, then re-run Step 1.4. + +- [ ] **Step 1.4b (conditional fallback): `OpenApiCustomizer` bean** + +Only if Step 1.4 failed. Create `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertConditionSchemaCustomizer.java`: + +```java +package com.cameleer.server.app.alerting.config; + +import io.swagger.v3.oas.models.media.Discriminator; +import io.swagger.v3.oas.models.media.Schema; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Ensures the AlertCondition polymorphic schema emits an explicit discriminator + * mapping. Without this, openapi-typescript synthesises {@code kind: ""} + * literals that collapse TS indexed access to {@code never}. + * + * Only wired if the @Schema(discriminatorMapping=…) annotation on the sealed + * interface is not honored by springdoc 2.8.6. + */ +@Configuration +public class AlertConditionSchemaCustomizer { + + @Bean + public OpenApiCustomizer alertConditionDiscriminator() { + return openApi -> { + if (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null) return; + Schema alertCondition = openApi.getComponents().getSchemas().get("AlertCondition"); + if (alertCondition == null) return; + + Map mapping = new LinkedHashMap<>(); + mapping.put("ROUTE_METRIC", "#/components/schemas/RouteMetricCondition"); + mapping.put("EXCHANGE_MATCH", "#/components/schemas/ExchangeMatchCondition"); + mapping.put("AGENT_STATE", "#/components/schemas/AgentStateCondition"); + mapping.put("DEPLOYMENT_STATE", "#/components/schemas/DeploymentStateCondition"); + mapping.put("LOG_PATTERN", "#/components/schemas/LogPatternCondition"); + mapping.put("JVM_METRIC", "#/components/schemas/JvmMetricCondition"); + + Discriminator discriminator = alertCondition.getDiscriminator(); + if (discriminator == null) { + discriminator = new Discriminator().propertyName("kind"); + alertCondition.setDiscriminator(discriminator); + } + discriminator.mapping(mapping); + }; + } +} +``` + +Re-run Step 1.3 + Step 1.4. + +- [ ] **Step 1.5: Verify `mvn verify` still green** + +```bash +mvn -pl cameleer-server-app verify -am +``` + +Expected: `BUILD SUCCESS`, all ITs pass including `SpringContextSmokeIT` and alerting ITs. + +- [ ] **Step 1.6: Detect-changes check** + +Run: `gitnexus_detect_changes({scope: "staged"})` + +Expected: only `AlertCondition.java` staged (plus `AlertConditionSchemaCustomizer.java` if fallback was needed), plus regenerated `openapi.json` and `schema.d.ts`. + +- [ ] **Step 1.7: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertCondition.java +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +# If fallback was used: +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/config/AlertConditionSchemaCustomizer.java +git commit -m "$(cat <<'EOF' +feat(alerting): discriminator mapping on AlertCondition + +openapi-typescript was synthesising kind: "" literals on each +condition subtype because the generated discriminator had no mapping. +Those literals intersected with the real Jackson enum and collapsed to +never, forcing enums.ts to hand-declare every polymorphic union. + +@Schema(discriminatorProperty/discriminatorMapping) on the sealed +interface makes springdoc emit an explicit mapping, so openapi-typescript +uses the real wire values (ROUTE_METRIC etc.) as literals. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Simplify `ui/src/pages/Alerts/enums.ts` — derive polymorphic unions + +**Why:** With Task 1's mapping fix in place, indexed access on the condition subtypes yields real unions instead of `never`. The 4 hand-declared unions and the 30-line workaround comment can go. + +**Files:** +- Modify: `ui/src/pages/Alerts/enums.ts` + +- [ ] **Step 2.1: Edit `enums.ts` — replace hand-declared unions with derived types** + +Full contents of `ui/src/pages/Alerts/enums.ts` after this step: + +```typescript +/** + * Alerting option lists and enum types used by the rule editor. + * + * All types derived from `schema.d.ts`. Declaration order on label maps is + * load-bearing — it controls dropdown order, which the user sees. + * + * Hidden values stay in the `Record` map (so `Record`-exhaustiveness + * still pins labels to the union) and in the type `T` (so rules carrying a + * hidden value round-trip fine through save/load) — they're simply filtered + * out of the visible option array. + */ +import type { components } from '../../api/schema'; + +type AlertRuleRequest = components['schemas']['AlertRuleRequest']; +type AlertRuleTarget = components['schemas']['AlertRuleTarget']; +type RouteMetricCondition = components['schemas']['RouteMetricCondition']; +type JvmMetricCondition = components['schemas']['JvmMetricCondition']; +type ExchangeMatchCondition = components['schemas']['ExchangeMatchCondition']; + +export type ConditionKind = NonNullable; +export type Severity = NonNullable; +export type TargetKind = NonNullable; +export type RouteMetric = NonNullable; +export type Comparator = NonNullable; +export type JvmAggregation = NonNullable; +export type ExchangeFireMode = NonNullable; + +export interface Option { value: T; label: string } + +function toOptions(labels: Record, hidden?: readonly T[]): Option[] { + const skip: ReadonlySet = new Set(hidden ?? []); + return (Object.keys(labels) as T[]) + .filter((value) => !skip.has(value)) + .map((value) => ({ value, label: labels[value] })); +} + +// --------------------------------------------------------------------------- +// Label maps — declaration order = dropdown order. +// --------------------------------------------------------------------------- + +const CONDITION_KIND_LABELS: Record = { + ROUTE_METRIC: 'Route metric (error rate, latency, throughput)', + EXCHANGE_MATCH: 'Exchange match (specific failures)', + AGENT_STATE: 'Agent state (DEAD / STALE)', + DEPLOYMENT_STATE: 'Deployment state (FAILED / DEGRADED)', + LOG_PATTERN: 'Log pattern (count of matching logs)', + JVM_METRIC: 'JVM metric (heap, GC, inflight)', +}; + +const SEVERITY_LABELS: Record = { + CRITICAL: 'Critical', + WARNING: 'Warning', + INFO: 'Info', +}; + +const ROUTE_METRIC_LABELS: Record = { + ERROR_RATE: 'Error rate', + P99_LATENCY_MS: 'P99 latency (ms)', + AVG_DURATION_MS: 'Avg duration (ms)', + THROUGHPUT: 'Throughput (msg/s)', + ERROR_COUNT: 'Error count', +}; + +const COMPARATOR_LABELS: Record = { + GT: '>', + GTE: '\u2265', + LT: '<', + LTE: '\u2264', + EQ: '=', +}; + +const JVM_AGGREGATION_LABELS: Record = { + MAX: 'MAX', + AVG: 'AVG', + MIN: 'MIN', + LATEST: 'LATEST', +}; + +const EXCHANGE_FIRE_MODE_LABELS: Record = { + PER_EXCHANGE: 'One alert per matching exchange', + COUNT_IN_WINDOW: 'Threshold: N matches in window', +}; + +const TARGET_KIND_LABELS: Record = { + USER: 'User', + GROUP: 'Group', + ROLE: 'Role', +}; + +const COMPARATOR_HIDDEN: readonly Comparator[] = [ + 'EQ', // Exact equality on floating-point metrics is rarely useful and noisy + // — users should pick GT/LT with a sensible threshold instead. +]; + +const JVM_AGGREGATION_HIDDEN: readonly JvmAggregation[] = [ + 'LATEST', // Point-in-time reads belong on a metric dashboard, not an alert + // rule — a windowed MAX/MIN/AVG is what you want for alerting. +]; + +export const CONDITION_KIND_OPTIONS: Option[] = toOptions(CONDITION_KIND_LABELS); +export const SEVERITY_OPTIONS: Option[] = toOptions(SEVERITY_LABELS); +export const ROUTE_METRIC_OPTIONS: Option[] = toOptions(ROUTE_METRIC_LABELS); +export const COMPARATOR_OPTIONS: Option[] = toOptions(COMPARATOR_LABELS, COMPARATOR_HIDDEN); +export const JVM_AGGREGATION_OPTIONS: Option[] = toOptions(JVM_AGGREGATION_LABELS, JVM_AGGREGATION_HIDDEN); +export const EXCHANGE_FIRE_MODE_OPTIONS: Option[] = toOptions(EXCHANGE_FIRE_MODE_LABELS); +export const TARGET_KIND_OPTIONS: Option[] = toOptions(TARGET_KIND_LABELS); +``` + +- [ ] **Step 2.2: Typecheck** + +```bash +cd ui && npm run typecheck +``` + +Expected: `0 errors`. If errors surface in call sites (e.g. in condition-forms/), they typically mean the call site relied on a manual union that's now narrower. Each error message will point to an exact file:line; fix the call site (update label maps, widen/narrow the call-site type as needed). + +- [ ] **Step 2.3: Unit test** + +```bash +cd ui && npm run test -- pages/Alerts/enums.test.ts +``` + +Expected: inline-snapshot test for every `_OPTIONS` array passes. If snapshot differs, inspect: either the schema genuinely narrowed an enum (accept snapshot) or the derived type dropped a value we care about (re-check the backend enum). + +- [ ] **Step 2.4: Detect-changes check** + +Run: `gitnexus_detect_changes({scope: "staged"})` + +Expected: `ui/src/pages/Alerts/enums.ts` only (plus any call-site fixes you made in Step 2.2). + +- [ ] **Step 2.5: Commit** + +```bash +git add ui/src/pages/Alerts/enums.ts +# + any call-site fix files from step 2.2 +git commit -m "$(cat <<'EOF' +refactor(ui/alerts): derive polymorphic unions from schema + +Now that discriminator.mapping lands on AlertCondition (Task 1), indexed +access on each condition subtype resolves to real unions instead of +never. Deletes the 4 hand-declared unions (RouteMetric, Comparator, +JvmAggregation, ExchangeFireMode) and the 30-line workaround preamble. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: `AgentStateCondition.state` → `AgentState` enum + +**Why:** Field is typed String despite a closed vocabulary. Making it `AgentState` lets springdoc emit a closed union, lets `@Valid` reject unknown values, and removes the hand-maintained option list in the UI. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java` +- Modify: `ui/src/pages/Alerts/enums.ts` (add `AgentStateOption` label map) +- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx` (consume derived type) + +- [ ] **Step 3.1: Impact-check** + +Run: `gitnexus_impact({target: "AgentStateCondition", direction: "upstream"})` + +Expected: direct callers = `AgentStateEvaluator`, `PostgresAlertRuleRepository` (JSON deserialization), the rule-evaluator job. Low risk. + +- [ ] **Step 3.2: Edit `AgentStateCondition.java`** + +```java +package com.cameleer.server.core.alerting; + +import com.cameleer.server.core.agent.AgentState; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AgentStateCondition(AlertScope scope, AgentState state, int forSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.AGENT_STATE; } +} +``` + +- [ ] **Step 3.3: Fix `AgentStateEvaluator.java`** + +Read the current file first to find where `c.state()` is used: + +```bash +grep -n "state()" cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java +``` + +Wherever the evaluator compared `state.equals(agent.state().name())` or similar, change to direct enum comparison: `c.state() == agent.state()`. If `c.state()` is passed to a store query, pass `c.state().name()` (the wire form remains unchanged). + +- [ ] **Step 3.4: Compile Java side** + +```bash +mvn -pl cameleer-server-core,cameleer-server-app compile +``` + +Expected: `BUILD SUCCESS`. If `AgentStateEvaluator` or any other caller has type errors, each one points to a specific fix (usually substituting `.name()` or `==`). + +- [ ] **Step 3.5: Run Java tests** + +```bash +mvn -pl cameleer-server-app test +``` + +Expected: all unit tests pass. If a test that constructs `new AgentStateCondition(scope, "DEAD", 30)` fails to compile, update it to `new AgentStateCondition(scope, AgentState.DEAD, 30)`. + +- [ ] **Step 3.6: Regenerate schema** + +```bash +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +``` + +Verify in the regenerated schema: + +```bash +grep -A3 '"AgentStateCondition"' ui/src/api/openapi.json | head -20 +``` + +Expected: `state` property has `"enum": ["LIVE", "STALE", "DEAD", "SHUTDOWN"]`. + +- [ ] **Step 3.7: Update `ui/src/pages/Alerts/enums.ts`** + +Append: + +```typescript +type AgentStateCondition = components['schemas']['AgentStateCondition']; +export type AgentStateValue = NonNullable; + +const AGENT_STATE_LABELS: Record = { + LIVE: 'Live', + STALE: 'Stale', + DEAD: 'Dead', + SHUTDOWN: 'Shutdown', +}; + +const AGENT_STATE_HIDDEN: readonly AgentStateValue[] = [ + 'LIVE', // LIVE agents are the healthy case — never an alerting condition + 'SHUTDOWN', // SHUTDOWN is an operator-initiated state, not an alert-worthy anomaly +]; + +export const AGENT_STATE_OPTIONS: Option[] = toOptions(AGENT_STATE_LABELS, AGENT_STATE_HIDDEN); +``` + +- [ ] **Step 3.8: Update `AgentStateForm.tsx`** + +Read: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx` + +Replace any hardcoded ` + ))} + +``` + +- [ ] **Step 3.9: UI typecheck + test** + +```bash +cd ui && npm run typecheck && npm run test +``` + +Expected: 0 errors. If form-state.ts tests reference `state: 'DEAD'` string literals, update to `AgentState.DEAD` — the TS string union `'LIVE' | 'STALE' | 'DEAD' | 'SHUTDOWN'` still accepts the literal `'DEAD'` so existing fixtures should keep passing without source change. + +- [ ] **Step 3.10: IT sanity check** + +```bash +mvn -pl cameleer-server-app verify -Dit.test='AlertingFullLifecycleIT#*' +``` + +Expected: lifecycle IT passes, including AGENT_STATE rule evaluation. + +- [ ] **Step 3.11: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AgentStateCondition.java +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/AgentStateEvaluator.java +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git add ui/src/pages/Alerts/enums.ts +git add ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx +# + any other fix files touched in 3.5/3.9 +git commit -m "$(cat <<'EOF' +refactor(alerting): AgentStateCondition.state — String to AgentState enum + +Springdoc now emits a closed enum union; UI derives the type and +replaces the hand-maintained dropdown value list with a label map. +@Valid on AlertRuleRequest rejects unknown values at the controller +boundary. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: `DeploymentStateCondition.states` → `List` + +**Why:** Same reasoning as Task 3 — closed vocabulary typed as `List`. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java` +- Modify: `ui/src/pages/Alerts/enums.ts` (append `DEPLOYMENT_STATE_LABELS` + `DEPLOYMENT_STATE_OPTIONS`) +- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx` + +- [ ] **Step 4.1: Impact-check** + +Run: `gitnexus_impact({target: "DeploymentStateCondition", direction: "upstream"})` + +Expected: direct callers = `DeploymentStateEvaluator`, `PostgresAlertRuleRepository`, tests. + +- [ ] **Step 4.2: Edit `DeploymentStateCondition.java`** + +```java +package com.cameleer.server.core.alerting; + +import com.cameleer.server.core.runtime.DeploymentStatus; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record DeploymentStateCondition(AlertScope scope, List states) implements AlertCondition { + public DeploymentStateCondition { states = List.copyOf(states); } + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.DEPLOYMENT_STATE; } +} +``` + +- [ ] **Step 4.3: Fix `DeploymentStateEvaluator.java`** + +Read the current file, find where `states` is used. The evaluator likely compares each `deployment.status()` (already `DeploymentStatus`) against each element of `c.states()`. Change string comparison to enum equality: + +```java +// Before (illustrative): if (c.states().contains(deployment.status().name())) { ... } +// After: if (c.states().contains(deployment.status())) { ... } +``` + +- [ ] **Step 4.4: Compile** + +```bash +mvn -pl cameleer-server-core,cameleer-server-app compile +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 4.5: Run Java tests** + +```bash +mvn -pl cameleer-server-app test +``` + +Expected: all tests pass. Update any test fixture that constructs `new DeploymentStateCondition(scope, List.of("FAILED"))` to `List.of(DeploymentStatus.FAILED)`. + +- [ ] **Step 4.6: Regenerate schema** + +```bash +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +``` + +Verify: + +```bash +grep -A5 '"DeploymentStateCondition"' ui/src/api/openapi.json | head -15 +``` + +Expected: `states` property has `"type": "array", "items": { "enum": ["STOPPED", "STARTING", ...] }`. + +- [ ] **Step 4.7: Update `enums.ts`** + +Append: + +```typescript +type DeploymentStateCondition = components['schemas']['DeploymentStateCondition']; +export type DeploymentStateValue = NonNullable[number]>; + +const DEPLOYMENT_STATE_LABELS: Record = { + STOPPED: 'Stopped', + STARTING: 'Starting', + RUNNING: 'Running', + DEGRADED: 'Degraded', + STOPPING: 'Stopping', + FAILED: 'Failed', +}; + +const DEPLOYMENT_STATE_HIDDEN: readonly DeploymentStateValue[] = [ + 'STOPPED', // A stopped deployment is an operator action, not an alert. + 'STARTING', // Transient. + 'STOPPING', // Transient. + 'RUNNING', // Healthy — never the firing condition. +]; + +export const DEPLOYMENT_STATE_OPTIONS: Option[] = + toOptions(DEPLOYMENT_STATE_LABELS, DEPLOYMENT_STATE_HIDDEN); +``` + +- [ ] **Step 4.8: Update `DeploymentStateForm.tsx`** + +Replace hardcoded option list with a loop over `DEPLOYMENT_STATE_OPTIONS`. Same pattern as AgentStateForm in Step 3.8 but the form is multi-select — adapt accordingly: + +```tsx +import { DEPLOYMENT_STATE_OPTIONS, type DeploymentStateValue } from '../../enums'; + +// In the multi-select, map over DEPLOYMENT_STATE_OPTIONS instead of a hardcoded array +``` + +- [ ] **Step 4.9: UI typecheck + test** + +```bash +cd ui && npm run typecheck && npm run test +``` + +Expected: 0 errors. + +- [ ] **Step 4.10: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/DeploymentStateCondition.java +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git add ui/src/pages/Alerts/enums.ts +git add ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx +git commit -m "$(cat <<'EOF' +refactor(alerting): DeploymentStateCondition.states — List to List + +Same pattern as AgentState migration — typed vocabulary, @Valid +enforcement, UI-side derived options. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: New `LogLevel` enum + migrate `LogPatternCondition.level` + +**Why:** `level` is a closed SLF4J-style vocabulary. `.claude/rules/core-classes.md` needs updating because a new enum lands. + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogLevel.java` +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java` +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java` +- Modify: `ui/src/pages/Alerts/enums.ts` +- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx` +- Modify: `.claude/rules/core-classes.md` + +- [ ] **Step 5.1: Create `LogLevel.java`** + +```java +package com.cameleer.server.core.alerting; + +/** + * SLF4J-compatible log severity levels. Used by LogPatternCondition to filter + * which log rows participate in pattern-match counting. + */ +public enum LogLevel { + TRACE, + DEBUG, + INFO, + WARN, + ERROR +} +``` + +- [ ] **Step 5.2: Edit `LogPatternCondition.java`** + +```java +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record LogPatternCondition( + AlertScope scope, + LogLevel level, + String pattern, + int threshold, + int windowSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.LOG_PATTERN; } +} +``` + +- [ ] **Step 5.3: Fix `LogPatternEvaluator.java`** + +Read the current file. Where it passes `c.level()` to a ClickHouse query or filter predicate, convert to `c.level() == null ? null : c.level().name()` at the query boundary — the `logs` ClickHouse table stores log level as a text column. + +- [ ] **Step 5.4: Compile + test** + +```bash +mvn -pl cameleer-server-core,cameleer-server-app compile +mvn -pl cameleer-server-app test +``` + +Expected: `BUILD SUCCESS`. Fix any test fixture that did `new LogPatternCondition(scope, "ERROR", ...)` → `LogLevel.ERROR`. + +- [ ] **Step 5.5: Regenerate schema** + +```bash +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +grep -A4 '"LogPatternCondition"' ui/src/api/openapi.json | head -15 +``` + +Expected: `level` has `"enum": ["TRACE","DEBUG","INFO","WARN","ERROR"]`. + +- [ ] **Step 5.6: Update `enums.ts`** + +Append: + +```typescript +type LogPatternCondition = components['schemas']['LogPatternCondition']; +export type LogLevelValue = NonNullable; + +const LOG_LEVEL_LABELS: Record = { + ERROR: 'ERROR', + WARN: 'WARN', + INFO: 'INFO', + DEBUG: 'DEBUG', + TRACE: 'TRACE', +}; + +export const LOG_LEVEL_OPTIONS: Option[] = toOptions(LOG_LEVEL_LABELS); +``` + +- [ ] **Step 5.7: Update `LogPatternForm.tsx`** + +Same pattern — replace hardcoded options with `LOG_LEVEL_OPTIONS`. + +- [ ] **Step 5.8: Update `.claude/rules/core-classes.md`** + +Find the `alerting/` section in the file and insert, alphabetically: + +```markdown +- `LogLevel` — enum: `TRACE, DEBUG, INFO, WARN, ERROR`. SLF4J-compatible vocabulary used by `LogPatternCondition.level`. +``` + +- [ ] **Step 5.9: UI typecheck + test** + +```bash +cd ui && npm run typecheck && npm run test +``` + +- [ ] **Step 5.10: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogLevel.java +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/LogPatternCondition.java +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/LogPatternEvaluator.java +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git add ui/src/pages/Alerts/enums.ts +git add ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx +git add .claude/rules/core-classes.md +git commit -m "$(cat <<'EOF' +refactor(alerting): LogPatternCondition.level — String to LogLevel enum + +Adds LogLevel {TRACE, DEBUG, INFO, WARN, ERROR} in core/alerting. +Evaluator passes .name() at the ClickHouse query boundary; wire format +unchanged. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: `ExchangeFilter.status` → `ExecutionStatus` + +**Why:** Closed Camel vocabulary. `ExecutionStatus` already exists in `cameleer-common` — no new enum needed. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java` (nested `ExchangeFilter` record) +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java` +- Modify: `ui/src/pages/Alerts/enums.ts` +- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx` + +- [ ] **Step 6.1: Impact-check** + +Run: `gitnexus_impact({target: "ExchangeMatchCondition", direction: "upstream"})` + +Expected: direct callers = `ExchangeMatchEvaluator`, `PostgresAlertRuleRepository`, `AlertRuleController` validation path. + +- [ ] **Step 6.2: Edit `ExchangeMatchCondition.java`** + +```java +package com.cameleer.server.core.alerting; + +import com.cameleer.common.model.ExecutionStatus; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +public record ExchangeMatchCondition( + AlertScope scope, + ExchangeFilter filter, + FireMode fireMode, + Integer threshold, + Integer windowSeconds, + Integer perExchangeLingerSeconds +) implements AlertCondition { + + public ExchangeMatchCondition { + if (fireMode == null) + throw new IllegalArgumentException("fireMode is required (PER_EXCHANGE or COUNT_IN_WINDOW)"); + if (fireMode == FireMode.COUNT_IN_WINDOW && (threshold == null || windowSeconds == null)) + throw new IllegalArgumentException("COUNT_IN_WINDOW requires threshold + windowSeconds"); + if (fireMode == FireMode.PER_EXCHANGE && perExchangeLingerSeconds == null) + throw new IllegalArgumentException("PER_EXCHANGE requires perExchangeLingerSeconds"); + } + + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.EXCHANGE_MATCH; } + + public record ExchangeFilter(ExecutionStatus status, Map attributes) { + public ExchangeFilter { attributes = attributes == null ? Map.of() : Map.copyOf(attributes); } + } +} +``` + +- [ ] **Step 6.3: Fix `ExchangeMatchEvaluator.java`** + +Read the evaluator. At lines where `filter.status()` is passed to `AlertMatchSpec` or `SearchRequest`, convert at the boundary: + +```java +// Before: filter != null ? filter.status() : null +// After: filter != null && filter.status() != null ? filter.status().name() : null +``` + +Both call sites in the evaluator (around lines 60 and 99 per earlier grep) need this pattern. + +- [ ] **Step 6.4: Compile + test** + +```bash +mvn -pl cameleer-server-core,cameleer-server-app compile +mvn -pl cameleer-server-app test +``` + +Expected: `BUILD SUCCESS`. Fix any test fixture that passed `"FAILED"` → `ExecutionStatus.FAILED`. + +- [ ] **Step 6.5: Regenerate schema** + +```bash +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +grep -A6 '"ExchangeFilter"' ui/src/api/openapi.json +``` + +Expected: `status` has `"enum": ["RUNNING","COMPLETED","FAILED","ABANDONED"]`. + +- [ ] **Step 6.6: Update `enums.ts`** + +Append: + +```typescript +type ExchangeFilter = components['schemas']['ExchangeFilter']; +export type ExchangeStatus = NonNullable; + +const EXCHANGE_STATUS_LABELS: Record = { + COMPLETED: 'Completed', + FAILED: 'Failed', + RUNNING: 'Running', + ABANDONED: 'Abandoned', +}; + +const EXCHANGE_STATUS_HIDDEN: readonly ExchangeStatus[] = [ + // No hidden values — all four are legitimate alerting targets. +]; + +export const EXCHANGE_STATUS_OPTIONS: Option[] = toOptions(EXCHANGE_STATUS_LABELS, EXCHANGE_STATUS_HIDDEN); +``` + +- [ ] **Step 6.7: Update `ExchangeMatchForm.tsx`** + +Replace the status input (likely a text field or hardcoded select) with a dropdown over `EXCHANGE_STATUS_OPTIONS`. + +- [ ] **Step 6.8: UI typecheck + test** + +```bash +cd ui && npm run typecheck && npm run test +``` + +- [ ] **Step 6.9: IT** + +```bash +mvn -pl cameleer-server-app verify -Dit.test='AlertingFullLifecycleIT#*' +``` + +Expected: exchange-match rule lifecycle green. + +- [ ] **Step 6.10: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/ExchangeMatchEvaluator.java +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git add ui/src/pages/Alerts/enums.ts +git add ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx +git commit -m "$(cat <<'EOF' +refactor(alerting): ExchangeFilter.status — String to ExecutionStatus + +Reuses com.cameleer.common.model.ExecutionStatus (RUNNING, COMPLETED, +FAILED, ABANDONED). Evaluator unwraps to .name() at the ClickHouse +query boundary; wire format unchanged. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: `@Schema(allowableValues)` on `JvmMetricCondition.metric` + +**Why:** Spec §6 — field stays `String` (evaluator passes through to Micrometer metric store), but OpenAPI emits a narrowed union so UI gets autocomplete. + +**Files:** +- Modify: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java` +- Modify: `ui/src/pages/Alerts/enums.ts` +- Modify: `ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx` + +- [ ] **Step 7.1: Edit `JvmMetricCondition.java`** + +```java +package com.cameleer.server.core.alerting; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public record JvmMetricCondition( + AlertScope scope, + @Schema( + description = "Agent-reported Micrometer metric name. Any string is accepted at the backend; " + + "the allowable-values list drives UI autocomplete.", + allowableValues = { + "process.cpu.usage.value", + "jvm.memory.used.value", + "jvm.memory.max.value", + "jvm.threads.live.value", + "jvm.gc.pause.total_time" + } + ) + String metric, + AggregationOp aggregation, + Comparator comparator, + double threshold, + int windowSeconds) implements AlertCondition { + @Override + @JsonProperty(value = "kind", access = JsonProperty.Access.READ_ONLY) + public ConditionKind kind() { return ConditionKind.JVM_METRIC; } +} +``` + +- [ ] **Step 7.2: Regenerate schema** + +```bash +mvn -pl cameleer-server-core,cameleer-server-app compile +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +grep -B1 -A2 '"metric"' ui/src/api/openapi.json | grep -A2 JvmMetricCondition +``` + +Expected: `metric` property has `"type": "string"` + `"enum": ["process.cpu.usage.value", ...]`. + +- [ ] **Step 7.3: Update `enums.ts`** + +Append: + +```typescript +type JvmMetricCondition = components['schemas']['JvmMetricCondition']; +export type JvmMetric = NonNullable; + +const JVM_METRIC_LABELS: Record = { + 'process.cpu.usage.value': 'CPU usage (ratio 0–1)', + 'jvm.memory.used.value': 'Heap used (bytes)', + 'jvm.memory.max.value': 'Heap max (bytes)', + 'jvm.threads.live.value': 'Live threads', + 'jvm.gc.pause.total_time': 'GC pause total time (seconds)', +}; + +export const JVM_METRIC_OPTIONS: Option[] = toOptions(JVM_METRIC_LABELS); +``` + +- [ ] **Step 7.4: Update `JvmMetricForm.tsx`** + +Replace the metric-name input with a dropdown backed by `JVM_METRIC_OPTIONS`. Because the backend is permissive, optionally keep a "custom metric name" escape hatch — but if the existing form was a plain dropdown, just swap the option list. + +- [ ] **Step 7.5: Typecheck + tests** + +```bash +cd ui && npm run typecheck && npm run test +``` + +Expected: all green. + +- [ ] **Step 7.6: Commit** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/JvmMetricCondition.java +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git add ui/src/pages/Alerts/enums.ts +git add ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx +git commit -m "$(cat <<'EOF' +refactor(alerting): JvmMetricCondition.metric — @Schema(allowableValues) hint + +Field stays String because JvmMetricEvaluator passes it through to +MetricsQueryStore as a raw Micrometer metric name. The allowableValues +hint narrows the TS type to a union of known metrics so UI can render +an autocomplete-backed dropdown. Backend remains permissive to +accommodate metrics we haven't curated yet. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Context-startup smoke test (unit-test tier, no Testcontainers) + +**Why:** Existing `SpringContextSmokeIT` catches wiring regressions but only runs during `mvn verify` (Testcontainers requires Docker). A parallel unit-test tier that runs in `mvn test` catches them faster in the dev loop. + +**Files:** +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/ContextStartupSmokeTest.java` +- Create: `cameleer-server-app/src/test/resources/application-test-context-smoke.yml` + +- [ ] **Step 8.1: Create the test profile config** + +Create `cameleer-server-app/src/test/resources/application-test-context-smoke.yml`: + +```yaml +# Stubbed minimal config for context-load smoke tests. +# Replaces real infra (DB, CH, Docker) with in-memory / mock beans so the test +# runs in ~5s without Docker. +spring: + datasource: + url: jdbc:h2:mem:smoke;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + flyway: + enabled: false + jpa: + hibernate: + ddl-auto: none + +cameleer: + server: + tenant: + id: smoke-test + security: + jwt-secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + bootstrap-token: "smoke-bootstrap" + cors-allowed-origins: "http://localhost:5173" + clickhouse: + enabled: false + runtime: + enabled: false + outbound-http: + trust-all: true + alerting: + evaluator-tick-interval-ms: 0 # disabled — no scheduled ticks during smoke +``` + +- [ ] **Step 8.2: Create the test** + +Create `cameleer-server-app/src/test/java/com/cameleer/server/app/ContextStartupSmokeTest.java`: + +```java +package com.cameleer.server.app; + +import com.cameleer.server.app.alerting.eval.AgentStateEvaluator; +import com.cameleer.server.app.alerting.eval.ConditionEvaluator; +import com.cameleer.server.app.alerting.eval.DeploymentStateEvaluator; +import com.cameleer.server.app.alerting.eval.ExchangeMatchEvaluator; +import com.cameleer.server.app.alerting.eval.JvmMetricEvaluator; +import com.cameleer.server.app.alerting.eval.LogPatternEvaluator; +import com.cameleer.server.app.alerting.eval.PerKindCircuitBreaker; +import com.cameleer.server.app.alerting.eval.RouteMetricEvaluator; +import com.cameleer.server.app.alerting.metrics.AlertingMetrics; +import com.cameleer.server.app.alerting.notify.MustacheRenderer; +import com.cameleer.server.app.alerting.notify.NotificationContextBuilder; +import com.cameleer.server.app.alerting.notify.NotificationDispatchJob; +import com.cameleer.server.app.alerting.notify.WebhookDispatcher; +import com.cameleer.server.app.alerting.retention.AlertingRetentionJob; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit-tier context-load smoke test: runs under {@code mvn test} (no + * Testcontainers) so wiring regressions surface before they reach the + * slower {@code mvn verify} tier. + * + * Asserts the presence of every public alerting bean. If any alerting + * component loses its @Autowired or its @Component stereotype, context + * construction fails here with a clear bean-name message. + * + * Companion to {@link SpringContextSmokeIT}, which runs the same check + * against a real Postgres + ClickHouse stack via Testcontainers. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test-context-smoke") +class ContextStartupSmokeTest { + + @Autowired ApplicationContext ctx; + + @Test + void alertingBeansAreWired() { + assertThat(ctx.getBean(AlertingMetrics.class)).isNotNull(); + assertThat(ctx.getBean(NotificationContextBuilder.class)).isNotNull(); + assertThat(ctx.getBean(MustacheRenderer.class)).isNotNull(); + assertThat(ctx.getBean(WebhookDispatcher.class)).isNotNull(); + assertThat(ctx.getBean(NotificationDispatchJob.class)).isNotNull(); + assertThat(ctx.getBean(AlertingRetentionJob.class)).isNotNull(); + assertThat(ctx.getBean(PerKindCircuitBreaker.class)).isNotNull(); + + // Each condition kind has a corresponding evaluator bean. + assertThat(ctx.getBean(RouteMetricEvaluator.class)).isNotNull(); + assertThat(ctx.getBean(ExchangeMatchEvaluator.class)).isNotNull(); + assertThat(ctx.getBean(AgentStateEvaluator.class)).isNotNull(); + assertThat(ctx.getBean(DeploymentStateEvaluator.class)).isNotNull(); + assertThat(ctx.getBean(LogPatternEvaluator.class)).isNotNull(); + assertThat(ctx.getBean(JvmMetricEvaluator.class)).isNotNull(); + + // And they all register under the common interface. + assertThat(ctx.getBeansOfType(ConditionEvaluator.class)).hasSize(6); + } +} +``` + +- [ ] **Step 8.3: Add H2 test dependency** + +If `cameleer-server-app/pom.xml` doesn't already declare H2 with `scope=test`, add it. Check: + +```bash +grep -A2 "h2" cameleer-server-app/pom.xml +``` + +If absent, add to the `` section: + +```xml + + com.h2database + h2 + test + +``` + +- [ ] **Step 8.4: Run the test** + +```bash +mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest +``` + +Expected: `Tests run: 1, Failures: 0`. Runtime target: under 10 seconds. + +If it fails on a missing bean (e.g. `BeanCreationException: No qualifying bean of type 'javax.sql.DataSource'`), the test profile in Step 8.1 needs another stub. Read the error, adjust the profile, re-run. + +- [ ] **Step 8.5: Validate the test catches the target regression** + +Temporarily remove `@Autowired` from `AlertingMetrics`'s production constructor: + +```bash +# Edit cameleer-server-app/.../metrics/AlertingMetrics.java line 78: remove "@Autowired" +mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest +``` + +Expected: `FAILED — BeanInstantiationException` or similar, referencing `AlertingMetrics`. + +Revert the change: + +```bash +git checkout cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java +mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest +``` + +Expected: green again. + +- [ ] **Step 8.6: Full test suite** + +```bash +mvn -pl cameleer-server-app test +``` + +Expected: all tests green, no regressions in existing suite. + +- [ ] **Step 8.7: Commit** + +```bash +git add cameleer-server-app/src/test/java/com/cameleer/server/app/ContextStartupSmokeTest.java +git add cameleer-server-app/src/test/resources/application-test-context-smoke.yml +# + cameleer-server-app/pom.xml if H2 dependency was added +git commit -m "$(cat <<'EOF' +test(alerting): Spring context-startup smoke (unit-tier) + +Complements SpringContextSmokeIT (Testcontainers) with a no-infra +smoke that runs under mvn test. Asserts every public alerting bean +is wired. Catches the class of @Autowired/@Component regression that +produced the #141 crashloop and the AlertingMetrics ctor bug. + +Verified by temporarily deleting @Autowired on AlertingMetrics: the +test fails loudly with a BeanInstantiationException. Revert restores +green. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Template-variables registry in `cameleer-server-core` + +**Why:** Single source of truth for Mustache variable metadata. NotificationContextBuilder (Task 10) will consume this registry; the controller (Task 11) exposes it; the UI (Task 12) imports it. + +**Files:** +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/TemplateVariableDescriptor.java` +- Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertTemplateVariables.java` + +- [ ] **Step 9.1: Create `TemplateVariableDescriptor.java`** + +```java +package com.cameleer.server.core.alerting; + +import java.util.List; + +/** + * Metadata for one Mustache template variable available to alert rules. + * + * @param path dotted leaf path, e.g. "alert.firedAt" + * @param type one of: "string", "Instant", "number", "boolean", "url", "uuid" + * @param description human-readable summary rendered in the editor tooltip + * @param example sample value rendered in the autocomplete preview + * @param availableForKinds ConditionKinds where this variable is populated; + * empty list = always populated + * @param mayBeNull true if the value can legitimately be null/empty at render time + */ +public record TemplateVariableDescriptor( + String path, + String type, + String description, + String example, + List availableForKinds, + boolean mayBeNull +) { + public TemplateVariableDescriptor { + availableForKinds = List.copyOf(availableForKinds); + } +} +``` + +- [ ] **Step 9.2: Create `AlertTemplateVariables.java`** + +```java +package com.cameleer.server.core.alerting; + +import java.util.List; + +/** + * Static registry of every Mustache variable the {@link com.cameleer.server.app.alerting.notify.NotificationContextBuilder} + * can populate. This is the single source of truth: both the context builder + * and the UI consume this list (via {@code GET /api/v1/alerts/template-variables}). + * + * Invariant: for every descriptor in {@link #ALL}, either + * (a) the builder always emits it, or + * (b) the builder emits it for exactly those {@code availableForKinds}. + * + * {@code NotificationContextBuilderRegistryTest} enforces this structurally. + */ +public final class AlertTemplateVariables { + + private AlertTemplateVariables() {} + + public static final List ALL = List.of( + // --- Always available --- + new TemplateVariableDescriptor("env.slug", "string", "Environment slug", "prod", List.of(), false), + new TemplateVariableDescriptor("env.id", "uuid", "Environment UUID", "00000000-0000-0000-0000-000000000001", List.of(), false), + new TemplateVariableDescriptor("rule.id", "uuid", "Rule UUID", "11111111-1111-1111-1111-111111111111", List.of(), false), + new TemplateVariableDescriptor("rule.name", "string", "Rule display name", "Order API error rate", List.of(), false), + new TemplateVariableDescriptor("rule.severity", "string", "Rule severity", "CRITICAL", List.of(), false), + new TemplateVariableDescriptor("rule.description", "string", "Rule description", "Paging ops if error rate >5%", List.of(), false), + new TemplateVariableDescriptor("alert.id", "uuid", "Alert instance UUID", "22222222-2222-2222-2222-222222222222", List.of(), false), + new TemplateVariableDescriptor("alert.state", "string", "Alert state", "FIRING", List.of(), false), + new TemplateVariableDescriptor("alert.firedAt", "Instant", "When the alert fired", "2026-04-20T14:33:10Z", List.of(), false), + new TemplateVariableDescriptor("alert.resolvedAt", "Instant", "When the alert resolved", "2026-04-20T14:45:00Z", List.of(), true), + new TemplateVariableDescriptor("alert.ackedBy", "string", "User who acknowledged the alert", "alice", List.of(), true), + new TemplateVariableDescriptor("alert.link", "url", "UI link to this alert", "https://cameleer.example.com/alerts/inbox/2222...", List.of(), false), + new TemplateVariableDescriptor("alert.currentValue", "number", "Observed metric value", "0.12", List.of(), true), + new TemplateVariableDescriptor("alert.threshold", "number", "Rule threshold", "0.05", List.of(), true), + + // --- app subtree — all condition kinds --- + new TemplateVariableDescriptor("app.slug", "string", "App slug", "orders", + List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH, ConditionKind.AGENT_STATE, + ConditionKind.DEPLOYMENT_STATE, ConditionKind.LOG_PATTERN, ConditionKind.JVM_METRIC), true), + new TemplateVariableDescriptor("app.id", "uuid", "App UUID", "33333333-3333-3333-3333-333333333333", + List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH, ConditionKind.AGENT_STATE, + ConditionKind.DEPLOYMENT_STATE, ConditionKind.LOG_PATTERN, ConditionKind.JVM_METRIC), true), + + // --- ROUTE_METRIC + EXCHANGE_MATCH --- + new TemplateVariableDescriptor("route.id", "string", "Route ID", "route-1", + List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH), false), + new TemplateVariableDescriptor("route.uri", "string", "Route URI", "direct:orders", + List.of(ConditionKind.ROUTE_METRIC, ConditionKind.EXCHANGE_MATCH), false), + + // --- EXCHANGE_MATCH --- + new TemplateVariableDescriptor("exchange.id", "string", "Exchange ID", "exch-ab12", + List.of(ConditionKind.EXCHANGE_MATCH), false), + new TemplateVariableDescriptor("exchange.status", "string", "Exchange status", "FAILED", + List.of(ConditionKind.EXCHANGE_MATCH), false), + + // --- AGENT_STATE + JVM_METRIC share agent.id/name --- + new TemplateVariableDescriptor("agent.id", "string", "Agent instance ID", "prod-orders-0", + List.of(ConditionKind.AGENT_STATE, ConditionKind.JVM_METRIC), false), + new TemplateVariableDescriptor("agent.name", "string", "Agent display name", "orders-0", + List.of(ConditionKind.AGENT_STATE, ConditionKind.JVM_METRIC), false), + // AGENT_STATE adds agent.state + new TemplateVariableDescriptor("agent.state", "string", "Agent state", "DEAD", + List.of(ConditionKind.AGENT_STATE), false), + + // --- DEPLOYMENT_STATE --- + new TemplateVariableDescriptor("deployment.id", "uuid", "Deployment UUID", "44444444-4444-4444-4444-444444444444", + List.of(ConditionKind.DEPLOYMENT_STATE), false), + new TemplateVariableDescriptor("deployment.status", "string", "Deployment status", "FAILED", + List.of(ConditionKind.DEPLOYMENT_STATE), false), + + // --- LOG_PATTERN --- + new TemplateVariableDescriptor("log.pattern", "string", "Matched log pattern", "TimeoutException", + List.of(ConditionKind.LOG_PATTERN), false), + new TemplateVariableDescriptor("log.matchCount", "number", "Number of matches in window", "7", + List.of(ConditionKind.LOG_PATTERN), false), + + // --- JVM_METRIC --- + new TemplateVariableDescriptor("metric.name", "string", "Metric name", "jvm.memory.used.value", + List.of(ConditionKind.JVM_METRIC), false), + new TemplateVariableDescriptor("metric.value", "number", "Metric value", "92.1", + List.of(ConditionKind.JVM_METRIC), false) + ); +} +``` + +- [ ] **Step 9.3: Compile** + +```bash +mvn -pl cameleer-server-core compile +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 9.4: Commit (Task 9 lands standalone — registry without consumer yet)** + +Defer the commit until Task 10 lands the consumer — committing the registry without a use site leaves the repo in a weird "declared but unused" state. Continue to Task 10. + +--- + +## Task 10: Refactor `NotificationContextBuilder` to consume registry + round-trip test + +**Why:** Structural guarantee that the registry and the builder agree. Prevents the drift bug that `18e6dde6` fixed. + +**Files:** +- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java` +- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderRegistryTest.java` + +- [ ] **Step 10.1: Impact-check** + +Run: `gitnexus_impact({target: "NotificationContextBuilder", direction: "upstream"})` + +Expected: direct callers = `MustacheRenderer`, `WebhookDispatcher`, test fixtures. Refactor preserves the public signature `build(rule, instance, env, uiOrigin)`, so no caller changes. + +- [ ] **Step 10.2: Write the failing round-trip test first (TDD)** + +Create `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderRegistryTest.java`: + +```java +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.core.alerting.AlertCondition; +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.AlertScope; +import com.cameleer.server.core.alerting.AlertSeverity; +import com.cameleer.server.core.alerting.AlertState; +import com.cameleer.server.core.alerting.AlertTemplateVariables; +import com.cameleer.server.core.alerting.ConditionKind; +import com.cameleer.server.core.alerting.RouteMetricCondition; +import com.cameleer.server.core.alerting.TemplateVariableDescriptor; +import com.cameleer.server.core.runtime.Environment; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Structural round-trip between {@link AlertTemplateVariables#ALL} and + * {@link NotificationContextBuilder#build}: + * + * (a) every variable listed in ALL whose availableForKinds is empty + * (=always) must be present in the built context for every kind; + * (b) every variable listed for a specific kind must be present in the + * built context when building for that kind; + * (c) no variable may appear in the built context that is not listed + * in ALL — catches builder-added-a-leaf-without-registering drift. + */ +class NotificationContextBuilderRegistryTest { + + private final NotificationContextBuilder builder = new NotificationContextBuilder(); + + @Test + @DisplayName("every always-available variable is present for every condition kind") + void alwaysVariablesPresentForAllKinds() { + List alwaysPaths = AlertTemplateVariables.ALL.stream() + .filter(v -> v.availableForKinds().isEmpty()) + .map(TemplateVariableDescriptor::path) + .toList(); + + for (ConditionKind kind : ConditionKind.values()) { + Map ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test"); + Map flattened = flatten(ctx); + for (String path : alwaysPaths) { + assertThat(flattened.keySet()) + .as("always-available variable %s missing from %s context", path, kind) + .contains(path); + } + } + } + + @Test + @DisplayName("kind-specific variables are present for their kind") + void kindSpecificVariablesPresent() { + for (ConditionKind kind : ConditionKind.values()) { + List expectedPaths = AlertTemplateVariables.ALL.stream() + .filter(v -> v.availableForKinds().contains(kind)) + .map(TemplateVariableDescriptor::path) + .toList(); + + Map ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test"); + Map flattened = flatten(ctx); + for (String path : expectedPaths) { + assertThat(flattened.keySet()) + .as("variable %s missing from %s context", path, kind) + .contains(path); + } + } + } + + @Test + @DisplayName("no leaf appears in the built context unless listed in the registry") + void noExtraLeaves() { + Set registered = AlertTemplateVariables.ALL.stream() + .map(TemplateVariableDescriptor::path) + .collect(Collectors.toSet()); + + for (ConditionKind kind : ConditionKind.values()) { + Map ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test"); + Set flattened = flatten(ctx).keySet(); + Set extras = flattened.stream() + .filter(p -> !registered.contains(p)) + .collect(Collectors.toSet()); + assertThat(extras) + .as("kind=%s built context has leaves missing from AlertTemplateVariables.ALL", kind) + .isEmpty(); + } + } + + // ── fixtures ──────────────────────────────────────────────────────── + + private Environment env() { + return new Environment(UUID.fromString("00000000-0000-0000-0000-000000000001"), "prod", 10); + } + + private AlertRule ruleFor(ConditionKind kind) { + AlertCondition condition = new RouteMetricCondition( + new AlertScope("orders", "route-1", null), + com.cameleer.server.core.alerting.RouteMetric.ERROR_RATE, + com.cameleer.server.core.alerting.Comparator.GT, + 0.05, + 60 + ); + return new AlertRule( + UUID.fromString("11111111-1111-1111-1111-111111111111"), + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "test rule", + "desc", + AlertSeverity.CRITICAL, + true, + kind, + condition, + 60, 0, 0, "", "", + List.of(), List.of(), + Instant.now(), "admin", + Instant.now(), "admin", + Map.of() + ); + } + + private AlertInstance instanceFor(ConditionKind kind) { + Map ctx = new LinkedHashMap<>(); + ctx.put("app", Map.of("slug", "orders", "id", "33333333-3333-3333-3333-333333333333")); + ctx.put("route", Map.of("id", "route-1", "uri", "direct:orders")); + ctx.put("exchange", Map.of("id", "exch-ab12", "status", "FAILED")); + ctx.put("agent", Map.of("id", "prod-0", "name", "orders-0", "state", "DEAD")); + ctx.put("deployment", Map.of("id", "44444444-4444-4444-4444-444444444444", "status", "FAILED")); + ctx.put("log", Map.of("pattern", "TimeoutException", "matchCount", "7")); + ctx.put("metric", Map.of("name", "jvm.memory.used.value", "value", "92.1")); + + return new AlertInstance( + UUID.fromString("22222222-2222-2222-2222-222222222222"), + UUID.fromString("11111111-1111-1111-1111-111111111111"), + UUID.fromString("00000000-0000-0000-0000-000000000001"), + "fp", + AlertState.FIRING, + Instant.now(), null, null, "admin", null, + 0.12, 0.05, + ctx, + Map.of() + ); + } + + /** Flattens a nested Map into dot-separated leaf paths. */ + @SuppressWarnings("unchecked") + private static Map flatten(Map src) { + Map out = new HashMap<>(); + flattenInto("", src, out); + return out; + } + + @SuppressWarnings("unchecked") + private static void flattenInto(String prefix, Map src, Map out) { + for (Map.Entry e : src.entrySet()) { + String key = prefix.isEmpty() ? e.getKey() : prefix + "." + e.getKey(); + if (e.getValue() instanceof Map m) { + flattenInto(key, (Map) m, out); + } else { + out.put(key, e.getValue()); + } + } + } +} +``` + +- [ ] **Step 10.3: Run — expect failure (no extra leaves pass, always/kind-specific likely pass too if builder is already aligned)** + +```bash +mvn -pl cameleer-server-app test -Dtest=NotificationContextBuilderRegistryTest +``` + +If all three tests pass first-try: the registry in Step 9.2 already matches the builder. Skip Step 10.4. Otherwise, the failing test points to the exact drift. + +- [ ] **Step 10.4: Align builder (or registry) until tests pass** + +Read the test failure message. It names one of: +- **Missing from built context** → the builder is silent on a path the registry lists. Either add it to the builder or remove from the registry (if you added it speculatively). +- **Extra leaf in built context** → the builder emits a path the registry doesn't know about. Add it to the registry. + +Iterate in the file whose contents match reality. Keep both in sync. + +- [ ] **Step 10.5: Run all alerting unit tests** + +```bash +mvn -pl cameleer-server-app test -Dtest='com.cameleer.server.app.alerting.*' +``` + +Expected: all green. + +- [ ] **Step 10.6: Commit (Tasks 9 + 10 together)** + +```bash +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/TemplateVariableDescriptor.java +git add cameleer-server-core/src/main/java/com/cameleer/server/core/alerting/AlertTemplateVariables.java +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilder.java +git add cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/NotificationContextBuilderRegistryTest.java +git commit -m "$(cat <<'EOF' +feat(alerting): AlertTemplateVariables registry + builder round-trip test + +AlertTemplateVariables.ALL is the single source of truth for Mustache +variable metadata. NotificationContextBuilderRegistryTest enforces +three structural invariants: + +1. every always-available variable is present for every kind +2. every kind-specific variable is present when building for that kind +3. the builder never emits a leaf the registry doesn't know about + +Breaks the UI↔backend drift class that commit 18e6dde6 patched +one-shot. Future drift fails at mvn test time. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: `AlertTemplateVariablesController` + rules update + +**Why:** Expose the registry via REST so the UI can consume it. Flat endpoint (registry is tenant + env agnostic). + +**Files:** +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/TemplateVariableDto.java` +- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesController.java` +- Modify: `.claude/rules/app-classes.md` (add to flat allow-list) + +- [ ] **Step 11.1: Create `TemplateVariableDto.java`** + +```java +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.core.alerting.ConditionKind; + +import java.util.List; + +/** + * REST-facing DTO for /api/v1/alerts/template-variables. Mirrors + * {@code TemplateVariableDescriptor} 1:1 but kept as a distinct type so the + * public API shape is not coupled to the core record if the latter evolves. + */ +public record TemplateVariableDto( + String path, + String type, + String description, + String example, + List availableForKinds, + boolean mayBeNull +) {} +``` + +- [ ] **Step 11.2: Create `AlertTemplateVariablesController.java`** + +```java +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.alerting.notify.TemplateVariableDto; +import com.cameleer.server.core.alerting.AlertTemplateVariables; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.util.List; + +/** + * Exposes {@link AlertTemplateVariables#ALL} as a REST endpoint so the UI + * MustacheEditor can source its variable registry from the server (single + * source of truth with {@code NotificationContextBuilder}). + * + * Flat — the registry is tenant- and env-agnostic global metadata. + */ +@RestController +@RequestMapping("/api/v1/alerts/template-variables") +public class AlertTemplateVariablesController { + + @Operation(summary = "List Mustache template variables available in alert rules") + @PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") + @GetMapping + public ResponseEntity> list() { + List body = AlertTemplateVariables.ALL.stream() + .map(v -> new TemplateVariableDto( + v.path(), v.type(), v.description(), v.example(), + v.availableForKinds(), v.mayBeNull())) + .toList(); + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic()) + .body(body); + } +} +``` + +- [ ] **Step 11.3: Update `.claude/rules/app-classes.md`** + +Find the "Flat-endpoint allow-list" table and add a row (keep alphabetical): + +```markdown +| `/api/v1/alerts/template-variables` | Registry of Mustache variables is tenant + env-agnostic global metadata. | +``` + +Also add a bullet under the "Alerting" section of controllers (find it under the env-scoped section): + +```markdown +- `AlertTemplateVariablesController` — GET `/api/v1/alerts/template-variables` (flat). VIEWER+. Returns `List` mirroring `AlertTemplateVariables.ALL`. Response cached 1h via `Cache-Control: public, max-age=3600` — registry is deterministic per server version. +``` + +- [ ] **Step 11.4: Compile** + +```bash +mvn -pl cameleer-server-app compile +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 11.5: Write a controller integration test** + +Create `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesControllerIT.java`: + +```java +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertTemplateVariablesControllerIT extends AbstractPostgresIT { + + @Autowired TestSecurityHelper security; + @Autowired ObjectMapper objectMapper; + @Value("${local.server.port}") int port; + + @Test + void viewerCanListTemplateVariables() throws Exception { + HttpEntity auth = new HttpEntity<>(security.bearer("alice", "VIEWER")); + ResponseEntity resp = new RestTemplate().exchange( + "http://localhost:" + port + "/api/v1/alerts/template-variables", + HttpMethod.GET, auth, String.class); + + assertThat(resp.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(resp.getHeaders().getCacheControl()).contains("max-age=3600"); + + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.isArray()).isTrue(); + assertThat(body.size()).isGreaterThan(20); // 26+ variables in the registry + + // Spot-check a known always-available variable. + boolean hasRuleName = false; + for (JsonNode v : body) { + if ("rule.name".equals(v.get("path").asText())) { + hasRuleName = true; + assertThat(v.get("type").asText()).isEqualTo("string"); + assertThat(v.get("availableForKinds").isArray()).isTrue(); + assertThat(v.get("availableForKinds").size()).isEqualTo(0); + break; + } + } + assertThat(hasRuleName).isTrue(); + } +} +``` + +- [ ] **Step 11.6: Run IT** + +```bash +mvn -pl cameleer-server-app verify -Dit.test=AlertTemplateVariablesControllerIT +``` + +Expected: green. + +**If `TestSecurityHelper.bearer(String, String)` doesn't exist with that signature:** read `cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java` and adapt the call to whatever factory method issues a bearer token for a given role. Existing controller ITs use the same helper — copy their pattern. + +- [ ] **Step 11.7: Regenerate schema** + +```bash +mvn -pl cameleer-server-app spring-boot:run & +SERVER_PID=$! +until curl -fsS http://localhost:8081/api/v1/health >/dev/null 2>&1; do sleep 2; done +cd ui && npm run generate-api:live +cd .. +kill $SERVER_PID +grep -A2 "/api/v1/alerts/template-variables" ui/src/api/openapi.json | head -10 +``` + +Expected: endpoint listed, returns `List`. + +- [ ] **Step 11.8: Commit** + +```bash +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/TemplateVariableDto.java +git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesController.java +git add cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertTemplateVariablesControllerIT.java +git add .claude/rules/app-classes.md +git add ui/src/api/openapi.json ui/src/api/schema.d.ts +git commit -m "$(cat <<'EOF' +feat(alerting): template-variables SSOT endpoint + +GET /api/v1/alerts/template-variables returns AlertTemplateVariables.ALL +as the single source of truth the UI consumes (replaces the hand- +maintained ui/src/components/MustacheEditor/alert-variables.ts in a +follow-up commit). + +Flat endpoint — registry is tenant + env-agnostic global metadata. +VIEWER+. 1-hour Cache-Control because the registry is deterministic +per server version. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: UI — consume template-variables, delete the duplicate + +**Why:** Wire the UI to the SSOT endpoint. Remove the hand-maintained `alert-variables.ts`. + +**Files:** +- Create: `ui/src/api/queries/alertTemplateVariables.ts` +- Modify: `ui/src/components/MustacheEditor/MustacheEditor.tsx` +- Modify: `ui/src/components/MustacheEditor/mustache-completion.ts` +- Modify: `ui/src/components/MustacheEditor/mustache-linter.ts` +- Modify: `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx` +- Modify: any other consumer of `ALERT_VARIABLES` (grep to find; expect at least webhook body editor + connection editor if one exists) +- Delete: `ui/src/components/MustacheEditor/alert-variables.ts` + +- [ ] **Step 12.1: Find every consumer of the old registry** + +```bash +grep -rn "alert-variables\|ALERT_VARIABLES\|availableVariables\|extractReferences\|unknownReferences" ui/src --include="*.ts" --include="*.tsx" +``` + +Record every file that appears. Each is a call site to update. + +- [ ] **Step 12.2: Create `useTemplateVariables` hook** + +Create `ui/src/api/queries/alertTemplateVariables.ts`: + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../client'; +import type { components } from '../schema'; + +export type TemplateVariable = components['schemas']['TemplateVariableDto']; + +/** + * Mustache template variables available in alert rules — fetched from + * {@code GET /api/v1/alerts/template-variables}. Cached for the session + * (staleTime: Infinity) because the registry is deterministic per + * server version and the SPA is freshly loaded on each deploy. + */ +export function useTemplateVariables() { + return useQuery({ + queryKey: ['alert-template-variables'], + queryFn: async () => { + const { data, error } = await apiClient.GET('/api/v1/alerts/template-variables'); + if (error) throw error; + return data ?? []; + }, + staleTime: Infinity, + gcTime: Infinity, + }); +} + +/** + * Filter variables to those available for the given ConditionKind. + * If kind is undefined (e.g. OutboundConnection editor URL field), + * returns only always-available variables. + */ +export function availableVariables( + all: readonly TemplateVariable[], + kind: components['schemas']['AlertRuleRequest']['conditionKind'] | undefined, + opts: { reducedContext?: boolean } = {}, +): TemplateVariable[] { + if (opts.reducedContext) { + return all.filter((v) => v.path.startsWith('env.')); + } + if (!kind) { + return all.filter((v) => (v.availableForKinds ?? []).length === 0); + } + return all.filter( + (v) => (v.availableForKinds ?? []).length === 0 + || (v.availableForKinds ?? []).includes(kind), + ); +} + +/** Parse `{{path}}` references from a Mustache template. + * Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}`. */ +export function extractReferences(template: string): string[] { + const out: string[] = []; + const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g; + let m; + while ((m = re.exec(template)) !== null) out.push(m[1]); + return out; +} + +/** Find references in a template that are not in the allowed-variable set. */ +export function unknownReferences( + template: string, + allowed: readonly TemplateVariable[], +): string[] { + const allowedSet = new Set(allowed.map((v) => v.path)); + return extractReferences(template).filter((r) => !allowedSet.has(r)); +} +``` + +- [ ] **Step 12.3: Update `MustacheEditor.tsx`** + +Read the current file: + +```bash +cat ui/src/components/MustacheEditor/MustacheEditor.tsx | head -40 +``` + +Replace the import of `alert-variables` with a prop. The `MustacheEditor` interface goes from: + +```typescript +// BEFORE +interface Props { + value: string; + onChange: (v: string) => void; + conditionKind?: ConditionKind; + // ... other props +} +``` + +to: + +```typescript +// AFTER +import type { TemplateVariable } from '../../api/queries/alertTemplateVariables'; +interface Props { + value: string; + onChange: (v: string) => void; + variables: readonly TemplateVariable[]; // <-- new required prop + // ... other props +} +``` + +Pass `variables` into the completion + linter extension factories (see Steps 12.4 and 12.5). + +- [ ] **Step 12.4: Update `mustache-completion.ts`** + +Change the module from exporting a completion source constant to exporting a factory that takes the variable list: + +```typescript +import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import type { TemplateVariable } from '../../api/queries/alertTemplateVariables'; + +export function mustacheCompletion(variables: readonly TemplateVariable[]) { + return (context: CompletionContext): CompletionResult | null => { + const word = context.matchBefore(/\{\{[a-zA-Z0-9_.]*/); + if (!word) return null; + return { + from: word.from + 2, // skip the `{{` + options: variables.map((v) => ({ + label: v.path, + info: `${v.type} — ${v.description} (e.g. ${v.example})`, + type: 'variable', + })), + }; + }; +} +``` + +(Adapt to the exact existing shape; preserve any fuzzy-match or boost logic it already had.) + +- [ ] **Step 12.5: Update `mustache-linter.ts`** + +Same pattern — function of the variable list: + +```typescript +import { Diagnostic, linter } from '@codemirror/lint'; +import type { TemplateVariable } from '../../api/queries/alertTemplateVariables'; +import { unknownReferences } from '../../api/queries/alertTemplateVariables'; + +export function mustacheLinter(variables: readonly TemplateVariable[]) { + return linter((view) => { + const diagnostics: Diagnostic[] = []; + const src = view.state.doc.toString(); + const unknowns = unknownReferences(src, variables); + // Map each unknown reference to its position in the doc + const re = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g; + let m; + while ((m = re.exec(src)) !== null) { + if (unknowns.includes(m[1])) { + diagnostics.push({ + from: m.index, + to: m.index + m[0].length, + severity: 'error', + message: `Unknown template variable: ${m[1]}`, + }); + } + } + return diagnostics; + }); +} +``` + +- [ ] **Step 12.6: Update `NotifyStep.tsx`** + +```tsx +import { useTemplateVariables, availableVariables } from '../../../api/queries/alertTemplateVariables'; + +// Inside the component: +const { data: allVariables = [] } = useTemplateVariables(); +const visible = availableVariables(allVariables, form.conditionKind); + +// Wherever was rendered without a variables prop: + + +``` + +- [ ] **Step 12.7: Update every other consumer found in Step 12.1** + +For each file, import `useTemplateVariables` + `availableVariables` and pass `variables` to ``. If a consumer is in the outbound-connection admin editor (where no `conditionKind` exists), pass `availableVariables(allVariables, undefined, { reducedContext: true })`. + +- [ ] **Step 12.8: Delete the duplicate** + +```bash +rm ui/src/components/MustacheEditor/alert-variables.ts +``` + +Grep-verify: + +```bash +grep -rn "alert-variables" ui/src --include="*.ts" --include="*.tsx" +``` + +Expected: no output. If anything remains, that file still imports the deleted module and must be fixed. + +- [ ] **Step 12.9: Typecheck + tests** + +```bash +cd ui && npm run typecheck +cd ui && npm run test +``` + +Expected: 0 errors, all unit tests green. Vitest tests that mocked `ALERT_VARIABLES` must be updated to mock `useTemplateVariables` instead — use `vi.mock('../../api/queries/alertTemplateVariables', ...)`. + +- [ ] **Step 12.10: E2E sanity** + +```bash +cd ui && npm run dev & +DEV_PID=$! +sleep 10 +cd ui && npm run test:e2e -- alerting.spec.ts +kill $DEV_PID +``` + +Expected: the existing Plan 03 smoke still passes (it touches MustacheEditor indirectly via NotifyStep). + +- [ ] **Step 12.11: Commit** + +```bash +git add ui/src/api/queries/alertTemplateVariables.ts +git add ui/src/components/MustacheEditor/MustacheEditor.tsx +git add ui/src/components/MustacheEditor/mustache-completion.ts +git add ui/src/components/MustacheEditor/mustache-linter.ts +git add ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx +# + every other file from Step 12.7 +git rm ui/src/components/MustacheEditor/alert-variables.ts +git commit -m "$(cat <<'EOF' +refactor(ui/alerts): consume template-variables via API + +useTemplateVariables() replaces the hand-maintained +ui/src/components/MustacheEditor/alert-variables.ts registry. The +backend is now the single source of truth — any new context leaf +surfaces in the UI on next SPA reload, no manual alignment. + +MustacheEditor, mustache-completion, and mustache-linter are now +functions of the variables list instead of importing a global +module. Consumers pass in the filtered list from availableVariables(). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: Second Playwright spec — editor, MustacheEditor, promotion, inbox + +**Why:** Plan 03's smoke only covers CREATE + DELETE. This spec adds the coverage gaps from spec §5. + +**Files:** +- Create: `ui/src/test/e2e/alerting-editor.spec.ts` + +- [ ] **Step 13.1: Read the existing spec for patterns** + +```bash +cat ui/src/test/e2e/alerting.spec.ts +``` + +Note the fixture shape (`./fixtures`), selectors used for the wizard, how the seeded auth works. Mirror these. + +- [ ] **Step 13.2: Create `alerting-editor.spec.ts`** + +```typescript +import { test, expect, APIRequestContext } from './fixtures'; + +/** + * Plan 04 alerting editor coverage. + * + * Complements alerting.spec.ts with the paths that wizard CREATE + DELETE + * don't exercise: + * - rule EDIT round-trip + * - MustacheEditor autocomplete + linter in-browser + * - env-promotion warnings banner + * - inbox ack + * - bulk-read + * + * End-to-end fire→notify dispatch is covered server-side by + * AlertingFullLifecycleIT; asserting it from the UI would require + * injecting executions into ClickHouse, which is out of scope here. + */ + +async function seedRule(api: APIRequestContext, envSlug: string, overrides: Record = {}) { + const body = { + name: `e2e edit ${Date.now()}`, + severity: 'WARNING', + conditionKind: 'ROUTE_METRIC', + condition: { + scope: { appSlug: 'demo-app', routeId: 'route-1' }, + metric: 'ERROR_RATE', + comparator: 'GT', + threshold: 0.05, + windowSeconds: 60, + }, + evaluationIntervalSeconds: 30, + forDurationSeconds: 0, + reNotifyMinutes: 0, + notificationTitleTmpl: 'Alert: {{rule.name}}', + notificationMessageTmpl: 'Threshold exceeded', + webhooks: [], + targets: [], + ...overrides, + }; + const r = await api.post(`/api/v1/environments/${envSlug}/alerts/rules`, { data: body }); + expect(r.ok()).toBeTruthy(); + return await r.json(); +} + +async function seedFiringAlert(api: APIRequestContext, envSlug: string, ruleId: string) { + // The alerting subsystem manufactures alerts from evaluator ticks; for + // inbox tests we poke the test-evaluate endpoint that Plan 02 shipped, + // which synthesises an instance without waiting for the scheduler. + const r = await api.post(`/api/v1/environments/${envSlug}/alerts/rules/${ruleId}/test-evaluate`, { + data: { synthesize: true }, + }); + expect(r.ok()).toBeTruthy(); + return await r.json(); +} + +test.describe('alerting editor', () => { + test('rule EDIT round-trip persists changes', async ({ page, api, envSlug }) => { + const rule = await seedRule(api, envSlug); + await page.goto(`/alerts/rules/${rule.id}`); + + // Step to condition — change threshold + await page.getByRole('button', { name: /^condition$/i }).click(); + const thresholdInput = page.getByLabel(/threshold/i); + await thresholdInput.fill('0.15'); + + await page.getByRole('button', { name: /^save$/i }).click(); + await expect(page.getByText(/rule saved/i)).toBeVisible({ timeout: 5_000 }); + + // Reload — assert persisted + await page.reload(); + await page.getByRole('button', { name: /^condition$/i }).click(); + await expect(page.getByLabel(/threshold/i)).toHaveValue('0.15'); + }); + + test('MustacheEditor autocomplete inserts a registry variable', async ({ page, api, envSlug }) => { + await page.goto('/alerts/rules/new'); + await page.getByPlaceholder('Order API error rate').fill('autocomplete test'); + await page.getByRole('button', { name: /^next$/i }).click(); // scope → condition + await page.getByRole('button', { name: /^next$/i }).click(); // condition → trigger + await page.getByRole('button', { name: /^next$/i }).click(); // trigger → notify + + const titleField = page.getByLabel(/notification title/i); + await titleField.click(); + await titleField.fill(''); + await titleField.type('{{'); + + // CodeMirror renders the completion popover as a role=listbox. + const popover = page.locator('.cm-tooltip-autocomplete'); + await expect(popover).toBeVisible({ timeout: 2_000 }); + await expect(popover).toContainText('rule.name'); + + // Select the first option. + await page.keyboard.press('Enter'); + await expect(titleField).toHaveValue('{{rule.name}}'); + }); + + test('MustacheEditor linter flags unknown variables', async ({ page }) => { + await page.goto('/alerts/rules/new'); + await page.getByPlaceholder('Order API error rate').fill('linter test'); + for (let i = 0; i < 3; i++) await page.getByRole('button', { name: /^next$/i }).click(); + + const titleField = page.getByLabel(/notification title/i); + await titleField.click(); + await titleField.fill('{{alert.bogus_variable}}'); + + // Linter diagnostics render as .cm-diagnostic elements with aria role=alert. + const diagnostic = page.locator('.cm-diagnostic-error'); + await expect(diagnostic).toBeVisible({ timeout: 2_000 }); + await expect(diagnostic).toContainText(/unknown template variable/i); + }); + + test('env-promotion warns about missing route', async ({ page, api, envSlug }) => { + // Seed rule in the current env referencing a route that doesn't exist + // in the target env. + const rule = await seedRule(api, envSlug, { + condition: { + scope: { appSlug: 'demo-app', routeId: 'route-does-not-exist-in-staging' }, + metric: 'ERROR_RATE', + comparator: 'GT', + threshold: 0.05, + windowSeconds: 60, + }, + }); + + await page.goto('/alerts/rules'); + // Find the rule row and trigger promotion. + const row = page.getByText(rule.name).locator('xpath=ancestor::tr'); + await row.getByRole('button', { name: /promote/i }).click(); + await page.getByRole('menuitem', { name: /staging/i }).click(); + + // Wizard opens prefilled with a warning banner. + await expect(page.getByRole('alert').filter({ hasText: /route.*not.*found|not found in/i })) + .toBeVisible({ timeout: 5_000 }); + }); + + test('inbox ack flips state chip', async ({ page, api, envSlug }) => { + const rule = await seedRule(api, envSlug); + const alert = await seedFiringAlert(api, envSlug, rule.id); + + await page.goto('/alerts/inbox'); + const row = page.getByTestId(`alert-row-${alert.id}`); + await expect(row).toBeVisible(); + await expect(row.getByText('FIRING')).toBeVisible(); + + await row.getByRole('button', { name: /^ack/i }).click(); + await expect(row.getByText('ACKNOWLEDGED')).toBeVisible({ timeout: 5_000 }); + await expect(row.getByRole('button', { name: /^ack/i })).toBeDisabled(); + }); + + test('bulk-read zeroes the notification-bell count', async ({ page, api, envSlug }) => { + const rule = await seedRule(api, envSlug); + for (let i = 0; i < 3; i++) await seedFiringAlert(api, envSlug, rule.id); + + await page.goto('/alerts/inbox'); + const bell = page.getByTestId('notification-bell'); + await expect(bell).toContainText(/^[3-9]|\d{2,}/); + + await page.getByRole('checkbox', { name: /^select all/i }).check(); + await page.getByRole('button', { name: /mark selected as read/i }).click(); + + await expect(bell).not.toContainText(/^\d+$/, { timeout: 10_000 }); // unread count hidden or 0 + }); +}); +``` + +**If any `getByTestId` selector doesn't match a rendered attribute** (e.g. `alert-row-` or `notification-bell`): update the rendering component in a minimal follow-up — Plan 03 did not standardise these, so one or two `data-testid="..."` attribute adds are acceptable here. They're single-line edits and belong in this same commit. + +- [ ] **Step 13.3: Check seed endpoint exists** + +The seed helper uses `POST /rules/{id}/test-evaluate` (Plan 02). If `synthesize:true` isn't supported — read the controller: + +```bash +grep -n "test-evaluate" cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertRuleController.java +``` + +If the existing endpoint doesn't synthesise alerts the test expects, skip inbox + bulk-read cases and note them in a follow-up. Ship the editor/autocomplete/linter/promotion cases alone — that's still a material coverage add. + +- [ ] **Step 13.4: Run the spec** + +```bash +cd ui && npm run test:e2e -- alerting-editor.spec.ts +``` + +Expected: all tests green. Watch for flakes — any test that fails on a timing assertion needs `waitForFunction` instead of `waitForTimeout` (use CodeMirror's own state queries). + +- [ ] **Step 13.5: Run three consecutive times for stability** + +```bash +for i in 1 2 3; do cd ui && npm run test:e2e -- alerting-editor.spec.ts || break; done +``` + +Expected: 3 consecutive green runs. If flaky, adjust the assertion to be event-driven, not timer-driven. + +- [ ] **Step 13.6: Commit** + +```bash +git add ui/src/test/e2e/alerting-editor.spec.ts +# + any data-testid attribute adds from Step 13.2 +git commit -m "$(cat <<'EOF' +test(ui/alerts): Playwright editor spec + +Covers the paths the Plan 03 smoke doesn't: + - rule EDIT round-trip + - MustacheEditor autocomplete + linter in-browser + - env-promotion warnings banner + - inbox ack + - bulk-read + +Three consecutive green runs validated locally before merge. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Post-flight — Verification against spec acceptance criteria + +Before requesting code review on the PR, self-verify every item from spec §10: + +- [ ] **Step V.1: Regression protection for Plan 03 bug class** + +Temporarily revert commit `5edf7eb2` locally: + +```bash +git revert --no-commit 5edf7eb2 +mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest +``` + +Expected: test FAILS. + +```bash +git reset --hard HEAD +mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest +``` + +Expected: test passes again. + +- [ ] **Step V.2: No hand-maintained variable registry remains** + +```bash +grep -rn "ALERT_VARIABLES" ui/src --include="*.ts" --include="*.tsx" +``` + +Expected: no results. + +```bash +test ! -f ui/src/components/MustacheEditor/alert-variables.ts && echo "File deleted" || echo "STILL PRESENT" +``` + +Expected: `File deleted`. + +- [ ] **Step V.3: No hand-declared polymorphic unions** + +```bash +grep -E "^export type (RouteMetric|Comparator|JvmAggregation|ExchangeFireMode) = '" ui/src/pages/Alerts/enums.ts +``` + +Expected: no results (all four are derived via `NonNullable` now). + +- [ ] **Step V.4: All 5 condition fields typed in schema** + +```bash +python -c " +import json +d = json.load(open('ui/src/api/openapi.json')) +s = d['components']['schemas'] +checks = [ + ('AgentStateCondition', 'state', 'enum'), + ('DeploymentStateCondition', 'states', 'array-of-enum'), + ('LogPatternCondition', 'level', 'enum'), + ('ExchangeFilter', 'status', 'enum'), + ('JvmMetricCondition', 'metric', 'enum'), +] +for sch, field, kind in checks: + # Schemas are in allOf for subtypes; walk them. + obj = s[sch] + props = {} + if 'allOf' in obj: + for part in obj['allOf']: + if 'properties' in part: + props.update(part['properties']) + else: + props = obj.get('properties', {}) + p = props.get(field, {}) + if kind == 'enum': + has = 'enum' in p + elif kind == 'array-of-enum': + has = p.get('type') == 'array' and 'enum' in (p.get('items') or {}) + else: + has = False + print(('OK' if has else 'MISS'), sch, field, kind) +" +``` + +Expected: five `OK` lines. + +- [ ] **Step V.5: Playwright 3-consecutive-run stability** + +```bash +for i in 1 2 3; do cd ui && npm run test:e2e || break; done +``` + +Expected: 3 green runs. + +- [ ] **Step V.6: Rules docs updated** + +```bash +grep -c "AlertTemplateVariablesController" .claude/rules/app-classes.md +grep -c "LogLevel" .claude/rules/core-classes.md +``` + +Expected: both print `1` or higher. + +- [ ] **Step V.7: Full build + test suite** + +```bash +mvn clean verify -pl cameleer-server-app -am +cd ui && npm run typecheck && npm run test && npm run test:e2e +``` + +Expected: everything green. + +- [ ] **Step V.8: GitNexus re-analyze** + +```bash +cd .worktrees/alerting-04 # or whichever worktree root +npx gitnexus analyze --embeddings +``` + +Expected: fresh index reflecting the new classes. + +- [ ] **Step V.9: Open PR** + +```bash +git push -u origin feat/alerting-04-hardening +gh pr create --title "feat(alerting): Plan 04 — post-ship hardening" --body "$(cat <<'EOF' +## Summary + +Closes the loop on three bug classes from Plan 03 triage: + +1. **Spring wiring regressions** — new `ContextStartupSmokeTest` runs at `mvn test` tier (no Testcontainers) and asserts every public alerting bean is present. Validated by reverting `5edf7eb2` — test fails loudly. +2. **UI↔backend drift on Mustache template variables** — `AlertTemplateVariables.ALL` is the single source of truth; `NotificationContextBuilderRegistryTest` enforces structural agreement with the builder; UI consumes via `GET /api/v1/alerts/template-variables`. +3. **Hand-maintained TS enum unions** — `@Schema(discriminatorProperty, discriminatorMapping)` on `AlertCondition` fixes springdoc output; 4 condition String fields become proper enums; 1 (`JvmMetricCondition.metric`) gets an `@Schema(allowableValues)` hint. + +No new product features. Pure hardening. + +Spec: `docs/superpowers/specs/2026-04-20-alerting-04-hardening-design.md` +Plan: `docs/superpowers/plans/2026-04-20-alerting-04-hardening.md` + +## Test plan + +- [x] `mvn clean verify` green +- [x] `cd ui && npm run typecheck && npm run test` green +- [x] `cd ui && npm run test:e2e` green (3 consecutive runs) +- [x] `ContextStartupSmokeTest` fails when `AlertingMetrics` loses `@Autowired` (validated locally) +- [x] `alert-variables.ts` deleted; grep shows no residual references +- [x] `.claude/rules/app-classes.md` + `core-classes.md` updated + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Summary + +11 atomic commits covering 5 hardening tasks. Each commit reversible. Each task leaves the tree buildable and tests green. Total estimated effort: ~1-2 days of focused work. + +**Acceptance gates enforced by this plan:** + +1. `ContextStartupSmokeTest` catches `@Autowired` regressions at `mvn test` time. +2. `NotificationContextBuilderRegistryTest` catches UI/backend variable-list drift at `mvn test` time. +3. `alerting-editor.spec.ts` catches MustacheEditor + rule-EDIT + promotion + inbox regressions. +4. Springdoc + 5 enum migrations make `ui/src/pages/Alerts/enums.ts` trivial to maintain — all vocabulary derived from schema. + +Plan complete.