# Milestone 4: Storage + Config

Goal (PLAN §16 Phase 4): a thin SQLite wrapper over the vendored amalgamation; `config.db` schema with a compiled-in migration runner; `querylog.db` schema with recreate-on-mismatch; repositories; one config model with a pure validator; ZON bootstrap, `nxdns import`, `nxdns export`, `nxdns check`. Exit: first start seeds the DB from `/etc/nxdns/config.zon`; `export` → `import` → `export` is byte-identical.

Read first: `AGENTS.md` (values), `specs/research/zig-0.16-api-notes.md` (verified stdlib facts — pre-0.16 knowledge is stale and MUST NOT be used), `specs/milestone-1.md`, `specs/milestone-2.md`, `specs/milestone-3.md` (module conventions and the "As built" notes). The Zig source of truth is `/home/mokhtar/app/zig` at tag `0.16.0`. The SQLite source of truth is the vendored `sqlite3.h` inside the pinned `sqlite` dependency (3.53.4). PLAN §3.5–3.7, §3.13, §11, §12, §15 are the scope authority; §11.2 and §11.3 carry the DDL, reproduced verbatim below.

## What already exists (do not respecify, import it)

- `src/platform/address.zig` — `NetAddress.parse`, `NetAddress.format`, `Prefix.parse`. The validator uses these for every IP and prefix string. Do not write a second IP parser.
- `src/dns/name.zig` — `Name.fromText` (pure, allocation-free, rejects empty/oversize labels and oversize names). The validator uses this for every domain-name-shaped string. Do not write a second hostname validator.
- `src/upstream/transport.zig` — `Endpoint.parse` (`https://…` → DoH, `tls://…` → DoT), `Endpoint`, `Client`, `ExchangeError`, `group`. The validator uses `Endpoint.parse` for upstream URLs; `nxdns check` builds real clients from the parsed endpoints.
- `src/upstream/health.zig` — `health.Config`, `health.State`.
- `src/upstream/pool.zig` — `Pool.init(entries, cfg, attempt_timeout, seed)`, `Pool.exchange`, `Pool.snapshot(io, out) Cancelable!usize`, `Pool.Snapshot`, `Pool.Entry`. `nxdns check` probes through this, one `Pool` per upstream (see S6.4).
- `src/upstream/doh_client.zig`, `src/upstream/dot_client.zig` — the two `transport.Client` implementations `check` instantiates.
- `build.zig` — `-Dintegration` (hermetic, loopback/tmpdir only, PR-blocking) and `-Dlive` (leaves the machine, manual workflow only) reach test files through `@import("build_options")`. The test artifact and the executable already link the static `sqlite3` library; no build change is needed to call SQLite from Zig. The `sqlite` dependency does **not** install headers — declare the C API as `extern` functions in Zig, exactly as `src/tests.zig` and `src/platform/tls_server.zig` already do.

## Sessions

```
S1 (storage/db.zig)          S2 (config/model.zig + config/validate.zig)     [parallel, no deps]
   |                              |
   +--> S3 (config_schema + migrations + querylog_schema)   [needs S1]
   |                              |
   +---------+--------------------+
             v
         S4 (storage/repositories/*)          [needs S1, S2, S3]
             v
         S5 (config/bootstrap + import + export)   [needs S4]
             v
         S6 (cli.zig + main.zig)              [needs S5 + the M3 pool]
             v
         S7 (storage_integration_test.zig)    [needs everything]
```

S1 and S2 are fully specified below, so they start together. Every later session is written against this spec, not against the previous session's source. The orchestrator — not any session — wires `src/tests.zig` imports and any `build.zig` change. A session that needs a build change reports the exact change in its completion report.

## Session verification protocol (read this before starting)

`zig test <file>` does **not** work for the files in this milestone. Every file here either imports across `src/` subdirectories or calls into the linked `sqlite3` library, and a standalone `zig test` invocation has neither the module wiring nor the C library. Therefore:

- Every session verifies its own work with `zig fmt --check <its files>` and `zig ast-check <its files>`. `zig ast-check` reports only what is decidable from the source alone — syntax and AST-level errors. It does **not** type-check, so it cannot prove the code compiles.
- The orchestrator wires the session's files into `src/tests.zig` and runs `zig build test` (and `zig build test -Dintegration` for S7). That run is the real gate.
- A session must state in its completion report that its tests have not been executed, and list the exact test names it wrote so the orchestrator can confirm they ran.
- No session edits `build.zig`, `build.zig.zon`, or `src/tests.zig`.

## Design invariants (all sessions)

- **`src/storage/db.zig` takes no `std.Io`.** This is the one deliberate exception to Decision E, and it must be documented in a comment at the top of the file. SQLite performs its own file I/O through its VFS; routing it through `std.Io` would mean writing a custom SQLite VFS — a large, security- sensitive component bought for nothing at household scale. Every *other* file in this milestone that touches the filesystem (`querylog_schema.zig`, `config/import.zig`, `config/export.zig`, `config/bootstrap.zig`, `src/cli.zig`) takes `io: std.Io`.
- **No `@cImport`.** SQLite is reached through hand-written `extern` declarations, matching milestone 1's mbedTLS approach.
- **The validator is pure**: no `std.Io`, no SQLite, no clock. It takes a `Config` and an allocator (for diagnostic text) and returns typed errors. It is unit-testable with `std.testing.allocator` alone.
- **Nothing is silently swallowed.** A failed `ROLLBACK` is logged at `err` level with the SQLite message. A `busy_timeout` that does not take is an error. An unknown settings key is logged at `warn` and counted. A recreated `querylog.db` is logged at `warn` with the reason and the rename-aside path.
- **Every list query has a deterministic `ORDER BY` whose trailing column set is unique.** Byte-stable export depends on it; append `id` when nothing else guarantees uniqueness.
- **Arithmetic on config-supplied integers cannot panic.** Every numeric config field has an explicit sized type; every unit conversion lives in a named function in `config/model.zig` guarded by a `comptime` assertion that the field type's maximum times the conversion factor fits the destination type (see S2.4).
- **`std.zon.parse` output is arena-owned.** Never call `std.zon.parse.free` on a parsed `Config`. See the verdict in S5.1 — this is a hard rule, not a style preference.
- Unit tests live in-file. Tests that touch the filesystem or open real databases live in `src/storage/storage_integration_test.zig`, guarded by `if (!build_options.integration) return error.SkipZigTest;`, mirroring milestones 1 and 3. Exception: tests that open an in-memory database (`":memory:"`) touch no filesystem and stay in-file in the default `zig build test`.

---

## Verified 0.16.0 stdlib facts used by this milestone

Read from `/home/mokhtar/app/zig` at tag `0.16.0`. The `make*` family that pre-0.16 knowledge suggests **does not exist**; do not reach for it.

### Filesystem — `lib/std/Io/Dir.zig`, `lib/std/Io/File.zig`

```zig
pub fn cwd() Dir                                                                    // Dir.zig:88, no io param
pub fn access(dir: Dir, io: Io, sub_path: []const u8, options: AccessOptions) AccessError!void        // Dir.zig:438
pub fn openDir(dir: Dir, io: Io, sub_path: []const u8, options: OpenOptions) OpenError!Dir            // Dir.zig:481
pub fn close(dir: Dir, io: Io) void                                                                   // Dir.zig:490
pub fn openFile(dir: Dir, io: Io, sub_path: []const u8, options: OpenFileOptions) File.OpenError!File // Dir.zig:577
pub fn createFile(dir: Dir, io: Io, sub_path: []const u8, flags: CreateFileOptions) File.OpenError!File // Dir.zig:638
pub fn createDir(dir: Dir, io: Io, sub_path: []const u8, permissions: Permissions) CreateDirError!void  // Dir.zig:797
pub fn createDirPath(dir: Dir, io: Io, sub_path: []const u8) CreateDirPathError!void                   // Dir.zig:843
pub fn createDirPathStatus(dir: Dir, io: Io, sub_path: []const u8, permissions: Permissions) CreateDirPathError!CreatePathStatus // Dir.zig:851
pub fn statFile(dir: Dir, io: Io, sub_path: []const u8, options: StatFileOptions) StatFileError!Stat   // Dir.zig:899
pub fn deleteFile(dir: Dir, io: Io, sub_path: []const u8) DeleteFileError!void                         // Dir.zig:1004
pub fn readFileAlloc(dir: Dir, io: Io, sub_path: []const u8, gpa: Allocator, limit: Io.Limit) ReadFileAllocError![]u8 // Dir.zig:1326
pub fn readFileAllocOptions(dir: Dir, io: Io, sub_path: []const u8, gpa: Allocator, limit: Io.Limit,
                            comptime alignment: std.mem.Alignment, comptime sentinel: ?u8) ...         // Dir.zig:1346
pub fn setFilePermissions(dir: Dir, io: Io, sub_path: []const u8, new_permissions: File.Permissions,
                          options: SetFilePermissionsOptions) SetFilePermissionsError!void             // Dir.zig:1959
```

`rename` takes `io` **last** and both directories (Dir.zig:1093):

```zig
pub fn rename(old_dir: Dir, old_sub_path: []const u8, new_dir: Dir, new_sub_path: []const u8, io: Io) RenameError!void
```

It **replaces** an existing destination (doc comment Dir.zig:1085; POSIX `renameat`). The non-replacing counterpart is `renamePreserve` (Dir.zig:1133, same `io`-last order), which returns `error.PathAlreadyExists` — Linux `renameat2` with `RENAME_NOREPLACE`. S3 uses `renamePreserve` to uniquify the corrupt-querylog aside name.

Permissions are `File.Permissions` (File.zig:335), a non-exhaustive enum over `std.posix.mode_t` with `fromMode(mode) Permissions` (File.zig:385) and `toMode() std.posix.mode_t` (File.zig:381). Named values `default_file = 0o666`, `default_dir = 0o777`.

- 0o600 file: `dir.createFile(io, name, .{ .permissions = .fromMode(0o600) })` — `Dir.CreateFileOptions.permissions` (Dir.zig:632). There is **no `mode` field**; `File.CreateFlags` is a deprecated alias of `Dir.CreateFileOptions` (File.zig:158).
- 0o700 directory: `dir.createDir(io, name, .fromMode(0o700))`, or `dir.createDirPathStatus(io, path, .fromMode(0o700))`. Plain `createDirPath` (Dir.zig:843) **hardcodes `.default_dir`** and must not be used where the mode matters.
- There is no function named `chmod`; use `File.setPermissions` (File.zig:308), `Dir.setPermissions` (Dir.zig:1942), or `Dir.setFilePermissions` (Dir.zig:1959).

Atomic write — present, but not under the pre-0.16 names (`atomicFile`/`AtomicFile` do not exist):

```zig
pub fn createFileAtomic(dir: Dir, io: Io, sub_path: []const u8, options: CreateFileAtomicOptions)
    CreateFileAtomicError!File.Atomic                                                // Dir.zig:1924
// CreateFileAtomicOptions (Dir.zig:1870): permissions: File.Permissions = .default_file,
//                                          make_path: bool = false, replace: bool = false
pub fn deinit(af: *Atomic, io: Io) void                                              // File/Atomic.zig:23
pub fn link(af: *Atomic, io: Io) LinkError!void                                      // File/Atomic.zig:48  (non-replacing)
pub fn replace(af: *Atomic, io: Io) ReplaceError!void                                // File/Atomic.zig:77  (replacing)
```

File I/O:

```zig
pub fn writer(file: File, io: Io, buffer: []u8) Writer   // File.zig:600, returns File.Writer BY VALUE
pub fn close(file: File, io: Io) void                    // File.zig:221
pub fn sync(file: File, io: Io) SyncError!void           // File.zig:241  (there is no syncAll)
pub fn stat(file: File, io: Io) StatError!Stat           // File.zig:141
```

`File.writeAll` does **not** exist. Write through the interface: `var fw = file.writer(io, &buf); const w = &fw.interface; try w.writeAll(bytes); try w.flush();` The `File.Writer` value is self-referential (`@fieldParentPtr("interface", …)`, File/Writer.zig:90) and must be stored in a `var` that does not move.

### Clock — `lib/std/Io.zig`

```zig
pub fn now(clock: Clock, io: Io) Io.Timestamp    // Io.zig:778  →  std.Io.Clock.real.now(io)
pub const Timestamp = struct { nanoseconds: i96, ... }   // Io.zig:906
pub fn toSeconds(t: Timestamp) i64 { return @intCast(@divTrunc(t.nanoseconds, std.time.ns_per_s)); } // Io.zig:943
```

`io.now(.real)` does **not** exist (confirmed: no top-level `pub fn now` in Io.zig). Unix epoch seconds for `first_seen`/`last_seen`/`created_at` columns: `std.Io.Clock.real.now(io).toSeconds()`. `Clock.real` is documented (Io.zig:733) as relative to 1970-01-01T00:00:00Z, so no offset is needed.

### `std.zon` — `lib/std/zon/parse.zig`, `lib/std/zon/stringify.zig`

```zig
pub fn fromSliceAlloc(T: type, gpa: Allocator, source: [:0]const u8, diag: ?*Diagnostics, options: Options)
    error{ OutOfMemory, ParseZon }!T                                    // parse.zig:276
pub fn free(gpa: Allocator, value: anytype) void                        // parse.zig:412
pub fn serialize(val: anytype, options: SerializeOptions, writer: *Writer) Writer.Error!void  // stringify.zig:45
// SerializeOptions (stringify.zig:29): whitespace: bool = true, emit_codepoint_literals = .never,
//                                       emit_strings_as_containers: bool = false,
//                                       emit_default_optional_fields: bool = true
```

### Testing

`std.testing.checkAllAllocationFailures(backing, test_fn, extra_args)` (testing.zig:1115) and `std.testing.FailingAllocator` (testing.zig:13) exist and are the required tool for the repository leak-safety criteria in S4.

### SQLite — vendored amalgamation 3.53.4

Verified from the pinned dependency's `sqlite3.h`. Result codes: `SQLITE_OK 0`, `ERROR 1`, `INTERNAL 2`, `PERM 3`, `ABORT 4`, `BUSY 5`, `LOCKED 6`, `NOMEM 7`, `READONLY 8`, `INTERRUPT 9`, `IOERR 10`, `CORRUPT 11`, `NOTFOUND 12`, `FULL 13`, `CANTOPEN 14`, `PROTOCOL 15`, `EMPTY 16`, `SCHEMA 17`, `TOOBIG 18`, `CONSTRAINT 19`, `MISMATCH 20`, `MISUSE 21`, `NOLFS 22`, `AUTH 23`, `FORMAT 24`, `RANGE 25`, `NOTADB 26`, `ROW 100`, `DONE 101`. Open flags: `READONLY 0x1`, `READWRITE 0x2`, `CREATE 0x4`, `URI 0x40`, `NOMUTEX 0x8000`, `FULLMUTEX 0x10000`, `EXRESCODE 0x2000000`. `SQLITE_STATIC` is destructor pointer `0`, `SQLITE_TRANSIENT` is `-1`.

---

## Session S1: `src/storage/db.zig`

The whole SQLite surface nxdns owns (PLAN Decision G). Nothing above this file calls SQLite directly.

### S1.1 extern layer

Declare the C API by hand. Opaque handles, no `@cImport`:

```zig
pub const c = struct {
    pub const Sqlite3 = opaque {};
    pub const Stmt = opaque {};
    pub const Destructor = *const fn (?*anyopaque) callconv(.c) void;
    pub const transient: ?Destructor = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));

    pub extern fn sqlite3_open_v2(filename: [*:0]const u8, ppDb: *?*Sqlite3, flags: c_int, zVfs: ?[*:0]const u8) c_int;
    pub extern fn sqlite3_close_v2(db: ?*Sqlite3) c_int;
    pub extern fn sqlite3_extended_result_codes(db: *Sqlite3, onoff: c_int) c_int;
    pub extern fn sqlite3_busy_timeout(db: *Sqlite3, ms: c_int) c_int;
    pub extern fn sqlite3_exec(db: *Sqlite3, sql: [*:0]const u8, cb: ?*const anyopaque, arg: ?*anyopaque, errmsg: ?*?[*:0]u8) c_int;
    pub extern fn sqlite3_errmsg(db: *Sqlite3) [*:0]const u8;
    pub extern fn sqlite3_errcode(db: *Sqlite3) c_int;
    pub extern fn sqlite3_extended_errcode(db: *Sqlite3) c_int;
    pub extern fn sqlite3_errstr(code: c_int) [*:0]const u8;
    pub extern fn sqlite3_prepare_v2(db: *Sqlite3, sql: [*]const u8, n_byte: c_int, ppStmt: *?*Stmt, pzTail: ?*?[*]const u8) c_int;
    pub extern fn sqlite3_step(stmt: *Stmt) c_int;
    pub extern fn sqlite3_reset(stmt: *Stmt) c_int;
    pub extern fn sqlite3_clear_bindings(stmt: *Stmt) c_int;
    pub extern fn sqlite3_finalize(stmt: ?*Stmt) c_int;
    pub extern fn sqlite3_bind_int64(stmt: *Stmt, idx: c_int, value: i64) c_int;
    pub extern fn sqlite3_bind_text(stmt: *Stmt, idx: c_int, text: [*]const u8, n: c_int, d: ?Destructor) c_int;
    pub extern fn sqlite3_bind_null(stmt: *Stmt, idx: c_int) c_int;
    pub extern fn sqlite3_bind_parameter_count(stmt: *Stmt) c_int;
    pub extern fn sqlite3_column_count(stmt: *Stmt) c_int;
    pub extern fn sqlite3_column_type(stmt: *Stmt, col: c_int) c_int;
    pub extern fn sqlite3_column_int64(stmt: *Stmt, col: c_int) i64;
    pub extern fn sqlite3_column_text(stmt: *Stmt, col: c_int) ?[*]const u8;
    pub extern fn sqlite3_column_bytes(stmt: *Stmt, col: c_int) c_int;
    pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
    pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
};
```

Column type codes are `SQLITE_INTEGER 1`, `FLOAT 2`, `TEXT 3`, `BLOB 4`, `NULL 5`; declare them as named constants rather than magic numbers.

### S1.2 error set and code mapping

```zig
pub const Error = error{
    Abort, Auth, Busy, CantOpen, Constraint, Corrupt, Empty, Format, Full, Internal,
    Interrupt, IoErr, Locked, Mismatch, Misuse, NoLfs, NotADb, NotFound, Perm, Protocol,
    Range, ReadOnly, Schema, TooBig, SqliteError, OutOfMemory, Unexpected,
};

/// Maps a primary SQLite result code to `Error`. `SQLITE_NOMEM` becomes `error.OutOfMemory`
/// so it joins `transport.LocalResource` semantics: out of memory is never the data's fault.
pub fn mapCode(code: c_int) Error;
```

Rules:

- `mapCode` switches on the **primary** code, `code & 0xff`, so extended codes (`SQLITE_IOERR_*`, `SQLITE_CONSTRAINT_*`, `SQLITE_BUSY_SNAPSHOT`, …) land on their family. The extended code is preserved for humans through `Db.lastError`.
- `SQLITE_OK`, `SQLITE_ROW` and `SQLITE_DONE` are not errors — `mapCode` asserts it is never called with them.
- The switch is explicit over every listed primary code with `else => error.SqliteError` for future codes; `SqliteError` is a distinct member so an unmapped code is visible rather than disguised as a known failure.
- A test walks all codes 1–26 and asserts each maps to a distinct member, and that `mapCode(SQLITE_NOMEM) == error.OutOfMemory`.

### S1.3 `Db`

```zig
pub const OpenMode = enum { read_write_create, read_write_existing, read_only, memory };

pub const OpenOptions = struct {
    mode: OpenMode = .read_write_create,
    busy_timeout_ms: c_int = 5000,
};

pub const Db = struct {
    handle: *c.Sqlite3,

    pub fn open(path: [:0]const u8, options: OpenOptions) Error!Db;
    pub fn close(self: *Db) void;

    /// Borrowed; valid until the next SQLite call on this handle. Formats as
    /// "<message> (code <primary>/<extended>)".
    pub fn lastError(self: *Db, buf: []u8) []const u8;

    pub fn exec(self: *Db, sql: [:0]const u8) Error!void;
    pub fn prepare(self: *Db, sql: []const u8) Error!Stmt;

    /// Runs `sql` (which must yield exactly one row with one integer column) and returns it.
    pub fn queryInt(self: *Db, sql: []const u8) Error!i64;

    pub fn lastInsertRowid(self: *Db) i64;
    pub fn changes(self: *Db) i64;
};
```

`open` rules:

1. Flags: `.read_write_create` → `READWRITE|CREATE|EXRESCODE|FULLMUTEX`; `.read_write_existing` → `READWRITE|EXRESCODE|FULLMUTEX`; `.read_only` → `READONLY|EXRESCODE|FULLMUTEX`; `.memory` → `READWRITE|CREATE|EXRESCODE|FULLMUTEX` with path `":memory:"`. `FULLMUTEX` (serialized mode) because Phase 6's query logger and Phase 8's API handlers will share one handle across `std.Io` tasks; a per-handle mutex inside SQLite is cheaper to be correct about than a hand-rolled one, and config.db write volume is negligible. Document this in a comment. `EXRESCODE` so `sqlite3_extended_errcode` is meaningful from the first call.
2. **`sqlite3_open_v2` allocates a handle even on failure.** On a non-`OK` return, read the message, call `sqlite3_close_v2` on the returned handle, and only then return the mapped error. Losing this handle is a leak on every failed open; a test with a directory path (`error.CantOpen`) covers it.
3. `sqlite3_busy_timeout` **result is checked** and mapped. A silently ignored busy timeout is how a contended WAL database turns into random `SQLITE_BUSY` failures under load.
4. `open` does **not** apply pragmas. `applyPragmas` is separate (S1.5) because the migration runner must apply `foreign_keys` before it opens a transaction.

`prepare` takes a non-sentinel `[]const u8` and passes `@intCast(sql.len)` as `nByte`, with `pzTail` non-null; if the tail is not exhausted the statement text contained more than one statement — return `error.Misuse`. Multi-statement text belongs in `exec`.

`exec` passes `null` for the `errmsg` out-parameter and reads `sqlite3_errmsg` instead, so there is no `sqlite3_free` obligation. `exec` is for DDL and multi-statement scripts only.

### S1.4 `Stmt`

```zig
pub const Stmt = struct {
    handle: *c.Stmt,
    db: *Db,

    pub fn deinit(self: *Stmt) void;             // sqlite3_finalize; logs a non-OK code at err level
    pub fn reset(self: *Stmt) Error!void;        // sqlite3_reset + sqlite3_clear_bindings

    /// 1-based indices, matching SQLite.
    pub fn bindInt(self: *Stmt, idx: c_int, value: i64) Error!void;
    pub fn bindBool(self: *Stmt, idx: c_int, value: bool) Error!void;
    pub fn bindText(self: *Stmt, idx: c_int, value: []const u8) Error!void;
    pub fn bindTextOrNull(self: *Stmt, idx: c_int, value: ?[]const u8) Error!void;
    pub fn bindNull(self: *Stmt, idx: c_int) Error!void;

    /// true = a row is available, false = statement finished.
    pub fn step(self: *Stmt) Error!bool;

    /// Runs to completion; asserts no rows were produced.
    pub fn exec(self: *Stmt) Error!void;

    pub fn columnInt(self: *Stmt, col: c_int) i64;
    pub fn columnBool(self: *Stmt, col: c_int) bool;   // != 0
    pub fn isNull(self: *Stmt, col: c_int) bool;

    /// Borrowed: valid only until the next `step`, `reset` or `deinit` on this statement.
    /// Every caller that keeps the value must copy it. Documented on the function.
    pub fn columnText(self: *Stmt, col: c_int) []const u8;
    pub fn columnTextOrNull(self: *Stmt, col: c_int) ?[]const u8;

    /// Copies into `gpa`. Caller owns the result.
    pub fn columnTextAlloc(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}![]u8;
    pub fn columnTextAllocOrNull(self: *Stmt, gpa: std.mem.Allocator, col: c_int) error{OutOfMemory}!?[]u8;
};
```

- `bindText` uses `c.transient` so the caller never has to keep the source buffer alive. Document the cost (SQLite copies) and why correctness wins here.
- `columnText` on a NULL column: `sqlite3_column_text` returns null; return `""` from `columnText` and `null` from `columnTextOrNull`. A schema with `NOT NULL` on the column makes the first case unreachable in practice, but it must not be undefined behaviour.
- `step` maps `SQLITE_ROW` → `true`, `SQLITE_DONE` → `false`, everything else through `mapCode`.
- **No prepared-statement cache in this milestone.** `config.db` is written a handful of times per process lifetime; a cache is unmeasured complexity here. Phase 6's query-log flush loop is the only hot path and it will own its own long-lived statements. State this decision in a comment so it does not read as an oversight against PLAN §3.4.

### S1.5 pragmas and transactions

```zig
pub const Pragmas = struct {
    journal_wal: bool = true,
    synchronous_normal: bool = true,
    foreign_keys: bool = true,
};

/// MUST be called before any transaction is opened: `PRAGMA foreign_keys` is a no-op inside a
/// transaction, so applying it later silently leaves referential integrity off.
pub fn applyPragmas(self: *Db, p: Pragmas) Error!void;
```

- `PRAGMA journal_mode = WAL` **returns a row**. Run it through `prepare`/`step` and verify the returned text equals `"wal"` (ASCII case-insensitive); otherwise return `error.SqliteError`. An `exec` here would discard the answer and a memory database (which cannot do WAL) would look fine. For `.memory` databases, accept `"memory"` as the result — in-memory tests must not fail on this.
- `PRAGMA synchronous = NORMAL` produces no row; `exec` is fine.
- `PRAGMA foreign_keys = ON` produces no row, but the follow-up `PRAGMA foreign_keys` **does**; read it back and require `1`, else `error.SqliteError`.

```zig
pub const Tx = struct {
    db: *Db,
    active: bool,

    /// BEGIN IMMEDIATE — takes the write lock up front. A deferred transaction that upgrades
    /// mid-way can fail with SQLITE_BUSY after arbitrary work; immediate cannot.
    pub fn begin(db: *Db) Error!Tx;

    pub fn commit(self: *Tx) Error!void;

    /// Safe in `errdefer` and after `commit`. Never returns an error; a failed ROLLBACK is logged
    /// at `err` level with the SQLite message, because a database that will not roll back is an
    /// operational event, not a detail.
    pub fn rollback(self: *Tx) void;
};
```

Usage contract, stated in a doc comment and followed everywhere in this milestone:

```zig
var tx = try Tx.begin(db);
errdefer tx.rollback();
... // all writes
try tx.commit();
```

`commit` and `rollback` both clear `active`, so the `errdefer` after a successful commit is a no-op. `begin` on a `Tx` whose `active` is already true is a programming error — assert.

### S1.6 Tests (in-file, `:memory:` only)

- `mapCode` distinctness over codes 1–26; `SQLITE_NOMEM` → `error.OutOfMemory`.
- Open/close a `:memory:` database; `applyPragmas` succeeds and `PRAGMA foreign_keys` reads back 1.
- `open` on a path that is a directory returns `error.CantOpen` and leaks nothing (run it 1000 times in a loop; the test passes if it does not exhaust file descriptors — a crude but real regression guard for the close-on-failed-open rule).
- `prepare` on two statements separated by `;` returns `error.Misuse`.
- Bind/step/column round-trip: create a table, insert one row with text and integer columns, read it back, including a NULL text column through `columnTextOrNull`.
- `columnTextAlloc` returns an owned copy that survives a subsequent `step`.
- Transaction commits persist; `rollback` discards; `rollback` after `commit` is a no-op.
- A statement whose `exec` produces a row triggers the assertion path — assert instead through `step` returning true, to keep the test free of `unreachable`.
- A constraint violation (insert a duplicate into a `UNIQUE` column) returns `error.Constraint`.

### S1.7 Acceptance criteria

- [ ] `zig fmt --check src/storage/db.zig` and `zig ast-check src/storage/db.zig` clean.
- [ ] No `@cImport` anywhere in the file.
- [ ] The `std.Io` exception is documented in a file-level comment.
- [ ] `sqlite3_busy_timeout`'s result is checked; a failed `ROLLBACK` reaches `std.log.err`.
- [ ] Every bullet in S1.6 exists as a named test.

---

## Session S2: `src/config/model.zig`, `src/config/validate.zig`

Pure. No `std.Io`, no SQLite. These two files are the contract every later session codes against.

### S2.1 PLAN §12.1 amendments (deliberate, not drift)

PLAN §12.1 sketches the bootstrap file. Three of its shapes cannot round-trip the database, and the database is truth (Decision F). The model below changes them; the orchestrator updates PLAN §12.1 to match. The reasons:

1. `.upstream.servers = .{ "url", … }` (a list of strings) cannot express the `upstreams` table's `priority` and `enabled` columns. Export would silently drop a disabled upstream or renumber priorities, and the byte-stable round trip would be a lie. The model uses `.upstreams = .{ .{ .url = …, .priority = 100, .enabled = true }, … }`.
2. `.local_records` entries gain `.ttl` (default 300), matching the column.
3. `web.password` is operator input; storing it verbatim contradicts PLAN §3.11 (argon2id). The model carries **both** `password` (input only, always exported as `""`) and `password_hash` (the stored PHC string). See S2.5.

The DB also holds five collections §12.1 never mentions — `groups`, `clients`, `client_prefixes`, `blocklist_sources`, `rules` — and export must reproduce them. They are added below.

### S2.2 The model

One struct, used by bootstrap, import, export and (Phase 7) the running server. Every field has a default so a minimal bootstrap file is legal.

```zig
pub const Config = struct {
    runtime: Runtime = .{},
    upstream: Upstream = .{},
    dns: Dns = .{},
    blocking: Blocking = .{},
    cache: Cache = .{},
    web: Web = .{},
    doh_server: TlsEndpoint = .{},
    dot_server: TlsEndpoint = .{ .port = 853 },
    edns: Edns = .{},
    logging: Logging = .{},
    disk: Disk = .{},
    blocklist_update: BlocklistUpdate = .{},

    groups: []const Group = &.{},
    upstreams: []const UpstreamServer = &.{},
    clients: []const Client = &.{},
    client_prefixes: []const ClientPrefix = &.{},
    blocklist_sources: []const BlocklistSource = &.{},
    group_sources: []const GroupSource = &.{},
    rules: []const Rule = &.{},
    local_records: []const LocalRecord = &.{},
    forward_zones: []const ForwardZone = &.{},
};
```

Scalar sections — the types are load-bearing (S2.4):

```zig
pub const IoBackend = enum { threaded, evented };            // PLAN §3.2
pub const Runtime = struct { io_backend: IoBackend = .threaded };

pub const Upstream = struct {
    connect_timeout_ms: u32 = 2000,
    read_timeout_ms: u32 = 3000,
    total_timeout_ms: u32 = 5000,                            // PLAN §9 total budget
};

pub const Dns = struct {
    bind_ipv4: []const u8 = "0.0.0.0",
    bind_ipv6: []const u8 = "::",
    port: u16 = 53,
    rate_limit: u32 = 1000,
    rate_window_seconds: u32 = 60,
};

pub const BlockResponse = enum { zero, nxdomain };
pub const Blocking = struct { response: BlockResponse = .zero, ttl: u32 = 5 };

pub const Cache = struct { size: u32 = 10000, negative_ttl_max: u32 = 3600 };

pub const Web = struct {
    enabled: bool = true,
    bind: []const u8 = "0.0.0.0",
    port: u16 = 8080,
    password: []const u8 = "",           // input only; never stored, always exported as ""
    password_hash: []const u8 = "",      // argon2id PHC string; "" disables auth
    session_ttl_hours: u16 = 24,
    api_rate_limit_per_min: u32 = 300,
    sse_max_connections_per_ip: u16 = 3,
};

pub const TlsEndpoint = struct {
    enabled: bool = false,
    bind: []const u8 = "0.0.0.0",
    port: u16 = 443,
    cert_path: []const u8 = "/etc/nxdns/cert.pem",
    key_path: []const u8 = "/etc/nxdns/key.pem",
};

pub const EcsMode = enum { strip, forward };
pub const Edns = struct { ecs_mode: EcsMode = .strip };

pub const LogLevel = enum { err, warn, info, debug };
pub const LogOutput = enum { stderr, syslog, file };
pub const Logging = struct {
    level: LogLevel = .info,
    retention_days: u16 = 30,
    query_log_buffer_max: u32 = 10000,
    hide_domains: bool = false,
    hide_client_ips: bool = false,
    output: LogOutput = .stderr,
    file_path: []const u8 = "/var/log/nxdns/nxdns.log",
    max_size_mb: u32 = 50,
    max_files: u8 = 5,
};

pub const Disk = struct { min_free_mb: u32 = 200, warn_free_mb: u32 = 500 };
pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 = 24 };
```

Collections — every group and source reference is **by name/URL**, never by row id, because ids are not stable across an import:

```zig
pub const Group = struct { name: []const u8, safe_search: bool = false };

pub const UpstreamServer = struct { url: []const u8, priority: i32 = 100, enabled: bool = true };

pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" };

pub const ClientPrefix = struct { prefix: []const u8, group: []const u8 = "default", priority: i32 = 100 };

pub const BlocklistSource = struct {
    url: []const u8,
    name: []const u8,
    enabled: bool = true,
    is_suggested: bool = false,
};

pub const GroupSource = struct { group: []const u8, source_url: []const u8 };

pub const RuleKind = enum { exact, wildcard };
pub const RuleAction = enum { allow, block };
pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction };

pub const RecordType = enum { a, aaaa, cname };   // stored as 'A'/'AAAA'/'CNAME'
pub const LocalRecord = struct { name: []const u8, rtype: RecordType, value: []const u8, ttl: u32 = 300 };

pub const ForwardZone = struct { zone: []const u8, resolver: []const u8 };
```

**Runtime columns are deliberately absent from the model.** `clients.first_seen`, `clients.last_seen`, `rules.created_at`, and `blocklist_sources.{last_updated, domain_count, wildcard_count, skipped_regex_count, checksum}` are facts a running server produces, not configuration. Including them would make two exports taken minutes apart differ and would make the byte-stable round-trip criterion untestable against a live server. Import sets the timestamps to the import time and the counters to their column defaults. Document this in the file.

`RecordType` maps to the DDL's `CHECK(rtype IN ('A','AAAA','CNAME'))` through explicit `toDb`/`fromDb` functions in `model.zig`; the enum tag names are lowercase because ZON enum literals are, and `.A` would be an unusual Zig identifier. Same pattern for `RuleKind`, `RuleAction`, `BlockResponse`, `EcsMode`, `LogLevel`, `LogOutput`, `IoBackend`, whose DB text is the lowercase tag name (`@tagName`) with one exception: `LogLevel.err` is stored as `"error"`, because that is the operator-facing word. Both directions are explicit functions with a round-trip test.

### S2.3 settings mapping

The eleven scalar sections live in `settings(key, value)` as TEXT. The key is `"<section>.<field>"`, built at comptime from the field names, so a new field cannot drift out of the mapping:

```zig
pub const SettingPair = struct { key: []const u8, value: []const u8 };

/// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are comptime strings
/// (never freed); values are allocated from `gpa`.
pub fn toSettings(cfg: Config, gpa: std.mem.Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void;

pub const SettingsError = error{ BadSettingValue, OutOfMemory };

/// Applies pairs onto `cfg`, which the caller has initialized to `.{}` (all defaults).
/// An absent key keeps the default — that is how a migration adds a setting with no data step.
/// An unknown key is logged at `warn` and counted in `unknown_keys`; it is never an error, because
/// downgrading a binary must not brick a config database.
pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void;
```

Both are implemented with `inline for` over `@typeInfo(Section).@"struct".fields` for each section, so the section list appears exactly once. Value encoding, one function per field type:

- `bool` → `"true"` / `"false"`; anything else → `error.BadSettingValue`.
- integers → `std.fmt.parseInt` / decimal formatting, on the field's declared type, so an out-of-range stored value is `error.BadSettingValue` rather than a truncating cast.
- enums → `std.meta.stringToEnum` on the DB text form; an unknown tag is `error.BadSettingValue`.
- `[]const u8` → verbatim; allocated from `gpa` on the way out of the DB.

`web.password` is skipped in **both** directions — it is never a settings row (S2.5).

The full key list is 47 entries and is asserted by a test: `toSettings` on a default `Config` produces exactly the expected sorted key list, written out literally in the test. A field added without updating that literal breaks the test — which is the point.

### S2.4 no-panic arithmetic

Every unit conversion the rest of the program needs lives here as a named function, and every one is guarded by a `comptime` assertion that the field type's maximum cannot overflow the destination:

```zig
pub fn connectTimeout(u: Upstream) std.Io.Duration;      // ms → ns
pub fn readTimeout(u: Upstream) std.Io.Duration;
pub fn totalTimeout(u: Upstream) std.Io.Duration;
pub fn sessionTtlSeconds(w: Web) i64;                    // hours → seconds
pub fn retentionSeconds(l: Logging) i64;                 // days  → seconds
pub fn maxLogBytes(l: Logging) u64;                      // MiB   → bytes
pub fn minFreeBytes(d: Disk) u64;
pub fn warnFreeBytes(d: Disk) u64;
pub fn updateIntervalSeconds(b: BlocklistUpdate) i64;    // hours → seconds

comptime {
    assertFits(u32, std.time.ns_per_ms, i96);            // timeouts
    assertFits(u16, 3600, i64);                          // session ttl
    assertFits(u16, 86400, i64);                         // retention, update interval
    assertFits(u32, 1024 * 1024, u64);                   // MiB conversions
}
```

`assertFits(FieldType, factor, Dest)` is a comptime helper that `@compileError`s unless `@as(u128, std.math.maxInt(FieldType)) * factor <= std.math.maxInt(Dest)`. No conversion in this file uses a `@intCast` on a value whose range has not been proven this way, and no conversion can return an error — the types make overflow impossible, which is stronger than checking at runtime. `std.Io.Duration` is nanoseconds in an `i96` (verified above), so the timeout conversions are trivially safe; the assertion is there so a later widening of a field type breaks the build.

### S2.5 password handling

- `web.password` is operator input in a hand-written bootstrap or import file.
- `web.password_hash` is the argon2id PHC string actually stored in `settings` under `web.password_hash`.
- Import/bootstrap rule (implemented in S5, specified here because it is model semantics):
  - `password != "" and password_hash != ""` → `error.PasswordAndHashBothSet`. Ambiguity in a security setting is refused, not guessed.
  - `password != ""` → hash it, store the hash, discard the plaintext.
  - `password == ""` → store `password_hash` verbatim (possibly `""`, which disables auth per PLAN §3.11).
- Export always emits `.password = ""` and the stored `.password_hash`. This is exactly what makes the round trip byte-stable: the second import takes the `password == ""` branch and stores the same hash, so the third export is identical to the second.
- Hashing uses `std.crypto.pwhash.argon2.strHash(password, .{ .allocator = gpa, .params = .owasp_2id, .mode = .argon2id, .encoding = .phc }, out_buf, io)` (verified: argon2.zig:591; `Params.owasp_2id` is `t=2, m=19 MiB, p=1`, argon2.zig:96). `owasp_2id` rather than `interactive_2id` (64 MiB) because PLAN §18 budgets under 100 MB total on a Pi 5. `out_buf` is 256 bytes, which comfortably holds a PHC-encoded argon2id string.
- **Not in this milestone:** verification, sessions, cookies, any login flow. Only the hash is produced and stored.

### S2.6 the validator

```zig
pub const Problem = struct {
    /// Dotted path into the config, e.g. "upstreams[2].url" or "web.port". Owned by Diagnostics.
    path: []const u8,
    /// Human-readable, e.g. "unknown group 'kids'". Owned by Diagnostics.
    message: []const u8,
    err: ValidateError,
};

pub const Diagnostics = struct {
    gpa: std.mem.Allocator,
    problems: std.ArrayList(Problem),

    pub fn init(gpa: std.mem.Allocator) Diagnostics;
    pub fn deinit(self: *Diagnostics) void;         // frees every path and message
    pub fn add(self: *Diagnostics, err: ValidateError, comptime fmt: []const u8, args: anytype) error{OutOfMemory}!void;
    /// Writes one "path: message" line per problem.
    pub fn writeAll(self: *const Diagnostics, w: *std.Io.Writer) std.Io.Writer.Error!void;
};

pub const ValidateError = error{
    NoUpstreams, BadUpstreamUrl, DuplicateUpstreamUrl,
    MissingDefaultGroup, DuplicateGroupName, UnknownGroup, EmptyGroupName,
    BadClientIp, DuplicateClientIp,
    BadClientPrefix, DuplicateClientPrefix,
    BadSourceUrl, DuplicateSourceUrl, EmptySourceName,
    UnknownSource, DuplicateGroupSource,
    BadRulePattern,
    BadLocalRecordName, BadLocalRecordValue, DuplicateLocalRecord,
    BadForwardZone, DuplicateForwardZone, BadResolverUrl,
    BadPort, BadTimeout, BadTtl, BadRetention, BadLogRotation, BadDiskThresholds,
    BadRateLimit, BadBindAddress, MissingCertPath, MissingKeyPath, MissingLogPath,
    PasswordAndHashBothSet,
    OutOfMemory,
};

/// Collects EVERY problem into `diags`, then returns the first one's `err`.
/// Pure: no Io, no SQLite, no clock. The allocator is only for diagnostic text.
pub fn validate(cfg: Config, diags: *Diagnostics) ValidateError!void;
```

The controlling requirement: **the validator must reject everything the schema would reject.** An import that passes validation and then dies on a `Constraint` mid-transaction gives the operator a SQLite error message instead of a line number. Concretely:

*Uniqueness* — one check per `UNIQUE` constraint in §11.2, each implemented with a `std.StringHashMapUnmanaged(void)` over the already-seen values:

| Constraint | Check |
|---|---|
| `groups.name UNIQUE` | `DuplicateGroupName`; also `EmptyGroupName` for `""` |
| `upstreams.url UNIQUE` | `DuplicateUpstreamUrl` |
| `clients.ip UNIQUE` | `DuplicateClientIp`, compared on the **canonical** form (see below) |
| `client_prefixes.prefix UNIQUE` | `DuplicateClientPrefix`, canonical form |
| `blocklist_sources.url UNIQUE` | `DuplicateSourceUrl` |
| `group_sources PRIMARY KEY (group,source)` | `DuplicateGroupSource` |
| `local_records UNIQUE(name,rtype,value)` | `DuplicateLocalRecord` |
| `forward_zones.zone UNIQUE` | `DuplicateForwardZone` |

`rules` has no `UNIQUE` constraint, so duplicate rules are accepted; export sorts them deterministically, so a duplicate still round-trips byte-stably.

Client IPs and prefixes are compared **after canonicalization** through `NetAddress.parse` + `format` / `Prefix.parse` + `format`, because `fd00::1` and `FD00:0:0:0:0:0:0:1` are the same row to a human but two distinct rows to `UNIQUE`. Import writes the canonical form, so the validator must detect the collision the same way.

*Foreign keys* — every `group` reference in **all four** referencing collections (`clients`, `client_prefixes`, `group_sources`, `rules`) must name a group present in `cfg.groups` (`UnknownGroup`), and every `group_sources.source_url` must name a source present in `cfg.blocklist_sources` (`UnknownSource`). `cfg.groups` must contain a group named `"default"` (`MissingDefaultGroup`) — §11.2 seeds it, §7.2 depends on it, and an import that dropped it would leave the server with nowhere to assign an unknown client.

*Parsing* — reuse, do not reimplement:

- upstream URLs → `transport.Endpoint.parse` (`BadUpstreamUrl`).
- `clients.ip` → `NetAddress.parse` (`BadClientIp`).
- `client_prefixes.prefix` → `Prefix.parse` (`BadClientPrefix`).
- `dns.bind_ipv4` must parse as an `.ip4`, `dns.bind_ipv6` and `web.bind`, `doh_server.bind`, `dot_server.bind` must parse as either family (`BadBindAddress`).
- every domain-shaped string — `rules.pattern` (with `*` segments removed first), `local_records.name`, a `cname` record's `value`, `forward_zones.zone` — through `dns.name.fromText`.
- `local_records.value` for `a` must parse as `.ip4`, for `aaaa` as `.ip6` (`BadLocalRecordValue`).
- `blocklist_sources.url` must be `http://` or `https://` with a non-empty host (`BadSourceUrl`); `blocklist_sources.name` non-empty (`EmptySourceName`).
- `forward_zones.resolver` through a `pub fn parseResolver(text: []const u8) ResolverError!Resolver` declared **in this file**: scheme `udp://` or `tcp://` (PLAN §6.5 permits plain transports for local infra), host an IP literal via `NetAddress.parse`, port 1–65535, nothing after the authority. `transport.Endpoint.parse` cannot be used — it rejects these schemes by design. Phase 5's `local/forward_zones.zig` imports `parseResolver` rather than writing a second one.
- Wildcard rule patterns: `kind == .wildcard` requires at least one label that is exactly `"*"`; `kind == .exact` requires no `*` at all. Every other label must satisfy `dns.name.fromText` when the `*` labels are substituted with a placeholder label. Matching semantics are Phase 5's; this is syntax only.

*Ranges* — all `BadPort` / `BadTimeout` / … as listed:

- `dns.port`, `web.port`, `doh_server.port`, `dot_server.port` in 1–65535 (0 is rejected).
- `upstream.connect_timeout_ms`, `read_timeout_ms`, `total_timeout_ms` each ≥ 100 and ≤ 120_000; `total_timeout_ms >= connect_timeout_ms` and `>= read_timeout_ms`.
- `dns.rate_limit` ≥ 1, `dns.rate_window_seconds` in 1–3600, `web.api_rate_limit_per_min` ≥ 1, `web.sse_max_connections_per_ip` ≥ 1 (`BadRateLimit`).
- `blocking.ttl` ≤ 86400, `cache.negative_ttl_max` ≤ 86400, `local_records.ttl` in 1–604800 (`BadTtl`).
- `logging.retention_days` ≥ 1, `logging.query_log_buffer_max` ≥ 1 (`BadRetention`).
- `logging.max_size_mb` ≥ 1 and `logging.max_files` ≥ 1 (`BadLogRotation`).
- `disk.min_free_mb <= disk.warn_free_mb`, both ≥ 1 (`BadDiskThresholds`).
- `web.session_ttl_hours` ≥ 1, `blocklist_update.interval_hours` ≥ 1.
- `cfg.upstreams` must contain at least one entry with `enabled = true` (`NoUpstreams`) — PLAN §12.2.

*Conditional* — `doh_server.enabled` or `dot_server.enabled` requires non-empty `cert_path` and `key_path` (`MissingCertPath`, `MissingKeyPath`); readability is `nxdns check`'s job, not the pure validator's. `logging.output == .file` requires a non-empty absolute `file_path` (`MissingLogPath`). `web.password != "" and web.password_hash != ""` → `PasswordAndHashBothSet`.

`validate` never stops at the first problem. It runs every check, appends every failure, and only then returns `diags.problems.items[0].err`. On success it returns without touching `diags`.

### S2.7 Tests (in-file)

- A default `Config` with one enabled upstream and a `default` group validates cleanly.
- One named test per `ValidateError` member (except `OutOfMemory`), each asserting the exact error and that the diagnostic path names the offending element (e.g. `"clients[1].ip"`).
- A config with **five** distinct problems yields five diagnostics and returns the first one's error.
- Duplicate detection across canonical forms: `clients` holding both `"fd00::1"` and `"FD00:0:0:0:0:0:0:1"` is `DuplicateClientIp`.
- Unknown group referenced from each of the four collections in turn.
- Unknown source referenced from `group_sources`.
- `toSettings`/`fromSettings` round-trip on a non-default `Config` reproduces every scalar field.
- `toSettings` on a default `Config` produces exactly the literal expected key list.
- An unknown settings key increments `unknown_keys` and does not error; a malformed integer value is `error.BadSettingValue`.
- `LogLevel.err` encodes as `"error"` and decodes back.
- Every `toDb`/`fromDb` enum pair round-trips over all tags.
- The `parseResolver` table: `udp://192.168.1.1:53` ok; `tcp://[fd00::1]:53` ok; `https://x/` → error; `udp://host.name:53` → error (IP literal required); `udp://1.1.1.1` → error (port required); `udp://1.1.1.1:0` → error.

### S2.8 Acceptance criteria

- [ ] `zig fmt --check` and `zig ast-check` clean on both files.
- [ ] Neither file imports `std.Io` for anything but `std.Io.Writer` (diagnostics) and
      `std.Io.Duration` (the timeout conversions). No `Io` value is a parameter anywhere.
- [ ] Neither file imports `storage/db.zig`.
- [ ] Every bullet in S2.7 exists as a named test.
- [ ] The comptime `assertFits` block is present and every conversion function is covered by it.

---

## Session S3: `src/storage/config_schema.zig`, `src/storage/migrations.zig`, `src/storage/querylog_schema.zig`

### S3.1 `config_schema.zig`

Holds the DDL, verbatim from PLAN §11.2, as the first migration step:

```sql
CREATE TABLE schema_version (version INTEGER NOT NULL);

CREATE TABLE groups (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    safe_search INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO groups (id, name) VALUES (1, 'default');

CREATE TABLE clients (
    id INTEGER PRIMARY KEY,
    ip TEXT NOT NULL UNIQUE,            -- canonical text form (v4 dotted / v6 RFC 5952)
    name TEXT,
    group_id INTEGER NOT NULL REFERENCES groups(id),
    hand_edited INTEGER NOT NULL DEFAULT 0,
    first_seen INTEGER NOT NULL,
    last_seen INTEGER NOT NULL
);

CREATE TABLE client_prefixes (
    id INTEGER PRIMARY KEY,
    prefix TEXT NOT NULL UNIQUE,        -- "192.168.1.0/24", "fd00:abcd::/48"
    group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    priority INTEGER NOT NULL DEFAULT 100
);

CREATE TABLE upstreams (
    id INTEGER PRIMARY KEY,
    url TEXT NOT NULL UNIQUE,
    priority INTEGER NOT NULL DEFAULT 100,
    enabled INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE blocklist_sources (
    id INTEGER PRIMARY KEY,
    url TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    enabled INTEGER NOT NULL DEFAULT 1,
    is_suggested INTEGER NOT NULL DEFAULT 0,
    last_updated INTEGER,
    domain_count INTEGER NOT NULL DEFAULT 0,
    wildcard_count INTEGER NOT NULL DEFAULT 0,
    skipped_regex_count INTEGER NOT NULL DEFAULT 0,
    checksum TEXT
);

CREATE TABLE group_sources (
    group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    source_id INTEGER NOT NULL REFERENCES blocklist_sources(id) ON DELETE CASCADE,
    PRIMARY KEY (group_id, source_id)
);

CREATE TABLE rules (
    id INTEGER PRIMARY KEY,
    group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    pattern TEXT NOT NULL,
    kind TEXT NOT NULL CHECK(kind IN ('exact','wildcard')),
    action TEXT NOT NULL CHECK(action IN ('allow','block')),
    created_at INTEGER NOT NULL
);

CREATE TABLE local_records (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    rtype TEXT NOT NULL CHECK(rtype IN ('A','AAAA','CNAME')),
    value TEXT NOT NULL,
    ttl INTEGER NOT NULL DEFAULT 300,
    UNIQUE(name, rtype, value)
);

CREATE TABLE forward_zones (
    id INTEGER PRIMARY KEY,
    zone TEXT NOT NULL UNIQUE,
    resolver TEXT NOT NULL              -- "udp://192.168.1.1:53"
);

CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
```

Exported as `pub const ddl_v1: [:0]const u8 = @embedFile(...)` or a multiline string literal — either is fine, but the SQL text must be byte-identical to the block above.

Also in this file, because they belong with the schema and every other session needs them:

```zig
/// Child-before-parent. Used by import's wipe step; correct under `foreign_keys = ON`.
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign keys, so their
/// position is free; `groups` and `blocklist_sources` must come last, after every referrer.
pub const delete_order = [_][]const u8{
    "group_sources", "rules", "client_prefixes", "clients",
    "upstreams", "local_records", "forward_zones", "settings",
    "blocklist_sources", "groups",
};

/// Every table whose emptiness defines "the database has never been configured" (S5.2).
pub const content_tables = [_][]const u8{
    "clients", "client_prefixes", "upstreams", "blocklist_sources",
    "group_sources", "rules", "local_records", "forward_zones", "settings",
};
```

Check both lists against the DDL before using them and report any correction in the completion report — a wrong delete order surfaces as a `Constraint` error inside import's transaction, which is exactly the failure mode this milestone is meant to make impossible.

### S3.2 `migrations.zig`

```zig
pub const Step = struct { version: u32, sql: [:0]const u8 };

pub const steps = [_]Step{
    .{ .version = 1, .sql = config_schema.ddl_v1 },
};

pub const target_version: u32 = steps[steps.len - 1].version;

pub const Error = db.Error || error{ SchemaTooNew, SchemaCorrupt };

/// Reads the stamped version, applies every newer step in ONE transaction, stamps the result.
/// `db` must already have had `applyPragmas` called (foreign_keys is a no-op inside a transaction).
pub fn migrate(database: *db.Db) Error!u32;

/// Seam for tests: same logic against an injected step list.
pub fn migrateSteps(database: *db.Db, list: []const Step) Error!u32;
```

Algorithm:

1. Assert at comptime that `steps` versions are `1, 2, 3, …` with no gaps and strictly increasing.
2. Read the current version: `SELECT count(*) FROM sqlite_schema WHERE type='table' AND name='schema_version'`. Zero → current version is `0`. Otherwise `SELECT version FROM schema_version` — it must yield **exactly one row**; zero rows or more than one row is `error.SchemaCorrupt`, never a guess.
3. `current > target` → `error.SchemaTooNew`. Distinct from every other error so the CLI can print "config.db is at schema version N; this nxdns binary supports N-1. Install a newer nxdns." A database from the future is never silently accepted, and never downgraded.
4. `current == target` → return `current`. No transaction, no write.
5. `var tx = try Tx.begin(database); errdefer tx.rollback();` then, for each step with `version > current`, in ascending order, `database.exec(step.sql)`. Then `DELETE FROM schema_version;` and `INSERT INTO schema_version (version) VALUES (?)` with the target. Then `tx.commit()`. SQLite runs DDL transactionally, so a failing step leaves the file exactly as it was.
6. Return the new version.

Reading the version happens **outside** the transaction in step 2 and is re-read **inside** it before applying, so two processes starting simultaneously cannot both apply step N (the second sees the stamped version under `BEGIN IMMEDIATE` and finds nothing to do).

Tests (in-file, `:memory:`):

- Fresh database → `migrate` returns `target_version`; every table in the DDL exists (assert by counting `sqlite_schema` rows of `type='table'` matching a literal name list); `groups` holds exactly one row, `id = 1`, `name = 'default'`.
- `migrate` twice is idempotent: the second call writes nothing (`sqlite3_changes` after it, or a before/after comparison of `sqlite_schema`) and returns the same version.
- A database stamped `target_version + 1` → `error.SchemaTooNew`, and the stamped version is unchanged afterwards.
- `schema_version` with two rows → `error.SchemaCorrupt`.
- Rollback proof through `migrateSteps`: a two-step list whose second step is invalid SQL leaves the stamped version at the value before the call and leaves the second step's table absent.
- Stepwise upgrade through `migrateSteps`: apply a one-step list, then a two-step list with the same first step, and assert the second step's effect is present and the version is 2. This is the "upgrade = install binary, restart" proof PLAN §20.11 asks for.

### S3.3 `querylog_schema.zig`

The DDL, verbatim from PLAN §11.3:

```sql
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,
    block_reason TEXT,
    response_time_us INTEGER,
    cache_hit INTEGER,
    upstream TEXT
);
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);
```

The fingerprint is derived from the DDL text, not maintained by hand:

```zig
pub const ddl: [:0]const u8 = ...;

/// PRAGMA user_version is a signed 32-bit field. Deriving the fingerprint from the DDL means
/// editing the schema automatically invalidates every existing file — which is exactly the
/// policy (PLAN §3.7: querylog.db is never migrated).
pub const fingerprint: i32 = @bitCast(std.hash.Crc32.hash(ddl));
```

`std.hash.Crc32.hash(bytes) u32` verified at `lib/std/hash/crc/impl.zig:96` via `lib/std/hash.zig:10`.

```zig
pub const RecreateReason = enum { missing, corrupt, not_a_database, quick_check_failed, fingerprint_mismatch };

pub const OpenResult = struct {
    database: db.Db,
    recreated: ?RecreateReason,   // non-null feeds a counter and the /api/health rollup in Phase 8
};

pub const Error = db.Error || error{AsideNameCollision} ||
    std.Io.Dir.RenameError || std.Io.Dir.DeleteFileError || std.Io.Dir.AccessError;

/// Opens `<dir>/querylog.db`, recreating it if and only if it is genuinely unusable.
pub fn open(io: std.Io, dir: std.Io.Dir, path: [:0]const u8) Error!OpenResult;
```

Open sequence:

1. `dir.access(io, path, .{})`. `error.FileNotFound` → create fresh (reason `.missing`, which is not an anomaly — do not log it as a warning on first start; log at `info`).
2. Open with `.read_write_existing`, `applyPragmas`.
3. `PRAGMA quick_check` — a single row whose text must be `"ok"`. Anything else → `.quick_check_failed`. `quick_check` rather than `integrity_check` because it skips the (expensive) index-vs-table cross-check while still catching structural damage, and a damaged index on an expendable log is not worth a multi-second startup scan.
4. `PRAGMA user_version` must equal `fingerprint`, else `.fingerprint_mismatch`.
5. Otherwise return the handle with `recreated = null`.

**The recreate predicate is a whitelist, not a fallback.** Recreate only for: `error.Corrupt` → `.corrupt`; `error.NotADb` → `.not_a_database`; a failed `quick_check`; a fingerprint mismatch; a missing file.

Every other error **propagates unchanged and the file is not touched**. Named explicitly, because each one has destroyed data in some other project: `error.Busy` and `error.Locked` (another process holds the write lock — waiting is right, deleting is catastrophic), `error.OutOfMemory` (this process's problem), `error.CantOpen` (usually a permissions or missing-directory problem — recreating would not help and would mask it), `error.ReadOnly`, `error.IoErr`, `error.Full`, `error.Perm`, `error.Auth`, `error.Canceled`. A test exists for `Busy`/`Locked`/`OutOfMemory` asserting the file's bytes are unchanged.

Recreate sequence, in this order:

1. Close the handle (if one was opened) so SQLite checkpoints and drops `-wal`/`-shm` where it can.
2. Build the aside name `querylog.db.corrupt-<unix_seconds>` from `std.Io.Clock.real.now(io).toSeconds()`. Rename with **`dir.renamePreserve(path, dir, aside, io)`** (verified: `RENAME_NOREPLACE`, returns `error.PathAlreadyExists` when taken). On `error.PathAlreadyExists`, retry with `-<seconds>-1`, `-2`, … up to 100 attempts, then `error.AsideNameCollision`. The aside name must never overwrite a previously saved corrupt file — two recreates in the same second are not hypothetical on a boot loop.
3. `dir.deleteFile(io, "querylog.db-wal")` and `dir.deleteFile(io, "querylog.db-shm")`, tolerating **only** `error.FileNotFound`. This step is not optional: a stale WAL left beside the renamed database would be replayed into the freshly created file and corrupt it immediately. If either delete fails for any other reason, propagate — do not create the new database on top of a half-cleaned state.
4. Create fresh with `.read_write_create`, `applyPragmas`, `exec(ddl)`, and `PRAGMA user_version = <fingerprint>` inside one transaction.
5. `std.log.warn` with the reason and the aside path. `.missing` logs at `info` instead.

Tests — these need real files, so they live in S7's integration file; S3 writes them there is **not** permitted (S7 owns that file). Instead S3 exposes the seams the tests need and lists the required cases in its completion report:

- fresh directory → created, `recreated == .missing`, `user_version == fingerprint`;
- reopen → `recreated == null`;
- stamp a wrong `user_version` → recreated with `.fingerprint_mismatch`, aside file exists;
- write garbage bytes over the file → recreated with `.not_a_database` or `.corrupt`;
- two recreates within one second produce two distinct aside files;
- a pre-existing stale `querylog.db-wal` is removed by the recreate.

In-file tests S3 *can* write (`:memory:` only): `fingerprint` is stable across calls; `ddl` executes cleanly against a fresh memory database and creates `domains`, `query_log` and the three indexes.

### S3.4 Acceptance criteria

- [ ] `zig fmt --check` and `zig ast-check` clean on all three files.
- [ ] The DDL in `config_schema.zig` is byte-identical to PLAN §11.2; the DDL in
      `querylog_schema.zig` is byte-identical to PLAN §11.3.
- [ ] `migrate` applies everything in one transaction and refuses a newer database with
      `error.SchemaTooNew`.
- [ ] Every `migrations.zig` test bullet exists as a named test.
- [ ] The recreate predicate is a positive whitelist; `Busy`, `Locked` and `OutOfMemory` propagate.
- [ ] `renamePreserve` is used for the aside, with uniquifying retries.
- [ ] `-wal` and `-shm` removal precedes creating the replacement.

---

## Session S4: `src/storage/repositories/*.zig`

Seven files, one per PLAN §5 name that is in scope. `queries_repo.zig` is Phase 6 and is **not** created here.

| File | Tables |
|---|---|
| `groups_repo.zig` | `groups`, `group_sources` |
| `clients_repo.zig` | `clients`, `client_prefixes` |
| `upstreams_repo.zig` | `upstreams` |
| `sources_repo.zig` | `blocklist_sources` |
| `rules_repo.zig` | `rules` |
| `local_repo.zig` | `local_records`, `forward_zones` |
| `settings_repo.zig` | `settings` |

### S4.1 Uniform API

Each repository exposes, for each of its tables:

```zig
pub fn listX(database: *db.Db, gpa: std.mem.Allocator) db.Error!std.ArrayList(model.X);
pub fn freeX(gpa: std.mem.Allocator, items: []const model.X) void;
pub fn insertX(database: *db.Db, item: model.X, ctx: InsertContext) db.Error!void;
pub fn deleteAllX(database: *db.Db) db.Error!void;
pub fn countX(database: *db.Db) db.Error!i64;
```

`InsertContext` carries what the model deliberately omits: `now: i64` (for `first_seen`, `last_seen`, `created_at`) and the id lookups (`group_ids: *const std.StringHashMapUnmanaged(i64)`, `source_ids: …`) that turn a name reference into the `group_id`/`source_id` column. Building those maps is the caller's job (S5), because only the caller knows the ids it just inserted.

Only these five operations exist. Update-by-id, delete-by-id and paged reads are Phase 8's REST surface; adding them now would be untested, unused generality (AGENTS.md).

### S4.2 Ordering

Every `list` uses an `ORDER BY` whose trailing columns are unique, so export is byte-stable:

| List | `ORDER BY` |
|---|---|
| groups | `name` |
| group_sources (joined to names) | `g.name, s.url` |
| clients | `ip` |
| client_prefixes | `prefix` |
| upstreams | `priority, url` |
| blocklist_sources | `url` |
| rules | `group_id, kind, action, pattern, id` |
| local_records | `name, rtype, value` |
| forward_zones | `zone` |
| settings | `key` |

`rules` has no unique tuple, hence the trailing `id`. `upstreams` sorts by `priority` first because that is the operationally meaningful order (it matches `Pool.init`'s expectation), and `url` breaks ties uniquely.

`group_sources` and the `group`-referencing collections are read through a join so the list yields model values holding **names**, not ids:

```sql
SELECT g.name, s.url FROM group_sources gs
  JOIN groups g ON g.id = gs.group_id
  JOIN blocklist_sources s ON s.id = gs.source_id
 ORDER BY g.name, s.url
```

`clients` list applies `WHERE hand_edited = 1` (S2.2's rationale: auto-materialized clients are runtime state and must not appear in an export). `countClients` counts **all** rows — the emptiness predicate in S5 needs the true count.

### S4.3 Memory discipline

- Returned strings are heap-owned copies via `Stmt.columnTextAlloc`. `columnText` borrows from SQLite and is invalidated by the next `step`; a repository that returns a borrowed slice is a use-after-free waiting for the second row.
- Every `list` builds into a `std.ArrayList(T)` with an `errdefer` that frees **both** every element already appended and every string of the partially-built element. The two list-level `errdefer`s run in reverse declaration order, so the free pass is declared **after** `out.deinit` to run **before** it — the other order reads `out.items` once the backing array is already released:

  ```zig
  var out: std.ArrayList(model.Group) = .empty;
  errdefer out.deinit(gpa);
  errdefer freeGroups(gpa, out.items);
  while (try stmt.step()) {
      const name = try stmt.columnTextAlloc(gpa, 0);
      errdefer gpa.free(name);
      try out.append(gpa, .{ .name = name, .safe_search = stmt.columnBool(1) });
  }
  ```

  > **Correction (milestone 18, ruling 2).** This sample originally declared the two `errdefer`s in
  > the opposite order, which frees the backing array before the pass that walks it — a
  > use-after-free. Every repository written from it silently corrected the order; the sample above
  > is the corrected one. Milestone 18 moved the whole choreography into
  > `src/storage/repositories/crud.zig` as `listRows`/`freeRows`, so a new repository delegates to
  > that helper instead of copying this shape.
- `freeX` frees every heap string in every element and is idempotent against an empty slice.
- The callers of `list` may pass an arena; `freeX` must still be correct against a general-purpose allocator, because the tests use `std.testing.allocator`.

### S4.4 Tests (in-file, `:memory:`)

Each repository gets, at minimum:

- Round trip: `migrate` a memory database, insert three rows, `list` them, assert the exact order produced by the specified `ORDER BY`, assert every field survived.
- `deleteAll` empties the table and `count` reflects it.
- A leak-safety test through `std.testing.checkAllAllocationFailures(std.testing.allocator, testListImpl, .{})`, where `testListImpl` seeds rows and calls `list` + `free`. This is the runnable form of "leak-safe error paths"; a missing `errdefer` fails it.
- `clients_repo`: a `hand_edited = 0` row is absent from `listClients` but counted by `countClients`.
- `settings_repo`: keys sort ascending; a value containing a `'` round-trips (proving parameter binding, not string concatenation, is used).
- `groups_repo`: `listGroupSources` yields names, not ids, and rejects nothing — the FK guarantees the join is total.

### S4.5 Acceptance criteria

- [ ] `zig fmt --check` and `zig ast-check` clean on all seven files.
- [ ] Every list statement's `ORDER BY` matches the table in S4.2 exactly.
- [ ] No repository returns a slice borrowed from SQLite.
- [ ] Every repository has a passing `checkAllAllocationFailures` test.
- [ ] No SQL is built by string concatenation of a value; every value is bound.
- [ ] No update-by-id / delete-by-id / pagination functions exist.

---

## Session S5: `src/config/bootstrap.zig`, `src/config/import.zig`, `src/config/export.zig`

### S5.1 `std.zon` ownership — verified verdict, and the rule that follows

**Verified in `/home/mokhtar/app/zig/lib/std/zon/parse.zig` at tag 0.16.0:**

`Parser.parseStruct` fills absent fields from the struct's default value by copying the default straight through (`parse.zig:874–888`):

```zig
inline for (field_found, 0..) |found, i| {
    if (!found) {
        const field_info = field_infos[i];
        if (field_info.default_value_ptr) |default| {
            const typed: *const field_info.type = @ptrCast(@alignCast(default));
            @field(result, field_info.name) = typed.*;
        } else { ... }
    }
}
```

For a `[]const u8` field with a default like `"0.0.0.0"`, the resulting slice points into the binary's read-only data. `std.zon.parse.free` (`parse.zig:412–456`) recurses over **every** field of a struct with no record of which were parsed and which were defaulted, and its slice arm is `for (value) |item| free(gpa, item); gpa.free(value);`. `std.mem.Allocator.free` (`lib/std/mem/Allocator.zig`) returns early only for zero length; for a non-empty slice it does `@memset(bytes, undefined)` and then `rawFree`. Applied to a defaulted string that lives in rodata, that is a write to read-only memory followed by a free of a pointer the allocator never owned.

**Verdict: calling `std.zon.parse.free` on a parsed `Config` with defaulted string fields is a crash, and the nxdns config model has many non-empty string defaults.** (The `free_on_error` path at `parse.zig:830` is *not* affected — its `errdefer` walks only `fields.names[0..initialized]`, the fields actually present in the source. The hazard is exclusively in the caller-invoked `free`.)

**Rule, binding on this milestone and every later one:** parse into an `std.heap.ArenaAllocator`, free the arena, **never** call `std.zon.parse.free` on a `Config`. A comment stating this and citing `parse.zig:874` goes at the parse site in `import.zig`. A test asserts the shape of the rule by parsing a source that omits every optional field and then destroying the arena — under `std.testing.allocator` a stray `parse.free` would fail the test.

### S5.2 the emptiness predicate

Shared by `bootstrap` and `import --force`-less mode; lives in `import.zig` and is exported:

```zig
/// A database is "never configured" when migrations have run and nothing else has.
/// Migrations themselves create `schema_version` and seed `groups(1,'default')`, so
/// "no rows anywhere" is the wrong test.
pub fn isEmpty(database: *db.Db) db.Error!bool;
```

True iff **all** of the following hold:

1. Every table in `config_schema.content_tables` has `count(*) = 0`. (That is `clients`, `client_prefixes`, `upstreams`, `blocklist_sources`, `group_sources`, `rules`, `local_records`, `forward_zones`, `settings`.)
2. `SELECT count(*) FROM groups` is exactly 1.
3. That row satisfies `id = 1 AND name = 'default' AND safe_search = 0`.

Any deviation makes the database non-empty. Note that `countClients` here counts every row including auto-materialized ones — a server that has answered one query is configured enough that a bootstrap file must not overwrite it.

### S5.3 `import.zig`

```zig
pub const Options = struct { force: bool = false };

pub const Error = db.Error || validate.ValidateError || std.Io.Dir.ReadFileAllocError ||
    error{ DatabaseNotEmpty, ConfigTooLarge, ParseZon, PasswordAndHashBothSet };

pub const max_config_bytes = 4 * 1024 * 1024;

/// Reads, parses, validates, then replaces the database contents. Nothing is written to the
/// database and no file is created until every check has passed.
pub fn importFile(
    io: std.Io,
    gpa: std.mem.Allocator,
    database: *db.Db,
    dir: std.Io.Dir,
    path: []const u8,
    options: Options,
    diags: *validate.Diagnostics,
) Error!void;

/// The half that bootstrap reuses: an already-parsed, already-validated config into the database,
/// all or nothing. `now` is the caller's timestamp for the runtime columns.
pub fn applyToDb(
    io: std.Io,
    gpa: std.mem.Allocator,
    database: *db.Db,
    cfg: model.Config,
    now: i64,
    options: Options,
) Error!void;
```

`importFile` order — the order is the specification:

1. `dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0)` → a `[:0]u8`. `std.zon.parse` requires the sentinel; `readFileAlloc` cannot supply one. `error.StreamTooLong` maps to `error.ConfigTooLarge`.
2. `var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit();` then `std.zon.parse.fromSliceAlloc(model.Config, arena.allocator(), source, &zon_diag, .{})`. Never `parse.free`. `zon_diag` is a `std.zon.parse.Diagnostics`; on `error.ParseZon` its `{f}` rendering goes into `diags` verbatim so the operator sees line and column.
3. `validate.validate(cfg, diags)` — every problem collected before returning.
4. `applyToDb`.

`applyToDb`:

1. `var tx = try db.Tx.begin(database); errdefer tx.rollback();`
2. **Inside** the transaction, `if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty;`. Checking before `BEGIN IMMEDIATE` would be a TOCTOU window against a concurrently starting process; `BEGIN IMMEDIATE` already holds the write lock, so the check and the writes are one atomic unit.
3. Delete every table in `config_schema.delete_order` (children first, so `foreign_keys = ON` never fires).
4. Insert `groups`, `"default"` **first and with an explicit `id = 1`**, then the rest in list order. §11.2 seeds group 1 as `default` and §7.2's fallback assignment depends on it; letting an import renumber it would silently move every unassigned client. Build the `name → id` map from `sqlite3_last_insert_rowid` as you go.
5. Insert `blocklist_sources`, building the `url → id` map.
6. Insert `clients` (`hand_edited = 1`, `first_seen = last_seen = now`), `client_prefixes`, `upstreams`, `group_sources`, `rules` (`created_at = now`), `local_records`, `forward_zones` — each resolving names through the maps built above.
7. Resolve the password per S2.5 (`strHash` when `password != ""`), then `toSettings` and insert every pair. `web.password` is never a settings row.
8. `tx.commit()`.

No filesystem write happens anywhere in `applyToDb`, and `importFile` opens the input read-only. The only file this milestone creates outside the data directory is `export --out`.

### S5.4 `export.zig`

```zig
/// Reads every repository into `arena` and assembles a Config. Deterministic by construction:
/// every list is ordered per S4.2 and every scalar comes from the settings map.
pub fn readConfig(database: *db.Db, arena: std.mem.Allocator) Error!model.Config;

/// Canonical ZON. Deterministic for a given Config — no timestamps, no host names, no counters.
pub fn writeConfig(cfg: model.Config, w: *std.Io.Writer) std.Io.Writer.Error!void;

/// Atomic, owner-only. The exported file contains `web.password_hash`, so 0600 is not optional.
pub fn writeToFile(io: std.Io, gpa: std.mem.Allocator, database: *db.Db, dir: std.Io.Dir, path: []const u8) Error!void;

pub fn writeToWriter(gpa: std.mem.Allocator, database: *db.Db, w: *std.Io.Writer) Error!void;
```

`writeConfig` emits a fixed two-line header comment and then the value:

```
// nxdns configuration
// generated by `nxdns export` — the database is the source of truth
```

The header carries **no** timestamp, version or hostname. Any of those would break the byte-stable round trip and would turn a config diff into noise. ZON permits comments, and `std.zon.parse` reads through them.

Serialization: `std.zon.stringify.serialize(cfg, .{ .whitespace = true, .emit_default_optional_fields = true }, w)`. Emitting defaults explicitly makes the exported file a complete, self-documenting record of the running configuration and makes the round trip independent of a later change to a default value.

`writeToFile` uses the verified atomic helper:

```zig
var af = try dir.createFileAtomic(io, path, .{ .permissions = .fromMode(0o600), .replace = true });
defer af.deinit(io);
var fw = af.file.writer(io, &buf);
try writeConfig(cfg, &fw.interface);
try fw.interface.flush();
try af.file.sync(io);
try af.replace(io);
```

The session must read `Io/Threaded.zig`'s `dirCreateFileAtomic` implementation and **confirm that the temporary file is created in the destination's directory** — a temporary on another filesystem cannot be renamed atomically into place. Record the finding in the completion report; if it is not, fall back to an explicit temp-plus-`rename` in the same directory (`rename` replaces, verified Dir.zig:1085) and say so.

### S5.5 `bootstrap.zig`

```zig
pub const Outcome = enum { seeded, db_already_configured, no_config_file };

/// First-start seeding (PLAN §3.5). Called by `nxdns run` before serving.
pub fn bootstrap(
    io: std.Io,
    gpa: std.mem.Allocator,
    database: *db.Db,
    dir: std.Io.Dir,
    config_path: []const u8,
    diags: *validate.Diagnostics,
) Error!Outcome;
```

1. `dir.access(io, config_path, .{})` → `error.FileNotFound` returns `.no_config_file`. An absent bootstrap file is the normal steady state, not a problem: log at `info` and continue. Any other access error propagates.
2. `if (!try import.isEmpty(database)) return .db_already_configured;` — log at `info` ("configuration file ignored; the database is already configured"), do not read the file. PLAN §3.5: subsequent starts ignore the file.
3. Otherwise `import.importFile(..., .{ .force = false }, diags)`. A file that is present but unreadable, unparseable, or invalid is an **error** — the operator wrote it, meant it, and starting with silent defaults instead would be the exact silent-failure mode PLAN §1.3 exists to prevent. Return `.seeded` on success.

There is no second copy of the seeding logic: bootstrap is a policy wrapper over `importFile`.

### S5.6 Tests

In-file (`:memory:`, no filesystem):

- `isEmpty` on a freshly migrated database is true; after one `settings` row it is false; after one auto-materialized `clients` row (`hand_edited = 0`) it is false; after renaming group 1 it is false.
- `applyToDb` on a non-empty database without `force` returns `error.DatabaseNotEmpty` and leaves every row untouched (compare a full dump before and after).
- `applyToDb` with a config whose insert fails mid-way (inject via a group name exceeding no constraint but a `local_records` row duplicating an earlier one — validation would catch it, so instead call `applyToDb` directly with an invalid config that bypasses `validate`) leaves the database exactly as it was. This is the all-or-nothing proof and it must not go through `importFile`.
- `readConfig` → `writeConfig` → `parse` → `applyToDb` → `readConfig` produces an equal `Config`.
- **Byte-stable round trip**: seed a memory database, `writeToWriter` into buffer A, create a second memory database, import buffer A, `writeToWriter` into buffer B, `try std.testing.expectEqualStrings(A, B)`. This is PLAN §20.8 as a runnable check.
- Parse a ZON source omitting every optional field into an arena, destroy the arena, and assert no leak under `std.testing.allocator` (the S5.1 rule, as a test).
- `password` set → the stored `web.password_hash` starts with `"$argon2id$"` and `settings` has no `web.password` key; `password` and `password_hash` both set → `error.PasswordAndHashBothSet`.
- `import` of a config with a validation error writes nothing: assert `isEmpty` still true.

Filesystem cases (`writeToFile` permissions and atomicity, `bootstrap`'s three outcomes against real files) belong to S7.

### S5.7 Acceptance criteria

- [ ] `zig fmt --check` and `zig ast-check` clean on all three files.
- [ ] `std.zon.parse.free` appears nowhere in the repository (`grep` it).
- [ ] The arena rule is documented at the parse site with the `parse.zig:874` citation.
- [ ] The non-empty check is inside the transaction.
- [ ] Validation completes before the first `Tx.begin` and before any file is created.
- [ ] The byte-stable round-trip test passes.
- [ ] `bootstrap` has one code path into the database, and it is `importFile`.

---

## Session S6: `src/cli.zig`, `src/main.zig`

`main.zig` becomes a thin shell: parse arguments, dispatch, return an exit code. Everything testable lives in `cli.zig`.

### S6.1 argument parsing (pure)

```zig
pub const Command = union(enum) {
    run: Paths,
    check: CheckArgs,
    export_: ExportArgs,
    import_: ImportArgs,
    version,
    help,
};

pub const Paths = struct {
    data_dir: []const u8 = "/var/lib/nxdns",        // PLAN §3.13
    config: []const u8 = "/etc/nxdns/config.zon",
};

pub const CheckArgs  = struct { paths: Paths = .{}, config_explicit: bool = false };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };

pub const ParseError = error{ UnknownCommand, UnknownFlag, MissingValue, MissingArgument, TooManyArguments };

/// `argv` excludes the program name. Slices borrow from `argv`.
pub fn parseArgs(argv: []const []const u8) ParseError!Command;
```

Accepted forms:

```
nxdns run     [--data-dir DIR] [--config FILE]
nxdns check   [--data-dir DIR] [--config FILE]
nxdns export  [--data-dir DIR] [--out FILE]
nxdns import  FILE [--force] [--data-dir DIR]
nxdns version
nxdns help | --help | -h
```

`--flag=value` and `--flag value` both work. `config_explicit` records whether `--config` was given, because `check` needs to distinguish "the operator named a file" from "the default path happens to exist" (S6.4). Unknown flags and missing values are `ParseError`, not silent ignores.

Exit codes: `0` success, `1` a runtime failure (I/O, database), `2` a validation or check failure, `64` a usage error (matching the existing milestone-1 convention).

### S6.2 path assembly

```zig
pub const DataDir = struct {
    dir: std.Io.Dir,
    config_db_path: [:0]const u8,     // "<data_dir>/config.db"
    querylog_db_path: [:0]const u8,   // "<data_dir>/querylog.db"

    /// Creates `<data_dir>` (and parents) with mode 0700 if absent, then opens it.
    pub fn open(io: std.Io, gpa: std.mem.Allocator, data_dir: []const u8, create: bool) !DataDir;
    pub fn close(self: *DataDir, io: std.Io, gpa: std.mem.Allocator) void;
};
```

- Directory creation uses `Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700))`. Plain `createDirPath` hardcodes `0o777` (verified Dir.zig:843) and must not be used here — the data directory holds `config.db`, which holds `web.password_hash`.
- `config.db` must end up mode 0600. SQLite creates it itself, at `0644 & ~umask`. The order matters:

  1. `db.open(config_db_path, .{ .mode = .read_write_create })` — this creates the file.
  2. `dir.setFilePermissions(io, "config.db", .fromMode(0o600), .{})`.
  3. `applyPragmas` — this is what creates `config.db-wal` and `config.db-shm`, and SQLite copies the **main database file's** permissions onto them. Setting 0600 first therefore gets the sidecars for free; doing it in the other order leaves them world-readable.

  Encode this ordering in `DataDir.open` with a comment stating the reason, and cover it in S7.
- `export --out` is written relative to the current working directory (an operator running `nxdns export --out backup.zon` means the shell's directory), not the data directory.

### S6.3 entry functions

```zig
pub const Runner = struct { io: std.Io, gpa: std.mem.Allocator, out: *std.Io.Writer, err: *std.Io.Writer };

pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8;
pub fn runExport(r: Runner, args: ExportArgs) u8;
pub fn runImport(r: Runner, args: ImportArgs) u8;
pub fn runVersion(r: Runner) u8;
pub fn usage(w: *std.Io.Writer) void;
```

Every one takes its writers as parameters so tests can capture output into `std.Io.Writer.Allocating`. `main.zig` builds the `Runner` from `std.process.Init` (`init.io`, `init.gpa`, `std.Io.File.stdout()/stderr()`), calls `parseArgs`, dispatches, and returns the code. `main.zig` contains no logic beyond that — no database calls, no formatting decisions.

`run` prints `not implemented` and returns 2, unchanged from milestone 1. Wiring the server to the configuration is Phase 7.

### S6.4 `nxdns check`

The point of `check` is that an operator sees **every** problem in one run. It never returns early.

1. Choose what to check:
   - `--config FILE` given → validate that file (parse it into an arena, then `validate`).
   - otherwise, if `<data_dir>/config.db` exists → migrate it (a `check` on a database a version behind should still work), `export.readConfig`, then `validate`.
   - otherwise, if the default `/etc/nxdns/config.zon` exists → validate that file.
   - otherwise → print "nothing to check: no config.db in <dir> and no <path>" and return 2. Print which source was used, so the answer is never ambiguous.
2. Run `validate` and print **every** diagnostic through `Diagnostics.writeAll`, one `path: message` line each. Do not stop at the first.
3. Certificates: for each of `doh_server` and `dot_server` that is `enabled`, `dir.access(io, cert_path, .{})` and the same for `key_path`; print a `FAIL` line per unreadable path. Then `dir.statFile(io, key_path, .{})` and, if `stat.permissions.toMode() & 0o077 != 0`, print a `WARN` line — PLAN §19 requires TLS keys readable by the service user only. A warning does not change the exit code; an unreadable file does.
4. Upstream probe (only when `probe` is true — `main` passes true, unit tests pass false): for **each** enabled upstream, build its `transport.Endpoint`, its `DohClient` or `DotClient`, and a `pool.Pool` with that **single** entry, then `exchange` a hand-built `A example.com` query with `attempt_timeout` from `model.totalTimeout(cfg.upstream)`. One `Pool` per upstream rather than one pool over all of them, because the pool's job is failover — it would report success as soon as any upstream answered, and a broken upstream would stay invisible. Reusing the pool (rather than calling the client directly) keeps the deadline, cancellation and health machinery identical to what the server will do. Read `Pool.snapshot` for the error text and print one `OK`/`FAIL` line per upstream with the endpoint URL and, on failure, `snapshot.last_error`.
5. Exit `0` if there were no `FAIL` lines and validation passed, else `2`. Warnings alone keep `0`.

The probe leaves the machine, so it is `-Dlive` territory: `runCheck(r, args, false)` is what the default test suite exercises, and the probing path gets one `-Dlive` test in S7.

### S6.5 Tests (in-file, pure)

- `parseArgs` table: every accepted form above; `--data-dir` with and without `=`; `import` without a file → `MissingArgument`; `--out` without a value → `MissingValue`; `--nope` → `UnknownFlag`; `nxdns frobnicate` → `UnknownCommand`; `export extra` → `TooManyArguments`.
- `usage` writes non-empty text.
- `runCheck` with `probe = false` against an in-memory-backed config: a clean config returns 0 and a config with three problems returns 2 and prints three lines (assert on captured output).

`runExport` / `runImport` need real files; those tests live in S7.

### S6.6 Acceptance criteria

- [ ] `zig fmt --check` and `zig ast-check` clean on both files.
- [ ] `main.zig` contains argument dispatch and nothing else; every command body is in `cli.zig` and
      takes its writers as parameters.
- [ ] `nxdns version` behaviour is unchanged from milestone 1.
- [ ] `check` prints every diagnostic before exiting.
- [ ] `check` builds one `Pool` per upstream.
- [ ] The `config.db` permission ordering (create → chmod 0600 → WAL) is implemented and commented.
- [ ] Every `parseArgs` bullet exists as a named test.

---

## Session S7: `src/storage/storage_integration_test.zig`

One file, everything real: real temporary directories, real database files, real exports. Guarded by `if (!build_options.integration) return error.SkipZigTest;` at the top of every test, and one `build_options.live` test for the upstream probe.

Use `std.testing.tmpDir(.{})` for isolation — verified `pub fn tmpDir(opts: Io.Dir.OpenOptions) TmpDir` (`lib/std/testing.zig:634`). It uses `std.testing`'s own `io`, so the tests must drive the code under test with the same `Io` instance the returned `TmpDir` was created against; read `lib/std/testing.zig` around line 634 for how that `io` is obtained and report it. Capture command output with `std.Io.Writer.Allocating` (`lib/std/Io/Writer.zig:2502`).

### S7.1 Required cases

*querylog recreate policy* (the S3.3 list, which S3 could not write):

1. Fresh directory → `open` creates the file, `recreated == .missing`, `PRAGMA user_version` equals `fingerprint`, `domains` and `query_log` exist.
2. Reopen the same file → `recreated == null`, no aside file was created.
3. Stamp a wrong `user_version`, reopen → `recreated == .fingerprint_mismatch`, exactly one `querylog.db.corrupt-*` file exists, and its bytes equal the original file's bytes.
4. Overwrite the file with 4096 bytes of garbage, reopen → recreated with `.not_a_database` or `.corrupt`; the garbage is preserved in the aside file.
5. Two recreates without an intervening second produce **two** aside files with distinct names (drive it by recreating twice in a loop; the uniquifier, not the clock, must make them distinct).
6. Create a stale `querylog.db-wal` beside a fingerprint-mismatched database; after the recreate, the stale `-wal` is gone.
7. A `querylog.db` whose file permissions deny reading (`0o000`) → the error propagates and the file still exists with its original bytes. This is the "never destroy a healthy file" guarantee.

*config.db and permissions*:

8. `DataDir.open` on a fresh path creates the directory with mode `0o700` (assert via `statFile` → `permissions.toMode() & 0o777 == 0o700`).
9. After `DataDir.open` + `migrate`, `config.db` is `0o600` and `config.db-wal` (if present) is `0o600`.
10. `migrate` on a database stamped one version ahead → `error.SchemaTooNew`; the file is unchanged.

*export / import / bootstrap*:

11. Seed a database, `export.writeToFile`, assert the output file is mode `0o600` and its first line is the header comment.
12. Byte-stable round trip **through real files**: export to `a.zon`, import into a second data directory, export to `b.zon`, `expectEqualStrings` on the two file contents.
13. `import` into a non-empty database without `--force` → `error.DatabaseNotEmpty` and the existing rows are unchanged; with `--force` → replaced.
14. `import` of a file with two validation errors → both diagnostics are produced, the database is still empty, and no file was created.
15. `bootstrap` with no config file → `.no_config_file`, database still empty.
16. `bootstrap` with a valid config file on an empty database → `.seeded`, and the rows match.
17. `bootstrap` on an already-configured database → `.db_already_configured` and the file is not even read (prove it by making the file invalid ZON: the call must still succeed).
18. `bootstrap` with a present-but-invalid config file on an empty database → an error, and the database is still empty.
19. `export.writeToFile` over an existing file replaces it atomically: assert the old content is gone and the mode is still `0o600`.

*CLI end to end*:

20. `cli.runImport` then `cli.runExport` through the public entry functions, capturing stdout, reproducing case 12 at the CLI level and asserting the exit codes are 0.
21. `cli.runCheck` with `probe = false` against a seeded data directory returns 0; against a database seeded with a config carrying two problems returns 2 with two diagnostic lines.

*live*:

22. Guarded by `build_options.live`: `cli.runCheck` with `probe = true` against a config naming `https://cloudflare-dns.com/dns-query` prints an `OK` line. A live failure is an environment finding, not a gate (milestone 1 and 3 convention).

### S7.2 Acceptance criteria

- [ ] `zig fmt --check` and `zig ast-check` clean.
- [ ] All 21 hermetic cases exist as named tests and pass under `zig build test -Dintegration`.
- [ ] No test leaves a file behind outside its `tmpDir`.
- [ ] No hermetic test opens a socket or resolves a name.

---

## Module Layout

```
src/storage/db.zig                             S1  sqlite3 wrapper: errors, Stmt, Tx, pragmas
src/storage/config_schema.zig                  S3  PLAN §11.2 DDL, delete order, content tables
src/storage/migrations.zig                     S3  numbered steps, one-transaction runner
src/storage/querylog_schema.zig                S3  PLAN §11.3 DDL, fingerprint, recreate policy
src/storage/repositories/groups_repo.zig       S4  groups, group_sources
src/storage/repositories/clients_repo.zig      S4  clients, client_prefixes
src/storage/repositories/upstreams_repo.zig    S4  upstreams
src/storage/repositories/sources_repo.zig      S4  blocklist_sources
src/storage/repositories/rules_repo.zig        S4  rules
src/storage/repositories/local_repo.zig        S4  local_records, forward_zones
src/storage/repositories/settings_repo.zig     S4  settings
src/storage/storage_integration_test.zig       S7  -Dintegration, real files
src/config/model.zig                           S2  Config, settings mapping, unit conversions
src/config/validate.zig                        S2  pure validator + Diagnostics + parseResolver
src/config/bootstrap.zig                       S5  first-start seeding
src/config/import.zig                          S5  read → parse → validate → replace
src/config/export.zig                          S5  DB → canonical ZON, atomic 0600 write
src/cli.zig                                    S6  parseArgs, DataDir, runCheck/runExport/runImport
src/main.zig                                   S6  thin dispatch shell (rewritten)
```

## File Ownership

| Files | Owner | Notes |
|---|---|---|
| `src/storage/db.zig` | S1 | frozen after S1 verifies |
| `src/config/model.zig`, `src/config/validate.zig` | S2 | frozen after S2 verifies |
| `src/storage/config_schema.zig`, `src/storage/migrations.zig`, `src/storage/querylog_schema.zig` | S3 | |
| `src/storage/repositories/*.zig` (7 files) | S4 | |
| `src/config/bootstrap.zig`, `src/config/import.zig`, `src/config/export.zig` | S5 | |
| `src/cli.zig`, `src/main.zig` | S6 | `main.zig` is rewritten; milestone-1 CLI behaviour preserved |
| `src/storage/storage_integration_test.zig` | S7 | |
| `build.zig`, `build.zig.zon`, `src/tests.zig` | orchestrator | no session edits these |
| `PLAN.md` §12.1 | orchestrator | amended per S2.1 after S2 verifies |

No session touches milestone 1, 2 or 3 files. A needed change there is reported, not made. In particular `src/upstream/*` and `src/server/*` are read-only in this milestone.

## Acceptance Criteria (Milestone 4 Complete)

- [ ] `zig build test` exits 0 with every new file wired into `src/tests.zig`.
- [ ] `zig build test -Dintegration` exits 0: milestone 1's loopback TLS echo, milestone 3's listener
      and resolver tests, and all 21 hermetic storage cases pass.
- [ ] `zig build test -Dintegration -Dlive` exits 0 locally, or live failures are reported as
      environment findings with the exact error.
- [ ] `zig build cross` still produces two statically linked executables.
- [ ] `grep -rn "std.zon.parse.free" src/` returns nothing.
- [ ] `grep -rn "@cImport" src/` returns nothing.
- [ ] Byte-stable round trip: `nxdns import a.zon && nxdns export --out b.zon` on a fresh data
      directory, then `cmp a.zon b.zon` after the first export — expressed as test case 12 and
      reproducible by hand with:
      ```
      ./zig-out/bin/nxdns --help >/dev/null
      ./zig-out/bin/nxdns import --data-dir /tmp/nx1 fixture.zon
      ./zig-out/bin/nxdns export --data-dir /tmp/nx1 --out /tmp/a.zon
      ./zig-out/bin/nxdns import --data-dir /tmp/nx2 /tmp/a.zon
      ./zig-out/bin/nxdns export --data-dir /tmp/nx2 --out /tmp/b.zon
      cmp /tmp/a.zon /tmp/b.zon
      ```
- [ ] `stat -c %a /tmp/nx1` reports `700`; `stat -c %a /tmp/nx1/config.db` and `/tmp/a.zon` report
      `600`.
- [ ] `nxdns check --config <a file with 3 problems>` prints 3 diagnostic lines and exits 2.
- [ ] `nxdns import` of an invalid file leaves the database empty (`sqlite3 config.db "select count(*)
      from settings"` is 0 — or the equivalent assertion in test case 14, since the repo ships no
      sqlite3 CLI).
- [ ] A `config.db` stamped one version ahead makes startup fail with the "schema too new" message,
      not a crash and not a silent downgrade.
- [ ] `zig fmt --check` clean repo-wide; GPG-signed lowercase commits.

## Anti-Requirements

- **No web server, no REST API, no SSE, no `/metrics`, no auth flow.** Phase 8. The argon2id hash is computed and stored; nothing verifies it yet.
- **No blocklist downloading, parsing, or compiling.** Phase 5. `blocklist_sources` rows are metadata; no `.list`/`.wild` file is created, no HTTP fetch happens.
- **No filtering, rule matching, wildcard matching, safe-search, CNAME uncloaking.** Phase 5. The validator checks rule *syntax* only.
- **No query-log writing.** Phase 6. `querylog.db` is created and its schema is verified; nothing inserts a row, there is no `queries_repo.zig`, no buffer, no flush loop, no retention job, no VACUUM, no disk monitor.
- **No DNS serving changes.** `src/dns/`, `src/server/`, `src/upstream/` are untouched. `nxdns run` still prints `not implemented`; wiring the config into the servers is Phase 7.
- **No local records or conditional forwarding behaviour.** Phase 5. Their rows are stored and validated, nothing consumes them.
- **No prepared-statement cache, no connection pool, no ORM, no query builder.** One handle per database, statements prepared where used.
- **No `querylog.db` migrations, ever.** That is the design (PLAN §3.7), not a gap.
- **No CRUD beyond list/insert/deleteAll/count** in the repositories.
- **No config file watcher, no auto-regeneration, no reload signal.** PLAN §3.5.
- **No third-party Zig packages.** stdlib plus the two pinned C libraries.

## As built

The implementation matches the spec with these evaluation- and review-driven refinements (three review rounds; findings went 8 → 3 → 1 low):

- **Logging policy (binding, repo-wide):** a condition that is returned as a typed error logs at `warn` at most; `err` is reserved for failures the code swallows (the logged-then-discarded ROLLBACK failure). The zig test runner fails any test that emits `err` logs, and the negative tests exercise these paths.
- `db.zig`: `c.Destructor` is `?*anyopaque`, not a fn-pointer type — the `SQLITE_TRANSIENT` sentinel (-1) is not a valid function address and aarch64 fn pointers require alignment, so the fn-pointer form fails `zig build cross`. `queryInt` now enforces its contract: exactly one column, integer-typed, exactly one row.
- `querylog_schema.zig`: the DDL fingerprint's comptime CRC needs `@setEvalBranchQuota(2_000_000)` (the stdlib lookup-table generation runs under the caller's quota). Behavior-level integration tests prove `open()` preserves the file under `Busy`/`Locked` (a concurrent exclusive transaction) and only whitelisted corruption recreates.
- `rules_repo.zig`: rule listing orders by group NAME (subselect), not `group_id` — ids permute across import into a fresh database and would break the byte-stable round trip.
- `validate.zig`: one shared `parseAuthority` (strict brackets, no userinfo/query/fragment, printable-ASCII-only, port 1–65535) backs both the source-URL check and `parseResolver`. `Problem.err` is `ProblemError` (`ValidateError || error{ParseZon}`) so import's ZON line/column diagnostics travel the same channel the CLI renders; `validate` returns the first problem it recorded itself.
- `cli.zig`: certificate/key checks do a real readability probe, not an existence check; an OutOfMemory during diagnostics recording takes the runtime exit code, never the config-problem exit; `help` rejects trailing arguments like every other command.
- **Discovered stdlib limitation** (recorded in specs/research/zig-0.16-api-notes.md): `Certificate.Parsed.verifyHostName` ignores `iPAddress` SANs, so DoT to an IP literal cannot pass verification on stock 0.16 — the `-Dlive` DoT case fails with `CertificateHostMismatch` against 1.1.1.1 while live DoH passes. Planned follow-up (own commit): a per-upstream `tls_name` for SNI + verification while dialing the IP.

### Follow-up: per-upstream `tls_name`

The stdlib limitation above is now worked around the way stubby and unbound do it. `UpstreamServer` (not the `upstream` timeout section) gains `tls_name: []const u8 = ""`, migration step 2 adds `upstreams.tls_name TEXT NOT NULL DEFAULT ''`, and the ZON round trip carries it like every other field. `validate.zig` adds `BadTlsName` (must pass `dns.name.fromText`) and `TlsNameOnNonTlsUpstream` (a `tls_name` on a DoH upstream is a config error, because DoH verifies by its url host and would ignore the field). `DotClient` uses it for SNI and verification while still dialing `endpoint.host`, and `cli.zig`'s check probe passes it through. The `-Dlive` DoT test now uses `1.1.1.1` with `one.one.one.one` and passes; making it pass also uncovered and fixed a missing socket flush in the DoT send path (see the milestone-3 note).
