//! The composition root: everything `nxdns run` owns, in the order it is built.
//!
//! Nothing else in the program constructs a collaborator. Each module takes its
//! dependencies as parameters and knows nothing about who supplies them, so
//! this file is the only place where the object graph exists — and the only
//! place a lifetime spans the whole process.
//!
//! Two rules shape it:
//!
//! 1. **Nothing that is pointed at may move.** `Pool.Entry.client` erases a
//!    pointer into a DoH or DoT client, the `Logger`'s queue holds waiting
//!    tasks in intrusive lists, and the `Manager` is addressed through `self`.
//!    Every such value therefore lives in `serve`'s frame or in an owned heap
//!    allocation, never in a temporary that is copied afterwards.
//! 2. **Every background loop is a task in one `std.Io.Group`.** Shutdown is
//!    `group.cancel`, which requests cancellation and joins, so no loop can
//!    still be running when the resources it borrows are released.
//!
//! Failure policy: a startup failure prints one line to the runner's error
//! writer and exits. A configuration the operator can fix exits 2, so that
//! `nxdns check` is the obvious next step; anything else exits 1. Once serving
//! starts, nothing is fatal — the pipeline answers queries with a snapshot or
//! without one (ruling 4), and every loop logs its own failures and keeps
//! going.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Certificate = std.crypto.Certificate;
const Writer = std.Io.Writer;
const net = std.Io.net;

const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const client_names = @import("server/client_names.zig");
const clients = @import("server/clients.zig");
const config_export = @import("config/export.zig");
const db = @import("storage/db.zig");
const disk_monitor = @import("storage/disk_monitor.zig");
const dns_cache = @import("cache/dns_cache.zig");
const doh_server = @import("server/doh_server.zig");
const dot_server = @import("server/dot_server.zig");
const events = @import("storage/events.zig");
const faults = @import("config/faults.zig");
const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_controller = @import("storage/logger_controller.zig");
const logging = @import("platform/logging.zig");
const manager_mod = @import("filter/manager.zig");
const migrations = @import("storage/migrations.zig");
const model = @import("config/model.zig");
const pause = @import("server/pause.zig");
const queries_repo = @import("storage/repositories/queries_repo.zig");
const query_sink = @import("server/query_sink.zig");
const querylog_migrations = @import("storage/querylog_migrations.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const shutdown = @import("server/shutdown.zig");
const sse = @import("web/sse.zig");
const static = @import("web/static.zig");
const tcp_server = @import("server/tcp_server.zig");
const transport = @import("upstream/transport.zig");
const udp_server = @import("server/udp_server.zig");
const upstream_owner = @import("upstream/owner.zig");
const validate = @import("config/validate.zig");
const version = @import("version.zig");
const web_server = @import("web/server.zig");

const log = std.log.scoped(.nxdns);

/// How long the cache and rate-limiter sweeps wait between passes. Both tables
/// evict lazily on lookup as well; this is what keeps memory bounded for a
/// client that asked once and never came back.
const maintenance_interval_s = 60;

/// Bounds one blocklist download (`Manager.total_budget`). Generous, because a
/// megabyte-scale list over a household uplink is normal, and a download that
/// takes longer than this is not going to finish at all.
const download_budget_s = 300;

pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
    const code = serve(runner, args) catch |err| code: {
        runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
        const mapped = failureExitCode(err);
        if (mapped == cli.exit_check) {
            runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
            // Ruling 6: after bootstrap seeding died, a fresh database fails
            // validation naturally (`NoUsableUpstreams`) and the operator needs
            // to be told how a database gets a configuration at all. In file
            // mode they already have a file, and the diagnostics above name what
            // is wrong with it.
            if (args.config == null) cli.writeDbSourceHint(runner.err);
        }
        break :code mapped;
    };

    // Output the operator never received is not output, so a failed flush
    // outranks a successful run — the same rule `cli.finish` applies.
    runner.out.flush() catch return cli.exit_runtime;
    runner.err.flush() catch return cli.exit_runtime;
    return code;
}

/// The one classification, from `config/faults.zig`. This file keeps no list of
/// its own: `run` exiting 1 on a seed file `check` and `import` exit 2 on was
/// exactly the cost of the second list that used to be here.
fn failureExitCode(err: anyerror) u8 {
    return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
}

/// File authority (ruling 2): read the file the operator named, validate it, and
/// converge the database onto it — on this start and on every start after it.
/// Returns the wall clock the reconcile ran at, which becomes the settings
/// envelope's `reconciled_at`.
///
/// A missing, unreadable or invalid file fails the start. There is no fallback
/// to the database under any failure: a fallback turns a deploy typo into a
/// silently stale configuration, which is the failure mode the whole mode exists
/// to prevent.
///
/// Diagnostics are printed on the way out either way. A file can be accepted and
/// still carry warnings — a blocklist source in no group is downloaded and
/// compiled into nothing — and a warning that only appears when the start fails
/// is a warning nobody ever reads: the start it describes is the one that
/// worked.
///
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
/// of `Diagnostics.writeAll`.
///
/// Flushed here rather than left to `run`'s exit flush. Both writers are
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
/// the service runs, so a line left in the buffer reaches the operator when the
/// process stops — days after the start it describes. A failure path flushes
/// anyway because it returns immediately; the successful start is the one that
/// needs this.
fn reconcileFromFile(
    r: cli.Runner,
    config_db: *db.Db,
    dir: std.Io.Dir,
    config_path: []const u8,
    config_load: ?*ConfigLoad,
) !i64 {
    return reconcileFromFileAt(
        r,
        config_db,
        dir,
        config_path,
        std.Io.Clock.real.now(r.io).toSeconds(),
        config_load,
    );
}

/// `pass_now` is what the engine stamps into the runtime columns of the rows it
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
/// the value this returns.
///
/// The two clocks answer different questions, and conflating them was a real
/// defect: `reconciled_at` means "this process loaded the file at T" and is
/// compared against the file's mtime to detect a restart-pending state
/// (ruling 7). A stamp taken *before* the read makes a file written during the
/// read look newer than the process that loaded it — a false "restart pending"
/// in the UI for a file that is fully applied. So this returns a clock read
/// taken immediately after the commit, and `pass_now` never leaves the engine.
///
/// Split from `reconcileFromFile` so the two are separable in a test: pin
/// `pass_now` and the returned stamp must still be the real clock.
fn reconcileFromFileAt(
    r: cli.Runner,
    config_db: *db.Db,
    dir: std.Io.Dir,
    config_path: []const u8,
    pass_now: i64,
    config_load: ?*ConfigLoad,
) !i64 {
    var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
    defer arena_state.deinit();

    var diags: validate.Diagnostics = .init(r.gpa);
    defer diags.deinit();

    const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);

    // Both discards are deliberate, and they answer different questions. On a
    // rejected file the operator gets the reason the start failed, never a
    // writer error standing in front of it — a broken stderr is not why the
    // configuration was refused. On a file that applied, a failure here does not
    // stop the start: the trade is one lost warning line against a household
    // with no name resolution, and this writer *is* the error channel, so the
    // failure has nowhere to be reported anyway.
    diags.writeAll(r.err) catch {};
    r.err.flush() catch {};

    // Warnings only. A `.fail` rejects the file and the process exits, so there
    // is nobody left to read a diagnostics row about it; a warning is the case
    // where the box serves on with a setting the operator did not mean.
    if (config_load) |collector| {
        for (diags.problems.items) |problem| {
            if (problem.severity != .warn) continue;
            collector.note(problem.path, problem.path, problem.message);
        }
    }

    return result;
}

/// Boot's replay of one upstream finding. The rendering is the owner's, shared
/// with the runtime reconciler so a warning raised at boot and the same warning
/// raised by a settings PUT are byte-identical.
fn noteUpstream(config_load: *ConfigLoad, url: []const u8, message: []const u8) void {
    var rendered: upstream_owner.Rendered = .{};
    rendered.render(.{ .url = url, .message = message });
    config_load.note(url, rendered.label, rendered.detail);
}

/// Returns the moment the transaction committed, which is what the settings
/// envelope reports as `reconciled_at`.
fn applyManagedFile(
    r: cli.Runner,
    config_db: *db.Db,
    dir: std.Io.Dir,
    config_path: []const u8,
    arena: Allocator,
    diags: *validate.Diagnostics,
    now: i64,
) !i64 {
    const cfg = try loader.load(r.io, arena, dir, config_path, diags);
    try validate.validate(cfg, diags);

    // The keys this pass wrote, never their values (ruling 8). Duplicated into
    // `gpa` by the engine, so this frame frees them.
    var changed: std.ArrayList([]const u8) = .empty;
    defer {
        for (changed.items) |key| r.gpa.free(key);
        changed.deinit(r.gpa);
    }

    var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
        .changed_settings = &changed,
    }) catch |err| {
        // `begin` has already rolled its own transaction back. What the operator
        // needs is the cause: a full SD card must read as "disk", not as a bare
        // exit 1, so the SQLite condition is named.
        reportReconcileFailure(r, config_db, config_path, err);
        return err;
    };
    errdefer pass.rollback();

    pass.commit() catch |err| {
        reportReconcileFailure(r, config_db, config_path, err);
        return err;
    };

    // Read here and nowhere earlier: the file is loaded once this line runs, and
    // not one statement before it.
    const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();

    printSummary(r, config_path, pass.summary, changed.items) catch {};
    return reconciled_at;
}

/// SQLite conditions an operator acts on differently. `@errorName` alone would
/// say `Full`, which is not a word anyone can search for; the primary result
/// code's own name is.
fn sqliteCodeName(err: anyerror) ?[]const u8 {
    return switch (err) {
        error.Full => "SQLITE_FULL",
        error.Busy => "SQLITE_BUSY",
        error.IoErr => "SQLITE_IOERR",
        error.ReadOnly => "SQLITE_READONLY",
        error.Corrupt => "SQLITE_CORRUPT",
        error.Constraint => "SQLITE_CONSTRAINT",
        else => null,
    };
}

fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
    var buf: [256]u8 = undefined;
    const detail = config_db.lastError(&buf);
    const code = sqliteCodeName(err) orelse @errorName(err);
    r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
    r.err.flush() catch {};
}

/// What that restart changed, without opening sqlite (ruling 8): per-table
/// counts, the settings keys that moved — never their values — and an
/// authentication change, which is never a silent line item in a count.
fn printSummary(
    r: cli.Runner,
    config_path: []const u8,
    summary: reconcile.Summary,
    changed_settings: []const []const u8,
) !void {
    try r.out.print("reconciled '{s}':", .{config_path});
    if (summary.isNoOp()) {
        try r.out.writeAll(" no changes\n");
    } else {
        inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
            if (field.type == reconcile.TableCounts) {
                const counts = @field(summary, field.name);
                if (counts.total() != 0) {
                    try r.out.print(" {s} +{d} ~{d} -{d};", .{
                        field.name,
                        counts.inserted,
                        counts.updated,
                        counts.deleted,
                    });
                }
            }
        }
        try r.out.writeAll("\n");

        if (changed_settings.len != 0) {
            try r.out.writeAll("settings keys changed:");
            for (changed_settings) |key| try r.out.print(" {s}", .{key});
            try r.out.writeAll("\n");
        }
        switch (summary.auth_transition) {
            .none => {},
            .enabled => try r.out.writeAll("web authentication is now enabled\n"),
            .disabled => try r.out.writeAll("web authentication is now disabled\n"),
            .rotated => try r.out.writeAll("the web password changed\n"),
        }
    }
    try r.out.flush();
}

/// The `configuration.load` findings of one boot.
///
/// This code is boot-finalized: nothing during the run can fix a setting, so a
/// restart is its recovery. Every finding is reported as it is made and its key
/// kept, and one `resolveExcept` after the last of them closes the episodes of
/// settings that were wrong last boot and are not wrong now.
const ConfigLoad = struct {
    store: ?*events.Store,
    io: std.Io,
    now_s: i64,
    keys: [events.Store.max_kept_keys][events.Store.max_subject_key_len]u8 = undefined,
    lens: [events.Store.max_kept_keys]u16 = @splat(0),
    len: usize = 0,
    /// Set when a boot produced more distinct findings than `resolveExcept`
    /// carries. The bulk resolve is then refused rather than truncated: a stale
    /// episode left open is honest, and one that is still true closed is not.
    /// The refusal goes through the store, so it is counted and latched.
    overflowed: bool = false,

    fn note(self: *ConfigLoad, key: []const u8, label: []const u8, detail: []const u8) void {
        const store = self.store orelse return;
        store.report(self.io, self.now_s, .configuration_load, key, label, .warning, detail);
        self.keep(key);
    }

    fn keep(self: *ConfigLoad, key: []const u8) void {
        var canon_buf: [events.Store.max_subject_key_len]u8 = undefined;
        const canon = events.canonicalKey(key, &canon_buf);
        for (0..self.len) |i| {
            if (std.mem.eql(u8, self.keys[i][0..self.lens[i]], canon)) return;
        }
        if (self.len == self.keys.len) {
            self.overflowed = true;
            return;
        }
        @memcpy(self.keys[self.len][0..canon.len], canon);
        self.lens[self.len] = @intCast(canon.len);
        self.len += 1;
    }

    fn finalize(self: *ConfigLoad) void {
        const store = self.store orelse return;
        if (self.overflowed) return store.refuseResolveExcept(self.io, self.now_s);
        var kept: [events.Store.max_kept_keys][]const u8 = undefined;
        for (0..self.len) |i| kept[i] = self.keys[i][0..self.lens[i]];
        store.resolveExcept(self.io, self.now_s, .configuration_load, kept[0..self.len]);
    }
};

fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
    const io = r.io;
    const gpa = r.gpa;
    const paths = args.paths;

    // -----------------------------------------------------------------------
    // storage and configuration
    // -----------------------------------------------------------------------

    var data = try cli.DataDir.open(io, gpa, paths.data_dir, true);
    defer data.close(io, gpa);

    var config_db = try data.openConfigDb(io);
    defer config_db.close();
    _ = try migrations.migrate(&config_db);

    // Opened unconditionally, not gated on `cfg.web.enabled`: diagnostics record
    // what went wrong whether or not anyone is running the UI, and the store
    // owns this connection outright (`storage/events.zig`).
    var events_db = try data.openConfigDb(io);
    defer events_db.close();
    const boot_now_s = std.Io.Clock.real.now(io).toSeconds();
    var event_store_storage: ?events.Store = events.Store.init(io, &events_db, boot_now_s) catch |err| blk: {
        // No store rather than a store on a mirror it could not verify: the
        // latter answers `resolve` with confident no-ops. `/api/health` reports
        // the absence as `unavailable` and degrades on it.
        log.warn("diagnostics store unavailable: {s}", .{@errorName(err)});
        break :blk null;
    };
    const event_store: ?*events.Store = if (event_store_storage) |*s| s else null;
    var config_load: ConfigLoad = .{ .store = event_store, .io = io, .now_s = boot_now_s };

    // Ruling 1: the presence of `--config` is the whole authority decision. With
    // it, the file is the sole declarative source and the database is converged
    // onto it here, before anything reads the database. Without it the database
    // is authority and this step does not exist — a file on disk that no flag
    // names changes nothing.
    const reconciled_at: ?i64 = if (args.config) |config_path|
        try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path, &config_load)
    else
        null;

    // Every string in `cfg` points into this arena, and the pool's endpoints,
    // the handler's records and the monitor's paths all keep such strings. It
    // therefore outlives all of them, which is why it is declared here and not
    // inside a narrower scope.
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // The web layer reads the managed path out of `WebState` for the whole life
    // of the process, so it takes a copy from the arena that outlives it rather
    // than borrowing `argv`.
    const authority: web_server.Authority = if (args.config) |config_path|
        .{ .managed_file = try arena.dupe(u8, config_path) }
    else
        .database;

    // The database stays the runtime substrate and the effective-config read
    // path in both modes: in file mode the reconcile above has just made it
    // agree with the file.
    const cfg = try config_export.readConfig(&config_db, arena);

    // From here on `std.log` goes wherever the operator asked. Before this call
    // the sink passes through to stderr, which is where a failure above needs
    // to appear anyway.
    logging.install(io, cfg.logging);
    defer logging.deinstall();

    if (cfg.dns.rate_limit == 0 or cfg.dns.rate_window_seconds == 0) return error.BadRateLimit;

    // The API limiter and the session store assert these are nonzero
    // (`validate` refuses such a config, but nothing validates a database an
    // operator edited by hand), and a fault the operator can fix must exit 2,
    // not trip an assertion.
    if (cfg.web.enabled and
        (cfg.web.api_rate_limit_per_min == 0 or cfg.web.session_ttl_hours == 0))
    {
        return error.BadRateLimit;
    }

    // -----------------------------------------------------------------------
    // local answers
    // -----------------------------------------------------------------------

    // Ruling 12: the tables are published through the holder the API swaps, so
    // the holder owns them from here on and frees whichever generation is
    // current at shutdown.
    var tables: local_tables.LocalTables = .empty;
    defer tables.deinit(gpa);
    tables.records = try local_records.Records.build(gpa, cfg.local_records);
    tables.zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);

    // -----------------------------------------------------------------------
    // blocklists
    // -----------------------------------------------------------------------

    // Two HTTP clients on purpose. A blocklist download streams tens of
    // megabytes and holds its connection for the whole of it; DoH queries must
    // not queue behind that, and the two have nothing to share but a type.
    var fetch_http: std.http.Client = .{ .allocator = gpa, .io = io };
    defer fetch_http.deinit();
    var dns_http: std.http.Client = .{ .allocator = gpa, .io = io };
    defer dns_http.deinit();

    const fetch_transfer = try gpa.alloc(u8, fetcher.min_transfer_buf);
    defer gpa.free(fetch_transfer);
    const fetch_redirect = try gpa.alloc(u8, fetcher.redirect_buffer_len);
    defer gpa.free(fetch_redirect);
    var fetch: fetcher.Fetcher = .{
        .http = &fetch_http,
        .transfer_buf = fetch_transfer,
        .redirect_buf = fetch_redirect,
    };

    var manager: manager_mod.Manager = try .init(
        gpa,
        &config_db,
        .{ .dir = data.dir },
        &fetch,
        cfg.blocklist_update,
        .{ .raw = .fromSeconds(download_budget_s), .clock = .awake },
    );
    defer manager.deinit(io);

    // -----------------------------------------------------------------------
    // upstream pool
    // -----------------------------------------------------------------------

    // Shared by every DoT client: the trust store does not vary per upstream
    // and the scan is expensive. Both must outlive the clients that hold them.
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(gpa);
    var bundle_lock: std.Io.RwLock = .init;

    const upstream_generation = try upstream_owner.build(.{
        .gpa = gpa,
        .io = io,
        .servers = cfg.upstreams,
        .http = &dns_http,
        .bundle = &bundle,
        .bundle_lock = &bundle_lock,
        .timeouts = .{
            .attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
            .total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
        },
        .seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
        .diagnostics = event_store,
    });

    // `build` writes no event rows, so that a settings PUT can prepare a
    // candidate that is never published without leaving a trace. Boot has no
    // such candidate: this generation is the one that serves, and replaying its
    // report through the collector is what keeps the `configuration.load`
    // episodes — and the keys `finalize` below spares — exactly what they were
    // when this composition lived in this file.
    for (upstream_generation.report().notes) |finding| {
        noteUpstream(&config_load, finding.url, finding.message);
    }
    const upstream_count = upstream_generation.activeCount();

    var upstreams: upstream_owner.Owner = .init(upstream_generation);
    defer upstreams.deinit(io);

    // -----------------------------------------------------------------------
    // per-query state
    // -----------------------------------------------------------------------

    var paused: pause.Pause = .{};
    // One cell for both retention consumers, owned here so a settings apply
    // moves the daily query-log prune and the stale-client prune together.
    var retention_days: retention_mod.RetentionDays = .init(cfg.logging.retention_days);
    var tracker: clients.Tracker = .init(&retention_days);
    tracker.diagnostics = event_store;
    // Naming rides the tracker's pass, on the tracker's task and connection
    // (milestone-25 ruling 1), and reads the live forward zones.
    var client_names_resolver: client_names.Resolver = .init(&tables);
    client_names_resolver.diagnostics = event_store;

    // Milestone 8 fans every logged query out to the SSE hub as well. The hub
    // exists only when the web interface does (ruling 6) — without it the sink
    // costs the query path one null check. Its rings are ~900 KiB, so it lives
    // on the heap and initializes in place; a by-value init would copy the
    // whole of it through this frame.
    var hub: ?*sse.Hub = null;
    defer if (hub) |hub_ptr| gpa.destroy(hub_ptr);
    if (cfg.web.enabled) {
        const created = try gpa.create(sse.Hub);
        created.init();
        hub = created;
    }

    // -----------------------------------------------------------------------
    // disk, retention and the remaining connections (ruling 21)
    // -----------------------------------------------------------------------

    const data_path = try arena.dupeZ(u8, paths.data_dir);
    const log_dir_path: ?[:0]const u8 = if (logging.logDirname(cfg.logging)) |dir|
        try arena.dupeZ(u8, dir)
    else
        null;
    var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
    // Frees whatever log-directory generation a settings apply installed; boot's
    // path is borrowed from the arena and owned by nobody here.
    defer monitor.deinit(io);

    // Ruling 17. The scheduler consults it before every scheduled pass; the
    // startup `reload` below is an operator action and stays ungated.
    manager.monitor = &monitor;
    manager.diagnostics = event_store;

    // One synchronous sample before anything can consult the gate. `Monitor`
    // initializes to `.ok`, and `Monitor.run` takes its first sample inside the
    // task — so without this call the log writer, the tracker and the blocklist
    // scheduler would all see "writes allowed" during the boot window and could
    // write to a critically full disk before the gate ever reflected it.
    // `run`'s own first sample repeats this one, which costs one extra statvfs
    // and directory scan at startup and keeps `run`'s "sample first, then
    // sleep" contract intact.
    //
    // `sample` returns nothing: every failure warns and counts inside the
    // monitor, and leaves the previous reading in place. A first sample that
    // fails therefore leaves the state at `.ok` — an unreadable filesystem is
    // not evidence that the disk is full, which is the monitor's documented
    // policy and the right one here too.
    monitor.sample(io, event_store, boot_now_s);

    var retention: retention_mod.Retention = .init(&retention_days);

    var querylog_opened = try data.openQuerylogDb(io);
    var querylog_writer_db = querylog_opened.database;

    reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db);

    // The controller adopts that first connection and owns the query logger
    // from here: the buffer, the `Logger`, the writer task and the connection
    // are one generation, and `logging.query_log_buffer_max` can replace all
    // four while the server runs (milestone 34 §S4). Its writer starts now and
    // parks on an empty queue, which is where it would be anyway — no producer
    // exists until the listeners below start serving.
    var log_controller: logger_controller.Controller = undefined;
    try log_controller.init(io, .{
        .gpa = gpa,
        .database = querylog_writer_db,
        .source = .{ .dir = std.Io.Dir.cwd(), .path = data.querylog_db_path },
        .logging = cfg.logging,
        .monitor = &monitor,
        .diagnostics = event_store,
    });
    defer log_controller.deinit(io);
    var sink: query_sink.QuerySink = .init(&log_controller, hub);

    var querylog_retention_db = try data.reopenQuerylogDb(io);
    defer querylog_retention_db.close();
    var tracker_db = try data.openConfigDb(io);
    defer tracker_db.close();

    // The web task's own two connections (ruling 26; m7 ruling 21: one SQLite
    // connection per task), opened only when the web interface is (ruling 6).
    // `reopenQuerylogDb` requires the file `openQuerylogDb` established above.
    var web_config_db: ?db.Db = null;
    defer if (web_config_db) |*database| database.close();
    var web_querylog_db: ?db.Db = null;
    defer if (web_querylog_db) |*database| database.close();
    if (cfg.web.enabled) {
        web_config_db = try data.openConfigDb(io);
        web_querylog_db = try data.reopenQuerylogDb(io);
    }

    var sessions: ?auth.Sessions = if (cfg.web.enabled) .init(cfg.web.session_ttl_hours) else null;

    var web_limiter: ?api_limiter.ApiLimiter = null;
    defer if (web_limiter) |*limiter_ptr| limiter_ptr.deinit();
    if (cfg.web.enabled) {
        web_limiter = try api_limiter.ApiLimiter.init(gpa, .{
            .rate_per_min = cfg.web.api_rate_limit_per_min,
            .localhost_exempt = cfg.web.api_localhost_exempt,
            .sse_max_per_ip = cfg.web.sse_max_connections_per_ip,
        });
    }

    // -----------------------------------------------------------------------
    // first snapshot
    // -----------------------------------------------------------------------

    // Ruling 4: serving starts either way. A household loses more from a DNS
    // server that refuses to start than from one that answers unfiltered until
    // the scheduler's first pass succeeds.
    manager.reload(io) catch |err| {
        log.warn(
            "loading the blocklist snapshot failed ({s}); serving unfiltered until the next refresh",
            .{@errorName(err)},
        );
        // No manager lock is held here, so this reports directly rather than
        // through the manager's collector.
        if (event_store) |store| {
            var buf: [events.Store.max_detail_len]u8 = undefined;
            const detail = std.fmt.bufPrint(
                &buf,
                "loading the blocklist snapshot failed: {s}",
                .{@errorName(err)},
            ) catch buf[0..];
            store.report(io, boot_now_s, .blocklist_snapshot, "snapshot", "blocklist snapshot", .@"error", detail);
        }
    };

    // -----------------------------------------------------------------------
    // listeners
    // -----------------------------------------------------------------------

    // Both are on the heap and both are freed through the handler rather than
    // through this frame: a `cache.size` or `dns.rate_limit` change swaps in a
    // replacement built with this same `gpa` and frees what it displaced, so
    // what the handler holds at shutdown is not necessarily what boot built.
    // Both are built inside one block so their errdefers end with it: after
    // the block the handler is the sole owner, and the teardown defers below
    // are what free them. An errdefer that outlived the block would free the
    // same object those defers free.
    const both = blk: {
        const c = try gpa.create(dns_cache.DnsCache);
        errdefer gpa.destroy(c);
        c.* = try .init(gpa, cfg.cache);
        errdefer c.deinit();

        const l = try gpa.create(rate_limiter.RateLimiter);
        errdefer gpa.destroy(l);
        l.* = try .init(gpa, .{
            .limit = cfg.dns.rate_limit,
            .window_seconds = cfg.dns.rate_window_seconds,
        });
        break :blk .{ .cache = c, .limiter = l };
    };
    const cache = both.cache;
    const limiter = both.limiter;

    var h: handler.Handler = .{
        .upstream = &upstreams,
        .policy = .{
            .blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
            .ecs_mode = cfg.edns.ecs_mode,
            .forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
            .negative_ttl_max = cfg.cache.negative_ttl_max,
        },
        .manager = &manager,
        .local_tables = &tables,
        .cache = cache,
        .limiter = limiter,
        .sink = &sink,
        .pause = &paused,
        .tracker = &tracker,
    };
    // These run after `group.cancel` below, so no query is inside either table.
    defer if (h.replaceCache(io, null)) |live| {
        live.deinit();
        gpa.destroy(live);
    };
    defer if (h.replaceRateLimiter(io, null)) |live| {
        live.deinit();
        gpa.destroy(live);
    };

    // -----------------------------------------------------------------------
    // DoH/DoT listeners (milestone-10 ruling 11)
    // -----------------------------------------------------------------------

    // The stores are declared before the listeners on purpose: their deinit
    // defers run last, and `CertStore.deinit` asserts every connection has
    // released its generation, which only holds once the listeners are gone.
    // A certificate that does not load at boot is exit 2 — `nxdns check`
    // promises that an enabled endpoint has readable certs — while anything
    // that breaks later is the watcher's to absorb.
    var doh_certs: ?cert_store.CertStore = null;
    defer if (doh_certs) |*store| store.deinit(io);
    if (cfg.doh_server.enabled) {
        doh_certs = try openCertStore(r, gpa, io, cfg.doh_server, "doh_server", doh_server.alpn_protocols);
        doh_certs.?.kind = .doh;
        doh_certs.?.diagnostics = event_store;
    }

    var dot_certs: ?cert_store.CertStore = null;
    defer if (dot_certs) |*store| store.deinit(io);
    if (cfg.dot_server.enabled) {
        dot_certs = try openCertStore(r, gpa, io, cfg.dot_server, "dot_server", dot_alpn);
        dot_certs.?.kind = .dot;
        dot_certs.?.diagnostics = event_store;
    }

    // Bound here, in this frame, rather than through doh_server's module-level
    // entry: `/metrics` reads the listeners' counters through `WebState`, and
    // only a listener that lives in this frame has an address to wire there.
    // A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS
    // failing to come up must not stop the plain-DNS side this box exists for.
    var failed_listeners: [2][]const u8 = undefined;
    var failed_listener_count: usize = 0;

    var doh: ?doh_server.DohServer = null;
    defer if (doh) |*server| server.deinit(io);
    if (doh_certs) |*store| {
        doh = bindDoh(gpa, io, cfg.doh_server, &h, store, event_store, boot_now_s);
        if (doh == null) {
            failed_listeners[failed_listener_count] = "doh";
            failed_listener_count += 1;
        }
    }

    var dot: ?dot_server.DotServer = null;
    defer if (dot) |*server| server.deinit(io);
    if (dot_certs) |*store| {
        dot = bindDot(gpa, io, cfg.dot_server, &h, store, event_store, boot_now_s);
        if (dot == null) {
            failed_listeners[failed_listener_count] = "dot";
            failed_listener_count += 1;
        }
    }

    // Boot-finalized: one call closes whatever the last boot left open for an
    // endpoint that started clean this time — including an endpoint now
    // disabled, which contributes no key and so is not kept.
    if (event_store) |store| {
        store.resolveExcept(io, boot_now_s, .listener_start, failed_listeners[0..failed_listener_count]);
    }

    // -----------------------------------------------------------------------
    // web interface (ruling 26)
    // -----------------------------------------------------------------------

    // Everything the web layer borrows lives above; the group below cancels the
    // web task before any of it is released. With the web interface disabled
    // the state stays in its null-defaulted shape and no task reads it.
    var web_state: web_server.WebState = .{ .gpa = gpa };
    // The live hash may own a gpa replacement after a settings PUT; this defer
    // runs after `group.cancel` below, so no web task can still read it.
    defer web_state.live_hash.deinit(gpa);
    // Same argument as the live hash: a settings PUT may have installed an
    // owned generation, and this runs after `group.cancel`.
    defer web_state.proxies.deinit(gpa);
    // The Overview response cache owns its bodies from `gpa`. Same argument
    // again: no web task can still be reading a slot once the group is cancelled.
    defer web_state.overview_cache.deinit(gpa);
    if (cfg.web.enabled) web_state = .{
        .gpa = gpa,
        .web = cfg.web,
        .proxies = .init(cfg.web.trusted_proxies),
        .authority = authority,
        .reconciled_at = reconciled_at,
        .live_hash = .init(cfg.web.password_hash orelse ""),
        .handler = &h,
        .pause = &paused,
        .tracker = &tracker,
        .client_names = &client_names_resolver,
        .manager = &manager,
        .upstreams = &upstreams,
        .upstream_build = .{
            .http = &dns_http,
            .bundle = &bundle,
            .bundle_lock = &bundle_lock,
        },
        .monitor = &monitor,
        .local_tables = &tables,
        .logger = &log_controller,
        .retention = &retention,
        .retention_days = &retention_days,
        .sessions = if (sessions) |*s| s else null,
        .limiter = if (web_limiter) |*l| l else null,
        .hub = hub,
        .sink = &sink,
        .doh_certs = if (doh_certs) |*store| store else null,
        .dot_certs = if (dot_certs) |*store| store else null,
        .doh_listener = if (doh) |*server| server else null,
        .dot_listener = if (dot) |*server| server else null,
        .config_db = if (web_config_db) |*database| database else null,
        .querylog_db = if (web_querylog_db) |*database| database else null,
        .events = event_store,
        .version = version.string,
        .started_unix = std.Io.Clock.real.now(io).toSeconds(),
        // Ruling 24: `--admin-dev` serves from disk with no cache headers;
        // otherwise the embedded assets answer every non-/api miss.
        .fallback = if (args.admin_dev != null) serveAdminDev else static.fallback,
        .dev_dir = args.admin_dev orelse "",
        .reload_fn = reloadManager,
    };

    const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6", .ip6) catch |err| return err;
    const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4", .ip4) catch |err| return err;

    // IPv6 first, and the order is load-bearing — see `Listeners`.
    var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
        if (!ipv6Unavailable(err)) return reportBind(r, "udp", v6_bind, err);
        log.warn("this system has no IPv6; serving IPv4 only", .{});
        config_load.note("dns.bind_ipv6", "dns.bind_ipv6", "this system has no IPv6; serving IPv4 only");
        break :bound null;
    };
    defer if (udp6) |*s| s.deinit(gpa, io);

    const v6_is_wildcard = udp6 != null and isWildcard(v6_bind);

    var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
        if (err != error.AddressInUse or !v6_is_wildcard) return reportBind(r, "udp", v4_bind, err);
        log.info("the IPv6 UDP listener is dual-stack and already serves IPv4", .{});
        break :bound null;
    };
    defer if (udp4) |*s| s.deinit(gpa, io);

    var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
        if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
        break :bound null;
    };
    defer if (tcp6) |*s| s.deinit(io);

    var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
        if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
            return reportBind(r, "tcp", v4_bind, err);
        }
        log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
        break :bound null;
    };
    defer if (tcp4) |*s| s.deinit(io);

    // Every `configuration.load` finding of this boot is in by here: the managed
    // file, the upstream table and the IPv6 bind above. Finalizing any earlier
    // would resolve an episode this boot is about to reopen, so consecutive
    // IPv6-less boots would read as a new episode each time.
    config_load.finalize();

    // Ruling 13: `/metrics` sums each transport's listeners into one family, so
    // the web state carries pointers to whichever of the four came up. The
    // arrays are declared here rather than beside `web_state` because a
    // listener that failed to bind is not in them; `group.cancel` below runs
    // before this frame is released, so no web task can outlive them.
    var udp_listeners: [2]*udp_server.UdpServer = undefined;
    var udp_count: usize = 0;
    if (udp6) |*s| {
        udp_listeners[udp_count] = s;
        udp_count += 1;
    }
    if (udp4) |*s| {
        udp_listeners[udp_count] = s;
        udp_count += 1;
    }
    web_state.udp_listeners = udp_listeners[0..udp_count];

    var tcp_listeners: [2]*tcp_server.TcpServer = undefined;
    var tcp_count: usize = 0;
    if (tcp6) |*s| {
        tcp_listeners[tcp_count] = s;
        tcp_count += 1;
    }
    if (tcp4) |*s| {
        tcp_listeners[tcp_count] = s;
        tcp_count += 1;
    }
    web_state.tcp_listeners = tcp_listeners[0..tcp_count];

    // -----------------------------------------------------------------------
    // run
    // -----------------------------------------------------------------------

    shutdown.install(io);

    // Declared after everything it borrows, so the teardown below — whose
    // `cancel` both requests cancellation and joins — is the first thing that
    // runs on the way out (ruling 22). Nothing below this line may be released
    // while a task could still touch it.
    var group: std.Io.Group = .init;

    // The gate every non-essential write consults. Reading it before the
    // monitor's own task has sampled is safe: a fresh `Monitor` publishes `.ok`
    // (disk_monitor.zig:63), so nothing is refused for want of a sample.
    const gate: ?*disk_monitor.Monitor = &monitor;

    // The query-log writers are deliberately *not* in `group`, and the live one
    // started before every producer. Inside the group their lives would end
    // with the same `cancel` that stops the producers, and cancellation would
    // race the queue's close: whichever landed first decided whether the batch
    // a writer was holding reached the database or was counted as dropped. The
    // controller owns their futures instead, so they outlive the producers by
    // construction.
    //
    // Ruling 4's shutdown order, on the one path every exit from here takes:
    // every producer stops and is joined, then `Controller.shutdown` closes
    // each queue and awaits each writer — so the last batch is written rather
    // than raced. A writer the disk gate will not let write counts its batch as
    // dropped instead of holding the exit open (`logger.zig`), so this wait
    // always ends.
    //
    // A `defer` and not straight-line code after `shutdown.wait`, because a
    // `concurrent` spawn below can fail with the DNS listeners already
    // serving; an orderly error teardown owes the operator the same drain a
    // signal gets.
    defer {
        group.cancel(io);
        log_controller.shutdown(io);
    }

    if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
    if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
    if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
    if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
    if (doh) |*s| try group.concurrent(io, doh_server.DohServer.serve, .{ s, io });
    if (dot) |*s| try group.concurrent(io, dot_server.DotServer.serve, .{ s, io });
    if (doh_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
    if (dot_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });

    try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate, event_store });
    try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io, event_store });
    try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
    try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver });
    try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io });

    // Started last (ruling 26), canceled by the same `group.cancel`; its inner
    // connection group is canceled, not awaited (ruling 4), so an idle
    // keep-alive client cannot hold shutdown open. A web bind failure is not
    // fatal: `web_server.serve` warns and returns, and the DNS side — the thing
    // this box exists for — keeps serving.
    if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });

    logStartup(io, authority, &manager, upstream_count, .{
        .udp6 = if (udp6) |*s| s.boundAddress() else null,
        .udp4 = if (udp4) |*s| s.boundAddress() else null,
        .tcp6 = if (tcp6) |*s| s.boundAddress() else null,
        .tcp4 = if (tcp4) |*s| s.boundAddress() else null,
    });

    // A canceled wait is a shutdown request too: whoever canceled this task
    // wants the process to stop, and returning into the teardown deferred above
    // is how it stops.
    shutdown.wait(io) catch {};
    log.info("shutting down", .{});

    return cli.exit_ok;
}

// ---------------------------------------------------------------------------
// web seams
// ---------------------------------------------------------------------------

/// Ruling 12: mutations to rules, blocklists, groups, clients and prefixes
/// rebuild the blocklist snapshot so the change is live on the next query. A
/// state without a manager has nothing to rebuild.
fn reloadManager(state: *web_server.WebState, io: std.Io) anyerror!void {
    const manager = state.manager orelse return;
    try manager.reload(io);
}

/// Dev-mode asset serving (ruling 24): straight from disk, no cache headers,
/// so an edit shows up on the next reload.
fn serveAdminDev(
    state: *web_server.WebState,
    io: std.Io,
    request: *http_util.Request,
) http_util.HandlerError!void {
    return static.serveFromDisk(state.dev_dir, io, request);
}

// ---------------------------------------------------------------------------
// DoH/DoT listeners
// ---------------------------------------------------------------------------

/// RFC 7858 has no IANA-registered ALPN id in wide use beyond "dot"
/// (milestone-10 ruling 5). Mbed TLS records the pointer, so the list must
/// outlive every `ServerContext` built with it; module scope gives it static
/// lifetime, the same shape as `doh_server.alpn_protocols`.
const dot_alpn: [*:null]const ?[*:0]const u8 = &.{"dot"};

/// The boot-time certificate load for one enabled endpoint. A failure is a
/// configuration fault the operator can fix — the same files `nxdns check`
/// verifies — reported in `check`'s style and mapped to exit 2 (ruling 11).
/// Out of memory is the one exception: nothing about the configuration is
/// wrong, so it keeps its own name and exits 1.
fn openCertStore(
    r: cli.Runner,
    gpa: Allocator,
    io: std.Io,
    endpoint: model.TlsEndpoint,
    section: []const u8,
    alpn: ?[*:null]const ?[*:0]const u8,
) !cert_store.CertStore {
    return cert_store.CertStore.init(gpa, io, endpoint.cert_path, endpoint.key_path, alpn) catch |err| {
        if (err == error.OutOfMemory) return error.OutOfMemory;
        r.err.print("{s}: '{s}' + '{s}': {s}\n", .{
            section,
            endpoint.cert_path,
            endpoint.key_path,
            cert_store.humanMessage(err),
        }) catch {};
        return error.BadCertificate;
    };
}

/// An error, not a warning: an endpoint the operator enabled is not serving.
fn reportListener(
    store: ?*events.Store,
    io: std.Io,
    now_s: i64,
    kind: []const u8,
    comptime fmt: []const u8,
    args: anytype,
) void {
    const s = store orelse return;
    var buf: [events.Store.max_detail_len]u8 = undefined;
    var w: std.Io.Writer = .fixed(&buf);
    w.print("{s} listener ", .{kind}) catch {};
    w.print(fmt, args) catch {};
    s.report(io, now_s, .listener_start, kind, kind, .@"error", w.buffered());
}

/// Ruling 1: a listener that cannot bind warns and stays off. The bind text
/// itself gets the same treatment — `validate` refuses it, but a hand-edited
/// database can still carry one, and it is not worth taking DNS down over.
fn bindDoh(
    gpa: Allocator,
    io: std.Io,
    endpoint: model.TlsEndpoint,
    h: *handler.Handler,
    store: *cert_store.CertStore,
    diagnostics: ?*events.Store,
    now_s: i64,
) ?doh_server.DohServer {
    const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
        log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind});
        reportListener(diagnostics, io, now_s, "doh", "bind '{s}' is not an IP address", .{endpoint.bind});
        return null;
    };
    const server = doh_server.DohServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
        log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
        reportListener(diagnostics, io, now_s, "doh", "cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
        return null;
    };
    log.info("doh listener on {f}", .{server.boundAddress()});
    return server;
}

fn bindDot(
    gpa: Allocator,
    io: std.Io,
    endpoint: model.TlsEndpoint,
    h: *handler.Handler,
    store: *cert_store.CertStore,
    diagnostics: ?*events.Store,
    now_s: i64,
) ?dot_server.DotServer {
    const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
        log.warn("dot_server.bind '{s}' is not an IP address; DoT is disabled", .{endpoint.bind});
        reportListener(diagnostics, io, now_s, "dot", "bind '{s}' is not an IP address", .{endpoint.bind});
        return null;
    };
    const server = dot_server.DotServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
        log.warn("dot listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
        reportListener(diagnostics, io, now_s, "dot", "cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
        return null;
    };
    log.info("dot listener on {f}", .{server.boundAddress()});
    return server;
}

// ---------------------------------------------------------------------------
// background maintenance
// ---------------------------------------------------------------------------

/// Sweeps the cache, the DNS rate-limiter table and the API rate-limiter table.
/// The first two are guarded by mutexes the handler owns, because the handler is
/// what contends for them; the sweeps live here because walking a whole table is
/// not work a query should pay for. The API limiter needs the same schedule for
/// the same reason: its table holds 4096 addresses, and once it is full every
/// unknown address pays an eviction scan.
///
/// The locks are taken cancelably: unlike `handle`, this loop has an error
/// union to carry `error.Canceled` out of, and a shutdown that arrives while
/// the query path holds a lock should not wait for it.
fn runMaintenance(
    h: *handler.Handler,
    api: ?*api_limiter.ApiLimiter,
    io: std.Io,
) std.Io.Cancelable!void {
    const interval: std.Io.Clock.Duration = .{
        .raw = .fromSeconds(maintenance_interval_s),
        .clock = .boot,
    };
    while (true) {
        try interval.sleep(io);
        try maintenanceOnce(h, api, io);
    }
}

/// One sweep of each table. Separate from the loop so a test can run a pass
/// without waiting out `maintenance_interval_s`.
fn maintenanceOnce(
    h: *handler.Handler,
    api: ?*api_limiter.ApiLimiter,
    io: std.Io,
) std.Io.Cancelable!void {
    // Both pointers are read inside the mutex that guards their replacement,
    // the same discipline the query path follows: a load taken before the lock
    // could sweep a table `replaceCache`/`replaceRateLimiter` has just freed.
    {
        const now_s = std.Io.Clock.real.now(io).toSeconds();
        try h.cache_mutex.lock(io);
        defer h.cache_mutex.unlock(io);
        if (h.cache) |cache| _ = cache.sweep(now_s);
    }

    {
        const now = std.Io.Clock.awake.now(io);
        try h.limiter_mutex.lock(io);
        defer h.limiter_mutex.unlock(io);
        if (h.limiter) |limiter| _ = limiter.sweep(now);
    }

    // The API limiter takes its own mutex, unlike the two above, which are the
    // handler's. Nothing here holds a lock across the call.
    if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io));
}

// ---------------------------------------------------------------------------
// listeners
// ---------------------------------------------------------------------------

/// The addresses actually bound, for the startup line. A null is a family this
/// process does not listen on separately — either because the system has no
/// IPv6, or because the IPv6 socket is dual-stack and already serves IPv4.
const Listeners = struct {
    udp6: ?net.IpAddress,
    udp4: ?net.IpAddress,
    tcp6: ?net.IpAddress,
    tcp4: ?net.IpAddress,
};

const BindFamily = enum { ip4, ip6 };

/// `config/validate.checkBind` enforces the same family rule on import, check
/// and settings PUT — but not on a config.db written before the rule existed,
/// and `serve` loads that DB without re-validating. Boot is the last seam: a
/// cross-family literal here would bind the wrong family's socket and make the
/// real one fail with AddressInUse, silently losing a family.
fn parseBind(
    r: cli.Runner,
    text: []const u8,
    port: u16,
    field: []const u8,
    family: BindFamily,
) !net.IpAddress {
    const addr = net.IpAddress.parse(text, port) catch {
        r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {};
        return error.BadBindAddress;
    };
    const matches = switch (addr) {
        .ip4 => family == .ip4,
        .ip6 => family == .ip6,
    };
    if (!matches) {
        const digit: u8 = if (family == .ip4) '4' else '6';
        r.err.print(
            "{s}: '{s}' is not an IPv{c} address; re-import the configuration or correct it with a settings PUT\n",
            .{ field, text, digit },
        ) catch {};
        return error.BadBindAddress;
    }
    return addr;
}

/// Files the one-shot `query_log.recreated` event for a boot that replaced the
/// query log.
///
/// One-shot and already over: the file was recreated during this boot, and
/// there is nothing to recover from. Never emitted for `.missing` — a first
/// creation renames nothing aside, so the event would carry an aside path that
/// does not exist and would greet every fresh install with a warning.
///
/// `database` is the connection to the file that was just created; the coverage
/// start is read from it rather than recomputed, so the event states the value
/// the API will.
fn reportQuerylogRecreated(
    store: ?*events.Store,
    io: std.Io,
    now_s: i64,
    opened: *const querylog_schema.OpenResult,
    database: *db.Db,
) void {
    const cause = opened.recreated orelse return;
    if (cause == .missing) return;
    const s = store orelse return;

    // The coverage start belongs in this detail: the recreate is exactly the
    // moment the history the operator had stops existing, and the watermark is
    // the answer to "from when can I still ask?".
    const coverage_start: ?i64 = queries_repo.availableSince(database) catch null;
    var detail_buf: [events.Store.max_detail_len]u8 = undefined;
    const detail = recreatedDetail(&detail_buf, opened.aside(), coverage_start);
    s.reportResolved(io, now_s, .query_log_recreated, @tagName(cause), @tagName(cause), .warning, detail);
}

/// The `query_log.recreated` detail line: what was kept, and from when the new
/// file can answer.
///
/// The coverage start is the operator's actual remedy information — the event
/// says "this history is gone" and this says "and here is where the new history
/// begins". Null only when the fresh file would not answer, which is already a
/// separate failure; the line still names the aside rather than saying nothing.
///
/// The aside is a full path under the data directory, which can be longer than
/// the whole detail column, so the two facts compete for the buffer. The
/// watermark always wins and the name degrades in whole steps: full path, then
/// basename — which the event's own database directory disambiguates — then no
/// name at all. Never a path cut mid-string, which names no file on disk and
/// reads as if it did.
fn recreatedDetail(
    buf: *[events.Store.max_detail_len]u8,
    aside: []const u8,
    coverage_start: ?i64,
) []const u8 {
    const names = [_][]const u8{ aside, std.fs.path.basename(aside) };
    const since = coverage_start orelse {
        for (names) |name| {
            return std.fmt.bufPrint(buf, "previous file kept as '{s}'", .{name}) catch continue;
        }
        return "previous file kept aside";
    };
    for (names) |name| {
        return std.fmt.bufPrint(
            buf,
            "previous file kept as '{s}'; query history is available from {d}",
            .{ name, since },
        ) catch continue;
    }
    // The buffer is `max_detail_len`, which no i64 can overrun on its own.
    return std.fmt.bufPrint(buf, "query history is available from {d}", .{since}) catch unreachable;
}

const events_fixture = @import("storage/events_fixture.zig");
const testing = std.testing;

test "the recreated detail names the aside and the new coverage start" {
    var buf: [events.Store.max_detail_len]u8 = undefined;

    try std.testing.expectEqualStrings(
        "previous file kept as 'querylog.db.quick-check-failed-1700000000'; " ++
            "query history is available from 1700000001",
        recreatedDetail(&buf, "querylog.db.quick-check-failed-1700000000", 1700000001),
    );

    // A fresh file that will not answer is a separate failure; the line still
    // says what was kept rather than reporting nothing.
    try std.testing.expectEqualStrings(
        "previous file kept as 'querylog.db.corrupt-1700000000'",
        recreatedDetail(&buf, "querylog.db.corrupt-1700000000", null),
    );

    // A data directory deep enough that its path alone would fill the column:
    // the watermark is complete and the name degrades to the basename, which
    // still names a real file.
    const deep = "/srv/" ++ ("d" ** 60 ++ "/") ** 8 ++ "querylog.db.corrupt-1700000000";
    try std.testing.expectEqualStrings(
        "previous file kept as 'querylog.db.corrupt-1700000000'; " ++
            "query history is available from 1700000001",
        recreatedDetail(&buf, deep, 1700000001),
    );
    try std.testing.expectEqualStrings(
        "previous file kept as 'querylog.db.corrupt-1700000000'",
        recreatedDetail(&buf, deep, null),
    );

    // No filesystem produces a name this long, but a truncated one would name
    // nothing: the watermark survives alone rather than half-named.
    const unnameable = "/srv/" ++ "n" ** 500;
    try std.testing.expectEqualStrings(
        "query history is available from 1700000001",
        recreatedDetail(&buf, unnameable, 1700000001),
    );
    try std.testing.expectEqualStrings(
        "previous file kept aside",
        recreatedDetail(&buf, unnameable, null),
    );
}

/// The `querylog.db` schema as milestone 29 shipped it, verbatim from
/// `querylog_schema.zig` at commit fa323c7. A literal and not this build's DDL
/// with the deleted tables appended: the appended form drifts the moment the
/// surviving tables change, and its fingerprint was never the one an m29 file
/// on disk actually carries. The transition under test is that exact byte
/// sequence meeting this build.
/// The `PRAGMA user_version` an m29 file on disk carries, written down rather
/// than recomputed from the literal below. A CRC taken over the fixture
/// validates whatever the fixture happens to say, so a slip in the "byte-exact"
/// literal would still self-certify; pinning the historical number turns that
/// slip into a failure. Its value is `Crc32` over `querylog_schema.ddl` at
/// commit fa323c7.
const m29_fingerprint: i32 = 603440875;

/// A watermark from long before this test runs. Both schemas seed
/// `available_since` from `unixepoch()`, so a fixture left at its own default
/// would satisfy "the new file's coverage is not older" even if recreation
/// copied the replaced file's promise straight across.
const m29_available_since: i64 = 1_600_000_000;

const m29_ddl: [:0]const u8 =
    \\CREATE TABLE domains (
    \\    id INTEGER PRIMARY KEY,
    \\    domain TEXT NOT NULL UNIQUE
    \\);
    \\
    \\CREATE TABLE query_log (
    \\    id INTEGER PRIMARY KEY,
    \\    timestamp INTEGER NOT NULL,
    \\    domain_id INTEGER NOT NULL REFERENCES domains(id),
    \\    client_ip TEXT NOT NULL,            -- text, not a FK: log rows are immutable facts
    \\    qtype INTEGER,
    \\    blocked INTEGER NOT NULL,
    \\    response_time_us INTEGER,
    \\    cache_hit INTEGER,
    \\    upstream TEXT,
    \\    qclass INTEGER NOT NULL,
    \\    rcode INTEGER NOT NULL,
    \\    group_id INTEGER,                   -- text/id pairs, not FKs: a renamed
    \\    group_name TEXT,                    -- group must not rewrite history
    \\    policy_action TEXT NOT NULL,
    \\    policy_reason TEXT NOT NULL,
    \\    matched TEXT,
    \\    source_id INTEGER,
    \\    source_name TEXT,
    \\    cname_target TEXT,
    \\    safe_search_target TEXT,
    \\    route_kind TEXT NOT NULL,
    \\    forward_zone TEXT,
    \\    CHECK (rcode BETWEEN 0 AND 4095)    -- twelve bits (RFC 6891 6.1.3)
    \\);
    \\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
    \\CREATE INDEX idx_query_log_client ON query_log(client_ip);
    \\CREATE INDEX idx_query_log_domain ON query_log(domain_id);
    \\
    \\CREATE TABLE upstream_targets (
    \\    id INTEGER PRIMARY KEY,
    \\    url TEXT NOT NULL UNIQUE   -- the historical identity: config.db ids cannot cross database files
    \\);
    \\
    \\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);
    \\
    \\CREATE TABLE querylog_meta (
    \\    id INTEGER PRIMARY KEY CHECK (id = 1),   -- one row, enforced by the schema
    \\    created_at INTEGER NOT NULL,
    \\    available_since INTEGER NOT NULL
    \\);
    \\INSERT INTO querylog_meta (id, created_at, available_since)
    \\VALUES (1, unixepoch(), unixepoch() + 1);
;

test "an m29 query log refuses the startup and is left exactly as it is" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    var path_buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});

    // The fixture is only worth anything while it is still a *different*
    // schema from this build's, and one that carries the deleted tables.
    try testing.expect(!std.mem.eql(u8, m29_ddl, querylog_schema.ddl));
    try testing.expect(std.mem.indexOf(u8, m29_ddl, "CREATE TABLE upstream_minute") != null);
    try testing.expect(std.mem.indexOf(u8, querylog_schema.ddl, "upstream_minute") == null);
    // And only while it is still m29's bytes: this is the one check that an
    // edit to the literal cannot satisfy by changing what it is compared to.
    try testing.expectEqual(m29_fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(m29_ddl))));

    // A healthy m29 file, stamped with the fingerprint m29's own DDL produced.
    {
        var m29 = try db.Db.open(path, .{ .mode = .read_write_create });
        defer m29.close();
        try db.applyPragmas(&m29, .{});
        try m29.exec(m29_ddl);
        try m29.exec("INSERT INTO upstream_targets (url) VALUES ('https://dns.example/dns-query');");
        var meta_buf: [128]u8 = undefined;
        try m29.exec(try std.fmt.bufPrintZ(
            &meta_buf,
            "UPDATE querylog_meta SET created_at = {d}, available_since = {d};",
            .{ m29_available_since, m29_available_since },
        ));
        var version_buf: [64]u8 = undefined;
        try m29.exec(try std.fmt.bufPrintZ(
            &version_buf,
            "PRAGMA user_version = {d};",
            .{m29_fingerprint},
        ));
    }

    // m29 predates the version stamp entirely: its `user_version` is a CRC of a
    // schema no migration chain starts from, so the only honest answer is to
    // refuse and say so. The pre-0.0.12 contract — set it aside and start empty
    // — is gone.
    querylog_migrations.expected_failures.begin();
    defer querylog_migrations.expected_failures.end();
    try testing.expectError(
        error.SchemaUnsupported,
        querylog_schema.open(io, std.Io.Dir.cwd(), path),
    );

    // Nothing was renamed, nothing was created, and the file still answers for
    // itself: the operator can downgrade and keep the history.
    var entries: usize = 0;
    var it = tmp.dir.iterate();
    while (try it.next(io)) |entry| {
        try testing.expect(std.mem.startsWith(u8, entry.name, "querylog.db"));
        try testing.expect(std.mem.indexOfScalar(u8, entry.name[10..], '.') == null);
        entries += 1;
    }
    try testing.expect(entries >= 1);

    var reopened = try db.Db.open(path, .{ .mode = .read_write_existing });
    defer reopened.close();
    try testing.expectEqual(
        @as(i64, m29_fingerprint),
        try reopened.queryInt("PRAGMA user_version"),
    );
    try testing.expectEqual(
        m29_available_since,
        try reopened.queryInt("SELECT available_since FROM querylog_meta"),
    );
    try testing.expectEqual(
        @as(i64, 1),
        try reopened.queryInt("SELECT count(*) FROM upstream_targets"),
    );
}

test "a recreate files a resolved event naming the real aside and watermark" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    var path_buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    // A fresh install: the file was missing, nothing was set aside, and the
    // event would name a path that does not exist.
    var created = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    reportQuerylogRecreated(&fx.store, io, 1000, &created, &created.database);
    created.database.close();
    try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events"));

    // Real damage: corruption is the only thing that recreates now.
    try tmp.dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });

    var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer recreated.database.close();
    try testing.expectEqual(querylog_schema.RecreateReason.not_a_database, recreated.recreated.?);

    reportQuerylogRecreated(&fx.store, io, 2000, &recreated, &recreated.database);

    try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events"));
    try testing.expectEqualStrings("not_a_database", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events"));
    // One-shot: already over when it is filed, so it never becomes an open
    // episode `/api/health` counts.
    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );

    // The detail carries the path that is actually on disk and the watermark
    // the API will serve, both read back from the recreate rather than from
    // the arguments the event was built with.
    try tmp.dir.access(io, std.fs.path.basename(recreated.aside()), .{});
    const coverage = try queries_repo.availableSince(&recreated.database);
    var expected_buf: [events.Store.max_detail_len]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &expected_buf,
        "previous file kept as '{s}'; query history is available from {d}",
        .{ recreated.aside(), coverage },
    );
    try testing.expectEqualStrings(expected, try fx.text("SELECT detail FROM operational_events"));

    // A boot with no diagnostics store configured is not a failure path.
    reportQuerylogRecreated(null, io, 2000, &recreated, &recreated.database);
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));
}

test "a recreate under a long data directory keeps the watermark and a usable name" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var tmp = testing.tmpDir(.{ .iterate = true });
    defer tmp.cleanup();

    // Deep enough that the aside outgrows the detail column, shallow enough
    // that SQLite's unix VFS still opens the file: it caps a path at 512 bytes.
    const nested = ("d" ** 60 ++ "/") ** 6 ++ "d" ** 60;
    try tmp.dir.createDirPath(io, nested);

    var path_buf: [1024]u8 = undefined;
    const path = try std.fmt.bufPrintZ(
        &path_buf,
        ".zig-cache/tmp/{s}/{s}/querylog.db",
        .{ tmp.sub_path, nested },
    );

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    {
        var created = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
        created.database.close();
    }
    {
        var deep_dir = try tmp.dir.openDir(io, nested, .{});
        defer deep_dir.close(io);
        try deep_dir.writeFile(io, .{ .sub_path = "querylog.db", .data = "not a database at all" });
    }

    var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path);
    defer recreated.database.close();
    try testing.expectEqual(querylog_schema.RecreateReason.not_a_database, recreated.recreated.?);
    const line_overhead = "previous file kept as ''; query history is available from ".len;
    try testing.expect(recreated.aside().len + line_overhead > events.Store.max_detail_len);

    reportQuerylogRecreated(&fx.store, io, 2000, &recreated, &recreated.database);

    const detail = try fx.text("SELECT detail FROM operational_events");
    const coverage = try queries_repo.availableSince(&recreated.database);
    const name = std.fs.path.basename(recreated.aside());
    var expected_buf: [events.Store.max_detail_len]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &expected_buf,
        "previous file kept as '{s}'; query history is available from {d}",
        .{ name, coverage },
    );
    // The watermark is whole — the fact that would be lost to a mid-string cut
    // — and the name it kept is the file's, not a prefix of its path.
    try testing.expectEqualStrings(expected, detail);
    try testing.expect(detail.len <= events.Store.max_detail_len);

    var deep = try tmp.dir.openDir(io, nested, .{});
    defer deep.close(io);
    try deep.access(io, name, .{});
}

test "parseBind refuses a bind address of the wrong family" {
    var out_buf: [8]u8 = undefined;
    var err_buf: [256]u8 = undefined;
    var out: Writer = .fixed(&out_buf);
    var err_writer: Writer = .fixed(&err_buf);
    const r: cli.Runner = .{
        .io = std.testing.io,
        .gpa = std.testing.allocator,
        .out = &out,
        .err = &err_writer,
    };

    _ = try parseBind(r, "0.0.0.0", 53, "dns.bind_ipv4", .ip4);
    _ = try parseBind(r, "::", 53, "dns.bind_ipv6", .ip6);

    try std.testing.expectError(
        error.BadBindAddress,
        parseBind(r, "0.0.0.0", 53, "dns.bind_ipv6", .ip6),
    );
    try std.testing.expectError(
        error.BadBindAddress,
        parseBind(r, "::", 53, "dns.bind_ipv4", .ip4),
    );

    const printed = err_writer.buffered();
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv6 address"));
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv4 address"));
}

test "run maps a rejected configuration to exit 2 and everything else to exit 1" {
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MissingDefaultGroup));
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.ParseZon));
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.NoUsableUpstreams));
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.BadCertificate));
    // The query-log schema refusals: `run` is the only command that reaches
    // them, and exit 1 would put a deliberate refusal under the unit's
    // `Restart=on-failure`.
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.SchemaTooNew));
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.SchemaUnsupported));
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MigrationFailed));
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MigrationBackupFailed));
    try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.AccessDenied));
    try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.OutOfMemory));
}

test "run, check and import agree on a seed file with no default group" {
    const config_import = @import("config/import.zig");

    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    // The file from discrepancy D1: parseable, one upstream, no group named
    // 'default'.
    const source: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "kids" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    ;

    // `run --config`: `serve` validates through this exact call before it
    // reconciles, so this is the error `run` classifies.
    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try std.testing.expectError(
        error.MissingDefaultGroup,
        config_import.importSource(io, gpa, &database, source, .{}, &diags),
    );
    try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MissingDefaultGroup));

    // `import`: `cli.failureExitCode` reaches exit 2 by either route — the
    // recorded failures, or the classification `run` just used.
    try std.testing.expect(diags.failureCount() != 0);
    try std.testing.expect(faults.isConfigFault(error.MissingDefaultGroup));

    // `check`: the same file, through the code `nxdns check` runs.
    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena_state.allocator(), source, null, .{});

    var out_buf: [2048]u8 = undefined;
    var err_buf: [256]u8 = undefined;
    var out: Writer = .fixed(&out_buf);
    var err_writer: Writer = .fixed(&err_buf);
    const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer };

    try std.testing.expectEqual(cli.exit_check, try cli.checkConfig(r, cfg, false));
}

test "a configuration whose blocklist source is in no group imports and checks clean" {
    const config_import = @import("config/import.zig");

    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    // A source is created before it is attached — `docs/tutorial/first-run.md`
    // POSTs the blocklist and then PUTs the group's sources — so an unattached
    // source is a legal intermediate state on every write path. It warns; it
    // never fails.
    const source: [:0]const u8 =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
        \\}
    ;

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var diags: validate.Diagnostics = .init(gpa);
    defer diags.deinit();
    try config_import.importSource(io, gpa, &database, source, .{}, &diags);
    try std.testing.expectEqual(@as(usize, 0), diags.failureCount());
    try std.testing.expectEqual(@as(usize, 1), diags.warningCount());

    var arena_state: std.heap.ArenaAllocator = .init(gpa);
    defer arena_state.deinit();
    const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena_state.allocator(), source, null, .{});

    var check_diags: validate.Diagnostics = .init(gpa);
    defer check_diags.deinit();
    // No error means no subcommand may reject it; the warning is report-only.
    try validate.validate(cfg, &check_diags);
    try std.testing.expectEqual(@as(usize, 0), check_diags.failureCount());
    try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
}

test "a start in file mode prints the warnings the file earned" {
    // D5, second half. `run` printed diagnostics only when the file was
    // rejected, which made a successful start the one place a finding could not
    // surface. Under file authority the file is read on every start, so this is
    // the line an operator sees after every restart, not only the first.
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
        \\}
    });

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    // A buffered file writer, the shape `main` builds over stderr, and not
    // `Writer.fixed`: a fixed writer's flush is a no-op, so it counts a line
    // still sitting in the buffer as delivered. Reading the file back is the
    // only way to ask what the operator can actually see.
    var out_buf: [64]u8 = undefined;
    var out: Writer = .fixed(&out_buf);
    var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
    defer err_file.close(io);
    var err_buf: [4096]u8 = undefined;
    var err_writer = err_file.writer(io, &err_buf);
    const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };

    // The file is valid, so the start succeeds and the database converges onto
    // it. The returned stamp is what the settings envelope reports.
    const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null);
    try std.testing.expect(reconciled_at > 0);
    try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));

    // Nothing flushes here on purpose. In production `serve` runs from this
    // point until the service stops, so a line that has not reached the file by
    // now is a line the operator does not get for days.
    const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
    defer gpa.free(printed);

    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "WARN blocklist_sources[0]: "));
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "belongs to no group"));
    // A warning is not a rejection: nothing here claims the start failed.
    try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
}

test "run with a missing managed file exits 2 with the path, and never serves from the database" {
    // Ruling 2: file mode fails closed. The database below is a perfectly good
    // one — migrated, and the run would have reached the listeners on it in db
    // mode — so a fallback would show up here as exit 0.
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();

    var data_buf: [160]u8 = undefined;
    const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
    var missing_buf: [160]u8 = undefined;
    const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});

    // Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
    // operator received the lines, and a fixed writer's flush is a no-op that
    // counts a buffered line as delivered.
    var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
    defer out_file.close(io);
    var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
    defer err_file.close(io);
    var out_buf: [4096]u8 = undefined;
    var err_buf: [4096]u8 = undefined;
    var out_writer = out_file.writer(io, &out_buf);
    var err_writer = err_file.writer(io, &err_buf);
    const r: cli.Runner = .{
        .io = io,
        .gpa = gpa,
        .out = &out_writer.interface,
        .err = &err_writer.interface,
    };

    try std.testing.expectEqual(cli.exit_check, run(r, .{
        .paths = .{ .data_dir = data_dir },
        .config = missing,
    }));

    const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
    defer gpa.free(printed);
    // The path is in the message: an error name alone tells the operator nothing
    // about which file the deploy got wrong.
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
    // The db-mode remediation hint belongs to db mode: in file mode the operator
    // has a file, and the diagnostic above says what is wrong with it.
    try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
}

test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
    // Ruling 7: `reconciled_at` means "this process loaded the file at T", and
    // the UI compares it against the file's mtime to say whether a restart is
    // pending. A stamp taken before the read makes a file written while the read
    // ran look newer than the process that loaded it — a restart-pending banner
    // over a configuration that is fully applied.
    //
    // The pass clock is pinned to 1970 here, which the engine really does use:
    // the inserted client below carries it. If the two were one value, the
    // returned stamp would be 1970 too.
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
        \\}
    });

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var out_buf: [4096]u8 = undefined;
    var err_buf: [1024]u8 = undefined;
    var out: Writer = .fixed(&out_buf);
    var err: Writer = .fixed(&err_buf);
    const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };

    const pass_now: i64 = 42;
    const before = std.Io.Clock.real.now(io).toSeconds();
    const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now, null);

    // The pinned clock reached the engine, so the two values really are separate
    // inputs rather than the same read twice.
    try std.testing.expectEqual(pass_now, try database.queryInt(
        "SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
    ));
    try std.testing.expect(reconciled_at != pass_now);
    try std.testing.expect(reconciled_at >= before);
}

test "the startup summary reports what the reconcile changed, then that nothing changed" {
    // Ruling 8: the answer to "what did that restart change" without opening
    // sqlite. Also ruling 5 from the operator's side — the second start of an
    // unchanged file says so.
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
        \\}
    });

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
    defer out_file.close(io);
    var out_buf: [4096]u8 = undefined;
    var out_writer = out_file.writer(io, &out_buf);
    var err_buf: [1024]u8 = undefined;
    var err: Writer = .fixed(&err_buf);
    const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };

    _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null);
    {
        // Read back through the file: `serve` does not return for as long as the
        // service runs, so a summary still in the buffer is a summary nobody
        // reads.
        const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
        defer gpa.free(printed);
        try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
        try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
        try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
        try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
        try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
        // The keys, never the values: the hash the file set must not be echoed.
        try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
    }

    try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
    _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null);
    {
        const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
        defer gpa.free(printed);
        try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
    }
}

/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
/// counts a line still sitting in the buffer as delivered.
///
/// The file stays empty for as long as the mode is `.failure`, which is what
/// lets a test tell "the writer really failed" from "the writer worked".
fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writer {
    var w = file.writer(io, buffer);
    w.mode = .failure;
    return w;
}

test "a broken error writer does not replace the reason a managed file was rejected" {
    // The operator has to see why the start failed, and a broken stderr is not
    // that reason.
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // No group named 'default': rejected, and it records a FAIL line on the way.
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "kids" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    });

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var out_buf: [64]u8 = undefined;
    var out: Writer = .fixed(&out_buf);
    var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
    defer err_file.close(io);
    // Eight bytes: no FAIL line fits, so `writeAll` must drain mid-line and is
    // itself the call that fails. The test below covers the other discard, where
    // the line fits and only the flush fails.
    var err_buf: [8]u8 = undefined;
    var err_writer = brokenErrWriter(io, err_file, &err_buf);
    const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };

    try std.testing.expectError(
        error.MissingDefaultGroup,
        reconcileFromFile(r, &database, tmp.dir, "config.zon", null),
    );

    // Empty, so the writer did fail — without this the assertion above would
    // hold just as well against a writer that worked.
    const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
    defer gpa.free(printed);
    try std.testing.expectEqual(@as(usize, 0), printed.len);
}

test "a broken error writer does not stop a start whose file applied" {
    // The call this file makes: a DNS server for a household does not refuse to
    // resolve because stderr is broken. What it must not do is drop the warning
    // on the floor, so the second half checks the buffer still holds it.
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    const gpa = std.testing.allocator;

    var tmp = std.testing.tmpDir(.{});
    defer tmp.cleanup();
    // Valid, and it earns one warning: the source belongs to no group.
    try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
        \\}
    });

    var database = try db.Db.open(":memory:", .{ .mode = .memory });
    defer database.close();
    try db.applyPragmas(&database, .{});
    _ = try migrations.migrate(&database);

    var out_buf: [64]u8 = undefined;
    var out: Writer = .fixed(&out_buf);
    var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
    defer err_file.close(io);
    // 4 KiB, the buffer `main` gives the real stderr writer: the warning fits,
    // so `writeAll` succeeds into the buffer and the flush is what fails. That
    // is the shape production hits.
    var err_buf: [4096]u8 = undefined;
    var err_writer = brokenErrWriter(io, err_file, &err_buf);
    const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };

    _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null);
    try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));

    // Nothing reached the file, so the flush really did fail.
    const undelivered = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
    defer gpa.free(undelivered);
    try std.testing.expectEqual(@as(usize, 0), undelivered.len);

    // And the warning is still buffered rather than dropped: this is the same
    // writer, and these are the bytes `run`'s exit flush meets on the way out.
    err_writer.mode = .positional;
    try err_writer.interface.flush();
    const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
    defer gpa.free(printed);
    try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "WARN blocklist_sources[0]: "));
}

fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
    r.err.print("cannot bind {s} {f}: {s}\n", .{ which, addr, @errorName(err) }) catch {};
    return err;
}

/// The kernel has no IPv6 at all. Refusing to start would be the wrong answer
/// for the default configuration, which asks for both families.
fn ipv6Unavailable(err: anyerror) bool {
    return switch (err) {
        error.AddressFamilyUnsupported,
        error.ProtocolUnsupportedBySystem,
        error.ProtocolUnsupportedByAddressFamily,
        => true,
        else => false,
    };
}

fn isWildcard(addr: net.IpAddress) bool {
    switch (addr) {
        .ip4 => |a| {
            for (a.bytes) |b| if (b != 0) return false;
        },
        .ip6 => |a| {
            for (a.bytes) |b| if (b != 0) return false;
        },
    }
    return true;
}

// ---------------------------------------------------------------------------
// startup line
// ---------------------------------------------------------------------------

/// One line, at info, naming what an operator needs to see in `journalctl`
/// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live.
///
/// Preceded by the authority (ruling 8), because every other question about a
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
/// which of the two governs this process, and the journal is where an operator
/// looks for it.
fn logStartup(
    io: std.Io,
    authority: web_server.Authority,
    manager: *manager_mod.Manager,
    upstream_count: usize,
    bound: Listeners,
) void {
    log.info("authority: {f}", .{AuthorityText{ .authority = authority }});

    var buf: [256]u8 = undefined;
    var w: Writer = .fixed(&buf);
    appendBind(&w, "udp", bound.udp6);
    appendBind(&w, "udp", bound.udp4);
    appendBind(&w, "tcp", bound.tcp6);
    appendBind(&w, "tcp", bound.tcp4);

    // The generation is read under the snapshot handle's shared lock, which is
    // the same lock the reload took to publish it.
    if (manager.acquire(io)) |snapshot| {
        defer snapshot.release(io);
        log.info("nxdns {s} serving on{s}; {d} upstream(s); blocklist generation {d}", .{
            version.string,
            w.buffered(),
            upstream_count,
            manager.generation,
        });
    } else {
        log.info("nxdns {s} serving on{s}; {d} upstream(s); unfiltered (no blocklist snapshot)", .{
            version.string,
            w.buffered(),
            upstream_count,
        });
    }
}

/// Which source governs this process, in the words the journal carries.
///
/// A formatter rather than a rendering into a buffer of this file's own: a
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
/// the half of the line an operator came for. Writing straight to the log sink's
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
/// as the only limit.
const AuthorityText = struct {
    authority: web_server.Authority,

    pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
        switch (self.authority) {
            .database => try w.writeAll("database"),
            .managed_file => |path| try w.print("file ({s})", .{path}),
        }
    }
};

/// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
    const value = addr orelse return;
    w.print(" {s} {f}", .{ which, value }) catch {};
}

test "the startup line names which source governs this process, path and all" {
    // Ruling 8. Every other question about a restart starts here, so the answer
    // is in the journal rather than derived from the unit file by whoever is
    // reading at 2am.
    const gpa = std.testing.allocator;

    var short: Writer.Allocating = .init(gpa);
    defer short.deinit();
    try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
    try std.testing.expectEqualStrings("database", short.written());

    var named: Writer.Allocating = .init(gpa);
    defer named.deinit();
    try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
    try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());

    // A path past any buffer this file could reasonably have picked. Nested bind
    // mounts produce paths like this, and the path is the half of the line the
    // operator came for — dropping it to keep the line short is the wrong trade.
    const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
    try std.testing.expect(long_path.len > 256);

    var long: Writer.Allocating = .init(gpa);
    defer long.deinit();
    try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
    try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
}

const test_address = @import("platform/address.zig");

test "one maintenance pass drops the api limiter's stale buckets" {
    var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var limiter = try api_limiter.ApiLimiter.init(std.testing.allocator, .{
        .rate_per_min = 60,
        .localhost_exempt = false,
        .sse_max_per_ip = 3,
    });
    defer limiter.deinit();

    // A bucket last touched a full window ago has refilled to capacity, so a
    // fresh bucket would answer identically and the sweep may drop it. The
    // pass reads the real `.awake` clock, so the bucket is aged by dating the
    // request rather than by waiting.
    const now = std.Io.Clock.awake.now(io);
    const window_ns = @as(i96, api_limiter.window_seconds) * std.time.ns_per_s;
    const client: test_address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 10 } };
    _ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client);
    try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io));

    // Nothing here exchanges: the pass only sweeps the two tables.
    var unreachable_upstream: upstream_owner.Borrowed = .{};
    var h: handler.Handler = .{
        .upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }),
        .policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } },
    };
    try maintenanceOnce(&h, &limiter, io);

    // Before ruling 12 the sweep had no production caller, so the table kept
    // this bucket until the process restarted.
    try std.testing.expectEqual(@as(u32, 0), limiter.trackedClients(io));

    // A limiter the app did not build is not a reason for the pass to fail.
    try maintenanceOnce(&h, null, io);
}

test "a failing listener bind opens an error episode a clean boot closes" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    reportListener(&fx.store, io, 1000, "dot", "cannot listen on {s}:{d}: {t}", .{
        "0.0.0.0",
        @as(u16, 853),
        error.AddressInUse,
    });

    try testing.expectEqualStrings("listener.start", try fx.text("SELECT code FROM operational_events"));
    try testing.expectEqualStrings("dot", try fx.text("SELECT subject_key FROM operational_events"));
    try testing.expectEqualStrings("error", try fx.text("SELECT severity FROM operational_events"));

    // The next boot: DoH failed, DoT started clean. One `resolveExcept` over
    // the failed keys closes the stale DoT episode and leaves the DoH one open.
    reportListener(&fx.store, io, 1100, "doh", "cannot listen on {s}:{d}: {t}", .{
        "0.0.0.0",
        @as(u16, 443),
        error.AddressInUse,
    });
    fx.store.resolveExcept(io, 1100, .listener_start, &.{"doh"});

    try testing.expectEqual(
        @as(i64, 1),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
    try testing.expectEqualStrings("doh", try fx.text(
        "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
    ));
}

test "a boot with no listener finding closes every listener episode" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    fx.store.report(io, 900, .listener_start, "doh", "doh", .@"error", "stale");
    fx.store.report(io, 900, .listener_start, "dot", "dot", .@"error", "stale");

    fx.store.resolveExcept(io, 1000, .listener_start, &.{});

    try testing.expectEqual(
        @as(i64, 0),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
}

test "a configuration finding is reported once and finalize closes the rest" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    // Two stale episodes from a previous boot.
    fx.store.report(io, 900, .configuration_load, "dns.bind_ipv6", "dns.bind_ipv6", .warning, "stale");
    fx.store.report(io, 900, .configuration_load, "upstreams[0].url", "upstreams[0].url", .warning, "stale");

    var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
    collector.note("dns.bind_ipv6", "dns.bind_ipv6", "this system has no IPv6; serving IPv4 only");
    // The same finding twice is one episode and one kept key.
    collector.note("dns.bind_ipv6", "dns.bind_ipv6", "this system has no IPv6; serving IPv4 only");
    try testing.expectEqual(@as(usize, 1), collector.len);

    collector.finalize();

    try testing.expectEqual(
        @as(i64, 1),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
    try testing.expectEqualStrings("dns.bind_ipv6", try fx.text(
        "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
    ));
    // The stale episode is still the same one: this boot bumped it twice.
    try testing.expectEqual(@as(i64, 3), try fx.count(
        "SELECT occurrences FROM operational_events WHERE resolved_at IS NULL",
    ));
}

test "the upstream build's report replays into the same rows the boot path used to write" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    // Last boot's finding for a row this boot has fixed. Only `finalize`
    // closes it, which is why the replay has to run before it.
    fx.store.report(io, 900, .configuration_load, "ftp://fixed.example", "ftp://fixed.example", .warning, "stale");

    var http: std.http.Client = .{ .allocator = testing.allocator, .io = io };
    defer http.deinit();
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(testing.allocator);
    var bundle_lock: std.Io.RwLock = .init;

    const generation = try upstream_owner.build(.{
        .gpa = testing.allocator,
        .io = io,
        // The credential in the bad row is why the key and the rendered text
        // differ: the key is the whole url, and both rendered forms drop it.
        .servers = &.{
            .{ .url = "ftp://user:hunter2@nope.example" },
            .{ .url = "https://good.example/dns-query" },
        },
        .http = &http,
        .bundle = &bundle,
        .bundle_lock = &bundle_lock,
        .timeouts = .{
            .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
            .total = .{ .raw = .fromMilliseconds(200), .clock = .awake },
        },
        .seed = 1,
    });
    var upstreams: upstream_owner.Owner = .init(generation);
    defer upstreams.deinit(io);

    // `build` itself wrote nothing: a candidate a settings PUT never publishes
    // must leave the diagnostics log exactly as it found it.
    try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events"));

    var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
    for (generation.report().notes) |finding| {
        noteUpstream(&collector, finding.url, finding.message);
    }
    collector.finalize();

    // The whole url is the key, the redaction is the label, and the detail is
    // the sentence `noteUpstream` has always written — byte for byte.
    try testing.expectEqual(
        @as(i64, 1),
        try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"),
    );
    try testing.expectEqualStrings("ftp://user:hunter2@nope.example", try fx.text(
        "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL",
    ));
    try testing.expectEqualStrings("ftp://nope.example", try fx.text(
        "SELECT subject_label FROM operational_events WHERE resolved_at IS NULL",
    ));
    try testing.expectEqualStrings(
        "upstream 'ftp://nope.example' not an https:// or tls:// endpoint; skipped",
        try fx.text("SELECT detail FROM operational_events WHERE resolved_at IS NULL"),
    );

    // The row that was wrong last boot and is not wrong now is closed by the
    // same `finalize` as ever: the replay is what puts the keys in front of it.
    try testing.expectEqual(@as(i64, 1), try fx.count(
        "SELECT count(*) FROM operational_events WHERE subject_key = 'ftp://fixed.example' AND resolved_at IS NOT NULL",
    ));
}

test "an over-long boot finding list refuses to finalize rather than truncate" {
    var threaded: std.Io.Threaded = .init(testing.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();

    var fx: events_fixture.Fixture = .{};
    try fx.init(io, 1000);
    defer fx.deinit();

    fx.store.report(io, 900, .configuration_load, "stale.setting", "stale.setting", .warning, "stale");

    var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 };
    var key_buf: [32]u8 = undefined;
    for (0..events.Store.max_kept_keys + 1) |i| {
        const key = try std.fmt.bufPrint(&key_buf, "upstreams[{d}].url", .{i});
        collector.note(key, key, "not a usable url");
    }
    try testing.expect(collector.overflowed);

    collector.finalize();

    // Closing more than the boot meant would resolve episodes that are still
    // true, so the stale one stays open instead.
    try testing.expectEqual(@as(i64, 1), try fx.count(
        "SELECT count(*) FROM operational_events WHERE subject_key = 'stale.setting' AND resolved_at IS NULL",
    ));
    // Refused, and refused out loud: the store counted and latched it, so
    // `/api/health` reports the diagnostics log as not recording.
    try testing.expect(fx.store.writeFailed());
    try testing.expectEqual(@as(u64, 1), fx.store.writeFailures());
}
