Skip to content

Commit 9bf81d7

Browse files
committed
Add more testing and guardrails for the built tlds.json file
1 parent 920f81d commit 9bf81d7

4 files changed

Lines changed: 269 additions & 6 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
title: Per-field source-of-truth for IANA data
3+
summary: Each IANA source is authoritative for a specific field set; the build does not cross-validate redundant sources. Encoded by reconciliation tests in tests/integration/test_build_reconciliation.py.
4+
created: 2026-05-16
5+
author: Eric Case
6+
tags: [log, decision, architecture, build, testing]
7+
---
8+
9+
# 2026-05-16 - Per-field source-of-truth
10+
11+
Each IANA data source is authoritative for a specific set of fields. The build never asks "do these two sources agree" for redundant data; it asks "does each source agree with the field it owns."
12+
13+
## Authoritative source per field
14+
15+
| Source | Authoritative for | Role |
16+
|-----------------------------------------|------------------------------------------------------------|---------------------|
17+
| `iana-root.html` (Root Zone DB) | TLD set membership, delegation status, manager, iana_tag | Primary build input |
18+
| `iana-rdap.json` (RDAP bootstrap) | RDAP server URLs for TLDs IANA has bootstrapped (predominantly gTLDs) | Annotation input |
19+
| `supplemental-cctld-rdap.json` (manual) | ccTLD RDAP servers not registered in IANA's bootstrap | Annotation input |
20+
| `icann-registry-agreement-table.csv` | gTLD agreement metadata (sponsorship, brand status) | Annotation input |
21+
| `iana-tlds.txt` (alphabetical list) | Nothing the build consumes | Advisory only |
22+
23+
## Why
24+
25+
Source-of-truth-by-field rather than source-of-truth-for-everything. The same logical fact (e.g., "is `.merck` delegated") appears in multiple IANA sources on different publication cadences. Asserting cross-source agreement as a build invariant fails whenever IANA legitimately publishes inconsistent intermediate state.
26+
27+
The .merck drift incident (commit 9a2c1b9, March 2026) is the precedent: root DB and tlds.txt disagreed on `.merck`'s delegation. The fix demoted tlds.txt from "build input" to "advisory monitoring signal" and rewrote the build to derive delegation from root DB alone.
28+
29+
## Encoded by
30+
31+
- `tests/integration/test_build_reconciliation.py` — five tests asserting per-field invariants:
32+
- Set equality between built tlds.json and root DB
33+
- Every IANA RDAP bootstrap TLD present in tlds.json with `rdap_server` populated and `annotations.rdap_source == "IANA"`
34+
- Per-TLD delegation flag agreement vs root DB
35+
- TLD count within 5% drift baseline (catches systematic parser regression that cross-source equality misses)
36+
- AST-based architectural guard: build never imports any symbol from `src/parse/tlds_txt`
37+
- `tests/integration/test_source_drift.py` — warns on root-DB-vs-tlds.txt drift, does not fail. The warning-only treatment is load-bearing: flipping it to failure would resurrect the .merck-class bug.
38+
- `tests/integration/test_data_integrity.py` — count-based variants kept alongside the new set-equality tests as descriptive companions; their failure messages name the specific count mismatch (delegated, undelegated, total math).
39+
40+
## Residual gap
41+
42+
The cross-source reconciliation tests compare each parser's output against a tlds.json built from that same parser (root DB for tests 1, 3; RDAP bootstrap for test 2). A parser regression that silently shrinks the source-side set would not be caught — both sides shrink together. `test_built_tld_count_within_baseline_threshold` partially closes this for the root DB parser with an independent inline baseline (1594 total, 1437 delegated as of 2026-05-16, 5% threshold). A full regression-against-prior-committed-tlds.json check that diffs the actual set, not just counts, is future work and would cover all parsers symmetrically.

docs/memory/memory-index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Agents: consult before suggesting layout, naming, dependencies, vendors, or conv
99
- [Architecture](architecture.md) - current implementation: stack, layout, conventions
1010

1111
## Log (newest first)
12+
- [2026-05-16 Per-field source-of-truth](log/2026-05-16-per-field-truth.md) - each IANA source authoritative for a specific field set; reconciliation tests encode the rule
1213
- [2026-05-15 Per-TLD JSON publication](log/2026-05-15-per-tld-publish.md) - per-TLD files + slim index alongside bulk tlds.json
1314
- [2026-05-15 Atomic JSON writes](log/2026-05-15-atomic-writes.md) - NamedTemporaryFile + fsync + chmod 0o644 + os.replace pattern
1415
- [2026-05-15 bin/ scripts replace make targets](log/2026-05-15-bin-scripts.md) - bin/setup, bin/lint, bin/test; make retains domain targets

tests/build/test_tlds.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,16 @@ def test_build_tlds_json_tld_count_matches_source(shared_build):
9191

9292

9393
def test_build_tlds_json_has_required_fields(shared_build):
94-
"""Each TLD entry has required fields."""
94+
"""Every TLD entry has required fields."""
9595
with open(shared_build.tlds_json) as f:
9696
data = json.load(f)
9797

98-
for tld_entry in data["tlds"][:10]:
99-
assert "tld" in tld_entry
100-
assert "delegated" in tld_entry
101-
assert "iana_tag" in tld_entry
102-
assert "type" in tld_entry
98+
required_keys = {"tld", "delegated", "iana_tag", "type"}
99+
for i, tld_entry in enumerate(data["tlds"]):
100+
missing = required_keys - tld_entry.keys()
101+
assert not missing, (
102+
f"Entry {i} ({tld_entry.get('tld', '<no tld>')}) missing keys: {missing}"
103+
)
103104

104105

105106
def test_build_tlds_json_strips_leading_dots(shared_build):
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Cross-source reconciliation of the built tlds.json.
2+
3+
These tests assert that the shipped tlds.json is consistent with the
4+
authoritative IANA source for each field. Per the .merck drift incident
5+
(commit 9a2c1b9, March 2026), we follow per-field source-of-truth:
6+
7+
- Root DB (iana-root.html) is authoritative for the TLD set, delegation
8+
status, manager, and type.
9+
- IANA RDAP bootstrap is authoritative for gTLD RDAP server URLs.
10+
- iana-tlds.txt is advisory only; it drifts from root DB in normal
11+
operation. test_source_drift.py surfaces the drift as warnings; the
12+
build must not depend on it.
13+
14+
These tests prefer the committed data/generated/tlds.json artifact when
15+
present so CI catches corruption in the file that would actually ship,
16+
not just in live build logic. On a fresh checkout the fixture builds
17+
into a tmp dir so the safeguard still runs.
18+
"""
19+
20+
import ast
21+
import inspect
22+
import json
23+
from pathlib import Path
24+
25+
import pytest
26+
from _pytest.monkeypatch import MonkeyPatch
27+
28+
import src.build.tlds as build_module
29+
from src.build.tlds import OutputPaths, build_tlds_json
30+
from src.config import TLDS_OUTPUT_FILE
31+
from src.parse.rdap_json import parse_rdap_json
32+
from src.parse.root_db_html import parse_root_db_html
33+
34+
35+
@pytest.fixture(scope="module")
36+
def tlds_data(tmp_path_factory):
37+
"""Return parsed tlds.json: prefer committed artifact, else build fresh.
38+
39+
Normal operation: the committed data/generated/tlds.json always exists,
40+
so the build-fresh branch is exercised only on fresh checkouts that
41+
haven't been built yet. It is intentionally kept rather than replaced
42+
with pytest.skip so the safeguard runs even in that case.
43+
"""
44+
committed = Path(TLDS_OUTPUT_FILE)
45+
if committed.exists():
46+
with open(committed) as f:
47+
return json.load(f)
48+
49+
tmp = tmp_path_factory.mktemp("reconciliation_build")
50+
mp = MonkeyPatch()
51+
mp.setattr("src.utilities.metadata.METADATA_FILE", str(tmp / "metadata.json"))
52+
paths = OutputPaths(
53+
tlds_json=tmp / "tlds.json",
54+
tlds_index=tmp / "tlds-index.json",
55+
tld_dir=tmp / "tld",
56+
)
57+
try:
58+
build_tlds_json(paths)
59+
with open(paths.tlds_json) as f:
60+
return json.load(f)
61+
finally:
62+
mp.undo()
63+
64+
65+
def test_built_tlds_set_equals_root_db_set(tlds_data):
66+
"""Every TLD in root DB is in tlds.json and nothing extra.
67+
68+
Stronger than count equality: catches the "drop entry X, duplicate
69+
entry Y, count unchanged" regression that count-based tests miss.
70+
71+
Asserts against root DB only, not against tlds.txt or the union of
72+
sources, because per-field source-of-truth means tlds.txt drift is
73+
not a build error (see .merck incident, commit 9a2c1b9).
74+
"""
75+
expected = {entry["domain"].lstrip(".").lower() for entry in parse_root_db_html()}
76+
actual = {entry["tld"].lower() for entry in tlds_data["tlds"]}
77+
78+
missing = expected - actual
79+
extra = actual - expected
80+
assert not missing and not extra, (
81+
f"TLD set mismatch vs root DB. "
82+
f"Missing from tlds.json: {sorted(missing)}. "
83+
f"Extra in tlds.json: {sorted(extra)}."
84+
)
85+
86+
87+
def test_built_rdap_entries_present_and_iana_sourced(tlds_data):
88+
"""Every TLD in the IANA RDAP bootstrap appears in tlds.json with an IANA-sourced rdap_server.
89+
90+
Does NOT exact-match URL strings because the build's source precedence
91+
is page_data > bootstrap > supplemental, and the two IANA sources can
92+
disagree on URL normalization (e.g., trailing slash). The invariants
93+
that actually matter: the TLD entry exists, rdap_server is populated,
94+
and annotations.rdap_source is "IANA" (not "supplemental").
95+
"""
96+
rdap_lookup = parse_rdap_json()
97+
by_tld = {entry["tld"].lower(): entry for entry in tlds_data["tlds"]}
98+
99+
missing_entries = []
100+
missing_rdap_server = []
101+
wrong_source = []
102+
for tld in rdap_lookup:
103+
entry = by_tld.get(tld.lower())
104+
if entry is None:
105+
missing_entries.append(tld)
106+
continue
107+
if not entry.get("rdap_server"):
108+
missing_rdap_server.append(tld)
109+
rdap_source = entry.get("annotations", {}).get("rdap_source")
110+
if rdap_source != "IANA":
111+
wrong_source.append((tld, rdap_source))
112+
113+
assert not missing_entries and not missing_rdap_server and not wrong_source, (
114+
f"RDAP reconciliation failed. "
115+
f"Missing entries: {sorted(missing_entries)}. "
116+
f"Missing rdap_server: {sorted(missing_rdap_server)}. "
117+
f"Wrong rdap_source (first 5): {wrong_source[:5]}."
118+
)
119+
120+
121+
def test_built_delegation_flag_matches_root_db(tlds_data):
122+
"""For every TLD, delegated flag matches manager-not-not-assigned in root DB."""
123+
root_db_by_tld = {
124+
entry["domain"].lstrip(".").lower(): (entry["manager"] != "Not assigned")
125+
for entry in parse_root_db_html()
126+
}
127+
128+
mismatches = []
129+
for entry in tlds_data["tlds"]:
130+
tld = entry["tld"].lower()
131+
if tld not in root_db_by_tld:
132+
continue
133+
expected_delegated = root_db_by_tld[tld]
134+
if entry["delegated"] != expected_delegated:
135+
mismatches.append((tld, entry["delegated"], expected_delegated))
136+
137+
assert not mismatches, (
138+
f"delegated flag mismatch vs root DB for {len(mismatches)} TLD(s) "
139+
f"(first 5: {mismatches[:5]})"
140+
)
141+
142+
143+
def test_built_tld_count_within_baseline_threshold(tlds_data):
144+
"""Defend against systematic parser regression by comparing to a recorded baseline.
145+
146+
All the other reconciliation tests above compare parse_root_db_html()
147+
against the built tlds.json. Both sides flow through the same parser,
148+
so a parser regression that silently shrinks the set (e.g., a regex
149+
change that fails to match some root-zone rows) would not be caught.
150+
Both sides shrink together and the cross-source equality still holds.
151+
152+
This test guards against that by comparing the current TLD count
153+
against a baseline recorded inline below. The 5% threshold (~80 TLDs
154+
out of ~1594) is well above IANA's annual TLD growth rate. Update
155+
the baseline constants when a legitimate IANA expansion crosses the
156+
threshold (e.g., ICANN's next-round gTLD additions).
157+
"""
158+
baseline_as_of = "2026-05-16"
159+
baseline_total = 1594
160+
baseline_delegated = 1437
161+
threshold = 0.05
162+
163+
actual_total = len(tlds_data["tlds"])
164+
actual_delegated = sum(1 for e in tlds_data["tlds"] if e["delegated"])
165+
166+
total_drift = abs(actual_total - baseline_total) / baseline_total
167+
delegated_drift = abs(actual_delegated - baseline_delegated) / baseline_delegated
168+
169+
failures = []
170+
if total_drift >= threshold:
171+
failures.append(
172+
f"total TLD count drifted {total_drift:.1%} "
173+
f"(baseline {baseline_total} as of {baseline_as_of}, now {actual_total})"
174+
)
175+
if delegated_drift >= threshold:
176+
failures.append(
177+
f"delegated TLD count drifted {delegated_drift:.1%} "
178+
f"(baseline {baseline_delegated} as of {baseline_as_of}, now {actual_delegated})"
179+
)
180+
181+
assert not failures, (
182+
f"TLD count outside baseline threshold ({threshold:.0%}): {failures}. "
183+
f"If this is legitimate IANA growth, update the baseline constants "
184+
f"in this test."
185+
)
186+
187+
188+
def test_build_does_not_import_tlds_txt():
189+
"""Architectural invariant: build must not depend on the tlds_txt module.
190+
191+
tlds.txt drifts from root DB in normal operation (see .merck
192+
incident, commit 9a2c1b9, where the build was rewritten to derive
193+
delegation status from root DB alone). Re-introducing any tlds_txt
194+
module symbol into the build path would resurrect that
195+
drift-as-build-failure class of bug.
196+
197+
Uses AST inspection rather than string grep so the check ignores
198+
occurrences of "tlds_txt" inside comments, docstrings, or unrelated
199+
identifiers.
200+
"""
201+
tree = ast.parse(inspect.getsource(build_module))
202+
203+
violations = []
204+
for node in ast.walk(tree):
205+
if isinstance(node, ast.ImportFrom):
206+
module = node.module or ""
207+
for alias in node.names:
208+
if "tlds_txt" in module or "tlds_txt" in alias.name:
209+
violations.append(f"from {module} import {alias.name}")
210+
elif isinstance(node, ast.Import):
211+
for alias in node.names:
212+
if "tlds_txt" in alias.name:
213+
violations.append(f"import {alias.name}")
214+
215+
assert not violations, (
216+
f"src/build/tlds.py imports from tlds_txt module: {violations}. "
217+
"Per .merck incident (commit 9a2c1b9), build must derive TLD "
218+
"set membership from root DB alone."
219+
)

0 commit comments

Comments
 (0)