milestone 25: client names learned over reverse dns
Gates / test (push) Successful in 2m58s
Gates / frontend (push) Successful in 3m57s
Gates / test-aarch64 (push) Successful in 8m20s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 19m9s

This commit is contained in:
2026-08-15 11:18:44 +02:00
parent c428bc2398
commit 3c794b645b
20 changed files with 2638 additions and 36 deletions
+642
View File
@@ -0,0 +1,642 @@
# Milestone 25: client names learned over reverse DNS
Goal: the clients table names devices by itself. The tracker already
materialises every querying address into a `clients` row (PLAN §7.2,
`src/server/clients.zig`), but `name` stays NULL until the operator types one —
which defeats the point of auto-materialisation on a LAN whose router already
knows every DHCP hostname. This milestone asks the router: for each unnamed
client, nxdns builds the address's reverse name, matches it against the
operator's conditional forward zones (PLAN §6.5), sends one PTR query to the
zone's resolver, and stores the answer as a *learned* name — runtime state
beside `last_seen`, never configuration. The dashboard shows it; a hand-typed
name always wins; a reverse name no forward zone covers is never sent anywhere.
Design written 2026-08-14 against HEAD `c428bc2`, revised after a Codex review
of the first draft (all 19 findings accepted; the review's fingerprints are
called out inline where a first-draft claim was wrong).
## Implementation contract (read first)
- Read `AGENTS.md`, then this spec whole, before session work starts.
- **The v1 baseline is editable and there is no migration step** (milestone-24
ruling 1 stands unchanged). The two new columns are lines edited into
`config_schema.ddl_v1` plus the identical lines in PLAN §11.2, kept
byte-identical. A `ddl_v2` or a second `Step` is wrong and must be reverted.
A development database stamped version 1 before the edit fails its first
`SELECT` naming the columns; delete the scratch database.
- The new `src/local/reverse_name.zig` is pure: bytes in, bytes out, no
`std.Io`, no clock, no sockets. (The blanket claim "src/local/ is pure" from
the first draft was false — `forward_client.zig` does socket I/O by design;
the purity requirement here binds the *new* module and the lookup-table
modules it sits beside.) Everything that touches the network or the database
lives in `src/server/`.
- After the API shape change, regenerate
`web/src/lib/contractSamples.gen.ts` with the AGENTS.md command (never a
hand edit) and update `web/src/lib/types.ts` to match, in the same session.
- Verify stdlib claims against `../zig` at tag 0.16.0. `specs/research/` holds
verified notes. One verified here: randomness is on the `Io` interface —
`io.random(buffer)` (`std/Io.zig:2468`); `std.crypto.random` does not exist
in 0.16.0.
- No `std.log.err` in new code. PTR answers are untrusted bytes: never let one
reach a log line, a metric label, or the UI without passing `acceptHostname`
(ruling 4).
## Rulings (binding)
### 1. When: the tracker's flush pass resolves, bounded, serial, last
Resolution is a step appended to the tracker's existing flush pass
(`Tracker.flushOnce`, `src/server/clients.zig:164`), not a new loop and not a
query-path hook. Reasons: the pass already runs every `flush_interval_s` (60 s)
on a dedicated task with a dedicated `config.db` connection (app.zig:523-524,
:754), it already respects the disk-monitor gate, and a name can only be
learned for a row that exists — which the flush itself just wrote. A DNS query
must never wait on naming, and with this placement it structurally cannot.
**Order within one pass is fixed: drain-and-write, then prune (when due), then
resolve.** One `now_s` is read at the top of the pass and used by all three
steps. Resolving before pruning could spend PTR queries on rows the same pass
deletes; the fixed order makes that impossible, and a test proves a row past
the prune cutoff on a due pass causes no exchange.
Per pass, the resolver:
- selects at most `max_per_pass = 16` candidate rows (ruling 3 defines a
candidate), ordered by `name_attempt_after` ascending then ip;
- resolves them **serially** — in-flight is exactly 1. At 16 per 60 s pass the
LAN resolver sees at most ~0.27 queries/second from naming.
**Naming has its own read timeout**, `ptr_read_timeout` = 2 s on the `.awake`
clock, a `pub const` on the resolver — it does *not* inherit the handler's
`forward_read_timeout`, which the operator may have set as high as the
upstream budget. **The budget bounds the whole attempt, not one transport
leg**: `ForwardClient` hands its duration to the UDP receive and then again
to the TCP fallback, so a resolver that answers UDP late with TC=1 and then
stalls TCP would spend up to 2× the budget per attempt — 64 s per pass, not
32. The default `exchangeFn` therefore wraps the entire `ForwardClient`
exchange in `transport.raceWithin(io, ptr_read_timeout, ...)` (the same
mechanism `exchangeTcp` itself uses, forward_client.zig:195), so UDP,
fallback and all share one deadline. The worst case is 16 × 2 s = 32 s of
naming per pass, and that number is a bound, not an estimate. That is acceptable because the drain already ran first: a slow pass
delays the *next* pass's start (the run loop sleeps after `flushOnce`
returns), and one extra interval against a 512-slot pending table on a
household LAN drops nothing. The acceptance test for this: with a stub client
that times out on every exchange, the pass still drains its pending batch
before any exchange is attempted, and attempts stop at `max_per_pass`.
Retry cadence is split by outcome (first draft used one 24 h cadence for
everything, which left a freshly-declared zone, a recovered router, or a fixed
resolver unnamed for a day while forward-zone changes publish live):
- **definitive** outcomes (`answered`, `nxdomain`): next attempt after
`refresh_after_s = 86_400` — the daily refresh that tracks DHCP renames;
- **non-definitive** outcomes (`no_zone`, `failed`, `invalid`): next attempt
after `retry_after_s = 3_600` — an operator who declares the missing zone
or fixes the resolver sees names within the hour, and a dead resolver costs
at most 16 timeouts per hour, not per minute.
Both constants are `pub const` on the resolver; no config knob (household
scale, AGENTS.md: no generality nobody asked for).
### 2. Where: only through a declared forward zone, never the pool
The reverse name (`10.1.168.192.in-addr.arpa`, or the 32-nibble `ip6.arpa`
form) is matched against the live forward-zones table — the same
`forward_zones.Zones.match` the query path uses, bracketed by
`LocalTables.acquire`/`release` (`src/server/local_tables.zig:48`), so naming
always sees the generation the operator last published.
**The zone's `resolver` is copied by value under the shared lock, and the
handle is released before any socket work.** `match` returns a pointer into
the current table generation, and `swap` frees the old generation as soon as
it holds the exclusive lock — a handle held across a PTR exchange, or a
released handle whose `*const Zone` is still dereferenced, is a use-after-free
against a config reload. `validate.Resolver` is a plain value; the copy is the
whole fix. A regression test must place the `swap` **inside the hazard
window** — after the handle release, before the copied resolver is used. A
test that swaps before `runPass` evaluates its arguments exercises nothing
(the first implementation's test had exactly this defect: the stub's exchange
performed the swap, but the resolver had already been copied into the call's
arguments, so the test passed regardless of what production retained). The
required shape: two candidates in one pass, two generations whose zones
cover both but carry **different resolver ports**; the stub `exchangeFn`
performs the swap during the *first* attempt (freeing the old generation
under `testing.allocator`) and records each attempt's resolver. Assertions:
the first attempt's resolver still reads the old generation's port after the
swap (the copy, not freed memory), and the second attempt's resolver reads
the *new* generation's port (each attempt re-acquires; no table pointer or
handle is cached across attempts). With a production `runPass` that retains
`*const Zone` or the handle across the release, the first assertion reads
freed memory and the second reads the stale generation — either fails.
- Match found: one PTR query to that zone's resolver through
`forward_client.ForwardClient`, built per attempt as `Context.viaForwardZone`
builds one (handler.zig:411), with `ptr_read_timeout` (ruling 1).
- No match: **no query is sent to anyone** — not the pool, not any resolver.
The attempt counts under `no_zone` and retries per ruling 1.
Scope of the promise, stated precisely (the first draft's "never to a public
resolver" claimed more than the code enforces): what this milestone
guarantees is that a reverse name reaches **only the resolver of a zone the
operator declared**, and never the upstream pool. `parseResolver`
(validate.zig:274) accepts any IP literal, so an operator who declares
`168.192.in-addr.arpa → udp://8.8.8.8:53` has pointed their private reverse
space at a public resolver — that is their declaration, and nxdns does not
second-guess it, any more than it second-guesses the same zone for forward
queries. Likewise nxdns cannot stop a declared LAN resolver forwarding
onward. The docs (ruling 11) say this in one sentence.
The DNS cache is not consulted and not written: one PTR per client per day is
not worth a cache entry, and bypassing the cache keeps the resolver a plain
consumer of the exchange seam.
### 3. Precedence: the operator's name always wins; NXDOMAIN clears
Schema (ruling 5) keeps learned names in their own column, so precedence is a
display rule, not a write conflict — `name` is never written by this feature
and `learned_name` is never written by the operator.
A **candidate** row satisfies both of:
- `name IS NULL OR name = ''` — a row whose displayed name would come from
learning. `hand_edited` does not appear in the predicate (the first draft
had it; display precedence never consults it, so candidacy must not
either): a hand-edited row the operator grouped but did not name still
benefits, and a named row is skipped whatever its flag — its learned name
would never be shown.
- `name_attempt_after <= now_s` (ruling 6 gives the exact SQL).
Outcomes of an attempt, written through one repo call (ruling 6), each
setting `name_attempt_after` per ruling 1's cadence:
- **Answer with a valid PTR target**: store it in `learned_name`
(lowercased, no trailing dot), overwriting whatever learned name was there —
the router is the authority on its own zone, and a changed answer is a
renamed device, not a conflict. A test covers the overwrite: two attempts,
two different valid targets, second wins.
- **NXDOMAIN, or NOERROR with no matching PTR record** (NODATA): clear
`learned_name` to NULL. The router affirmatively says the address has no
name; keeping a stale one would show a device under its previous owner's
hostname after a lease change.
- **Everything else** — transport error, any RCODE other than NOERROR and
NXDOMAIN (REFUSED, FORMERR, SERVFAIL, unknown values), malformed reply,
invalid hostname (ruling 4): keep the stored `learned_name` as it stands,
count under `failed` (or `invalid`). An outage must not strip names from
the whole dashboard.
**"RCODE" here is the full 12-bit value**: the header's 4 bits extended by
the OPT record's upper 8 bits when the response carries one
(`packet.findOptRecord` locates it; `dns/edns.zig` decodes it). A response
whose header says NOERROR under a nonzero extended RCODE is a failure, not
an answer and not NODATA — classifying on the header nibble alone would
store or clear a name on what the resolver called an error. An OPT record
that fails to parse makes the whole reply malformed (`failed`, keep). The
first implementation had exactly this bug; the regression test is a
NOERROR header + OPT with a nonzero extended RCODE, asserting `failed`
counts and the stored name survives — it must fail with the classification
reverted to header-only.
**Staleness bound, stated honestly:** an address reassigned to a new device
immediately after a successful lookup shows the previous device's hostname for
up to `refresh_after_s` (24 h) — the next refresh then overwrites or clears
it. That bound is accepted: it matches the prune cadence's granularity, and
tightening it means more PTR traffic for a cosmetic lag. The docs state the
bound.
`upsertSeen`, `updateClient`, `pruneStale` and the reconcile engine are
untouched: a pruned row takes its learned name with it, a re-materialised
device is re-learned within a pass, and `updateClient` writing a name removes
the row from candidacy (the name predicate) without touching `learned_name`.
### 4. PTR answers are untrusted input: `acceptHostname` gates everything
The answer bytes come from whatever box the operator pointed a zone at, and
they land in a UI. Validation has three layers, all required:
1. **Transport**: `transport.validateResponse` (ID and question echo — that is
*all* it checks; the first draft leaned on it for more).
2. **Record selection**: walk `packet.answers`; the accepted record is the
**first** whose `rtype` is `.ptr`, whose class is `.in`, and whose owner
name equals the queried reverse name case-insensitively
(`name.eqlIgnoreCase`, name.zig:185). Records failing any of these are
skipped, not errors; an answer section with no accepted record under
RCODE NOERROR is NODATA (ruling 3 clears). Extra accepted records after
the first are ignored.
3. **Hostname shape**: the PTR target decodes via `record.rdataCname`
(record.zig:101 handles PTR) and formats via `name.formatText`; the text is
accepted only if every byte is in `[a-z0-9._-]` after ASCII-lowercasing
`A-Z`, labels are 163 bytes, the whole name is ≤ 253 bytes, no label
starts or ends with `-`, and there is no empty label (which also rejects a
trailing dot — `formatText` emits none, so one appearing is malformed).
Underscore is included because real DHCP hostnames carry it; nothing else
is. A failing target counts under `invalid` and keeps the stored name.
`acceptHostname` is a pure function in `src/local/reverse_name.zig`, tested
against: the empty string, a 254-byte name, a 64-byte label, `a b`, `a\x00b`,
`héllo`, `-x`, `x-.y`, `a..b`, `.a`, `a.` (trailing dot), and accepting
`nas-1.lan`, `my_printer.home`, `x`.
### 5. Schema: two runtime columns, not a reuse of `name`
`clients` gains, in `config_schema.ddl_v1` directly after `name`:
```sql
learned_name TEXT,
name_attempt_after INTEGER NOT NULL DEFAULT 0,
```
with the identical lines in PLAN §11.2. `name_attempt_after` is the epoch
second before which the resolver will not attempt this row again; 0 (the
default, and every pre-existing row) means "eligible now". Storing the *next*
attempt rather than the *last* one keeps the candidate predicate a plain
comparison (no addition that could overflow on a corrupt value, no special
case for "never attempted") and lets one column carry ruling 1's two cadences.
Reusing `name` with a provenance marker was rejected: `name` feeds
`listClients`, which feeds `nxdns export`, and a learned name in an export
would turn runtime state into configuration — it would churn export diffs as
leases move, and under file authority the next reconcile would fight it. With
separate columns:
- **Export/import untouched.** `listClients` selects `name` only
(clients_repo.zig:33-38); no code change, and a test proves an export before
and after a learned name lands is byte-identical.
- **Reconcile untouched.** The engine never touches runtime columns; the
learned columns ride the row exactly as `last_seen` does. Promotion
(declaring an observed address) keeps them; deletion of a declared row drops
them with the row, correctly.
- **File-authority mode needs no write rule.** Naming is a runtime write like
`upsertSeen`, permitted in both authority modes for the same reason. In
file mode the operator's *declared* name wins through the ordinary
precedence (a non-empty `name` removes the row from candidacy after the
reconcile writes it), and the learned name stays display-only runtime
state.
The baseline test in `src/storage/migrations.zig` ("a fresh database reaches
the baseline...") gains `columnExists` probes for both columns.
### 6. Repo surface: two calls, owned by the resolver
`src/storage/repositories/clients_repo.zig`, in the runtime section beside
`upsertSeen` / `pruneStale`:
- `resolveCandidates(database, out, now_s)` — fills a caller-supplied
fixed-capacity buffer (capacity `max_per_pass`, each slot
`logger.max_client_len` bytes — the bound every address text in this
program is sized by) and returns the filled slice or count. **No
allocation**: the candidate set is small by construction and the resolver
runs on the tracker's task, which owns no allocator today. The WHERE
clause, exactly:
```sql
(name IS NULL OR name = '') AND name_attempt_after <= ?1
ORDER BY name_attempt_after, ip LIMIT 16
```
Tests cover: `now_s` smaller than any cadence constant still selects
never-attempted rows (`DEFAULT 0`); the boundary `name_attempt_after =
now_s` selects; `now_s + 1` does not; an extreme stored value
(`i64` max) never selects and never traps.
- `noteNameOutcome(database, ip, outcome)` where the outcome carries
`attempt_after: i64` and one of: store `text`, clear, keep. One UPDATE:
always sets `name_attempt_after`; sets `learned_name` to the text on
store, to NULL on clear, leaves it alone on keep. **A row deleted between
selection and this call makes the UPDATE touch zero rows; that is a
no-op by design, not an error and not a counter** — the device left, and
nothing was learned about nothing.
`ClientRow` gains `learned_name: []const u8` (NULL reads as `""`, like
`name`); `readClientRow` and both SELECTs carry it. `name_attempt_after` is
**not** exposed on `ClientRow` or the API — it is scheduling state with no
operator meaning, and keeping it out keeps the contract surface one field.
`listClients` (export) is not widened — that is the point of ruling 5.
### 7. The resolver module: `src/server/client_names.zig`
New file. A `Resolver` struct owning:
- `stats: Stats` with the six **outcome** counters `attempted`, `answered`,
`nxdomain`, `no_zone`, `invalid`, `failed`, plus two counters *outside* the
outcome sum: `read_failures` (the candidate SELECT failed; the pass skips
naming) and `write_failures` (`noteNameOutcome` failed; the outcome was
still counted). Guarded by the tracker's mutex pattern, with a
`snapshotStats`. Invariant, asserted in a test: `attempted` equals
`answered + nxdomain + no_zone + invalid + failed`. Database failures log
at most one `warn` per pass each for reads and writes, tracker-style
(clients.zig:181-186); network failures are the counters' job and log
nothing new (the `forward_client` module's existing `debug`-level
diagnostics are exempt from this milestone's no-log rule — they predate it,
are debug-level, and suppressing them per-caller would fork the client).
- an **exchange seam**: production code cannot inject into `ForwardClient`
(it is a concrete struct the resolver constructs), so the seam sits above
it — a field
`exchangeFn: *const fn (io, validate.Resolver, query, response_buf) transport.ExchangeError![]u8`
defaulting to a function that builds a stack `ForwardClient` with
`ptr_read_timeout` and calls `exchange`. Tests replace the pointer; the
production default is itself covered by one test against a loopback UDP
socket if one already exists in the suite's patterns, otherwise by the live
smoke. The no-zone test runs the **production** pass code with a counting
stub and asserts the count stays 0.
- a `runPass(io, database, tables, now_s)`: select candidates (ruling 6), and
for each — build the reverse name (pure, ruling 8), acquire/match/**copy
resolver**/release (ruling 2), build the PTR query: header with a random id
filled from `io.random` (`std/Io.zig:2468` — *not* `std.crypto.random`,
which 0.16.0 does not have), RD set, one question, qtype `.ptr`, qclass
`.in`, encoded with `dns/header` + `dns/question.encode`; exchange through
the seam, classify per rulings 34, write per ruling 6. The id is
test-visible through the seam (the stub sees the query bytes), so no
determinism hook is needed beyond the seam itself.
Wiring: `Tracker.flushOnce` gains an optional `*client_names.Resolver`
parameter, invoked **after** the drain/write and prune steps (ruling 1's
order); `app.zig` constructs the Resolver beside the tracker (holding the
`*LocalTables` pointer) and passes it through `Tracker.run`'s arguments
(app.zig:754). The resolver runs on the tracker's task against `tracker_db` —
the dedicated-connection rule (clients.zig:127-131) is satisfied because it
is the *same* task, serial with the flush. A gated pass (disk critical) skips
naming too: naming writes.
### 8. The pure part: `src/local/reverse_name.zig`
New file, pure (no Io, no clock). Two functions plus their tests:
- `reverseName(addr: address.NetAddress, buf: *[max_reverse_len]u8) []const u8`
— `d.c.b.a.in-addr.arpa` for v4, the 32-nibble lowercase `ip6.arpa` form for
v6. `max_reverse_len` is a comptime bound (v6 form: 32 nibbles · 2 + 8 for
`ip6.arpa` = 72; compute, don't hand-wave). An IPv4-mapped v6 address was
already canonicalised to `ip4` by `NetAddress` (clients.zig test at :305
proves the tracker's addresses are canonical) — assert, don't re-handle.
- `acceptHostname(text: []const u8) bool` — ruling 4's gate, with ruling 4's
test table.
`src/platform/address.zig` types are plain values; importing them into
`local/` keeps the module pure. Register the file in `src/tests.zig` the way
its siblings are registered.
### 9. Failure visibility: one metrics group
The resolver must be reachable from `metrics.collect`, which reads
`server.WebState` — so `src/web/server.zig`'s `WebState` gains an optional
`client_names: ?*client_names.Resolver` collaborator, populated by `app.zig`
beside `tracker` (the first draft wired the sample and forgot the state; S2
owns `src/web/server.zig` for this field). `src/web/metrics.zig`: `Sample`
gains `client_names: ?client_names.Resolver.Stats`, collected beside the
tracker's (metrics.zig:191-193) and rendered as
`counterGroup(w, "nxdns_client_names_", ...)` beside `nxdns_clients_`
(metrics.zig:333-334). `counterGroup` renders every struct field, so all
eight counters (six outcomes + `read_failures` + `write_failures`) surface
without per-field code. The render test pins one line (e.g.
`nxdns_client_names_no_zone_total`).
### 10. API and UI: learned is visible and visibly different
- `GET /api/clients` rows carry `learned_name`
(`src/web/openapi.yaml` `required` + `properties` for the client schema;
contract samples regenerated; `web/src/lib/types.ts` client row type gains
`learned_name: string`). `name_attempt_after` stays internal (ruling 6).
- `web/src/features/clients/ClientsPage.tsx`: the name cell shows `name` when
non-empty; otherwise `learned_name` in the page's existing muted text style
with a visually-distinct treatment that marks it as learned (exact
affordance is the implementer's, but: no new colour system, reuse the muted
style the page already has, and the distinction must survive a screen
reader — an `aria-label` or visible suffix, not colour alone). An empty
both shows what it shows today.
- `ClientEditDialog.tsx` may pre-fill its name field with `learned_name` when
`name` is empty — adopting the learned name as a typed name is the natural
gesture — but saving still goes through the ordinary PUT and sets
`hand_edited` server-side as today. This applies in database-authority mode
only; in file mode client edits answer 403 as they do today, and the
file-mode story is ruling 5's (declared name wins via candidacy).
- Mocks in `ClientsPage.test.tsx` gain the field with distinct values;
rendering tests assert: a named row shows `name` and not `learned_name`; an
unnamed row shows `learned_name` with the learned affordance.
### 11. Docs
- `docs/reference/configuration.md`, clients section: a paragraph stating the
learned-name mechanism; that it requires a conditional forward zone
covering the LAN's reverse space (with the `168.192.in-addr.arpa` example);
that hand-typed names win; that learned names never appear in exports; that
a rename or lease change can display stale for up to a day (ruling 3's
bound). The cadence must be described as it is, not rounded up: each pass
attempts **at most 16 due rows**, and a row is due per ruling 1's two
cadences — "every unnamed device, once a minute" overstates both coverage
and rate and must not appear; and one sentence that PTR queries go to the declared zone's
resolver, wherever the operator pointed it (ruling 2's scope).
- The how-to/tutorial page that documents conditional forward zones gains the
reverse-zone example if it lacks one (locate it; do not guess its path).
- PLAN §7.2 gains one sentence: materialised clients are named by PTR through
the declared forward zones; learned names are runtime state.
- `docs/reference/api.md` only if it enumerates client row fields (check).
## Sessions
Three, strictly sequential: S1 → S2 → S3. S2 needs S1's columns and repo
calls; S2 owns the contract regeneration (the API shape changes when S1 widens
`ClientRow`, but the sample regen runs the integration suite, which S2's
wiring changes — one regen at the end of S2 avoids regenerating twice). S3
reads the fields S2's regenerated types carry.
### Session S1: pure module, schema, repo
Owns: `src/local/reverse_name.zig` (new), `src/tests.zig` (registration),
`src/storage/config_schema.zig`, `src/storage/migrations.zig` (test),
`src/storage/repositories/clients_repo.zig`, `PLAN.md` (§11.2 lines only).
- S1.1 ruling 8: `reverse_name.zig` with exhaustive tests (v4, v6, bounds;
`acceptHostname` accept/reject table from ruling 4).
- S1.2 ruling 5: the two DDL lines in both copies, baseline column probes.
- S1.3 ruling 6: repo calls, `ClientRow` widening, tests — including: the
candidate SQL's boundary and extreme-value cases; the ordering; NULL
round-trips as `""`; export is byte-stable across a `noteNameOutcome`;
`updateClient` leaves `learned_name` alone; the zero-row UPDATE no-op.
Acceptance (S1):
- [ ] `zig build test` passes; baseline test proves both columns exist.
- [ ] `reverseName` output for `192.168.1.10` is
`10.1.168.192.in-addr.arpa` and for `fd00::1` is the full 32-nibble
lowercase form; both are matched by a `forward_zones.Zones` built over
`168.192.in-addr.arpa` / a covering `ip6.arpa` zone in a test that ties
the two modules together.
- [ ] `acceptHostname` passes ruling 4's full accept/reject table, including
`a..b`, `.a` and the trailing-dot case.
- [ ] An export taken before and after `noteNameOutcome` on an observed row is
byte-identical.
### Session S2: resolver, wiring, metrics, API contract (needs S1)
Owns: `src/server/client_names.zig` (new), `src/tests.zig` (registration),
`src/server/clients.zig`, `src/app.zig`, `src/web/server.zig` (the
`client_names` field), `src/web/metrics.zig`, `src/web/openapi.yaml`,
`web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` (regenerated),
`web/src/features/clients/ClientsPage.test.tsx` (mock fields only).
- S2.1 ruling 7: the Resolver, the exchange seam, `flushOnce` hook (ruling 1
order), app and WebState wiring.
- S2.2 rulings 14: classification tests against a stub exchange. Tests must
cover: answered stores and lowercases; a second answered overwrites;
NXDOMAIN clears; NODATA clears; REFUSED and an unknown RCODE keep and count
`failed`; timeout keeps; invalid hostname keeps and counts `invalid`; a
record with wrong type, class, or owner name is skipped; no-zone sends
nothing (the stub's call count is the assertion) and counts;
`name_attempt_after` lands at `now + refresh_after_s` for definitive and
`now + retry_after_s` for non-definitive outcomes; the candidate cap holds;
a named row is never attempted; a gated pass attempts nothing; drain
precedes any exchange when every exchange times out (ruling 1); a row past
the prune cutoff on a due pass causes no exchange (ruling 1); the
two-generation swap test per ruling 2's exact shape (swap inside the
hazard window, both assertions); a NOERROR header under a nonzero OPT
extended RCODE counts `failed` and keeps the stored name (ruling 3); the
default `exchangeFn` bounds UDP plus the TCP fallback under one
`ptr_read_timeout` deadline (ruling 1) — a loopback resolver that answers
UDP with TC=1 late in the budget and then stalls TCP must fail the attempt
within one budget, not two (assert elapsed with margin; inject a shortened
budget locally if the default seam needs one).
- S2.3 ruling 9: WebState field, metrics sample, render pin.
- S2.4 ruling 10's API half: openapi.yaml, regen samples, types.ts, mock
fields.
Acceptance (S2):
- [ ] `zig build test` and `zig build test -Dintegration` pass; the
regenerated samples carry `learned_name` in the client row sample and
do not carry `name_attempt_after`.
- [ ] `cd web && npm run typecheck && npm test` pass with the widened types.
- [ ] The stats-sum invariant test passes (`attempted` = sum of the five
outcomes; `read_failures`/`write_failures` outside it).
- [ ] The no-zone test proves zero exchanges for an uncovered reverse name
through the production pass code.
- [ ] The concurrent-swap regression test passes (ruling 2).
### Session S3: UI and docs (needs S2)
Owns: `web/src/features/clients/ClientsPage.tsx`,
`web/src/features/clients/ClientEditDialog.tsx`,
`web/src/features/clients/ClientsPage.test.tsx` (rendering assertions),
`docs/reference/configuration.md`, `docs/reference/api.md` (if applicable),
the conditional-forwarding doc page, `PLAN.md` (§7.2 sentence only).
- S3.1 ruling 10's UI half.
- S3.2 ruling 11.
Acceptance (S3):
- [ ] `cd web && npm run typecheck && npm test && npm run lint && npm run
build` pass; rendering tests cover named-wins and learned-affordance.
- [ ] The docs name the reverse-zone prerequisite with a concrete
`in-addr.arpa` example, the one-day staleness bound, and the
resolver-is-the-operator's-choice sentence.
### Orchestrator
Verify each session's acceptance before starting the next. After S3, the full
gate set (`zig build test`, `zig build test -Dintegration`, `test-aarch64` if
qemu is present, `cd web && npm test`, `npm run assert-bundled`), then a live
smoke per the verify-against-the-real-network rule: a scratch server with a
forward zone `168.192.in-addr.arpa → udp://<router>:53` (or a local stub
resolver serving PTR), one real query from a LAN client, then wait one flush
interval and confirm the learned name in `GET /api/clients`, in the UI, and in
`nxdns_client_names_answered_total`. Then delete the zone and confirm the next
attempt counts `no_zone` with no upstream traffic (tcpdump or the pool
counters — pool queries must not move); with the hourly non-definitive
cadence, age `name_attempt_after` by SQL in the scratch database rather than
waiting. In database mode, hand-edit the name and confirm it wins and the row
stops being attempted. In file mode, confirm a file-declared name wins after
reconcile and the learned name remains display-only. Record deviations in
`## Recorded (implementation)`.
## Module layout
New files: `src/local/reverse_name.zig`, `src/server/client_names.zig`.
Deleted surface: none.
## File ownership
| File | Session |
| --- | --- |
| `src/local/reverse_name.zig` (new) | S1 |
| `src/storage/config_schema.zig`, `src/storage/migrations.zig` (test) | S1 |
| `src/storage/repositories/clients_repo.zig` | S1 |
| `PLAN.md` | S1 (§11.2 lines), then S3 (§7.2 sentence) — sequential, never concurrent |
| `src/tests.zig` | S1, then S2 — sequential |
| `src/server/client_names.zig` (new) | S2 |
| `src/server/clients.zig`, `src/app.zig`, `src/web/server.zig`, `src/web/metrics.zig` | S2 |
| `src/web/openapi.yaml`, `web/src/lib/types.ts`, `web/src/lib/contractSamples.gen.ts` | S2 |
| `web/src/features/clients/ClientsPage.test.tsx` | S2 (mock fields), then S3 (assertions) — sequential |
| `web/src/features/clients/ClientsPage.tsx`, `ClientEditDialog.tsx` | S3 |
| `docs/reference/configuration.md`, `docs/reference/api.md`, forwarding doc page | S3 |
## Acceptance (milestone complete)
- [ ] All session acceptance boxes.
- [ ] `config_schema.ddl_v1` and PLAN §11.2 byte-identical, both carrying the
two lines; `migrations.steps` still one step, `target_version` still 1.
- [ ] The live smoke: a real client's hostname appears without any operator
edit; removing the zone stops queries entirely (`no_zone` moves, pool
counters do not).
- [ ] `nxdns export` output is byte-identical before and after names are
learned.
- [ ] Database mode: hand-editing a name wins over the learned name
immediately and permanently (the row leaves candidacy). File mode: a
file-declared name wins after reconcile; the learned name is
display-only runtime state.
- [ ] `src/web/openapi.yaml` reviewed by hand against the changed client row
shape, reviewer says so in `## Recorded` (no automated guard covers
schema-to-response agreement — milestone-24 finding, still true).
## Anti-requirements
- No migration step, no `ddl_v2`, no runtime schema probing.
- No PTR query to the upstream pool, and none for a reverse name no declared
forward zone covers — under any fallback, ever. (The declared resolver
itself is the operator's choice; ruling 2 scopes the promise.)
- No reuse of `clients.name` for learned names, and no learned data in
`nxdns export` / `import` ZON or in reconcile semantics.
- No mDNS, NetBIOS, DHCP-lease-file parsing, or any second naming source.
PTR through declared zones is the mechanism; a router that serves no
reverse zone yields `no_zone` counts and an unnamed row, visibly.
- No per-query or query-path resolution: naming rides the flush pass only.
- No config knobs for cadence, caps, or timeouts; constants per ruling 1.
- No DNS-cache participation for PTR probes.
- No unbounded anything: candidates per pass, in-flight, per-attempt timeout,
name length, and retry cadence are all bounded above by rulings 1 and 4.
- No storing or displaying a PTR target that fails `acceptHostname` — not
even truncated or escaped.
- No new per-attempt log lines from this milestone's code; counters and the
metrics group are the surface. `forward_client.zig`'s existing debug
diagnostics stand as they are.
- No allocation on the naming path: candidate storage is fixed-capacity
(ruling 6).
## Recorded (implementation)
- `src/server/phase7_integration_test.zig` sits outside the ownership table
but gained a mechanical `, null` argument at every `flushOnce`/`run` call
site, following the new `names: ?*client_names.Resolver` parameter.
- Review: one external round found four defects (extended RCODE ignored the
OPT upper bits; `exchangeWithin` did not bound the UDP+TCP pair under one
deadline; the swap test asserted nothing observable; a docs cadence
sentence). All four were fixed; the re-review of
`src/server/client_names.zig` returned no findings.
- Every fix shipped with a test the author watched fail with the fix
reverted. For the swap test the hazard was injected (a pass-level resolver
cache that skips re-acquisition) and the second-port assertion failed; the
one-deadline test's bound is `budget + late/2` (550 ms) because the
unbounded path takes ~700 ms and a 2×-budget bound would pass it.
- `src/web/openapi.yaml` was reviewed by hand against `ClientRow`
(clients_repo.zig:312): the nine required fields match one-to-one and
`name_attempt_after` is absent from the response, as intended.
- Live smoke (scratch server, file mode, zone `127.in-addr.arpa` at a local
stub PTR resolver): one real query materialised the client; the flush pass
learned `smoke-host.lan` with no operator edit, visible in
`GET /api/clients` and `nxdns_client_names_answered_total`. Removing the
zone and re-arming `name_attempt_after` produced `no_zone` with zero
packets to the stub and no pool movement. The learned name survived a
restart's reconcile. A file-declared `name` won in the API after reconcile
with the learned name still present as display-only state, and the named
row left candidacy (`attempted` stayed 0 over a full flush interval). The
database-mode hand-edit path runs the same candidacy SQL
(`name IS NULL OR name = ''`) and is covered by the repo unit tests rather
than a second live run. The UI half is covered by the ClientsPage
rendering tests, not a live browser check.
- The first smoke attempt polled a stale pre-milestone binary out of
`zig-out/bin` — `zig build test` does not refresh the install step. Rebuild
before any live check.