# Milestone 26: upstream health honors the dashboard period

The dashboard's upstream numbers must respect the selected period. Today `GET /api/upstream/health` serves process-lifetime counters and a last-32-exchanges window beside a period picker that scopes everything else on the page; "63 failures, 100.0% success rate" side by side was the result. This milestone records per-minute upstream outcome history in `querylog.db` and serves ranged aggregates from it, keeping the in-memory health state for routing, backoff and `/metrics` only.

Design authority: the Codex design review of 2026-08-17 (adopted whole). Where this spec deviates from it, the deviation is named in the ruling that makes it.

## Implementation contract (read first)

- Verify every stdlib claim against `/home/mokhtar/app/zig` at tag 0.16.0. Pre-0.16 knowledge is stale.
- m13 ruling F-f binds every session: every behavior ships with a test the author watched fail — run the assertion before the code, or with the code reverted, and say so in the report.
- No new `std.log.err`. Counters and the health rollup are the failure surface; each new failure path logs at most one `warn` per pass.
- `admin/src/lib/contractSamples.gen.ts` is regenerated with the AGENTS.md command, never hand-edited, in the same session that changes `src/web/openapi.yaml`.
- Do not commit. The orchestrator commits after review, spec sync, and the user's screenshot approval.

## Rulings (binding)

### 1. History is event aggregation, not counter sampling

Outcomes are aggregated into their wall-clock UTC minute at the moment they are recorded, and the aggregates are flushed to storage. Nothing samples `total_successes`/`total_failures` and subtracts. Consequences a test must prove:

- a restart cannot invert or reset any persisted number (rows are additive facts; a restart within the same minute adds to the same row);
- a crash loses at most the currently unflushed aggregates — bounded undercount, never a negative delta;
- the failure that happened inside a period is reportable with its error name, because the minute row carries it.

### 2. Storage: two tables in `querylog.db`, identity is the URL

Appended to `querylog_schema.ddl` (which changes the DDL fingerprint; by the established policy — querylog_schema.zig:1-7, PLAN §3.7 — every existing `querylog.db` fails the fingerprint check on upgrade and is recreated, with the previous file renamed aside as `querylog.db.corrupt-<timestamp>` rather than deleted; the changelog must state that query history restarts empty and where the old file sits):

```sql
CREATE TABLE upstream_targets (
    id INTEGER PRIMARY KEY,
    url TEXT NOT NULL UNIQUE
);

CREATE TABLE upstream_minute (
    upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id),
    minute_ts INTEGER NOT NULL,
    successes INTEGER NOT NULL,
    failures INTEGER NOT NULL,
    last_failure_ts INTEGER,
    last_error TEXT,
    PRIMARY KEY (upstream_id, minute_ts),
    CHECK (successes >= 0),
    CHECK (failures >= 0)
) WITHOUT ROWID;

CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
```

- `minute_ts` is the UTC minute start in seconds (`@divFloor(ts, 60) * 60`), stamped from `std.Io.Clock.real` — history participates in wall-clock periods, so it uses the wall clock. Routing state stays on `.awake` untouched.
- Identity is the URL through a dimension table, not the `config.db` upstream id: ids cannot be foreign keys across database files and may be deleted or reused; the URL is the immutable historical identity the query log already uses. A URL edit deliberately starts a new history.
- Rows exist only for minutes that had at least one attempt. Bound: upstreams × 1440 rows/day.
- A deleted upstream's history stays until retention removes it and is simply not returned (ruling 6 returns current pool entries only).

### 3. Recording: accumulate in memory at the record points, never touch SQLite on the query path

New module `src/upstream/history.zig`:

```zig
pub const max_pending = 4096;

pub const Accumulator = struct {
    pub const Cell = struct {
        url: []const u8,          // borrowed from the pool entry's endpoint; entries live for the process
        minute_ts: i64,
        successes: u32,
        failures: u32,
        last_failure_ts: ?i64,
        last_error_buf: [48]u8,   // the error-name capacity health.State.last_error_buf uses (health.zig:58); S1 should lift 48 into a shared pub const rather than duplicate the literal
        last_error_len: u8,
    };

    pub const Stats = struct {
        flushes: u64 = 0,
        flush_failures: u64 = 0,
        rows_dropped: u64 = 0,
        pending: u32 = 0,
    };

    mutex: std.Io.Mutex = .init,
    cells: [max_pending]Cell,
    count: u32,
    last_drop_minute: ?i64,     // max minute_ts ever dropped; feeds `complete` (ruling 6)
    last_flush_failed: bool,    // set by a failed flush, cleared by the next successful one (ruling 7)
    // counters atomic, same shape as retention.zig's Counters

    /// The declared init: count 0, last_drop_minute null, last_flush_failed false,
    /// every counter zero, cells undefined (a cell is written before it is read).
    pub const init: Accumulator = ...;

    pub fn recordSuccess(self: *Accumulator, io: std.Io, url: []const u8, wall_s: i64) void;
    pub fn recordFailure(self: *Accumulator, io: std.Io, url: []const u8, wall_s: i64, error_name: []const u8) void;

    /// The only read surface. Web, health and metrics consumers read through
    /// this mutex-protected snapshot; the fields above are private to the module.
    pub fn snapshotStats(self: *Accumulator, io: std.Io) Stats;   // Stats gains last_drop_minute: ?i64, last_flush_failed: bool
};
```

- `Pool.recordSuccess` / `Pool.recordFailure` (pool.zig:309, :318) gain, after their existing health bookkeeping and **after releasing the pool mutex**, a call into an optional `history: ?*history_mod.Accumulator` field on `Pool`, passing `entry.endpoint.url` and `std.Io.Clock.real.now(io).toSeconds()`. Restructure both wrappers so the health update sits in an inner block whose close releases the pool mutex, and the history call follows the block; a comment on each states that ordering is the constraint. The accumulator has its own mutex (`lockUncancelable`, mirroring the health sections' reasoning at pool.zig:310-313); no lock is ever held while taking the other, so no ordering deadlock exists. Proven by: (1) an S1 unit test that wires an `Accumulator` into a `Pool` built from the existing `Fake` client machinery (pool.zig tests), drives one success and one failure, and asserts both outcomes landed in the accumulator's cells; (2) the S1 report citing the restructured wrapper bodies showing the call after the mutex scope closes — the earlier draft's "hook takes the pool mutex" deadlock test is withdrawn as not runnable without production callback seams.
- Lookup is linear over the live cells (url pointer equality first, then bytes; at household scale the live set is a handful). On a miss with `count == max_pending`, evict the cell with the oldest `minute_ts`, set `last_drop_minute = @max(last_drop_minute orelse evicted.minute_ts, evicted.minute_ts)` (never plain assignment — a merge-back after eviction must not move the watermark backwards), and bump `rows_dropped`. Dropping is the overflow behavior, retrying is the flush-failure behavior — the two must not be conflated.
- On failure, keep the newest `last_failure_ts` and its error name in the cell (same max-wins rule the SQL upsert applies).

### 4. Flushing: a dedicated task, one transaction per pass, additive upserts

New repo `src/storage/repositories/upstream_history_repo.zig` — S1 owns the whole repository contract including the ranged read; S2 consumes it and defines nothing of its own SQL:

```zig
pub const FlushRow = struct { url: []const u8, minute_ts: i64, successes: u32, failures: u32, last_failure_ts: ?i64, last_error: []const u8 };
pub fn flush(database: *db.Db, rows: []const FlushRow) db.Error!void;   // one Tx: ensure targets, then upsert minutes

pub const WindowStats = struct {
    attempts: u64,
    successes: u64,
    failures: u64,
    last_failure_ts: ?i64,
    /// The error name of the row holding the newest last_failure_ts in the
    /// window; "" when the window holds no failure.
    last_failure_error_buf: [48]u8,
    last_failure_error_len: u8,
};
pub fn windowStats(database: *db.Db, url: []const u8, since: i64, until: i64) db.Error!WindowStats;

pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64;
```

`windowStats` is **one statement** — two statements would not share a SQLite snapshot, and the flush connection committing between them could pair `max(last_failure_ts)` from one state with an error text from another. The error lookup is a scalar subquery inside the same statement, with a deterministic tiebreak on its ORDER BY:

```sql
SELECT coalesce(sum(m.successes), 0), coalesce(sum(m.failures), 0), max(m.last_failure_ts),
       (SELECT e.last_error FROM upstream_minute e
        WHERE e.upstream_id = m.upstream_id AND e.minute_ts >= ?2 AND e.minute_ts < ?3
          AND e.last_failure_ts IS NOT NULL
        ORDER BY e.last_failure_ts DESC, e.minute_ts DESC LIMIT 1)
FROM upstream_minute m JOIN upstream_targets t ON t.id = m.upstream_id
WHERE t.url = ?1 AND m.minute_ts >= ?2 AND m.minute_ts < ?3;
```

(Adjust the correlation to the repo's statement idioms; the binding requirements are one atomic statement and the deterministic tiebreak. A bare `SELECT max(last_failure_ts), last_error` without the subquery would pair the max with an arbitrary row's error — SQLite's bare-column-with-aggregate behavior.) An unknown URL or an empty window returns zeros and the empty error. `pruneOlderThan` wraps the minute delete and the unreferenced-target cleanup in one `db.Tx`; the returned count is deleted minute rows only, and a test proves the target delete does not inflate it.

The upsert is exactly:

```sql
INSERT INTO upstream_minute (upstream_id, minute_ts, successes, failures, last_failure_ts, last_error)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(upstream_id, minute_ts) DO UPDATE SET
    successes = successes + excluded.successes,
    failures = failures + excluded.failures,
    last_failure_ts = coalesce(max(last_failure_ts, excluded.last_failure_ts), last_failure_ts, excluded.last_failure_ts),
    last_error = CASE
        WHEN excluded.last_failure_ts IS NOT NULL
         AND (last_failure_ts IS NULL OR excluded.last_failure_ts >= last_failure_ts)
        THEN excluded.last_error
        ELSE last_error
    END
```

(`max()` over a NULL is NULL in SQLite, hence the coalesce; a test proves a success-only upsert leaves an existing failure column intact.)

The flush loop lives on `Accumulator`:

- `run(self, io, database)` — every 60 s on the `.boot` clock (retention.zig:144-146's reasoning: a box that suspends must still see its interval elapse), `flushOnce` then sleep, `std.Io.Cancelable!void`.
- `flushOnce` (seam signature two bullets below) uses a **swap, never subtraction**: under the mutex, move the dirty cells into a flush-owned `[max_pending]Cell` buffer and clear the accumulator (`count = 0`); release the mutex; run `flush` on the buffer. On success the buffer is done. On failure, set `last_flush_failed`, bump `flush_failures`, log one `warn`, retake the mutex and **merge the buffer back additively** through the same cell-merge rules recording uses — cells recorded during the write keep their outcomes, a merged cell that collides sums, and a merge that overflows `max_pending` follows the normal drop policy (oldest evicted, `rows_dropped` and `last_drop_minute` updated). No subtraction exists anywhere: the earlier copy-and-subtract draft was unsound — while SQLite ran outside the mutex, a full accumulator could evict a copied cell and later recreate the same `(url, minute_ts)`, and the post-flush subtract then destroyed newly recorded outcomes. Tests must include the interleaving the swap makes possible: during a stubbed in-flight flush, recreate a swapped-out cell's `(url, minute_ts)` and fill the accumulator to overflow; fail the flush; assert the drop policy's accounting held — drops counted, `last_drop_minute` advanced via `@max`, and the flush-owned buffer unaffected by the recording that happened beside it. (Overflow loss is the specced policy, not a defect the merge-back must prevent.)
- Counter meanings, pinned by tests: `flushes` counts successful flush transactions only; `flush_failures` counts failed flush attempts; a pass with nothing pending counts neither. A successful flush clears `last_flush_failed`.
- The flush write goes through a seam so tests can fail it deterministically: `flushOnce(self, io, database, write: *const fn (*db.Db, []const upstream_history_repo.FlushRow) db.Error!void)` with the production caller passing `upstream_history_repo.flush`; `run` closes over the real function. Tests pass a failing or recording stub.
- Clean shutdown order, explicitly: drain the query logger, cancel and join the task group, then run one final `flushOnce`, then close the history connection. A crash loses at most the pending cells.
- The task owns a dedicated `querylog.db` connection from `cli.DataDir.reopenQuerylogDb` (retention.zig:143-157 explains why handles are never shared; app.zig:525 is the pattern).
- The disk-monitor gate does not block the flush (prune-sized writes, same category as the query logger's own writes, which are ungated).

### 5. Retention: the existing daily pass, a fixed window, no knob

`Retention.runOnce` gains an upstream-history prune step placed **with the prune and checkpoint steps, before the vacuum-cadence logic** — the vacuum block early-returns (`passes_since_vacuum < vacuum_every_passes`, and the gated path returns too; retention.zig:114-123), so a step after it would be skipped on most passes. The step is `upstream_history_repo.pruneOlderThan(database, now - retention_window_s)` with `pub const retention_window_s: i64 = 31 * 86_400` on the repo. On the arithmetic: `stats.window` gives `since = until - width * count` with `until > now`, so a 30-day window's `since` never precedes `now - 30d`; 31 days is a full day of slack, not a bound the windows require, chosen so a pass that runs late clips nothing (deviation from the Codex text, which suggested computing the cutoff through `stats.window(.@"30d")` — that would import `web/handlers/stats.zig` into `storage/`, and the constant dominates every aligned window anyway). `logging.retention_days` does not apply to upstream history; a test proves a 1-day query-log retention still keeps 30 days of upstream minutes. Pruned minute rows do **not** count into the existing `rows_pruned` counter (`nxdns_retention_rows_pruned_total` stays query-log-only); `Retention.Stats` gains a separate `upstream_rows_pruned` counter with its own metric line, and a test pins that a pass pruning both kinds moves each counter by its own amount.

### 6. API: `GET /api/upstream/health?period=` with a now/period split

`src/web/handlers/upstream_health.zig` is rewritten. Period handling reuses `stats.periodParam` and `stats.window` verbatim — absent defaults to `24h`, anything else unreadable is the same 400 text stats uses. The aggregation runs on the web task's own `state.querylog_db` connection over `[since, until)` by `minute_ts`.

```zig
pub const PeriodStats = struct {
    attempts: u64,
    successes: u64,
    failures: u64,
    success_rate: ?f32,          // null when attempts == 0 — no observations is not perfect reliability
    last_failure_at: ?i64,       // newest last_failure_ts inside the window
    last_failure_error: ?[]const u8,
};
pub const Upstream = struct {
    url: []const u8,
    enabled: bool,
    available: bool,
    period: PeriodStats,
};
pub const Body = struct {
    period: []const u8,
    since: i64,
    until: i64,
    available: u32,
    total: u32,
    complete: bool,
    upstreams: []const Upstream,
};
```

- Rows come from the current pool snapshot (`metrics.poolSnapshot`), joined to history by URL through `upstream_history_repo.windowStats` — the handler owns no SQL of its own: current upstreams only, deleted URLs' history is not returned, an upstream with no rows in the window gets zeros and nulls. `WindowStats` returns `last_failure_error_buf` by value; the handler must copy the selected error text into the request arena before it builds the response entry — a slice into the loop-local `WindowStats` dangles into stack storage the next iteration reuses.
- `complete` is per-window and stateless: false iff the accumulator's `last_drop_minute` is non-null and `>= since`. The field's definition is deliberately narrow, and the openapi description carries it verbatim: "No capacity drops known in this process within the selected window; up to about a minute of the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are not detectable." A window that starts after the last drop is complete again, so one historical overflow does not mark every future response. The UI shows the muted incomplete note when false and asserts nothing affirmatively when true — absence of a warning, never a "complete" badge.
- Removed from the response entirely: `consecutive_failures`, `total_successes`, `total_failures`, the last-32 `success_rate`, process-lifetime `last_error`/`last_error_age_s`. They remain in `health.State` for routing and in `/metrics` unchanged. openapi.yaml: the route gains the shared `period` query-parameter reference the stats routes use and a 400 response for a bad period, and the schema is rewritten to this shape (required: all fields; nullables marked); samples regenerated and `admin/src/lib/types.ts` updated in the same session.

### 7. Failure visibility

- `metrics.zig` gains a group fed by `Accumulator.snapshotStats`: `nxdns_upstream_history_flushes_total`, `nxdns_upstream_history_flush_failures_total`, `nxdns_upstream_history_rows_dropped_total`, `nxdns_upstream_history_pending` (gauge).
- The `/api/health` rollup (handlers/health.zig) gains one boolean in its `Input`: `history_flush_failing`, fed from the accumulator's `last_flush_failed` — current state, set by a failed flush and cleared by the next successful one. The rollup degrades to `degraded` while it is true and recovers when it clears (`/api/health` has no `warn` status). Nothing in `collect`/`rollup` compares cumulative counters across samples (they are stateless, so "grows between collections" is unimplementable there), and `rows_dropped` does not feed the rollup at all — a historical overflow must not latch `/api/health` to `degraded` forever; drops surface through the metric and through ruling 6's per-window `complete`. Extend the degraded-matrix table test with the new row in both states.

### 8. UI: one ranged table, live state labeled locally

- `upstreamHealthQuery(period)` in `admin/src/lib/queries.ts` — the query key includes the period, so the picker refetches upstream health with totals and timeseries. `UpstreamHealth` types match ruling 6.
- The upstreams card rejoins the ranged content (the m26 layout deletes the "Right now" section the previous fix added; DiskCard becomes a card titled "Storage now"). Columns: Upstream · Status now · Attempts · Failures · Success rate · Last failure. A grouped header labels the last four "Selected period · {label}".
- "Status now" is one word from live state: `Disabled` when not enabled, else `Available`/`Backing off` from `available`. The yes/no Enabled and Available columns are gone.
- Zero attempts: `0`, `0`, `—`, `—`; when every upstream has zero attempts the card shows "No upstream attempts in this period." A `100.0%` must be unreachable from zero attempts (vitest proves it).
- Last failure renders `ErrorName · age` via the existing `formatAge` against `last_failure_at` and the response's `until`… no — against `Date.now()` at render, same as the m25.5 cell; `—` when null. `complete: false` renders a muted "history incomplete" note on the card.
- Numeric columns use `shared.tabularNums`.

## Sessions

Sequential: S1 → S2 → S3. No parallel sessions; each depends on the previous session's files.

### Session S1: schema, accumulator, flush, retention, metrics, health rollup

Owns: `src/storage/querylog_schema.zig`, `src/upstream/history.zig` (new), `src/storage/repositories/upstream_history_repo.zig` (new), `src/upstream/pool.zig`, `src/storage/retention.zig`, `src/app.zig`, `src/cli.zig` (its connection-count comment on the querylog reopen becomes false — the count is writer + retention + history = three background connections, plus the web connection when the web server is enabled; correct it), `src/web/metrics.zig`, `src/web/handlers/health.zig`, `src/tests.zig`.

Tests (each watched failing): additive upsert including the NULL/max interaction and success-only-preserves-error; restart-shaped double flush into one minute row sums; `windowStats` sums only the window and pairs the newest failure with its own error row, not an arbitrary one; eviction at `max_pending` drops oldest, counts, and moves `last_drop_minute` only forward; the ruling-4 eviction-and-reinsertion-during-flush interleaving (fail the flush; assert the correct drop count, the forward-only watermark, and the unaffected flush-owned buffer); flush failure sets `last_flush_failed` and the next success clears it, with the counter meanings of ruling 4 (a no-op pass counts neither); retention prunes a 32-day-old minute row and its orphaned target while keeping day-29 rows under `retention_days = 1`, inside one transaction, counting minute rows only, into `upstream_rows_pruned` and not `rows_pruned`; the pruning step runs on a pass where the vacuum logic early-returns; the pool-with-accumulator test of ruling 3 (Fake-client success and failure both land in cells); metrics render the four new series; rollup degrades on `history_flush_failing` and recovers, and does not degrade on `rows_dropped`.

Acceptance (S1):
- [ ] `zig build test` and `-Dintegration` green with explicit counts from the test binary.
- [ ] The DDL fingerprint changed and the schema test names both new tables and the index.
- [ ] No SQLite call reachable from `exchangeLoopLen` (grep-level review stated in the report).

### Session S2: the ranged endpoint and the API contract

Owns: `src/web/handlers/upstream_health.zig`, `src/web/openapi.yaml`, `admin/src/lib/types.ts`, `admin/src/lib/contractSamples.gen.ts` (regenerated).

Tests (watched failing): window aggregation sums only `[since, until)`; zero attempts → `success_rate: null` and null failure fields; last failure inside the window beats an older one outside; `complete` flips on a drop stamped inside the window and not on one before it; the 400 and default-period behavior match stats; serialization pins the exact field set (the removed fields must not appear — assert their absence); two upstreams with different last-failure errors serialize both error texts correctly (no dangling or cross-row reuse from the by-value `WindowStats` buffer).

Acceptance (S2):
- [ ] Both Zig suites green; regenerated samples show the new shape and none of the removed fields.
- [ ] `cd admin && npx tsc --noEmit` green against the new types.

### Session S3: dashboard UI

Owns: `admin/src/lib/queries.ts`, `admin/src/lib/api.ts` (`getUpstreamHealth` takes the period), `admin/src/features/dashboard/DashboardPage.tsx`, `UpstreamHealthTable.tsx`, `DiskCard.tsx`, their test files, `admin/src/ui/styles.ts` (only if a shared style is genuinely reused), `CHANGELOG.md` (the Unreleased notes of the milestone acceptance).

Tests (watched failing): period in the query key refetches on picker change; column set and grouped header; `Backing off` and `Disabled` states; zero-attempt row renders em-dashes and never `100.0%`; card-level no-attempts message; incomplete note; the m25.5 "Right now" section is gone and "Storage now" exists.

Acceptance (S3):
- [ ] `cd admin && npx tsc --noEmit && npx vitest run && npx prettier --check . && npm run lint` all green with counts.

## Module layout

New files: `src/upstream/history.zig`, `src/storage/repositories/upstream_history_repo.zig`. Deleted surface: the unranged fields of `/api/upstream/health` (breaking API change, pre-v0.1, changelog notes it), the dashboard's "Right now" section.

## File ownership

| File | Session |
| --- | --- |
| `src/storage/querylog_schema.zig`, `src/upstream/history.zig`, `src/storage/repositories/upstream_history_repo.zig` | S1 |
| `src/upstream/pool.zig`, `src/storage/retention.zig`, `src/app.zig`, `src/cli.zig`, `src/web/metrics.zig`, `src/web/handlers/health.zig`, `src/tests.zig` | S1 |
| `src/web/handlers/upstream_health.zig`, `src/web/openapi.yaml`, `admin/src/lib/types.ts`, `admin/src/lib/contractSamples.gen.ts` | S2 |
| `admin/src/lib/queries.ts`, `admin/src/lib/api.ts`, `admin/src/features/dashboard/*`, `CHANGELOG.md` | S3 |

Sessions are strictly sequential; the table exists so a fix agent knows whose file it is touching.

## Acceptance (milestone complete)

- [ ] All session boxes.
- [ ] Live smoke per the verify-against-the-real-network rule: a scratch server with one dead upstream (TEST-NET address) and one real one; real queries; confirm the 1h window shows the dead upstream's failures with `success_rate` 0% and the real one's successes; restart the server mid-window and confirm the counts continue rather than reset; wait past a flush and confirm rows in `upstream_minute` via sqlite3; switch the picker across all four periods and confirm the numbers change window, not meaning.
- [ ] UI screenshots (dashboard across at least two periods, plus the zero-attempts state) reviewed by the user in Firefox before anything is pushed — the user's standing rule; no push, no release without it.
- [ ] `src/web/openapi.yaml` reviewed by hand against the response struct; reviewer says so in `## Recorded`.
- [ ] Changelog notes: ranged upstream health, the removed API fields, and that the upgrade recreates `querylog.db` — query history restarts empty and the previous file is kept aside as `querylog.db.corrupt-<timestamp>`, per the established schema-change policy.

## Anti-requirements

- No config knobs: cadence, capacity, retention window are `pub const`s.
- No per-exchange rows anywhere; the finest grain is the minute aggregate.
- No third database file and no history in `config.db`.
- No counter-delta sampling and no code path that can produce a negative count; restarts add, never subtract.
- No `100.0%` (or `1.0`) from zero attempts — null, rendered as an em-dash.
- No SQLite work on the query path; the accumulator is memory-only under its own lock.
- No page-wide "the period does not apply" banner; live values are labeled locally (`Status now`, `Storage now`).
- No removal of `health.State` internals — the last-32 window, lifetime totals, consecutive failures and backoff stay for routing and `/metrics`.

## Recorded (implementation)

Built S1 → S2 → S3, reviewed (one Codex round, two findings, both fixed), live-smoked. Deviations and observations, per session:

S1:
- `WebState` gained a `history` field so the handler can reach the accumulator; `web/state.zig` was not in the ownership table.
- The accumulator is heap-allocated, following the `sse.Hub` pattern, so its address is stable across the task group and the web server.
- Shutdown calls `group.cancel` explicitly before the final flush; the spec's order (drain logger → cancel and join → final flush → close connection) holds, the explicitness is the deviation.
- The `/api/health` change touched only the rollup `Input`; `collect` stayed as it was.
- Honest negative from the S1 report: a bare-column mutation of `last_failure_ts` in the upsert survives the tests because SQLite's `max()` NULL rule absorbs it; the deterministic-tiebreak test covers the observable behavior instead.

Review round:
- app.zig error-path teardown now runs the ruling-4 shutdown sequence via a single `defer`; no test — the structural argument (one defer, one sequence, no early-return can skip it) is the recorded justification.
- The u32 saturation in the accumulator's cell merge got a constraint comment.

S2:
- `bad_period_message` is duplicated in `upstream_health.zig` because `stats.badPeriod` is private; follow-up noted to export it and delete the copy.
- The route-level 400 is not testable through `handle` for either period route; the behavior is covered at the `periodParam` level, shared with stats.
- A 500 response was added to the openapi entry beyond the spec, matching the other database-backed routes.
- A float-spelling correction in the contract samples (regenerated, not hand-edited).

S3:
- Changelog entries sit under Unreleased pending the release commit.
- The incomplete note reads "history incomplete for this period" — wording chosen in session, within ruling 8's intent.

Live smoke: all four periods returned window-scoped numbers; an invalid period returned 400; restart continuity held (counts continued, no reset); the schema-fingerprint aside-rename fired and produced `querylog.db.corrupt-1786956534`; `upstream_minute` rows confirmed via sqlite3 after a flush.
