# System Health & Status Page — Implementation Plan

**Spec:** docs/specs/2026-05-31-system-status-page.md  ·  **Slug:** system-status-page  ·  **Wave:** 4
**Depends on:** admin-dashboard, foundation-monorepo

## Goal
Deliver a public, outage-resilient system status page (served from `zync-www`, a separate origin from `app.zync.is`) showing real-time service health and incident history, plus a SUPER_ADMIN incident-management surface at `/admin/incidents` in `apps/zync-admin`. Service status is derived from active (unresolved) incidents — no separate health-check pinging. Visitors can subscribe by email; subscribers are notified on incident create/update/resolve via Resend. The public status payload is cached in a dedicated `STATUS_KV` namespace (30s TTL) and invalidated immediately on any incident mutation so the page stays cheap during traffic spikes that coincide with outages.

## Architecture
- **DB (`@zync/db`):** three new tables — `system_incidents`, `system_incident_updates`, `status_subscribers`. Actor columns are `created_by TEXT` (admin email), NOT a UUID FK — admin staff live in `admin_users`, not the tenant `users` table, and the spec deliberately stores the email string. `unsubscribe_token` uses pgcrypto (`encode(gen_random_bytes(16),'hex')`), so the migration must ensure `CREATE EXTENSION IF NOT EXISTS pgcrypto`.
- **Service catalog:** a canonical constant `STATUS_SERVICES` (exported from `@zync/types`) lists the seven services with snake_case ids; `affected_services TEXT[]` values and the public page's per-service rows both derive from it. Per-service status is computed: a service is `degraded` (impact `minor`/`major`) or `outage` (impact `critical`) if any unresolved incident lists it; otherwise `operational`.
- **API (`apps/zync-api`, Hono):** public `GET /api/status`, `GET /api/status/history`, `GET /status/unsubscribe`, `GET /status/rss.xml`; admin `POST /api/admin/incidents`, `/:id/update`, `/:id/resolve` behind the locked `requireAdminSession()` + `AdminSessionPayload`. Mutations write rows, enqueue subscriber emails via the locked `sendEmail` export, and call `invalidateStatusCache()`.
- **KV:** new `STATUS_KV` binding (added to `apps/zync-api` wrangler config + `Env`). `GET /api/status` reads/writes a single cache key with 30s TTL.
- **Public UI (`apps/zync-www`, Astro):** SSR `status.astro` page fetching `/api/status` server-side, with a React island for the subscribe form. Status conveyed by text + `aria-label` (not color alone); uptime bar respects `prefers-reduced-motion`; CSP via existing Astro middleware nonce.
- **Admin UI (`apps/zync-admin`, Vite+React):** `/admin/incidents` page (list active + past 30d, new-incident form, post-update, resolve) using locked `@zync/ui` primitives; adds an "Incidents" nav entry to the admin shell (admin-dashboard's nav does not list it).

Consumes upstream exports: `createDb`, `DB`, `Env`, `requireAdminSession`, `AdminSessionPayload`, `admin_users`, `sendEmail`, `Button`, `Dialog`, `DataTable`, `Badge`, `Form`, `FormField`, `Input`, `Select`, `Card`, `StatCard`, `Toast`, `toast`.

## Tech Stack
- Packages: `@zync/db` (Drizzle schema + queries), `@zync/types` (service catalog + DTO types), `@zync/ui` (shared components).
- Apps: `apps/zync-api` (Hono routes, KV cache, email dispatch), `apps/zync-www` (Astro public page + island), `apps/zync-admin` (React admin page + nav).
- Cloudflare bindings: `DB` (Neon via Hyperdrive), new `STATUS_KV` (KV), Resend via `RESEND_API_KEY` (through `sendEmail`).
- Libraries: Drizzle ORM, Zod (route validation), `@astrojs/cloudflare` SSR adapter, React islands.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Data & shared | 1, 2 | `packages/db/src/schema`, `packages/db/migrations`, `packages/types` | Task 1 then 2 (2 needs schema) |
| B — Server core | 3, 4, 5 | `packages/db/src/queries`, `apps/zync-api/src/lib`, wrangler config | 3 after 2; 4 after 3; 5 after 4 |
| C — API routes | 6, 7, 8 | `apps/zync-api/src/routes` | 6,7,8 parallel after 5 |
| D — Public UI | 9, 10 | `apps/zync-www` | 9 then 10 (island after page) |
| E — Admin UI | 11, 12 | `apps/zync-admin` | 11 then 12 (page after nav) |

## Tasks

### Task 1: Database schema — incidents, updates, subscribers
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/status.ts`
- Modify: `packages/db/src/schema/index.ts` (re-export status schema)
- Create: `packages/db/migrations/00XX_system_status.sql`
**Steps:**
- [ ] Define the three tables in Drizzle (pgTable) matching the DDL below; export `systemIncidents`, `systemIncidentUpdates`, `statusSubscribers`.
- [ ] Write the raw SQL migration. First line ensures pgcrypto for `gen_random_bytes`: `CREATE EXTENSION IF NOT EXISTS pgcrypto;`.
- [ ] Convert the spec's comment-enums into inline `CHECK` constraints (status, impact).
- [ ] Add an index on `system_incidents (resolved_at)` for the "active incidents" lookup and on `system_incident_updates (incident_id, created_at)` for ordered update fetch.
- [ ] Re-export the new tables from the package schema barrel so `createDb` exposes them.
**Schema / Interfaces:**
```sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE system_incidents (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title             TEXT NOT NULL,
  status            TEXT NOT NULL CHECK (status IN ('investigating','identified','monitoring','resolved')),
  impact            TEXT NOT NULL CHECK (impact IN ('minor','major','critical')),
  affected_services TEXT[] NOT NULL DEFAULT '{}',
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  resolved_at       TIMESTAMPTZ,
  created_by        TEXT NOT NULL          -- SUPER_ADMIN email (admin staff are not tenant users)
);
CREATE INDEX idx_system_incidents_resolved_at ON system_incidents (resolved_at);

CREATE TABLE system_incident_updates (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  incident_id UUID NOT NULL REFERENCES system_incidents(id),
  body        TEXT NOT NULL,
  status      TEXT NOT NULL CHECK (status IN ('investigating','identified','monitoring','resolved')),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  created_by  TEXT NOT NULL          -- SUPER_ADMIN email
);
CREATE INDEX idx_system_incident_updates_incident ON system_incident_updates (incident_id, created_at);

CREATE TABLE status_subscribers (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email             TEXT NOT NULL UNIQUE,
  subscribed_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  unsubscribe_token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(16), 'hex')
);
```
**Acceptance:**
- [ ] Migration applies cleanly to a fresh Neon branch; pgcrypto default populates `unsubscribe_token`.
- [ ] Inserting an incident with `status='bogus'` is rejected by the CHECK constraint.
- [ ] `created_by` is TEXT (not a UUID FK) on both incident tables.

### Task 2: Service catalog + DTO types in `@zync/types`
**Blocks:** 3, 9, 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/status.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Define `STATUS_SERVICES` as a readonly array of `{ id, label }` covering the seven services in the spec mockup.
- [ ] Define `ServiceStatus`, `IncidentStatus`, `IncidentImpact` union types and the public response DTOs.
- [ ] Export everything from the types barrel.
**Schema / Interfaces:**
```ts
export const STATUS_SERVICES = [
  { id: 'app',             label: 'App (app.zync.is)' },
  { id: 'api',             label: 'API' },
  { id: 'file_uploads',    label: 'File uploads (R2)' },
  { id: 'email_delivery',  label: 'Email delivery' },
  { id: 'payments',        label: 'Payment processing' },
  { id: 'ai_assistant',    label: 'AI assistant' },
  { id: 'customer_portals',label: 'Customer portals' },
] as const;

export type StatusServiceId = (typeof STATUS_SERVICES)[number]['id'];
export type ServiceStatus = 'operational' | 'degraded' | 'outage';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
export type IncidentImpact = 'minor' | 'major' | 'critical';

export interface IncidentUpdateObject {
  id: string;
  body: string;
  status: IncidentStatus;
  createdAt: string;
}
export interface IncidentObject {
  id: string;
  title: string;
  status: IncidentStatus;
  impact: IncidentImpact;
  affectedServices: StatusServiceId[];
  createdAt: string;
  resolvedAt: string | null;
  updates: IncidentUpdateObject[];
}
export interface ServiceStatusObject {
  id: StatusServiceId;
  label: string;
  status: ServiceStatus;
}
export interface StatusPayload {
  overall: ServiceStatus;
  services: ServiceStatusObject[];
  activeIncidents: IncidentObject[];
  updatedAt: string;
}
export interface StatusHistoryDay {
  date: string;          // YYYY-MM-DD
  status: ServiceStatus; // worst status that day
}
export interface StatusHistoryPayload {
  days: StatusHistoryDay[];
  uptimePct: number;     // e.g. 99.8
  incidents: IncidentObject[];
}
```
**Acceptance:**
- [ ] `STATUS_SERVICES`, `StatusPayload`, `IncidentObject`, `ServiceStatus` importable from `@zync/types`.
- [ ] Service ids are snake_case and match the example `['email_delivery','api']`.

### Task 3: DB query helpers for status
**Blocks:** 6, 7, 8  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/queries/status.ts`
- Modify: `packages/db/src/queries/index.ts` (re-export)
**Steps:**
- [ ] Implement `getActiveIncidents(db)` — incidents where `resolved_at IS NULL`, each with its updates ordered `created_at DESC`.
- [ ] Implement `deriveServiceStatuses(activeIncidents)` — pure function returning `ServiceStatusObject[]` from `STATUS_SERVICES`: a service listed in any active incident is `outage` if any such incident impact is `critical`, else `degraded`; otherwise `operational`. `overall` = worst of all services.
- [ ] Implement `getIncidentHistory(db, days)` — incidents with `created_at >= now() - days`, ordered desc, with updates; compute per-day worst status and `uptimePct` (days with no degraded/outage incident ÷ total days × 100, rounded to 1 dp).
- [ ] Implement `createIncident(db, input)`, `addIncidentUpdate(db, incidentId, input)`, `resolveIncident(db, incidentId, input)` — the resolve helper sets `resolved_at = now()`, `status = 'resolved'`, and inserts a final update row in one transaction.
- [ ] Implement `listSubscribers(db)`, `addSubscriber(db, email)` (idempotent via `ON CONFLICT (email) DO NOTHING RETURNING`), `getSubscriberByToken(db, token)`, `removeSubscriber(db, token)`.
- [ ] Implement `serializeIncident(row, updates)` → `IncidentObject` (camelCase DTO).
**Schema / Interfaces:**
```ts
export function deriveServiceStatuses(active: IncidentObject[]): ServiceStatusObject[];
export function getActiveIncidents(db: DB): Promise<IncidentObject[]>;
export function getIncidentHistory(db: DB, days: number): Promise<StatusHistoryPayload>;
export function createIncident(db: DB, input: {
  title: string; status: IncidentStatus; impact: IncidentImpact;
  affectedServices: StatusServiceId[]; body: string; createdBy: string;
}): Promise<IncidentObject>;
export function addIncidentUpdate(db: DB, incidentId: string, input: {
  body: string; status: IncidentStatus; createdBy: string;
}): Promise<IncidentObject>;
export function resolveIncident(db: DB, incidentId: string, input: {
  body: string; createdBy: string;
}): Promise<IncidentObject>;
export function addSubscriber(db: DB, email: string): Promise<{ unsubscribeToken: string } | null>;
export function listSubscribers(db: DB): Promise<{ email: string; unsubscribeToken: string }[]>;
export function getSubscriberByToken(db: DB, token: string): Promise<{ email: string } | null>;
export function removeSubscriber(db: DB, token: string): Promise<boolean>;
export function serializeIncident(row: SystemIncidentRow, updates: SystemIncidentUpdateRow[]): IncidentObject;
```
**Acceptance:**
- [ ] `deriveServiceStatuses` returns `degraded` for a `minor`/`major` incident's services and `outage` for `critical`; all others `operational`.
- [ ] `resolveIncident` is transactional (resolved_at + status + final update all-or-nothing).
- [ ] `addSubscriber` on a duplicate email does not throw and returns null (already subscribed).

### Task 4: `STATUS_KV` binding + cache helpers
**Blocks:** 5, 6  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/wrangler.toml` (add KV namespace binding `STATUS_KV`)
- Modify: `apps/zync-api/src/env.ts` (or wherever `Env` is declared — add `STATUS_KV: KVNamespace`)
- Create: `apps/zync-api/src/lib/status-cache.ts`
**Steps:**
- [ ] Add a `[[kv_namespaces]]` entry `binding = "STATUS_KV"` to the api wrangler config (placeholder id documented for ops to provision).
- [ ] Extend the `Env` interface with `STATUS_KV: KVNamespace`.
- [ ] Implement `getCachedStatus(env)` — read key `status:current`; on miss recompute via `getActiveIncidents` + `deriveServiceStatuses`, write with `{ expirationTtl: 30 }`, return `StatusPayload`.
- [ ] Implement `invalidateStatusCache(env)` — delete key `status:current`.
**Schema / Interfaces:**
```ts
export async function getCachedStatus(env: Env): Promise<StatusPayload>;
export async function invalidateStatusCache(env: Env): Promise<void>;
```
**Acceptance:**
- [ ] Second call to `getCachedStatus` within 30s does not hit the DB (cache hit).
- [ ] `invalidateStatusCache` followed by `getCachedStatus` recomputes from DB.
- [ ] `STATUS_KV` is declared in both wrangler config and `Env`.

### Task 5: Subscriber email notifier
**Blocks:** 6  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-api/src/lib/status-notify.ts`
**Steps:**
- [ ] Implement `notifySubscribers(env, kind, incident)` where `kind ∈ 'created'|'updated'|'resolved'`.
- [ ] Fetch `listSubscribers(db)`; for each, call the locked `sendEmail` export (Resend) with a subject/body describing the incident and its latest update.
- [ ] Append an unsubscribe link to every email: `https://zync.is/status/unsubscribe?token={unsubscribeToken}`.
- [ ] Send in batches (chunk subscribers, e.g. 50/iteration) to avoid one giant fan-out; failures per-recipient are logged, not fatal.
**Schema / Interfaces:**
```ts
export async function notifySubscribers(
  env: Env, kind: 'created' | 'updated' | 'resolved', incident: IncidentObject,
): Promise<void>;
```
**Acceptance:**
- [ ] Each email body contains a working unsubscribe URL with the recipient's token.
- [ ] One failed recipient send does not abort the rest of the batch.
- [ ] Uses the locked `sendEmail` export (no direct Resend HTTP call duplicated here).

### Task 6: Admin incident API routes
**Blocks:** 12  ·  **Blocked by:** 3, 4, 5
**Files:**
- Create: `apps/zync-api/src/routes/admin/incidents.ts`
- Modify: `apps/zync-api/src/routes/admin/index.ts` (mount router)
**Steps:**
- [ ] Mount all routes behind `requireAdminSession()`; read the admin email from `AdminSessionPayload` for `created_by`.
- [ ] `POST /api/admin/incidents` — Zod-validate `{ title, status, impact, affectedServices[], body }`; `affectedServices` items must be valid `StatusServiceId`s; call `createIncident`, then `invalidateStatusCache`, then `notifySubscribers('created', …)`. Return the `IncidentObject`.
- [ ] `POST /api/admin/incidents/:id/update` — Zod-validate `{ status, body }`; call `addIncidentUpdate`, invalidate cache, `notifySubscribers('updated', …)`.
- [ ] `POST /api/admin/incidents/:id/resolve` — Zod-validate `{ body }`; call `resolveIncident`, invalidate cache, `notifySubscribers('resolved', …)`.
- [ ] Validate `:id` is a UUID and that the incident exists / is not already resolved (for update/resolve); return 404/409 appropriately.
**Schema / Interfaces:**
```ts
// POST /api/admin/incidents
const createBody = z.object({
  title: z.string().min(1),
  status: z.enum(['investigating','identified','monitoring','resolved']),
  impact: z.enum(['minor','major','critical']),
  affectedServices: z.array(z.enum(['app','api','file_uploads','email_delivery','payments','ai_assistant','customer_portals'])),
  body: z.string().min(1),
});
// POST /api/admin/incidents/:id/update
const updateBody = z.object({
  status: z.enum(['investigating','identified','monitoring','resolved']),
  body: z.string().min(1),
});
// POST /api/admin/incidents/:id/resolve
const resolveBody = z.object({ body: z.string().min(1) });
```
**Acceptance:**
- [ ] All three routes reject requests without a valid admin session (401).
- [ ] Each successful mutation invalidates `STATUS_KV` and enqueues subscriber notifications.
- [ ] `created_by` on the row equals the authenticated admin's email.
- [ ] Invalid `affectedServices` value → 400.

### Task 7: Public status API routes
**Blocks:** 9  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/public/status.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount, no auth)
**Steps:**
- [ ] `GET /api/status` — no auth; return `getCachedStatus(env)` with `Cache-Control: public, max-age=30`.
- [ ] `GET /api/status/history?days=90` — no auth; Zod-coerce `days` (default 90, clamp 1–365); return `getIncidentHistory(db, days)`.
- [ ] `GET /status/rss.xml` — no auth; render an RSS 2.0 feed of the last 30 incidents (title + latest update body + pubDate); `Content-Type: application/rss+xml`.
- [ ] `GET /status/unsubscribe?token=` — no auth; Zod-validate token; call `removeSubscriber`; render a minimal confirmation HTML page (success or "already unsubscribed").
**Schema / Interfaces:**
```ts
const historyQuery = z.object({ days: z.coerce.number().int().min(1).max(365).default(90) });
const unsubQuery   = z.object({ token: z.string().min(1) });
// GET /api/status            -> StatusPayload
// GET /api/status/history    -> StatusHistoryPayload
```
**Acceptance:**
- [ ] `GET /api/status` responds with `StatusPayload` and no auth required.
- [ ] `GET /api/status/history?days=90` returns up to 90 days with `uptimePct`.
- [ ] `GET /status/rss.xml` returns valid RSS with `application/rss+xml` content type.
- [ ] Unsubscribe with a valid token removes the subscriber and confirms; invalid token shows a graceful message.

### Task 8: Public subscribe API route
**Blocks:** 10  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/public/status.ts`
**Steps:**
- [ ] `POST /api/status/subscribe` — no auth; Zod-validate `{ email }`; rate-limit via existing `RATE_LIMITER_LEAD_FORM` (or document a dedicated limiter) to prevent abuse; call `addSubscriber`; always respond 200 with a neutral message (do not leak whether the email was already subscribed).
**Schema / Interfaces:**
```ts
const subscribeBody = z.object({ email: z.string().email() });
// POST /api/status/subscribe -> { ok: true }
```
**Acceptance:**
- [ ] Valid email is stored once; duplicate submission still returns 200 without error.
- [ ] Endpoint is rate-limited.
- [ ] Response does not reveal subscription state of the email.

### Task 9: Public Astro status page
**Blocks:** 10  ·  **Blocked by:** 2, 7
**Files:**
- Create: `apps/zync-www/src/pages/status.astro`
- Modify: `apps/zync-www/src/middleware.ts` (ensure CSP nonce covers this page; connect-src allows the api origin)
**Steps:**
- [ ] SSR-fetch `GET /api/status` and `GET /api/status/history?days=90` at request time (different origin from `app.zync.is` so the page survives an app outage).
- [ ] Render the overall banner (● operational / ⚠ investigating) with status conveyed by **text + `aria-label`**, never color alone (WCAG color-not-sole-indicator); banner uses `role="status"`.
- [ ] Render the services list from `payload.services` (icon + label + textual status).
- [ ] Render the 90-day uptime bar chart; the bar/transitions must respect `prefers-reduced-motion` (no animation when reduced).
- [ ] Render active incidents with their update timeline (newest first, timestamps in UTC).
- [ ] Render "No recent incidents." when `activeIncidents` is empty and history has none.
- [ ] Add `[Subscribe to updates]` (mounts the island from Task 10) and `[RSS feed]` link to `/status/rss.xml`.
- [ ] Mirror RTL/Hebrew handling from the marketing site's existing locale setup (logical CSS properties; `dir` from locale).
- [ ] "Updated: N min ago" derived from `payload.updatedAt`.
**Acceptance:**
- [ ] Page renders server-side and works even if `app.zync.is` is unreachable (separate origin).
- [ ] Service status readable without color (text label + aria-label present).
- [ ] Uptime bar does not animate under `prefers-reduced-motion: reduce`.
- [ ] CSP header present; `[RSS feed]` links to `/status/rss.xml`.

### Task 10: Subscribe-form React island
**Blocks:** —  ·  **Blocked by:** 8, 9
**Files:**
- Create: `apps/zync-www/src/components/StatusSubscribeForm.tsx`
**Steps:**
- [ ] Build a React island (hydrated `client:visible`) with an email input + submit button using shared `@zync/ui` `Input`/`Button`.
- [ ] POST to `/api/status/subscribe`; on success show a neutral confirmation ("If that email is valid, you'll receive status updates."); on validation error show inline message.
- [ ] Provide accessible labels, an `aria-live` region for the result message, and disabled/submitting state.
**Acceptance:**
- [ ] Submitting a valid email shows the neutral success message.
- [ ] Form is keyboard-accessible with a labeled input and live result region.

### Task 11: Admin shell nav entry for Incidents
**Blocks:** 12  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-admin/src/components/AdminNav.tsx` (or the admin shell nav config)
- Modify: `apps/zync-admin/src/router.tsx` (register `/admin/incidents` route)
**Steps:**
- [ ] Add an "Incidents" nav item (`/admin/incidents`) to the admin shell navigation after the existing entries.
- [ ] Register the lazy-loaded `IncidentsPage` route guarded by the admin session (admin app already enforces `session.type === 'admin'`).
**Acceptance:**
- [ ] "Incidents" appears in admin nav and routes to `/admin/incidents`.
- [ ] Route is only reachable with an admin session.

### Task 12: Admin incidents management page
**Blocks:** —  ·  **Blocked by:** 6, 11
**Files:**
- Create: `apps/zync-admin/src/pages/IncidentsPage.tsx`
- Create: `apps/zync-admin/src/components/NewIncidentDialog.tsx`
- Create: `apps/zync-admin/src/components/PostUpdateDialog.tsx`
- Create: `apps/zync-admin/src/api/incidents.ts` (client fetchers)
**Steps:**
- [ ] Fetch status data: active incidents (from `GET /api/status`) and history (from `GET /api/status/history?days=30`) to render "Active" and "Past (last 30 days)" sections.
- [ ] "+ New Incident" → `NewIncidentDialog`: fields title, status (select), impact (minor/major/critical select), affected services (multi-select from `STATUS_SERVICES`), initial update text → `POST /api/admin/incidents`.
- [ ] Per active incident: "Post update" → `PostUpdateDialog` (status select + message) → `POST /api/admin/incidents/:id/update`; "Resolve" → confirm dialog prompting final update text → `POST /api/admin/incidents/:id/resolve`.
- [ ] Use locked `@zync/ui` primitives (`Dialog`, `Form`, `FormField`, `Input`, `Select`, `Button`, `Badge`, `Card`, `Toast`/`toast`); show success/error toasts; refetch on mutation success.
- [ ] Render statuses with text labels + badges (not color alone) for a11y.
**Acceptance:**
- [ ] Creating an incident posts to `/api/admin/incidents` and the new incident appears in Active.
- [ ] "Post update" and "Resolve" call the correct endpoints; resolve requires final update text.
- [ ] Resolved incidents move from Active to Past.
- [ ] UI uses shared `@zync/ui` components and surfaces success/error via toast.
