Files
cameleer-server/docs/superpowers/plans/2026-04-20-alerting-04-hardening.md
hsiegeln e7ce1a73d0 docs(alerting): Plan 04 implementation plan — post-ship hardening
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) <noreply@anthropic.com>
2026-04-20 21:54:09 +02:00

2691 lines
104 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 → 37 → 8 → 912 → 13. Task 1 is the smallest backend-only change that unlocks Task 2's TS-side cleanup. Tasks 37 are independent enum migrations; land them in any order. Task 8 is a new unit test, independent. Tasks 912 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<String>``List<DeploymentStatus>`)
- `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 36 (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: "<ClassName>"}
* 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<String, String> 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: "<ClassName>" 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) <noreply@anthropic.com>
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<T, string>` 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<AlertRuleRequest['conditionKind']>;
export type Severity = NonNullable<AlertRuleRequest['severity']>;
export type TargetKind = NonNullable<AlertRuleTarget['kind']>;
export type RouteMetric = NonNullable<RouteMetricCondition['metric']>;
export type Comparator = NonNullable<RouteMetricCondition['comparator']>;
export type JvmAggregation = NonNullable<JvmMetricCondition['aggregation']>;
export type ExchangeFireMode = NonNullable<ExchangeMatchCondition['fireMode']>;
export interface Option<T extends string> { value: T; label: string }
function toOptions<T extends string>(labels: Record<T, string>, hidden?: readonly T[]): Option<T>[] {
const skip: ReadonlySet<T> = 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<ConditionKind, string> = {
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<Severity, string> = {
CRITICAL: 'Critical',
WARNING: 'Warning',
INFO: 'Info',
};
const ROUTE_METRIC_LABELS: Record<RouteMetric, string> = {
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<Comparator, string> = {
GT: '>',
GTE: '\u2265',
LT: '<',
LTE: '\u2264',
EQ: '=',
};
const JVM_AGGREGATION_LABELS: Record<JvmAggregation, string> = {
MAX: 'MAX',
AVG: 'AVG',
MIN: 'MIN',
LATEST: 'LATEST',
};
const EXCHANGE_FIRE_MODE_LABELS: Record<ExchangeFireMode, string> = {
PER_EXCHANGE: 'One alert per matching exchange',
COUNT_IN_WINDOW: 'Threshold: N matches in window',
};
const TARGET_KIND_LABELS: Record<TargetKind, string> = {
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<ConditionKind>[] = toOptions(CONDITION_KIND_LABELS);
export const SEVERITY_OPTIONS: Option<Severity>[] = toOptions(SEVERITY_LABELS);
export const ROUTE_METRIC_OPTIONS: Option<RouteMetric>[] = toOptions(ROUTE_METRIC_LABELS);
export const COMPARATOR_OPTIONS: Option<Comparator>[] = toOptions(COMPARATOR_LABELS, COMPARATOR_HIDDEN);
export const JVM_AGGREGATION_OPTIONS: Option<JvmAggregation>[] = toOptions(JVM_AGGREGATION_LABELS, JVM_AGGREGATION_HIDDEN);
export const EXCHANGE_FIRE_MODE_OPTIONS: Option<ExchangeFireMode>[] = toOptions(EXCHANGE_FIRE_MODE_LABELS);
export const TARGET_KIND_OPTIONS: Option<TargetKind>[] = 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) <noreply@anthropic.com>
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<AgentStateCondition['state']>;
const AGENT_STATE_LABELS: Record<AgentStateValue, string> = {
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<AgentStateValue>[] = 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 `<option value="DEAD">` with a loop over `AGENT_STATE_OPTIONS`:
```tsx
import { AGENT_STATE_OPTIONS, type AgentStateValue } from '../../enums';
// Inside the component, replace the state <select> with:
<Select
value={condition.state ?? ''}
onChange={(e) => onChange({ ...condition, state: e.target.value as AgentStateValue })}
>
{AGENT_STATE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</Select>
```
- [ ] **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) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: `DeploymentStateCondition.states` → `List<DeploymentStatus>`
**Why:** Same reasoning as Task 3 — closed vocabulary typed as `List<String>`.
**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<DeploymentStatus> 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<NonNullable<DeploymentStateCondition['states']>[number]>;
const DEPLOYMENT_STATE_LABELS: Record<DeploymentStateValue, string> = {
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<DeploymentStateValue>[] =
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<String> to List<DeploymentStatus>
Same pattern as AgentState migration — typed vocabulary, @Valid
enforcement, UI-side derived options.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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<LogPatternCondition['level']>;
const LOG_LEVEL_LABELS: Record<LogLevelValue, string> = {
ERROR: 'ERROR',
WARN: 'WARN',
INFO: 'INFO',
DEBUG: 'DEBUG',
TRACE: 'TRACE',
};
export const LOG_LEVEL_OPTIONS: Option<LogLevelValue>[] = 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) <noreply@anthropic.com>
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<String, String> 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<ExchangeFilter['status']>;
const EXCHANGE_STATUS_LABELS: Record<ExchangeStatus, string> = {
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<ExchangeStatus>[] = 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) <noreply@anthropic.com>
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<JvmMetricCondition['metric']>;
const JVM_METRIC_LABELS: Record<JvmMetric, string> = {
'process.cpu.usage.value': 'CPU usage (ratio 01)',
'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<JvmMetric>[] = 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) <noreply@anthropic.com>
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 "<artifactId>h2</artifactId>" cameleer-server-app/pom.xml
```
If absent, add to the `<dependencies>` section:
```xml
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
```
- [ ] **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) <noreply@anthropic.com>
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<ConditionKind> 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<TemplateVariableDescriptor> 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<String> alwaysPaths = AlertTemplateVariables.ALL.stream()
.filter(v -> v.availableForKinds().isEmpty())
.map(TemplateVariableDescriptor::path)
.toList();
for (ConditionKind kind : ConditionKind.values()) {
Map<String, Object> ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test");
Map<String, Object> 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<String> expectedPaths = AlertTemplateVariables.ALL.stream()
.filter(v -> v.availableForKinds().contains(kind))
.map(TemplateVariableDescriptor::path)
.toList();
Map<String, Object> ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test");
Map<String, Object> 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<String> registered = AlertTemplateVariables.ALL.stream()
.map(TemplateVariableDescriptor::path)
.collect(Collectors.toSet());
for (ConditionKind kind : ConditionKind.values()) {
Map<String, Object> ctx = builder.build(ruleFor(kind), instanceFor(kind), env(), "https://ui.test");
Set<String> flattened = flatten(ctx).keySet();
Set<String> 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<String, Object> 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<String, Object> flatten(Map<String, Object> src) {
Map<String, Object> out = new HashMap<>();
flattenInto("", src, out);
return out;
}
@SuppressWarnings("unchecked")
private static void flattenInto(String prefix, Map<String, Object> src, Map<String, Object> out) {
for (Map.Entry<String, Object> e : src.entrySet()) {
String key = prefix.isEmpty() ? e.getKey() : prefix + "." + e.getKey();
if (e.getValue() instanceof Map<?, ?> m) {
flattenInto(key, (Map<String, Object>) 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) <noreply@anthropic.com>
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<ConditionKind> 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<TemplateVariableDto>> list() {
List<TemplateVariableDto> 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<TemplateVariableDto>` 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<Void> auth = new HttpEntity<>(security.bearer("alice", "VIEWER"));
ResponseEntity<String> 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<TemplateVariableDto>`.
- [ ] **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) <noreply@anthropic.com>
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 <MustacheEditor> was rendered without a variables prop:
<MustacheEditor value={form.notificationTitleTmpl} onChange={setTitle} variables={visible} />
<MustacheEditor value={form.notificationMessageTmpl} onChange={setMessage} variables={visible} />
```
- [ ] **Step 12.7: Update every other consumer found in Step 12.1**
For each file, import `useTemplateVariables` + `availableVariables` and pass `variables` to `<MustacheEditor>`. 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) <noreply@anthropic.com>
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<string, unknown> = {}) {
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-<id>` 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) <noreply@anthropic.com>
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<X['field']>` 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.