27 KiB
Milestone 19: hygiene sweep
Goal: remove the dead surface, unify the re-hardcoded constants, close the
small silent failures, and fix the frontend state hazards — the residue of
TECH_DEBT.md Theme 8 plus the remaining lows, after the behavioral fixes
(m16), the contract work (m17) and the duplication refactors (m18) have
landed.
PROVISIONAL. This spec was written before milestones 16-18 were built. It
assumes: m17 shipped BADVERS (so edns.extendedRcode has a production
caller), m17 built the upstream editor (so the queries.ts upstream factories
have consumers and are not deleted here), and m18 shipped the frontend ui/
extraction and deleted doh_server's dead serve(). Before starting this
milestone, re-verify every line reference and update this spec where the
earlier milestones moved things.
Rulings (binding)
1. The dead ECS parse surface is deleted
src/dns/edns.zig: delete parseEcs (:111), Ecs (:98-105), EcsError
(:107), ecs_family_ipv4/ecs_family_ipv6 (:95-96), and their in-file tests
(:371-:469 block, nine tests). stripEcs filters by option code without
decoding, forward mode keys on raw bytes — no production path decodes ECS.
Trap the audit missed: tests/fuzz/dns_fuzz.zig:86 calls edns.parseEcs
inside a fuzz target that the default test step builds. Delete that line in
the same change or the build breaks.
extendedRcode (:272) is kept: milestone 17 gave it the BADVERS caller.
If m17 diverged and BADVERS was not built, delete it too and note the
divergence here.
Precedent: ecsPayload was built and removed within milestone 7 when it lost
its only caller (specs/milestone-7.md:361). Git history keeps the code.
2. writeSettings returns an error union
src/web/handlers/settings.zig:349 returns ?db.Error, so every exit is a
value return and the errdefer at :354 never fires — it implies protection
it cannot provide. The real safety is two explicit tx.rollback() calls
(:358, :363 — the audit said three; it is two).
Change the return type to db.Error!void. The errdefer becomes live
(db.Tx.rollback at storage/db.zig:814 is idempotent and documented safe in
an errdefer), and the two manual rollback-then-return arms collapse into
plain try/error returns. The one caller (applyPut, :327) switches from
if (writeSettings(...)) |err| to catch; its hash-free-on-failure behavior
is unchanged.
3. The logger's field widths become the single source
src/storage/logger.zig:44-48 holds four file-private constants
(max_domain_len = 253, max_client_len = 45, max_reason_len = 32,
max_upstream_len = 64). Make all four pub. Then:
src/server/handler.zig:67deletes its localmax_reason_len = 32; the comptime guard at :69-75 checkslogger.max_reason_len, so the guard finally proves what its comment claims.src/server/handler.zig:79deletes its localmax_ip_text = 45and useslogger.max_client_len(the :82max_resolver_textderivation follows).src/server/clients.zig:32likewise.src/web/handlers/queries.zig:27-30— the fifth copy the audit missed — adoptslogger.max_domain_lenfor the domain width. Itsmax_client_lenis 64, not 45; the query log's client column is written by the logger and can never exceed 45 bytes, so it adoptslogger.max_client_lentoo. If a test proves a wider value ever reaches that field, stop and record the divergence instead of forcing it.
Acceptance is a grep: the literals 253, 45 and 32 appear in these roles only in logger.zig.
4. track() reports fullness; one mutex acquisition per query
src/server/handler.zig:255-258 takes the tracker mutex twice per query:
tracker.track(io, from) and then tracker.snapshotStats(io) — a full
struct copy — solely to mirror dropped_full into the handler's atomic
tracker_full (:150-153).
Change Tracker.track/trackAt (src/server/clients.zig:89-97) to return
u64: the current dropped_full, read under the mutex they already hold.
The handler stores that return value. snapshotStats stays for /metrics.
5. Log truncation gets a marker and a counter
src/platform/logging.zig:285-288: mw.print(format, args) catch {} on a
fixed 2048-byte writer, then the partial buffer is used with no marker — the
module's own never-both-discarded-and-silent invariant, violated in its own
kitchen. On error.WriteFailed: overwrite the final three bytes of the
buffered message with ... (the safe_url.zig marker, src/safe_url.zig:11-14)
and increment a new state.stats.lines_truncated, sibling to
lines_deduped (:295). One test: a message over 2048 bytes ends in ...
and bumps the counter by one.
6. TLS errors are classified by name, not by name prefix
src/filter/fetcher.zig:169-170 and src/upstream/doh_client.zig:160-161
classify TLS failures with startsWith(@errorName(err), "Tls") /
"Certificate" — a std rename silently downgrades TLS failures into generic
buckets. Verified reachable set from std.http.Client (0.16.0) today:
exactly error.TlsInitializationFailed and
error.CertificateBundleLoadFailure. But this milestone lands after
milestone 18, whose ruling 4 makes the DoH path unwrap the client's stashed
read cause — and that cause set includes the record-layer members
(TlsAlert, TlsBadRecordMac, TlsDecodeError, ...) from
std.crypto.tls.Client. A two-member switch written against today's
surface would downgrade those to generic receive failures.
In both files: replace the prefix match with an exact switch → error.TlsFailed
naming the two top-level members plus, on whichever paths m18's cause
unwrap surfaces, the Tls* members of the unwrapped read-cause set —
enumerated at implementation time from the pinned std sources against the
m18 code as landed, not from this spec's list. The else falls through to
the existing per-phase mapping. Rewrite both tests (fetcher.zig:311-313,
doh_client.zig:261-263): they currently assert on CertificateExpired,
which nothing can produce, and omit CertificateBundleLoadFailure; the new
tests use only names from the verified reachable set, including a stashed
record-layer cause on the unwrapped path.
If m18 already extracted a shared mapError into transport.zig, apply this
ruling to the shared copy instead and note it here.
7. The transcribed mbedTLS values get an init-time check
src/platform/tls_server.zig:501-511 hand-transcribes four config enums and
six error codes from the pinned Mbed TLS 3.6.7 headers with no drift guard —
unlike the sizes and alignment, which the shim reports. A renumbered
close_notify after a version bump would silently invert truncation
detection (:286, :351).
src/platform/mbedtls_shim.c gains ten getters in the existing shape
(int nx_const_ssl_transport_stream(void) { return MBEDTLS_SSL_TRANSPORT_STREAM; }
etc. — the needed headers are already included). tls_server.zig verifies
all ten against the transcribed constants next to the existing alignment
assert at :78, and the :657 test block asserts them too. Cheap insurance,
one-time cost.
8. The reload lock invariant is written down
Fourteen call sites in four handler files (groups, rules, clients,
blocklists — the audit said five handlers; it is four files, fourteen sites)
call mutations.reload after releasing config_lock. That is safe only
because the production reload_fn re-reads the database under the manager's
own writer lock — a cross-module fact stated nowhere. local.zig is the
deliberate opposite (publish under the lock; the ordering rationale lives at
local.zig:80-85).
Extend the doc comment on mutations.reload
(src/web/handlers/mutations.zig:122-125): the reload_fn contract is that it
re-reads all state from the database itself; it must never accept pre-read
rows (contrast swapLocalTables, which is the pre-read shape and is why
local.zig holds the lock). A future signature change to reload that adds
row-passing breaks fourteen call sites' correctness — the comment must say
exactly that. Documentation only; no locking change.
9. web_dev_dir moves into WebState
src/app.zig:605-609 holds the --web-dev directory in a module-level
mutable global because the fallback handler "has no closure" — but
serveWebDev (:613-620) receives *WebState and discards it. Add
dev_dir: []const u8 = "" to WebState (src/web/server.zig, beside
version), set it at the composition root, read it in serveWebDev, delete
the global and its apologia comment.
10. The private key PEM is wiped
src/server/cert_store.zig:326 frees the key PEM without zeroization, and
readPem (:345-362) uses readFileAllocOptions, whose grow-as-you-read can
leave intermediate copies in freed pages — a wipe at the call site cannot
reach them. Split the key path: a readKeyPem that reads through a single
fixed max_pem_bytes + 1 buffer (no reallocation), copies to an exact-size
allocation, and std.crypto.secureZeros the big buffer before returning; the
caller wipes the returned slice before gpa.free. The idiom precedent is
auth.zig:228. The certificate PEM path stays as-is — it is public material.
Runs at boot and on real renewals only (:311-312 stats gate the poll).
11. Header-array overflow asserts
src/web/http_util.zig:335 maps a too-long extra_headers onto
error.OutOfMemory — a future eighth header would surface as mysterious
OOM-labeled drops. Every one of the 62 call sites passes a compile-time
count, maximum 3 (static.zig:174, +1 for content-type = 4 of 8). Replace the
check with std.debug.assert(extra_headers.len + 1 <= headers.len); so the
mistake fails loudly in tests.
12. Shipped .gz siblings are verified; orphans are rejected
tools/gen_web_assets.zig:71-77 embeds a dist-shipped .gz sibling with
zero content checks, and an orphan .gz (no base file) is silently embedded
as unreachable application/octet-stream bytes. Exposure is latent (no
frontend compression plugin today) — close it while it is cheap:
- Sibling: decompress with
std.compress.flate.Decompress(container.gzip; the tool already uses the Compress side at :133) and byte-compare against the base file;std.process.fatalon mismatch, matching the tool's existing error style (:73-75). - Orphan:
std.process.fatalnaming the path, instead of indexing it.
Extend the embedded-dist test (src/web/static.zig:413-439) to decompress
each .gz entry and compare with its base — it currently checks magic bytes
and size only.
13. The Dockerfile arch default comes from the host
deploy/docker/Dockerfile:21: ${TARGETARCH:-amd64} — under the legacy
builder TARGETARCH is empty, so a plain docker build on the Pi 5 packages
the x86_64 binary and dies at docker run with exec-format, far from the
mistake. Replace the default with a uname -m mapping resolved before the
case:
RUN arch="${TARGETARCH:-}"; \
if [ -z "$arch" ]; then case "$(uname -m)" in \
x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; \
*) echo "unsupported build host $(uname -m); use buildx" >&2; exit 1 ;; \
esac; fi; \
case "$arch" in ...existing arms... esac
The existing unsupported-arch arm stays fail-fast.
14. Frontend: the settings registry is typed
web/src/features/settings/SettingsPage.tsx — SectionDef exists (:21) but
FieldDef.key is string (:16), driving as Record<string, unknown> casts
at :217, :226, :261. A typo'd key compiles and breaks the field. Make the
pair generic:
interface FieldDef<S extends keyof Settings> {
key: keyof Settings[S] & string;
kind: "number" | "text" | "boolean" | readonly string[];
}
interface SectionDef<S extends keyof Settings> { section: S; title: string; fields: readonly FieldDef<S>[]; }
function defineSection<S extends keyof Settings>(def: SectionDef<S>) { return def; }
SECTIONS (:35-118, 12 sections) becomes a tuple of defineSection(...)
calls, TLS_FIELDS (:27) becomes FieldDef<"doh_server" | "dot_server">[]
compatible, and the three Record<string, unknown> casts are deleted. The
per-branch value casts inside FieldRow (:141, :160, :174, :198) may stay —
they are narrowing on kind, not drift holes. Acceptance: renaming a
Settings field makes tsc fail on the registry.
15. Frontend: the settings baseline is frozen
Same file, :220 — buildSettingsPatch(data.settings, edited, ...) diffs the
mount-time clone against live query data, so a background refetch makes
out-of-band changes appear as user edits and Save silently reverts them. Add
a baseline state initialized with the same structuredClone and reset in
the same onSuccess (:234-241) where edited resets; the patch becomes
buildSettingsPatch(baseline, edited, ...). settingsDiff.ts is unchanged.
16. Frontend: refresh status leaves the query cache
web/src/features/blocklists/BlocklistsPage.tsx:33 reads the refresh
snapshot with non-subscribing getQueryData from a cache entry that has no
queryFn, no subscriber, and the default 5-minute gcTime — after which the
page claims no refresh ever ran. This is client UI state. New module
web/src/features/blocklists/refreshStore.ts in the exact shape of
settings/restartBanner.ts (module-level value + listener Set +
useSyncExternalStore hook), holding SourceStatus[] | null. The mutation
(blocklistsUpdateNowMutation, lib/queries.ts:156) writes the store instead
of setQueryData; the page subscribes via the hook; the
queryKeys.blocklistSources entry and its doc comment are deleted. Note the
prefix-invalidation side effect disappears with it (invalidateBlocklistWorld
invalidating ["blocklists"] used to mark the entry stale — harmless, since
the entry could never refetch).
17. Frontend: one default-group helper
Four encodings of "group 1", two semantics (GroupsPage.tsx:14 named
constant; PrefixesEditor.tsx:21 and LookupPage.tsx:133 byte-identical
prefer-id-1 expressions; RulesPage.tsx:24 first-of-list — which, because the
API orders by name (groups_repo.zig:148), preselects the alphabetically
first group, not the default). New module web/src/lib/defaultGroup.ts:
export const DEFAULT_GROUP_ID = 1;
export function defaultGroupId(groups: readonly Group[]): number {
return groups.find((g) => g.id === DEFAULT_GROUP_ID)?.id ?? groups[0]?.id ?? DEFAULT_GROUP_ID;
}
All four sites adopt it; RulesPage's preselect bug goes away with the adoption.
18. Frontend: the dashboard primes without throwing
web/src/routes.tsx:97-102 — the dashboard loader's Promise.all over four
ensureQueryData calls means one failing endpoint blanks the whole page with
RouteError on cold navigation, defeating the page's own per-widget
degrade (DashboardPage.tsx:68-96, which uses useQuery precisely to allow
it). Change only the dashboard loader to Promise.allSettled. The five
other Promise.all loaders stay: their pages use useSuspenseQuery and have
no granular fallback to preserve.
19. Frontend: the query-key table is complete
web/src/lib/queries.ts — eight raw literals, all in this file: seven
["lookup"] prefix-invalidations (:99, :125, :132, :159, :167, :189, :211)
and one ["groups"] (:133). Add lookupAll: ["lookup"] as const to
queryKeys, use it at the seven sites, and use queryKeys.groups at :133.
Acceptance: no ["lookup"]/["groups"] literal outside the table.
20. Frontend: the form catch swallows only the expected class
web/src/features/blocklists/BlocklistForm.tsx:21-33 — the bare catch {}
wraps both the await onSubmit and the reset setState calls; anything but
the mutation rejection dies silently with no console trace. House precedent
is auth/store.tsx:85-89 (swallow only the 401 it expects, rethrow the rest),
pinned by two tests. Restructure: try { await onSubmit(...) } catch (error) { if (error instanceof ApiError) return; throw error; }, resets after the
try. The page's <InlineError> keeps rendering the mutation error as today.
Carried in from milestone 17: the stale phase-comment sweep
Milestone 17 ruling 7 rewrote the "Phase 7/8" headers in three files; its repo-wide acceptance grep then found 24 more stale phase references in files its sessions did not own, recorded here for this milestone: safesearch.zig:58, forward_client.zig:7, dns_cache.zig:10/:42/:435, cli.zig:332, filter_integration_test.zig:1391, logger.zig:178/:303, retention.zig:7/:137/:151, disk_monitor.zig:7, querylog_schema.zig:71, db.zig:272, manager.zig:292/:613/:1145, and the six repository files (groups_repo.zig:7, rules_repo.zig:15, sources_repo.zig:9, upstreams_repo.zig:7, local_repo.zig:3, clients_repo.zig:12-13). Each comment is rewritten to state the as-built truth it gestures at (not deleted, unless it says nothing beyond the phase number). Line numbers are hints from m17-time HEAD; re-grep before acting. Acceptance: "Phase 7" and "Phase 8" appear in no source doc comment repo-wide; spec files and TECH_DEBT.md are exempt. The session owning each file in the plan below picks up its share; files owned by no session fall to S4.
Also carried from milestone 17 (ruling 4 residual): the login log line in
src/web/handlers/auth.zig prints the socket peer, which reads as
loopback for every login behind a trusted proxy; it should print
request.client_addr.
Sessions
S1-S4 run in parallel; no two sessions write the same file.
Session S1: backend surface
Owns src/dns/edns.zig, tests/fuzz/dns_fuzz.zig,
src/web/handlers/settings.zig, src/web/handlers/mutations.zig,
src/web/http_util.zig, src/server/cert_store.zig, src/app.zig,
src/web/server.zig. Rulings 1, 2, 8, 9, 10, 11.
Session S2: constants, counters, classification
Owns src/storage/logger.zig, src/server/handler.zig,
src/server/clients.zig, src/web/handlers/queries.zig,
src/platform/logging.zig, src/filter/fetcher.zig,
src/upstream/doh_client.zig, src/platform/tls_server.zig,
src/platform/mbedtls_shim.c. Rulings 3, 4, 5, 6, 7.
Session S3: frontend
Owns everything under web/src/. Rulings 14-20.
Session S4: tools and deploy
Owns tools/gen_web_assets.zig, src/web/static.zig,
deploy/docker/Dockerfile. Rulings 12, 13.
Orchestrator
Re-verifies this spec's line references against post-m18 reality before S1-S4
start; strikes the closed findings in TECH_DEBT.md after.
Module layout
New files:
web/src/features/blocklists/refreshStore.ts— refresh snapshot store.web/src/lib/defaultGroup.ts— default-group constant and helper.
Deleted surface: parseEcs/Ecs/EcsError/ecs_family_* (edns.zig), the
web_dev_dir global (app.zig), the queryKeys.blocklistSources entry
(queries.ts).
Acceptance (milestone complete)
parseEcsand its family are gone;zig build testcompiles and passes (the dns_fuzz caller was removed in the same change).writeSettingsreturnsdb.Error!void; the errdefer is the only rollback path; the settings PUT integration tests still pass. A fault test forces a failure afterTx.begin(a fault seam underbuiltin.is_test, the milestone-15 rotation-seam shape) and proves the errdefer rolled the transaction back: a subsequentTx.beginon the same connection succeeds.- Grep proves the field widths exist once: 253/45/32 in their logger roles appear only in logger.zig.
- One tracker-mutex acquisition per query:
trackreturnsdropped_full;/metricsoutput for the tracker family is unchanged. - An oversized log message ends in
...and incrementslines_truncated; the new test pins both. - Both TLS mapError switches name exactly the two reachable errors; the rewritten tests use reachable names only.
- The ten mbedTLS constants are verified at init against shim getters; a deliberately wrong transcription fails the :657 test block.
mutations.reload's doc comment states the re-read contract and the fourteen-call-site consequence.WebState.dev_dirreplaces the global;--web-devserving still works (manual check with a dev dir).- The key PEM path reads through a fixed buffer and is wiped before
free; no
readFileAllocOptionsremains on the key path. respondBytesasserts instead of returning OutOfMemory.- gen_web_assets rejects a corrupted shipped sibling and an orphan
.gz(both proven with a doctored dist in a temp dir); the embedded test decompresses and compares. docker build(legacy builder, no buildx) on this host produces a runnable image; the Dockerfile has no bare:-amd64default.tscfails when a Settings field named in SECTIONS is renamed (proven, then reverted).- The settings baseline freezes at mount: a simulated background refetch with out-of-band changes produces no phantom patch entries (frontend test).
- The refresh snapshot survives past gcTime: a frontend test renders the status 6+ minutes of fake time after the mutation.
- All four group-id sites import
defaultGroup.ts; RulesPage preselects the id-1 group when present (frontend test with a group sorting before "default"). - The dashboard renders per-widget errors on cold navigation with one endpoint failing (frontend test); the other five loaders are unchanged.
- No
["lookup"]or["groups"]literal outsidequeryKeys. - BlocklistForm rethrows a non-ApiError (frontend test, mirroring the logout pair).
- Full suite green:
zig build test -Dintegration,npm run test,npm run typecheck,npm run lint.
Recorded (implementation)
Accepted deviations and findings from the built milestone.
Ruling 1 (S1)
- The edns.zig test block held six
parseEcstests, not nine. A seventh use lived inside "encodeOpt round-trips through record parse and parseOpt", which is not a parseEcs test; its last two lines now compare the found option's bytes directly. extendedRcodestays, with a nuance the ruling's premise missed: the BADVERS production path uses the write-sidesplitRcode; extendedRcode is the read-side verifier whose callers are the two BADVERS wire-format tests (handler.zig, packet.zig). Deleting it would weaken those tests.- The write-fault seam for the rollback test has no exported control surface — the test lives in the same file and needs none.
- The ruling-8 doc comment names
local.zig'spublishfunction instead of a line number, so it cannot go stale.
Rulings 3-7 (S2)
- Ownership amendment:
src/web/metrics.zig(owned by no session) was granted to S2 mid-flight. Ruling 5's new counter raises the /metrics sample count 29 → 30 (nxdns_log_lines_truncated_total); the hard-coded assertion became fully derived (1 + dns_stat_fields.len + @typeInfo(LoggerCounters)... + @typeInfo(logging.Stats)...), so a new counter in any of those structs extends the exposition and the assertion together. - Ruling 6 lands asymmetrically, following the ruling body over the
stale acceptance bullet: doh_client.zig names eleven members (the two
collapsed top-level ones plus the nine
Tls*members of the unwrapped read-cause set); fetcher.zig names only the two, because it has no cause unwrap and no record-layer member can reach it. Both files carry a comment pointing at the other. - Addition beyond the ruling: an exact switch does not catch a std
rename —
error.Xin an expression names a member into existence, so a renamed member leaves the switch compiling and matching nothing. Comptime guards close this for real: doh_client.zig asserts every member ofstd.crypto.tls.Client.ReadErrormaps toTlsFailed, and both files assert the two collapsed names still exist instd.http.Client.RequestError. - The ruling's "stashed record-layer cause through the unwrap" test is
not constructible: the stub connection is
.plainon purpose, and a plain connection's stashed error type has noTls*member. The record-layer set is instead tested by aninline forover the std error set againstmapError— all nine members, not one sample. - Ruling 3 behavioral note: queries.zig's client width dropped 64 → 45
as directed; a
?client=filter of 46-64 bytes now returns 400 instead of matching nothing.src/filter/wildcard.zig'smax_pattern_len = 253is a rule-pattern limit, not a logger role, and stays.
Rulings 14-20 (S3)
- Ruling 14: the three
Record<string, unknown>consumption casts collapse into one documented cast insidesectionValuesrather than zero — iterating the heterogeneous registry erases the per-section correlation TypeScript needs, andTlsListenerSettings(a named interface, no implicit index signature) blocks the assignment route with types.ts off-limits. Four casts before, one after. - Ruling 20: the catch body lives in an exported
swallowMutationErrorhelper because jsdom never firesunhandledrejection, so a rethrow from an async submit handler cannot be asserted directly; the helper is tested directly, mirroring how auth/store.tsx's logout pair is tested. - The refresh store notifies synchronously inside
onSuccess, one flush before react-query's success dispatch; one pre-existing assertion moved fromgetByTexttoawait findByText. The sub-frame transient was judged cheaper than batching through notifyManager. lib/queries.tsimportsfeatures/blocklists/refreshStore.ts(lib → features) because the spec directs the mutation factory to write the store; recorded as sanctioned layering.SourceStatusSection's prop isSourceStatus[] | nullto match the store; BlocklistsPage tests clear the module-level store inbeforeEach.- Ruling 18: post-m18 there are four other
Promise.allloaders, not five.
Rulings 12, 13 and carry-ins (S4)
- The undecodable-sibling message reports
decompress.err orelse err, becausestd.Io.Readercollapses every decode failure intoerror.ReadFailedand stashes the real one. - The Dockerfile keeps its
&&chain instead of the spec sketch's;separators, so any failed step still aborts the layer; the case arms are untouched. - The phase-comment sweep leaves
PLAN.md:605/:609alone — they are the section headings that define Phase 7 and Phase 8, not stale comments. - The orchestrator added the host-fallback sentence to docs/how-to/install-with-docker.md.
Residual (named, not fixed)
fetcher.zig hands the collapsed ReadFailed/WriteFailed to
mapError with no cause unwrap, so a blocklist download canceled at
shutdown returns error.ReceiveFailed instead of error.Canceled — the
class m18 ruling 4 fixed for DoH. Consequence: a wrong source status and
a spurious warn line at shutdown. Fixing it needs req/resp plumbing
at four call sites; it deserves its own ruling.
Anti-requirements
- No deletion of
extendedRcode(m17 gave it a caller) and no deletion of the upstream mutation factories (m17 built their consumer). - No locking change in the reload handlers — ruling 8 is documentation.
- No new frontend dependencies; the store is hand-rolled like restartBanner.
- No compression plugin for the frontend build; ruling 12 guards the seam, it does not start using it.
- No
useInfiniteQuerymigration and no QueryLogPage work — that was milestone 16. - No renaming of settings keys or API fields — typing the registry must not change the wire format.