Files
nxdns/specs/milestone-4.md
T

1938 lines
97 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.53.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 126 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 126; `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 165535, 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 165535 (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 13600, `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 1604800
(`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:
```zig
var out: std.ArrayList(model.Group) = .empty;
errdefer freeGroups(gpa, out.items);
errdefer out.deinit(gpa);
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) });
}
```
- `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:874888`):
```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:412456`) 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 165535) 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).