38 KiB
Milestone 21: ABP exceptions from lists, regex rules for operators
Goal: honor @@||domain^ exception lines in downloaded blocklists as allow
entries scoped below operator rules and above list blocks (tier 1), and add a
regex rule kind for operator rules backed by a homegrown linear-time engine
(tier 2). Nothing else from the ABP syntax enters scope.
Design written 2026-08-09 against HEAD ffc3ca6; every anchor re-verified
2026-08-11 against a8e0fe4 after milestone 20 landed. The milestone amends
PLAN §2.2, which currently rules regex out permanently; the amendment is part
of session S3, not a side effect.
Implementation contract (read first)
- Read
AGENTS.md, then this spec whole, before session work starts. - Pure core stays pure:
src/filter/files that are fuzz-module roots import onlystd(src/filter/parsers.zig:1-14). The newsrc/filter/regex.zigobeys the same constraint. - Every new
src/**.zigfile must be listed insrc/tests.zig(build.zig:494-539fatals otherwise). - Frozen DDL is frozen: schema changes are new migration steps
(
src/storage/config_schema.zig:1-6,src/storage/migrations.zig:21-22). - After any API shape change, regenerate the contract samples
(
web/src/lib/contractSamples.gen.ts; procedure in AGENTS.md).
Rulings (binding)
1. Exception lines are @@||name^ and nothing else, plus one modifier
src/filter/parser_abp.zig:22 currently maps every @@ line to
.unsupported. After this milestone, a line is an exception when it is
@@||name^ or @@||name (same trailing-^ and rule_tokens treatment as the
block anchor at parser_abp.zig:26-34), optionally suffixed with the literal
$important — that suffix is the common form in AdGuard-authored lists and
changes nothing about the meaning here, because list exceptions already sit
below every operator rule. Any other @@ form (@@name without the anchor,
any other $ modifier, a path, a scheme) stays .unsupported. The
parser-header policy paragraph (parser_abp.zig:5-6) is rewritten to state the
new rule and its precedence justification: a list exception can cancel only
list blocks, never an operator decision, so no downloaded list can open an
allow hole the operator did not open.
2. Precedence: list exceptions sit between operator rules and list blocks
matcher.Snapshot.evaluate (src/filter/matcher.zig:286-338) gains one level
between the operator wildcard-block walk (level 4) and the blocklist domain
probe (level 5): for each attached source, an exception match — full name or
parent walk, apex covered — returns blocked = false, reason
.blocklist_exception, matched = the matching entry, source = the source
index. The doc comment at matcher.zig:269-285 and PLAN §3.10 (PLAN.md:108-117)
are both updated with the new level. Regex rules (ruling 6) slot in as levels
after the wildcard rules and before list exceptions, allow before block, so
the full order is: exact allow, exact block, wildcard allow, wildcard block,
regex allow, regex block, list exception, list domain, list wildcard.
"Tie-break at same specificity: allow wins" is preserved.
3. The compiled-source format grows a third body, checksum-compatibly
compiler.compile (src/filter/compiler.zig:50-56) takes a third writer
(allow_w) and Counts (compiler.zig:23-35) gains exceptions: u32 = 0.
Exception candidates go through the existing addCandidate path into a third
Entries and emit as a sorted, deduplicated .allow body. The shared SHA-256
covers the bodies in order list, wild, allow — because SHA-256 of
list ++ wild ++ "" equals the current SHA-256 of list ++ wild, every
already-published checksum stays valid, and Manager.loadSource
(src/filter/manager.zig:543-598) treats a missing <id>.allow file as an
empty body. No refetch is forced by upgrading. bodyChecksum
(manager.zig:1572) follows the same order; source_file_suffixes
(manager.zig:1589) gains .allow.tmp and .allow with longest-suffix-first
order preserved; the on-disk header (manager.zig:221-241) gains
# exceptions {d} after the # wildcards line and the pinning test at
manager.zig:1914-1946 is extended, not weakened.
The "checksum over the .list body followed by the .wild body" sentence
exists in THREE places, not the one this ruling first named: compiler.zig:38-39,
manager.zig:218 and sources_repo.zig:100. All three move together, or the
next reader trusts a stale one.
4. Exception counts persist and surface
Migration step 3 (ddl_v3, appended at src/storage/migrations.zig:23-26):
ALTER TABLE blocklist_sources ADD COLUMN exception_count INTEGER NOT NULL DEFAULT 0;. sources_repo.SourceRow and updateSourceStats
(src/storage/repositories/sources_repo.zig:80-93,159-169) carry it, and the
checksum doc line at sources_repo.zig:100 is updated alongside
bodyChecksum's per ruling 3;
SourceStatus rehydration (manager.zig:1458-1502) restores it alongside the
existing three counts; StatusView (src/web/handlers/blocklists.zig:52-78)
gains exceptions: u32; the blocklists UI shows it where skipped_regex
already shows.
skipped_regex shows in TWO tables, and exceptions follows it into both:
SourceStatus.skipped_regex (web/src/lib/types.ts:192) renders at
SourceStatusSection.tsx:112, and Blocklist.skipped_regex_count
(types.ts:163) renders at BlocklistsPage.tsx:188. S1 therefore owns
BlocklistsPage.tsx as well.
5. The regex engine is a Pike VM, linear-time by construction, std only
New file src/filter/regex.zig. Syntax: literal bytes, ., character
classes [...] with ranges and leading-^ negation, escapes
\. \\ \- \d \w, repetition * + ? {n} {n,m}, alternation |,
non-capturing grouping (...), anchors ^ and $. No backreferences, no
lookaround, no captures.
Two syntax amendments from S2's review, both widening what is accepted:
{n,}is legal. It lowers to{n}followed by*, stays linear, and an over-largenstill reportsPatternTooComplex. Operators write this form; rejecting it buys no safety.\before any ASCII punctuation yields that literal, not only the five escapes listed above —\*,\/and\+occur in Pi-hole-style patterns. This can only narrow a pattern to a literal, never silently change its meaning. Escapes that WOULD change meaning stay rejected: any alphanumeric escape outside\dand\w(\s,\b,\1,\D,\p{L}) isBadPattern.
One rejection the review added: a quantifier applied directly to another
quantifier is BadPattern. a+? previously compiled as (a+)?, which
matches every name — a block rule written in conventional lazy syntax would
have sinkholed the whole LAN instead of being refused. Parenthesised forms
such as (a+)? stay legal and keep their meaning. Matching is unanchored unless anchors are written
(POSIX-grep convention, matching Pi-hole user expectations). Input is the
normalized lowercase name, ≤ types.max_name_len bytes. Hard limits, each a
distinct error: pattern ≤ 256 bytes (PatternTooLong), compiled program
≤ 1024 instructions (PatternTooComplex). Public API:
pub const Error = error{ OutOfMemory, BadPattern, PatternTooLong, PatternTooComplex };
pub const Program = struct { ... , pub fn deinit(self: *Program, gpa: Allocator) void };
pub fn compile(gpa: Allocator, pattern: []const u8) Error!Program;
pub fn matches(prog: *const Program, input: []const u8) bool;
matches is a Pike VM: two thread lists, each program counter admitted at
most once per input position, worst case O(program × input) with zero
allocation at match time.
Amended in S2. This ruling first said the thread lists live in the
Program, sized at compile time. They do not, and must not: the runtime is
std.Io.Threaded, so several query threads evaluate one shared snapshot at
once. Scratch inside a shared Program is a data race, and reaching it
through *const Program would need a @constCast that is undefined
behaviour on a genuinely const program. All VM scratch — both thread lists,
the admission marks and the closure stack — is instead a fixed array on the
caller's stack, sized by the compile-time max_program_len constant. Zero
allocation at match time is preserved, the published signature is unchanged,
and a Program becomes safe to share across threads, which the original
wording would have prevented. The engine is a fuzz-module root like parsers.zig and imports only
std.
6. regex is a third rule kind, validated at the edge, memoized by the cache
Migration step 4 (ddl_v4): the 12-step rebuild of rules with
CHECK(kind IN ('exact','wildcard','regex')) — the frozen v1 DDL
(src/storage/config_schema.zig:65-72) cannot be edited. The rebuilt table
keeps the name rules: config_schema.table_names
(config_schema.zig:112-119) and the invariant tests at
config_schema.zig:120-127 and migrations.zig:356 assert the schema's table
set, and a rename would fail both. model.RuleKind
(src/config/model.zig:259-275) gains .regex; the exhaustive switches in
config/validate.zig:1099-1130 (compile the pattern, report
"... is not a valid regex pattern" through the existing error path at
validate.zig:891-902) and src/filter/rules.zig:62-68 extend. RuleSet
(rules.zig:28-36) grows regex_allow and regex_block slices holding
compiled Programs plus their pattern texts (for Decision.matched);
bucketOf (rules.zig:133-143) becomes a six-bucket layout, and the fixed
var spans: [4]std.ArrayList(Span) at rules.zig:55 widens with it;
patternIsValid (validate.zig:1099-1103) is declared
error{OutOfMemory}!bool, so a regex compile's BadPattern,
PatternTooLong and PatternTooComplex must either fold into false or
widen that error set together with its caller at validate.zig:893 — the
diagnostic text itself needs no edit, because validate.zig:898 already
interpolates rule.kind.toDb() into "{f} is not a valid {s} pattern";
max_regex_per_group: usize = 256 with TooManyRegexRules mirroring
max_wildcards_per_group (rules.zig:26). A pattern that fails to compile is
error.BadPattern at snapshot build, never skipped (rules.zig:41-49 doc
holds). Reason tags rule_allow_regex and rule_block_regex join
matcher.Reason (matcher.zig:24-32); /api/lookup and the query log pick
them up automatically via @tagName (src/web/handlers/lookup.zig:89;
max_reason_len = 32 in src/storage/logger.zig fits both at 16 chars).
Regex evaluation runs only after every hash and wildcard level missed, and
answers are memoized by the existing DNS cache like every other decision, so
the per-query cost lands on cache misses only.
7. The web contract names the third kind everywhere it names the first two
src/web/handlers/rules.zig: toInput accepts "regex"; the 400 string at
rules.zig:42 becomes "kind must be 'exact', 'wildcard' or 'regex'".
src/web/openapi.yaml:1948,1962,1976: all three enum: [exact, wildcard] become
[exact, wildcard, regex]. web/src/lib/types.ts:195:
RuleKind = "exact" | "wildcard" | "regex"; the rules page kind selector
gains the option. Milestone 23 replaced the native <select> with a React
Aria wrapper, so that is now a data edit, not JSX: append to KIND_OPTIONS
at RulesPage.tsx:15-18, and extend the option-list assertion at
RulesPage.test.tsx:119. Any new test that opens the selector depends on the
CSS.escape polyfill in web/vitest.setup.ts. Contract samples are
regenerated with the AGENTS.md command
(zig build test -Dintegration -Dcontract-samples-out=...); no npm script
generates them. nxdns export / import
round-trip the new kind with no extra work once RuleKind.toDb/fromDb extend
— the enum round-trip test at model.zig:782 is extended to prove it.
8. PLAN amendments land with the code, in S3
PLAN.md:36 (§2.2) is rewritten: operator regex rules are in scope, backed by
the linear-time engine of ruling 5; regex lines in downloaded lists stay
counted and skipped; $ modifiers (except the $important suffix of ruling
1), partial-segment wildcards, and browser-syntax honoring stay permanently
out. PLAN.md:26 (§2.1 filtering sentence), PLAN.md:106 (§3.9), PLAN.md:108-117
(§3.10 precedence) and PLAN.md:700 (decision B) are updated to match rulings
2 and 6. PLAN.md:99 (§3.8) documents exactly two compiled bodies and names
<source_id>.wild as the second; ruling 3's third body makes it stale, so S1
updates that line when it lands the .allow body. PLAN.md:73 ("No
TOML/regex/HTTP packages needed") stays true and stays as written — a
homegrown engine adds no package. In-code echoes of the old §2.2 move with it:
src/filter/wildcard.zig:6-7,20-23, src/filter/parsers.zig:25,
src/filter/parser_abp.zig:5-6, src/filter/compiler.zig:129-131.
9. Milestone 20's authority modes bound where rules are written
m20 classifies POST/PUT/DELETE /api/rules as .config_write
(src/web/routes.zig:94-97), and under .managed_file authority the router
answers 403 (src/web/router.zig:173-178). Everything in this milestone
works in both modes, but the write path differs: in .database mode regex
rules arrive through the API; in .managed_file mode they arrive through the
config file and src/config/reconcile.zig (reconcileRules,
reconcile.zig:566-598), whose enum comparison carries .regex with no code
change. S3 owns a reconcile test proving a file-declared regex rule converges
into the table. Every S3 API acceptance check runs a server in .database
authority (no --config). The SELECT * dump helpers
(src/config/import.zig:167-186, src/config/reconcile.zig:981) will emit
the new exception_count column after ddl_v3; golden dump assertions in
those suites are updated in S1, which owns that migration. Reconcile's
runtime-column protection (reconcile.zig:11-15,435) covers exception_count
with no change — the column survives reconciles untouched.
10. Fuzz invariants move, never lapse
tests/fuzz/blocklist_fuzz.zig header invariant "covers_apex only on
.wildcard" (its stated form at :4-28) becomes "only on .wildcard or
.exception". tests/fuzz/compiler_fuzz.zig:43 reads Format from
compile's parameter list by index — the new allow_w parameter appends
after wild_w, leaving index 2 valid; the session touching compile runs the
fuzz suite and fixes that line if the assumption fails. A new
tests/fuzz/regex_fuzz.zig target asserts: compile on arbitrary bytes
never crashes and either errors or produces a program within the ruling-5
limits; matches terminates and its VM step count never exceeds
program length × (input length + 1); compile-then-match is deterministic.
Sessions
Three sessions. S1 and S2 run in parallel — they share no files. S3 starts after both land.
Session S1: list exceptions end to end (tier 1)
Owns: src/filter/parsers.zig, src/filter/parser_abp.zig,
src/filter/compiler.zig, src/filter/manager.zig, src/filter/matcher.zig,
src/filter/domain_set.zig (only if a helper is needed; expected untouched),
src/filter/filter_integration_test.zig, src/storage/migrations.zig
(step 3 only), src/storage/repositories/sources_repo.zig,
src/web/handlers/blocklists.zig, src/web/handlers/lookup.zig (doc
sentence only), src/web/openapi.yaml (StatusView shape only),
web/src/features/blocklists/* (which includes BlocklistsPage.tsx per
ruling 4), web/src/lib/types.ts (source-stat fields
only), web/src/lib/contractSamples.gen.ts, PLAN.md (line 99 only, per
ruling 8 — S3 owns every other PLAN edit),
tests/fuzz/blocklist_fuzz.zig, tests/fuzz/compiler_fuzz.zig, and the
dump-golden assertions in src/config/import.zig and
src/config/reconcile.zig test suites only (ruling 9's exception_count
fallout from ddl_v3; S1 touches no reconcile logic).
- S1.1
Kind.exceptionin parsers.zig; parser_abp emits it per ruling 1; parser_hosts and parser_domains never emit it (no change beyond the enum). - S1.2 compiler third body per ruling 3;
Counts.exceptions. - S1.3 manager: suffixes, header line,
bodyChecksum,loadSourcemissing-file-is-empty,Snapshot.Compiled.allow_body,prepareRefresh/publishRefresh/applyLoadOutcomescarry the count.rejectedWithoutEntries(manager.zig:1563-1570) treats a compile with only exceptions as loadable, not rejected. - S1.4 matcher:
SourceSets.exceptions,Reason.blocklist_exception, the new evaluate level per ruling 2,memoryBytesincludes the new sets. - S1.5 storage + API + UI per ruling 4.
migrations.zig:349assertstarget_version == 2; S1 moves it to 3 (S3 moves it to 4). The spec did not name that test; it fails otherwise. - S1.6 tests: parser cases (
@@||x^,@@||x,@@||x^$important,@@||x^$third-party→ unsupported,@@x→ unsupported); compiler three-body + checksum-compat cases (empty allow body reproduces the old digest byte for byte); manager header pin extended; matcher precedence cases: operator block beats list exception, list exception beats list domain and list wildcard, exception parent walk covers apex and subdomains; an integration case throughfilter_integration_test.zigwith a real ABP fixture carrying@@lines.
Acceptance (S1):
zig build testpasses; fuzz targets build and run.- A fixture list with
||ads.example^and@@||good.ads.example^compiled and loaded blocksads.exampleandx.ads.example, does not blockgood.ads.exampleory.good.ads.example, and/api/lookupreportsblocklist_exceptionwith the source id for the latter two. Also proven live against the AdGuard DNS filter, which carries||ads.tvb.com^beside@@||api.ads.tvb.com^:digsinkholedads.tvb.comandx.ads.tvb.comto 0.0.0.0 and resolvedapi.ads.tvb.comnormally. - A pre-milestone data directory (no
.allowfiles, old checksums) loads with zero checksum mismatches. Proven live by deleting a loaded source's.allowand reloading. POST /api/blocklists/updateresponse rows carryexceptions(live:"domains":154667,"wildcards":154666,"exceptions":11, "skipped_regex":21).
Session S2: the regex engine (tier 2, engine only)
Owns: src/filter/regex.zig (new), tests/fuzz/regex_fuzz.zig (new),
src/tests.zig (one added line), build.zig (fuzz-suite wiring for the new
target only).
- S2.1 the engine per ruling 5: parser → AST → NFA program → Pike VM.
- S2.2 unit tests in-file: every syntax form; anchored and unanchored
matching; negated classes;
{n,m}bounds; each error case; the pathological backtracker-killers ((a+)+bagainstaaaaaaaaaaaaaaaaaaaaX, nested alternation) complete within the step bound. - S2.3 the fuzz target per ruling 10.
Acceptance (S2):
zig build testpasses with the new file insrc/tests.zig.- The step-bound property holds under the fuzz corpus.
expectLinear(tests/fuzz/regex_fuzz.zig:101) assertssteps <= program_len * (input_len + 1)over the corpus thatzig build testreplays. An interactive--fuzzsession could not be used as additional evidence — see the deviation below. regex.zigimports nothing butstd.
Session S3: the regex rule kind, wired through (needs S1 + S2)
Owns: src/storage/migrations.zig (step 4), src/storage/config_schema.zig
(comment only if needed), src/config/model.zig, src/config/validate.zig,
src/filter/rules.zig, src/filter/matcher.zig,
src/storage/repositories/rules_repo.zig, src/web/handlers/rules.zig,
src/web/handlers/mutations.zig (only if checkRule needs the kind),
src/web/openapi.yaml, web/src/features/rules/*, web/src/lib/types.ts,
web/src/lib/contractSamples.gen.ts, src/config/reconcile.zig (test per
ruling 9), PLAN.md, src/filter/wildcard.zig (comments),
src/filter/parsers.zig (comment), src/filter/parser_abp.zig (comment),
src/filter/compiler.zig (comment), tools/bench.zig.
- S3.1 migration step 4 per ruling 6; repo and model layers.
- S3.2 validate at both edges (config import, API) per rulings 6 and 7.
- S3.3
RuleSetsix buckets; matcher levels per ruling 2; reasons. - S3.4 web + UI + openapi + samples per ruling 7.
- S3.5 PLAN and comment amendments per ruling 8.
- S3.6 bench: the filter suite in
tools/bench.ziggains a variant with 32 regex rules loaded; the existing p95 < 1 ms assertion covers it. The line to change istools/bench.zig:158, which currently reads.rules = &.{}— the filter bench loads no rules at all today, so the variant is new coverage rather than an edit to an existing rule set. S3 also movesmigrations.zig:349from 3 to 4. - S3.7 tests: rule CRUD with kind
regexthrough the API including the 400 for a bad pattern at insert time; precedence cases regex-allow over regex-block, wildcard over regex, regex over list entries; export/import round trip; a migration test upgrading a v3 database.
Acceptance (S3):
zig build testandcd web && npm testpass.POST /api/ruleswith{"kind":"regex","pattern":"^ad[0-9]+-"}returns 201; with"pattern":"("returns 400 naming the pattern (live:{"error":"rules[0].pattern: '(' is not a valid regex pattern"}).- A regex block rule blocks a matching name;
/api/lookupreportsrule_block_regexandmatchedcarries the pattern text (live:ad42-tracker.example.comblocked,matched^ad[0-9]+-). zig build bench -Doptimize=ReleaseFast -- filterpasses its targets with the regex variant present (32 regex rules; p95 2.89 µs against a 1 ms target; VmRSS 32.0 MiB against a 100 MiB target).- PLAN §2.2 no longer forbids operator regex; all listed echoes updated. Ruling 8's list turned out to be incomplete: §7.1's evaluation sequence, the §5 module tree and the Phase 5 summary also spoke of a two-body, three-kind, no-exception world. All are corrected, and the sweep that found them also caught milestone-20 drift the ruling never covered.
Orchestrator
Verify S1 and S2 acceptance before starting S3. After S3: run the full gate
set (zig build test, test-aarch64 if qemu present, npm test,
npm run assert-bundled), then a live smoke against a scratch server: load
one real ABP list with @@ lines, add one regex rule, verify both over dig
and /api/lookup. Record deviations in ## Recorded (implementation).
Module layout
New files:
src/filter/regex.zig— the linear-time engine (ruling 5).tests/fuzz/regex_fuzz.zig— its fuzz target (ruling 10).
Deleted surface: none.
Acceptance (milestone complete)
- All session acceptance boxes above.
- Schema at version 4; a v2 database migrates cleanly with data intact
(
migrations.zig:344, "a version 2 database upgrades and keeps its sources at exception_count 0"; the live smoke migrated0 to 4). - A pre-milestone blocklist data directory loads without refetch.
- The six-level operator precedence plus three list levels behave per
ruling 2, proven by matcher tests that enumerate adjacent-level pairs.
Every boundary on the nine-level ladder has a test in which one query
matches both levels, so reversing either order fails the suite. The
milestone added four: "both wildcard levels beat an allow regex that
matches", "an operator block rule beats a list exception", "a list
exception beats a list domain entry on the same name", and "a list
domain entry beats a list wildcard entry". The last two were added after
the second review pass found the earlier claim overstated — the existing
exception test used a name absent from
.list, so it pinned exception-versus-wildcard rather than exception-versus-domain. - No
src/filter/fuzz-root file imports anything butstd(regex.zigimportsstdalone). - Contract samples, openapi.yaml and
web/src/lib/types.tsagree with the server (the drift guards pass).
Recorded (anchor re-verification, 2026-08-12)
The spec was written against ffc3ca6 and verified against a8e0fe4. It was
re-verified a third time after milestones 22 and 23 landed (TypeScript 7,
Tailwind removed, StyleX and React Aria). Findings, all folded into the
rulings above:
- No Zig file changed between
a8e0fe4and this re-verification. Every Zig anchor holds; six ranges are off by a line or two but still contain what the spec names. The schema is still at version 2, so migration steps 3 and 4 are genuinely new. - The rules-page kind selector is no longer a
<select>. It is aKIND_OPTIONSarray feedingweb/src/ui/Select.tsx, a React Aria wrapper (ruling 7 rewritten). migrations.zig:349pinstarget_versionand is unnamed by the original spec. S1 moves it to 3, S3 to 4.exception_counthas two UI sites, not one, so S1 ownsBlocklistsPage.tsx(ruling 4 rewritten).- The checksum sentence has three copies, not one (ruling 3 rewritten).
patternIsValid's error set cannot carry the engine's three error tags as written (ruling 6 rewritten).PLAN.md:99documents two compiled bodies and goes stale with ruling 3 (ruling 8 rewritten).tools/bench.zig:158reads.rules = &.{}, so the filter bench loads no rules today.web/vitest.setup.tspolyfillsCSS.escape; without it, a test that opens the React Aria selector throws under jsdom.npm run buildgainedscripts/assert-css-layers.mjs, which fails on any rule outside a cascade layer. A pure StyleX change cannot trip it.
Recorded (implementation)
Deviations and findings from the build, the Codex review rounds and the live smoke. Everything here is folded into the code; nothing is outstanding.
-
Regex scratch is on the caller's stack, not in
Program.std.Io.Threadedmeans several query threads share one snapshot, so the Pike VM's two thread lists cannot live in the compiled program.matchestakes its scratch from the caller's frame, which keepsProgramimmutable and shareable and keeps the match path allocation-free. -
{n,}is legal and\+ any ASCII punctuation is legal. Ruling 5 named neither. Quantifier-on-quantifier (a+?read as(a+)?) isBadPattern: the first Codex round founda+?compiling to something that matched everything. -
Embedded whitespace was accepted on the anchored ABP forms. A line such as
||good.example bad.example^reached the compiler, which lowercases and length-checks but does not reject a space, and wrote an entry only a query carrying the same space could match.compiler.zigpasses.wildcardand.exceptiontext toaddCandidatewhole, which is why the space survived there.parser_abp.isNameCandidatenow refuses whitespace and control bytes on those two paths.The bare-name path deliberately does not use it.
compiler.zig:93-98tokenizes.domaintext on whitespace and adds each field separately, so a bare line carrying a space was never broken — it produced two valid entries. SincedetectFormatassigns one format per source, a mostly-ABP list that also carries hosts-style lines depends on exactly that tokenizer to keep them working. The first attempt at this fix applied the helper to all three paths and silently dropped that fallback; the second Codex pass caught it. Two tests now pin it: a parser test that the bare form stays.domain, and a compiler test that an ABP-classified list carrying0.0.0.0 ads.examplestill emitsads.example. The third pass pointed out that the parser test alone would pass even if the compiler stopped tokenizing, which is the behaviour the fallback actually depends on. -
The
.allowbody left stale enumerations behind it. Adding a third compiled body — and with it a fourth temporary,.allow.tmp— updated the production code but not every place that lists the file names. The third review pass found four: twomanager.zigtests that spell the names by hand, the orphan-sweep fixture infilter_integration_test.zig, andPLAN.md§3.13 and §5. Themanager.zigtable-driven test was worse than stale — it iteratessource_file_suffixesitself, so deleting an entry changes the code and the test's expectations together and everything still passes. The whole repo was then swept for the pattern rather than the four instances patched, which turned up seven more — including the reload-cancellation test, whose fixture wrote no.allowfile at all, so the third ofloadSource's three read sites was never exercised. A fourth pass then found test 10e comparing only.listand.wildacross a restart, so a restart that rewrote the exception body alone would have stayed green. Acomptimeassertion onsource_file_suffixes.lennow breaks the build when a suffix is added or removed without updating the hand-written tests. -
A pre-existing flake in the required suite.
src/cli.zig:1538assertedstd.mem.count(u8, text, "OK") == 0over output that embeds the temporary directory path.std.testing.tmpDirnames that directory with base64 over 12 random bytes, whose 64-symbol alphabet includes uppercase: 15 adjacent positions each carryOKwith probability 1/4096, so about one run in 273 fails a test that has nothing to do with naming. It surfaced during this milestone's watched-fail injections. The assertion now checks that no line starts with a verdict, which covers bothOK:andOK upstreams[...]and cannot match a path segment. Reproduced and fixed outside the milestone's scope because a randomly failing required gate devalues every green run after it. -
PLAN.mdstill described the seed-once config model. Seven sites said or implied that the first start seeds the database from/etc/nxdns/config.zon, which milestone 20 replaced with the two authority modes selected by the presence of--config. One of them listed aconfig/bootstrap.zigthat does not exist — the module isloader.zigplusreconcile.zig. The claim was checked againstsrc/app.zig:339, not just against the docs. This is milestone-20 drift found while fixing the milestone-21 echoes in the same document, and corrected because PLAN is the source of truth a later session builds from. -
PLAN.md§12.1 held a config sample nobody could load. It described an.upstream.serversfield that never existed and omitted the required.groupsand.upstreams. The section now points atdocs/reference/configuration.mdandnxdns exportand keeps only a skeleton: a second copy of the schema is what produced the drift, so the copy is gone rather than corrected. -
PLAN.md§7.1 claimed blocklist entries never parent-walk. Two of the three list levels do: wildcard entries match every proper parent, and exception entries walk the candidate chain, which is why@@||good.ads.example^also liftsy.good.ads.example. Only domain entries match the query name alone. -
src/config/model.zig's header described the retired bootstrap and omittedexception_countfrom its list of runtime columns. The first attempt at correcting it introduced a new error — it called import a wholesale replacement set against reconciliation — which the sixth pass caught.import.zig:3is explicit that import has been a thin wrapper overreconcile.zigsince milestone 20, so there is one declarative write path, not two, and it preserves the runtime state of every row the input still names (reconcile.zig:1139). A seventh pass then corrected two more claims in the same header:Configis the whole shape of a config file but not the only shape the repositories accept (the API writes throughRuleInput,ClientInput,ClientEdit), and compiled-body reuse turns on the preserved source id and checksum rather than the counters —loadSourcenames the files after the id and accepts them only against the stored checksum. -
PLAN.md§11.2 presented the v1 DDL as the live schema. It predates three migrations, so it lacksupstreams.tls_nameandblocklist_sources.exception_countand itskindCHECK admits onlyexactandwildcard— implementing against it would produce a database that rejects every regex rule this milestone added. The section is now labelled the v1 baseline and points atconfig_schema.zigandmigrations.zig, with the three steps named. -
INSTALL.mdsaid the service readsconfig.zon"on the first start". Neither authority mode behaves that way: underrun --configthe service reads it on every start, and under database authoritynxdns importreads it while a barenxdns runnever does. The sentence justified a file mode, so an operator tightening permissions after first boot would have broken the next file-mode restart. More milestone-20 drift. The claim had a second copy indocs/how-to/install-with-systemd.md, found only because the seventh pass looked for it after the first copy was fixed. -
PLAN.md§7.1 and the Phase 5 summary missed this milestone's own additions. The evaluation sequence went straight from operator rules to blocklist domains, omitting the exception level, and the matcher was still enumerated as exact/parent/wildcard with no regex. Ruling 8 required these echoes and S3 did not reach them. -
The UI trimmed regex patterns.
RulesPage.onSubmitappliedpattern.trim()to every kind, so a regex created in the UI did not store the bytes an identicalPOST /api/ruleswould. It now trims onlyexactandwildcard, where the server normalizes anyway. No client-side whitespace rejection was added:" foo|bar"still has a livebarbranch, so refusing it would over-reject, and the server stays the authority on pattern validity. -
The rule pattern field opted into mobile autocapitalization. An autocapitalized regex validates and then silently never matches, because query names are lowercase and a regex is never normalized. The field now sets
autoCapitalize="none",autoCorrect="off",spellCheck={false}. -
RuleSet.buildreceived the snapshot arena for its temporaries. An arena reclaims only its most recent allocation, so everydefer …deinitinsidebuildwas a silent no-op and the scratch survived until snapshot teardown, uncounted bymemoryBytes().buildnow takes a permanent and a scratch allocator, andmatcher.zigpassesarenaandgparespectively. Regex programs compile into scratch and are copied across by a newProgram.clone, which keepsregex.ziga single-allocator engine and leavescompile's signature — and thereforeconfig/validate.zigand the fuzz target — untouched.Measured on 16 groups each holding 256 regex rules of 254 bytes, 4096 wildcards and 2048 exact rules, with no blocklist sources: arena capacity fell from 135,662,440 to 12,686,214 bytes against an unchanged
memoryBytes()of 11,058,791, so the ratio of real to reported went from 12.27× to 1.15×. The hidden footprint per snapshot fell from 118.83 MiB to 1.55 MiB, so 117.28 MiB went away. A reload holds two snapshots, so it was carrying twice that.memoryBytes()needed no change: the formula was always right about what theRuleSetretains, and the divergence was arena capacity the formula does not claim to describe. The residual 1.15× is arena node headers and page rounding, whichmemoryBytesdocuments itself as excluding.The guard is
the build's temporaries stay out of the permanent allocator, which assertsarena.queryCapacity() < 2 * set.memoryBytes()and passesscratchastesting.allocator, so a permanent allocation wrongly taken from scratch also fails as a leak. It was watched failing with the split reverted. -
An interactive
--fuzzsession cannot run on zig 0.16.0. Building any fuzz target with-ffuzzfails inside the stock/usr/lib/zig/compiler/test_runner.zig:566, which passes a*builtin.StackTracetodebug.writeStackTracewhere a*const debug.StackTraceis wanted — two distinct struct declarations. The failure is entirely inside the toolchain and reproduces oncompiler-fuzz, a target this milestone did not touch. Corpus replay underzig build testis unaffected and remains the evidence for the step-bound property. -
api.mdgained a block-reason table. The milestone added three reason tags and no doc page enumerated any of them. The table lists all nine in evaluation order plusnoneand thecname:prefix. -
Two comment sites still counted three temporaries.
manager.zigline 40 (therefresh_lockinvariant) and line 1293 (pruneOrphans) both omitted.allow.tmp. Each presents itself as exhaustive, so a maintainer could have added an.allow.tmpwriter outsiderefresh_lockand let the orphan sweep delete it mid-compile.sourceFileIdandsource_file_suffixesalready matched all four. -
The rule pattern placeholder named only two kinds. Ruling 7 requires the web contract to name the third kind everywhere it names the first two, and the placeholder read
ads.example.com or *.example.com. It now carries a regex example too. -
Two pre-existing tutorial errors surfaced during the docs sweep.
tutorial/first-run.mdstep 14 claimed the compiled list stays on disk when it is in fact pruned (real output:pruned orphaned blocklist file 1.allow,1.wild,1.list), and step 11 namedads.doubleclick.net, which StevenBlack no longer carries.
Anti-requirements
- No
$modifier support beyond tolerating$importanton exception lines.$dnstype,$dnsrewrite,$client,$denyallowand every browser modifier stay unsupported and counted. - No regex from downloaded lists.
.regexlines stay counted and skipped;skipped_regexkeeps its meaning. The engine exists for operator rules only. - No partial-segment wildcards (
ads*.example.com) — writing one as a rule stays rejected; the regex kind covers the need. - No backreferences, lookaround, captures, named groups or Unicode classes in the engine, ever. A pattern needing them is rejected, not approximated.
- No PCRE2, RE2 or any external regex dependency.
- No exception-rule UI editor: exceptions come from lists; operators write allow rules.
- No re-download forced by the upgrade; checksum compatibility (ruling 3) is a requirement, not an optimization.