# 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 only `std` (`src/filter/parsers.zig:1-14`). The new `src/filter/regex.zig` obeys the same constraint. - Every new `src/**.zig` file must be listed in `src/tests.zig` (`build.zig:494-539` fatals 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 `.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-large `n` still reports `PatternTooComplex`. 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 `\d` and `\w` (`\s`, `\b`, `\1`, `\D`, `\p{L}`) is `BadPattern`. **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: ```zig 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 `Program`s 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 ``. It is a `KIND_OPTIONS` array feeding `web/src/ui/Select.tsx`, a React Aria wrapper (ruling 7 rewritten). - `migrations.zig:349` pins `target_version` and is unnamed by the original spec. S1 moves it to 3, S3 to 4. - `exception_count` has two UI sites, not one, so S1 owns `BlocklistsPage.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:99` documents two compiled bodies and goes stale with ruling 3 (ruling 8 rewritten). - `tools/bench.zig:158` reads `.rules = &.{}`, so the filter bench loads no rules today. - `web/vitest.setup.ts` polyfills `CSS.escape`; without it, a test that opens the React Aria selector throws under jsdom. - `npm run build` gained `scripts/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.Threaded` means several query threads share one snapshot, so the Pike VM's two thread lists cannot live in the compiled program. `matches` takes its scratch from the caller's frame, which keeps `Program` immutable 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+)?`) is `BadPattern`: the first Codex round found `a+?` 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.zig` passes `.wildcard` and `.exception` text to `addCandidate` whole, which is why the space survived there. `parser_abp.isNameCandidate` now refuses whitespace and control bytes on those two paths. The bare-name path deliberately does **not** use it. `compiler.zig:93-98` tokenizes `.domain` text on whitespace and adds each field separately, so a bare line carrying a space was never broken — it produced two valid entries. Since `detectFormat` assigns 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 carrying `0.0.0.0 ads.example` still emits `ads.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 `.allow` body 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: two `manager.zig` tests that spell the names by hand, the orphan-sweep fixture in `filter_integration_test.zig`, and `PLAN.md` §3.13 and §5. The `manager.zig` table-driven test was worse than stale — it iterates `source_file_suffixes` itself, 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 `.allow` file at all, so the third of `loadSource`'s three read sites was never exercised. A fourth pass then found test 10e comparing only `.list` and `.wild` across a restart, so a restart that rewrote the exception body alone would have stayed green. A `comptime` assertion on `source_file_suffixes.len` now 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:1538` asserted `std.mem.count(u8, text, "OK") == 0` over output that embeds the temporary directory path. `std.testing.tmpDir` names that directory with base64 over 12 random bytes, whose 64-symbol alphabet includes uppercase: 15 adjacent positions each carry `OK` with 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 both `OK:` and `OK 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.md` still 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 a `config/bootstrap.zig` that does not exist — the module is `loader.zig` plus `reconcile.zig`. The claim was checked against `src/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.servers` field that never existed and omitted the required `.groups` and `.upstreams`. The section now points at `docs/reference/configuration.md` and `nxdns export` and 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 lifts `y.good.ads.example`. Only domain entries match the query name alone. - **`src/config/model.zig`'s header described the retired bootstrap** and omitted `exception_count` from 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:3` is explicit that import has been a thin wrapper over `reconcile.zig` since 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: `Config` is the whole shape of a config file but not the only shape the repositories accept (the API writes through `RuleInput`, `ClientInput`, `ClientEdit`), and compiled-body reuse turns on the preserved source id and checksum rather than the counters — `loadSource` names 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 lacks `upstreams.tls_name` and `blocklist_sources.exception_count` and its `kind` CHECK admits only `exact` and `wildcard` — 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 at `config_schema.zig` and `migrations.zig`, with the three steps named. - **`INSTALL.md` said the service reads `config.zon` "on the first start".** Neither authority mode behaves that way: under `run --config` the service reads it on every start, and under database authority `nxdns import` reads it while a bare `nxdns run` never 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 in `docs/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.onSubmit` applied `pattern.trim()` to every kind, so a regex created in the UI did not store the bytes an identical `POST /api/rules` would. It now trims only `exact` and `wildcard`, where the server normalizes anyway. No client-side whitespace rejection was added: `" foo|bar"` still has a live `bar` branch, 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.build` received the snapshot arena for its temporaries.** An arena reclaims only its most recent allocation, so every `defer …deinit` inside `build` was a silent no-op and the scratch survived until snapshot teardown, uncounted by `memoryBytes()`. `build` now takes a permanent and a scratch allocator, and `matcher.zig` passes `arena` and `gpa` respectively. Regex programs compile into scratch and are copied across by a new `Program.clone`, which keeps `regex.zig` a single-allocator engine and leaves `compile`'s signature — and therefore `config/validate.zig` and 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 the `RuleSet` retains, and the divergence was arena capacity the formula does not claim to describe. The residual 1.15× is arena node headers and page rounding, which `memoryBytes` documents itself as excluding. The guard is `the build's temporaries stay out of the permanent allocator`, which asserts `arena.queryCapacity() < 2 * set.memoryBytes()` and passes `scratch` as `testing.allocator`, so a permanent allocation wrongly taken from scratch also fails as a leak. It was watched failing with the split reverted. - **An interactive `--fuzz` session cannot run on zig 0.16.0.** Building any fuzz target with `-ffuzz` fails inside the stock `/usr/lib/zig/compiler/test_runner.zig:566`, which passes a `*builtin.StackTrace` to `debug.writeStackTrace` where a `*const debug.StackTrace` is wanted — two distinct struct declarations. The failure is entirely inside the toolchain and reproduces on `compiler-fuzz`, a target this milestone did not touch. Corpus replay under `zig build test` is unaffected and remains the evidence for the step-bound property. - **`api.md` gained 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 plus `none` and the `cname:` prefix. - **Two comment sites still counted three temporaries.** `manager.zig` line 40 (the `refresh_lock` invariant) and line 1293 (`pruneOrphans`) both omitted `.allow.tmp`. Each presents itself as exhaustive, so a maintainer could have added an `.allow.tmp` writer outside `refresh_lock` and let the orphan sweep delete it mid-compile. `sourceFileId` and `source_file_suffixes` already 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.md` step 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 named `ads.doubleclick.net`, which StevenBlack no longer carries. ## Anti-requirements - No `$` modifier support beyond tolerating `$important` on exception lines. `$dnstype`, `$dnsrewrite`, `$client`, `$denyallow` and every browser modifier stay unsupported and counted. - No regex from downloaded lists. `.regex` lines stay counted and skipped; `skipped_regex` keeps 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.