Files
nxdns/specs/milestone-8.md
T
mokhtar c50c6d285a
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s
milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
2026-08-07 17:55:59 +02:00

59 KiB
Raw Blame History

Milestone 8: web server, REST API, SSE, auth, metrics (PLAN Phase 8, Zig side)

Goal: the complete Zig web layer — HTTP server + router, every REST handler, SSE live query stream, optional argon2id auth with sessions, API token-bucket rate limiting, /metrics, /api/health, OpenAPI served + contract tests, asset embedding + dev-mode disk serving — running as one more task in app.zig's group.

Ground truth: PLAN.md:610-612 (phase text), :537-550 (endpoints), §3.11/§12.1/§19 (auth), §10:333 (API limiting), §11.4:455 (SSE precedes persistence), §13.2 (OpenAPI), scratchpad notes m8-explore-{plan,repo,zig}.md. Load-bearing Zig facts are restated inline; verify anything else against /home/mokhtar/app/zig tag 0.16.0.

Rulings (binding)

  1. Sequencing: this milestone is the Zig web layer end to end. The React SPA (PLAN §3.14, §14 — ten pages) is milestone 9, built against this milestone's finished, contract-tested API. Not a scope cut: the asset embedding, dev-mode disk serving, ETag and gzip machinery all ship NOW and are complete; milestone 9 only swaps the dist content. The embedded dist in this milestone is a minimal real page (see ruling 24) — machinery is not stubbed.

  2. POST /api/certs/reload is Phase 9's, whole: endpoint, openapi.yaml entry and docs land together with the DoH/DoT server it reloads. No 501 stub, no dangling contract entry.

  3. docs/api/ rendering is Phase 10 (the docs phase). Phase 8 serves the yaml.

  4. Server model: per-connection std.http.Server (Server.zig:25) over our own accept loop, copied from lib/std/Build/WebServer.zig:152-185: listener task in the app group; an inner Io.Group of connection tasks; keep-alive loop on receiveHead with error.HttpConnectionClosing => return. Cancel semantics mirror tcp_server's S3 As-built: on cancellation the inner group is CANCELED, not awaited (a keep-alive client must not hold shutdown open); on listener close (SocketNotListening) it drains.

  5. Bind: one listener socket on web.bind:web.port exactly as configured (default 0.0.0.0:8080). No dual-stack ceremony — milestone 7's parity ruling was about DNS client identity, not the admin UI. An operator who wants v6 sets web.bind = "::" (dual-stack by Linux default).

  6. web.enabled = false skips the entire subsystem, /metrics included. No web task, no web DB connections.

  7. Limits: recv buffer 8 KiB (this caps the request head — Server.zig:32); send buffer 4 KiB; request body cap 1 MiB (Io.Limit, read via readerExpectNone/allocRemaining); 64 concurrent connections (accept beyond that: respond 503 and close — never silently drop). No per-request timeout this milestone (LAN-facing; the cancel path bounds shutdown); documented in server.zig.

  8. JSON shape: snake_case field names everywhere (matches settings keys and SQL). Error envelope {"error":"<message>"}. Status codes: 400 validation, 401 unauthenticated, 404 missing, 405 wrong method (with Allow), 409 conflict (duplicate key), 413 body too large, 429 rate limited (with Retry-After), 500 internal (generic message, detail to log as warn), 503 over connection cap.

  9. Resource paths (freezing PLAN:541's ellipses): collections GET+POST and items GET+PUT+DELETE by numeric row id for: /api/groups[/{id}], /api/blocklists[/{id}] (the sources table), /api/rules[/{id}], /api/local-records[/{id}], /api/forward-zones[/{id}], /api/upstreams[/{id}] (PLAN's list omitted upstreams; the Settings page must edit them; new resource, same pattern). Group-source assignment: PUT /api/groups/{id}/sources with {"source_ids": [..]} — idempotent full-set replace. Clients: GET /api/clients (ALL rows, materialized included, each with hand_edited), GET/PUT/DELETE /api/clients/{id}; PUT sets hand_edited=1 and may change name/group; DELETE removes the row (a live client re-materializes). Client prefixes: GET/PUT /api/client-prefixes as a whole-list resource (tiny table, atomic replace). No POST for clients — creation is by DNS activity or import (PLAN:540 gives clients no POST deliberately).

    Placement deviation (milestone 17 ruling 3). "The Settings page must edit them" above names the wrong page. Milestone 9 dropped the obligation entirely; milestone 17 restored it as a dedicated Upstreams page with its own nav entry (web/src/features/upstreams/, route /upstreams), matching the house resource-page pattern that every other collection follows. The Settings page keeps the scalar settings keys only. The API contract in this ruling is unchanged.

  10. Repo layer: every list row the API serves carries its row id; each mutated resource gains getX(db, id), updateX(db, id, item), deleteX(db, id) (strict: 0 rows touched → error.NotFound), written in the house repo idiom with prepared statements. Existing import-path functions stay frozen.

  11. GET /api/queries: keyset pagination ?limit (default 100, max 1000) + ?before=<row id>, ordered id DESC; filters domain= (substring), client= (exact), blocked= (bool), since=/until= (unix seconds). Response {"queries":[...], "next_before": <id>|null}. Row fields: id, ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream.

  12. Live vs restart: mutations to rules, blocklists, groups, group-sources, clients and client-prefixes call Manager.reload(io) and take effect live (the handler's next acquire sees the new snapshot). Local records and forward zones ALSO apply live: a new src/server/local_tables.zig RCU holder (acquire/release + swap, mirroring the manager's pattern at small scale) owns {records, zones}; Handler reads through it instead of *const fields; the local-records/forward-zones handlers rebuild and swap. Upstream mutations and everything in /api/settings are restart-required. POST /api/blocklists/update = Manager.refreshAll then reload, 202 with the status snapshot.

  13. Stats grammar: period=1h|24h|7d|30d (else 400). /api/stats returns totals {queries, blocked, cached, clients (distinct), avg_response_time_us} for the period. /api/stats/timeseries buckets: 1h→60×1m, 24h→48×30m, 7d→168×1h, 30d→120×6h; UTC; each bucket {ts, queries, blocked, cached}. SQL aggregates over query_log on the web task's own read connection.

  14. GET /api/lookup?domain=&group_id= (group optional, default group when absent): normalizes, then reports the full pipeline view: {domain, group_id, local_records: bool, forward_zone: <zone>|null, blocked, reason, matched, source_url|null, safe_search_rewrite: <target>|null} via snapshot.evaluate + records/zones lookups. Null snapshot → 503 {"error":"no snapshot loaded"}.

  15. Pause API: GET /api/pause{"paused": bool, "until": <unix s>|null} (null while unpaused OR indefinite — disambiguated by paused). POST /api/pause body {"paused": true, "duration_seconds": <u32, optional>} or {"paused": false}.

  16. GET/PUT /api/settings: the typed config sections that live in settings rows (dns, blocking, cache, edns, upstream, logging, disk, blocklist_update, safe_search flag home — read model.toSettings for the exact key list), minus web.password* (never serialized; a PUT carrying web.password re-hashes via the import path's argon2id params and stores only the hash). GET marks every key restart_required: true except none — ALL settings are restart-required this milestone (live behavior comes from the resource endpoints and pause; a static table in the handler carries the flag so the UI banner has transport). PUT validates the merged config through validate.validate before writing any row; partial updates allowed.

  17. Auth: enabled iff web.password_hash != "". Login verifies with std.crypto.pwhash.argon2.strVerify(hash, password, .{ .allocator = gpa }, io) (argon2.zig:619 — PHC string carries its params). Sessions: in-memory fixed table of 32 (LRU evict), storing SHA-256 of a 32-byte io.randomSecure token; lookup compares digests with std.crypto.timing_safe.eql([32]u8, ...) (slices not accepted — timing_safe.zig:12). Cookie nxdns_session=<url_safe_no_pad base64>; HttpOnly; SameSite=Lax; Path=/; Secure NOT set (nxdns serves plain HTTP; TLS termination is the operator's proxy — documented). Expiry web.session_ttl_hours. Logout deletes the session. A PUT /api/settings that changes password_hash clears every session.

  18. Auth exemptions: /api/health, /api/version, /metrics, /api/openapi.yaml, and the static assets are always unauthenticated (monitoring endpoints; the SPA shell must load to show a login form). Everything else 401s without a valid session when auth is enabled. /api/auth/login is necessarily exempt; failed logins count and are rate limited like any request.

  19. API rate limiting: token bucket per client IP (NOT the DNS fixed-window limiter — m6 ruling 8 reserved the bucket shape for the API): capacity and refill web.api_rate_limit_per_min per 60 s, 4096 tracked IPs (same fixed-table idiom as rate_limiter/tracker). Localhost exemption: NEW config field web.api_localhost_exempt: bool = true (PLAN §10 says "configurable" and §12.1 forgot the field; model + validate

    • settings key + export round trip — settings are kv rows, no migration needed). 429 + Retry-After: <s>. SSE: connect consumes a token AND respects web.sse_max_connections_per_ip; /metrics and /api/health are limiter-exempt (Prometheus must never see 429).
  20. SSE: GET /api/queries/live, text/event-stream via respondStreaming(&.{}, ...) (EMPTY buffer — BodyWriter.flush does not flush the body writer's own buffer, http.zig:780; empty buffer makes every write go straight to the chunked drain), flush headers before the first event (test.zig:498 pattern). Fanout: milestone-6 ruling 4 concretized as src/server/query_sink.zig: QuerySink { logger: *Logger, hub: ?*sse.Hub } with log(io, entry) = transform once, publish to the hub, enqueue to the logger. Logger gains additive transformed(entry) Entry (pure) and logTransformed(io, entry); existing log = both, frozen behavior. Handler.logger: ?*Logger becomes sink: ?*QuerySink (mechanical rename at the two call sites + app wiring). Hub: fixed 32 subscriber slots, each a 64-entry ring + std.Io.Event; publish never blocks (full ring → disconnect that subscriber; SSE clients auto-reconnect). Wire format: retry: 3000 once, then event: query + data: <JSON, same fields as /api/queries rows> per entry; heartbeat comment : ping every 15 s from the subscriber task (Event.waitTimeout). Fanout precedes persistence (PLAN:455) — the sink publishes before enqueue.

  21. /metrics: Prometheus text format 0.0.4, nxdns_ prefix, # HELP/# TYPE lines. Counters: every Handler.Stats field (17, individually, nxdns_dns_<field>_total), logger (queries_dropped, rows_written, batches_gated), cache stats (6, under Handler.cache_mutex), DNS limiter stats (under limiter_mutex), tracker stats, retention stats, log-sink stats, nxdns_blocklist_refreshes_gated_total. Gauges: cache len + memory bytes, disk free/db/log bytes, tracked DNS clients, pending tracker clients, blocklist generation, nxdns_up 1. Per-upstream, labeled {url="..."}: up (available), success_rate, consecutive_failures, total_successes/_failures (copy Pool.Snapshot fields while holding the returned count — last_error is borrowed, copy immediately). No timestamps. Auth-exempt, limiter-exempt.

  22. GET /api/health: {"status":"ok"|"degraded","disk":{"state":..., "free_bytes":...,"db_bytes":...,"log_bytes":...,"sample_failures":...}, "upstreams":{"available":N,"total":M},"queries_dropped":N,"writer_failed":bool, "refreshes_gated":N,"snapshot_generation":N|null}. Degraded iff disk state != ok, or zero upstreams available, or writer_failed. 200 either way (degraded is data, not an HTTP failure).

  23. OpenAPI + contract tests: hand-written src/web/openapi.yaml, @embedFiled and served verbatim. No external validator (dependencies are liabilities): the contract IS a Zig table in the integration test — every route with method, auth requirement, a seeded request, expected status, and a response-shape struct that std.json.parseFromSlice must accept with .ignore_unknown_fields = false. Two drift guards: (a) the router exposes pub const routes: []const RouteInfo; a test asserts every entry's path+method appears textually in openapi.yaml; (b) the table covers every routes entry (count equality). CI gets one new job step running the existing -Dintegration suite (contract tests live inside it) — no new tooling.

  24. Static assets: build.zig gains -Dweb-dist=<path> (default web/dist-placeholder/, committed, containing a real minimal index.html — current status via /api/health fetch, links to /metrics; plus favicon). A build step copies the dist dir via b.addWriteFiles() + generated assets.zig index (the fixtures anonymous-import pattern, per research notes — @embedFile paths must live inside the module root), exposing pub const files: []const File{path, bytes, content_type, etag}; etag = comptime hash. Serving: exact path match, / → index.html, SPA fallback (unknown non-/api path → index.html, 200 — TanStack Router needs it in M9), ETag/ If-None-Match → 304, content-encoding: gzip when a sibling <name>.gz exists in the dist AND the request accepts it. No Date/Last-Modified headers (std has no RFC 1123 formatter; ETag is strictly better for immutable embedded content). Dev mode: CLI flag nxdns run --web-dev <dir> serves from disk (no cache headers), bypassing the embed.

  25. Head-string trap: request.head.target is invalidated by body reads (Server.zig:594/230). The router copies target into a stack buffer before any body read. Query strings: split on '?', std.mem.splitScalar for pairs, Uri.percentDecodeInPlace per value (std has no query iterator; std.Uri does not parse origin-form targets).

  26. Wiring: one web_server.serve task in the app group, started last, canceled by the same group.cancel. WebState struct (in web/server.zig) of borrowed pointers assembled by app.serve: handler (stats + mutexes + cache/limiter), pause, tracker, manager, pool, monitor, logger, retention stats, local_tables, sink/hub, config arena view (web cfg), its OWN config.db + querylog.db connections (m7 ruling 21), version string, started timestamp. Web DB connections open only when web.enabled.

  27. No new schema migration: query_log already carries what /api/queries needs; the read session verifies existing indexes and reports if a needed index is missing (then the orchestrator rules on adding migration v3 — do not add one silently).

  28. Client disconnect: stream writes surface error.WriteFailed with the real cause in stream_writer.err (ConnectionResetByPeer / SocketUnconnected=EPIPE — MSG.NOSIGNAL is set, Threaded.zig:13071, no SIGPIPE masking needed). A failed write ends the connection task quietly (debug log at most): clients vanishing is normal.

  29. Input sanitation (PLAN §19): every path/query/body input is length-capped and validated before SQL (prepared statements everywhere, already house rule); domain inputs run through matcher.normalize; no secrets ever logged (password, tokens, cookie values — assert by review, and the login handler logs only the client IP and outcome).

Sessions

Wave 1 (parallel): W1 query-log reads, W2 repo CRUD, W3 web core, W4 auth+limiter, W5 sink+SSE hub+logger split. Wave 2 (after W3, parallel where files allow): W6 read handlers + metrics, W7 mutation handlers + local_tables + handler.zig sink/local_tables changes. Wave 3: W8 static assets + build.zig + openapi.yaml + router registration of everything. Wave 4: W9 app/cli wiring. Wave 5: W10 integration + contract tests + CI. The orchestrator wires src/tests.zig per wave and rules on anything a session flags.


Session W1: query-log read layer

Owns: src/storage/repositories/queries_repo.zig (additive; BatchWriter and friends frozen).

pub const QueryRow = struct { id: i64, ts: i64, domain: []const u8, client_ip: []const u8,
    qtype: ?u16, blocked: bool, block_reason: []const u8, response_time_us: ?i64,
    cache_hit: ?bool, upstream: []const u8 };
pub const QueryFilter = struct { limit: u32 = 100, before: ?i64 = null,
    domain_substring: ?[]const u8 = null, client: ?[]const u8 = null,
    blocked: ?bool = null, since: ?i64 = null, until: ?i64 = null };
/// Rows come back newest-first. Strings are arena-allocated.
pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter)
    db.Error!std.ArrayList(QueryRow)
pub const StatsTotals = struct { queries: u64, blocked: u64, cached: u64,
    distinct_clients: u64, avg_response_time_us: ?i64 };
pub fn statsTotals(database: *db.Db, since: i64, until: i64) db.Error!StatsTotals
pub const Bucket = struct { ts: i64, queries: u64, blocked: u64, cached: u64 };
/// Fills `out` with fixed-width buckets covering [since, until); returns the count.
pub fn timeseries(database: *db.Db, since: i64, bucket_seconds: u32, out: []Bucket)
    db.Error!usize

Read the real query_log schema first (querylog_schema.zig) — domain interning means a JOIN; verify what indexes exist and REPORT (ruling 27) if ts or the join needs one that is missing rather than adding a migration. Dynamic WHERE assembly must still use bound parameters only (build the SQL from fixed fragments, never interpolate values). Cap limit at 1000 in the repo too. Tests: in-memory db seeded via BatchWriter; filters, keyset paging across a boundary, bucket alignment, empty ranges, substring escaping (%/_ in domain must not act as wildcards — use ESCAPE).

Acceptance: fmt/ast clean; temp-root (-lc -lsqlite3) green; frozen surface untouched.

W1 As built

pub const max_limit: u32 = 1000 exported. WHERE assembly: six comptime fragments copied into a comptime-sized stack buffer (overflow impossible by construction), one bare ? per predicate, bind order = append order; likePattern escapes %, _ and \ with ESCAPE '\'. Ruling 27 discharged: EXPLAIN QUERY PLAN over all six query shapes rides idx_query_log_client / idx_query_log_ts / rowid — NO migration v3; the time-windowed page's temp B-tree re-sort and the DISTINCT count's temp B-tree are inherent and cheap at household volume. statsTotals computes the mean as sum/count integer division (db.Stmt has no float column reader; avg() returns REAL) — exact microseconds, null when no row recorded a time. NULL block_reason/upstream arrive as "" (columnText convention; neither is ever written as an empty string). W6 convention, ruled now: "" serializes as "", not JSON nullupstream="" already means "cache hit" per milestone-7 ruling 20 and clients branch on blocked/cache_hit, not on reason presence. timeseries returns error.Misuse for bucket_seconds == 0 or an i64 window overflow, returns 0 for an empty out without touching the database, and aligns buckets to since — the HANDLER passes a grid-aligned since per ruling 13. 14 new tests (43 in file).


Session W2: repo CRUD by id

Owns every file in src/storage/repositories/ EXCEPT queries_repo.zig (W1's), plus nothing else. Additive only; import-path functions frozen.

For groups, clients (+prefixes), upstreams, sources, rules, local records, forward zones: id-carrying list variants (or extend existing rows where the shape already has no consumers outside export — check callers first; export/import must keep compiling unchanged), getX(db, id) db.Error!?XRow, insertXRow(db, item) db.Error!i64 (returns id; distinct from frozen import inserts where semantics differ — e.g. clients insert with hand_edited=1), updateX(db, id, item) db.Error!void (0 rows → error.NotFound), deleteX(db, id) db.Error!void (same), setGroupSources(db, group_id, source_ids) db.Error!void (transactional replace), replaceClientPrefixes(db, items) db.Error!void. Respect FK constraints: deleting a group with clients → error.Constraint surfaces as 409 at the handler; document each. settings_repo: putSetting(db, key, value) single-key upsert if absent. Tests per resource: round trip, NotFound on both update and delete, constraint surfacing, group-sources replace idempotence.

Acceptance: fmt/ast clean; temp-root green; zig build test green (export/import tests prove the frozen surface).

W2 As built

New shared crud.zig: execStrict turns zero-rows-touched into error.NotFound. Accepted deviations: getX(database, gpa, id) db.Error!?XRow (rows hold heap strings); insertClientRow/insertRuleRow take now_s (NOT NULL columns, repos take no Io — the InsertContext.now mirror); client writes split ClientInput{ip,name,group_id} (create) vs ClientEdit{name,group_id} (edit) — ip is not editable: it is the identity upsertSeen matches, and ruling 9 promises only name/group on PUT; client_prefixes get list + replace only (whole-list resource, per-id would be unused generality); write shapes carry group_id (a missing group surfaces as the FK violation → 409, not an id-map miss); added listGroupSourceIds (the PUT needs a read counterpart; frozen listGroupSources speaks names). Constraint map for W7: deleting a group with clients → Constraint (clients.group_id has no ON DELETE; rules/prefixes/group-sources cascade); bad group_id on client/rule writes → Constraint; url/ip/zone/name uniques → Constraint; deletes of clients/upstreams/rules/ local-records/forward-zones can only be NotFound. setGroupSources: BEGIN IMMEDIATE, existence check first (empty set must not report success for a missing group), delete + distinct insert (set semantics dedupe); replaceClientPrefixes transactional whole-table, duplicate prefix REJECTED as Constraint (two rows for one prefix is a contradiction, not a set). Schema facts: upstreams.tls_name comes from migration v2 (config_schema.zig is v1 only!) — the openapi /api/upstreams entry needs the fifth field; SourceRow gained is_suggested: bool = false appended last (manager.zig literals compile unchanged, no column index moved). putSetting is a primary-key upsert — partial settings PUT needs no read-first and fires no constraint. 45 new tests + 3 crud.


Session W3: web core — server, router, http plumbing

Owns: src/web/server.zig, src/web/router.zig, src/web/http_util.zig.

  • server.zig: WebState (ruling 26 — declare the struct; fields it cannot yet point at get wired by W9), pub fn serve(state: *WebState, io: std.Io) std.Io.Cancelable!void — bind per ruling 5 (bind failure: warn and return — the DNS side must keep serving; app treats web bind failure as non-fatal, W9 documents), accept loop per ruling 4 with the Stop split, connection budget per ruling 7 (503 over cap), per-connection buffers (8 KiB recv / 4 KiB send), keep-alive loop, dispatch into router.
  • router.zig: RouteInfo { method: http.Method, pattern: []const u8, auth: enum {open, session}, handler: *const fn(...) }; pub const routes: []const RouteInfo (ruling 23 depends on it); match = exact segments + one {id} numeric capture; 405 with Allow when the path matches another method; target copied before body reads (ruling 25); query-pair iterator + percent-decode helpers (in http_util, pure, tested hard: '+', '%2F', truncated %, overlong values → 400).
  • http_util.zig: JSON respond helpers (std.json.Stringify streaming into a Writer.Allocating for content-length, or direct respond for small bodies), the error envelope, request-body reader with the 1 MiB cap (413), cookie parse/format, bearer of nothing else.
  • The connection handler calls: limiter (W4, via a comptime-checkable interface field on WebState so W3 compiles before W4 lands — define ApiLimiter and Sessions as W3-owned INTERFACE structs? NO: simpler, W3 declares the WebState fields as *auth.Sessions / *auth.ApiLimiter types and W3 is built AFTER W4's file exists on disk in the same wave — to keep the wave parallel, W3 instead keeps auth/limiter checks behind two function pointers on WebState (check_auth, check_limit) that W9 wires; W3 tests them with test doubles. This is the one seam where indirection is warranted; document it.)
  • Tests: router matching table, 405/404, query decoding, body cap, cookie round trip, keep-alive across two requests (loopback socket), 503 over cap, cancel-during-idle keep-alive returns promptly.

Acceptance: fmt/ast clean; temp-root green (may import std only + own files); no listener/handler file touched.

W3 As built

Handler signature: fn(state: *server.WebState, io: std.Io, request: *http_util.Request) http_util.HandlerError!void, HandlerError = {WriteFailed, HttpExpectationFailed, OutOfMemory} — domain outcomes are status codes, only those three escape. Request folds the id capture in (request.id: ?i64; no separate params type) and carries a per-request arena. RouteInfo gained rate_limit: enum{counted,exempt} = .counted (ruling 19's exemptions as data, not path matching); WebState gained fallback: ?HandlerFn (SPA fallback is not a route entry; unmatched non-/api → fallback, unmatched /api → JSON 404) and reload_fn (W7's ruling-12 seam). routes.zig: W8 fills pub const table; router re-exports as routes; WebState.routes defaults to it, so the drift guards read the array the server matches. W4/W5 landed mid-session, so sessions/limiter/hub/sink are REAL typed pointers and check_auth/check_limit default to real implementations (sessionAuth, bucketLimit; allowAll/neverLimit exported as test doubles) — a half-wired server fails closed. serve returns void (cancellation is classified into the Stop enum; tcp_server precedent). Own validating percent-decoder — std.Uri's copies malformed escapes through as literal text (Uri.zig:172-192), ruling 29 wants 400; segments split before decode so %2F cannot forge a boundary. Loopback tests live in web/server_integration_test .zig (house gating pattern). Accepted tradeoff: over-capacity 503 is written and the socket closed without draining, so the client may see RST instead — draining would be an unbounded blocking read on an at-capacity accept loop (documented). RULINGS RECORDED: W7 may add the local_tables field + import to server.zig (two-line exception, W3 consents); Retention.stats is a cross-task data race — W6 owns storage/retention.zig additively this wave: atomic counters + snapshotStats (logger-counters pattern), and /metrics reads only through it. 39 tests.

Amendment (post-W9 critical fix): serveConn normalizes the request head immediately after receiveHead: a body-carrying method (POST/PUT/PATCH) with neither Content-Length nor Transfer-Encoding gets head.content_length = 0 before any dispatch or respond. RFC 9110 §8.6 defines such a request as having an empty body, but std leaves the head saying "unknown" and responddiscardBody (http/Server.zig:631) asserts one of the two is set — so a bare curl -X POST panicked the whole process, DNS included. Zero content-length satisfies every downstream path: bodyReader (http.zig:445) goes straight to .ready, discardRemaining sees EndOfStream, keep-alive survives, and http_util.readBody yields an empty slice. Regression test in server_integration_test.zig: raw POST without either header → well-formed response, empty body observed by the handler, fresh connection answered after, connection_errors 0.


Session W4: auth + API limiter

Owns: src/web/auth.zig, src/web/api_limiter.zig, plus the model.zig/validate.zig additions for web.api_localhost_exempt (ruling 19) — model field, validate rule (none needed beyond type), settings key list, and the export/import round-trip expectations (check model.toSettings reflection picks it up automatically; add the settings-count test adjustments).

  • auth.zig: Sessions fixed 32-slot store per ruling 17 (create → cookie value out; validate cookie → bool; logout; clearAll; expiry sweep on access; LRU evict), login verify via argon2 strVerify (io + allocator required — argon2.zig:600), token = 32 bytes randomSecure → url_safe_no_pad; storage = SHA-256 digest; compare timing_safe.eql([32]u8,...). authEnabled(cfg) bool. Pure unit tests with fixed tokens (seed io.random? no — inject the token bytes via a testable createWithToken).
  • api_limiter.zig: token bucket per ruling 19, fixed 4096-key table (house idiom), awake clock, check(now, key, is_localhost) Result{allowed, retry_after_s}; localhost exempt when configured; sse_connections per-IP counter with tryAcquireSse/releaseSse against sse_max_connections_per_ip. Tests: refill math at boundaries, burst=capacity, exempt localhost, sse cap acquire/release, eviction.

Acceptance: fmt/ast clean; temp-root green; zig build test green (model/validate/export tests still pass with the new field).

W4 As built

Sessions: digests only (SHA-256), full-slot scan on validate so work does not depend on match position; verifyPassword returns Outcome{ok,denied,unavailable}.unavailable (broken PHC string, OOM) maps to 500, not 401; nothing secret is ever formatted (warn prints @errorName only). createWithToken/validateAt are the test seams. Cookie value is 43 chars url_safe_no_pad; cookie_attributes = "HttpOnly; SameSite=Lax; Path=/". Limiter deviations, accepted: (1) check(io, now, addr) — the limiter holds its OWN std.Io.Mutex (W3 dispatches connections concurrently; one lock inside beats N outside) and derives loopback itself via exported isLoopback, so callers cannot disagree; (2) refill counts millionths of a token and advances the clock only by the span converted, so the truncated remainder survives — at 1 req/min the 60 s boundary is exact (tests at 999/1000 ms); (3) the SSE per-IP cap binds loopback clients too — api_localhost_exempt exempts the RATE, not the fixed hub-slot resource. Full table → unknown addresses allowed + untracked (DNS-limiter precedent); sweep drops only full, SSE-free, window-idle buckets; retry_after_s never 0 on refusal. Model ripple: web.api_localhost_exempt in Web + expected_keys + fixture; validate.zig needed no edit (bool; decodeValue already rejects non-bool text); no count assertion outside W4 files existed. 12 auth + 14 limiter tests.


Session W5: query sink, SSE hub, logger split

Owns: src/server/query_sink.zig (new), src/web/sse.zig (new), src/storage/logger.zig (additive split ONLY: transformed(entry) Entry pure + logTransformed(io, entry); existing log becomes transform+logTransformed with byte-identical behavior — the existing tests must pass unchanged), and src/server/handler.zig (mechanical: field logger: ?*logger_mod.Loggersink: ?*query_sink.QuerySink, the log call site, tests updated to construct a sink around their logger).

  • query_sink.zig per ruling 20: publish BEFORE enqueue (PLAN:455).
  • sse.zig Hub: 32 slots × 64-entry Entry rings + Event per subscriber; subscribe() ?SubscriberId / unsubscribe(id); publish(io, entry) copies the entry into every live ring (Entry is self-contained, ~400 B; a full ring marks the subscriber overflowed and sets its event — the subscriber task sees the flag and ends the response); wait(id, timeout) for the heartbeat loop. All under one mutex; publish is called on the DNS hot path — it must stay allocation-free and short (copy + flag set). Tests: publish/consume order, overflow disconnect flag, unsubscribe under load, heartbeat timeout returns empty.
  • Handler tests: one added test proving the sink publishes and logs (tiny hub + logger).

Acceptance: fmt/ast clean; temp-root green including ALL existing handler and logger tests; zig build test + -Dintegration green (phase7 tests construct Handlers — they compile against the renamed field; update them, they are in your ownership for THIS mechanical change only — list every touched line in the report).

W5 As built

Every Hub method takes io (the mutex needs it); all locking is lockUncancelable (milestone-7 tracker precedent: handle has no error union). Hub.init(self) void initializes IN PLACE — 32×64 rings ≈ 900 KiB, so W9 must gpa.create(Hub), never a stack local. Read surface ruling 20 left open, as built: next(io, id) ?Entry (pop oldest), overflowed(io, id) bool (sticky; pre-overflow entries stay readable — the subscriber drains then disconnects), wait(io, id, timeout) Cancelable!Wake with Wake = {ready, timeout}; wait resets the event under the mutex only while the ring is empty, so a racing publish is never lost, and a spurious futex wake reports .timeout (costs one heartbeat). Publish-before-persist is pinned structurally (enqueue never blocks, so a clock cannot observe order): the test closes the logger queue and the entry still reaches the hub while counting queries_dropped. W5 also made the minimal app.zig compile fix (sink around the logger, hub = null, .sink = &sink) — W9: the sink already exists in app.zig; only the hub and the rest of the wiring are missing. 11 sse + 4 sink

  • 1 logger-split + 1 handler test; all pre-existing logger/handler tests unchanged.

Session W6: read handlers + metrics + health + version + openapi route

Owns: src/web/handlers/stats.zig, queries.zig, lookup.zig, upstream_health.zig, health.zig, version.zig, metrics.zig (in web/, not handlers/ — PLAN layout :227).

Implement rulings 11, 13, 14, 21, 22; GET /api/version = version.zig string + git commit build option. Every handler is fn(state, io, request-ish, params) !void matching W3's handler signature; JSON via http_util. Metrics: mind the mutex discipline (cache/limiter stats under the handler's mutexes; pool snapshot copies borrowed strings immediately; atomics via .monotonic loads). Handlers read the querylog via state's own connection. Tests: pure formatting tests per handler where the data is injectable (metrics text golden test, health degraded matrix, stats bucket math via W1 fixtures on an in-memory db).

Acceptance: fmt/ast clean; temp-root green.

W6 As built

Every handler is split into a pure core plus a thin handle, so the decisions are tested without an http.Server.Request: metrics.collect/metrics.render, health.collect/ rollup, stats.window/periodParam, queries.parseFilter/page, lookup.evaluate/ body, upstream_health.collect, version.body. Entry points for W8's table: metrics.handle, stats.totals, stats.timeseries, queries.list, lookup.handle, upstream_health.handle, health.handle, version.handle — all match router.HandlerFn (proven by a temp-root assignment). /metrics and /api/health are auth = .open, rate_limit = .exempt; /api/version is .open, .counted; the other four are .session, .counted. W8 must add _ = @import("web/metrics.zig") and the six handler files to src/tests.zig — this session did not wire the orchestrator's file. Retention (ruled in W3's As built): Stats is now the plain snapshot type and the live counters moved to a private Counters of std.atomic.Value(u64) in Retention.counters; snapshotStats() reads them .monotonic; runOnce derives its pass number from the fetchAdd result rather than re-reading. Test assertions changed mechanically at retention.zig lines 192-195, 215, 220, 234, 238-240, 243-244, 258-260, 283-286, 312-313 and at phase6_integration_test.zig lines 365-368 (x.stats.fx.snapshotStats().f) — that second file is the only consumer outside retention.zig, and it compiles solely under -Dintegration, so the plain suite does not catch a break there. No behavior changed. Decisions and deviations, all accepted:

  • /metrics carries the DNS counters as an ARRAY keyed by Handler.Stats's comptime field list, so a new counter in the handler appears in the exposition with no edit here; the same counterGroup helper walks every plain stats struct. Missing collaborator = the whole family is omitted (a zero would read as health, an absent series reads as a gap).
  • Added beyond ruling 21's list: nxdns_disk_sample_failures_total (a failure mode that ruling 22 already surfaces) and nxdns_upstream_enabled (an upstream that is down and one that is switched off are different operator problems). Logger writer_failed is deliberately NOT a metric: it is in /api/health and would need a gauge family of one.
  • metrics.poolSnapshot is the one place that copies pool health, shared with the health rollup: cancel protection around Pool.snapshot (server.zig's precedent), then every borrowed string duped into the request arena immediately. Cache/limiter stats are read under Handler.cache_mutex/limiter_mutex with lockUncancelableHandlerError has no Canceled, so the W5 precedent applies.
  • Ruling 13's window: until = end of the bucket now falls in, since = until - width × count, both on the absolute epoch grid (every width divides 86 400, asserted at comptime), so /api/stats and /api/stats/timeseries cover the identical span — tested by summing the buckets against the totals. Both bodies also carry period, since and until (a chart cannot label an axis without them); the timeseries body adds bucket_seconds. openapi.yaml must document those fields.
  • /api/queries: limit outside 1..1000 is a 400 rather than a silent clamp, before must be positive, and each bad parameter has its own message. next_before is the last row's id only on a full page. domain=/client= empty read as absent.
  • /api/lookup resolves group_id through groupIndexById; an id no snapshot group has is a 400 (unknown group_id), a missing manager or snapshot is 503 no snapshot loaded, and the domain runs through name.fromText + matcher.normalize (ruling 29). source_url comes from sources_repo.getSource on the config connection keyed by the snapshot's source row id — SourceSets carries the display name, not the URL; an unreadable row leaves the field null rather than failing the lookup. Records and zones are read through state.handler.?.local_tables (acquire/release bracketed by one defer), NOT a WebState field, because that pointer already exists and is the same table W7 swaps.
  • /api/upstream/health reports url, enabled, available, consecutive_failures, total_successes, total_failures, success_rate, last_error, plus available/total rollup counts. No timestamps: they are awake-clock stamps and mean nothing to a client.
  • /api/version adds zig_version and uptime_seconds (from WebState.started_unix) to the version and commit strings.
  • The only logging in this session is one warn per failed database read or pool/source fault, on the 500 path; nothing is logged for a normal request, and no std.log.err. 40 new tests (metrics 5, stats 8, queries 9, lookup 7, health 4, upstream_health 3, version 4); retention's 6 tests unchanged in number and green.

Session W7: mutation handlers + local_tables + pause + settings + auth handlers

Owns: src/web/handlers/ groups.zig, blocklists.zig, rules.zig, local.zig, clients.zig, settings.zig, pause.zig, auth.zig (login/logout), plus src/server/local_tables.zig (new, ruling 12) and the src/server/handler.zig edit to read records/zones through it (second mechanical handler change; coordinate: W5 already edits handler.zig — W7 runs AFTER W5 lands; sequence inside wave 2).

  • local_tables.zig: LocalTables { lock: std.Io.RwLock, records, zones } with acquire/release (shared) and swap(io, new_records, new_zones) (exclusive; frees the old under the lock after swap — no reader can hold across queries since acquire/release brackets each query). Handler: acquire in handle, release on every exit (same single defer discipline as the snapshot).
  • Handlers: rulings 9, 12, 15, 16, 17-18 behaviors; every mutation validates (shared validators — reuse validate.zig section rules by constructing the candidate row set; where validate.zig only does whole-config, extract the per-section check it already has — validate.zig is NOT in your ownership: if extraction is needed, report it and use a local check this milestone), writes via W2 repos on state's config connection, then reload/rebuild per ruling 12. blocklists/update → refreshAll + reload, 202 + status. settings PUT: merge, validate whole config, write keys, clear sessions on hash change.
  • Tests: per handler with in-memory dbs and a real Manager where cheap, else assert repo effects + reload-called flag via a seam (a reload_fn pointer on WebState set by W9; tests inject a counter).

Acceptance: fmt/ast clean; temp-root green; phase7 integration still green.

W7 As built

Eleven files, 4199 lines, 89 tests (local_tables 5, mutations 6, groups 13, blocklists 8, rules 7, local 12, clients 9, upstreams 6, pause 7, settings 10, auth 6).

  • The apply/respond split (house pattern for later waves). http_util's response helpers need a live *http.Server.Request, which a unit test cannot construct. Every route splits into an apply* decision function driven against an in-memory database and a thin HTTP wrapper.
  • mutations.zig is a new shared file, owned by W7. It holds the Failure union, the constraint map (NotFound → 404, Constraint → 409, validation → 400), the reload and swapLocalTables seams, and the Bench test fixture.
  • config_lock lives on WebState (ruled post-report): connection tasks share one config database connection, and changes()/lastInsertRowid() are connection state that execStrict reads, so writes serialize through state.config_lock.
  • Per-row validation: a minimal valid skeleton config plus the one candidate row runs the real validate.validate; no validator duplicated. Duplicate keys and foreign-key violations surface from the database as 409, not 400.
  • upstreams.zig is W7's (ruling 9 listed the resource; neither ownership list named it). It never calls the reload seam, refuses to delete the last enabled upstream (409), and reports restart_required: true.
  • Ruled additions: GET /api/settings returns a derived web.auth_enabled flag; the default group cannot be renamed or deleted (409); a group that still holds clients cannot be deleted (409).
  • handler.zig: records/zones replaced by local_tables: ?*LocalTables = null; handle acquires once per query, releases on defer — one query reads one generation of records, zones and the filter snapshot together. Context gained records/zones.
  • Ruled mechanical ripples: web/server.zig two-line exception (import + WebState field, plus the config_lock field above); app.zig minimal fix (var tables: LocalTables = .empty + build calls — W9 must review); udp/tcp/resolver integration tests lost their empty_records/empty_zones consts and two bareHandler initialisers; phase7 test converted to real tables where needed.
  • Routes for W8 (all match W3's signature; id routes read request.id.?): groups list get create update remove getSources putSources; blocklists list get create update remove refresh (refresh = POST /api/blocklists/update, 202 + status snapshot); rules list get create update remove; local listRecords getRecord createRecord updateRecord removeRecord listZones getZone createZone updateZone removeZone; clients list get update remove listPrefixes putPrefixes (no create); upstreams list get create update remove; pause get post; settings get put; auth login logout.

Session W8: static assets, build pipeline, openapi.yaml, route table completion

Owns: src/web/static.zig, src/web/openapi.zig, src/web/openapi.yaml, web/dist-placeholder/ (index.html + favicon.svg), build.zig (the -Dweb-dist option + asset module generation), and the final router.zig route-table registration IF W3 left registration data-driven (coordinate: W3 owns router.zig — W8 only ADDS the route array entries file src/web/routes.zig if W3 designed it that way; otherwise W8 hands W3's owner a patch — resolve by making W3 expose routes as a separate file owned by W8 from the start; W3 must read this paragraph).

Ruling 24 in full: asset module via addWriteFiles + generated index; etag comptime hash; gzip sibling serving; SPA fallback; --web-dev disk serving (cli flag lands in W9 — W8 exposes serveFromDisk(dir, ...)). openapi.yaml documents EVERY route in the table with schemas matching the handlers' JSON (snake_case, error envelope, auth markers, 429/401 responses). Tests: static matching, 304 flow, gzip negotiation, SPA fallback vs /api 404, placeholder embeds and serves.

Acceptance: fmt/ast clean; zig build with default dist green; temp-root green.

W8 As built

Seven files: static.zig 362, openapi.zig 66, routes.zig 194 (55 routes), openapi.yaml 2255, handlers/live.zig 184 (ruled mid-session — no session owned the SSE HTTP handler), tools/gen_web_assets.zig 201 (new build tool, W8's), web/dist-placeholder (real page: fetches /api/health + /api/version, renders status/upstreams/disk). 21 new tests (static 9, openapi 3, routes 6, live 3).

  • Asset pipeline: gzip cannot run in the build system itself, so tools/gen_web_assets.zig runs as a build step: dist staged through WriteFiles (Run hashes only a directory arg's path string; staging bakes the content hash into the path, so edits invalidate — proven), tool output merged with generated .gz siblings and assets.zig into the web_assets anonymous import (exe, cross exes, tests).
  • web_assets module surface: File{path, bytes, content_type, etag}, files: []const File; root is the generated assets.zig.
  • ETag computed in the tool, not comptime (same build-time constant; avoids the comptime interpreter hashing large M9 bundles). SHA-256/128-bit, quoted, strong. .gz siblings are their own files entries with their own ETag; direct *.gz paths are not addressable. The tool skips gz when it does not shrink or below 128 bytes.
  • static.zig surface for W9: state.fallback = static.fallback (embedded) or wrap static.serveFromDisk(dir, io, request) in a HandlerFn for --web-dev (app.zig owns the dir storage; no cache headers in dev mode). Traversal guard in diskRelativePath.
  • live.zig (SSE, ruling 20): respondStreaming with empty buffer, retry: 3000, event: query frames, 15 s heartbeat (awake clock) via Hub.wait, per-IP cap via tryAcquireSse/releaseSse, ring overflow ends the stream with a clean chunked terminator so EventSource reconnects. RULED: route is .session, .exempt (overrides ruling 19's connect-token line; the per-IP SSE cap is the guard, loopback included). RULED: the SSE payload omits id (a live entry precedes persistence); a comptime test pins the remaining nine field names to QueryRow.
  • Route policy: open = {/metrics, /api/health, /api/version, /api/openapi.yaml, /api/auth/login}; limiter-exempt = {/metrics, /api/health, /api/queries/live}; logout is .session (ruling 18).
  • openapi.yaml documents all 55 routes, cookie security scheme, 401 on every session route, 429 + Retry-After on every counted route, the W6 stats fields (period, since, until, bucket_seconds), SSE as text/event-stream; no certs/reload. openapi.zig's test enforces route ⊆ yaml at unit level; W10 still owns the count-equality drift guard (b).

Session W9: app + cli wiring

Owns: src/app.zig, src/cli.zig, src/main.zig (if forced).

WebState assembly (ruling 26) gated on web.enabled; two extra DB connections; hub + sink construction (sink wraps logger always — hub only when web enabled; DNS path cost without web = one null check); local_tables construction replacing the direct records/zones locals; web serve task in the group (started last); reload_fn/check seams wired; --web-dev <dir> CLI flag (parseArgs + help text + runCheck untouched); web bind failure = warn + continue serving DNS (ruling in W3 — confirm and document in app). Shutdown order unchanged (web task dies with group.cancel; its connection group cancels per ruling 4). Smoke test yours: boot with web enabled, curl /api/health, /api/version, /metrics, /, login flow with a password set (import a config with web.password, verify cookie + 401 without), SIGTERM exit 0. Report the transcript.

Acceptance: fmt/ast clean; zig build; both test suites green; smoke transcript.

W9 As built

  • cli.Command.run payload is now RunArgs { paths: Paths, web_dev: ?[]const u8 }; parseRunArgs handles --web-dev DIR (both spellings); check rejects it; runCheck untouched. Anything spawning app.run passes RunArgs.
  • app.zig serve order: zero-guard (web.api_rate_limit_per_min/session_ttl_hours zero on an enabled web → error.BadRateLimit, exit 2 — collaborators assert nonzero and a hand-edited database bypasses validate); heap sse.Hub via gpa.create + in-place init (W5 rule), gated on web.enabled; sink wraps (&logger, hub) ALWAYS (hub null without web = one branch); two web DB connections (openConfigDb + reopenQuerylogDb, after openQuerylogDb establishes the file), gated; Sessions + ApiLimiter, gated; WebState assembled after the DNS handler (reload_fn = app.reloadManager, fallback = static.fallback or the dev wrapper, version.string, started_unix); web task added to the group LAST; bind failure = warn + return, DNS keeps serving. Shutdown order unchanged. web.enabled = false → no hub, no web DBs, no sessions/limiter, no web task.
  • --web-dev dir lives in a file-scope var in app.zig (WebState.fallback is a bare fn pointer, no closure; one composition root; written once before the task starts).
  • W7's var tables: LocalTables holder confirmed correct: WebState.local_tables points at the same holder the DNS handler reads, so swapLocalTables swaps both.
  • Mechanical ripple: phase7 test line 967 cli.Paths{...}RunArgs{ .paths = ... }.
  • Smoke test passed end to end: import with web.password → boot → 200 on /api/health, /api/version, /metrics, /, /api/openapi.yaml → 401 without cookie → login sets HttpOnly/SameSite=Lax cookie → authed read → logout kills the cookie → wrong password 401 → SIGTERM exit 0. No secrets in logs. --web-dev smoked: disk edits appear without restart.
  • Critical finding (fixed post-session in W3's files): a bodyless POST/PUT — no Content-Length, no Transfer-Encoding, exactly curl -X POST — hit the discardBody assert in std (Server.zig:631) and panicked the process, DNS included. RFC 9110 gives such a request an empty body, so the fix normalizes in the request path before any respond call (see W3 As-built amendment); W10 pins it with a contract test.

Session W10: integration + contract tests + CI

Owns: src/web/web_integration_test.zig (new), .gitea/workflows/ci.yml (additive job steps only).

Contract table per ruling 23 covering every route; auth on/off matrix (seeded password_hash vs empty); SSE test (connect, cause one query via a real Handler or direct sink.publish, assert event: query frame + heartbeat + cap enforcement via sse_max_connections_per_ip=1); rate-limit 429 + Retry-After; pagination walk; mutation → reload observed (snapshot generation bump); pause via API affects a real handler decision; settings PUT round trip; static + ETag 304; drift guards (a) and (b). CI: ensure the integration job still covers everything (contract tests ride -Dintegration; add a zig build test -Dintegration step only if missing — read the current yml first).

Acceptance: zig build test -Dintegration green with zero err lines; plain suite green.

W10 As built

web_integration_test.zig, 1406 lines, 14 tests: 11 integration-gated (contract walk over all 55 routes with strict ignore_unknown_fields = false response shapes, auth on, auth off, 429 + Retry-After, SSE, pagination walk, mutation → reload, pause, settings round trip, static/ETag 304, bodyless POST) + 3 ungated pure guards (contract-covers-routes, drift guard a route ⊆ yaml, drift guard b: 55 yaml operations == router.routes.len). ci.yml UNTOUCHED — its test job already runs zig build test -Dintegration (line 25).

  • Environment fully real except two seams: real Manager/reload over an in-memory config DB, real Sessions/ApiLimiter/Hub (heap Hub per W5 rule), seeded querylog via BatchWriter. The pool transport and fetcher are never invoked — POST /api/blocklists/update runs in the walk BEFORE any source row exists (hermetic).
  • SSE reader de-frames chunked transfer coding (the unbuffered SSE writer emits one frame as many chunks; std's chunked writer emits each chunk's closing CRLF lazily — reading separators eagerly deadlocks until the next heartbeat).
  • Heartbeat asserted against the real 15 s interval (live.heartbeat_interval is not injectable): the SSE test runs ~15-20 s in a 40 s budget; the integration suite gained roughly 45 s of wall clock. Accepted rather than weakening the assertion.
  • Pause test drives Handler.handle directly with real DNS packets (phase7 queryFor pattern): blocked → API pause → forwarded (paused_queries bumps) → unpause → blocked.
  • Mutation-reload observed as generation 1 → 2 plus the rule live via GET /api/lookup.
  • Contract markers asserted by lookup into router.routes (method+pattern → auth and rate_limit equality, exact one-to-one coverage); response shapes reference the handlers' pub types where they exist; the strict settings parse also proves web.password* never serializes.
  • One expected warn line ("web login refused") from the wrong-password case; zero err lines suite-wide; no secrets logged.

Module layout (new)

src/web/{server,router,http_util,auth,api_limiter,sse,static,openapi,metrics}.zig, src/web/routes.zig (W8), src/web/handlers/{auth,stats,queries,clients,groups,blocklists, rules,local,lookup,pause,settings,upstream_health,health,version}.zig, src/server/{query_sink,local_tables}.zig, src/web/openapi.yaml, web/dist-placeholder/, src/web/web_integration_test.zig.

File ownership

W1 queries_repo; W2 other repositories/*; W3 web/{server,router,http_util}; W4 web/{auth,api_limiter} + model/validate additions; W5 server/query_sink, web/sse, storage/logger (additive), server/handler (sink rename) + phase7 test compile fixes; W6 web/handlers read set + web/metrics; W7 web/handlers mutation set (incl. mutations.zig, upstreams.zig) + server/local_tables + server/handler (local_tables read path; AFTER W5); W8 web/{static,openapi,routes} + openapi.yaml + dist-placeholder + build.zig; W9 app/cli/main; W10 its test file + ci.yml. Orchestrator: src/tests.zig, spec. Within-wave parallel sessions never share a file; handler.zig is touched by W5 then W7, strictly sequenced.

Acceptance (milestone complete)

  • Both suites + cross green; fmt clean; zero err-level lines in integration output.
  • Every PLAN:537-550 endpoint except certs/reload (ruling 2) implemented, documented in openapi.yaml, and contract-tested; auth on/off matrix green.
  • SSE live stream works end to end with per-IP cap; fanout precedes persistence.
  • /metrics scrapes clean (golden test); /api/health rollup correct.
  • W9 smoke transcript: boot, API answers, login round trip, SIGTERM 0.
  • Placeholder UI loads at /; assets ETag/304/gzip machinery proven.
  • Spec As-built synced per session.

Review (Codex, As built)

Round 1: 6 important + 3 minor, all fixed.

  • local.zig: all six mutation paths hold config_lock across write AND rebuild+swap, so LocalTables swaps publish in database-write order (deadlock-checked: no callee takes the lock).
  • settings/auth/server/app: GET /api/settings reads under config_lock. New auth.LiveHash (mutex holder on WebState.live_hash, generation-checked — see round 2): login copies the hash out under a short lock and verifies OUTSIDE it; settings PUT dupes the new hash with gpa BEFORE the transaction, installs + clears sessions after commit; sessionAuth gates on live_hash.enabled(), so a first password set via PUT locks routes without restart. Boot hash borrows the config arena (owned=false); replacements are gpa-owned, freed on the next install or deinit (app.zig defer / Bench.deinit / Env.destroy).
  • mutations.zig checkUpstream: candidate validated beside a companion enabled upstream (URL collision-avoided), so disabled upstreams are creatable. upstreams.zig applyUpdate gained the missing last-enabled guard: disabling the last enabled upstream is 409 (delete path already had it; create needs none).
  • clients.zig: prefixes canonicalized via platform/address.zig (parse zeroes host bits; RFC 5952 text) before a set-level duplicate check (409, constraint-map ruling) and whole-list validation through the real validate.validate; canonical text is stored.
  • api_limiter.zig: full table fails closed — bucketLocked first evicts a bucket that refills to capacity with sse==0 (behaviorally identical to fresh; sweep's idle window is churn hysteresis, not correctness), else check refuses with retry_after and tryAcquireSse returns false. untracked is now a subset of refused.
  • static.zig: acceptsGzip parses every entry per the header grammar (specific gzip beats wildcard; malformed q = entry unusable). Dev mode realpath-checks containment of target AND index fallback (no-follow-on-open would miss symlinked intermediate directories).
  • web_integration_test.zig: drift guard (a) requires the method key inside the specific path's yaml block; a negative test doctors the yaml (method swap between two paths) and proves the guard bites.

Round 2: 1 important — login could copy the old hash, race a password-change PUT (install + clearAll), finish argon2 verification, and mint a session with the revoked password. Fixed via a generation counter on LiveHash: applyLogin verifies the copy outside any lock, then confirmSession checks the generation and inserts the session digest under LiveHash.mutex (lock order: live_hash → sessions, single direction at every site); a changed generation denies the login.

Round 3: 2 minors, both fixed. (a) install-then-clearAll had a gap where a legitimate new-password cookie could be minted and then wiped — install is replaced by installAndRevoke(io, gpa, sessions, new_hash): swap, generation bump, and nested clearAll in one LiveHash-ordered operation; a confirm either precedes it (wiped) or follows it (stale generation, denied). (b) confirmSession no longer holds LiveHash.mutex across entropy/clock work: token (randomSecure, zeroed on exit) and timestamp are generated before the lock; only the generation check + createWithToken digest insert run under the ordered locks. Sessions.create removed (createWithToken is the seam).

Round 4: no findings.

Anti-requirements

  • No React/SPA content (milestone 9). No DoH/DoT server or certs/reload (Phase 9). No docs rendering (Phase 10). No WebSocket. No HTTP/2, no TLS on the web port. No external schema validator or yaml parser. No new DB schema migration without an explicit orchestrator ruling (27). No pause persistence. No per-request timeouts. No changes to dns/, filter/matcher, cache, or the DNS pipeline order.