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

104 KiB
Raw Permalink Blame History

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: StringAgentState)
  • 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: StringLogLevel)
  • src/main/java/com/cameleer/server/core/alerting/ExchangeMatchCondition.java — Task 6 (nested ExchangeFilter.status: StringExecutionStatus)
  • 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
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
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
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.

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:

-- 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:

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
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
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:

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
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
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:

/**
 * 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
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
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
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.stateAgentState 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
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:

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
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
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
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:

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:

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:

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
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
mvn -pl cameleer-server-app verify -Dit.test='AlertingFullLifecycleIT#*'

Expected: lifecycle IT passes, including AGENT_STATE rule evaluation.

  • Step 3.11: Commit
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.statesList<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
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:

// Before (illustrative):  if (c.states().contains(deployment.status().name())) { ... }
// After:                  if (c.states().contains(deployment.status())) { ... }
  • Step 4.4: Compile
mvn -pl cameleer-server-core,cameleer-server-app compile

Expected: BUILD SUCCESS.

  • Step 4.5: Run Java tests
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
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:

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:

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:

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
cd ui && npm run typecheck && npm run test

Expected: 0 errors.

  • Step 4.10: Commit
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

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
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
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
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:

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:

- `LogLevel` — enum: `TRACE, DEBUG, INFO, WARN, ERROR`. SLF4J-compatible vocabulary used by `LogPatternCondition.level`.
  • Step 5.9: UI typecheck + test
cd ui && npm run typecheck && npm run test
  • Step 5.10: Commit
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.statusExecutionStatus

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
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:

// 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
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
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:

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
cd ui && npm run typecheck && npm run test
  • Step 6.9: IT
mvn -pl cameleer-server-app verify -Dit.test='AlertingFullLifecycleIT#*'

Expected: exchange-match rule lifecycle green.

  • Step 6.10: Commit
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

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
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:

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
cd ui && npm run typecheck && npm run test

Expected: all green.

  • Step 7.6: Commit
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:

# 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:

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:

grep -A2 "<artifactId>h2</artifactId>" cameleer-server-app/pom.xml

If absent, add to the <dependencies> section:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
  • Step 8.4: Run the test
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:

# 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:

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
mvn -pl cameleer-server-app test

Expected: all tests green, no regressions in existing suite.

  • Step 8.7: Commit
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

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
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
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:

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)
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
mvn -pl cameleer-server-app test -Dtest='com.cameleer.server.app.alerting.*'

Expected: all green.

  • Step 10.6: Commit (Tasks 9 + 10 together)
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

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
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):

| `/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):

- `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
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:

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
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
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
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

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:

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:

cat ui/src/components/MustacheEditor/MustacheEditor.tsx | head -40

Replace the import of alert-variables with a prop. The MustacheEditor interface goes from:

// BEFORE
interface Props {
  value: string;
  onChange: (v: string) => void;
  conditionKind?: ConditionKind;
  // ... other props
}

to:

// 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:

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:

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
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
rm ui/src/components/MustacheEditor/alert-variables.ts

Grep-verify:

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
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
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
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

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
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:

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
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
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
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:

git revert --no-commit 5edf7eb2
mvn -pl cameleer-server-app test -Dtest=ContextStartupSmokeTest

Expected: test FAILS.

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
grep -rn "ALERT_VARIABLES" ui/src --include="*.ts" --include="*.tsx"

Expected: no results.

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
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
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
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
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
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
cd .worktrees/alerting-04  # or whichever worktree root
npx gitnexus analyze --embeddings

Expected: fresh index reflecting the new classes.

  • Step V.9: Open PR
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.