# Milestone 29: Activity consolidation

Redesign step 3 of specs/ui-redesign.md ("Activity", "Time scoping", "Deletions and their cost", build-sequence step 3). Query Log, Live and Lookup merge into one Activity surface; the three old routes and their code are removed in the same change. Admin-only — no Zig, wire or openapi changes; if a session believes it needs one, that is a spec bug to report, not code to write. Codex design review folded in (thread 01a02857); its corrections are marked where they changed the shape.

## Sessions

S1: route-independent primitives only — it must not touch routes.tsx, AppShell.tsx, or move any route-bound page (Codex: the original move-then-delete split could not keep the tree shippable between sessions). S2: one atomic landable session — routes, page assembly, moves, deletion, retargeting, test migration, smoke. Sequential.

---

## Session S1: primitives (no route changes)

### S1.1 Summary projection and cells

- `QuerySummary` (querySummary.ts:13) gains `rcode` and `route_kind` only. **Result derives from the existing `blocked` projection** (which already folds live `policy.action` — Codex: the summary has no `policy_action` and does not need one), else the rcode.
- New `admin/src/features/activity/cells.tsx`: the seven-column set. The Domain cell takes a **route-neutral link-renderer prop** (Codex: S1 cannot reference the not-yet-existing typed route without failing typecheck); S2 supplies the typed `Link` to `/activity/queries/$id`. — Time, Domain, Client, Type, Result, Route, Duration. Table-only compact labels, defined and tested exactly here (the detail page keeps `provenanceCopy`'s long forms): Result → `Blocked` | `NOERROR` | `SERVFAIL` | … (bare rcode name, no numeric suffix); Route → `Blocked` | `Local` | `Forward zone` | `Upstream` | `Cache` | `Rejected`. Non-NOERROR result carries a non-color signal (weight/icon), same treatment as Blocked. Duration formats `response_time_us`, em dash when null.
- Tests: fixtures for blocked, allowed-NOERROR (**explicitly `blocked=false`** — Codex: the allowed case must be pinned, not implied), SERVFAIL, cache hit; each cell's exact text, including an unassigned extended rcode preserving provenanceCopy's `RCODE <n>` fallback shape (compact form without the parenthesized number).

### S1.2 Shared provenance-detail renderer (Codex critical: streamed live rows must keep a detail surface)

Extract the ordered-explanation body of QueryDetailPage (request / group / policy / rewrites / route / response sections, historical-vs-current separation, the honesty wordings) into `admin/src/features/activity/ProvenanceDetail.tsx` taking a `Provenance` plus optional persisted id. QueryDetailPage becomes a thin route wrapper around it (page stays at its current route in S1 — only the body moves to a route-independent component). The renderer takes related-action links as props so S1 stays route-agnostic.

### S1.3 Form/URL plumbing primitives

- `Select` (ui/Select.tsx:23) gains `isDisabled`, passed through to React Aria (Codex: the filter row cannot otherwise be disabled).
- Datetime conversion becomes bidirectional and second-exact: `datetimeLocalToUnix` gets its inverse (`unixToDatetimeLocal`, local time), inputs use `step={1}`. **DST makes local text lossy** (Codex: a fall-back fold maps two instants to one string; a spring-forward gap silently normalizes): the form keeps the original unix value plus a per-field dirty flag, reuses the original unless the operator edited that field, and rejects an edited value that does not format back identically after parsing. Tests: non-zero-second round trips, a DST-fold instant, a nonexistent spring-forward time.
- Search-param validators as pure functions in `activity/search.ts`: `since`/`until` accepted only via `Number.isSafeInteger`; `blocked` only when `typeof === "boolean"`; `mode` in the two-value union defaulting `"history"`; `domain`/`client` trimmed, with the empty string normalized to `undefined` (Codex: `domain=` must not persist as applied state the server treats as no filter). Tests: fractions, Infinity, overflow, quoted booleans, `false`, empty and whitespace strings.

### S1.4 Acceptance (S1)

- [ ] Typecheck, vitest, oxlint, format clean; existing pages still work untouched (only additive files plus the QueryDetailPage body extraction and Select prop).

## Session S2: the Activity surface, atomically

### S2.1 Routes (routes.tsx) — created and deleted in one change

- `/activity` → ActivityPage, `validateSearch` from S1's validators. `loaderDeps` returns an **explicit object** of `{mode, since, until, domain, client, blocked}` — never the whole search (Codex: unknown keys create spurious matches without `search.strict`); the history loader builds `QueriesFilter` **field-by-field** and only calls `ensureInfiniteQueryData` in history mode.
- `/activity/queries/$id` → the detail wrapper (loader unchanged, incl. the deliberate 404 swallow). The route's search carries the originating Activity search (validated by the same functions), so the back link restores the exact investigation view (Codex: a bare `/activity` back link discards context and violates absolute time scoping).
- `/activity/test` → policy simulation, search `domain`, `?domain=` auto-run preserved.
- `/queries`, `/queries/$id`, `/live`, `/lookup` deleted from the route tree in the same edit. No aliases, no redirects; unknown paths fall to the router's existing not-found handling (verify sane, build nothing).

### S2.2 ActivityPage

- **Mode switch is URL-controlled** (the uncontrolled Tabs at ui/Tabs.tsx:76 does not fit — a controlled switch or controlled-Tabs variant), updating via the functional form `search: prev => ({...prev, mode})` so filters survive (Codex: object-replacement navigate loses them). The Live subtree (`LiveActivity`) mounts **only** in live mode so no SSE connection lingers in history. Tests: switching to history closes the EventSource; switching back creates exactly one fresh source.
- History mode: URL is the applied state. The form draft resets whenever the applied search changes (back/forward, pasted URL — Codex: the current seed-once pattern fails back/forward; test both, with non-zero seconds in the bounds). Apply = navigate with the explicit search object; keyset/infinite mechanics (`next_before`, `isPlaceholderData` guard, `hasMore` derivation) carry over; CoverageNotice stays.
- Live mode: follow-by-default, Freeze/Resume ephemeral (never URL). The live stack moves as-is: useLiveQueries (freeze snapshot, gap re-sync, 401 probe, cap threshold, stale-source guards), ringBuffer (LiveRow union, occurrence-counting mergeGap, rationale comment verbatim), fakeEventSource injection, status pill, `aria-pressed` Freeze, missed-recovered note, resync-failed alert, capped alert + Retry, blocked-row tint, footnote count.
- **Live detail**: a streamed row (id null) opens `ProvenanceDetail` in-place from its in-memory `event` — no invented correlation id, no persisted fetch (ui-redesign.md:177). Recovered rows link to `/activity/queries/$id`. **Retention contract** (Codex): selection stores the selected `LiveRow` snapshot itself; the open detail survives ring eviction, gap merges, and Freeze/Resume, closing only on explicit close or mode unmount. Tests pin: opening a streamed row's detail, keyboard access, survival across capacity eviction (501+ events) and across Freeze/Resume.
- Filter row in live mode: disabled via S1's `isDisabled` (visible, value-preserving), including native inputs and Apply/Clear; keyboard/tab-order test proves nothing in the row is tabbable while disabled.
- Detail-only facts, stated per what the wire actually carries (Codex: no source URL or upstream error text exists on `QueryDetail`): matched pattern, historical source label, selected upstream, rcode. Underlying failure text lives in Diagnostics — the detail page's related actions gain the accepted absolute-window Diagnostics link: `/diagnostics?since=<ts-300>&until=<ts+300>` (the redesign's five-minute window around the query, ui-redesign.md:173).
- Related domain/client links carry absolute bounds: `/activity?mode=history&domain=…&since=…&until=…` with **`since = origin.since ?? ts - 300` and `until = origin.until ?? ts + 300`** per bound independently (Codex: the fallback is product contract, not implementation choice). Tests cover zero-, one-, and two-bound origins.
- **Diagnostics bounds must actually apply** (Codex: `/diagnostics` neither validates nor uses `since`/`until` at HEAD, so the ±300s link would render an unbounded page): the diagnostics route gains safe-integer `since`/`until` search validation and explicit loaderDeps, DiagnosticsPage applies them to both the active and resolved queries, and the page shows a visible range indication when bounded. Tests assert the outgoing `/api/diagnostics` requests carry the bounds.

### S2.3 Policy simulation

LookupPage moves to `activity/` with the "Current policy simulation" framing (forward-tense property preserved); verdict rendering, 503/429 handling, group select, same-pair refetch carry over. Reached from an Activity action, not primary nav.

### S2.4 Navigation, deletion, migration

- `NAV_ITEMS`: Query Log/Live/Lookup out, `{to: "/activity", label: "Activity"}` in their position.
- Old page files, their cells/stylex exports, and dead helpers deleted. One home per module under `activity/`; no re-export shims.
- Test migration with the no-shrink rule: every behavior pinned by the old tests is re-pinned at the new home or listed in the report as intentionally dead with its page. Files: QueryLogPage.test, LiveLogPage.test, useLiveQueries.test, ringBuffer.test, LookupPage.test, QueryDetailPage.test, AppShell.test, LoginPage safeRedirect samples (cosmetic).
- Sweep: no import, string or test references `/queries`, `/live`, `/lookup` as routes (API paths `/api/queries*` stay).
- **CHANGELOG.md is S2-owned** (Codex): retarget the still-Unreleased m28 entry's `/queries/{id}` mention to the new route, add the removal notice (bookmarks break) and the Activity surface + Result column.

### S2.5 Smoke + screenshots

Rebuild bundle (`npm run build`, `zig build -Dadmin-dist=admin/dist`), run the real binary with the smoke28 scratchpad config: URL-driven history filters (paste a full URL, screenshot the reproduced view), live streaming with freeze and an open streamed-row detail, the SERVFAIL row distinguishable in the list, detail via recovered/history row with the context-preserving back link, policy simulation, five-minute Diagnostics link landing on the bounded window, mobile-width nav drawer.

### S2.6 Acceptance (S2)

- [ ] Typecheck, vitest, oxlint, format clean; `zig build -Dadmin-dist=admin/dist` succeeds; `zig build test` untouched-green.
- [ ] The S2.2 test list green, including EventSource lifecycle, back/forward, disabled-row tab order, streamed-row detail.
- [ ] Router-level loader regression (Codex): `/activity?mode=live` — including with retained filters and unknown search keys — makes no `/api/queries` request; history mode forwards exactly the six normalized filter fields and nothing else.
- [ ] Screenshots per S2.5.

## File ownership

Sequential; S2 starts only after S1's gates are green. S1: additive files under activity/, querySummary.ts, ui/Select.tsx, the QueryDetailPage body extraction, their tests. S2: everything else listed, admin/src/* (including the diagnostics route/page bounds work) plus CHANGELOG.md.

## Anti-requirements

- No server/API/openapi changes; no golden regeneration.
- No route aliases, redirects, or bookmark shims; no custom 404.
- No filter push-down into the SSE stream.
- No Overview changes (step 4), no configuration-page changes (step 5).
- No new dependencies; no virtualization.

## Acceptance (milestone complete)

- [ ] All admin gates clean; zig suites untouched-green; bundled build succeeds; screenshots per S2.5.
- [ ] CHANGELOG updated per S2.4.
