29 KiB
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/zigat 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 onewarnper pass. admin/src/lib/contractSamples.gen.tsis regenerated with the AGENTS.md command, never hand-edited, in the same session that changessrc/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_tsis the UTC minute start in seconds (@divFloor(ts, 60) * 60), stamped fromstd.Io.Clock.real— history participates in wall-clock periods, so it uses the wall clock. Routing state stays on.awakeuntouched.- Identity is the URL through a dimension table, not the
config.dbupstream 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 optionalhistory: ?*history_mod.Accumulatorfield onPool, passingentry.endpoint.urlandstd.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 anAccumulatorinto aPoolbuilt from the existingFakeclient 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 oldestminute_ts, setlast_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 bumprows_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_tsand 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.bootclock (retention.zig:144-146's reasoning: a box that suspends must still see its interval elapse),flushOncethen 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]Cellbuffer and clear the accumulator (count = 0); release the mutex; runflushon the buffer. On success the buffer is done. On failure, setlast_flush_failed, bumpflush_failures, log onewarn, 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 overflowsmax_pendingfollows the normal drop policy (oldest evicted,rows_droppedandlast_drop_minuteupdated). 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_minuteadvanced 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:
flushescounts successful flush transactions only;flush_failurescounts failed flush attempts; a pass with nothing pending counts neither. A successful flush clearslast_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 passingupstream_history_repo.flush;runcloses 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.dbconnection fromcli.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 throughupstream_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.WindowStatsreturnslast_failure_error_bufby value; the handler must copy the selected error text into the request arena before it builds the response entry — a slice into the loop-localWindowStatsdangles into stack storage the next iteration reuses. completeis per-window and stateless: false iff the accumulator'slast_drop_minuteis 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-32success_rate, process-lifetimelast_error/last_error_age_s. They remain inhealth.Statefor routing and in/metricsunchanged. openapi.yaml: the route gains the sharedperiodquery-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 andadmin/src/lib/types.tsupdated in the same session.
7. Failure visibility
metrics.ziggains a group fed byAccumulator.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/healthrollup (handlers/health.zig) gains one boolean in itsInput:history_flush_failing, fed from the accumulator'slast_flush_failed— current state, set by a failed flush and cleared by the next successful one. The rollup degrades todegradedwhile it is true and recovers when it clears (/api/healthhas nowarnstatus). Nothing incollect/rollupcompares cumulative counters across samples (they are stateless, so "grows between collections" is unimplementable there), androws_droppeddoes not feed the rollup at all — a historical overflow must not latch/api/healthtodegradedforever; drops surface through the metric and through ruling 6's per-windowcomplete. Extend the degraded-matrix table test with the new row in both states.
8. UI: one ranged table, live state labeled locally
upstreamHealthQuery(period)inadmin/src/lib/queries.ts— the query key includes the period, so the picker refetches upstream health with totals and timeseries.UpstreamHealthtypes 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:
Disabledwhen not enabled, elseAvailable/Backing offfromavailable. 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." A100.0%must be unreachable from zero attempts (vitest proves it). - Last failure renders
ErrorName · agevia the existingformatAgeagainstlast_failure_atand the response'suntil… no — againstDate.now()at render, same as the m25.5 cell;—when null.complete: falserenders 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 testand-Dintegrationgreen 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 --noEmitgreen 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 lintall 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_rate0% 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 inupstream_minutevia 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.yamlreviewed 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 asquerylog.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%(or1.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.Stateinternals — 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:
WebStategained ahistoryfield so the handler can reach the accumulator;web/state.zigwas not in the ownership table.- The accumulator is heap-allocated, following the
sse.Hubpattern, so its address is stable across the task group and the web server. - Shutdown calls
group.cancelexplicitly 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/healthchange touched only the rollupInput;collectstayed as it was. - Honest negative from the S1 report: a bare-column mutation of
last_failure_tsin the upsert survives the tests because SQLite'smax()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_messageis duplicated inupstream_health.zigbecausestats.badPeriodis private; follow-up noted to export it and delete the copy.- The route-level 400 is not testable through
handlefor either period route; the behavior is covered at theperiodParamlevel, 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.