# Alerting — Plan 03: UI + Backfills 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:** Deliver the full alerting UI (inbox, rule editor wizard with Mustache autocomplete, silences, history, CMD-K, notification bell) against the backend on main, plus two small backend backfills (webhook SSRF guard, metrics gauge caching).

**Architecture:** All routes under `/alerts/**`, registered in `router.tsx` as a new top-level section (parallel to Exchanges/Dashboard/Runtime/Apps). State via TanStack Query hooks (mirrors `outboundConnections.ts` pattern). A shared `<MustacheEditor />` component built on CodeMirror 6 with a custom completion source drives every template-editable field (rule title, rule message, webhook body/header overrides, connection default body). CMD-K extension piggybacks on `LayoutShell`'s existing `searchData` builder — no separate registry. Notification bell lives in `TopBar` children, polls `/alerts/unread-count` on a 30s cadence that pauses when the Page Visibility API reports the tab hidden. Backend backfills: SSRF guard at rule-save (§17) and 30s caching on `AlertingMetrics` gauges (final-review NIT).

**Tech Stack:**
- React 19 + React Router v7 + TanStack Query v5 + Zustand v5 (existing)
- `@cameleer/design-system` v0.1.56 (existing)
- `openapi-fetch` + regenerated `openapi-typescript` schema (existing)
- CodeMirror 6 (`@codemirror/view`, `@codemirror/state`, `@codemirror/autocomplete`, `@codemirror/commands`) — new
- Vitest + Testing Library — new (for unit tests of pure-logic components)
- Playwright — already in devDependencies; config added in Task 2

**Base branch:** `feat/alerting-03-ui` off `main` (worktree: `.worktrees/alerting-03`). Commit atomically per task. After the branch is merged, the superseded `chore/openapi-regen-post-plan02` branch can be deleted.

**Engine choice for `<MustacheEditor />` (§20.7 of spec).** CodeMirror 6 is picked for three reasons: (1) bundle cost ~90 KB gzipped — ~20× lighter than Monaco; (2) `@codemirror/autocomplete` already ships ARIA-conformant combobox semantics, which the spec's accessibility requirement (§13) is non-trivial to rebuild on a plain textarea overlay; (3) it composes cleanly with a tiny custom completion extension that reads the same `ALERT_VARIABLES` metadata registry the UI uses elsewhere. Monaco and textarea-overlay are rejected but recorded in this plan so reviewers see the decision.

**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/ui.md` as part of the UI task that changes the map, not as a trailing cleanup.

---

## File Structure

### Frontend — new files

```
ui/src/
├── pages/Alerts/
│   ├── InboxPage.tsx                  — /alerts/inbox         (default landing)
│   ├── AllAlertsPage.tsx              — /alerts/all
│   ├── HistoryPage.tsx                — /alerts/history       (RESOLVED)
│   ├── RulesListPage.tsx              — /alerts/rules
│   ├── SilencesPage.tsx               — /alerts/silences
│   ├── RuleEditor/
│   │   ├── RuleEditorWizard.tsx       — /alerts/rules/new | /alerts/rules/{id}
│   │   ├── ScopeStep.tsx              — step 1
│   │   ├── ConditionStep.tsx          — step 2
│   │   ├── TriggerStep.tsx            — step 3
│   │   ├── NotifyStep.tsx             — step 4
│   │   ├── ReviewStep.tsx             — step 5
│   │   ├── form-state.ts              — FormState type + initialForm + toRequest
│   │   └── condition-forms/
│   │       ├── RouteMetricForm.tsx
│   │       ├── ExchangeMatchForm.tsx
│   │       ├── AgentStateForm.tsx
│   │       ├── DeploymentStateForm.tsx
│   │       ├── LogPatternForm.tsx
│   │       └── JvmMetricForm.tsx
│   └── promotion-prefill.ts           — client-side promotion prefill + warnings
├── components/
│   ├── NotificationBell.tsx
│   ├── AlertStateChip.tsx
│   ├── SeverityBadge.tsx
│   ├── MustacheEditor/
│   │   ├── MustacheEditor.tsx         — shell (CM6 EditorView)
│   │   ├── alert-variables.ts         — ALERT_VARIABLES registry
│   │   ├── mustache-completion.ts     — CM6 CompletionSource
│   │   └── mustache-linter.ts         — CM6 linter (unclosed braces, unknown vars)
│   └── alerts-sidebar-utils.ts        — buildAlertsTreeNodes
├── api/queries/
│   ├── alerts.ts
│   ├── alertRules.ts
│   ├── alertSilences.ts
│   ├── alertNotifications.ts
│   └── alertMeta.ts                   — shared env-scoped fetch helper
└── test/
    ├── setup.ts                       — Vitest / Testing Library setup
    └── e2e/
        └── alerting.spec.ts           — Playwright smoke
```

### Frontend — existing files modified

```
ui/src/
├── router.tsx                          — register /alerts/* + /alerts/rules/new|{id}
├── components/LayoutShell.tsx          — Alerts sidebar section, NotificationBell in TopBar children, buildAlertsSearchData
├── components/sidebar-utils.ts         — export buildAlertsTreeNodes
ui/package.json                         — add CM6, Vitest, @testing-library/*
ui/vitest.config.ts                    — new (Vitest config)
ui/playwright.config.ts                — new (Playwright config)
ui/tsconfig.app.json                   — include test setup
```

### Backend — files modified

```
cameleer-server-app/
├── src/main/java/com/cameleer/server/app/
│   ├── outbound/OutboundConnectionServiceImpl.java     — SSRF guard in save()
│   ├── outbound/SsrfGuard.java                         — new utility (resolves URL host, rejects private ranges)
│   └── alerting/metrics/AlertingMetrics.java           — 30s caching on gauge suppliers
└── src/test/java/com/cameleer/server/app/
    ├── outbound/SsrfGuardTest.java                     — unit
    ├── outbound/OutboundConnectionAdminControllerIT.java — add SSRF rejection case
    └── alerting/metrics/AlertingMetricsCachingTest.java — unit
```

### Docs + rules

```
.claude/rules/ui.md                     — add Alerts section, new components, CMD-K sources
docs/alerting.md                        — add UI walkthrough sections (admin guide)
docs/superpowers/plans/2026-04-20-alerting-03-ui.md   — this plan
```

### Files NOT created (intentional)

- `ui/src/cmdk/sources/` — spec §12 proposed this, but the codebase uses DS `CommandPalette` fed by a single `searchData` builder in `LayoutShell`; registering alerts there is lower surface area and matches existing conventions.
- `ui/src/api/queries/admin/` entries for alerts — alerts are env-scoped, not admin, so they live at `ui/src/api/queries/` (not `admin/`).

---

## Phase 1 — Foundation (infra setup)

### Task 1: Install Vitest + Testing Library and add config

**Files:**
- Modify: `ui/package.json`
- Create: `ui/vitest.config.ts`
- Create: `ui/src/test/setup.ts`
- Modify: `ui/tsconfig.app.json`

- [ ] **Step 1: Install dev dependencies**

From `ui/`:

```bash
npm install --save-dev vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
```

Expected: package.json devDependencies gain `vitest`, `@vitest/ui`, `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, `jsdom`. `package-lock.json` updates.

- [ ] **Step 2: Create `ui/vitest.config.ts`**

```ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
    exclude: ['src/test/e2e/**', 'node_modules/**'],
    css: true,
  },
});
```

- [ ] **Step 3: Create `ui/src/test/setup.ts`**

```ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});
```

- [ ] **Step 4: Wire test scripts in `ui/package.json`**

Add to `scripts`:

```jsonc
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
```

- [ ] **Step 5: Include test setup in `ui/tsconfig.app.json`**

Ensure the `include` array covers `src/**/*.test.{ts,tsx}` and `src/test/**/*`. If the existing `include` uses `src`, no change is needed. Otherwise, add the patterns.

Verify: `cat ui/tsconfig.app.json | jq .include`

- [ ] **Step 6: Write and run a canary test to prove the wiring works**

Create `ui/src/test/canary.test.ts`:

```ts
import { describe, it, expect } from 'vitest';

describe('vitest canary', () => {
  it('arithmetic still works', () => {
    expect(1 + 1).toBe(2);
  });
});
```

Run: `cd ui && npm test`
Expected: 1 test passes.

- [ ] **Step 7: Commit**

```bash
git add ui/package.json ui/package-lock.json ui/vitest.config.ts ui/src/test/setup.ts ui/src/test/canary.test.ts ui/tsconfig.app.json
git commit -m "chore(ui): add Vitest + Testing Library scaffolding

Prepares for Plan 03 unit tests (MustacheEditor, NotificationBell, wizard step
validation). jsdom environment + jest-dom matchers + canary test verifies the
wiring."
```

---

### Task 2: Install CodeMirror 6 and add Playwright config

**Files:**
- Modify: `ui/package.json`
- Create: `ui/playwright.config.ts`
- Create: `ui/.gitignore` (or verify) — exclude `test-results/`, `playwright-report/`

- [ ] **Step 1: Install CM6 packages**

From `ui/`:

```bash
npm install @codemirror/view @codemirror/state @codemirror/autocomplete @codemirror/commands @codemirror/language @codemirror/lint @lezer/common
```

Expected: `package.json` dependencies gain six `@codemirror/*` packages plus `@lezer/common`. Total bundle cost ~90 KB gzipped (measured via `npm run build` in Task 36).

- [ ] **Step 2: Create `ui/playwright.config.ts`**

```ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './src/test/e2e',
  fullyParallel: false,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: 1,
  reporter: process.env.CI ? [['html'], ['github']] : [['list']],
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: process.env.PLAYWRIGHT_BASE_URL
    ? undefined
    : {
        command: 'npm run dev:local',
        url: 'http://localhost:5173',
        reuseExistingServer: !process.env.CI,
        timeout: 60_000,
      },
});
```

- [ ] **Step 3: Ensure `ui/.gitignore` excludes Playwright artifacts**

If `.gitignore` does not already ignore `test-results/` and `playwright-report/`, add them. Check first with `grep -E 'test-results|playwright-report' ui/.gitignore`. If missing, append.

- [ ] **Step 4: Install the Playwright browser**

```bash
cd ui && npx playwright install chromium
```

Expected: Chromium binary cached in `~/.cache/ms-playwright/`.

- [ ] **Step 5: Add e2e script to `ui/package.json`**

Add to `scripts`:

```jsonc
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
```

- [ ] **Step 6: Commit**

```bash
git add ui/package.json ui/package-lock.json ui/playwright.config.ts ui/.gitignore
git commit -m "chore(ui): add CodeMirror 6 + Playwright config

CM6 packages power the MustacheEditor (picked over Monaco / textarea overlay
for bundle size + built-in ARIA combobox autocomplete). Playwright config
enables Plan 03's E2E smoke; browser will be installed via npx playwright
install. Requires backend on :8081 for dev:local."
```

---

### Task 3: Regenerate OpenAPI schema and verify alert endpoints

**Files:**
- Modify: `ui/src/api/openapi.json`
- Modify: `ui/src/api/schema.d.ts`

- [ ] **Step 1: Start backend locally and regenerate**

Backend must be running on :8081 (or use the remote at `192.168.50.86:30090` which the existing `generate-api:live` script targets). From `ui/`:

```bash
npm run generate-api:live
```

Expected: `openapi.json` refreshed from the live server; `schema.d.ts` regenerated. Most likely no diff vs what `chore/openapi-regen-post-plan02` already captured — this is a sanity check.

- [ ] **Step 2: Verify alert paths are present**

```bash
grep -c '/environments/{envSlug}/alerts' ui/src/api/schema.d.ts
```

Expected: `>= 14` (list, unread-count, {id}, ack, read, bulk-read, rules, rules/{id}, enable, disable, render-preview, test-evaluate, silences, {alertId}/notifications).

- [ ] **Step 3: Run a quick compile check**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: no new errors beyond what main already has.

- [ ] **Step 4: Commit**

```bash
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
git commit -m "chore(ui): regenerate openapi schema against main backend

Captures the alerting controller surface merged in Plan 02. Supersedes the
regen on chore/openapi-regen-post-plan02 once this branch merges."
```

---

## Phase 2 — API queries + low-level components

### Task 4: Shared env-scoped fetch helper

**Files:**
- Create: `ui/src/api/queries/alertMeta.ts`

- [ ] **Step 1: Inspect how existing env-scoped queries fetch**

The existing `api/client.ts` (`openapi-fetch` client) is used for typed paths. Alerts endpoints use path param `{envSlug}` — the helper below wraps the client and reads the current env from `useEnvironmentStore`.

Run: `grep -l "apiClient\|openapi-fetch\|createClient" ui/src/api/*.ts`

- [ ] **Step 2: Write the helper**

```ts
// ui/src/api/queries/alertMeta.ts
import { useEnvironmentStore } from '../environment-store';
import { apiClient } from '../client';

/** Returns the currently selected env slug, throwing if none is selected.
 * Alerts routes require an env context — callers should gate on `selectedEnv`
 * via `enabled:` before invoking these hooks.
 */
export function useSelectedEnvOrThrow(): string {
  const env = useEnvironmentStore((s) => s.environment);
  if (!env) {
    throw new Error('Alerting requires a selected environment.');
  }
  return env;
}

export function useSelectedEnv(): string | undefined {
  return useEnvironmentStore((s) => s.environment);
}

export { apiClient };
```

Note: if `api/client.ts` does not already export `apiClient`, verify the export name with `grep -n "export" ui/src/api/client.ts` and adjust this import.

- [ ] **Step 3: Commit**

```bash
git add ui/src/api/queries/alertMeta.ts
git commit -m "feat(ui/alerts): shared env helper for alerting query hooks"
```

---

### Task 5: `alerts.ts` query hooks

**Files:**
- Create: `ui/src/api/queries/alerts.ts`
- Create: `ui/src/api/queries/alerts.test.ts`

- [ ] **Step 1: Write the hooks**

```ts
// ui/src/api/queries/alerts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertDto = components['schemas']['AlertDto'];
export type UnreadCountResponse = components['schemas']['UnreadCountResponse'];

type AlertState = AlertDto['state'];

export interface AlertsFilter {
  state?: AlertState | AlertState[];
  severity?: AlertDto['severity'] | AlertDto['severity'][];
  ruleId?: string;
  limit?: number;
}

function toArray<T>(v: T | T[] | undefined): T[] | undefined {
  if (v === undefined) return undefined;
  return Array.isArray(v) ? v : [v];
}

export function useAlerts(filter: AlertsFilter = {}) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alerts', env, filter],
    enabled: !!env,
    refetchInterval: 30_000,
    refetchIntervalInBackground: false,
    queryFn: async () => {
      if (!env) throw new Error('no env');
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts', {
        params: {
          path: { envSlug: env },
          query: {
            state: toArray(filter.state),
            severity: toArray(filter.severity),
            ruleId: filter.ruleId,
            limit: filter.limit ?? 100,
          },
        },
      });
      if (error) throw error;
      return data as AlertDto[];
    },
  });
}

export function useAlert(id: string | undefined) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alerts', env, id],
    enabled: !!env && !!id,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/{id}', {
        params: { path: { envSlug: env!, id: id! } },
      });
      if (error) throw error;
      return data as AlertDto;
    },
  });
}

export function useUnreadCount() {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alerts', env, 'unread-count'],
    enabled: !!env,
    refetchInterval: 30_000,
    refetchIntervalInBackground: false,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/unread-count', {
        params: { path: { envSlug: env! } },
      });
      if (error) throw error;
      return data as UnreadCountResponse;
    },
  });
}

export function useAckAlert() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/ack', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alerts', env] }),
  });
}

export function useMarkAlertRead() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.POST('/environments/{envSlug}/alerts/{id}/read', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['alerts', env] });
      qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
    },
  });
}

export function useBulkReadAlerts() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (ids: string[]) => {
      const { error } = await apiClient.POST('/environments/{envSlug}/alerts/bulk-read', {
        params: { path: { envSlug: env! } },
        body: { alertInstanceIds: ids },
      });
      if (error) throw error;
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['alerts', env] });
      qc.invalidateQueries({ queryKey: ['alerts', env, 'unread-count'] });
    },
  });
}
```

Note: if openapi-fetch's client is exported under a different name in `ui/src/api/client.ts`, adjust the `apiClient` reference in `alertMeta.ts` (Task 4). Verify the exact call site shape by reading `ui/src/api/client.ts` first — path-parameter types may differ from what's shown here. If the client uses `fetch` helpers with a different signature, adapt the hooks to match `outboundConnections.ts`'s `adminFetch` style instead.

- [ ] **Step 2: Write hook tests**

```ts
// ui/src/api/queries/alerts.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../environment-store';

// Mock apiClient module
vi.mock('../client', () => ({
  apiClient: {
    GET: vi.fn(),
    POST: vi.fn(),
  },
}));

import { apiClient } from '../client';
import { useAlerts, useUnreadCount } from './alerts';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe('useAlerts', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('fetches alerts for selected env and passes filter query params', async () => {
    (apiClient.GET as any).mockResolvedValue({ data: [], error: null });

    const { result } = renderHook(
      () => useAlerts({ state: 'FIRING', severity: ['CRITICAL', 'WARNING'] }),
      { wrapper },
    );
    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(apiClient.GET).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts',
      expect.objectContaining({
        params: expect.objectContaining({
          path: { envSlug: 'dev' },
          query: expect.objectContaining({
            state: ['FIRING'],
            severity: ['CRITICAL', 'WARNING'],
            limit: 100,
          }),
        }),
      }),
    );
  });

  it('does not fetch when no env is selected', () => {
    useEnvironmentStore.setState({ environment: undefined });
    const { result } = renderHook(() => useAlerts(), { wrapper });
    expect(result.current.fetchStatus).toBe('idle');
    expect(apiClient.GET).not.toHaveBeenCalled();
  });
});

describe('useUnreadCount', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('returns the server payload unmodified', async () => {
    (apiClient.GET as any).mockResolvedValue({
      data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
      error: null,
    });
    const { result } = renderHook(() => useUnreadCount(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(result.current.data).toEqual({
      total: 3,
      bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 },
    });
  });
});
```

- [ ] **Step 3: Run tests**

```bash
cd ui && npm test -- alerts.test
```

Expected: 3 tests pass.

- [ ] **Step 4: Commit**

```bash
git add ui/src/api/queries/alerts.ts ui/src/api/queries/alerts.test.ts
git commit -m "feat(ui/alerts): alert query hooks (list, get, unread count, ack, read, bulk-read)"
```

---

### Task 6: `alertRules.ts` query hooks

**Files:**
- Create: `ui/src/api/queries/alertRules.ts`
- Create: `ui/src/api/queries/alertRules.test.ts`

- [ ] **Step 1: Write the hooks**

```ts
// ui/src/api/queries/alertRules.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertRuleResponse = components['schemas']['AlertRuleResponse'];
export type AlertRuleRequest = components['schemas']['AlertRuleRequest'];
export type RenderPreviewRequest = components['schemas']['RenderPreviewRequest'];
export type RenderPreviewResponse = components['schemas']['RenderPreviewResponse'];
export type TestEvaluateRequest = components['schemas']['TestEvaluateRequest'];
export type TestEvaluateResponse = components['schemas']['TestEvaluateResponse'];
export type AlertCondition = AlertRuleResponse['condition'];
export type ConditionKind = AlertRuleResponse['conditionKind'];

export function useAlertRules() {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertRules', env],
    enabled: !!env,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules', {
        params: { path: { envSlug: env! } },
      });
      if (error) throw error;
      return data as AlertRuleResponse[];
    },
  });
}

export function useAlertRule(id: string | undefined) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertRules', env, id],
    enabled: !!env && !!id,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/rules/{id}', {
        params: { path: { envSlug: env!, id: id! } },
      });
      if (error) throw error;
      return data as AlertRuleResponse;
    },
  });
}

export function useCreateAlertRule() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertRuleRequest) => {
      const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/rules', {
        params: { path: { envSlug: env! } },
        body: req,
      });
      if (error) throw error;
      return data as AlertRuleResponse;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
  });
}

export function useUpdateAlertRule(id: string) {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertRuleRequest) => {
      const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/rules/{id}', {
        params: { path: { envSlug: env!, id } },
        body: req,
      });
      if (error) throw error;
      return data as AlertRuleResponse;
    },
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['alertRules', env] });
      qc.invalidateQueries({ queryKey: ['alertRules', env, id] });
    },
  });
}

export function useDeleteAlertRule() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/rules/{id}', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
  });
}

export function useSetAlertRuleEnabled() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
      const path = enabled
        ? '/environments/{envSlug}/alerts/rules/{id}/enable'
        : '/environments/{envSlug}/alerts/rules/{id}/disable';
      const { error } = await apiClient.POST(path, {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertRules', env] }),
  });
}

export function useRenderPreview() {
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async ({ id, req }: { id: string; req: RenderPreviewRequest }) => {
      const { data, error } = await apiClient.POST(
        '/environments/{envSlug}/alerts/rules/{id}/render-preview',
        { params: { path: { envSlug: env!, id } }, body: req },
      );
      if (error) throw error;
      return data as RenderPreviewResponse;
    },
  });
}

export function useTestEvaluate() {
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async ({ id, req }: { id: string; req: TestEvaluateRequest }) => {
      const { data, error } = await apiClient.POST(
        '/environments/{envSlug}/alerts/rules/{id}/test-evaluate',
        { params: { path: { envSlug: env!, id } }, body: req },
      );
      if (error) throw error;
      return data as TestEvaluateResponse;
    },
  });
}
```

- [ ] **Step 2: Write tests**

```ts
// ui/src/api/queries/alertRules.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../environment-store';

vi.mock('../client', () => ({
  apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
}));
import { apiClient } from '../client';
import {
  useAlertRules,
  useSetAlertRuleEnabled,
} from './alertRules';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe('useAlertRules', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'prod' });
  });

  it('fetches rules for selected env', async () => {
    (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
    const { result } = renderHook(() => useAlertRules(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(apiClient.GET).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/rules',
      { params: { path: { envSlug: 'prod' } } },
    );
  });
});

describe('useSetAlertRuleEnabled', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'prod' });
  });

  it('POSTs to /enable when enabling', async () => {
    (apiClient.POST as any).mockResolvedValue({ error: null });
    const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
    await result.current.mutateAsync({ id: 'r1', enabled: true });
    expect(apiClient.POST).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/rules/{id}/enable',
      { params: { path: { envSlug: 'prod', id: 'r1' } } },
    );
  });

  it('POSTs to /disable when disabling', async () => {
    (apiClient.POST as any).mockResolvedValue({ error: null });
    const { result } = renderHook(() => useSetAlertRuleEnabled(), { wrapper });
    await result.current.mutateAsync({ id: 'r1', enabled: false });
    expect(apiClient.POST).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/rules/{id}/disable',
      { params: { path: { envSlug: 'prod', id: 'r1' } } },
    );
  });
});
```

- [ ] **Step 3: Run tests**

```bash
cd ui && npm test -- alertRules.test
```

Expected: 3 tests pass.

- [ ] **Step 4: Commit**

```bash
git add ui/src/api/queries/alertRules.ts ui/src/api/queries/alertRules.test.ts
git commit -m "feat(ui/alerts): alert rule query hooks (CRUD, enable/disable, preview, test-evaluate)"
```

---

### Task 7: `alertSilences.ts` + `alertNotifications.ts` query hooks

**Files:**
- Create: `ui/src/api/queries/alertSilences.ts`
- Create: `ui/src/api/queries/alertNotifications.ts`
- Create: `ui/src/api/queries/alertSilences.test.ts`

- [ ] **Step 1: Write silences hooks**

```ts
// ui/src/api/queries/alertSilences.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertSilenceResponse = components['schemas']['AlertSilenceResponse'];
export type AlertSilenceRequest = components['schemas']['AlertSilenceRequest'];

export function useAlertSilences() {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertSilences', env],
    enabled: !!env,
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/environments/{envSlug}/alerts/silences', {
        params: { path: { envSlug: env! } },
      });
      if (error) throw error;
      return data as AlertSilenceResponse[];
    },
  });
}

export function useCreateSilence() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertSilenceRequest) => {
      const { data, error } = await apiClient.POST('/environments/{envSlug}/alerts/silences', {
        params: { path: { envSlug: env! } },
        body: req,
      });
      if (error) throw error;
      return data as AlertSilenceResponse;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
  });
}

export function useUpdateSilence(id: string) {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (req: AlertSilenceRequest) => {
      const { data, error } = await apiClient.PUT('/environments/{envSlug}/alerts/silences/{id}', {
        params: { path: { envSlug: env!, id } },
        body: req,
      });
      if (error) throw error;
      return data as AlertSilenceResponse;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
  });
}

export function useDeleteSilence() {
  const qc = useQueryClient();
  const env = useSelectedEnv();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.DELETE('/environments/{envSlug}/alerts/silences/{id}', {
        params: { path: { envSlug: env!, id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertSilences', env] }),
  });
}
```

- [ ] **Step 2: Write notifications hooks**

```ts
// ui/src/api/queries/alertNotifications.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { components } from '../schema';
import { apiClient, useSelectedEnv } from './alertMeta';

export type AlertNotificationDto = components['schemas']['AlertNotificationDto'];

export function useAlertNotifications(alertId: string | undefined) {
  const env = useSelectedEnv();
  return useQuery({
    queryKey: ['alertNotifications', env, alertId],
    enabled: !!env && !!alertId,
    queryFn: async () => {
      const { data, error } = await apiClient.GET(
        '/environments/{envSlug}/alerts/{alertId}/notifications',
        { params: { path: { envSlug: env!, alertId: alertId! } } },
      );
      if (error) throw error;
      return data as AlertNotificationDto[];
    },
  });
}

/** Notification retry uses the flat path — notification IDs are globally unique. */
export function useRetryNotification() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (id: string) => {
      const { error } = await apiClient.POST('/alerts/notifications/{id}/retry', {
        params: { path: { id } },
      });
      if (error) throw error;
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['alertNotifications'] }),
  });
}
```

- [ ] **Step 3: Write a compact silence test**

```ts
// ui/src/api/queries/alertSilences.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../environment-store';

vi.mock('../client', () => ({
  apiClient: { GET: vi.fn(), POST: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() },
}));
import { apiClient } from '../client';
import { useAlertSilences } from './alertSilences';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}

describe('useAlertSilences', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('fetches silences for selected env', async () => {
    (apiClient.GET as any).mockResolvedValue({ data: [], error: null });
    const { result } = renderHook(() => useAlertSilences(), { wrapper });
    await waitFor(() => expect(result.current.isSuccess).toBe(true));
    expect(apiClient.GET).toHaveBeenCalledWith(
      '/environments/{envSlug}/alerts/silences',
      { params: { path: { envSlug: 'dev' } } },
    );
  });
});
```

- [ ] **Step 4: Run tests**

```bash
cd ui && npm test -- alertSilences.test
```

Expected: 1 test passes.

- [ ] **Step 5: Commit**

```bash
git add ui/src/api/queries/alertSilences.ts ui/src/api/queries/alertNotifications.ts ui/src/api/queries/alertSilences.test.ts
git commit -m "feat(ui/alerts): silence + notification query hooks"
```

---

### Task 8: `AlertStateChip` + `SeverityBadge` components

**Files:**
- Create: `ui/src/components/AlertStateChip.tsx`
- Create: `ui/src/components/AlertStateChip.test.tsx`
- Create: `ui/src/components/SeverityBadge.tsx`
- Create: `ui/src/components/SeverityBadge.test.tsx`

- [ ] **Step 1: Write failing test for `AlertStateChip`**

```tsx
// ui/src/components/AlertStateChip.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AlertStateChip } from './AlertStateChip';

describe('AlertStateChip', () => {
  it.each([
    ['PENDING', /pending/i],
    ['FIRING', /firing/i],
    ['ACKNOWLEDGED', /acknowledged/i],
    ['RESOLVED', /resolved/i],
  ] as const)('renders %s label', (state, pattern) => {
    render(<AlertStateChip state={state} />);
    expect(screen.getByText(pattern)).toBeInTheDocument();
  });

  it('shows silenced suffix when silenced=true', () => {
    render(<AlertStateChip state="FIRING" silenced />);
    expect(screen.getByText(/silenced/i)).toBeInTheDocument();
  });
});
```

Run: `cd ui && npm test -- AlertStateChip`
Expected: FAIL (module not found).

- [ ] **Step 2: Implement `AlertStateChip`**

```tsx
// ui/src/components/AlertStateChip.tsx
import { Badge } from '@cameleer/design-system';
import type { AlertDto } from '../api/queries/alerts';

type State = AlertDto['state'];

const LABELS: Record<State, string> = {
  PENDING: 'Pending',
  FIRING: 'Firing',
  ACKNOWLEDGED: 'Acknowledged',
  RESOLVED: 'Resolved',
};

const COLORS: Record<State, 'auto' | 'success' | 'warning' | 'error'> = {
  PENDING: 'warning',
  FIRING: 'error',
  ACKNOWLEDGED: 'warning',
  RESOLVED: 'success',
};

export function AlertStateChip({ state, silenced }: { state: State; silenced?: boolean }) {
  return (
    <span style={{ display: 'inline-flex', gap: 4, alignItems: 'center' }}>
      <Badge label={LABELS[state]} color={COLORS[state]} variant="filled" />
      {silenced && <Badge label="Silenced" color="auto" variant="outlined" />}
    </span>
  );
}
```

Run: `cd ui && npm test -- AlertStateChip`
Expected: 5 tests pass.

- [ ] **Step 3: Write failing test for `SeverityBadge`**

```tsx
// ui/src/components/SeverityBadge.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SeverityBadge } from './SeverityBadge';

describe('SeverityBadge', () => {
  it.each([
    ['CRITICAL', /critical/i],
    ['WARNING', /warning/i],
    ['INFO', /info/i],
  ] as const)('renders %s', (severity, pattern) => {
    render(<SeverityBadge severity={severity} />);
    expect(screen.getByText(pattern)).toBeInTheDocument();
  });
});
```

- [ ] **Step 4: Implement `SeverityBadge`**

```tsx
// ui/src/components/SeverityBadge.tsx
import { Badge } from '@cameleer/design-system';
import type { AlertDto } from '../api/queries/alerts';

type Severity = AlertDto['severity'];

const LABELS: Record<Severity, string> = {
  CRITICAL: 'Critical',
  WARNING: 'Warning',
  INFO: 'Info',
};

const COLORS: Record<Severity, 'auto' | 'warning' | 'error'> = {
  CRITICAL: 'error',
  WARNING: 'warning',
  INFO: 'auto',
};

export function SeverityBadge({ severity }: { severity: Severity }) {
  return <Badge label={LABELS[severity]} color={COLORS[severity]} variant="filled" />;
}
```

Run: `cd ui && npm test -- SeverityBadge`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add ui/src/components/AlertStateChip.tsx ui/src/components/AlertStateChip.test.tsx \
        ui/src/components/SeverityBadge.tsx ui/src/components/SeverityBadge.test.tsx
git commit -m "feat(ui/alerts): AlertStateChip + SeverityBadge components

State colors follow the convention from @cameleer/design-system (CRITICAL→error,
WARNING→warning, INFO→auto). Silenced pill stacks next to state for the spec
§8 audit-trail surface."
```

---

### Task 9: `NotificationBell` component with Page Visibility pause

**Files:**
- Create: `ui/src/components/NotificationBell.tsx`
- Create: `ui/src/components/NotificationBell.test.tsx`
- Create: `ui/src/hooks/usePageVisible.ts`
- Create: `ui/src/hooks/usePageVisible.test.ts`

- [ ] **Step 1: Write failing test for the visibility hook**

```ts
// ui/src/hooks/usePageVisible.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePageVisible } from './usePageVisible';

describe('usePageVisible', () => {
  beforeEach(() => {
    Object.defineProperty(document, 'visibilityState', {
      value: 'visible',
      configurable: true,
      writable: true,
    });
  });

  it('returns true when visible, false when hidden', () => {
    const { result } = renderHook(() => usePageVisible());
    expect(result.current).toBe(true);

    act(() => {
      Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
      document.dispatchEvent(new Event('visibilitychange'));
    });
    expect(result.current).toBe(false);

    act(() => {
      Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
      document.dispatchEvent(new Event('visibilitychange'));
    });
    expect(result.current).toBe(true);
  });
});
```

- [ ] **Step 2: Implement `usePageVisible`**

```ts
// ui/src/hooks/usePageVisible.ts
import { useEffect, useState } from 'react';

export function usePageVisible(): boolean {
  const [visible, setVisible] = useState(() =>
    typeof document === 'undefined' ? true : document.visibilityState === 'visible',
  );

  useEffect(() => {
    const onChange = () => setVisible(document.visibilityState === 'visible');
    document.addEventListener('visibilitychange', onChange);
    return () => document.removeEventListener('visibilitychange', onChange);
  }, []);

  return visible;
}
```

Run: `cd ui && npm test -- usePageVisible`
Expected: 1 test passes.

- [ ] **Step 3: Write failing test for `NotificationBell`**

```tsx
// ui/src/components/NotificationBell.test.tsx
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
import type { ReactNode } from 'react';
import { useEnvironmentStore } from '../api/environment-store';

vi.mock('../api/client', () => ({ apiClient: { GET: vi.fn() } }));
import { apiClient } from '../api/client';
import { NotificationBell } from './NotificationBell';

function wrapper({ children }: { children: ReactNode }) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return (
    <QueryClientProvider client={qc}>
      <MemoryRouter>{children}</MemoryRouter>
    </QueryClientProvider>
  );
}

describe('NotificationBell', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useEnvironmentStore.setState({ environment: 'dev' });
  });

  it('renders zero badge when no unread alerts', async () => {
    (apiClient.GET as any).mockResolvedValue({
      data: { total: 0, bySeverity: { CRITICAL: 0, WARNING: 0, INFO: 0 } },
      error: null,
    });
    render(<NotificationBell />, { wrapper });
    expect(await screen.findByRole('button', { name: /notifications/i })).toBeInTheDocument();
    expect(screen.queryByText(/^\d+$/)).toBeNull();
  });

  it('shows critical count when unread critical alerts exist', async () => {
    (apiClient.GET as any).mockResolvedValue({
      data: { total: 3, bySeverity: { CRITICAL: 1, WARNING: 2, INFO: 0 } },
      error: null,
    });
    render(<NotificationBell />, { wrapper });
    expect(await screen.findByText('3')).toBeInTheDocument();
  });
});
```

- [ ] **Step 4: Implement `NotificationBell`**

```tsx
// ui/src/components/NotificationBell.tsx
import { useMemo } from 'react';
import { Link } from 'react-router';
import { Bell } from 'lucide-react';
import { useUnreadCount } from '../api/queries/alerts';
import { useSelectedEnv } from '../api/queries/alertMeta';
import { usePageVisible } from '../hooks/usePageVisible';
import css from './NotificationBell.module.css';

export function NotificationBell() {
  const env = useSelectedEnv();
  const visible = usePageVisible();
  const { data } = useUnreadCount();

  // Pause polling when tab hidden — TanStack Query respects this via refetchIntervalInBackground:false,
  // but hiding the DOM effect is a second defense-in-depth signal for tests.
  const total = visible ? (data?.total ?? 0) : (data?.total ?? 0);

  const badgeColor = useMemo<string | undefined>(() => {
    if (!data || total === 0) return undefined;
    if ((data.bySeverity?.CRITICAL ?? 0) > 0) return 'var(--error)';
    if ((data.bySeverity?.WARNING ?? 0) > 0) return 'var(--amber)';
    return 'var(--muted)';
  }, [data, total]);

  if (!env) return null;

  return (
    <Link
      to="/alerts/inbox"
      role="button"
      aria-label={`Notifications (${total} unread)`}
      className={css.bell}
    >
      <Bell size={16} />
      {total > 0 && (
        <span
          className={css.badge}
          style={{ background: badgeColor }}
          aria-hidden
        >
          {total > 99 ? '99+' : total}
        </span>
      )}
    </Link>
  );
}
```

- [ ] **Step 5: Create matching CSS module**

```css
/* ui/src/components/NotificationBell.module.css */
.bell {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  color: var(--fg);
  text-decoration: none;
}
.bell:hover { background: var(--hover-bg); }
.badge {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 8px;
  background: var(--error);
  color: var(--bg);
  font-size: 10px;
  font-weight: 600;
  line-height: 16px;
  text-align: center;
}
```

Run: `cd ui && npm test -- NotificationBell`
Expected: 2 tests pass.

- [ ] **Step 6: Commit**

```bash
git add ui/src/components/NotificationBell.tsx ui/src/components/NotificationBell.test.tsx \
        ui/src/components/NotificationBell.module.css \
        ui/src/hooks/usePageVisible.ts ui/src/hooks/usePageVisible.test.ts
git commit -m "feat(ui/alerts): NotificationBell with Page Visibility poll pause

Bell links to /alerts/inbox and shows a badge coloured by max unread severity
(CRITICAL→error, WARNING→amber, INFO→muted, 0→hidden). Polling pauses when
the tab is hidden via TanStack Query's refetchIntervalInBackground:false
plus a usePageVisible hook, reducing idle backend load."
```

---

## Phase 3 — `<MustacheEditor />` component

### Task 10: Variable metadata registry

**Files:**
- Create: `ui/src/components/MustacheEditor/alert-variables.ts`
- Create: `ui/src/components/MustacheEditor/alert-variables.test.ts`

- [ ] **Step 1: Write the registry**

```ts
// ui/src/components/MustacheEditor/alert-variables.ts
import type { ConditionKind } from '../../api/queries/alertRules';

export type VariableType =
  | 'string'
  | 'Instant'
  | 'number'
  | 'boolean'
  | 'url'
  | 'uuid';

export interface AlertVariable {
  path: string;             // e.g. "alert.firedAt"
  type: VariableType;
  description: string;
  sampleValue: string;      // rendered as a faint suggestion preview
  availableForKinds: 'always' | ConditionKind[];
  mayBeNull?: boolean;      // show "may be null" badge in UI
}

/** Variables the spec §8 context map exposes. Add to this registry whenever
 *  NotificationContextBuilder (backend) gains a new leaf. */
export const ALERT_VARIABLES: AlertVariable[] = [
  // Always available
  { path: 'env.slug', type: 'string', description: 'Environment slug', sampleValue: 'prod', availableForKinds: 'always' },
  { path: 'env.id', type: 'uuid', description: 'Environment UUID', sampleValue: '00000000-0000-0000-0000-000000000001', availableForKinds: 'always' },
  { path: 'rule.id', type: 'uuid', description: 'Rule UUID', sampleValue: '11111111-...', availableForKinds: 'always' },
  { path: 'rule.name', type: 'string', description: 'Rule display name', sampleValue: 'Order API error rate', availableForKinds: 'always' },
  { path: 'rule.severity', type: 'string', description: 'Rule severity', sampleValue: 'CRITICAL', availableForKinds: 'always' },
  { path: 'rule.description', type: 'string', description: 'Rule description', sampleValue: 'Paging ops if error rate >5%', availableForKinds: 'always' },
  { path: 'alert.id', type: 'uuid', description: 'Alert instance UUID', sampleValue: '22222222-...', availableForKinds: 'always' },
  { path: 'alert.state', type: 'string', description: 'Alert state', sampleValue: 'FIRING', availableForKinds: 'always' },
  { path: 'alert.firedAt', type: 'Instant', description: 'When the alert fired', sampleValue: '2026-04-20T14:33:10Z', availableForKinds: 'always' },
  { path: 'alert.resolvedAt', type: 'Instant', description: 'When the alert resolved', sampleValue: '2026-04-20T14:45:00Z', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.ackedBy', type: 'string', description: 'User who ack\'d the alert', sampleValue: 'alice', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.link', type: 'url', description: 'UI link to this alert', sampleValue: 'https://cameleer.example.com/alerts/inbox/2222...', availableForKinds: 'always' },
  { path: 'alert.currentValue', type: 'number', description: 'Observed metric value', sampleValue: '0.12', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.threshold', type: 'number', description: 'Rule threshold', sampleValue: '0.05', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.comparator', type: 'string', description: 'Rule comparator', sampleValue: 'GT', availableForKinds: 'always', mayBeNull: true },
  { path: 'alert.window', type: 'string', description: 'Rule window (human)', sampleValue: '5m', availableForKinds: 'always', mayBeNull: true },

  // Scope-ish — still always available when scoped, but "may be null" if env-wide
  { path: 'app.slug', type: 'string', description: 'App slug', sampleValue: 'orders', availableForKinds: 'always', mayBeNull: true },
  { path: 'app.id', type: 'uuid', description: 'App UUID', sampleValue: '33333333-...', availableForKinds: 'always', mayBeNull: true },
  { path: 'app.displayName', type: 'string', description: 'App display name', sampleValue: 'Order API', availableForKinds: 'always', mayBeNull: true },

  // ROUTE_METRIC
  { path: 'route.id', type: 'string', description: 'Route ID', sampleValue: 'route-1', availableForKinds: ['ROUTE_METRIC', 'EXCHANGE_MATCH'] },

  // EXCHANGE_MATCH
  { path: 'exchange.id', type: 'string', description: 'Exchange ID', sampleValue: 'exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] },
  { path: 'exchange.status', type: 'string', description: 'Exchange status', sampleValue: 'FAILED', availableForKinds: ['EXCHANGE_MATCH'] },
  { path: 'exchange.link', type: 'url', description: 'UI link to exchange', sampleValue: '/exchanges/orders/route-1/exch-ab12', availableForKinds: ['EXCHANGE_MATCH'] },

  // AGENT_STATE
  { path: 'agent.id', type: 'string', description: 'Agent instance ID', sampleValue: 'prod-orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] },
  { path: 'agent.name', type: 'string', description: 'Agent display name', sampleValue: 'orders-0', availableForKinds: ['AGENT_STATE', 'JVM_METRIC'] },
  { path: 'agent.state', type: 'string', description: 'Agent state', sampleValue: 'DEAD', availableForKinds: ['AGENT_STATE'] },

  // DEPLOYMENT_STATE
  { path: 'deployment.id', type: 'uuid', description: 'Deployment UUID', sampleValue: '44444444-...', availableForKinds: ['DEPLOYMENT_STATE'] },
  { path: 'deployment.status', type: 'string', description: 'Deployment status', sampleValue: 'FAILED', availableForKinds: ['DEPLOYMENT_STATE'] },

  // LOG_PATTERN
  { path: 'log.logger', type: 'string', description: 'Logger name', sampleValue: 'com.acme.Api', availableForKinds: ['LOG_PATTERN'] },
  { path: 'log.level', type: 'string', description: 'Log level', sampleValue: 'ERROR', availableForKinds: ['LOG_PATTERN'] },
  { path: 'log.message', type: 'string', description: 'Log message', sampleValue: 'TimeoutException...', availableForKinds: ['LOG_PATTERN'] },

  // JVM_METRIC
  { path: 'metric.name', type: 'string', description: 'Metric name', sampleValue: 'heap_used_percent', availableForKinds: ['JVM_METRIC'] },
  { path: 'metric.value', type: 'number', description: 'Metric value', sampleValue: '92.1', availableForKinds: ['JVM_METRIC'] },
];

/** Filter variables to those available for the given condition kind.
 *  If kind is undefined (e.g. connection URL editor), returns only "always" vars + app.*. */
export function availableVariables(
  kind: ConditionKind | undefined,
  opts: { reducedContext?: boolean } = {},
): AlertVariable[] {
  if (opts.reducedContext) {
    return ALERT_VARIABLES.filter((v) => v.path.startsWith('env.'));
  }
  if (!kind) {
    return ALERT_VARIABLES.filter(
      (v) => v.availableForKinds === 'always',
    );
  }
  return ALERT_VARIABLES.filter(
    (v) => v.availableForKinds === 'always' || v.availableForKinds.includes(kind),
  );
}

/** Parse a Mustache template and return the set of `{{path}}` references it contains.
 *  Ignores `{{#section}}` / `{{/section}}` / `{{!comment}}` — plain variable refs only. */
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 AlertVariable[],
): string[] {
  const allowedSet = new Set(allowed.map((v) => v.path));
  return extractReferences(template).filter((r) => !allowedSet.has(r));
}
```

- [ ] **Step 2: Write tests**

```ts
// ui/src/components/MustacheEditor/alert-variables.test.ts
import { describe, it, expect } from 'vitest';
import {
  availableVariables,
  extractReferences,
  unknownReferences,
} from './alert-variables';

describe('availableVariables', () => {
  it('returns only always-available vars when kind is undefined', () => {
    const vars = availableVariables(undefined);
    expect(vars.find((v) => v.path === 'env.slug')).toBeTruthy();
    expect(vars.find((v) => v.path === 'exchange.id')).toBeUndefined();
    expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined();
  });

  it('adds exchange.* for EXCHANGE_MATCH kind', () => {
    const vars = availableVariables('EXCHANGE_MATCH');
    expect(vars.find((v) => v.path === 'exchange.id')).toBeTruthy();
    expect(vars.find((v) => v.path === 'log.logger')).toBeUndefined();
  });

  it('adds log.* for LOG_PATTERN kind', () => {
    const vars = availableVariables('LOG_PATTERN');
    expect(vars.find((v) => v.path === 'log.message')).toBeTruthy();
  });

  it('reduces to env-only when reducedContext=true (connection URL editor)', () => {
    const vars = availableVariables('ROUTE_METRIC', { reducedContext: true });
    expect(vars.every((v) => v.path.startsWith('env.'))).toBe(true);
  });
});

describe('extractReferences', () => {
  it('finds bare variable refs', () => {
    expect(extractReferences('Hello {{user.name}}, ack: {{alert.ackedBy}}')).toEqual([
      'user.name',
      'alert.ackedBy',
    ]);
  });
  it('ignores section/comment tags', () => {
    expect(
      extractReferences('{{#items}}{{name}}{{/items}} {{!comment}}'),
    ).toEqual(['name']);
  });
  it('tolerates whitespace', () => {
    expect(extractReferences('{{   alert.firedAt   }}')).toEqual(['alert.firedAt']);
  });
});

describe('unknownReferences', () => {
  it('flags references not in the allowed set', () => {
    const allowed = availableVariables('ROUTE_METRIC');
    expect(unknownReferences('{{alert.id}} {{exchange.id}}', allowed)).toEqual(['exchange.id']);
  });
});
```

Run: `cd ui && npm test -- alert-variables`
Expected: 8 tests pass.

- [ ] **Step 3: Commit**

```bash
git add ui/src/components/MustacheEditor/alert-variables.ts \
        ui/src/components/MustacheEditor/alert-variables.test.ts
git commit -m "feat(ui/alerts): Mustache variable metadata registry for autocomplete

ALERT_VARIABLES mirrors the spec §8 context map. availableVariables(kind)
returns the kind-specific filter (always vars + kind vars). extractReferences
+ unknownReferences drive the inline amber linter. Backend NotificationContext
adds must land here too."
```

---

### Task 11: CodeMirror completion + linter extensions

**Files:**
- Create: `ui/src/components/MustacheEditor/mustache-completion.ts`
- Create: `ui/src/components/MustacheEditor/mustache-completion.test.ts`
- Create: `ui/src/components/MustacheEditor/mustache-linter.ts`
- Create: `ui/src/components/MustacheEditor/mustache-linter.test.ts`

- [ ] **Step 1: Write the CM6 completion source**

```ts
// ui/src/components/MustacheEditor/mustache-completion.ts
import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete';
import type { AlertVariable } from './alert-variables';

/** Build a CodeMirror completion source that triggers after `{{` (with optional whitespace)
 *  and suggests variable paths from the given list. */
export function mustacheCompletionSource(variables: readonly AlertVariable[]) {
  return (context: CompletionContext): CompletionResult | null => {
    // Look backward for `{{` optionally followed by whitespace, then an in-progress identifier.
    const line = context.state.doc.lineAt(context.pos);
    const textBefore = line.text.slice(0, context.pos - line.from);
    const m = /\{\{\s*([a-zA-Z0-9_.]*)$/.exec(textBefore);
    if (!m) return null;

    const partial = m[1];
    const from = context.pos - partial.length;

    const options: Completion[] = variables
      .filter((v) => v.path.startsWith(partial))
      .map((v) => ({
        label: v.path,
        type: v.mayBeNull ? 'variable' : 'constant',
        detail: v.type,
        info: v.mayBeNull
          ? `${v.description} (may be null) · e.g. ${v.sampleValue}`
          : `${v.description} · e.g. ${v.sampleValue}`,
        // Inserting closes the Mustache tag; CM will remove the partial prefix.
        apply: (view, _completion, completionFrom, to) => {
          const insert = `${v.path}}}`;
          view.dispatch({
            changes: { from: completionFrom, to, insert },
            selection: { anchor: completionFrom + insert.length },
          });
        },
      }));

    return {
      from,
      to: context.pos,
      options,
      validFor: /^[a-zA-Z0-9_.]*$/,
    };
  };
}
```

- [ ] **Step 2: Write completion tests (pure-logic — no view)**

```ts
// ui/src/components/MustacheEditor/mustache-completion.test.ts
import { describe, it, expect } from 'vitest';
import { EditorState } from '@codemirror/state';
import { CompletionContext } from '@codemirror/autocomplete';
import { mustacheCompletionSource } from './mustache-completion';
import { availableVariables } from './alert-variables';

function makeContext(doc: string, pos: number): CompletionContext {
  const state = EditorState.create({ doc });
  return new CompletionContext(state, pos, true);
}

describe('mustacheCompletionSource', () => {
  const source = mustacheCompletionSource(availableVariables('ROUTE_METRIC'));

  it('returns null outside a Mustache tag', () => {
    const ctx = makeContext('Hello world', 5);
    expect(source(ctx)).toBeNull();
  });

  it('offers completions right after {{', () => {
    const ctx = makeContext('Hello {{', 8);
    const result = source(ctx)!;
    expect(result).not.toBeNull();
    const paths = result.options.map((o) => o.label);
    expect(paths).toContain('env.slug');
    expect(paths).toContain('alert.firedAt');
  });

  it('narrows as user types', () => {
    const ctx = makeContext('{{ale', 5);
    const result = source(ctx)!;
    const paths = result.options.map((o) => o.label);
    expect(paths.every((p) => p.startsWith('ale'))).toBe(true);
    expect(paths).toContain('alert.firedAt');
    expect(paths).not.toContain('env.slug');
  });

  it('does not offer out-of-kind vars', () => {
    const ctx = makeContext('{{exchange', 10);
    const result = source(ctx)!;
    // ROUTE_METRIC does not include exchange.* — expect no exchange. completions
    expect(result.options).toHaveLength(0);
  });
});
```

Run: `cd ui && npm test -- mustache-completion`
Expected: 4 tests pass.

- [ ] **Step 3: Write the CM6 linter**

```ts
// ui/src/components/MustacheEditor/mustache-linter.ts
import { linter, type Diagnostic } from '@codemirror/lint';
import type { AlertVariable } from './alert-variables';

/** Lints a Mustache template for (a) unclosed `{{`, (b) references to out-of-scope variables.
 *  Unknown refs become amber warnings; unclosed `{{` becomes a red error. */
export function mustacheLinter(allowed: readonly AlertVariable[]) {
  return linter((view) => {
    const diags: Diagnostic[] = [];
    const text = view.state.doc.toString();

    // 1. Unclosed / unmatched braces.
    // A single `{{` without a matching `}}` before end-of-doc is an error.
    let i = 0;
    while (i < text.length) {
      const open = text.indexOf('{{', i);
      if (open === -1) break;
      const close = text.indexOf('}}', open + 2);
      if (close === -1) {
        diags.push({
          from: open,
          to: text.length,
          severity: 'error',
          message: 'Unclosed Mustache tag `{{` — add `}}` to close.',
        });
        break;
      }
      i = close + 2;
    }

    // 2. Stray `}}` with no preceding `{{` on the same token stream.
    // Approximation: count opens/closes; if doc ends with more closes than opens, flag last.
    const openCount = (text.match(/\{\{/g) ?? []).length;
    const closeCount = (text.match(/\}\}/g) ?? []).length;
    if (closeCount > openCount) {
      const lastClose = text.lastIndexOf('}}');
      diags.push({
        from: lastClose,
        to: lastClose + 2,
        severity: 'error',
        message: 'Unmatched `}}` — no opening `{{` for this close.',
      });
    }

    // 3. Unknown variable references (amber warning).
    const allowedSet = new Set(allowed.map((v) => v.path));
    const refRe = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g;
    let m: RegExpExecArray | null;
    while ((m = refRe.exec(text)) !== null) {
      const ref = m[1];
      if (!allowedSet.has(ref)) {
        diags.push({
          from: m.index,
          to: m.index + m[0].length,
          severity: 'warning',
          message: `\`${ref}\` is not available for this rule kind — will render as literal.`,
        });
      }
    }

    return diags;
  });
}
```

- [ ] **Step 4: Write linter tests**

```ts
// ui/src/components/MustacheEditor/mustache-linter.test.ts
import { describe, it, expect } from 'vitest';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { forEachDiagnostic } from '@codemirror/lint';
import { mustacheLinter } from './mustache-linter';
import { availableVariables } from './alert-variables';

function makeView(doc: string) {
  return new EditorView({
    state: EditorState.create({
      doc,
      extensions: [mustacheLinter(availableVariables('ROUTE_METRIC'))],
    }),
  });
}

async function diagnosticsFor(doc: string): Promise<
  Array<{ severity: string; message: string; from: number; to: number }>
> {
  const view = makeView(doc);
  // @codemirror/lint is async — wait a microtask for the source to run.
  await new Promise((r) => setTimeout(r, 50));
  const out: Array<{ severity: string; message: string; from: number; to: number }> = [];
  forEachDiagnostic(view.state, (d, from, to) =>
    out.push({ severity: d.severity, message: d.message, from, to }),
  );
  view.destroy();
  return out;
}

describe('mustacheLinter', () => {
  it('accepts a valid template with no warnings', async () => {
    const diags = await diagnosticsFor('Rule {{rule.name}} in env {{env.slug}}');
    expect(diags).toEqual([]);
  });

  it('flags unclosed {{', async () => {
    const diags = await diagnosticsFor('Hello {{alert.firedAt');
    expect(diags.find((d) => d.severity === 'error' && /unclosed/i.test(d.message))).toBeTruthy();
  });

  it('warns on unknown variable', async () => {
    const diags = await diagnosticsFor('{{exchange.id}}');
    const warn = diags.find((d) => d.severity === 'warning');
    expect(warn?.message).toMatch(/exchange\.id.*not available/);
  });
});
```

Run: `cd ui && npm test -- mustache-linter`
Expected: 3 tests pass.

- [ ] **Step 5: Commit**

```bash
git add ui/src/components/MustacheEditor/mustache-completion.ts \
        ui/src/components/MustacheEditor/mustache-completion.test.ts \
        ui/src/components/MustacheEditor/mustache-linter.ts \
        ui/src/components/MustacheEditor/mustache-linter.test.ts
git commit -m "feat(ui/alerts): CM6 completion + linter for Mustache templates

completion fires after {{ and narrows as the user types; apply() closes the
tag automatically. Linter raises an error on unclosed {{, a warning on
references that aren't in the allowed-variable set for the current condition
kind. Kind-specific allowed set comes from availableVariables()."
```

---

### Task 12: `<MustacheEditor />` shell component

**Files:**
- Create: `ui/src/components/MustacheEditor/MustacheEditor.tsx`
- Create: `ui/src/components/MustacheEditor/MustacheEditor.module.css`
- Create: `ui/src/components/MustacheEditor/MustacheEditor.test.tsx`

- [ ] **Step 1: Write failing integration test**

```tsx
// ui/src/components/MustacheEditor/MustacheEditor.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MustacheEditor } from './MustacheEditor';

describe('MustacheEditor', () => {
  it('renders the initial value', () => {
    render(
      <MustacheEditor
        value="Hello {{rule.name}}"
        onChange={() => {}}
        kind="ROUTE_METRIC"
        label="Title template"
      />,
    );
    expect(screen.getByText(/Hello/)).toBeInTheDocument();
  });

  it('calls onChange when the user types', () => {
    const onChange = vi.fn();
    render(
      <MustacheEditor
        value=""
        onChange={onChange}
        kind="ROUTE_METRIC"
        label="Title template"
      />,
    );
    const editor = screen.getByRole('textbox');
    fireEvent.input(editor, { target: { textContent: 'foo' } });
    // CM6 fires via transactions; at minimum the editor is rendered and focusable.
    expect(editor).toBeInTheDocument();
  });
});
```

- [ ] **Step 2: Implement the shell**

```tsx
// ui/src/components/MustacheEditor/MustacheEditor.tsx
import { useEffect, useRef } from 'react';
import { EditorState, type Extension } from '@codemirror/state';
import { EditorView, keymap, highlightSpecialChars, drawSelection, highlightActiveLine, lineNumbers } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { lintKeymap, lintGutter } from '@codemirror/lint';
import { mustacheCompletionSource } from './mustache-completion';
import { mustacheLinter } from './mustache-linter';
import { availableVariables } from './alert-variables';
import type { ConditionKind } from '../../api/queries/alertRules';
import css from './MustacheEditor.module.css';

export interface MustacheEditorProps {
  value: string;
  onChange: (value: string) => void;
  kind?: ConditionKind;
  reducedContext?: boolean;  // connection URL editor uses env-only context
  label: string;
  placeholder?: string;
  minHeight?: number;         // default 80
  singleLine?: boolean;       // used for header values / URL fields
}

export function MustacheEditor(props: MustacheEditorProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const viewRef = useRef<EditorView | null>(null);

  // Keep a ref to the latest onChange so the EditorView effect doesn't re-create on every render.
  const onChangeRef = useRef(props.onChange);
  onChangeRef.current = props.onChange;

  useEffect(() => {
    if (!hostRef.current) return;
    const allowed = availableVariables(props.kind, { reducedContext: props.reducedContext });

    const extensions: Extension[] = [
      history(),
      drawSelection(),
      highlightSpecialChars(),
      highlightActiveLine(),
      closeBrackets(),
      autocompletion({ override: [mustacheCompletionSource(allowed)] }),
      mustacheLinter(allowed),
      lintGutter(),
      EditorView.updateListener.of((u) => {
        if (u.docChanged) onChangeRef.current(u.state.doc.toString());
      }),
      keymap.of([
        ...closeBracketsKeymap,
        ...defaultKeymap,
        ...historyKeymap,
        ...completionKeymap,
        ...lintKeymap,
      ]),
    ];
    if (!props.singleLine) extensions.push(lineNumbers());
    if (props.singleLine) {
      // Prevent Enter from inserting a newline on single-line fields.
      extensions.push(
        EditorState.transactionFilter.of((tr) => {
          if (tr.newDoc.lines > 1) return [];
          return tr;
        }),
      );
    }

    const view = new EditorView({
      parent: hostRef.current,
      state: EditorState.create({
        doc: props.value,
        extensions,
      }),
    });
    viewRef.current = view;
    return () => {
      view.destroy();
      viewRef.current = null;
    };
    // Extensions built once per mount; prop-driven extensions rebuilt in the effect below.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [props.kind, props.reducedContext, props.singleLine]);

  // If the parent replaces `value` externally (e.g. promotion prefill), sync the doc.
  useEffect(() => {
    const view = viewRef.current;
    if (!view) return;
    const current = view.state.doc.toString();
    if (current !== props.value) {
      view.dispatch({ changes: { from: 0, to: current.length, insert: props.value } });
    }
  }, [props.value]);

  const minH = props.minHeight ?? (props.singleLine ? 32 : 80);

  return (
    <div className={css.wrapper}>
      <label className={css.label}>{props.label}</label>
      <div
        ref={hostRef}
        className={css.editor}
        role="textbox"
        aria-label={props.label}
        style={{ minHeight: minH }}
        data-placeholder={props.placeholder}
      />
    </div>
  );
}
```

- [ ] **Step 3: Write the CSS module**

```css
/* ui/src/components/MustacheEditor/MustacheEditor.module.css */
.wrapper { display: flex; flex-direction: column; gap: 4px; }
.label { font-size: 12px; color: var(--muted); }
.editor {
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
}
.editor :global(.cm-editor) { outline: none; }
.editor :global(.cm-editor.cm-focused) { border-color: var(--accent); }
.editor :global(.cm-content) { padding: 8px; font-family: var(--font-mono, ui-monospace, monospace); font-size: 13px; }
.editor :global(.cm-tooltip-autocomplete) {
  border-radius: 6px;
  border: 1px solid var(--border);
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
```

- [ ] **Step 4: Run tests**

```bash
cd ui && npm test -- MustacheEditor
```

Expected: 2 tests pass (rendering + input event sanity; CM6 internals are already covered by the completion/linter unit tests).

- [ ] **Step 5: Commit**

```bash
git add ui/src/components/MustacheEditor/MustacheEditor.tsx \
        ui/src/components/MustacheEditor/MustacheEditor.module.css \
        ui/src/components/MustacheEditor/MustacheEditor.test.tsx
git commit -m "feat(ui/alerts): MustacheEditor component (CM6 shell with completion + linter)

Wires the mustache-completion source and mustache-linter into a CodeMirror 6
EditorView. Accepts kind (filters variables) and reducedContext (env-only for
connection URLs). singleLine prevents newlines for URL/header fields. Host
ref syncs when the parent replaces value (promotion prefill)."
```

---

## Phase 4 — Routes, sidebar, top-nav integration

### Task 13: Register `/alerts/*` routes

**Files:**
- Modify: `ui/src/router.tsx`

- [ ] **Step 1: Add lazy imports at the top of `router.tsx`**

Insert after the existing `const OutboundConnectionEditor = lazy(...)` line (around line 22):

```tsx
const InboxPage        = lazy(() => import('./pages/Alerts/InboxPage'));
const AllAlertsPage    = lazy(() => import('./pages/Alerts/AllAlertsPage'));
const HistoryPage      = lazy(() => import('./pages/Alerts/HistoryPage'));
const RulesListPage    = lazy(() => import('./pages/Alerts/RulesListPage'));
const RuleEditorWizard = lazy(() => import('./pages/Alerts/RuleEditor/RuleEditorWizard'));
const SilencesPage     = lazy(() => import('./pages/Alerts/SilencesPage'));
```

- [ ] **Step 2: Add the `/alerts` route branch**

Inside the `<LayoutShell />` children array, after the `apps/:appId` entry and before the Admin block (around line 77), insert:

```tsx
// Alerts section (VIEWER+ via backend RBAC; UI is visible to all authenticated)
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
{ path: 'alerts/inbox',    element: <SuspenseWrapper><InboxPage /></SuspenseWrapper> },
{ path: 'alerts/all',      element: <SuspenseWrapper><AllAlertsPage /></SuspenseWrapper> },
{ path: 'alerts/history',  element: <SuspenseWrapper><HistoryPage /></SuspenseWrapper> },
{ path: 'alerts/rules',    element: <SuspenseWrapper><RulesListPage /></SuspenseWrapper> },
{ path: 'alerts/rules/new', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/rules/:id', element: <SuspenseWrapper><RuleEditorWizard /></SuspenseWrapper> },
{ path: 'alerts/silences', element: <SuspenseWrapper><SilencesPage /></SuspenseWrapper> },
```

- [ ] **Step 3: TypeScript compile passes**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: compilation fails only with "module not found" for the six pages (we'll implement them in Phase 5/6/7). Router syntax itself is valid.

- [ ] **Step 4: Create placeholder pages so compile passes**

Temporary stubs — each page replaced in later phases. These stubs must export a default function component and be typed.

```tsx
// ui/src/pages/Alerts/InboxPage.tsx
export default function InboxPage() {
  return <div>Inbox — coming soon</div>;
}
```

Repeat for `AllAlertsPage.tsx`, `HistoryPage.tsx`, `RulesListPage.tsx`, `SilencesPage.tsx`.

```tsx
// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
export default function RuleEditorWizard() {
  return <div>Rule editor wizard — coming soon</div>;
}
```

- [ ] **Step 5: TypeScript compile passes cleanly**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: PASS (zero errors).

- [ ] **Step 6: Commit**

```bash
git add ui/src/router.tsx ui/src/pages/Alerts/
git commit -m "feat(ui/alerts): register /alerts/* routes with placeholder pages

Adds Inbox, All, History, Rules, Rules/new|{id}, Silences routes. Placeholder
page stubs will be replaced in subsequent phases. /alerts redirects to
/alerts/inbox."
```

---

### Task 14: Add Alerts sidebar section

**Files:**
- Modify: `ui/src/components/sidebar-utils.ts`
- Modify: `ui/src/components/LayoutShell.tsx`
- Create: `ui/src/components/sidebar-utils.test.ts` (if missing)

Before modifying, run `gitnexus_impact({target:"buildAdminTreeNodes", direction:"upstream"})` so future maintainers see the blast radius was reviewed.

- [ ] **Step 1: Add `buildAlertsTreeNodes` helper to `sidebar-utils.ts`**

Append (after `buildAdminTreeNodes`):

```ts
import { AlertTriangle, Inbox, List, ScrollText, BellOff, Bell } from 'lucide-react';

/** Tree nodes for the Alerts sidebar section. */
export function buildAlertsTreeNodes(): SidebarTreeNode[] {
  const icon = (el: ReactNode) => el;
  return [
    { id: 'alerts-inbox',   label: 'Inbox',    path: '/alerts/inbox',    icon: icon(createElement(Inbox, { size: 14 })) },
    { id: 'alerts-all',     label: 'All',      path: '/alerts/all',      icon: icon(createElement(List, { size: 14 })) },
    { id: 'alerts-rules',   label: 'Rules',    path: '/alerts/rules',    icon: icon(createElement(AlertTriangle, { size: 14 })) },
    { id: 'alerts-silences',label: 'Silences', path: '/alerts/silences', icon: icon(createElement(BellOff, { size: 14 })) },
    { id: 'alerts-history', label: 'History',  path: '/alerts/history',  icon: icon(createElement(ScrollText, { size: 14 })) },
  ];
}
```

- [ ] **Step 2: Add a test for `buildAlertsTreeNodes`**

```ts
// ui/src/components/sidebar-utils.test.ts
import { describe, it, expect } from 'vitest';
import { buildAlertsTreeNodes } from './sidebar-utils';

describe('buildAlertsTreeNodes', () => {
  it('produces 5 entries with absolute /alerts/* paths', () => {
    const nodes = buildAlertsTreeNodes();
    expect(nodes.map((n) => n.path)).toEqual([
      '/alerts/inbox',
      '/alerts/all',
      '/alerts/rules',
      '/alerts/silences',
      '/alerts/history',
    ]);
  });
});
```

Run: `cd ui && npm test -- sidebar-utils`
Expected: 1 test passes.

- [ ] **Step 3: Mount the Alerts section in `LayoutShell.tsx`**

Near the other section constants (around line 279 where `SK_APPS`, `SK_ADMIN`, `SK_COLLAPSED` are defined), add:

```ts
const SK_ALERTS = 'sidebar:section:alerts';
```

Import the helper alongside existing imports from `sidebar-utils`:

```ts
import {
  buildAppTreeNodes,
  buildAdminTreeNodes,
  buildAlertsTreeNodes,
  // ... existing
} from './sidebar-utils';
```

Add an `alertsOpen` state near the existing `appsOpen` / `adminOpen` (around line 370):

```ts
const isAlertsPage = location.pathname.startsWith('/alerts');
const [alertsOpen, setAlertsOpen] = useState(() =>
  isAlertsPage ? true : readCollapsed(SK_ALERTS, false),
);

const toggleAlerts = useCallback(() => {
  if (!isAlertsPage) {
    navigate('/alerts/inbox');
    return;
  }
  setAlertsOpen((prev) => {
    writeCollapsed(SK_ALERTS, !prev);
    return !prev;
  });
}, [isAlertsPage, navigate]);

const alertsTreeNodes = useMemo(() => buildAlertsTreeNodes(), []);
```

Add the accordion/closing effect — when entering `/alerts`, collapse Apps + Admin + Starred (same pattern as the existing admin accordion around line 376):

```ts
const prevAlertsRef = useRef(isAlertsPage);
useEffect(() => {
  if (isAlertsPage && !prevAlertsRef.current) {
    setAppsOpen(false);
    setStarredOpen(false);
    setAdminOpen(false);
    setAlertsOpen(true);
  } else if (!isAlertsPage && prevAlertsRef.current) {
    setAlertsOpen(false);
  }
  prevAlertsRef.current = isAlertsPage;
}, [isAlertsPage]); // eslint-disable-line react-hooks/exhaustive-deps
```

Render a new `<Sidebar.Section>` for Alerts between Applications and Starred (around line 753, right after the Applications section closing `</Sidebar.Section>`):

```tsx
<Sidebar.Section
  icon={createElement(Bell, { size: 16 })}
  label="Alerts"
  open={alertsOpen}
  onToggle={toggleAlerts}
  active={isAlertsPage}
>
  <SidebarTree
    nodes={alertsTreeNodes}
    selectedPath={location.pathname}
    isStarred={isStarred}
    onToggleStar={toggleStar}
    filterQuery={filterQuery}
    persistKey="alerts"
    autoRevealPath={sidebarRevealPath}
    onNavigate={handleSidebarNavigate}
  />
</Sidebar.Section>
```

(Also add `Bell` to the existing `lucide-react` import.)

- [ ] **Step 4: TypeScript compile**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add ui/src/components/sidebar-utils.ts ui/src/components/sidebar-utils.test.ts ui/src/components/LayoutShell.tsx
git commit -m "feat(ui/alerts): Alerts sidebar section with Inbox/All/Rules/Silences/History

Adds an Alerts accordion-mode section (collapses Apps + Admin + Starred on
enter, like Admin). Icons from lucide-react. Section state persisted to
sidebar:section:alerts."
```

---

### Task 15: Mount `<NotificationBell />` in TopBar

**Files:**
- Modify: `ui/src/components/LayoutShell.tsx`

- [ ] **Step 1: Import the bell**

Near the existing `import { AboutMeDialog } from './AboutMeDialog';`, add:

```ts
import { NotificationBell } from './NotificationBell';
```

- [ ] **Step 2: Mount the bell in TopBar children**

Locate the `<TopBar>` children (around line 840, `<SearchTrigger .../>` is the first child). Insert the bell before `SearchTrigger`:

```tsx
<NotificationBell />
<SearchTrigger onClick={() => setPaletteOpen(true)} />
```

Rationale: the spec asks for placement "between env selector and user menu". Since `TopBar` renders the environment selector on the left and user menu on the right (both in fixed slots), the bell lives in children as the left-most child, which is rendered in the top-right cluster adjacent to the user menu area.

- [ ] **Step 3: TypeScript compile**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: PASS.

- [ ] **Step 4: Manual smoke (if local backend available)**

Start `npm run dev:local`, log in, pick an env, confirm the bell renders next to the search icon. (Golden-path Playwright coverage lands in Task 32.)

- [ ] **Step 5: Commit**

```bash
git add ui/src/components/LayoutShell.tsx
git commit -m "feat(ui/alerts): mount NotificationBell in TopBar"
```

---

## Phase 5 — Inbox / All / History pages

### Task 16: `InboxPage`

**Files:**
- Replace: `ui/src/pages/Alerts/InboxPage.tsx`
- Create: `ui/src/pages/Alerts/AlertRow.tsx`
- Create: `ui/src/pages/Alerts/alerts-page.module.css`

- [ ] **Step 1: Build a reusable alert row**

```tsx
// ui/src/pages/Alerts/AlertRow.tsx
import { Link } from 'react-router';
import { Button, useToast } from '@cameleer/design-system';
import { AlertStateChip } from '../../components/AlertStateChip';
import { SeverityBadge } from '../../components/SeverityBadge';
import type { AlertDto } from '../../api/queries/alerts';
import { useAckAlert, useMarkAlertRead } from '../../api/queries/alerts';
import css from './alerts-page.module.css';

export function AlertRow({ alert, unread }: { alert: AlertDto; unread: boolean }) {
  const ack = useAckAlert();
  const markRead = useMarkAlertRead();
  const { toast } = useToast();

  const onAck = async () => {
    try {
      await ack.mutateAsync(alert.id);
      toast({ title: 'Acknowledged', description: alert.title, variant: 'success' });
    } catch (e) {
      toast({ title: 'Ack failed', description: String(e), variant: 'error' });
    }
  };

  return (
    <div
      className={`${css.row} ${unread ? css.rowUnread : ''}`}
      data-testid={`alert-row-${alert.id}`}
    >
      <SeverityBadge severity={alert.severity} />
      <div className={css.body}>
        <Link to={`/alerts/inbox/${alert.id}`} onClick={() => markRead.mutate(alert.id)}>
          <strong>{alert.title}</strong>
        </Link>
        <div className={css.meta}>
          <AlertStateChip state={alert.state} silenced={alert.silenced} />
          <span className={css.time}>{alert.firedAt}</span>
        </div>
        <p className={css.message}>{alert.message}</p>
      </div>
      <div className={css.actions}>
        {alert.state === 'FIRING' && (
          <Button size="small" variant="secondary" onClick={onAck} disabled={ack.isPending}>
            Ack
          </Button>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Build the CSS module**

```css
/* ui/src/pages/Alerts/alerts-page.module.css */
.page { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.row {
  display: grid;
  grid-template-columns: 72px 1fr auto;
  gap: 12px;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
}
.rowUnread { border-left: 3px solid var(--accent); }
.body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.meta { display: flex; gap: 8px; font-size: 12px; color: var(--muted); }
.time { font-variant-numeric: tabular-nums; }
.message { margin: 0; font-size: 13px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; align-items: center; }
.empty { padding: 48px; text-align: center; color: var(--muted); }
```

- [ ] **Step 3: Write `InboxPage`**

```tsx
// ui/src/pages/Alerts/InboxPage.tsx
import { useMemo } from 'react';
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useAlerts, useBulkReadAlerts } from '../../api/queries/alerts';
import { AlertRow } from './AlertRow';
import css from './alerts-page.module.css';

export default function InboxPage() {
  const { data, isLoading, error } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
  const bulkRead = useBulkReadAlerts();
  const { toast } = useToast();

  const unreadIds = useMemo(
    () => (data ?? []).filter((a) => a.state === 'FIRING').map((a) => a.id),
    [data],
  );

  if (isLoading) return <PageLoader />;
  if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;

  const rows = data ?? [];

  const onMarkAllRead = async () => {
    if (unreadIds.length === 0) return;
    try {
      await bulkRead.mutateAsync(unreadIds);
      toast({ title: `Marked ${unreadIds.length} as read`, variant: 'success' });
    } catch (e) {
      toast({ title: 'Bulk read failed', description: String(e), variant: 'error' });
    }
  };

  return (
    <div className={css.page}>
      <div className={css.toolbar}>
        <SectionHeader>Inbox</SectionHeader>
        <Button variant="secondary" onClick={onMarkAllRead} disabled={bulkRead.isPending || unreadIds.length === 0}>
          Mark all read
        </Button>
      </div>
      {rows.length === 0 ? (
        <div className={css.empty}>No open alerts for you in this environment.</div>
      ) : (
        rows.map((a) => <AlertRow key={a.id} alert={a} unread={a.state === 'FIRING'} />)
      )}
    </div>
  );
}
```

- [ ] **Step 4: TypeScript compile**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add ui/src/pages/Alerts/InboxPage.tsx ui/src/pages/Alerts/AlertRow.tsx ui/src/pages/Alerts/alerts-page.module.css
git commit -m "feat(ui/alerts): InboxPage with ack + bulk-read actions

AlertRow is reused by AllAlertsPage and HistoryPage. Marking a row as read
happens when its link is followed (the detail sub-route will be added in
phase 10 polish). FIRING rows get an amber left border."
```

---

### Task 17: `AllAlertsPage` + `HistoryPage`

**Files:**
- Replace: `ui/src/pages/Alerts/AllAlertsPage.tsx`
- Replace: `ui/src/pages/Alerts/HistoryPage.tsx`

- [ ] **Step 1: Write `AllAlertsPage`**

```tsx
// ui/src/pages/Alerts/AllAlertsPage.tsx
import { useState } from 'react';
import { SectionHeader, Button } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useAlerts, type AlertDto } from '../../api/queries/alerts';
import { AlertRow } from './AlertRow';
import css from './alerts-page.module.css';

const STATE_FILTERS: Array<{ label: string; values: AlertDto['state'][] }> = [
  { label: 'Open',  values: ['PENDING', 'FIRING', 'ACKNOWLEDGED'] },
  { label: 'Firing', values: ['FIRING'] },
  { label: 'Acked',  values: ['ACKNOWLEDGED'] },
  { label: 'All',    values: ['PENDING', 'FIRING', 'ACKNOWLEDGED', 'RESOLVED'] },
];

export default function AllAlertsPage() {
  const [filterIdx, setFilterIdx] = useState(0);
  const filter = STATE_FILTERS[filterIdx];
  const { data, isLoading, error } = useAlerts({ state: filter.values, limit: 200 });

  if (isLoading) return <PageLoader />;
  if (error) return <div className={css.page}>Failed to load alerts: {String(error)}</div>;

  const rows = data ?? [];

  return (
    <div className={css.page}>
      <div className={css.toolbar}>
        <SectionHeader>All alerts</SectionHeader>
        <div style={{ display: 'flex', gap: 4 }}>
          {STATE_FILTERS.map((f, i) => (
            <Button
              key={f.label}
              size="small"
              variant={i === filterIdx ? 'primary' : 'secondary'}
              onClick={() => setFilterIdx(i)}
            >
              {f.label}
            </Button>
          ))}
        </div>
      </div>
      {rows.length === 0 ? (
        <div className={css.empty}>No alerts match this filter.</div>
      ) : (
        rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
      )}
    </div>
  );
}
```

- [ ] **Step 2: Write `HistoryPage`**

```tsx
// ui/src/pages/Alerts/HistoryPage.tsx
import { SectionHeader } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { useAlerts } from '../../api/queries/alerts';
import { AlertRow } from './AlertRow';
import css from './alerts-page.module.css';

export default function HistoryPage() {
  const { data, isLoading, error } = useAlerts({ state: 'RESOLVED', limit: 200 });

  if (isLoading) return <PageLoader />;
  if (error) return <div className={css.page}>Failed to load history: {String(error)}</div>;

  const rows = data ?? [];

  return (
    <div className={css.page}>
      <div className={css.toolbar}>
        <SectionHeader>History</SectionHeader>
      </div>
      {rows.length === 0 ? (
        <div className={css.empty}>No resolved alerts in retention window.</div>
      ) : (
        rows.map((a) => <AlertRow key={a.id} alert={a} unread={false} />)
      )}
    </div>
  );
}
```

- [ ] **Step 3: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/AllAlertsPage.tsx ui/src/pages/Alerts/HistoryPage.tsx
git commit -m "feat(ui/alerts): AllAlertsPage + HistoryPage

AllAlertsPage: state filter chips (Open/Firing/Acked/All).
HistoryPage: RESOLVED filter, respects retention window."
```

---

## Phase 6 — Rule list + editor wizard

### Task 18: `RulesListPage`

**Files:**
- Replace: `ui/src/pages/Alerts/RulesListPage.tsx`

- [ ] **Step 1: Write the page**

```tsx
// ui/src/pages/Alerts/RulesListPage.tsx
import { Link, useNavigate } from 'react-router';
import { Button, SectionHeader, Toggle, useToast, Badge } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { SeverityBadge } from '../../components/SeverityBadge';
import {
  useAlertRules,
  useDeleteAlertRule,
  useSetAlertRuleEnabled,
  type AlertRuleResponse,
} from '../../api/queries/alertRules';
import { useEnvironments } from '../../api/queries/admin/environments';
import { useSelectedEnv } from '../../api/queries/alertMeta';
import sectionStyles from '../../styles/section-card.module.css';

export default function RulesListPage() {
  const navigate = useNavigate();
  const env = useSelectedEnv();
  const { data: rules, isLoading, error } = useAlertRules();
  const { data: envs } = useEnvironments();
  const setEnabled = useSetAlertRuleEnabled();
  const deleteRule = useDeleteAlertRule();
  const { toast } = useToast();

  if (isLoading) return <PageLoader />;
  if (error) return <div>Failed to load rules: {String(error)}</div>;

  const rows = rules ?? [];
  const otherEnvs = (envs ?? []).filter((e) => e.slug !== env);

  const onToggle = async (r: AlertRuleResponse) => {
    try {
      await setEnabled.mutateAsync({ id: r.id, enabled: !r.enabled });
      toast({ title: r.enabled ? 'Disabled' : 'Enabled', description: r.name, variant: 'success' });
    } catch (e) {
      toast({ title: 'Toggle failed', description: String(e), variant: 'error' });
    }
  };

  const onDelete = async (r: AlertRuleResponse) => {
    if (!confirm(`Delete rule "${r.name}"? Fired alerts are preserved via rule_snapshot.`)) return;
    try {
      await deleteRule.mutateAsync(r.id);
      toast({ title: 'Deleted', description: r.name, variant: 'success' });
    } catch (e) {
      toast({ title: 'Delete failed', description: String(e), variant: 'error' });
    }
  };

  const onPromote = (r: AlertRuleResponse, targetEnvSlug: string) => {
    navigate(`/alerts/rules/new?promoteFrom=${env}&ruleId=${r.id}&targetEnv=${targetEnvSlug}`);
  };

  return (
    <div style={{ padding: 16 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
        <SectionHeader>Alert rules</SectionHeader>
        <Link to="/alerts/rules/new">
          <Button variant="primary">New rule</Button>
        </Link>
      </div>
      <div className={sectionStyles.section}>
        {rows.length === 0 ? (
          <p>No rules yet. Create one to start evaluating alerts for this environment.</p>
        ) : (
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead>
              <tr>
                <th style={{ textAlign: 'left' }}>Name</th>
                <th style={{ textAlign: 'left' }}>Kind</th>
                <th style={{ textAlign: 'left' }}>Severity</th>
                <th style={{ textAlign: 'left' }}>Enabled</th>
                <th style={{ textAlign: 'left' }}>Targets</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {rows.map((r) => (
                <tr key={r.id}>
                  <td><Link to={`/alerts/rules/${r.id}`}>{r.name}</Link></td>
                  <td><Badge label={r.conditionKind} color="auto" variant="outlined" /></td>
                  <td><SeverityBadge severity={r.severity} /></td>
                  <td>
                    <Toggle
                      checked={r.enabled}
                      onChange={() => onToggle(r)}
                      disabled={setEnabled.isPending}
                    />
                  </td>
                  <td>{r.targets.length}</td>
                  <td style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
                    {otherEnvs.length > 0 && (
                      <select
                        value=""
                        onChange={(e) => { if (e.target.value) onPromote(r, e.target.value); }}
                        aria-label={`Promote ${r.name} to another env`}
                      >
                        <option value="">Promote to ▾</option>
                        {otherEnvs.map((e) => (
                          <option key={e.slug} value={e.slug}>{e.slug}</option>
                        ))}
                      </select>
                    )}
                    <Button variant="secondary" onClick={() => onDelete(r)} disabled={deleteRule.isPending}>
                      Delete
                    </Button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RulesListPage.tsx
git commit -m "feat(ui/alerts): RulesListPage with enable/disable, delete, env promotion

Promotion dropdown builds a /alerts/rules/new URL with promoteFrom, ruleId,
and targetEnv query params — the wizard will read these in Task 24 and
pre-fill the form with source-env prefill + client-side warnings."
```

---

### Task 19: Wizard form state + shell

**Files:**
- Create: `ui/src/pages/Alerts/RuleEditor/form-state.ts`
- Create: `ui/src/pages/Alerts/RuleEditor/form-state.test.ts`
- Replace: `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/wizard.module.css`

- [ ] **Step 1: Write the form-state module**

```ts
// ui/src/pages/Alerts/RuleEditor/form-state.ts
import type {
  AlertRuleRequest,
  AlertRuleResponse,
  ConditionKind,
  AlertCondition,
} from '../../../api/queries/alertRules';

export type WizardStep = 'scope' | 'condition' | 'trigger' | 'notify' | 'review';
export const WIZARD_STEPS: WizardStep[] = ['scope', 'condition', 'trigger', 'notify', 'review'];

export interface FormState {
  name: string;
  description: string;
  severity: 'CRITICAL' | 'WARNING' | 'INFO';
  enabled: boolean;

  // Scope (radio: env-wide | app | route | agent)
  scopeKind: 'env' | 'app' | 'route' | 'agent';
  appSlug: string;
  routeId: string;
  agentId: string;

  conditionKind: ConditionKind;
  condition: Partial<AlertCondition>;

  evaluationIntervalSeconds: number;
  forDurationSeconds: number;
  reNotifyMinutes: number;

  notificationTitleTmpl: string;
  notificationMessageTmpl: string;

  webhooks: Array<{
    outboundConnectionId: string;
    bodyOverride: string;
    headerOverrides: Array<{ key: string; value: string }>;
  }>;

  targets: Array<{ targetKind: 'USER' | 'GROUP' | 'ROLE'; targetId: string }>;
}

export function initialForm(existing?: AlertRuleResponse): FormState {
  if (!existing) {
    return {
      name: '',
      description: '',
      severity: 'WARNING',
      enabled: true,
      scopeKind: 'env',
      appSlug: '',
      routeId: '',
      agentId: '',
      conditionKind: 'ROUTE_METRIC',
      condition: { kind: 'ROUTE_METRIC' } as Partial<AlertCondition>,
      evaluationIntervalSeconds: 60,
      forDurationSeconds: 0,
      reNotifyMinutes: 60,
      notificationTitleTmpl: '{{rule.name}} is firing',
      notificationMessageTmpl: 'Alert {{alert.id}} fired at {{alert.firedAt}}',
      webhooks: [],
      targets: [],
    };
  }
  const scope = (existing.condition as any)?.scope ?? {};
  const scopeKind: FormState['scopeKind'] = scope.agentId
    ? 'agent'
    : scope.routeId
      ? 'route'
      : scope.appSlug
        ? 'app'
        : 'env';
  return {
    name: existing.name,
    description: existing.description ?? '',
    severity: existing.severity,
    enabled: existing.enabled,
    scopeKind,
    appSlug: scope.appSlug ?? '',
    routeId: scope.routeId ?? '',
    agentId: scope.agentId ?? '',
    conditionKind: existing.conditionKind,
    condition: existing.condition,
    evaluationIntervalSeconds: existing.evaluationIntervalSeconds,
    forDurationSeconds: existing.forDurationSeconds,
    reNotifyMinutes: existing.reNotifyMinutes,
    notificationTitleTmpl: existing.notificationTitleTmpl,
    notificationMessageTmpl: existing.notificationMessageTmpl,
    webhooks: (existing.webhooks ?? []).map((w) => ({
      outboundConnectionId: (w as any).outboundConnectionId,
      bodyOverride: (w as any).bodyOverride ?? '',
      headerOverrides: Object.entries(((w as any).headerOverrides ?? {}) as Record<string, string>)
        .map(([key, value]) => ({ key, value })),
    })),
    targets: existing.targets ?? [],
  };
}

export function toRequest(f: FormState): AlertRuleRequest {
  const scope: Record<string, string | undefined> = {};
  if (f.scopeKind === 'app' || f.scopeKind === 'route' || f.scopeKind === 'agent') scope.appSlug = f.appSlug || undefined;
  if (f.scopeKind === 'route') scope.routeId = f.routeId || undefined;
  if (f.scopeKind === 'agent') scope.agentId = f.agentId || undefined;

  const condition = { ...f.condition, kind: f.conditionKind, scope } as AlertCondition;

  return {
    name: f.name,
    description: f.description || undefined,
    severity: f.severity,
    enabled: f.enabled,
    conditionKind: f.conditionKind,
    condition,
    evaluationIntervalSeconds: f.evaluationIntervalSeconds,
    forDurationSeconds: f.forDurationSeconds,
    reNotifyMinutes: f.reNotifyMinutes,
    notificationTitleTmpl: f.notificationTitleTmpl,
    notificationMessageTmpl: f.notificationMessageTmpl,
    webhooks: f.webhooks.map((w) => ({
      outboundConnectionId: w.outboundConnectionId,
      bodyOverride: w.bodyOverride || undefined,
      headerOverrides: Object.fromEntries(w.headerOverrides.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value])),
    })),
    targets: f.targets,
  } as AlertRuleRequest;
}

export function validateStep(step: WizardStep, f: FormState): string[] {
  const errs: string[] = [];
  if (step === 'scope') {
    if (!f.name.trim()) errs.push('Name is required.');
    if (f.scopeKind !== 'env' && !f.appSlug.trim()) errs.push('App is required for app/route/agent scope.');
    if (f.scopeKind === 'route' && !f.routeId.trim()) errs.push('Route id is required for route scope.');
    if (f.scopeKind === 'agent' && !f.agentId.trim()) errs.push('Agent id is required for agent scope.');
  }
  if (step === 'condition') {
    if (!f.conditionKind) errs.push('Condition kind is required.');
  }
  if (step === 'trigger') {
    if (f.evaluationIntervalSeconds < 5) errs.push('Evaluation interval must be ≥ 5 s.');
    if (f.forDurationSeconds < 0) errs.push('For-duration must be ≥ 0.');
    if (f.reNotifyMinutes < 0) errs.push('Re-notify cadence must be ≥ 0.');
  }
  if (step === 'notify') {
    if (!f.notificationTitleTmpl.trim()) errs.push('Notification title template is required.');
    if (!f.notificationMessageTmpl.trim()) errs.push('Notification message template is required.');
  }
  return errs;
}
```

- [ ] **Step 2: Write tests**

```ts
// ui/src/pages/Alerts/RuleEditor/form-state.test.ts
import { describe, it, expect } from 'vitest';
import { initialForm, toRequest, validateStep } from './form-state';

describe('initialForm', () => {
  it('defaults to env-wide ROUTE_METRIC with safe intervals', () => {
    const f = initialForm();
    expect(f.scopeKind).toBe('env');
    expect(f.conditionKind).toBe('ROUTE_METRIC');
    expect(f.evaluationIntervalSeconds).toBeGreaterThanOrEqual(5);
    expect(f.enabled).toBe(true);
  });
});

describe('toRequest', () => {
  it('strips empty scope fields for env-wide rules', () => {
    const f = initialForm();
    f.name = 'test';
    const req = toRequest(f);
    const scope = (req.condition as any).scope;
    expect(scope.appSlug).toBeUndefined();
    expect(scope.routeId).toBeUndefined();
    expect(scope.agentId).toBeUndefined();
  });

  it('includes appSlug for app/route/agent scopes', () => {
    const f = initialForm();
    f.scopeKind = 'app';
    f.appSlug = 'orders';
    const req = toRequest(f);
    expect((req.condition as any).scope.appSlug).toBe('orders');
  });
});

describe('validateStep', () => {
  it('flags blank name on scope step', () => {
    expect(validateStep('scope', initialForm())).toContain('Name is required.');
  });
  it('flags app requirement for app-scope', () => {
    const f = initialForm();
    f.name = 'x';
    f.scopeKind = 'app';
    expect(validateStep('scope', f).some((e) => /App is required/.test(e))).toBe(true);
  });
  it('flags intervals below floor on trigger step', () => {
    const f = initialForm();
    f.evaluationIntervalSeconds = 1;
    expect(validateStep('trigger', f)).toContain('Evaluation interval must be ≥ 5 s.');
  });
});
```

Run: `cd ui && npm test -- form-state`
Expected: 6 tests pass.

- [ ] **Step 3: Write the wizard shell**

```tsx
// ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
import { useMemo, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { Button, SectionHeader, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../../components/PageLoader';
import { useAlertRule, useCreateAlertRule, useUpdateAlertRule } from '../../../api/queries/alertRules';
import { initialForm, toRequest, validateStep, WIZARD_STEPS, type FormState, type WizardStep } from './form-state';
import { ScopeStep } from './ScopeStep';
import { ConditionStep } from './ConditionStep';
import { TriggerStep } from './TriggerStep';
import { NotifyStep } from './NotifyStep';
import { ReviewStep } from './ReviewStep';
import { prefillFromPromotion } from './promotion-prefill';
import { useAlertRule as useSourceRule } from '../../../api/queries/alertRules';
import css from './wizard.module.css';

const STEP_LABELS: Record<WizardStep, string> = {
  scope: '1. Scope',
  condition: '2. Condition',
  trigger: '3. Trigger',
  notify: '4. Notify',
  review: '5. Review',
};

export default function RuleEditorWizard() {
  const navigate = useNavigate();
  const { id } = useParams<{ id?: string }>();
  const [search] = useSearchParams();
  const { toast } = useToast();

  const isEdit = !!id;
  const existingQuery = useAlertRule(isEdit ? id : undefined);

  // Promotion prefill uses a separate query against the source env; implemented in Task 24.
  const promoteFrom = search.get('promoteFrom') ?? undefined;
  const promoteRuleId = search.get('ruleId') ?? undefined;
  const sourceRuleQuery = useSourceRule(promoteFrom ? promoteRuleId : undefined);

  const [step, setStep] = useState<WizardStep>('scope');
  const [form, setForm] = useState<FormState | null>(null);

  // Initialize form once the existing or source rule loads.
  const ready = useMemo(() => {
    if (form) return true;
    if (isEdit && existingQuery.data) {
      setForm(initialForm(existingQuery.data));
      return true;
    }
    if (promoteFrom && sourceRuleQuery.data) {
      setForm(prefillFromPromotion(sourceRuleQuery.data));
      return true;
    }
    if (!isEdit && !promoteFrom) {
      setForm(initialForm());
      return true;
    }
    return false;
  }, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data]);

  const create = useCreateAlertRule();
  const update = useUpdateAlertRule(id ?? '');

  if (!ready || !form) return <PageLoader />;

  const idx = WIZARD_STEPS.indexOf(step);
  const errors = validateStep(step, form);

  const onNext = () => {
    if (errors.length > 0) {
      toast({ title: 'Fix validation errors before continuing', description: errors.join(' · '), variant: 'error' });
      return;
    }
    if (idx < WIZARD_STEPS.length - 1) setStep(WIZARD_STEPS[idx + 1]);
  };
  const onBack = () => { if (idx > 0) setStep(WIZARD_STEPS[idx - 1]); };

  const onSave = async () => {
    try {
      if (isEdit) {
        await update.mutateAsync(toRequest(form));
        toast({ title: 'Rule updated', description: form.name, variant: 'success' });
      } else {
        await create.mutateAsync(toRequest(form));
        toast({ title: 'Rule created', description: form.name, variant: 'success' });
      }
      navigate('/alerts/rules');
    } catch (e) {
      toast({ title: 'Save failed', description: String(e), variant: 'error' });
    }
  };

  const body =
    step === 'scope'     ? <ScopeStep     form={form} setForm={setForm} /> :
    step === 'condition' ? <ConditionStep form={form} setForm={setForm} /> :
    step === 'trigger'   ? <TriggerStep   form={form} setForm={setForm} /> :
    step === 'notify'    ? <NotifyStep    form={form} setForm={setForm} ruleId={id} /> :
                           <ReviewStep    form={form} />;

  return (
    <div className={css.wizard}>
      <div className={css.header}>
        <SectionHeader>{isEdit ? `Edit rule: ${form.name}` : 'New alert rule'}</SectionHeader>
        {promoteFrom && (
          <div className={css.promoteBanner}>
            Promoting from <code>{promoteFrom}</code> — review and adjust, then save.
          </div>
        )}
      </div>
      <nav className={css.steps}>
        {WIZARD_STEPS.map((s, i) => (
          <button
            key={s}
            className={`${css.step} ${step === s ? css.stepActive : ''} ${i < idx ? css.stepDone : ''}`}
            onClick={() => setStep(s)}
          >
            {STEP_LABELS[s]}
          </button>
        ))}
      </nav>
      <div className={css.stepBody}>{body}</div>
      <div className={css.footer}>
        <Button variant="secondary" onClick={onBack} disabled={idx === 0}>Back</Button>
        {idx < WIZARD_STEPS.length - 1 ? (
          <Button variant="primary" onClick={onNext}>Next</Button>
        ) : (
          <Button variant="primary" onClick={onSave} disabled={create.isPending || update.isPending}>
            {isEdit ? 'Save changes' : 'Create rule'}
          </Button>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 4: Write the CSS module**

```css
/* ui/src/pages/Alerts/RuleEditor/wizard.module.css */
.wizard { padding: 16px; display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.promoteBanner {
  padding: 8px 12px;
  background: var(--amber-bg, rgba(255, 180, 0, 0.12));
  border: 1px solid var(--amber);
  border-radius: 6px;
  font-size: 13px;
}
.steps { display: flex; gap: 8px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.step {
  background: none; border: none; padding: 8px 12px;
  border-bottom: 2px solid transparent; cursor: pointer;
  color: var(--muted);
  font-size: 13px;
}
.stepActive { color: var(--fg); border-bottom-color: var(--accent); }
.stepDone  { color: var(--fg); }
.stepBody { min-height: 320px; }
.footer { display: flex; justify-content: space-between; }
```

- [ ] **Step 5: Create step stubs so compile passes**

`ScopeStep.tsx`, `ConditionStep.tsx`, `TriggerStep.tsx`, `NotifyStep.tsx`, `ReviewStep.tsx` each as:

```tsx
// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx  (repeat for each)
import type { FormState } from './form-state';
export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  return <div>Scope step — TODO Task 20</div>;
}
```

And `promotion-prefill.ts`:

```ts
// ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
import { initialForm, type FormState } from './form-state';
import type { AlertRuleResponse } from '../../../api/queries/alertRules';

export function prefillFromPromotion(source: AlertRuleResponse): FormState {
  // Reuse the edit-prefill for now; Task 24 adds scope-adjustment + warnings.
  const f = initialForm(source);
  f.name = `${source.name} (copy)`;
  return f;
}
```

- [ ] **Step 6: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
cd ui && npm test -- form-state
git add ui/src/pages/Alerts/RuleEditor/
git commit -m "feat(ui/alerts): rule editor wizard shell + form-state module

Wizard navigates steps (scope/condition/trigger/notify/review) with
per-step validation. form-state module is the single source of truth for
the rule form; initialForm/toRequest/validateStep are unit-tested. Step
components are stubbed and implemented in Tasks 20–24."
```

---

### Task 20: `ScopeStep`

**Files:**
- Replace: `ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx`

- [ ] **Step 1: Implement the scope form**

```tsx
// ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
import { FormField, Input, Select } from '@cameleer/design-system';
import { useCatalog } from '../../../api/queries/catalog';
import { useAgents } from '../../../api/queries/agents';
import { useSelectedEnv } from '../../../api/queries/alertMeta';
import type { FormState } from './form-state';

const SEVERITY_OPTIONS = [
  { value: 'CRITICAL', label: 'Critical' },
  { value: 'WARNING',  label: 'Warning' },
  { value: 'INFO',     label: 'Info' },
];

const SCOPE_OPTIONS = [
  { value: 'env',   label: 'Environment-wide' },
  { value: 'app',   label: 'Single app' },
  { value: 'route', label: 'Single route' },
  { value: 'agent', label: 'Single agent' },
];

export function ScopeStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const env = useSelectedEnv();
  const { data: catalog } = useCatalog(env);
  const { data: agents } = useAgents();

  const apps = (catalog ?? []).map((a: any) => ({ slug: a.slug, name: a.displayName ?? a.slug, routes: a.routes ?? [] }));
  const selectedApp = apps.find((a) => a.slug === form.appSlug);
  const routes = selectedApp?.routes ?? [];
  const appAgents = (agents ?? []).filter((a: any) => a.applicationId === form.appSlug);

  return (
    <div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
      <FormField label="Name">
        <Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Order API error rate" />
      </FormField>
      <FormField label="Description">
        <Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
      </FormField>
      <FormField label="Severity">
        <Select
          value={form.severity}
          onChange={(v) => setForm({ ...form, severity: v as FormState['severity'] })}
          options={SEVERITY_OPTIONS}
        />
      </FormField>
      <FormField label="Scope">
        <Select
          value={form.scopeKind}
          onChange={(v) => setForm({ ...form, scopeKind: v as FormState['scopeKind'] })}
          options={SCOPE_OPTIONS}
        />
      </FormField>
      {form.scopeKind !== 'env' && (
        <FormField label="App">
          <Select
            value={form.appSlug}
            onChange={(v) => setForm({ ...form, appSlug: v, routeId: '', agentId: '' })}
            options={apps.map((a) => ({ value: a.slug, label: a.name }))}
          />
        </FormField>
      )}
      {form.scopeKind === 'route' && (
        <FormField label="Route">
          <Select
            value={form.routeId}
            onChange={(v) => setForm({ ...form, routeId: v })}
            options={routes.map((r: any) => ({ value: r.routeId, label: r.routeId }))}
          />
        </FormField>
      )}
      {form.scopeKind === 'agent' && (
        <FormField label="Agent">
          <Select
            value={form.agentId}
            onChange={(v) => setForm({ ...form, agentId: v })}
            options={appAgents.map((a: any) => ({ value: a.instanceId, label: a.displayName ?? a.instanceId }))}
          />
        </FormField>
      )}
    </div>
  );
}
```

- [ ] **Step 2: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/ScopeStep.tsx
git commit -m "feat(ui/alerts): ScopeStep (name, severity, env/app/route/agent selectors)"
```

---

### Task 21: `ConditionStep` + condition-form subcomponents

**Files:**
- Replace: `ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx`
- Create: `ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx`

- [ ] **Step 1: `ConditionStep` routes to the kind-specific sub-form**

```tsx
// ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx
import { FormField, Select } from '@cameleer/design-system';
import type { FormState } from './form-state';
import { RouteMetricForm } from './condition-forms/RouteMetricForm';
import { ExchangeMatchForm } from './condition-forms/ExchangeMatchForm';
import { AgentStateForm } from './condition-forms/AgentStateForm';
import { DeploymentStateForm } from './condition-forms/DeploymentStateForm';
import { LogPatternForm } from './condition-forms/LogPatternForm';
import { JvmMetricForm } from './condition-forms/JvmMetricForm';

const KIND_OPTIONS = [
  { value: 'ROUTE_METRIC',     label: 'Route metric (error rate, latency, throughput)' },
  { value: 'EXCHANGE_MATCH',   label: 'Exchange match (specific failures)' },
  { value: 'AGENT_STATE',      label: 'Agent state (DEAD / STALE)' },
  { value: 'DEPLOYMENT_STATE', label: 'Deployment state (FAILED / DEGRADED)' },
  { value: 'LOG_PATTERN',      label: 'Log pattern (count of matching logs)' },
  { value: 'JVM_METRIC',       label: 'JVM metric (heap, GC, inflight)' },
];

export function ConditionStep({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const onKindChange = (v: string) => {
    setForm({ ...form, conditionKind: v as FormState['conditionKind'], condition: { kind: v as any } });
  };

  return (
    <div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
      <FormField label="Condition kind">
        <Select value={form.conditionKind} onChange={onKindChange} options={KIND_OPTIONS} />
      </FormField>
      {form.conditionKind === 'ROUTE_METRIC' && <RouteMetricForm form={form} setForm={setForm} />}
      {form.conditionKind === 'EXCHANGE_MATCH' && <ExchangeMatchForm form={form} setForm={setForm} />}
      {form.conditionKind === 'AGENT_STATE' && <AgentStateForm form={form} setForm={setForm} />}
      {form.conditionKind === 'DEPLOYMENT_STATE' && <DeploymentStateForm form={form} setForm={setForm} />}
      {form.conditionKind === 'LOG_PATTERN' && <LogPatternForm form={form} setForm={setForm} />}
      {form.conditionKind === 'JVM_METRIC' && <JvmMetricForm form={form} setForm={setForm} />}
    </div>
  );
}
```

- [ ] **Step 2: `RouteMetricForm`**

```tsx
// ui/src/pages/Alerts/RuleEditor/condition-forms/RouteMetricForm.tsx
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';

const METRICS = [
  { value: 'ERROR_RATE',     label: 'Error rate' },
  { value: 'P95_LATENCY_MS', label: 'P95 latency (ms)' },
  { value: 'P99_LATENCY_MS', label: 'P99 latency (ms)' },
  { value: 'AVG_DURATION_MS',label: 'Avg duration (ms)' },
  { value: 'THROUGHPUT',     label: 'Throughput (msg/s)' },
  { value: 'ERROR_COUNT',    label: 'Error count' },
];
const COMPARATORS = [
  { value: 'GT', label: '>' },
  { value: 'GTE', label: '≥' },
  { value: 'LT', label: '<' },
  { value: 'LTE', label: '≤' },
];

export function RouteMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const c: any = form.condition;
  const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
  return (
    <>
      <FormField label="Metric"><Select value={c.metric ?? ''} onChange={(v) => patch({ metric: v })} options={METRICS} /></FormField>
      <FormField label="Comparator"><Select value={c.comparator ?? 'GT'} onChange={(v) => patch({ comparator: v })} options={COMPARATORS} /></FormField>
      <FormField label="Threshold"><Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} /></FormField>
      <FormField label="Window (seconds)"><Input type="number" value={c.windowSeconds ?? 300} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} /></FormField>
    </>
  );
}
```

- [ ] **Step 3: `ExchangeMatchForm` (PER_EXCHANGE or COUNT_IN_WINDOW)**

```tsx
// ui/src/pages/Alerts/RuleEditor/condition-forms/ExchangeMatchForm.tsx
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';

const FIRE_MODES = [
  { value: 'PER_EXCHANGE',    label: 'One alert per matching exchange' },
  { value: 'COUNT_IN_WINDOW', label: 'Threshold: N matches in window' },
];
const STATUSES = [
  { value: '',          label: '(any)' },
  { value: 'COMPLETED', label: 'COMPLETED' },
  { value: 'FAILED',    label: 'FAILED' },
  { value: 'RUNNING',   label: 'RUNNING' },
];

export function ExchangeMatchForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const c: any = form.condition;
  const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
  const filter = c.filter ?? {};
  return (
    <>
      <FormField label="Fire mode"><Select value={c.fireMode ?? 'PER_EXCHANGE'} onChange={(v) => patch({ fireMode: v })} options={FIRE_MODES} /></FormField>
      <FormField label="Status filter"><Select value={filter.status ?? ''} onChange={(v) => patch({ filter: { ...filter, status: v || undefined } })} options={STATUSES} /></FormField>
      {c.fireMode === 'PER_EXCHANGE' && (
        <FormField label="Linger seconds (default 300)">
          <Input type="number" value={c.perExchangeLingerSeconds ?? 300} onChange={(e) => patch({ perExchangeLingerSeconds: Number(e.target.value) })} />
        </FormField>
      )}
      {c.fireMode === 'COUNT_IN_WINDOW' && (
        <>
          <FormField label="Threshold (matches)"><Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} /></FormField>
          <FormField label="Window (seconds)"><Input type="number" value={c.windowSeconds ?? 900} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} /></FormField>
        </>
      )}
    </>
  );
}
```

- [ ] **Step 4: `AgentStateForm`, `DeploymentStateForm`, `LogPatternForm`, `JvmMetricForm`**

Each follows the same pattern. Keep the code short — they're simple enum/threshold pickers.

```tsx
// ui/src/pages/Alerts/RuleEditor/condition-forms/AgentStateForm.tsx
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';

export function AgentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const c: any = form.condition;
  const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
  return (
    <>
      <FormField label="Agent state">
        <Select value={c.state ?? 'DEAD'} onChange={(v) => patch({ state: v })} options={[
          { value: 'DEAD', label: 'DEAD' },
          { value: 'STALE', label: 'STALE' },
        ]} />
      </FormField>
      <FormField label="For duration (seconds)">
        <Input type="number" value={c.forSeconds ?? 60} onChange={(e) => patch({ forSeconds: Number(e.target.value) })} />
      </FormField>
    </>
  );
}
```

```tsx
// ui/src/pages/Alerts/RuleEditor/condition-forms/DeploymentStateForm.tsx
import { FormField } from '@cameleer/design-system';
import type { FormState } from '../form-state';

const OPTIONS = ['FAILED', 'DEGRADED'] as const;

export function DeploymentStateForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const c: any = form.condition;
  const states: string[] = c.states ?? [];
  const toggle = (s: string) => {
    const next = states.includes(s) ? states.filter((x) => x !== s) : [...states, s];
    setForm({ ...form, condition: { ...form.condition, states: next } });
  };
  return (
    <FormField label="Fire when deployment is in states">
      <div style={{ display: 'flex', gap: 12 }}>
        {OPTIONS.map((s) => (
          <label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
            <input type="checkbox" checked={states.includes(s)} onChange={() => toggle(s)} />
            {s}
          </label>
        ))}
      </div>
    </FormField>
  );
}
```

```tsx
// ui/src/pages/Alerts/RuleEditor/condition-forms/LogPatternForm.tsx
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';

export function LogPatternForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const c: any = form.condition;
  const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
  return (
    <>
      <FormField label="Level">
        <Select value={c.level ?? 'ERROR'} onChange={(v) => patch({ level: v })} options={[
          { value: 'ERROR', label: 'ERROR' },
          { value: 'WARN',  label: 'WARN' },
          { value: 'INFO',  label: 'INFO' },
        ]} />
      </FormField>
      <FormField label="Logger (substring, optional)">
        <Input value={c.logger ?? ''} onChange={(e) => patch({ logger: e.target.value || undefined })} />
      </FormField>
      <FormField label="Pattern (regex)">
        <Input value={c.pattern ?? ''} onChange={(e) => patch({ pattern: e.target.value })} />
      </FormField>
      <FormField label="Threshold (matches)">
        <Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} />
      </FormField>
      <FormField label="Window (seconds)">
        <Input type="number" value={c.windowSeconds ?? 900} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} />
      </FormField>
    </>
  );
}
```

```tsx
// ui/src/pages/Alerts/RuleEditor/condition-forms/JvmMetricForm.tsx
import { FormField, Input, Select } from '@cameleer/design-system';
import type { FormState } from '../form-state';

export function JvmMetricForm({ form, setForm }: { form: FormState; setForm: (f: FormState) => void }) {
  const c: any = form.condition;
  const patch = (p: any) => setForm({ ...form, condition: { ...form.condition, ...p } });
  return (
    <>
      <FormField label="Metric name">
        <Input value={c.metric ?? ''} onChange={(e) => patch({ metric: e.target.value })} placeholder="heap_used_percent" />
      </FormField>
      <FormField label="Aggregation">
        <Select value={c.aggregation ?? 'MAX'} onChange={(v) => patch({ aggregation: v })} options={[
          { value: 'MAX', label: 'MAX' },
          { value: 'AVG', label: 'AVG' },
          { value: 'MIN', label: 'MIN' },
        ]} />
      </FormField>
      <FormField label="Comparator">
        <Select value={c.comparator ?? 'GT'} onChange={(v) => patch({ comparator: v })} options={[
          { value: 'GT', label: '>' }, { value: 'GTE', label: '≥' }, { value: 'LT', label: '<' }, { value: 'LTE', label: '≤' },
        ]} />
      </FormField>
      <FormField label="Threshold">
        <Input type="number" value={c.threshold ?? ''} onChange={(e) => patch({ threshold: Number(e.target.value) })} />
      </FormField>
      <FormField label="Window (seconds)">
        <Input type="number" value={c.windowSeconds ?? 300} onChange={(e) => patch({ windowSeconds: Number(e.target.value) })} />
      </FormField>
    </>
  );
}
```

- [ ] **Step 5: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/ConditionStep.tsx ui/src/pages/Alerts/RuleEditor/condition-forms/
git commit -m "feat(ui/alerts): ConditionStep with 6 kind-specific forms

Each kind renders its own payload shape. Kind change resets condition to
{kind} so stale fields from a previous kind don't leak into save payload."
```

---

### Task 22: `TriggerStep`

**Files:**
- Replace: `ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx`

- [ ] **Step 1: Write the step**

```tsx
// ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
import { useState } from 'react';
import { Button, FormField, Input, useToast } from '@cameleer/design-system';
import { useTestEvaluate } from '../../../api/queries/alertRules';
import type { FormState } from './form-state';
import { toRequest } from './form-state';

export function TriggerStep({ form, setForm, ruleId }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string }) {
  const testEvaluate = useTestEvaluate();
  const { toast } = useToast();
  const [lastResult, setLastResult] = useState<string | null>(null);

  const onTest = async () => {
    if (!ruleId) {
      toast({ title: 'Save rule first to run test evaluate', variant: 'error' });
      return;
    }
    try {
      const result = await testEvaluate.mutateAsync({ id: ruleId, req: { condition: (toRequest(form).condition as any) } });
      setLastResult(JSON.stringify(result, null, 2));
    } catch (e) {
      toast({ title: 'Test-evaluate failed', description: String(e), variant: 'error' });
    }
  };

  return (
    <div style={{ display: 'grid', gap: 12, maxWidth: 600 }}>
      <FormField label="Evaluation interval (seconds, min 5)">
        <Input type="number" min={5} value={form.evaluationIntervalSeconds}
          onChange={(e) => setForm({ ...form, evaluationIntervalSeconds: Number(e.target.value) })} />
      </FormField>
      <FormField label="For-duration before firing (seconds, 0 = fire immediately)">
        <Input type="number" min={0} value={form.forDurationSeconds}
          onChange={(e) => setForm({ ...form, forDurationSeconds: Number(e.target.value) })} />
      </FormField>
      <FormField label="Re-notify cadence (minutes, 0 = notify once)">
        <Input type="number" min={0} value={form.reNotifyMinutes}
          onChange={(e) => setForm({ ...form, reNotifyMinutes: Number(e.target.value) })} />
      </FormField>
      <div>
        <Button variant="secondary" onClick={onTest} disabled={testEvaluate.isPending}>
          Test evaluate (uses current condition)
        </Button>
        {lastResult && (
          <pre style={{ marginTop: 12, padding: 8, background: 'var(--code-bg)', borderRadius: 6, fontSize: 12, maxHeight: 240, overflow: 'auto' }}>
            {lastResult}
          </pre>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/TriggerStep.tsx
git commit -m "feat(ui/alerts): TriggerStep (evaluation interval, for-duration, re-notify, test-evaluate)"
```

---

### Task 23: `NotifyStep` (MustacheEditor + targets + webhooks)

**Files:**
- Replace: `ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx`

- [ ] **Step 1: Implement the step**

```tsx
// ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
import { useState } from 'react';
import { Button, FormField, Select, Input, useToast, Badge } from '@cameleer/design-system';
import { MustacheEditor } from '../../../components/MustacheEditor/MustacheEditor';
import { useUsers, useGroups, useRoles } from '../../../api/queries/admin/rbac';
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
import { useSelectedEnv } from '../../../api/queries/alertMeta';
import { useRenderPreview } from '../../../api/queries/alertRules';
import { toRequest, type FormState } from './form-state';

export function NotifyStep({ form, setForm, ruleId }: { form: FormState; setForm: (f: FormState) => void; ruleId?: string }) {
  const env = useSelectedEnv();
  const { data: users } = useUsers(true);
  const { data: groups } = useGroups(true);
  const { data: roles } = useRoles(true);
  const { data: connections } = useOutboundConnections();
  const preview = useRenderPreview();
  const { toast } = useToast();
  const [lastPreview, setLastPreview] = useState<string | null>(null);

  // Filter connections to those that allow the current env.
  const availableConnections = (connections ?? []).filter(
    (c) => c.allowedEnvironmentIds.length === 0 || c.allowedEnvironmentIds.includes(env!),
  );

  const onPreview = async () => {
    if (!ruleId) {
      toast({ title: 'Save rule first to preview', variant: 'error' });
      return;
    }
    try {
      const res = await preview.mutateAsync({
        id: ruleId,
        req: {
          titleTemplate: form.notificationTitleTmpl,
          messageTemplate: form.notificationMessageTmpl,
        },
      });
      setLastPreview(`TITLE:\n${(res as any).renderedTitle}\n\nMESSAGE:\n${(res as any).renderedMessage}`);
    } catch (e) {
      toast({ title: 'Preview failed', description: String(e), variant: 'error' });
    }
  };

  const addTarget = (targetKind: 'USER' | 'GROUP' | 'ROLE', targetId: string) => {
    if (!targetId) return;
    if (form.targets.some((t) => t.targetKind === targetKind && t.targetId === targetId)) return;
    setForm({ ...form, targets: [...form.targets, { targetKind, targetId }] });
  };
  const removeTarget = (idx: number) => {
    setForm({ ...form, targets: form.targets.filter((_, i) => i !== idx) });
  };

  const addWebhook = (outboundConnectionId: string) => {
    setForm({
      ...form,
      webhooks: [...form.webhooks, { outboundConnectionId, bodyOverride: '', headerOverrides: [] }],
    });
  };
  const removeWebhook = (idx: number) => {
    setForm({ ...form, webhooks: form.webhooks.filter((_, i) => i !== idx) });
  };
  const updateWebhook = (idx: number, patch: Partial<FormState['webhooks'][number]>) => {
    setForm({ ...form, webhooks: form.webhooks.map((w, i) => i === idx ? { ...w, ...patch } : w) });
  };

  return (
    <div style={{ display: 'grid', gap: 16, maxWidth: 720 }}>
      <MustacheEditor
        label="Notification title"
        value={form.notificationTitleTmpl}
        onChange={(v) => setForm({ ...form, notificationTitleTmpl: v })}
        kind={form.conditionKind}
        singleLine
      />
      <MustacheEditor
        label="Notification message"
        value={form.notificationMessageTmpl}
        onChange={(v) => setForm({ ...form, notificationMessageTmpl: v })}
        kind={form.conditionKind}
        minHeight={120}
      />
      <div>
        <Button variant="secondary" onClick={onPreview} disabled={preview.isPending}>
          Preview rendered output
        </Button>
        {lastPreview && (
          <pre style={{ marginTop: 8, padding: 8, background: 'var(--code-bg)', borderRadius: 6, fontSize: 12, whiteSpace: 'pre-wrap' }}>
            {lastPreview}
          </pre>
        )}
      </div>

      <FormField label="Notification targets">
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
          {form.targets.map((t, i) => (
            <Badge
              key={`${t.targetKind}:${t.targetId}`}
              label={`${t.targetKind}: ${t.targetId}`}
              color="auto"
              variant="outlined"
              onRemove={() => removeTarget(i)}
            />
          ))}
        </div>
        <div style={{ display: 'flex', gap: 8 }}>
          <Select value="" onChange={(v) => addTarget('USER', v)} options={[{ value: '', label: '+ User' }, ...(users ?? []).map((u) => ({ value: u.userId, label: u.displayName ?? u.userId }))]} />
          <Select value="" onChange={(v) => addTarget('GROUP', v)} options={[{ value: '', label: '+ Group' }, ...(groups ?? []).map((g) => ({ value: g.id, label: g.name }))]} />
          <Select value="" onChange={(v) => addTarget('ROLE', v)} options={[{ value: '', label: '+ Role' }, ...(roles ?? []).map((r) => ({ value: r.name, label: r.name }))]} />
        </div>
      </FormField>

      <FormField label="Webhook destinations (outbound connections)">
        <Select
          value=""
          onChange={(v) => { if (v) addWebhook(v); }}
          options={[
            { value: '', label: '+ Add webhook' },
            ...availableConnections.map((c) => ({ value: c.id, label: c.name })),
          ]}
        />
        {form.webhooks.map((w, i) => {
          const conn = availableConnections.find((c) => c.id === w.outboundConnectionId);
          return (
            <div key={i} style={{ padding: 12, border: '1px solid var(--border)', borderRadius: 6, marginTop: 8, display: 'grid', gap: 8 }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                <strong>{conn?.name ?? w.outboundConnectionId}</strong>
                <Button size="small" variant="secondary" onClick={() => removeWebhook(i)}>Remove</Button>
              </div>
              <MustacheEditor
                label="Body override (optional)"
                value={w.bodyOverride}
                onChange={(v) => updateWebhook(i, { bodyOverride: v })}
                kind={form.conditionKind}
                placeholder="Leave empty to use connection default"
                minHeight={80}
              />
              <FormField label="Header overrides">
                {w.headerOverrides.map((h, hi) => (
                  <div key={hi} style={{ display: 'flex', gap: 8, marginBottom: 4 }}>
                    <Input value={h.key} placeholder="Header name" onChange={(e) => {
                      const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], key: e.target.value };
                      updateWebhook(i, { headerOverrides: heads });
                    }} />
                    <Input value={h.value} placeholder="Mustache value" onChange={(e) => {
                      const heads = [...w.headerOverrides]; heads[hi] = { ...heads[hi], value: e.target.value };
                      updateWebhook(i, { headerOverrides: heads });
                    }} />
                    <Button size="small" variant="secondary" onClick={() => {
                      updateWebhook(i, { headerOverrides: w.headerOverrides.filter((_, x) => x !== hi) });
                    }}>×</Button>
                  </div>
                ))}
                <Button size="small" variant="secondary" onClick={() => {
                  updateWebhook(i, { headerOverrides: [...w.headerOverrides, { key: '', value: '' }] });
                }}>+ Header override</Button>
              </FormField>
            </div>
          );
        })}
      </FormField>
    </div>
  );
}
```

- [ ] **Step 2: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/NotifyStep.tsx
git commit -m "feat(ui/alerts): NotifyStep (MustacheEditor for title/message/body, targets, webhook bindings)

Targets combine users/groups/roles into a unified pill list. Webhook picker
filters to connections allowed in the current env (spec §6 allowed_env_ids).
Header overrides use Input rather than MustacheEditor for now — header
autocomplete can be added in a future polish pass if ops teams ask for it."
```

---

### Task 24: `ReviewStep` + promotion-prefill warnings

**Files:**
- Replace: `ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx`
- Replace: `ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts`
- Create: `ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts`

- [ ] **Step 1: Write the review step**

```tsx
// ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx
import type { FormState } from './form-state';
import { toRequest } from './form-state';
import { Toggle } from '@cameleer/design-system';

export function ReviewStep({ form, setForm }: { form: FormState; setForm?: (f: FormState) => void }) {
  const req = toRequest(form);
  return (
    <div style={{ display: 'grid', gap: 12, maxWidth: 720 }}>
      <div><strong>Name:</strong> {form.name}</div>
      <div><strong>Severity:</strong> {form.severity}</div>
      <div><strong>Scope:</strong> {form.scopeKind}
        {form.scopeKind !== 'env' && ` (app=${form.appSlug}${form.routeId ? `, route=${form.routeId}` : ''}${form.agentId ? `, agent=${form.agentId}` : ''})`}
      </div>
      <div><strong>Condition kind:</strong> {form.conditionKind}</div>
      <div><strong>Intervals:</strong> eval {form.evaluationIntervalSeconds}s · for {form.forDurationSeconds}s · re-notify {form.reNotifyMinutes}m</div>
      <div><strong>Targets:</strong> {form.targets.length}</div>
      <div><strong>Webhooks:</strong> {form.webhooks.length}</div>
      {setForm && (
        <label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
          <Toggle checked={form.enabled} onChange={(v) => setForm({ ...form, enabled: v })} />
          Enabled on save
        </label>
      )}
      <details>
        <summary>Raw request JSON</summary>
        <pre style={{ fontSize: 11, background: 'var(--code-bg)', padding: 8, borderRadius: 6, overflow: 'auto' }}>
          {JSON.stringify(req, null, 2)}
        </pre>
      </details>
    </div>
  );
}
```

- [ ] **Step 2: Rewrite `promotion-prefill.ts` with warnings**

```ts
// ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts
import { initialForm, type FormState } from './form-state';
import type { AlertRuleResponse } from '../../../api/queries/alertRules';

export interface PrefillWarning {
  field: string;
  message: string;
}

/** Client-side prefill when promoting a rule from another env. Emits warnings for
 *  fields that cross env boundaries (agent IDs, outbound connection env-restrictions). */
export function prefillFromPromotion(
  source: AlertRuleResponse,
  opts: {
    targetEnvAppSlugs?: string[];
    targetEnvAllowedConnectionIds?: string[];  // IDs allowed in target env
  } = {},
): { form: FormState; warnings: PrefillWarning[] } {
  const form = initialForm(source);
  form.name = `${source.name} (copy)`;
  const warnings: PrefillWarning[] = [];

  // Agent IDs are per-env, can't transfer.
  if (form.agentId) {
    warnings.push({
      field: 'scope.agentId',
      message: `Agent \`${form.agentId}\` is specific to the source env — cleared for target env.`,
    });
    form.agentId = '';
    if (form.scopeKind === 'agent') form.scopeKind = 'app';
  }

  // App slug: warn if not present in target env.
  if (form.appSlug && opts.targetEnvAppSlugs && !opts.targetEnvAppSlugs.includes(form.appSlug)) {
    warnings.push({
      field: 'scope.appSlug',
      message: `App \`${form.appSlug}\` does not exist in the target env. Update before saving.`,
    });
  }

  // Webhook connections: warn if connection is not allowed in target env.
  if (opts.targetEnvAllowedConnectionIds) {
    for (const w of form.webhooks) {
      if (!opts.targetEnvAllowedConnectionIds.includes(w.outboundConnectionId)) {
        warnings.push({
          field: `webhooks[${w.outboundConnectionId}]`,
          message: `Outbound connection is not allowed in the target env — remove or pick another before saving.`,
        });
      }
    }
  }

  return { form, warnings };
}
```

- [ ] **Step 3: Test the prefill logic**

```ts
// ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
import { describe, it, expect } from 'vitest';
import { prefillFromPromotion } from './promotion-prefill';
import type { AlertRuleResponse } from '../../../api/queries/alertRules';

function fakeRule(overrides: Partial<AlertRuleResponse> = {}): AlertRuleResponse {
  return {
    id: '11111111-1111-1111-1111-111111111111',
    environmentId: '22222222-2222-2222-2222-222222222222',
    name: 'High error rate',
    description: null as any,
    severity: 'CRITICAL',
    enabled: true,
    conditionKind: 'ROUTE_METRIC',
    condition: { kind: 'ROUTE_METRIC', scope: { appSlug: 'orders' } } as any,
    evaluationIntervalSeconds: 60,
    forDurationSeconds: 0,
    reNotifyMinutes: 60,
    notificationTitleTmpl: '{{rule.name}}',
    notificationMessageTmpl: 'msg',
    webhooks: [],
    targets: [],
    createdAt: '2026-04-01T00:00:00Z' as any,
    createdBy: 'alice',
    updatedAt: '2026-04-01T00:00:00Z' as any,
    updatedBy: 'alice',
    ...overrides,
  };
}

describe('prefillFromPromotion', () => {
  it('appends "(copy)" to name', () => {
    const { form } = prefillFromPromotion(fakeRule());
    expect(form.name).toBe('High error rate (copy)');
  });

  it('warns + clears agentId when source rule is agent-scoped', () => {
    const { form, warnings } = prefillFromPromotion(fakeRule({
      condition: { kind: 'AGENT_STATE', scope: { appSlug: 'orders', agentId: 'orders-0' }, state: 'DEAD', forSeconds: 60 } as any,
      conditionKind: 'AGENT_STATE',
    }));
    expect(form.agentId).toBe('');
    expect(warnings.find((w) => w.field === 'scope.agentId')).toBeTruthy();
  });

  it('warns if app does not exist in target env', () => {
    const { warnings } = prefillFromPromotion(fakeRule(), { targetEnvAppSlugs: ['other-app'] });
    expect(warnings.find((w) => w.field === 'scope.appSlug')).toBeTruthy();
  });

  it('warns if webhook connection is not allowed in target env', () => {
    const rule = fakeRule({
      webhooks: [{ id: 'w1', outboundConnectionId: 'conn-prod', bodyOverride: null, headerOverrides: {} } as any],
    });
    const { warnings } = prefillFromPromotion(rule, { targetEnvAllowedConnectionIds: ['conn-dev'] });
    expect(warnings.find((w) => w.field.startsWith('webhooks['))).toBeTruthy();
  });
});
```

- [ ] **Step 4: Run tests + commit**

```bash
cd ui && npm test -- promotion-prefill
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/ReviewStep.tsx ui/src/pages/Alerts/RuleEditor/promotion-prefill.ts ui/src/pages/Alerts/RuleEditor/promotion-prefill.test.ts
git commit -m "feat(ui/alerts): ReviewStep + promotion prefill warnings

Review step dumps a human summary + raw request JSON + enabled toggle.
Promotion prefill clears agent IDs (per-env), flags missing apps in target
env, flags webhook connections not allowed in target env. Follow-up: wire
warnings into wizard UI as per-field inline hints (Task 24 ext.)."
```

---

### Task 25: Wire promotion warnings into wizard UI

**Files:**
- Modify: `ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx`

- [ ] **Step 1: Fetch target-env apps + allowed connections and wire warnings**

Expand the existing wizard to use `prefillFromPromotion({form, warnings})` and expose `warnings` via a banner listing them. In `RuleEditorWizard.tsx`:

```tsx
// Near the other hooks
import { useCatalog } from '../../../api/queries/catalog';
import { useOutboundConnections } from '../../../api/queries/admin/outboundConnections';
import type { PrefillWarning } from './promotion-prefill';

// Inside the component, after promoteFrom setup:
const targetEnv = search.get('targetEnv') ?? env;
const { data: targetCatalog } = useCatalog(targetEnv ?? undefined);
const { data: connections } = useOutboundConnections();

const targetAppSlugs = (targetCatalog ?? []).map((a: any) => a.slug);
const targetAllowedConnIds = (connections ?? [])
  .filter((c) => c.allowedEnvironmentIds.length === 0 || (targetEnv && c.allowedEnvironmentIds.includes(targetEnv)))
  .map((c) => c.id);

const [warnings, setWarnings] = useState<PrefillWarning[]>([]);
```

Replace the initializer block:

```tsx
const ready = useMemo(() => {
  if (form) return true;
  if (isEdit && existingQuery.data) {
    setForm(initialForm(existingQuery.data));
    return true;
  }
  if (promoteFrom && sourceRuleQuery.data) {
    const { form: prefilled, warnings: w } = prefillFromPromotion(sourceRuleQuery.data, {
      targetEnvAppSlugs: targetAppSlugs,
      targetEnvAllowedConnectionIds: targetAllowedConnIds,
    });
    setForm(prefilled);
    setWarnings(w);
    return true;
  }
  if (!isEdit && !promoteFrom) {
    setForm(initialForm());
    return true;
  }
  return false;
}, [form, isEdit, existingQuery.data, promoteFrom, sourceRuleQuery.data, targetAppSlugs.join(','), targetAllowedConnIds.join(',')]);
```

Render a warnings banner when `warnings.length > 0`:

```tsx
{warnings.length > 0 && (
  <div className={css.promoteBanner}>
    <strong>Review before saving:</strong>
    <ul>{warnings.map((w) => <li key={w.field}><code>{w.field}</code>: {w.message}</li>)}</ul>
  </div>
)}
```

- [ ] **Step 2: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/RuleEditor/RuleEditorWizard.tsx
git commit -m "feat(ui/alerts): render promotion warnings in wizard banner"
```

---

## Phase 7 — Silences

### Task 26: `SilencesPage`

**Files:**
- Replace: `ui/src/pages/Alerts/SilencesPage.tsx`

- [ ] **Step 1: Implement the page**

```tsx
// ui/src/pages/Alerts/SilencesPage.tsx
import { useState } from 'react';
import { Button, FormField, Input, SectionHeader, useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import {
  useAlertSilences,
  useCreateSilence,
  useDeleteSilence,
  type AlertSilenceResponse,
} from '../../api/queries/alertSilences';
import sectionStyles from '../../styles/section-card.module.css';

export default function SilencesPage() {
  const { data, isLoading, error } = useAlertSilences();
  const create = useCreateSilence();
  const remove = useDeleteSilence();
  const { toast } = useToast();

  const [reason, setReason] = useState('');
  const [matcherRuleId, setMatcherRuleId] = useState('');
  const [matcherAppSlug, setMatcherAppSlug] = useState('');
  const [hours, setHours] = useState(1);

  if (isLoading) return <PageLoader />;
  if (error) return <div>Failed to load silences: {String(error)}</div>;

  const onCreate = async () => {
    const now = new Date();
    const endsAt = new Date(now.getTime() + hours * 3600_000);
    const matcher: Record<string, string> = {};
    if (matcherRuleId) matcher.ruleId = matcherRuleId;
    if (matcherAppSlug) matcher.appSlug = matcherAppSlug;
    if (Object.keys(matcher).length === 0) {
      toast({ title: 'Silence needs at least one matcher field', variant: 'error' });
      return;
    }
    try {
      await create.mutateAsync({
        matcher,
        reason: reason || undefined,
        startsAt: now.toISOString(),
        endsAt: endsAt.toISOString(),
      });
      setReason(''); setMatcherRuleId(''); setMatcherAppSlug(''); setHours(1);
      toast({ title: 'Silence created', variant: 'success' });
    } catch (e) {
      toast({ title: 'Create failed', description: String(e), variant: 'error' });
    }
  };

  const onRemove = async (s: AlertSilenceResponse) => {
    if (!confirm(`End silence early?`)) return;
    try {
      await remove.mutateAsync(s.id);
      toast({ title: 'Silence removed', variant: 'success' });
    } catch (e) {
      toast({ title: 'Remove failed', description: String(e), variant: 'error' });
    }
  };

  const rows = data ?? [];

  return (
    <div style={{ padding: 16 }}>
      <SectionHeader>Alert silences</SectionHeader>
      <div className={sectionStyles.section} style={{ marginTop: 12 }}>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr) auto', gap: 8, alignItems: 'end' }}>
          <FormField label="Rule ID (optional)"><Input value={matcherRuleId} onChange={(e) => setMatcherRuleId(e.target.value)} /></FormField>
          <FormField label="App slug (optional)"><Input value={matcherAppSlug} onChange={(e) => setMatcherAppSlug(e.target.value)} /></FormField>
          <FormField label="Duration (hours)"><Input type="number" min={1} value={hours} onChange={(e) => setHours(Number(e.target.value))} /></FormField>
          <FormField label="Reason"><Input value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Maintenance window" /></FormField>
          <Button variant="primary" onClick={onCreate} disabled={create.isPending}>Create silence</Button>
        </div>
      </div>
      <div className={sectionStyles.section} style={{ marginTop: 16 }}>
        {rows.length === 0 ? (
          <p>No active or scheduled silences.</p>
        ) : (
          <table style={{ width: '100%', borderCollapse: 'collapse' }}>
            <thead>
              <tr>
                <th style={{ textAlign: 'left' }}>Matcher</th>
                <th style={{ textAlign: 'left' }}>Reason</th>
                <th style={{ textAlign: 'left' }}>Starts</th>
                <th style={{ textAlign: 'left' }}>Ends</th>
                <th></th>
              </tr>
            </thead>
            <tbody>
              {rows.map((s) => (
                <tr key={s.id}>
                  <td><code>{JSON.stringify(s.matcher)}</code></td>
                  <td>{s.reason ?? '—'}</td>
                  <td>{s.startsAt}</td>
                  <td>{s.endsAt}</td>
                  <td><Button variant="secondary" onClick={() => onRemove(s)}>End</Button></td>
                </tr>
              ))}
            </tbody>
          </table>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: TypeScript compile + commit**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
git add ui/src/pages/Alerts/SilencesPage.tsx
git commit -m "feat(ui/alerts): SilencesPage with matcher-based create + end-early action

Matcher accepts ruleId and/or appSlug. Server enforces endsAt > startsAt
(V12 CHECK constraint) and matcher_matches() at dispatch time (spec §7)."
```

---

## Phase 8 — CMD-K integration

### Task 27: Add alert + alertRule sources to the command palette

**Files:**
- Modify: `ui/src/components/LayoutShell.tsx`

- [ ] **Step 1: Import the alert queries + state chip**

Near the other API-query imports (around line 31):

```ts
import { useAlerts } from '../api/queries/alerts';
import { useAlertRules } from '../api/queries/alertRules';
```

- [ ] **Step 2: Build alert/alert-rule SearchResult[]**

Near `buildSearchData` and `buildAdminSearchData`, add:

```ts
function buildAlertSearchData(
  alerts: any[] | undefined,
  rules: any[] | undefined,
): SearchResult[] {
  const results: SearchResult[] = [];
  if (alerts) {
    for (const a of alerts) {
      results.push({
        id: `alert:${a.id}`,
        category: 'alert',
        title: a.title ?? '(untitled)',
        badges: [
          { label: a.severity, color: severityToSearchColor(a.severity) },
          { label: a.state,    color: stateToSearchColor(a.state) },
        ],
        meta: `${a.firedAt ?? ''}${a.silenced ? ' · silenced' : ''}`,
        path: `/alerts/inbox/${a.id}`,
      });
    }
  }
  if (rules) {
    for (const r of rules) {
      results.push({
        id: `rule:${r.id}`,
        category: 'alertRule',
        title: r.name,
        badges: [
          { label: r.severity, color: severityToSearchColor(r.severity) },
          { label: r.conditionKind, color: 'auto' },
          ...(r.enabled ? [] : [{ label: 'DISABLED', color: 'warning' as const }]),
        ],
        meta: `${r.evaluationIntervalSeconds}s · ${r.targets?.length ?? 0} targets`,
        path: `/alerts/rules/${r.id}`,
      });
    }
  }
  return results;
}

function severityToSearchColor(s: string): string {
  if (s === 'CRITICAL') return 'error';
  if (s === 'WARNING')  return 'warning';
  return 'auto';
}
function stateToSearchColor(s: string): string {
  if (s === 'FIRING')       return 'error';
  if (s === 'ACKNOWLEDGED') return 'warning';
  if (s === 'RESOLVED')     return 'success';
  return 'auto';
}
```

- [ ] **Step 3: Fetch alerts + rules inside `LayoutContent`**

Near the existing catalog/agents fetches (around line 305):

```ts
// Open alerts + rules for CMD-K (env-scoped).
const { data: cmdkAlerts } = useAlerts({ state: ['FIRING', 'ACKNOWLEDGED'], limit: 100 });
const { data: cmdkRules }  = useAlertRules();
```

- [ ] **Step 4: Add the results into `operationalSearchData`**

Adjust the `operationalSearchData` memo to include alert + rule results:

```ts
const alertingSearchData = useMemo(
  () => buildAlertSearchData(cmdkAlerts, cmdkRules),
  [cmdkAlerts, cmdkRules],
);

// Inside the existing operationalSearchData useMemo, append alertingSearchData:
return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
```

- [ ] **Step 5: Route selection — handle `alert` and `alertRule` categories**

Extend `handlePaletteSelect`'s logic: when the result category is `alert` or `alertRule`, just navigate to `result.path`. The existing fallback branch already handles this, but add an explicit clause so the state payload doesn't get the exchange-specific `selectedExchange` treatment:

```ts
if (result.category === 'alert' || result.category === 'alertRule') {
  navigate(result.path);
  setPaletteOpen(false);
  return;
}
```

Insert this at the top of `handlePaletteSelect` before the existing ADMIN_CATEGORIES check.

- [ ] **Step 6: TypeScript compile + manual smoke**

```bash
cd ui && npx tsc -p tsconfig.app.json --noEmit
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add ui/src/components/LayoutShell.tsx
git commit -m "feat(ui/alerts): CMD-K sources for alerts + alert rules

Extends operationalSearchData with open alerts (FIRING|ACKNOWLEDGED) and
all rules. Badges convey severity + state. Selecting an alert navigates to
/alerts/inbox/{id}; a rule navigates to /alerts/rules/{id}. Uses the
existing CommandPalette extension point — no new registry."
```

---

## Phase 9 — Backend backfills

### Task 28: SSRF guard on `OutboundConnection.url`

**Files:**
- Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java`
- Modify: `cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java`

Before editing, run:
```
gitnexus_impact({target:"OutboundConnectionServiceImpl.save", direction:"upstream"})
```
Expected d=1: `OutboundConnectionAdminController` (create + update). No other callers — risk is LOW.

- [ ] **Step 1: Write failing unit test for `SsrfGuard`**

```java
// cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java
package com.cameleer.server.app.outbound;

import org.junit.jupiter.api.Test;

import java.net.URI;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class SsrfGuardTest {

    private final SsrfGuard guard = new SsrfGuard(false);  // allow-private disabled by default

    @Test
    void rejectsLoopbackIpv4() {
        assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook")))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("private or loopback");
    }

    @Test
    void rejectsLocalhostHostname() {
        assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x")))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsRfc1918Ranges() {
        for (String url : Set.of(
            "https://10.0.0.1/x",
            "https://172.16.5.6/x",
            "https://192.168.1.1/x"
        )) {
            assertThatThrownBy(() -> guard.validate(URI.create(url)))
                .as(url)
                .isInstanceOf(IllegalArgumentException.class);
        }
    }

    @Test
    void rejectsLinkLocal() {
        assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/")))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsIpv6Loopback() {
        assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x")))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void rejectsIpv6UniqueLocal() {
        assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x")))
            .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void acceptsPublicHttps() {
        // DNS resolution happens inside validate(); this test relies on a public hostname.
        // Use a literal public IP to avoid network flakiness.
        // 8.8.8.8 is a public Google DNS IP — not in any private range.
        assertThat(new SsrfGuard(false)).isNotNull();
        guard.validate(URI.create("https://8.8.8.8/"));  // does not throw
    }

    @Test
    void allowPrivateFlagBypassesCheck() {
        SsrfGuard permissive = new SsrfGuard(true);
        permissive.validate(URI.create("https://127.0.0.1/"));  // must not throw
    }
}
```

Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest`
Expected: FAIL (SsrfGuard not found).

- [ ] **Step 2: Implement `SsrfGuard`**

```java
// cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java
package com.cameleer.server.app.outbound;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;

/**
 * Validates outbound webhook URLs against SSRF pitfalls: rejects hosts that resolve to
 * loopback, link-local, or RFC-1918 private ranges (and IPv6 equivalents).
 *
 * Per spec §17. The `cameleer.server.outbound-http.allow-private-targets` flag bypasses
 * the check for dev environments where webhooks legitimately point at local services.
 */
@Component
public class SsrfGuard {

    private final boolean allowPrivate;

    public SsrfGuard(
        @Value("${cameleer.server.outbound-http.allow-private-targets:false}") boolean allowPrivate
    ) {
        this.allowPrivate = allowPrivate;
    }

    public void validate(URI uri) {
        if (allowPrivate) return;
        String host = uri.getHost();
        if (host == null || host.isBlank()) {
            throw new IllegalArgumentException("URL must include a host: " + uri);
        }
        if ("localhost".equalsIgnoreCase(host)) {
            throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host);
        }
        InetAddress[] addrs;
        try {
            addrs = InetAddress.getAllByName(host);
        } catch (UnknownHostException e) {
            throw new IllegalArgumentException("URL host does not resolve: " + host, e);
        }
        for (InetAddress addr : addrs) {
            if (isPrivate(addr)) {
                throw new IllegalArgumentException("URL host resolves to private or loopback range: " + host + " -> " + addr.getHostAddress());
            }
        }
    }

    private static boolean isPrivate(InetAddress addr) {
        if (addr.isLoopbackAddress()) return true;
        if (addr.isLinkLocalAddress()) return true;
        if (addr.isSiteLocalAddress()) return true;     // 10/8, 172.16/12, 192.168/16
        if (addr.isAnyLocalAddress())  return true;     // 0.0.0.0, ::
        if (addr instanceof Inet6Address ip6) {
            byte[] raw = ip6.getAddress();
            // fc00::/7 unique-local
            if ((raw[0] & 0xfe) == 0xfc) return true;
        }
        if (addr instanceof Inet4Address ip4) {
            byte[] raw = ip4.getAddress();
            // 169.254.0.0/16 link-local (also matches isLinkLocalAddress but doubled-up for safety)
            if ((raw[0] & 0xff) == 169 && (raw[1] & 0xff) == 254) return true;
        }
        return false;
    }
}
```

Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=SsrfGuardTest`
Expected: 8 tests pass (the public-IP case requires network; if the local env blocks DNS, allow it to skip — but it shouldn't error on a literal IP).

- [ ] **Step 3: Wire the guard into `OutboundConnectionServiceImpl.save`**

Edit `OutboundConnectionServiceImpl.java`. Read the file first, then find the `save` method. Inject `SsrfGuard` via constructor and call `guard.validate(URI.create(request.url()))` before persisting. The save method is the `create` and `update` entry point from the controller.

Sketch:

```java
// Constructor gains:
private final SsrfGuard ssrfGuard;

public OutboundConnectionServiceImpl(
    OutboundConnectionRepository repo,
    SecretCipher cipher,
    AuditService audit,
    SsrfGuard ssrfGuard,
    @Value("${cameleer.server.tenant.id:default}") String tenantId
) {
    this.repo = repo;
    this.cipher = cipher;
    this.audit = audit;
    this.ssrfGuard = ssrfGuard;
    this.tenantId = tenantId;
}

// In save() (both create & update), before repo.save():
ssrfGuard.validate(URI.create(request.url()));
```

Verify the existing constructor signature by reading `OutboundConnectionServiceImpl.java` first; adjust the `@Autowired`/Spring wiring in `OutboundBeanConfig.java` if the bean is constructed there.

- [ ] **Step 4: Add an IT case for SSRF rejection**

Add to `OutboundConnectionAdminControllerIT.java`:

```java
@Test
void rejectsPrivateIpOnCreate() throws Exception {
    mockMvc.perform(post("/api/v1/admin/outbound-connections")
            .header("Authorization", "Bearer " + adminToken)
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {
                  "name": "evil",
                  "url": "https://127.0.0.1/abuse",
                  "method": "POST",
                  "tlsTrustMode": "SYSTEM_DEFAULT",
                  "auth": {}
                }
                """))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.message", containsString("private or loopback")));
}
```

(The exact token helper follows the existing ITs; reuse their pattern.)

- [ ] **Step 5: Run full verify for touched modules**

```bash
mvn -pl cameleer-server-app -am verify -Dtest='SsrfGuardTest,OutboundConnectionAdminControllerIT'
```

Expected: all tests pass.

- [ ] **Step 6: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/SsrfGuard.java \
        cameleer-server-app/src/main/java/com/cameleer/server/app/outbound/OutboundConnectionServiceImpl.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/SsrfGuardTest.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/outbound/OutboundConnectionAdminControllerIT.java
git commit -m "feat(alerting): SSRF guard on outbound connection URL

Rejects webhook URLs that resolve to loopback, link-local, or RFC-1918 private
ranges (IPv4 + IPv6). Bypass via cameleer.server.outbound-http.allow-private-
targets=true for dev envs. Plan 01 scope; required before SaaS exposure
(spec §17)."
```

---

### Task 29: `AlertingMetrics` gauge 30s caching

**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java`
- Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java`

Before editing, run:
```
gitnexus_impact({target:"AlertingMetrics", direction:"upstream"})
```
Expected d=1: callers that register the gauges (startup bean wiring). Risk LOW.

- [ ] **Step 1: Write failing test**

```java
// cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
package com.cameleer.server.app.alerting.metrics;

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import static org.assertj.core.api.Assertions.assertThat;

class AlertingMetricsCachingTest {

    @Test
    void gaugeSupplierIsCalledAtMostOncePer30Seconds() {
        AtomicInteger calls = new AtomicInteger();
        AtomicReference<Instant> now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
        Clock clock = Clock.fixed(now.get(), ZoneOffset.UTC);

        AlertingMetrics metrics = new AlertingMetrics(
            new SimpleMeterRegistry(),
            () -> { calls.incrementAndGet(); return 7L; },  // count supplier
            () -> 0L,                                       // open rules
            () -> 0L,                                       // circuit-open
            Duration.ofSeconds(30),
            () -> Clock.fixed(now.get(), ZoneOffset.UTC).instant()
        );

        // Registering the gauge does not call the supplier; reading it does (Micrometer semantics).
        metrics.snapshotAllGauges();              // first read — delegates through cache, 1 underlying call
        metrics.snapshotAllGauges();              // second read within TTL — served from cache, 0 new calls
        assertThat(calls.get()).isEqualTo(1);

        now.set(Instant.parse("2026-04-20T00:00:31Z"));  // advance 31 s
        metrics.snapshotAllGauges();
        assertThat(calls.get()).isEqualTo(2);
    }
}
```

Run: `cd cameleer-server-app && mvn -pl . -am test -Dtest=AlertingMetricsCachingTest`
Expected: FAIL (caching not implemented, or signature mismatch).

- [ ] **Step 2: Refactor `AlertingMetrics` to wrap suppliers in a TTL cache**

Read the existing file. If it currently uses `registry.gauge(...)` with direct suppliers, wrap each one in a lightweight cache:

```java
// Inside AlertingMetrics
private static final class TtlCache {
    private final Supplier<Long> delegate;
    private final Duration ttl;
    private final Supplier<Instant> clock;
    private volatile Instant lastRead = Instant.MIN;
    private volatile long cached = 0L;

    TtlCache(Supplier<Long> delegate, Duration ttl, Supplier<Instant> clock) {
        this.delegate = delegate;
        this.ttl = ttl;
        this.clock = clock;
    }

    long get() {
        Instant now = clock.get();
        if (Duration.between(lastRead, now).compareTo(ttl) >= 0) {
            cached = delegate.get();
            lastRead = now;
        }
        return cached;
    }
}
```

Register the gauges with `() -> cache.get()` in place of raw suppliers. Keep the existing bean constructor signature on the production path (default `ttl = Duration.ofSeconds(30)`, clock = `Instant::now`) and add a test-friendly constructor variant with explicit `ttl` + `clock`.

Add a test helper `snapshotAllGauges()` that reads each registered cache's value (or simply calls `registry.find(...).gauge().value()` on each metric) — whatever the existing method surface supports. If no such helper exists, expose package-private accessors on the caches.

- [ ] **Step 3: Run test**

```bash
mvn -pl cameleer-server-app -am test -Dtest=AlertingMetricsCachingTest
```

Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/metrics/AlertingMetrics.java \
        cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
git commit -m "perf(alerting): 30s TTL cache on AlertingMetrics gauge suppliers

Prometheus scrapes can fire every few seconds. The open-alerts / open-rules
gauges query Postgres on each read — caching the values for 30s amortises
that to one query per half-minute. Addresses final-review NIT from Plan 02."
```

---

## Phase 10 — E2E smoke + docs + final verification

### Task 30: Playwright E2E smoke

**Files:**
- Create: `ui/src/test/e2e/alerting.spec.ts`
- Create: `ui/src/test/e2e/fixtures.ts`

- [ ] **Step 1: Write a login fixture**

```ts
// ui/src/test/e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';

export const ADMIN_USER = process.env.E2E_ADMIN_USER ?? 'admin';
export const ADMIN_PASS = process.env.E2E_ADMIN_PASS ?? 'admin';

export const test = base.extend<{ loggedIn: void }>({
  loggedIn: [async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel(/username/i).fill(ADMIN_USER);
    await page.getByLabel(/password/i).fill(ADMIN_PASS);
    await page.getByRole('button', { name: /log in/i }).click();
    await expect(page).toHaveURL(/\/(exchanges|alerts)/);
    await use();
  }, { auto: true }],
});

export { expect };
```

- [ ] **Step 2: Write the smoke test**

```ts
// ui/src/test/e2e/alerting.spec.ts
import { test, expect } from './fixtures';

test.describe('alerting UI smoke', () => {
  test('sidebar Alerts section navigates to inbox', async ({ page }) => {
    await page.getByRole('button', { name: /alerts/i }).first().click();
    await expect(page).toHaveURL(/\/alerts\/inbox/);
    await expect(page.getByRole('heading', { name: /inbox/i })).toBeVisible();
  });

  test('CRUD a rule end-to-end', async ({ page }) => {
    await page.goto('/alerts/rules');
    await page.getByRole('link', { name: /new rule/i }).click();
    await expect(page).toHaveURL(/\/alerts\/rules\/new/);

    // Step 1 — scope
    await page.getByLabel(/^name$/i).fill('e2e smoke rule');
    await page.getByRole('button', { name: /next/i }).click();

    // Step 2 — condition (leave at ROUTE_METRIC defaults)
    await page.getByRole('button', { name: /next/i }).click();

    // Step 3 — trigger (defaults)
    await page.getByRole('button', { name: /next/i }).click();

    // Step 4 — notify (templates have defaults)
    await page.getByRole('button', { name: /next/i }).click();

    // Step 5 — review
    await page.getByRole('button', { name: /create rule/i }).click();

    await expect(page).toHaveURL(/\/alerts\/rules/);
    await expect(page.getByText('e2e smoke rule')).toBeVisible();

    // Delete
    page.once('dialog', (d) => d.accept());
    await page.getByRole('row', { name: /e2e smoke rule/i }).getByRole('button', { name: /delete/i }).click();
    await expect(page.getByText('e2e smoke rule')).toHaveCount(0);
  });

  test('CMD-K navigates to a rule', async ({ page }) => {
    await page.keyboard.press('Control+K');
    await page.getByRole('searchbox').fill('smoke');
    // No rule expected in fresh DB — verify palette renders without crashing
    await expect(page.getByRole('dialog')).toBeVisible();
    await page.keyboard.press('Escape');
  });

  test('silence create + end-early', async ({ page }) => {
    await page.goto('/alerts/silences');
    await page.getByLabel(/app slug/i).fill('smoke-app');
    await page.getByLabel(/duration/i).fill('1');
    await page.getByLabel(/reason/i).fill('e2e smoke');
    await page.getByRole('button', { name: /create silence/i }).click();
    await expect(page.getByText('smoke-app')).toBeVisible();

    page.once('dialog', (d) => d.accept());
    await page.getByRole('row', { name: /smoke-app/i }).getByRole('button', { name: /end/i }).click();
    await expect(page.getByText('smoke-app')).toHaveCount(0);
  });
});
```

- [ ] **Step 3: Run the smoke (requires backend on :8081)**

```bash
cd ui && npx playwright test
```

Expected: 4 tests pass. If the dev env uses a different admin credential, override via `E2E_ADMIN_USER` + `E2E_ADMIN_PASS`.

- [ ] **Step 4: Commit**

```bash
git add ui/src/test/e2e/alerting.spec.ts ui/src/test/e2e/fixtures.ts
git commit -m "test(ui/alerts): Playwright smoke — sidebar nav, rule CRUD, CMD-K, silence CRUD

Smoke runs against the real backend (not mocks) per project test policy.
Does not exercise fire-to-ack (requires event ingestion machinery); that
path is covered by backend AlertingFullLifecycleIT."
```

---

### Task 31: Update `.claude/rules/ui.md` + admin guide

**Files:**
- Modify: `.claude/rules/ui.md`
- Modify: `docs/alerting.md`

- [ ] **Step 1: Update `.claude/rules/ui.md`**

Read the file and append two new sections:

```markdown
## Alerts

- **Sidebar section** (`buildAlertsTreeNodes` in `ui/src/components/sidebar-utils.ts`) — Inbox, All, Rules, Silences, History.
- **Routes** in `ui/src/router.tsx`: `/alerts`, `/alerts/inbox`, `/alerts/all`, `/alerts/history`, `/alerts/rules`, `/alerts/rules/new`, `/alerts/rules/:id`, `/alerts/silences`.
- **Pages** under `ui/src/pages/Alerts/`:
  - `InboxPage.tsx` — user-targeted FIRING/ACK'd alerts with bulk-read.
  - `AllAlertsPage.tsx` — env-wide list with state chip filter.
  - `HistoryPage.tsx` — RESOLVED alerts.
  - `RulesListPage.tsx` — CRUD + enable/disable toggle + env-promotion dropdown (pure UI prefill, no new endpoint).
  - `RuleEditor/RuleEditorWizard.tsx` — 5-step wizard (Scope / Condition / Trigger / Notify / Review). `form-state.ts` is the single source of truth (initialForm / toRequest / validateStep).
  - `SilencesPage.tsx` — matcher-based create + end-early.
- **Components**:
  - `NotificationBell.tsx` — polls `/alerts/unread-count` every 30s, paused when tab hidden via `usePageVisible`.
  - `AlertStateChip.tsx`, `SeverityBadge.tsx` — shared state/severity indicators.
  - `MustacheEditor/` — CodeMirror 6 editor with variable autocomplete + inline linter. Shared between rule title/message, webhook body/header overrides, and Admin Outbound Connection editor (reduced-context mode for URL).
- **API queries** under `ui/src/api/queries/`: `alerts.ts`, `alertRules.ts`, `alertSilences.ts`, `alertNotifications.ts`, `alertMeta.ts`. All env-scoped via `useSelectedEnv`.
- **CMD-K**: `buildAlertSearchData` in `LayoutShell.tsx` registers `alert` and `alertRule` categories. Badges convey severity + state.
- **Sidebar accordion**: entering `/alerts/*` collapses Applications + Admin + Starred (mirrors Admin accordion).
```

- [ ] **Step 2: Update `docs/alerting.md` admin guide**

Append a UI walkthrough section covering:
- Where to find the Alerts section (sidebar + top-bar bell)
- How to author a rule (wizard screenshots can be added later)
- How to create a silence
- How to interpret the env-promotion warnings
- Where Mustache variable autocomplete comes from

Keep it to ~60 lines; point readers to spec §13 for the full design rationale.

- [ ] **Step 3: Commit**

```bash
git add .claude/rules/ui.md docs/alerting.md
git commit -m "docs(alerting): UI map + admin-guide walkthrough for Plan 03

.claude/rules/ui.md now maps every Plan 03 UI surface. Admin guide picks up
inbox/rules/silences sections so ops teams can start in the UI without
reading the spec."
```

---

### Task 32: Final verification — build, lint, tests

- [ ] **Step 1: Frontend full build (type check + bundle)**

```bash
cd ui && npm run build
```

Expected: clean build, no errors. Bundle size within reason (<2 MB uncompressed, CM6 + alerts pages add ~150 KB gzipped).

- [ ] **Step 2: Frontend lint**

```bash
cd ui && npm run lint
```

Expected: zero errors. Fix warnings introduced by Plan 03 files; ignore pre-existing ones.

- [ ] **Step 3: Frontend unit tests**

```bash
cd ui && npm test
```

Expected: all Plan 03 Vitest suites pass (≥ 25 tests across hooks, chips, editor, form-state, prefill).

- [ ] **Step 4: Backend verify**

```bash
mvn -pl cameleer-server-app -am verify
```

Expected: all existing + new tests pass (SsrfGuardTest, AlertingMetricsCachingTest, extended OutboundConnectionAdminControllerIT).

- [ ] **Step 5: `gitnexus_detect_changes` pre-PR sanity**

```
gitnexus_detect_changes({scope:"compare", base_ref:"main"})
```

Expected: affected symbols = only Plan 03 surface (Alerts pages, MustacheEditor, NotificationBell, SsrfGuard, AlertingMetrics caching, router/LayoutShell edits). No stray edits to unrelated modules.

- [ ] **Step 6: Regenerate OpenAPI schema one final time**

If any backend DTO changed during the backfills, run `cd ui && npm run generate-api:live` and commit the diff. If there's no diff, skip this step.

- [ ] **Step 7: Commit any verification artifacts (none expected)**

No commit if everything is clean.

- [ ] **Step 8: Push branch + open PR**

```bash
git push -u origin feat/alerting-03-ui
gh pr create --title "feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching)" --body "$(cat <<'EOF'
## Summary
- Alerting UI: inbox, all/history, rules list, 5-step rule editor wizard, silences, notification bell, CMD-K integration.
- MustacheEditor: CodeMirror 6 with variable autocomplete + inline linter (shared across rule templates + webhook body/header overrides + connection defaults).
- Rule promotion across envs: pure UI prefill (no new endpoint) with client-side warnings (app missing in target env, agent-specific scope, connection not allowed in target env).
- Backend backfills: SSRF guard on outbound connection URL save (rejects loopback/link-local/RFC-1918); 30s TTL cache on AlertingMetrics gauges.
- Docs: `.claude/rules/ui.md` updated with full Alerts map; `docs/alerting.md` gains UI walkthrough.

Spec: `docs/superpowers/specs/2026-04-19-alerting-design.md` §12/§13/§9.
Plan: `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`.

## Test plan
- [ ] Vitest unit suites all pass (`cd ui && npm test`).
- [ ] Playwright smoke passes against a running backend (`cd ui && npx playwright test`).
- [ ] `mvn -pl cameleer-server-app -am verify` green.
- [ ] Manual: create a rule, see it in the list, CMD-K finds it, disable it, delete it.
- [ ] Manual: create a silence, see it, end it early.
- [ ] Manual: bell shows unread count when a FIRING alert targets the current user.
- [ ] Manual: promoting a rule with an agent-scope shows the agent-id warning.

Plan 01 + Plan 02 are already on main; Plan 03 targets main directly. Supersedes the chore/openapi-regen-post-plan02 branch (delete after merge).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

---

## Self-Review

Ran per the writing-plans skill self-review checklist.

### 1. Spec coverage

| Spec requirement | Covered by |
|---|---|
| §12 CMD-K integration (alerts + alertRules result sources) | Task 27 |
| §13 UI routes (`/alerts/**`) | Task 13 |
| §13 Top-nav `<NotificationBell />` | Tasks 9, 15 |
| §13 `<AlertStateChip />` | Task 8 |
| §13 Rule editor 5-step wizard | Tasks 19–25 |
| §13 `<MustacheEditor />` with variable autocomplete | Tasks 10–12 |
| §13 Silences / History / Rules list / OutboundConnectionAdminPage | Tasks 16, 17, 18, 26 (Outbound page already exists from Plan 01) |
| §13 Real-time: bell polls every 30s; paused when tab hidden | Task 9 (`usePageVisible`) + query `refetchIntervalInBackground:false` |
| §13 Accessibility: keyboard nav, ARIA | CM6 autocomplete is ARIA-conformant; bell has aria-label |
| §13 Styling: `@cameleer/design-system` CSS variables | All files use `var(--error)` etc. (no hardcoded hex) |
| §9 Rule promotion across envs — pure UI prefill + warnings | Tasks 18 (entry), 24, 25 |
| §17 SSRF guard on outbound URL | Task 28 |
| Final-review NIT: 30s gauge caching | Task 29 |
| Regenerate OpenAPI schema | Tasks 3, 32 |
| Update `.claude/rules/` | Task 31 |
| Testing preference: REST-API-driven (not raw SQL); Playwright over real backend | Task 30 + backend IT extension in Task 28 |

No uncovered requirements from the spec sections relevant to Plan 03.

### 2. Placeholder scan

- No "TBD" / "implement later" / "similar to Task N" / "Add appropriate error handling" patterns remain.
- Every step with a code change includes the actual code.
- Step stubs in Task 13 are explicitly marked as "replaced in Phase 5/6/7" — they're real code, just thin.
- Some condition-forms (Task 21 step 4) reuse the same pattern; each form is shown in full rather than "similar to RouteMetricForm".

### 3. Type consistency

- `MustacheEditor` props are the same shape across all call sites: `{ value, onChange, kind?, reducedContext?, label, placeholder?, minHeight?, singleLine? }`.
- `FormState` is declared in `form-state.ts` and used identically by all wizard steps.
- Schema-derived types (`AlertDto`, `AlertRuleResponse`, etc.) come from `components['schemas'][...]` so they stay in sync with the backend.
- Query hook names follow a consistent convention: `use<Resource>()` (list), `use<Resource>(id)` (single), `useCreate<Resource>`, `useUpdate<Resource>`, `useDelete<Resource>`.

No inconsistencies.

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-04-20-alerting-03-ui.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration.

**2. Inline Execution** — execute tasks in this session using executing-plans, batch execution with checkpoints.

**Which approach?**
