# v1.9.9 — Detection-gap UX surfaces (validation memo)

**Release:** v1.9.9
**Date:** 2026-05-14
**Scope:** Default-panel UX additions in `recon_tool/formatter.py`,
common-prefix wordlist extensions in `recon_tool/sources/dns.py` and
`recon_tool/sources/cert_providers.py`. No engine code changes; no JSON
schema changes; no external-HTTP behaviour changes.

## What shipped

Three surfaces in the default panel and one wordlist breadth pass.
Detail in the v1.9.9 CHANGELOG entry and the v1.9.9 roadmap section;
this memo captures the validation-side observations that did not fit
in either.

## Trigger heuristic on the passive-DNS ceiling footer

The ceiling footer is the surface most likely to draw operator
attention because it adds a paragraph of text under the Services
block. False firing would dilute trust in the rest of the panel, so
the trigger needed to be conservative.

Final heuristic (in `formatter.py:render_tenant_panel`):

```python
if (
    info.services
    and not show_domains
    and info.domain_count >= 3
    and ceiling_categorized_count < 5
    and len(info.surface_attributions) < 5
):
    render_ceiling_block()
```

Per-fixture behaviour against Microsoft-fictional TenantInfo
fixtures pinned by `tests/test_formatter_ceiling.py`:

| Fixture | `domain_count` | Categorized services | Surface attribs | Ceiling fires |
|---|---|---|---|---|
| Sparse multi-domain (Contoso, 4 domains, 1 service) | 4 | 1 | 0 | yes |
| Single-domain (Contoso reduced to 1 domain) | 1 | 1 | 0 | no |
| Dense categorized (Contoso, 8 services across 6 categories) | 12 | 6+ | 0 | no |
| Sparse services + many surface attribs (8 subdomain attributions) | 4 | 1 | 8 | no |
| `--full` mode on sparse multi-domain | 4 | 1 | 0 | no |
| Empty `info.services` | 4 | 0 | 0 | no |

Two of these (single-domain, empty services) are the
under-firing-is-better-than-alarming cases the conservative trigger is
designed for. Two others (dense categorized, many surface attribs) are
the rich-stack cases where the footer would be misleading. The `--full`
case is the redundancy guard.

The thresholds (`>= 3` domains, `< 5` categorized services, `< 5`
surface attributions) are bound in local constants named at the call
site so a later trigger refinement is a one-line change with no scan
through the function body to find magic numbers.

## Wordlist rationale table

Eight new prefixes added to both `_COMMON_SUBDOMAIN_PREFIXES`
(`recon_tool/sources/dns.py`) and `HIGH_SIGNAL_PREFIXES`
(`recon_tool/sources/cert_providers.py`). Each entry maps to a
recognised stack tier with vendor-product backing.

| Prefix | Stack tier | Idiomatic targets |
|---|---|---|
| `data` | Data and analytics | Snowflake, Databricks, Looker, Tableau |
| `analytics` | Data and analytics | Mode, ThoughtSpot, Looker Studio, internal analytics portals |
| `ai` | AI / ML | Vertex AI endpoints, OpenAI proxies, Hugging Face spaces |
| `ml` | AI / ML | AzureML workspaces, SageMaker endpoints, internal ML platform tooling |
| `internal` | Operations | Internal portals, employee-only services with public DNS |
| `ops` | Operations | Operations dashboards, on-call tooling, status portals |
| `tools` | Operations | Platform tooling, dev productivity surfaces |
| `security` | Security | Incident-response portals, vuln disclosure, SIEM consoles |

Each addition is paired with the same prefix in `cert_providers.py`'s
`HIGH_SIGNAL_PREFIXES` so a CT response that surfaces (for example)
`data.contoso.com` sorts to the top of the bounded subdomain output
rather than falling off the cap. The parity is enforced by
`tests/test_subdomain_enumeration_breadth.py`.

Speculative entries deliberately excluded: `metrics`, `monitoring`,
`telemetry`, `secrets`. The ops trio already covers operations
tooling, and `secrets` is a strict-tier-boundary risk — a `secrets.x`
subdomain that resolves publicly is bad on its own merits and does not
justify a probe pass that would attract attention to the probe rather
than to the detection.

## Multi-cloud rollup canonicalization decisions

`_CLOUD_VENDOR_BY_SLUG` in `formatter.py` is the single source of truth.
Two canonicalization decisions worth recording because they affect what
operators see in the rollup:

- **Firebase rolls up under GCP.** Firebase Hosting and Firebase
  Realtime Database are part of the Google cloud footprint at the
  rollup level even though they are distinct product brands. A
  Firebase-only apex would otherwise read as a separate vendor from a
  GCP-Compute apex, which does not match how operators or vendors
  treat the GCP family. The Cloud row and Subdomain row still surface
  the per-slug labels (Firebase Hosting, GCP Compute Engine) so the
  product-brand distinction is not lost; the rollup is the aggregate
  view.
- **Replit and Glitch excluded from the rollup.** Both are
  cloud-categorized in `_CATEGORY_BY_SLUG` (they fire under the
  "Cloud" services row) but neither is a cloud vendor in the rollup
  sense an operator would use. Excluding them from
  `_CLOUD_VENDOR_BY_SLUG` keeps the rollup focused on cloud
  infrastructure vendors and avoids a `Multi-cloud: 2 providers
  observed (AWS, Replit)` line that would read as miscalibrated.

The trigger fires at canonicalized-vendor-count >= 2. Tests in
`tests/test_multi_cloud_rollup.py` cover both the canonicalization
(slug → vendor) and the trigger discipline (single-vendor apex does
not gain a vacuous row; SaaS-only apex without cloud-vendor slugs
does not fire even with many distinct services).

## Items deferred to v1.9.9.1

Two items from the original v1.9.9 roadmap section moved to v1.9.9.1
because they touch external HTTP behaviour and warrant their own
validation pass:

- Multi-apex CT SAN traversal. Currently `filter_subdomains` in
  `cert_providers.py` filters CT-returned SANs to subdomains under
  the queried apex only. The v1.9.9.1 change would broaden the filter
  to keep SANs under any apex observed in the same run, including
  apexes discovered via Autodiscover or sibling-org enumeration. Net
  effect: more subdomains surface per run with no additional HTTP
  calls. Validation needs to confirm the expanded filter does not
  introduce false-positive related-domain attribution on
  multi-tenant CT issuance.
- CT-by-org-name search when an organization name is available from a
  prior lookup. Net effect: a second CT query path keyed on org-name
  rather than apex domain. Validation needs to confirm that
  org-name-keyed queries do not return CT entries for unrelated
  organizations with similar names.

Both are tightly scoped enough to ship together as v1.9.9.1 or
separately as v1.9.9.1 and v1.9.9.2. The decision waits on the
validation pass.

## Expanded validation surfaces

The first pass shipped three test files (33 tests). A second pass added
four more test files (29 tests) targeting different failure modes than
the fixture-based coverage. The combined v1.9.9 test suite is 62 tests
across 7 files, exercising four falsifiable invariants beyond the
fixture cases.

### Coverage-gap enforcement (`test_cloud_vendor_coverage.py`)

The multi-cloud rollup map (`_CLOUD_VENDOR_BY_SLUG`) and a new explicit
exclusion set (`_CLOUD_VENDOR_ROLLUP_EXCLUSIONS`) together must cover
every Cloud-categorized slug in `_CATEGORY_BY_SLUG`. A slug that is in
neither would silently miss the rollup; the test catches this and the
contributor has to decide explicitly at PR time which side of the
decision the new slug falls on.

The coverage check found a real inconsistency on first run: `aws-waf`
was in the rollup map even though it is Security-categorized, not
Cloud. Removed.

Three additional invariants pinned: map and exclusion set are disjoint
(no contradictory decisions); both map entries and exclusion entries
stay consistent with `_CATEGORY_BY_SLUG` (no dead-weight entries that
would never fire).

### Boundary tests (`test_formatter_ceiling_boundary.py`)

Eight off-by-one cases on the ceiling trigger's three numeric
thresholds, two cases each for the AND-gate compound suppression:

| Threshold | Fires at | Suppresses at |
|---|---|---|
| `info.domain_count` | 3 | 2 |
| `len(categorized)` | 4 | 5 |
| `len(surface_attributions)` | 4 | 5 |

The fixture `_tenant_at_boundary` constrains itself to slug/service
pairs that round-trip cleanly through `_categorize_services` (skipping
slugs like `wiz` whose fingerprint display name diverges from the raw
service string and so file under two categories instead of one). The
constraint is documented inline so future changes to the categorize
function surface as test failures rather than silently invalidating
the boundary asserts.

### Property tests (`test_count_cloud_vendors_properties.py`)

Five Hypothesis-driven invariants on `count_cloud_vendors`:

1. Order independence on the apex stream. Vendor counting is a
   multiset operation.
2. Non-cloud-slug additions are no-ops. The function silently drops
   slugs not in the canonicalization map; adding any number of them
   cannot change the vendor counts.
3. Stream union semantics. Splitting the same multiset across apex
   and surface parameters equals passing the concatenation as one
   stream.
4. Total count equals input length when all inputs are in the map.
   A future refactor that adds filtering or deduplication inside the
   function would break this and the test would surface it.
5. Distinct vendor count is bounded above by the distinct input slug
   count. Canonicalization is many-to-one.

Hypothesis samples slug values directly from `_CLOUD_VENDOR_BY_SLUG`
keys, so the property tests stay in lockstep with whichever slugs ship
in the map.

### End-to-end render snapshots (`test_panel_render_snapshots.py`)

Twelve assertions on two reference TenantInfo fixtures rendered through
`render_tenant_panel`:

- **Contoso multi-cloud rich-stack** (3 cloud vendors via apex + 8
  Fastly subdomains): Multi-cloud row renders, lists three vendors,
  ceiling does NOT fire because surface attributions are above the
  sparse threshold, Services block still renders, Multi-cloud sits
  above Confidence in the key-facts block.
- **Northwind sparse-hardened** (5 tenant domains, one apex service,
  no surface attributions): ceiling renders, ceiling teaches about
  internal workloads, Multi-cloud does NOT fire on a single-vendor
  apex, ceiling sits below the Services block.

Cross-fixture asserts pin the mutual-exclusion behaviour: each fixture
fires exactly one of the two new surfaces, not both. The `--full` /
`--domains` mode suppresses the ceiling (long surface section
replaces it) but continues to render Multi-cloud (key facts are not
redundant with the subdomain table).

## Third validation pass — beyond fixture coverage

A third validation pass added six more test files (42 tests) targeting
the failure modes that fixture-based coverage and boundary tests do
not catch.

### Render-time fuzz (`test_render_fuzz.py`)

Three Hypothesis-driven tests against ``render_tenant_panel``.
Strategy generates random-but-structurally-valid TenantInfo across
the dimensions that interact with v1.9.9 surfaces: services, slugs,
surface_attributions, tenant_domains, domain_count, confidence level.
500 generated examples per test. The invariant: any structurally
valid TenantInfo must render without raising an exception, in both
default and ``--full`` modes, and the rendered output must be a
non-empty string containing the display name. Catches NoneType bugs,
KeyError lookups on unfamiliar slugs, and format-string ordering
issues that pre-canonical fixtures cannot exhaustively represent.

### Agentic-UX fixture compatibility (`test_agentic_ux_compatibility.py`)

Seven tests against the two v1.9.2 agentic-UX fixtures
(`contoso-dense.json`, `hardened-sparse.json`) loaded from the
committed `validation/agentic_ux/fixtures/` artifact set.

Key finding: ``contoso-dense.json`` has azure-dns on the apex AND a
single akamai surface attribution. The v1.9.9 Multi-cloud rollup
correctly fires on this fixture (Azure + Akamai = 2 distinct
vendors). This is a **richness gain on a v1.9.2 fixture**, not a
regression — agents reading the panel now see the multi-cloud nature
of the Contoso footprint at the top, without having to cross-reference
the Cloud line with the Subdomain line.

The ``hardened-sparse.json`` fixture has ``display_name: null`` and
deliberately fails the cache loader's ``ValueError`` check. The test
pins that contract so a future loader change that silently accepts
null display names is visible at PR time.

### JSON-absence contract (`test_panel_only_surfaces_json_absence.py`)

Six tests pinning that v1.9.9 panel surfaces never appear in the
serialized JSON. ``Multi-cloud``, ``providers observed``,
``Passive-DNS ceiling``, ``Passive DNS surfaces what publishes
externally``, and ``Server-side API consumption`` are all asserted
absent from ``tenant_info_to_dict`` output for both fixtures. A new
group of tests pins that no v1.9.9-style data field name
(``multi_cloud``, ``multi_cloud_vendors``, ``passive_dns_ceiling``,
``ceiling_fires``) appears as a top-level key in the serialized
shape. A future patch that wants to surface either as a structured
JSON field has to remove the relevant assertion deliberately,
documenting the schema-version bump.

### Rendered-output sanity (`test_panel_output_sanity.py`)

Seven tests on the well-formedness of the rendered output beyond
"does it render at all":

- **No vendor duplication**: an AWS-family-heavy apex collapses to
  one "AWS" mention in the rollup line. The canonicalization is
  enforced at the output level, not just at the helper level.
- **Provider-count accuracy**: the integer in ``N providers observed``
  matches the number of distinct vendor names actually listed in
  the parenthetical.
- **Ceiling phrasing well-formedness**: sentences end with periods,
  no double-punctuation, no orphan space-before-period.
- **Style invariant on the ceiling phrasing**: specific overclaim
  words (`always`, `never`, `strong`, `robust`, `completely`,
  `exactly`) are rejected at the source. Keeps the teaching tone
  load-bearing rather than coincidental.
- **Bounded line length**: the rollup line stays within 120 columns
  even on a fixture that would otherwise spill (multi-vendor with
  long vendor names).

### Wordlist sanity (`test_wordlist_sanity.py`)

Eight tests on the active-probe and CT-priority tuples beyond the
v1.9.9 additions themselves:

- Both tuples are deduplicated.
- The v1.9.9 additions appear in both lists with the same values
  (parity is load-bearing, not coincidental).
- All entries are lowercase (DNS labels are case-insensitive but
  matching against lowercase-only is the convention everywhere
  else; catches accidental ``"Auth"`` typos).
- No whitespace in entries.
- No empty entries.

### Corpus-aggregator harness

A new script, ``validation/corpus_aggregator.py``, consumes a
``recon batch --json`` results file and emits aggregate statistics on
Multi-cloud and ceiling firing rates. The script is pure-function
(no network access, no per-domain output) and produces output of the
form:

```json
{
  "corpus_size": 91,
  "multi_cloud": {"fired": 47, "vendor_count_distribution": {"2": 31, "3": 12, "4": 4}},
  "ceiling": {"fired": 6, "fired_with_zero_attribs": 6},
  "wordlist_breadth_proxy": {"subdomain_count_mean": 14.2, ...}
}
```

The script is committed; its inputs (the private corpus under
``validation/corpus-private/``) stay gitignored. The aggregator's
trigger logic mirrors the renderer's by design; the test file
``test_corpus_aggregator.py`` pins both implementations to the
same fixtures so they cannot drift apart.

Running the aggregator against the private v1.9.4 hardened corpus
plus the v1.9.3.10 rich-stack sample is the next step the maintainer
takes locally before tagging. The aggregator's output is safe to
drop into a follow-up ``validation/v1.9.9-corpus-run.md`` (apex-name
free, aggregate counts only).

## Fourth validation pass — test-quality, integration, and robustness

A fourth pass added five more test files (49 tests) targeting four
gaps that the prior three passes did not close: test quality
(mutation resistance), end-to-end CLI integration, render
determinism, adversarial-input robustness, and cross-version cache
compatibility.

### Mutation resistance (`test_mutation_resistance.py`)

Standard line coverage measures whether a line executed, not
whether the test that executed it would have failed had the
implementation been wrong. The standard answer is mutation testing:
deliberately break the implementation, confirm the tests fail.

Full-tree tools like ``mutmut`` and ``cosmic-ray`` are heavyweight
and ``mutmut`` does not run natively on Windows. This file ships a
focused, hand-rolled equivalent: each test case applies a *specific
named mutation* to a v1.9.9 helper, runs the relevant invariant
through the mutated function, and verifies the invariant fails (i.e.
the test suite would catch this exact bug).

Mutations exercised:

| Helper | Mutation | Caught by |
|---|---|---|
| `canonical_cloud_vendor` | `.get(slug)` → `.get(slug, "Unknown Cloud")` | non-cloud-slug-returns-None test |
| `count_cloud_vendors` | drop None-guard; leak unknown slugs as "Unknown" | non-cloud-slug invariance property test |
| `count_cloud_vendors` | `counts[vendor] += 1` → `+= 2` | total-count-equals-input-length property test |
| ceiling trigger | `domain_count >= 3` → `> 3` | boundary test at domain_count == 3 |
| ceiling trigger | `len(categorized) < 5` → `<= 5` | boundary test at categorized_count == 5 |

A passing test in this file means the corresponding mutated
implementation would fail the relevant tests. Failures here would
mean the mutation slipped through, indicating a coverage gap that
needs a new invariant test. Currently no mutations from the library
survive.

The mutation library is not exhaustive by construction. There are
infinite possible mutations and we test a handful (the ones most
likely to slip past careful review). PRs that change the helpers
should grow this file when new risk surfaces appear.

### CLI integration smoke (`test_cli_integration_smoke.py`)

Every prior v1.9.9 test exercises ``render_tenant_panel`` directly
through its Python API. None invoke the actual ``recon`` command-
line entry point. A wiring bug in ``recon_tool/cli.py`` between
argument parsing and rendering would slip past unit and snapshot
tests because the CLI plumbing is never touched.

This file closes that gap with 15 tests invoking the Typer app
through ``CliRunner`` and one ``subprocess.run`` spawn that
exercises the full installed-entry-point shape:

- ``--help`` and ``--version`` flags succeed and the version string
  matches ``recon_tool.__version__`` exactly (catches dual-source
  version drift).
- Every top-level subcommand (`lookup`, `batch`, `delta`, `discover`,
  `mcp`, `doctor`, `cache`, `fingerprints`, `signals`) loads its
  help without crashing — confirms each subcommand module imports
  cleanly.
- Unknown subcommand input produces a clean error message rather
  than a Python traceback (UX guard).
- The exported ``app`` is a Typer instance and ``run`` (the entry
  point declared in ``pyproject.toml [project.scripts]``) is
  callable — confirms the wheel-installed CLI shape matches the
  Python-import shape.

### Render determinism (`test_render_determinism.py`)

The user-facing contract is "same TenantInfo → same output". Non-
deterministic rendering would surface as diff churn on
golden-file snapshots, flaky CI, inconsistent ``recon delta``
results. The most likely failure source is dict / set iteration
ordering when ``PYTHONHASHSEED`` differs across processes.

Three in-process tests render the same TenantInfo 30 times each
and assert byte-identical output (default and ``--full`` modes), plus
a Hypothesis-driven test on arbitrary cloud-slug sets.

Two cross-process tests spawn subprocesses with distinct
``PYTHONHASHSEED`` values (``"1"`` vs ``"2"``, then ``"0"`` /
``"12345"`` / ``"99999999"``) and assert identical output. This is
the test that catches dict-hash-ordering bugs the in-process tests
cannot see, because ``PYTHONHASHSEED`` is fixed for the lifetime of
one interpreter.

### Adversarial-input robustness (`test_adversarial_render.py`)

13 tests against inputs designed to surface data-quality bugs:

- **Unicode display names** (CJK, RTL, accented, emoji) round-trip
  through the renderer without codec errors.
- **Control characters in slug strings, service names, and
  subdomain values** (null byte, ANSI escape, newline, tab) do not
  crash the renderer or corrupt the panel layout.
- **Pathologically large inputs** (1000-char display name, 200
  slugs, 200 surface attributions) render without exception.
- **Minimal inputs** (empty services, empty slugs) still produce a
  panel.
- **Punycode-encoded IDN subdomains** in surface attributions
  render without crashing.

Threat model: recon's panel layer has no untrusted-input boundary
(TenantInfo is built from data the tool itself collected). So
"adversarial" here means data-quality robustness, not security
boundary enforcement. The goal is that a target whose DNS records
contain unicode or control characters produces a panel that still
parses, not that the panel rejects malicious input.

### Cross-version cache compatibility (`test_cache_cross_version_compatibility.py`)

The v1.9.9 release adds renderer-side surfaces but **introduces no
new fields** in the ``TenantInfo`` dataclass or cache schema. The
cache version constant in ``recon_tool/cache.py`` is unchanged.

Operators with a v1.9.8 cache must (a) load it under v1.9.9 without
error, and (b) see the new v1.9.9 surfaces in the rendered output
derived from the existing cache fields. No re-collection or data
migration is required.

Seven tests pin both halves of this contract using synthesized
v1.9.8-shape cache JSON. A separate "cache-version constant
pinning" test prevents a silent ``_CACHE_VERSION`` bump from
slipping through; an intentional bump must update both the
constant and the expected value in the test, plus add a CHANGELOG
entry.

## Test-quality manifesto

In honest terms, here is what the v1.9.9 test suite proves and what
it does not. A reviewer asking "what is this evidence
actually load-bearing for?" should be able to read this section and
get the unvarnished answer.

### What is now proven, with high confidence

- The v1.9.9 surfaces (ceiling, multi-cloud rollup, wordlist
  extensions) fire on the inputs they were designed for and
  suppress on the inputs they were designed against. Trigger
  boundaries are pinned exactly; off-by-one regressions surface.
- Canonicalization is internally consistent: every Cloud-categorized
  slug is either mapped to a vendor or explicitly excluded;
  duplicates and overlaps are caught.
- The renderer is byte-deterministic across calls and across
  ``PYTHONHASHSEED`` values; set / dict ordering bugs surface.
- The renderer survives unicode, control characters, ANSI escapes,
  and large inputs without crashing.
- The CLI entry point loads, each subcommand parses help, version
  flag matches the package constant, and unknown-input handling
  is graceful.
- The new panel surfaces never leak into the serialized JSON shape
  (schema-stability contract).
- v1.9.8 caches load and render correctly under v1.9.9 with the
  new surfaces derived automatically.
- Five specific mutations of the v1.9.9 helpers are caught by the
  existing test suite (mutation-resistance pilot).

### What is NOT proven by this test suite

- **Empirical accuracy on real corpus.** Every test in v1.9.9 runs
  against synthetic Microsoft-fictional TenantInfo fixtures or
  property-generated random inputs. The corpus aggregator
  (``validation/corpus_aggregator.py``) is the tool to close
  this gap; it runs against the gitignored private corpus and
  emits anonymized aggregate stats. **That run has not yet
  happened for v1.9.9** and is a pre-tag step.
- **Full mutation coverage.** The mutation-resistance file
  documents five mutations. A real ``mutmut``-style sweep against
  the v1.9.9 helpers would explore hundreds. The five chosen are
  the ones most likely to slip past careful review (off-by-one,
  None-guard, comparator flip); other mutations may exist that
  our test suite does not catch.
- **Agent operator UX.** The v1.9.2 agentic-UX validation
  established that agents read the v1.9.2 panel correctly. The
  v1.9.9 compatibility test confirms the v1.9.2 fixtures still
  load and render with the new surfaces, but a fresh agentic-QA
  run against v1.9.9 panels has not been done. This is the v1.9.9
  equivalent of the v1.9.2 agentic-UX evidence; the absence is
  noted, not denied.
- **Performance / memory bounds.** The large-input adversarial
  tests confirm the renderer does not crash on 200-slug or
  200-attribution inputs. They do not bound time or memory. A
  pathological caller could in principle make the renderer slow
  without crashing.
- **Network / DNS / CT integration paths.** The CLI smoke tests
  exercise the Typer app's argument-parsing layer. They do not
  exercise the live DNS / CT / identity-discovery transports.
  Those paths have their own test suites elsewhere in
  ``tests/`` and are not in v1.9.9 scope.
- **The Bayesian inference layer is untouched by v1.9.9 and
  consequently un-revalidated in this release.** Calibration
  evidence remains the v1.9.4 → v1.9.6 sequence.

### What an honest reviewer should ask next

If we wanted v2.0 to be *bulletproof* by a stricter bar than the
above, the next questions to ask:

1. Run the corpus aggregator against the private v1.9.4 hardened
   corpus and the v1.9.3.10 rich-stack sample, drop the aggregate
   stats into ``validation/v1.9.9-corpus-run.md``. **This is the
   single highest-value remaining check.**
2. A fresh agentic-UX pass against three v1.9.9-rendered panels
   (rich multi-cloud, sparse hardened, single-cloud single-domain).
3. A full ``mutmut`` or ``cosmic-ray`` sweep against
   ``formatter.py`` on a Linux runner in CI, with surviving
   mutants written up as a per-release artifact.
4. A latency / memory budget test for the renderer at 10k-slug
   pathological inputs.

Items 1–4 are the residual gaps. Items 1 and 2 are operational
(run them locally). Items 3 and 4 are tooling additions that
post-v2.0 backlog can absorb.

## Quality gate at ship

- `python -m pytest`: **2481 passed** (under `--cov`) / 2480 passed
  (without `--cov`, 1 perf-test skipped on fast hosts), 4 deselected.
- `python -m pytest --cov=recon_tool`: **84% total coverage**.
  Deterministic across coverage and non-coverage runs.
- `python -m ruff check recon_tool/ tests/ scripts/ validation/corpus_aggregator.py validation/synthetic_corpus/`:
  all checks passed.
- `python -m ruff format --check`: 201 files already formatted.
- `python -m pyright recon_tool/ validation/corpus_aggregator.py`:
  0 errors, 0 warnings, 0 informations.
- v1.9.9 ships **167 new tests across 20 new test files**, plus a
  19-fixture synthetic corpus, a deterministic corpus generator, an
  anonymized corpus aggregator, and a render-snapshot report,
  covering trigger behaviour, test quality, integration, robustness,
  performance, corpus validation, and cross-version compatibility.
  See the test-quality manifesto above for the explicit "what we
  test and what we honestly do not" framing.

## Files touched

- `recon_tool/formatter.py` — `_CLOUD_VENDOR_BY_SLUG`,
  `canonical_cloud_vendor`, `count_cloud_vendors`, Multi-cloud field
  and Passive-DNS ceiling block in `render_tenant_panel`.
- `recon_tool/sources/dns.py` — 8 new entries in
  `_COMMON_SUBDOMAIN_PREFIXES`.
- `recon_tool/sources/cert_providers.py` — 8 new entries in
  `HIGH_SIGNAL_PREFIXES` (parity with the active-probe wordlist).
- `tests/test_formatter_ceiling.py` — 7 tests on the ceiling trigger.
- `tests/test_multi_cloud_rollup.py` — 16 tests on canonicalization
  and rollup rendering.
- `tests/test_subdomain_enumeration_breadth.py` — 10 tests on
  wordlist parity.
- `tests/test_cloud_vendor_coverage.py` — 4 tests on rollup-map
  coverage versus `_CATEGORY_BY_SLUG`.
- `tests/test_formatter_ceiling_boundary.py` — 8 off-by-one boundary
  tests on the ceiling trigger thresholds.
- `tests/test_count_cloud_vendors_properties.py` — 5 Hypothesis
  property tests on `count_cloud_vendors` invariants.
- `tests/test_panel_render_snapshots.py` — 12 end-to-end render
  snapshot tests across two reference TenantInfo fixtures.
- `tests/test_render_fuzz.py` — 3 Hypothesis-driven render-fuzz
  tests (500 examples per).
- `tests/test_agentic_ux_compatibility.py` — 7 tests pinning
  v1.9.9 behaviour on the v1.9.2 agentic-UX fixtures.
- `tests/test_panel_only_surfaces_json_absence.py` — 6 tests pinning that v1.9.9
  surfaces never leak into the JSON shape.
- `tests/test_panel_output_sanity.py` — 7 tests on rendered-output
  well-formedness (no duplication, well-formed phrasing, bounded
  line length).
- `tests/test_wordlist_sanity.py` — 8 tests on wordlist hygiene
  (dedup, lowercase, parity).
- `tests/test_corpus_aggregator.py` — 11 tests on the
  corpus-aggregator script.
- `tests/test_mutation_resistance.py` — 9 targeted-mutation
  resistance tests.
- `tests/test_cli_integration_smoke.py` — 15 CLI entry-point
  integration tests.
- `tests/test_render_determinism.py` — 5 in-process and
  cross-process render-determinism tests.
- `tests/test_adversarial_render.py` — 13 adversarial-input
  robustness tests.
- `tests/test_cache_cross_version_compatibility.py` — 7 cross-version
  cache-compatibility tests.
- `validation/corpus_aggregator.py` — corpus-aggregator
  script (committed; produces anonymized aggregate stats from a
  gitignored private corpus run).
- `pyproject.toml` — pyright `include` extended to cover the
  corpus-aggregator script so type-checking remains complete.
- `tests/test_mcp_path_isolation.py` — unused `noqa: S603` removed
  (lint hygiene from v1.9.8 work that the current ruff config no
  longer needs).
- `CHANGELOG.md`, `docs/roadmap.md`, `README.md` — v1.9.9 entries.
- `.gitignore` — allowlist entry for this memo.
