Files
nxdns/specs/milestone-26.md
T

29 KiB
Raw Blame History

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

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:

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:

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:

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:

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.

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 consts.
  • 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.