# Roadmap

This file is forward-looking. Shipped work belongs in
[CHANGELOG.md](../CHANGELOG.md); release mechanics belong in
[release-process.md](release-process.md).

> **Status (2026-06):** v2.1.10 is the current release. The v2.0 schema lock, the
> v2.1 cohort summary (`recon batch --summary`), and the v2.1.1-v2.1.10 hardening,
> security, and assurance patches have all shipped; per-release detail is in
> [CHANGELOG.md](../CHANGELOG.md) and upgrade notes in
> [migration-v2.md](migration-v2.md).
>
> **Post-2.0 assurance track, in priority order:** the first three are done.
> Differential verification of the inference core shipped in v2.1.7; the 2026-06
> fault-injection sweep closed four confirmed ingestion-boundary gaps (v2.1.8 made
> the CSE/BIMI direct probes opt-in so the default is passive; v2.1.9 added a gzip
> decompression-bomb guard, deeply-nested-JSON handling in the cache and CT
> providers, and a CT-graph entry-count bound); and the hostile-input fuzz CI gate
> plus per-parser resource-bound tests shipped in v2.1.10, with the residual
> per-parser bounds and the OIDC/Azure region cap in v2.1.11. Reproducible builds
> (SOURCE_DATE_EPOCH, CI-gated) and sigstore-signed PyPI attestations shipped in
> v2.1.12 (see [supply-chain.md](supply-chain.md)), the auditable assurance case
> and operational contract in v2.1.13, and the PV2 maintainer-validation loop with
> a committed, CI-gated inference drift gate in v2.1.14 (see
> [maintainer-validation.md](maintainer-validation.md)). **Next:** the
> credible-interval coverage check, then the mutation gate plus traceability
> matrix. This file is the plan from here.

## Pre-2.0 hardening (shipped) and the road past v2.0

The pre-2.0 hardening below shipped with the v2.0 lock; the v1.9.x track detail
is in [CHANGELOG.md](../CHANGELOG.md), kept here only for the rationale. The
forward plan, the post-2.0 assurance north star, the backlog, and the invariants
follow it.

The engineering-elevation series (v1.9.27 to v1.9.37: py.typed, the 3.12
floor, branch coverage, `deal` contracts, build provenance, the stateful
and 3.14t test work, PEP 735, the C901 gate) is shipped. Before the v2.0
lock, the project is in a deliberate hardening phase: v2.0 is gated behind
making the engine and catalog demonstrably solid, not behind docs currency
alone. The scoping confirmed the eventual lock is small (EXPERIMENTAL
labels are already gone, `correlation.md` is most of the way to the polish
bar, the v2.0 corpus-run file is populated), so the value now is in depth,
not ceremony.

**What 2.0 delivers, and why the small ceremony is the point.** The 2.0
*event* is mechanical by design: a schema lock, a doc snapshot, the `--fusion`
default flip, and a tag, with no new feature work invented at the lock moment.
That is deliberate, not modest. Every capability ships and is validated in a
1.9.x patch first, so the lock is small precisely because the substance is
already done and proven. What 2.0 *delivers* is the capstone where that
accumulated excellence becomes a stable contract:

- a fingerprint catalog that is comprehensive and high-precision across the
  5,000+ company corpus, with the gap report driven to a low residual, not just
  the top-tier-vendor spot checks that cleared early;
- a Bayesian layer whose every node is calibrated and stable across the full
  corpus, the credible intervals behaving as designed on sparse and hardened
  targets, validated by complete (CT-enabled) calibration runs rather than
  snapshots;
- a JSON / MCP schema locked as a real v2.0 contract, with the promoted fields
  documented as stable;
- reference docs (notably `correlation.md`) at the polish bar;
- and zero EXPERIMENTAL labels anywhere.

So the "mechanical lock event" language elsewhere in this file describes the
*ceremony*, not the *ambition*. By the time it fires, recon is meant to be
excellent everywhere because the corpus-driven depth above made it so, and the
small tag step is the proof that nothing was rushed to get there.

Five tracks run as a sequence of focused, CI-green 1.9.x patches:

- **Complexity decomposition.** Decompose the functions carrying
  `# noqa: C901` (the gate from v1.9.37 already holds new code), removing
  each marker as its function drops under 15. Golden-output
  characterization tests for the renderers came first
  (`tests/test_golden_renders.py`, fictional brands only); with that net in
  place the whole of `formatter.py` is decomposed and carries no marker as of
  v1.9.52, the ~940-line `render_tenant_panel` and the markdown renderer
  included, output held byte-identical throughout. The remaining markers are
  the behaviour-heavy functions (`_lookup`, `merge_results`, `_batch`, and a
  tail of validators / loaders), which want characterization coverage of their
  own before they are split.
- **Test and validation rigor.** `deal` contracts on the boundary
  validators (third pass after the inference core and matchers); a
  Hypothesis stateful machine for the cache lifecycle; deterministic
  fault-injection at the source boundary (malformed / truncated payloads,
  timeouts, partial provider failures) asserting hedged output and
  `degraded_sources` hold; branch coverage driven up toward the
  `server.py` target.
- **Catalog growth.** Public vendor-doc-sourced `cname_target`
  fingerprints via the `CONTRIBUTING.md` methodology, plus corpus-mined
  candidates from `validation/scan.py` over private-corpus subsets
  (respecting CT rate limits and the `--ct-retry-from` multi-session
  workflow). Per-domain output stays gitignored; only aggregate counts
  reach committed memos, and committed examples use the fictional brands.
- **Robustness and security.** Adversarial bug-hunt of the ingestion and
  parse paths (DNS, CT, BIMI VMC, identity endpoints), input edge cases
  (IDN / punycode, malformed, oversized, truncated), and another
  security-audit round folded into `docs/security-audit-resolutions.md`.
- **CLI and agent quality-of-life.** Polish the consumer surface, separate
  from the internal complexity work. The capability is already broad (the
  `lookup` / `batch` / `delta` / `discover` / `doctor` / `cache` /
  `fingerprints` / `signals` / `mcp` command tree, `--explain` / `--full` /
  `--json` / `--exposure` / `--gaps` / `--profile` / `--chain`, an exit-code
  contract, and roughly two dozen MCP tools), so the gap is discoverability and
  small ergonomics rather than missing features. Concrete items, each its own
  small patch:
    - *Exit-code reference.* The contract exists (`0` success, `2` validation,
      `3` no-data, `4` internal) but is documented only in scattered spots
      (`schema.md`, `security.md`); pull it into one reference block for
      scripters, and name every literal as a constant in `cli.py`.
    - *Shell completion currency.* Typer ships `--install-completion` /
      `--show-completion` automatically; decide deliberately whether to
      document it or disable it, rather than leave it present but invisible.
    - *`batch` stdin.* Support `cat domains.txt | recon batch -` if not
      already present, the natural piping ergonomic for batch input.
    - *`autoApprove` guidance.* Document which MCP tools are read-only versus
      stateful so a consuming agent (and the README's manual-approval advice)
      can reason about what is safe to auto-approve.
    - *Schema-discovery surface.* An MCP tool or `recon://` read-only resource
      that returns the JSON schema and its version, so an agent can
      self-describe without an external fetch.
    - *Data-not-instructions demarcation.* Already tracked in Known gaps: mark
      recon's returned DNS / CT / BIMI strings as untrusted observed content so
      a consuming agent treats them as data, not instructions. It belongs to
      this track as the highest-value agent-facing item.
    - *Small consistency items.* `_SUBCOMMANDS` omits the real `discover`
      command (harmless today, since the set only gates dotted first args, but
      worth aligning). None of these add engine surface; they make what already
      ships easier to consume, which is squarely the "exceptionally well before
      2.0" goal a consumer actually notices.

### Remaining work to v2.0 (the execution queue)

> **All shipped (historical).** Everything in this subsection and the v2.0 lock
> ceremony below was completed; v2.0.0 and the v2.1.x line have all shipped (the
> current release is v2.1.10). The detail is kept for the rationale and the
> per-item disposition; the forward plan is the post-2.0 assurance track above
> and the feature candidates below. Per-release detail lives in
> [CHANGELOG.md](../CHANGELOG.md).

This subsection is the single source of truth for what is left before the v2.0
lock. The five tracks above describe the shape of the work; the queue below
turns it into an ordered, checkable list so "what ships next" is never
ambiguous. Each row is one coherent 1.9.x patch under the no-bundling
discipline (one story per patch, version bump last, local gate matched to CI).
Rows marked *corpus-driven* run against the gitignored ~5,200-domain validation
corpus (`validation/corpus-private/`) and the network, rather than from the public
tree alone. That corpus is intentionally kept out of git for cleanliness, respect
for the organizations in it, and security / legal hygiene, **not** because the
work is secret or done elsewhere. The scans run locally with the tooling already
in the repo (`validation/scan.py` to resolve, `validation/find_gaps.py` to rank
unclassified termini, the calibration / stability harness for the Bayesian
layer), and rigorous validation against that base is a core part of the pre-2.0
"exceptionally well done" bar. The only constraint is a commit filter: aggregate
counts, vetted patterns, fictional-brand examples, and calibration metrics are
committed; real apexes and per-domain findings stay in the gitignored paths.
Small ad-hoc random lists are fine for quick iteration on a specific change; the
full corpus is what gives the broad coverage and calibration confidence. Every
other row is code or docs work that ships on its own.

The order is the recommended sequencing: cheapest-and-clearest first
(consumer-facing polish and pure decomposition), then the rigor and
robustness layers, then the corpus-driven depth, and finally the mechanical
lock. Numbers are queue positions, not version numbers; each row claims the
next free `v1.9.x` when it ships.

**Order of operations to the lock.** Track E is done. The remaining doable work
runs in this order, each step one or more 1.9.x patches:

0. **Tooling foundation.** Done: `release.py`'s `_INIT_VERSION_RE` matches the
   refactored `__init__.py` (only `_FALLBACK_VERSION` is a literal now) and the
   `scripts/` C901s are cleared, so the release gate is sound.
1. **Finish complexity decomposition (Track A) to zero markers.** Done in
   v1.9.72 to v1.9.76. The behaviour-heavy tail was the last of it: A6 the `cli`
   set (`signals_show` + `_doctor` in v1.9.72, `_lookup` in v1.9.73, `_batch` in
   v1.9.74), A7 `merge_results` (v1.9.75), and A8 the `server` pair (v1.9.76),
   each with characterization coverage added first. Zero `# noqa: C901` markers
   remain and the cap holds the whole tree.
2. **Test and validation rigor (Track B).** Done in v1.9.77 to v1.9.80: `deal`
   contracts on `validator.py` (B1), the cache-lifecycle stateful machine (B2),
   source-boundary fault injection (B3), and the server-tool coverage lift that
   took `server.py` to ~75% (B4).
3. **Robustness and security (Track D).** D1 done in v1.9.81: a fresh adversarial
   ingestion audit closed round six (output-sink control stripping for
   source-derived service strings and the DMARC `p=` value), folded into
   `docs/security-audit-resolutions.md`.
4. **Catalog growth (Track C).** C1 shipped (v1.9.82 to v1.9.86, vendor-sourced
   `cname_target` fingerprints from local live-analysis batches, 808 to 829
   entries, fictional-brand examples only). C2 corpus-mined gap-fill is underway:
   a full 5,241-domain `validation/scan.py` run plus `validation/find_gaps.py` and
   `triage_candidates` drove three verified batches (v1.9.87 to v1.9.89, 13
   vendors, 829 to 841 entries; trail in `validation/v1.9.87-c2-corpus-batch.md`).
   The triage confirmed most high-count residual termini are org-internal GSLB /
   load-balancers that by design never become fingerprints; the named third-party
   residual is now closed, leaving only the low-frequency long tail. Aggregate
   counts and vetted patterns reach the repo; real apexes do not.
5. **Bayesian full-corpus calibration (Track C, C3),** with the legitimacy
   refinements below applied so the numbers are defensible, not just
   self-consistent. Run over the final catalog: per-node metrics,
   deterministic-vs-Bayesian *consistency*, interval coverage, sparse-case
   behavior. Aggregate metrics only.
6. **Docs currency (pre-lock).** Bring the release-notes draft current (F1),
   promote `correlation.md` to the polished reference (G3, incl. the
   independence-bias and ground-truth caveats below), and refresh the
   validation-summary with the fresh baseline (F2).

The corpus runs are network-heavy. The DNS / identity-endpoint pass runs at low
batch concurrency (a concurrency-16 pass blew the 120s per-domain budget on
~84% of domains via resolver saturation; concurrency 5 holds the error rate near
4%). CT is separate and self-throttles to a process-wide cap of 2 with an AIMD
limiter + circuit breaker, so a CT-enabled pass is small-subset / multi-session
(`--ct-retry-from`) and reported as partial. The nine Bayesian nodes are fed by
DNS / identity, not CT, so the no-CT pass calibrates the layer fully; CT only
feeds the separate `infrastructure_clusters` and cert lexical surfaces. Only the
v2.0 lock ceremony itself (G1 schema lock, G2 `--fusion` default-on, G4
changelog-move + tag) stays out of this sequence.

#### Calibration legitimacy refinements (Track C-cal)

A 2026-06 methodology review flagged that the current numbers measure
*self-consistency*, not calibration against ground truth, and over-claim when
they call it "calibration." A second review of the theory (`correlation.md`)
against the first full-corpus pass added CAL10 to CAL13 and sharpened CAL1 /
CAL7 / CAL9: the consistency number is near-tautological under the
virtual-evidence construction (so it cannot falsify the CPT values), the
correlated-binding multiplication makes the layer over-confident on
richly-instrumented targets even as the absence rule keeps it conservative on
hidden ones, and the information-recovery north-star is now measurable
(entropy reduction). These refinements make the Bayesian-validation story
defensible without overstating it. Each is its own small patch; they gate the
C3 calibration claims and the `correlation.md` polish (G3).

**Shipped so far (2026-06):** CAL1, CAL2, CAL5, CAL7, CAL10 landed in the
v1.9.61 to v1.9.71 calibration work. CAL13 (evidence-responsive framing in
`correlation.md` section 4.8.4) and CAL8 shipped 2026-06-04; CAL8 is a new
`validation/likelihood_sensitivity.py` harness (cleaner than overloading
`threshold_sensitivity.py`, which sweeps trigger thresholds) with worst-case
dECE <= 0.032 and decision flips <= 1.3% under a +/-20% perturbation, artifact
in `validation/cal8-likelihood-sensitivity.md`. CAL3 / CAL4 are reframed around
authoritative public oracles (DMARC / SPF / MTA-STS records as their own truth;
M365 / GWS tenancy via the providers' own endpoints) plus an optional, external,
anonymized case-study sanity check (`validation/2026-06-04-case-study-spot-check.md`,
first sample: 7 of 7 positive detections corroborated, 0 false positives), rather
than a fabricated hand-labeled corpus. **CAL14 design note:** the email-policy
node's `dmarc_reject` and `dmarc_quarantine` bindings are mutually exclusive, so a
correct absence-conditioning rule must operate per evidence-group with new
absence-likelihood parameters (each a claim under the CPT-change discipline), not
per independent non-fired binding; it is the heaviest remaining change and ships
with the maintainer in the loop on those modeling choices. Remaining open: the
CAL3 / CAL4 oracle calibration run, CAL6, CAL9, CAL11, CAL12, and CAL14.

| # | Refinement | Acceptance |
|---|---|---|
| CAL1 | Reframe consistency vs calibration (and show why) | Everywhere (analyzer headers, `correlation.md`, validation memos), the deterministic-vs-Bayesian number is named *consistency / agreement*, not *calibration*, with an explicit note that both layers share the same evidence. State the mechanism: under the virtual-evidence construction a high non-sparse posterior requires a fired positive binding, which is itself a deterministic detection, so the consistency number tests the inference plumbing, not the CPT / likelihood values. Only CAL3 interval coverage can falsify the values. Proxy-label Brier / ECE are labeled as proxy-label, not ground-truth. |
| CAL2 | Uncertainty on every metric | The agreement rate carries a Wilson / Rule-of-Three CI (report the upper bound on disagreement, e.g. ~3/n); Brier / ECE carry bootstrap CIs. No bare "100%". |
| CAL3 | Empirical interval coverage | The load-bearing claim (80% credible intervals) is tested: empirical coverage of the 80% interval against ground truth is computed and reported. |
| CAL4 | Ground-truth subset | A hand-labeled set of ~50-100 domains with independently-known answers (verified M365 / GWS tenants, known Okta orgs) lives gitignored; real calibration + CAL3 coverage are computed against it, aggregates only. |
| CAL5 | Split coverage from calibration | The per-node verdict separates firing count (coverage / power) from Brier / ECE (calibration). `okta_idp` is reported as well-calibrated-but-low-coverage, not a flat "fail"; the `>= 10` firing gate is a coverage note, not a calibration verdict. |
| CAL6 | Stratified sampling | Calibration draws a random / stratified sample (by cloud vendor / vertical / region, using `by-vertical/` and `by-region/`) and reports per stratum; the full 5,241 run is used so low-coverage nodes (okta_idp) clear the firing gate on volume. |
| CAL7 | Correlated-binding overconfidence | `correlation.md` documents that co-firing bindings (e.g. `microsoft365` + `entra-id` + `exchange-online`, three views of one fact) are multiplied as if conditionally independent, which compounds the likelihood ratio and narrows the interval more than the evidence warrants. Combined with the `LR=1` absence rule this gives an asymmetric failure: conservative on hidden targets, over-confident on richly-instrumented ones. Mitigation: a noisy-OR / single evidence-cluster factor over co-firing slugs or an explicit down-weight, plus an interval-width-vs-evidence-count diagnostic that surfaces the over-confidence (which the mean-agreement metric cannot see). |
| CAL8 | Sensitivity analysis per release | `threshold_sensitivity.py` runs a +/-20% likelihood perturbation each calibration pass and shows the posteriors / agreement are stable; the result is an aggregate artifact. |
| CAL9 | Reliability diagrams + proper scoring lead; do not drop the hard cases | The validation memo leads with reliability diagrams, the posterior histogram, and the log-score (a proper scoring rule); ECE is demoted to a secondary diagnostic with its binning scheme stated, and the memo notes that ECE looks low partly because the posterior distribution is bimodal (few mid-range points). The headline consistency number excludes sparse observations today; report the sparse cohort separately rather than silently dropping the cases where the model's real uncertainty lives. |
| CAL10 | Entropy-reduction (information-recovery) metric | Operationalize the mutual-information north-star that `correlation.md` currently disclaims as intent: report the per-domain posterior entropy reduction (H(prior) - H(posterior), in nats) distribution each calibration pass. A first full-corpus pass measured median ~0.85 nats (Q3 ~1.23). This is the concrete "information recovered" number the framing wants. |
| CAL11 | Graph partition stability, not a lone modularity score | The graph layer (Louvain / CPM) reports a partition stability / consensus measure across seeds and resolution, not a single modularity Q; `correlation.md` cites the resolution-limit (Fortunato-Barthelemy 2007) and degeneracy (Good et al. 2010) literature where CPM is introduced, since CT co-occurrence graphs are typically small and sparse. |
| CAL12 | Ground priors in observed base rates | Node priors are documented against observed corpus base rates and the elicitation is written down. The first full-corpus pass showed mismatches (e.g. `m365_tenant` prior 0.30 vs high-confidence in ~60% of domains, `aws_hosting` 0.40 vs ~28%). Strong likelihoods wash this out for the point estimate, but the prior is load-bearing for the sparse-case posterior and the interval, which is the field we say matters most. |
| CAL13 | "Evidence-responsive" not "calibrated" | Until CAL3 frequentist coverage exists, the 80% credible intervals are described as *evidence-responsive* (they widen on sparse evidence, a monotonicity property), not *calibrated* (a coverage statement in the Dawid sense). The word "calibrated" is reserved for what CAL3 demonstrates. |
| CAL14 | Node-dependent missingness (MAR vs MNAR) | The blanket asymmetric / LR=1 absence rule is right for hideable infrastructure (m365, okta), but wrong for public-declaration signals whose absence is genuine, not adversarially hidden (DMARC / SPF / MTA-STS policy). The 2026-06 synthetic calibration showed `email_security_policy_enforcing` at conditional ECE ~0.31 (the [0.85] reliability bin realizing 0.166) precisely because the model could not use the absence of the strong DMARC signals as disconfirmation. A `spf_strict` down-weight (v1.9.71, conceptual) took it to ~0.28; the real fix is per-node MAR / symmetric conditioning for public-declaration nodes. A prototype confirmed the direction (the no-signal baseline correctly drops toward 0). It is a core-invariant change that ripples into n_eff / sparse (absence is evidence, so a no-signal MAR node should not be flagged sparse) and the criterion-(a) stability test (reframe from "every binding raises the posterior above the all-absent baseline" to "toggling a binding raises the posterior"), so it ships as its own fully-validated patch, not bundled. v2.0 should gate on the *max* per-node conditional ECE, not the mean. |

#### External-review candidate items (2026-06)

A 2026-06 external review of the correlation engine surfaced four additive items
worth scheduling. The rest of that review either restated already-shipped work
(the evidence-group correction in v1.9.70, the CAL1 to CAL14 calibration track)
or conflicted with the zero-dependency / passive-only invariants and is not
adopted. None of the four are scheduled yet; each would be its own future patch.

| # | Item | Track | Notes |
|---|---|---|---|
| EXT1 | CT-edge temporal decay | C (graph) | Down-weight CT-derived edges by certificate age (`w = e^(-lambda * delta_t)`) before community detection, so legacy-infrastructure edges do not bridge distinct infrastructure epochs. Additive, no new dependency. |
| EXT2 | SAN-count edge weighting | C (graph) | Penalise edges from large shared-CDN certificates (`|SAN| > tau`): a 2-SAN cert is a strong ownership link, a 150-SAN edge cert is noise. Pairs with EXT1 and the CAL11 partition-stability work. |
| EXT3 | Counterfactual explanations | B / explainability | "If DMARC reject were absent, the policy posterior would drop from X to Y." Cheap given the exact-inference engine (re-run with one binding flipped); extends `--explain-dag`. The per-node LLR attribution it also asks for already exists (`NodeEvidence.llr` / `influence_pct`). |
| EXT4 | PROV-O / JSON-LD provenance export | E / agent QoL | A `--prov` flag emitting the evidence DAG as W3C PROV-O rather than the bespoke `explanation_dag` JSON. Optional polish, not a v2.0 gate. |

Explicitly *not* adopted from that review, with reasons: HPD-from-PMF (the claim
nodes are binary, so each posterior is a single probability, not a multi-state
mass function to take an HPD over); a scipy / numpy tensor inference backend
(breaches the no-numpy invariant, and the nine-node network is deliberately
small); an external empirical-Bayes prior source such as Censys / Rapid7 Sonar
(breaches zero-paid-API / no-bundled-DB; the own-corpus version is CAL12); a
historical-DNS `StateTransition` signal (recon has no historical-DNS source
under its invariants, only live resolution plus `delta` and cross-source
conflict provenance); matplotlib calibration SVGs (heavy dependency; the
calibration artifacts live as `validation/` memos); and a code-plugin node
system (data-only YAML overlays only, per the no-user-code invariant).

#### The concept that orders this plan

recon does one thing: report what the public channel can defensibly reveal about
a domain, with provenance and honest uncertainty. The invariants follow from that
single idea rather than from a feature list. No credentials and no active
scanning, because private and active observation are out of scope. No committed
company data, because examples teach the method, not targets. No shipped baselines
and no persistent aggregate store, because recon is a reducer over what it
observes, not an intelligence database. A small, inspectable Bayesian layer,
because the uncertainty exists to discipline claims, not to decorate them.

So the post-2.0 work is ordered concept-first: make what exists more trustworthy
before adding anything, and let any new candidate earn its place by passing back
through the concept. The cohort work is the clearest case: it is the same
uncertainty discipline applied across a caller-owned set, "across this
caller-supplied cohort, what did the public channel reveal, how observable was it,
and how uncertain are we", never "what does this industry use". Hold the concept
and the build order below stays simple.

#### Post-2.0 release sequence

The shape of the post-2.0 line, in order:

- **2.0.x: trust hardening.** The assurance work below, differential verification
  of the inference core first. This is the priority; the product is trust, not
  surface area.
- **2.1: aggregate state, thin and doc-first, in two stages (shipped).** 2.1a
  shipped the methodology doc (`docs/aggregate-state.md`), a synthetic fixture,
  and a local reducer script, with no change to core. 2.1b shipped the thinnest
  core surface: `recon batch --summary` (add `--json`), one cohort at a time,
  carrying its own `cohort_summary` record type and `schema_version` 2.1. It met
  the acceptance bar: no persistent store, no industry taxonomy or baselines in
  core, no multi-cohort ranking in core, no real apexes in committed examples, and
  the output carries the observability denominators and the sparse share. See PV1.
- **2.2 or later: maintainer validation loop formalized.** PV2 promoted from the
  by-hand re-grounding already done to a gated routine with drift detection.

Everything past the lock is additive and off the v2.0 schema; the lock stays
clean. The assurance items are not on the critical path of the feature
candidates; they come first.

#### Assurance and trust hardening (the post-2.0 north star)

The product is trust, not surface area. The priority after the v2.0 lock is to
make what exists more resilient and verifiable, not to make recon bigger. These are
quality tracks: none adds a user-facing feature, none touches the locked schema,
each is a small gated patch in the 2.0.x / 2.x line, and each deepens something
recon already leans on. They sit above the feature candidates on purpose.

Resilient (survives any input or failure):
- A fault-injection sweep across every external boundary (DNS, CT, the
  identity-discovery endpoints, certificate parsing) ran in 2026-06 and was
  adversarially verified: the four confirmed gaps shipped in v2.1.9 and the seven
  it rejected as already-guarded are recorded in
  `docs/security-audit-resolutions.md`. Builds on the existing source-boundary
  fault injection and the cache-lifecycle stateful machine. *Residual:* organize
  the per-boundary coverage as one explicit parametrized (boundary x failure-mode)
  matrix rather than topic-by-topic test files.
- Proven resource bounds on every parser: hostile-input fuzzing is promoted from
  a sweep to a dedicated CI gate (the `hostile_input` marker and the
  `hostile-input-fuzz` job, v2.1.10), with per-parser oversized-input bound
  assertions. *Residual:* the `_MAX_SUBDOMAIN_TXT_MATCH_LEN` and
  `_MAX_CNAME_MATCH_LEN` caps still want their own bound-assertion tests, and the
  OIDC / Azure `region` field wants a source-level length cap (today only the
  merger scrubs it).

Defensive (cannot be exploited or fooled):
- A written threat model with a traceability matrix (every threat to its
  mitigation to the test that proves it), on top of the six security-audit rounds.
- A second static-analysis lane in CI (taint analysis on the input-to-output
  paths) alongside the existing ruff-S and bandit.
- An adversarial-robustness corpus: can a domain owner manipulate public
  observables to make recon confidently wrong? The MNAR hardening is the start;
  this makes the predicted sparse and widened-interval behavior a standing
  asserted suite, not just a catalogued failure mode.

Adaptive (stays correct as the world drifts):
- PV2 (the maintainer-validation loop) shipped its committable core in v2.1.14: a
  deterministic inference drift gate (`validation/drift_check.py` +
  `inference_baseline.json`, CI-gated by `tests/test_drift_check.py`) that fails
  when the network's CPT-implied marginals move beyond a band without an
  acknowledged baseline update, plus the documented tiered loop
  (`docs/maintainer-validation.md`). The corpus firing-rate re-grounding tier
  stays maintainer-local against the gitignored corpus; wiring the agent to run
  the loop on a `/schedule` routine is the remaining operator step.
- A coverage check on the credible intervals, even a proxy-label or case-study
  version, framed exactly as honestly as CAL1 requires (consistency and
  evidence-responsiveness, never claimed ground-truth calibration).

Trusted (the artifact and the answer are both verifiable):
- Differential verification of the inference core: brute-force enumeration over
  the full joint (nine binary nodes, 512 states) cross-checked against variable
  elimination for every evidence configuration (3^9, about 19.7k, all
  enumerable), with an independent reference implementation so it verifies the
  factor construction (declarative conditioning, grouped evidence, virtual
  evidence), not just the elimination step. Turns "tested" into "exhaustively
  verified for this network." Shipped: `validation/differential_verification.py`
  carries the independent full-joint reference and sweeps the enumerable
  none/one/all cross product (~2.9k configs) plus an exhaustive per-node subset
  sweep over the grouped and declarative nodes; variable elimination matched
  naive enumeration on every node of every configuration (worst gap ~5e-5, the
  engine's 4-decimal posterior rounding). `tests/test_bayesian_differential.py`
  anchors the reference to hand-computed marginals and runs a fast subset in CI.
- Reproducible builds bit-for-bit and sigstore-signed releases shipped in v2.1.12
  (SOURCE_DATE_EPOCH pinned at release time and gated in ci.yml; PEP 740 PyPI
  attestations on top of the existing GitHub build-provenance attestation and
  CycloneDX SBOM; see docs/supply-chain.md). The full SLSA L3 generator workflow
  stays deferred as disproportionate for a passive single-maintainer tool.
- A cross-platform and cross-Python determinism gate so the same input yields
  byte-identical output on every matrix cell.
- Mutation testing promoted to a gate with a score floor, and a
  requirements-and-invariants traceability matrix so every promise maps to the
  test that keeps it.

Priority order, highest trust-per-effort first. **Done:** differential
verification of the inference core (v2.1.7); the fault-injection sweep and its
four fixes (v2.1.9) plus the hostile-input fuzz gate (v2.1.10); the "Resilient"
residuals, the source-level `region` cap, and the boundary-by-mode matrix
(v2.1.11); and reproducible builds plus sigstore-signed PyPI attestations
(v2.1.12); the auditable assurance case + operational contract (v2.1.13); and the
PV2 inference drift gate + maintainer-validation loop (v2.1.14). **Remaining, in
order:** the credible-interval coverage check; then mutation-as-a-gate and the
traceability matrix. None of these is on the critical path of the feature
candidates below; they are the work that matters most.

Each item ships with acceptance criteria, checkable gates rather than "improved
tests," and where it produces a durable artifact it ships a doc so the trust is
inspectable rather than asserted. Shipped: the engineering threat model
(`docs/security.md`), the assurance case mapping each promise to its mechanism,
its test, and its residual risk (`docs/assurance-case.md`), the operational
contract (`docs/operational-contract.md`: timeouts, caps, exit codes, cache and
partial-result semantics, determinism), and the supply-chain / release-integrity
doc (`docs/supply-chain.md`). Still to write: a data-handling policy (what may and
may not enter the public repo, down to comments and fixtures), and a
statistical-assurance dossier that separates what is observed, what is
consistency, what is evidence-responsive, and what has empirical coverage. This is
a standing track across the 2.x line, not a single release, and it adds no
discovery surface.

Kept proportionate to what recon is. The exhaustive differential cross-check, over
a few thousand enumerable states, is the right level of formal verification here;
full TLA+ or Lean machine proofs, recurring paid third-party reviews, and
long-term-support branches are recorded as aspirational, not committed, because
for a passive tool they cost more than they return. The principle holds inside
this track too: deepen what exists rather than keep adding.

Evidence-semantics diagnostics (candidates, not features). Several reviews
converged on enriching the evidence model rather than enlarging it: per-node
entropy reduction (how much the channel narrowed the prior), exact
leave-one-evidence-group-out counterfactual influence, framed as evidence
counterfactuals over the model and never causal claims about the world (the
nine-node joint makes it cheap), the evidence-group capping already tracked as CAL7, and graph
partition stability across seeds. These are additive `--explain` and JSON
diagnostics that leave the default panel unchanged and feed the
statistical-assurance dossier, and the monotonicity and missingness behaviors
become property tests in the differential-verification harness. Deferred as
disproportionate for a nine-node passive model: compiling the network to
sum-product or arithmetic circuits (exact variable elimination is already
instant, and the differential cross-check depends on the small joint), a credal
or imprecise-Dirichlet inference mode, and any baseline-relative anomaly scoring.

First pass landed in v2.1.1: a four-round adversarial bug-hunt of the
cohort-summary surface (two rounds were independent subagent passes) plus a
security review of the network, ReDoS, and resource/path/deserialization surfaces.
The SSRF/network and resource/path/deserialization reviews came back clean; the
fixes were a ReDoS-guard bypass (nested quantified groups such as `((a+))+`) and
output sanitization (`render_error` and the batch progress line), each with a
regression test. v2.1.2 added a full output-injection sweep across the render
paths and closed four more sanitization gaps (`render_warning`, verbose conflict
annotations, the `delta` error sinks, and markdown autodiscover domains). v2.1.3
took a deeper pass over the engine internals (Bayesian inference, merge / resolve
/ cache, graph / CT), fixing a Louvain determinism bug, a related-domain
enrichment correctness bug, and several cache and validation robustness gaps; the
inference math and the schema contract were re-confirmed clean. v2.1.4 closed an
external agentic-review batch: a cache symlink vector introduced by the v2.1.3
atomic write, cname_target substring overmatch, an IDNA2003 lossy mapping, and a
client-doctor terminal-escape sink, each with a regression test. v2.1.5 added a
self-driven five-area bug-hunt (fifteen fixes: markup injection on `--verbose`,
several control-character scrub gaps that bypassed the central merger scrub, a
non-capturing-group ReDoS gap, a CT-scan resource bound, and `merge_conflicts`
lost on the cache round-trip). v2.1.6 resolved the four held inference-layer
items: the `spf_strict` `-all` substring match is fixed to a token match (the one
behavior change); the `compute_slug_posteriors` "alpha double-count" was a
misleading comment, not a defect (the code matches the documented
`alpha_prior + weight` model, so posteriors are unchanged); a load-time warning
now flags declarative nodes whose grouped bindings lack a `group_absence` entry;
and the signed `entropy_reduction` is documented rather than clamped. The
differential verification of the inference core landed in v2.1.7 (the independent
full-joint reference in `validation/differential_verification.py` cross-checking
variable elimination across the enumerable evidence sweep). v2.1.8 then made the
Google CSE and BIMI VMC direct probes opt-in (`--direct-probes`) so a default
lookup stays passive, and added an `analyze_posture` profile guard. The 2026-06
fault-injection sweep closed four confirmed ingestion-boundary gaps in v2.1.9 (a
gzip decompression-bomb guard via identity-encoding plus a compressed-response
refusal, deeply-nested-JSON `RecursionError` handling in the cache and CT
providers, and a CT-graph entry-count bound), and the hostile-input fuzz CI gate
plus per-parser bound assertions shipped in v2.1.10. Signed and reproducible
builds with SLSA remain ahead.

#### Post-2.0 feature candidates (maintainer)

Ideas that fit the passive-primitive brand but are deliberately held until after
the v2.0 schema lock, since the lock adds no new surface by design. Each would be
its own post-2.0 patch, and each sits below the assurance work above in priority.
Every candidate earns its place against a trust budget: does it keep collection
passive-only, keep provenance intact, avoid any persistent cross-domain state, and
improve reliability or operator clarity, and what failure mode does it add? A
candidate that does not improve trust or clarity waits.

| # | Item | Track | Notes |
|---|---|---|---|
| PV1 | Local cohort summary over recon output | E / agent QoL | Local, caller-owned statistical analysis over a batch, built as a reducer that consumes recon's `--json` / `--ndjson` rather than as core logic, so core recon stays frozen and stateless. The caller owns the domain set and any grouping (by industry, portfolio, vendor set, location, or arbitrary type); those grouping files stay local and gitignored, never committed. The reducer emits aggregate-only views: provider / cloud / CDN / email-posture mix, concentration (entropy or HHI), and the Bayesian-claim prevalences. The honest-statistics discipline (refined from a 2026-06 review): observability-adjusted prevalence reported as three numbers (observed rate on the observable denominator, conservative lower bound over the whole cohort, observability fraction) so MNAR absence is encoded correctly; aggregate posterior mass plus a separate high-confidence (non-sparse, posterior > 0.8) share; hierarchical partial pooling of group rates only when the caller's batch has multiple strata, pooling toward the caller's own global across those strata and never a shipped baseline (a single cohort uses a Wilson interval, since there is nothing to pool toward); compositional treatment of mixes (entropy / divergence / log-ratios, not independent percent columns); weighted log-odds with a Dirichlet prior (the Fightin' Words method) for distinctive-slug ranking that shrinks unstable rare counts; Benjamini-Hochberg FDR control and paired permutation tests for any multi-cohort comparison; small-cell suppression (suppress counts of 1 to 10, caution below about 30); and ecological-fallacy discipline in the wording ("within this cohort tagged X, among observable signals, we saw Y", never "industry X does Y"). Guardrails: no persistent aggregated store (compute-and-forget; the SQLite / DuckDB hard-no holds, and with nothing persisted no differential privacy is needed); recon ships no curated lists and no industry baselines; humble naming ("cohort summary", not authoritative "industry insights"). The committed artifact is a methodology doc (`docs/aggregate-state.md`) with the metric definitions, the honesty rules, and a fully synthetic (Contoso-style) worked example with fabricated numbers; the real grouped numbers stay in gitignored local runs. Build and validate locally heading toward 2.1; if it proves out, the only thing upstreamed into core is the thinnest `recon batch --summary`, one cohort at a time, no grouping logic. v2.1 candidate, off the v2.0 schema. (Explicitly rejected, including ideas that keep resurfacing under "keep recon recon" cover: a persistent local learning store; accumulated or shipped industry baselines (including per-industry centroids in `verticals.yaml`, which is a fingerprint catalog, not a baseline store); Mahalanobis or Z-score anomaly scoring against shipped baselines; putting aggregate state into core (a top-level schema field, a core MCP tool, or a core `--aggregate` flag) instead of a downstream reducer; "industry benchmarking" or "industry intelligence" framing; inferring hidden or unobserved services from industry priors, which overclaims past the passive channel; and K-means / Markov / clustering / differential privacy over stored scans. The dependency floor holds: no pandas, numpy, scipy, matplotlib, or persistent store in core; a local sidecar may use heavier tools. All of these break the compute-and-forget, no-baseline, passive-only, defensive-only invariants.) |
| PV2 | Agentic maintainer-validation loop | maintainer ops | A periodic, maintainer-side validation loop, run by an agent rather than by hand, that keeps the Bayesian CPT numbers and the catalog honest as the world drifts. Premise: the priors and likelihoods are directionally-accurate, corpus-grounded estimates, not values precise to many decimals, and they are not meant to be; the credible interval already carries the residual uncertainty, so the right discipline is "grounded this release, re-checked next," not false precision. The loop: (1) a fresh corpus scan (`scan.py`), (2) re-ground the base rates from it, (3) the synthetic calibration and likelihood-sensitivity (CAL8) harnesses, (4) the external case-study spot-check, (5) a drift comparison against the previous release that an agent reads and, if a number moved materially, opens an issue or proposes a CPT-update PR with the reasoning (under the CPT-change discipline, with the maintainer approving any semantic change). Tiered by data sensitivity: the synthetic harnesses run anywhere with no data, the spot-check needs only public web plus recon, and the corpus re-grounding stays local on the maintainer's machine because the corpus is gitignored, aggregate-only output. Maintainer-facing, never something an end-user runs, and a natural fit for the existing agent surface (the MCP server, `agents/` scaffolding, and a `/schedule`-style routine). Not a v2.0 gate. |

**Track E - CLI and agent quality-of-life.** Complete. The seven items (exit-code
reference, `_SUBCOMMANDS` consistency, `batch` stdin, shell-completion docs,
`autoApprove` guidance, the `recon://schema` discovery resource, and the
data-not-instructions demarcation) shipped in v1.9.55 to v1.9.60; see the
CHANGELOG for the per-patch detail.

**Track A - Complexity decomposition.** Complete as of v1.9.76. Zero
`# noqa: C901` markers remain in `recon_tool/`, so the mccabe cap of 15 from
v1.9.37 now holds the whole tree, not just new code. The full sweep (formatter,
posture / insights core, the validator/loader tail, `validation_runner`,
`sources/dns`, `cert_providers`, `explain_insights`, and the behaviour-heavy
tail below) is in the CHANGELOG.

| # | Story | Status | Acceptance |
|---|---|---|---|
| A6 | `cli` behaviour-heavy set | done (v1.9.72-v1.9.74) | `signals_show` + `_doctor` (v1.9.72), `_lookup` (v1.9.73), `_batch` (v1.9.74), each characterized then decomposed under the cap. A new `tests/test_signals_show_cli.py` covers the previously-untested `signals show`. |
| A7 | `merger.merge_results` (64) | done (v1.9.75) | Decomposed under the cap via single-purpose helpers; the merge property tests stayed green. |
| A8 | `server` tool pair | done (v1.9.76) | `lookup_tenant` (text rendering to `_lookup_tenant_text`) and `simulate_hardening` (fix-parsing to `_simulate_fixes`) decomposed; server / exposure / GWS tests green. |

**Track B - Test and validation rigor:**

| # | Story | Status | Acceptance |
|---|---|---|---|
| B1 | `deal` contracts on boundary validators | done (v1.9.77) | Third `deal` pass: `@deal.post` on `strip_control_chars` (no-control-chars) and `validate_domain` (normalized-domain), named predicates with fire-on-violation tests in `test_contracts.py`; no-ops under `-O`. |
| B2 | Cache-lifecycle stateful machine | done (v1.9.78) | `tests/test_cache_stateful.py`: a Hypothesis `RuleBasedStateMachine` drives write / unknown-fields / corrupt / stale / clear / clear-all over a valid+invalid domain pool and reconciles disk against a model each step (load-known / ignore-unknown / skip-malformed / TTL-evict / traversal-safe). |
| B3 | Source-boundary fault injection | done (v1.9.79) | `tests/test_source_fault_injection.py`: a `_FaultySource` drives raise / hang / error / degraded / good modes through `_safe_lookup` and `resolve_tenant`, asserting exception hygiene, degraded surfacing, hedged output, all-fail, and timeout; one case isolates a malformed OIDC payload end to end. |
| B4 | Branch coverage toward `server.py` | done (v1.9.80) | `tests/test_server_bayesian_tools.py` covers `get_posteriors`, `explain_dag`, and `cluster_verification_tokens`, lifting `server.py` to ~75%. The global 82% branch gate is left unratcheted to avoid flaky failures on unrelated dips. |

**Track D - Robustness and security:**

| # | Story | Status | Acceptance |
|---|---|---|---|
| D1 | Ingestion adversarial bug-hunt | open | A fresh adversarial pass over the DNS / CT / BIMI VMC / identity-endpoint parse paths plus input edge cases (IDN / punycode, malformed, oversized, truncated); any finding fixed and folded into `docs/security-audit-resolutions.md` as a new round. |
| D2 | Operator-paced scanner pass | operator-paced | Any Codex / external scanner pass between now and the tag adds to the closure trail if it surfaces anything new. |

**Track C - Catalog growth** (the corpus-driven depth that is the real v2.0
gate; see "The pre-2.0 build plan" below for the empirical bar):

| # | Story | Status | Acceptance |
|---|---|---|---|
| C1 | Vendor-doc-sourced `cname_target` additions | done (v1.9.82-v1.9.86) | High-precision `cname_target` rules sourced from public vendor docs via the `CONTRIBUTING.md` methodology (21 rules, 808 to 829 entries). Committed examples use the fictional brands. |
| C2 | Full-corpus fingerprint mining | third-party residual closed (v1.9.87-v1.9.89) | `validation/scan.py` over the gitignored corpus plus `validation/find_gaps.py` and `/recon-fingerprint-triage`, merging vetted rules so the gap report reaches a low residual. Three verified batches shipped (v1.9.87 to v1.9.89, 13 vendors, 829 to 841 entries; trail in `validation/v1.9.87-c2-corpus-batch.md`). The named third-party residual is closed; the remaining high-count termini are org-internal GSLB / load-balancers that are not catalogued by design. Run locally; aggregate counts and vetted patterns reach the repo, real apexes do not. |
| C3 | CT-enabled full-corpus calibration | corpus-driven (local) | A complete (CT-enabled) full-corpus Bayesian calibration run so `infrastructure_clusters` and the region / shard `lexical_observations` are exercised on real input; per-node Brier / ECE, deterministic-vs-Bayesian agreement, sparse-case interval coverage, and the CAL10 entropy-reduction numbers tabulated, extending the release trend. Run locally; only aggregate metrics reach the repo. |

**v2.0 docs currency** (ships as one or two patches once the tracks above
settle, before the lock):

| # | Story | Status | Acceptance |
|---|---|---|---|
| F1 | Release-notes draft brought current | done | `validation/v2.0-release-notes-draft.md` extended through v1.9.95: the release-range table now covers the C2 corpus mining (v1.9.87-v1.9.89), CAL14 (v1.9.90), and the schema hardening (v1.9.91-v1.9.95); the schema-lock section describes the SH reshapes and the experimental-to-stable promotion note is corrected for the slug_confidences reshape. |
| F2 | Validation-summary refresh | docs done; C3 re-run remaining | `validation/v2.0-validation-summary.md` refreshed through v1.9.95: a post-v1.9.14 section covers the CAL track, CAL14 (synthetic conditional ECE about 0.31 to about 0.03), catalog C1/C2, the spot-check, and SH1-SH9; the stale corpus-gate and disposition-carried-unchanged claims are corrected. The CT-enabled full-corpus re-run (C3) that exercises the cert surfaces and reflects CAL14 on real input is the remaining corpus-driven step; the nine DNS/identity-fed nodes are already calibrated by the existing passes. |

**Pre-2.0 schema hardening (gates G1). SHIPPED v1.9.91 to v1.9.95.** A four-lens
pre-lock review of `docs/recon-schema.json` (API-design, Bayesian,
downstream-consumer, and long-term-maintainer perspectives, 2026-06-05) found a
finite set of field shapes that were cheap to fix before the lock but would have
needed a major (3.0) bump with a deprecation window to fix after. All nine items
shipped in five risk-ascending patches: v1.9.91 (SH1 descriptions, SH3 CT
loosening, SH4 cloud_instance enum drop), v1.9.92 (SH2 slug_confidences object
map, SH5 wildcard_sibling_clusters objects), v1.9.93 (SH6 fusion_enabled flag),
v1.9.94 (SH7 record_type / schema_version discriminator, SH8 error_kind), and
v1.9.95 (SH9 the include-ecosystem always-wrapper behavior fix). The two CS-panel
decisions resolved to the object map (SH2) and yes to the discriminator (SH7).
The bulk of the schema reviewed clean (the named-object `$defs`,
`posterior_observations`, `DeltaReport`, the nullable cert / BIMI objects,
`NodeConflict.magnitude`) and was left untouched. With the track done, G1 now
applies the lock to a surface with no known regret. The rows below are the
original plan, kept for the per-item detail; the schema-vs-emitter drift test
(`tests/test_json_schema_file.py`) and the batch-record contract test gate the
locked surface.

| # | Story | Decision | Acceptance |
|---|---|---|---|
| SH1 | Schema-description reconciliations | resolved | Reconcile the contradictory `partial` description (the JSON's "core sources only, not CT-only degradation" definition wins; fix `schema.md`); update the `sparse` / `n_eff` descriptions so they survive CAL14 (a declarative node can be confidently-absent: low posterior, narrow interval, `sparse=false`); tighten the credible-interval description to the CAL13 "evidence-responsive, not frequentist coverage" framing. Verify the maintainer-flagged `detection_scores` schema-vs-model shape (map vs pairs) and fix if drifted. Descriptions only, no field-shape change. |
| SH2 | `slug_confidences` to object map | resolved (CS panel): object map `{slug: posterior}` | Reshape the v2.0-new `slug_confidences` from a positional `[slug, posterior]` tuple to an object map `{slug: posterior}`, matching its structural twin `detection_scores` (the other slug-keyed scalar map) rather than the node-keyed `posterior_observations`. The panel split 2-1 for array-of-objects, but slug_confidences is slug-keyed with a scalar value (a map), the repo's own consumer already rebuilds it into a dict, and both shapes are equally 3.0-safe (a future per-slug interval is an additive sibling map either way), so the map is the more consistent permanent shape. Update the emitter, the cache round-trip, the schema, the tests. |
| SH3 | CT telemetry out of `required` | resolved | Remove `ct_provider_used`, `ct_cache_age_days`, `ct_attempt_outcome` from the root `required` list (keep emitting them as optional properties) so the CT pipeline can be restructured later without a 3.0. Pure loosening; the schema-contract required set updates. |
| SH4 | `cloud_instance` enum drop | resolved: drop enum, keep name | Drop the closed host enum on `cloud_instance` (Microsoft controls those values and adds sovereign clouds); keep type `string\|null` and document the known values. Keep the field name (a rename would break v1.x consumers for a cosmetic gain; not worth it). Same treatment for `tenant_region_sub_scope` only if a value gap appears. |
| SH5 | `wildcard_sibling_clusters` to objects | resolved | Reshape from a bare `list[list[string]]` to an array of `{names: [...]}` objects (room for the generating wildcard SAN / issuer later), matching the adjacent `CertBurst`. Experimental v1.7+ field; changes before the lock. |
| SH6 | `fusion_enabled` flag | resolved: add the flag | Add a required top-level `fusion_enabled: bool` so a consumer can tell `slug_confidences: []` / `posterior_observations: []` meaning "fusion off" from "fusion ran, found none". Keep both arrays `required` (2.0 flips `--fusion` default-on, so they are normally populated; the flag only disambiguates `--no-fusion`). `surface_attributions` is not fusion-gated and needs no change. |
| SH7 | Record discriminator | resolved (CS panel): yes | Add a required `schema_version` ("2.0") to the single-domain root and a `record_type` discriminator (`lookup` / `batch_result` / `delta` / `error`) to each object-shaped mode, so consumers (especially an agent handed a bare payload) do not discriminate by accidental key-disjointness. A required discriminator cannot be added cleanly after the lock (an agent cannot depend on an optional field); `record_type` is a field you would never drop, so "required forever" costs nothing, and `schema_version` is the only in-band 2.x->3.0 signal a detached payload carries. The cache reader defaults both fields on read for pre-2.0 entries (the same graceful-degradation pattern used for posterior_observations). |
| SH8 | `BatchErrorRecord` machine-readable kind | resolved | Add an `error_kind` enum (`validation` / `lookup` / `timeout`, extensible) to `BatchErrorRecord` and bless the "a success record never carries a top-level `error` key" guarantee in the contract, so a SIEM routes on a code not free-text and the cheap discriminator is stable. The error record is `additionalProperties: false`, so the slot must exist before the lock. |
| SH9 | `--include-ecosystem` always-wrapper | resolved | When `--include-ecosystem` is set, always emit the `BatchResult` wrapper (errors under `domains`, `ecosystem_hyperedges: []`) instead of falling back to a bare array when no domain resolved, so the top-level type does not flip on an all-failed batch. The one behavior change; gated by the batch-output tests. |

Two decisions want explicit sign-off before they ship: the `slug_confidences`
object form (SH2: array-of-objects recommended, vs an object-map) and whether to
add the `record_type` / `schema_version` discriminator (SH7). The rest are
clear-cut. Once SH1 to SH9 are green, G1 applies the lock to a surface with no
known regret.

**v2.0 lock ceremony** (operator-paced; mechanical, runs only after every row
above is green; Outstanding item 5):

| # | Story | Status | Acceptance |
|---|---|---|---|
| G1 | Schema lock | done (2.0.0) | `docs/recon-schema.json` (both copies) bumped from "Stable v1.0 contract" to "Stable v2.0 contract", additive-within-2.x; README, the formatter comments, and the contract-test docstring updated; `recon doctor` first line reads "v2.0 stable schema" (version-driven). EXPERIMENTAL labels were already zero in user-facing surfaces (stripped in v1.9.11). |
| G2 | `--fusion` default-on | done (2.0.0); panel speak-up done (2.0.1) | Fusion runs on every lookup; `--json` always emits the posteriors; `fusion_enabled` disambiguates; `--no-fusion` (new) opts out; `--fusion` is a now-default no-op. v2.0.1 added the panel "speak up": posterior-backed confidence dots (the weakest claimed node's interval position relative to the threshold), a dimmed plain-language clause when a claimed node is thin or contested, and the per-node intervals under `--verbose`. Without posteriors the panel is byte-identical to v1.x. See the v2.0.1 panel-disclosure design note below. |
| G3 | `correlation.md` promotion | done | Promoted from living draft to the polished reference: the header framing updated, the node-dependent-missingness section (4.8.3) rewritten to the shipped CAL14 reality (declarative email-policy node, the n_eff / stability ripples handled, corpus-grounded numbers, ECE about 0.31 to about 0.03). The four required sections are present (defense-correlation mapping 4a, prior-art 4b, dependency-floor manifesto 4c, failure-mode catalog 4.8.10); the CAL13 evidence-responsive framing and the ground-truth / independence-bias caveats are in place. No math removed. |
| G4 | Changelog move + tag | done (2.0.0 commit); tag pending operator | `CHANGELOG.md` carries the `## [2.0.0]` entry summarizing the lock; `docs/migration-v2.md` finalized (draft banner removed); version bumped to 2.0.0. The `v2.0.0` tag is the one remaining operator action (it publishes to PyPI), held for explicit go. |

Schema-contract polish (the original Outstanding item 1) shipped in v1.9.26 and
is not in the queue. The original v1.9.4 to v1.9.11 numbered sequence further
down this file is historical: every one of those versions shipped (the project
is at v1.9.54), and the current framing is these five tracks plus the lock
ceremony, not that sequence.

### The pre-2.0 build plan: corpus-driven validation depth

2.0 is the "polished and excellent everywhere" release, so before the schema
locks the engine and catalog have to be demonstrably excellent rather than
merely present, and that bar is set empirically against the private corpus of
5,000+ companies, not asserted. The rest of the 1.9.x build series is, at its
core, about exhausting what that corpus can teach before the lock. Two threads
run in parallel, each as its own focused 1.9.x patches:

- **Fingerprint and pattern excellence.** Keep mining the full corpus with
  `validation/scan.py` (the 4-5k+ domain library) and the
  `/recon-fingerprint-triage` flow, merging vetted `cname_target` and other
  rules so the catalog (841 entries today, up from 808 via the v1.9.82 to v1.9.86
  live-analysis batches and the v1.9.87 to v1.9.89 corpus-mining batches) closes its
  residual coverage gaps and every high-value detection is precision-checked
  against real observations. The goal at lock time is a catalog that is
  comprehensive and high-precision across the whole corpus, with the
  fingerprint-gap report driven to a low residual, rather than resting on the
  top-tier-vendor spot checks that cleared early. This is the "Catalog growth"
  track above, run to completion rather than opportunistically.
- **Thorough Bayesian-layer validation.** Run complete full-corpus calibration
  passes, not spot checks: per-node Brier / ECE, deterministic-versus-Bayesian
  agreement, and credible-interval coverage on sparse-evidence cases, repeated
  across the release trend so the calibration claims stay falsifiable over time
  rather than at one moment. The broadest run so far (2026-05-26) had 13,939 of
  13,939 high-confidence posteriors agree with the deterministic pipeline, zero
  cross-source conflicts across 5,236 successful domains, and all 9 nodes inside
  the v1.9.5 stability gates (Brier <= 0.05, ECE <= 0.19). Before lock we want a
  CT-enabled full-corpus re-run so the cert-fed signals
  (`infrastructure_clusters`, the region / shard `lexical_observations`) are
  exercised on real input too, plus confirmation that the trend holds across the
  rest of the hardening series, not just at one snapshot.

All of this stays aggregate-only: no apex names or per-domain output is
committed (the corpus lives in gitignored paths), so what reaches the repo is
counts and calibration metrics, not the underlying data. This corpus-driven
depth is what "demonstrably solid" means above, and it, not docs currency or
ceremony, is the real gate. 2.0 is revisited only once it holds; the items
below are the mechanical lock ceremony that follows it.

**Outstanding before v2.0:** *(refined 2026-05-26 after the v1.9.24
mega-batch; the v1.9.24 entry above absorbed items 1 and the
fingerprint / engine work that originally would have been split
across multiple patches.)*

The work below ships as 1.9.x patches following the no-bundling
discipline; v2.0 itself stays the mechanical lock event with no
new work. Recommended sequencing is at the end.

1. **Schema-contract polish.** *(SHIPPED in v1.9.26. Chose option (a):
   document the error-record shape and add an explicit pure-Python
   allowance rather than change runtime output. A fourth gap surfaced
   while grounding the work and was fixed in the same patch: the
   `BatchResult` `$def` claimed batch JSON always wraps under `domains`,
   but the default `recon batch --json` emits a bare array. See
   `validation/v1.9.26-schema-contract.md`.)* Three items the pre-lock
   audit surfaced in `docs/recon-schema.json` and the way real output
   relates to it:

   - The schema description currently reads "Stable v1.0 contract
     for `recon <domain> --json` output", but batch NDJSON output
     (`recon batch ... --ndjson`) also emits records, including
     input-validation error records of shape `{domain, error}`
     that do not match the single-domain contract. The schema
     must explicitly state its scope (single-domain success
     output) and either (a) document the batch error-record shape
     separately or (b) emit the full skeleton on input-validation
     failures.
   - Fields `domain` and `error` appear in batch error records
     but are not declared in the schema's `properties`. Either
     declare them with conditional semantics or document the
     omission with intent.
   - Fields `explanation_dag` and `unclassified_cname_chains` are
     in the schema but conditionally emitted (only with
     `--explain-dag` / `--include-unclassified`). Document the
     conditional emission explicitly so consumers do not infer
     "always present".

   *Acceptance:* a sample batch NDJSON over the private corpus
   validates against the schema using a single deterministic rule
   set (success records pass the full shape; error records are
   handled by an explicit allowance), and a re-read of the schema
   prose answers "what shape can I expect from each output mode"
   without ambiguity.

2. **Release-notes draft brought current.** `validation/v2.0-
   release-notes-draft.md` was staged in v1.9.14 prep and covers
   v1.9.3 through v1.9.14 only. The releases since (v1.9.15
   through whatever the last 1.9.x is at lock time) must be added
   so the eventual v2.0 CHANGELOG entry captures the full pre-2.0
   work. The post-v1.9.14 work includes audit rounds 1-5 (DNS
   leak, CT / BIMI ingestion, MCP / CLI / output injection, DoS /
   resource, detector exception isolation), the cname_target
   fingerprint expansion (v1.9.22), the broader corpus-
   discovery batch (v1.9.23, 113 new fingerprints), and the
   v1.9.24 mega-batch (216 catalog entries plus shadow-handling
   consistency across substring matchers and the cname-regex
   matcher fix).

   *Acceptance:* the draft's release table covers every tag from
   v1.9.3 through the lock-time 1.9.x, each row linked to its
   shipping patch and validation memo; the security closure table
   reflects rounds 1-5 in addition to the original 2026-05 cycle.

3. **Validation-summary refresh with the full-corpus baseline.**
   `validation/v2.0-validation-summary.md` was published in
   v1.9.14 prep. A full-corpus Bayesian calibration run on
   2026-05-26 produced the broadest result the project has:
   13,939 of 13,939 high-confidence posteriors agree with the
   deterministic pipeline (100%), zero cross-source conflicts
   across 5,236 successful domains, and all 9 nodes pass the
   v1.9.5 stability gates with Brier <= 0.05 and ECE <= 0.19.
   The summary should incorporate this run as the v2.0 lock-time
   baseline rather than the v1.9.14 snapshot, so the document a
   v2.0 consumer reads is the freshest evidence the engine
   improved. A CT-enabled re-run is also wanted before lock so
   `infrastructure_clusters` and the region / shard kinds of
   `lexical_observations` are exercised on real input rather than
   recorded as "no data" (the no-CT run skips the cert layer that
   feeds both signals).

   *Acceptance:* the summary's trend table extends through the
   2026-05-26 full-corpus run with CT enabled; per-node stability
   metrics on the full corpus are tabulated; the historical
   v1.9.14 baseline remains as the prior reference point.

4. **Codex security scans (operator-paced).** Continuing the
   trail that closed in v1.9.4 / v1.9.9 / v1.9.13 / v1.9.14 plus
   rounds 1-5; `docs/security-audit-resolutions.md` records the
   closure trail. Any additional scanner pass between now and the
   v2.0 tag adds to that trail if it surfaces anything new.

5. **Mechanical lock ceremony (v2.0.0 itself).** Once items 1-4
   are addressed:

   - Bump `docs/recon-schema.json` description from "v1.0
     contract" to "v2.0 contract"; strip EXPERIMENTAL language
     from the promoted-field descriptions per the schema-lock
     disposition table.
   - Flip `--fusion` to default-on with the clean-panel
     disclosure rule (see "v2.0 design decision" section below).
   - Promote `correlation.md` from living draft to polished
     reference, with the four sections the snapshot requires
     (defense / correlation mapping, prior-art comparison,
     dependency-floor manifesto, failure-mode catalog).
   - Move `validation/v2.0-release-notes-draft.md` body into
     `CHANGELOG.md` under `## [2.0.0] - <date>`; delete the
     draft file.
   - Run `scripts/release.py`.

**Recommended sequencing.** Items 1 through 3 ship as 1.9.x
patches, one per coherent story. The actual order kept diverging from the
original plan because standing work claimed the next number: **v1.9.25**
took the operator-paced CT-resilience + catalog story, sliding the
schema-contract polish to v1.9.26; **v1.9.27** took the MCP-onboarding
story; and the **engineering-elevation series** (the adopted slice of the
2026-05 standards review, see Engineering quality posture) then claimed
v1.9.28 onward, sliding the docs-currency work further down the line.

- ~~**v1.9.25** lands the schema-contract polish (item 1).~~ Shipped
  instead as **v1.9.26** (v1.9.25 was the CT-pipeline-resilience patch).
- **v1.9.27** lands MCP-onboarding UX and folds in the standards review.
  Standing work, not a numbered item.
- **Engineering-elevation series** (standing work) runs as a sequence of
  focused patches, each its own story: **v1.9.28** shipped `py.typed` +
  the 3.14 matrix; the next patches raise the floor to `>=3.12` (later
  relaxed back to `>=3.11` in v1.9.43), add the
  quality gates (branch coverage + the complexity refactor), adopt `deal`
  Design-by-Contract, migrate to PEP 735, and add the provenance step.
  Details and per-item rationale are in the Known gaps list.
- **v2.0 docs currency** (original items 2 and 3): bring the release-notes
  draft current and refresh the validation summary. Ships as one patch
  once the elevation series settles, before the lock.
- Item 4 runs in parallel on the operator's schedule and folds
  into the closure trail as it lands.
- **v2.0.0** is item 5, the mechanical ceremony, after the docs-currency
  patch is in `main` and any pending scanner pass has settled.

Current theme: treat correlation as inference
over a graph of strictly public observables (DNS, CT, identity-discovery
endpoints), keep every output hedged with full provenance, and let live
validation against a private corpus drive what ships next.

## What recon is

recon is the **passive-DNS primitive**: given an apex domain, return hedged
observations about the organization's public technology stack and identity
posture using public DNS, certificate transparency, and unauthenticated
identity-discovery endpoints.

It is designed to be consumed by other tools - active scanners, company
research enrichers, GTM systems, agent workflows - not to become those tools.
Humility over completeness is the product line: if a feature would make sparse
evidence sound more certain than it is, it does not belong here.

It is also meant to be useful to security teams, compliance analysts, vendor
due-diligence practitioners, IT architects, and AI-agent workflows that need
clean, provenance-rich public-signal visibility. The right mental model is a
composable building block, not a standalone source of truth.

## What recon can and can't do against a hardened target

Honest framing: a disciplined paranoid setup - wildcard certs everywhere,
short-lived rotation, multi-hop randomized proxy chains, decoy noise, minimal
public DNS, hardened IdP metadata - will always produce sparse, low-confidence
output. That is physics, not a bug. The "obscurity is not security" critique
cuts both ways: a defender can choose to publish very little, and recon will
report very little.

What we can do better is make the residual signal go further. Wildcard certs
still leak sibling SAN sets within the same issuance batch. Short-lived certs
still land in CT logs with timestamps. Randomized CNAME chains still expose
recurring proxy motifs and intermediate-vendor markers. Multi-hop chains have
structure even when individual hops look bespoke. Treating the noisy
remainder as an inference problem - graph structure, temporal proximity,
chain motif libraries, vertical baselines - recovers usable defensive
intelligence that single-hop fingerprinting misses, while staying inside the
invariants.

Progress shows up as **multi-signal correlation depth**: `--explain`
outputs whose evidence DAG references more than one source per
high-confidence slug. The metric is tracked per release against the private
corpus (see Success Metrics) and is the lens we use to ask "did the new
correlation work do something single-source detection could not?". The
**Build plan** below lists the concrete extensions, each gated by the same
live-validation discipline.

See [correlation.md](correlation.md) for the formal latent-variable model
$G = (V, E, \Theta)$, the mutual-information objective, and the detailed
mapping of each planned extension (v1.7 through v1.9) to hardened-target
signal recovery.

## Current Fingerprint Library Assessment

Built-in fingerprints live in nine categorized YAML files under
`recon_tool/data/fingerprints/`: `ai.yaml`, `crm-marketing.yaml`,
`data-analytics.yaml`, `email.yaml`, `infrastructure.yaml`,
`productivity.yaml`, `security.yaml`, `surface.yaml`
(per-subdomain CNAME-target classification, added in v1.5), and
`verticals.yaml`.

**Current totals** *(as of v1.9.89)*:

- 841 fingerprint entries (808 at v1.9.53, plus 21 cname_target rules from the
  v1.9.82 to v1.9.86 live-analysis batches and 13 vendors from the v1.9.87 to
  v1.9.89 C2 corpus-mining batches).
- All slugs map to a defender-visible category in `formatter.py`.
- Zero cross-file slug-name collisions (verified by `tests/test_fingerprint_expansion.py`).

**Coverage outlook.** v1.9.3.9 closed the largest cloud-vendor coverage
blindspot (GCP / Azure non-O365 / Oracle / IBM / Alibaba / SaaS-PaaS /
SSE-SASE) via vendor-doc-sourced fingerprints. Empirical validation
against a stratified sample of known-rich-stack public companies
(Stripe, Shopify, Slack, Atlassian, Datadog, HashiCorp; gitignored
private corpus) showed **zero unclassified CNAME chain termini** on
these targets - the catalog is comprehensive for top-tier enterprise
vendors. Residual coverage gaps live in lesser-known regional clouds
(Yandex, OVH, Kakao), SAP / Oracle SaaS apps beyond Fusion, and the
long-tail SSE/SASE vendors (iboss, Versa, Aryaka).

**What remains.** The metadata richness pass is the largest pending
catalog-quality item - see Track B below. Description and reference
coverage targets (≥ 80% / ≥ 25%) before v2.0; the vendor-doc-sourced
methodology codified in `CONTRIBUTING.md` advances reference coverage
on every new `cname_target` rule shipped under it.

**Detection-gap honesty.** Coverage is bounded by passive DNS
fundamentally: server-side API consumption, internal workloads, data
pipelines, and ML jobs that call cloud APIs from existing
infrastructure never surface in DNS records on the queried apex.
recon will not, and architecturally cannot, see these. The
[Backlog](#backlog-after-v20) carries the framing-side fixes that
make this limit visible to operators rather than hiding it.

## Invariants

- **Passive only.** No active scanning, port probes, zone transfers, or target
  TLS handshakes.
- **Zero credentials, zero API keys, zero paid APIs.** Every data source must
  be reachable without an account.
- **No bundled ML models, embeddings, ASN data, GeoIP data, or local aggregate
  intelligence database.**
- **No user-code plugin system.** Custom fingerprints, signals, and profiles
  are data files only.
- **Hedged output.** Sparse evidence stays qualified. Dense evidence can be
  firmer, but never absolute.
- **Neutral language.** Observable facts only. No offensive guidance, takeover
  hints, maturity verdicts, or timeline narratives.

Anything new must fit inside this box.

## Priority Order

Correctness → reliability → explainability → composability → new features.

Post-1.0, hardening existing behavior beats adding new surface. Data-file
growth is welcome; engine growth needs stronger justification.

## Engineering quality posture

Standing engineering practices - type checking, lint gates, test
coverage, dependency audit, trusted-publisher releases, structured
logging, schema versioning, property-based fuzzing - are the floor
this roadmap assumes. They are documented in `CONTRIBUTING.md` and
exercised by CI; the v1.9.x and v2.0 plans below build on top of
them.

A few patterns are distinctive enough to call out:

- **Strict-positive likelihoods, no degenerate factors.** The
  Bayesian schema rejects `{0, 1}` likelihoods because one
  mis-fingerprint would otherwise pin a node permanently.
- **Cache-poisoning resilience.** Cache loaders skip malformed
  entries instead of crashing.
- **Property-based testing where invariants exist.** Hypothesis
  generates random valid networks and evidence sets to surface
  edge cases unit tests miss.
- **Pure-Python dependency floor.** No numpy, no scipy, no
  probabilistic-programming framework, no C-extension of our own
  choosing. `pip install recon-tool` pulls roughly eight runtime
  packages. One deliberate addition was made in v1.9.31: `deal`
  (pure-Python Design-by-Contract), adopted from the 2026-05 standards
  review; it is a no-op in production under `-O`, so it adds an import,
  not runtime cost. The floor stays a hard constraint
  otherwise: `cryptography` and `pydantic-core` are the only compiled
  pieces in the tree, and both arrive transitively through `mcp` rather
  than by our choice.

### External standards review (2026-05)

Two general-purpose Python-standards briefs were reviewed against this
project (a first pass, then a sharper revision that argued a pure-Python
-first, formally-contracted, supply-chain-hardened posture). Most of the
combined ask is already in place (uv + lockfile, grouped Dependabot,
pre-commit with ruff / pyright / actionlint, pyright strict, pytest +
coverage + Hypothesis, pip-audit in CI and release, SBOM, gitleaks, OIDC
trusted-publisher). The items worth adopting are folded into Known gaps
below. This sub-section records the decisions, both the adoptions that
reverse an earlier call and the declines, so the reasoning is durable.

Adopted from the review:

- **Raise the Python floor to `>=3.12`.** The first brief asked for a
  3.14-only baseline, which we declined as hostile to downstream
  consumers. The revision made the better argument: floor at 3.12, dev
  and static-analysis baseline at 3.14, matrix 3.12 / 3.13 / 3.14. 3.10
  reaches EOL on 2026-10-31 and 3.11 on 2027-10, so 3.12 buys upstream
  security coverage through 2028 and lets the core use post-3.11 syntax
  and typing. This is a deliberate floor raise (it drops 3.10 and 3.11),
  tracked in Known gaps; v1.9.28 already added 3.14 to the matrix under
  the old `>=3.10` floor, so the raise removes the two oldest rows rather
  than adds work. *Update: shipped in v1.9.29, then reversed in v1.9.43.
  On review, no runtime dependency needed 3.12 (networkx set the practical
  floor at 3.11) and the only 3.12-only code was three PEP 695 `type`
  aliases, since rewritten as `TypeAlias`; for a building-block library the
  reach of supporting 3.11 while it still gets security fixes outweighed the
  cosmetic syntax. The floor is now `>=3.11` with the dev / static-analysis
  baseline still at 3.14. See the v1.9.43 row.*
- **Design-by-Contract via `deal`.** Pure-Python, so it clears the
  no-C-extensions bar, and a no-op in production under `-O`. Adopted as a
  real track on the highest-value surfaces (Bayesian fusion and the
  engine matchers) where preconditions and postconditions catch errors
  that types alone do not. This grows the runtime floor by one import, a
  cost accepted for the verifiability gain. Tracked in Known gaps.
- **Build-provenance attestation plus hash-pinned audit requirements.**
  GitHub-native `actions/attest-build-provenance` (cheap, did not exist
  when SLSA was first deferred) and `--require-hashes` on the exported
  audit requirements. Full SLSA L3 and Sigstore in-toto signing stay
  aspirational, not this cycle. Tracked in Known gaps.

Declined, with the reason:

- **Pydantic at every boundary plus structlog as runtime deps** -
  declined. This would break the pure-Python dependency-floor invariant
  above for little gain. Pydantic already rides in transitively via
  `mcp`, so the MCP layer can lean on it without taking a new floor, but
  the CLI and core keep their hand-rolled boundary validation
  (`validator.py`, `strict_mode.py`, `schema_contract.py`,
  `defusedxml`), which already follows "parse, don't validate" via frozen
  dataclasses. `deal` covers the contract half the brief wanted from
  Pydantic without the heavier dependency. The hedged, provenance-rich
  output is recon's observability story; a structured-logging framework
  is sized for long-running services, not a stdio tool.
- **mypy strict on top of pyright** - declined as redundant. pyright
  strict already gates CI. Astral's `ty` may be worth a spike once it
  reaches 1.0; it is in preview, so not now.
- **Blanket 95% branch coverage** - declined in favor of a measured
  raise (see Known gaps); branch coverage measured at 83% today, so the
  honest gate sits near there, not at a flat 95% that buys execution
  rather than test quality.
- **Literal Power-of-10 rules** (two asserts per public function,
  50-line cap, line-length 100) - adapted rather than adopted. The
  intent, simple statically-analyzable control flow and bounded
  resources, is already a project value (resource caps, bounded
  clustering, rate limits). We encode the measurable part as a ruff
  complexity cap with a worst-offender refactor (see Known gaps) and keep
  the deliberate 120 line length.
- **Trivy and a Dockerfile** - not applicable. recon ships as a stdio
  CLI and library on PyPI, not a container image, so there is no image to
  scan. If a container is ever published, Trivy folds in then.
- **Central Copier template plus reusable workflows across a repo
  portfolio** - out of scope for this roadmap. recon can serve as a
  reference for that later, but the multi-repo machinery is not recon's
  concern.

### AI-security review (2026-05)

A separate AI-systems security brief (adversarial-ML threat models;
poisoning / evasion / extraction / inference attacks; differential
privacy; secure enclaves; model watermarking; confidential computing;
SecMLOps) was reviewed. Almost all of it is out of scope here for a
structural reason: recon trains no model and serves no model. The Bayesian
layer is hand-authored YAML, not learned weights (the no-learned-weights
invariant); there is no training data, no fine-tuning loop, no inference
endpoint, and nothing multi-modal. So model poisoning, extraction,
inversion, membership inference, DP-SGD, enclaves, adversarial training,
and certified robustness do not apply, since there is no model to poison,
steal, or invert.

The part that does apply is the agentic surface, and it already has an
explicit threat model. recon is consumed by AI agents over MCP and is a
conduit of untrusted external strings (DNS TXT records, CT SAN names, BIMI
metadata, identity-endpoint responses) into an LLM's context. `SECURITY.md`
documents the MCP threat model (the agent is assumed adversarial; prompt
injection, tool poisoning, and parameter tampering are assumed), the
v1.9.18 to v1.9.21 audit rounds closed the output-injection surface (ANSI
/ newline / markdown sanitization, SSRF, ReDoS, resource caps), and the
v1.9.32 build-provenance attestation extends integrity to the bundled
fingerprint and signal data, which is recon's only "data supply chain".
The one genuinely new forward item is carried in Known gaps below.

### Known gaps

The list of things a security-focused project ideally has but we
do not yet ship. Listed publicly so an evaluator can see what we
know is missing rather than only what we know is present:

- ~~No SBOM attached to releases.~~ **Shipped in v1.9.3.1** -
  CycloneDX SBOM (`recon-tool-<version>.cdx.json`) attached to
  every GitHub Release as an artifact, generated by the release
  workflow from the same locked dependency set the audit gate
  validates.
- ~~No SECURITY.md / vulnerability disclosure policy.~~ **Shipped
  pre-v1.9.3.1** - see `SECURITY.md` for scope, response SLA,
  reporting channel, and the MCP-specific threat model.
- ~~No secrets-scanning in CI.~~ **Shipped in v1.9.3.1** -
  gitleaks runs on every PR, every push to main, and on a weekly
  scheduled scan against the historical branch tree. Read-only
  workflow permissions; failures are non-bypassable.
- ~~No forward-compat cache test.~~ **Shipped in v1.9.3.1** -
  `tests/test_cache_forward_compat.py` pins the implicit "ignore
  unknown fields, load known fields cleanly" contract that the
  reader has always honoured but never tested.
- ~~No `py.typed` marker (PEP 561).~~ **Shipped in v1.9.28** -
  `recon_tool/py.typed` added and verified present in the built wheel,
  so a downstream consumer's type checker now picks up recon's inline
  types. The `Typing :: Typed` classifier is no longer a false promise.
- ~~Python 3.14 not in the support matrix.~~ **Shipped in v1.9.28** -
  3.14 added to the `ci.yml` test matrix (Ubuntu, Windows, macOS) and a
  `Programming Language :: Python :: 3.14` classifier added. recon
  imports and passes its suites on CPython 3.14.5.
- ~~**Raise the Python floor to `>=3.12`** (engineering-elevation series).~~
  **Shipped in v1.9.29, then reversed in v1.9.43.** The raise dropped 3.10 and
  3.11, set the matrix to 3.12 / 3.13 / 3.14, and moved `ruff target-version` /
  `pyright pythonVersion` to 3.12, with the dev toolchain pinned at 3.14. On
  later review the 3.11 drop was reverted: no runtime dependency needs 3.12
  (networkx sets the practical floor at 3.11) and the only 3.12-only code was
  three PEP 695 `type` aliases (now `TypeAlias`), so for a building-block
  library the reach of supporting 3.11 while it still gets security fixes won
  out. The floor is now `>=3.11`; 3.10 stays dropped (EOL 2026-10-31); the dev /
  static-analysis baseline stays at 3.14. Both moves shipped as their own
  patches so the changelog shows exactly when the floor moved each way.
- **Adopt Design-by-Contract via `deal`** (engineering-elevation series).
  *First pass shipped in v1.9.31*: `deal` added as a runtime dependency
  (disabled in production via `deal.disable()` when not `__debug__`, so
  zero cost for installed users), with `@deal.post` contracts on the
  inference math in `bayesian.py` (factor entries are probabilities;
  evidence factors are strictly positive, encoding the no-degenerate
  invariant; marginals stay in `[0, 1]`; credible intervals are ordered
  within `[0, 1]`). `tests/test_contracts.py` proves the contracts fire
  on violation and are no-ops under `-O`. *Second pass shipped in v1.9.35*:
  contracts on the fingerprint engine, `filter_shadowed_matches` (no
  shadowed pair survives, the no-double-count invariant) and
  `evaluate_pattern` (match count stays within `[0, corpus_size]`), with
  matching predicate tests. Still open as a smaller follow-up: contracts
  on the boundary validators in `validator.py`. The dependency-floor note
  above reflects the deliberate addition.
- ~~Branch coverage is not enforced and the gate sits at 80% line
  coverage.~~ **Shipped in v1.9.30** - `--cov-branch` enabled in the CI
  matrix, the release test job, and the local gate (`scripts/release.py`,
  `CONTRIBUTING.md`). Branch coverage measured at 82.95%, so the gate
  moves from 80% line to 82% branch, a stricter metric at a higher number
  rather than the flat 95% the brief asked for. `server.py` (around 71%
  line) stays the named under-covered target for follow-up tests.
- Complexity gate enabled, refactoring incremental. **Gate live in
  v1.9.37**: ruff `C901` at `max-complexity=15` is now enforced, so new
  code must come in under 15. The functions over the cap at enable time
  carry an explicit `# noqa: C901` and are being decomposed in batches;
  each refactor removes its marker, ratcheting the debt down. This is the
  honest brownfield order: hold the line for new code first, then work the
  backlog. Progress: `tenant_info_from_dict` (v1.9.40) and then the entire
  `formatter.py` (v1.9.45 to v1.9.52, behind the golden-output net) are done,
  including the worst offender `render_tenant_panel` (96 to under 15); then
  `insights._email_security_insights` (v1.9.53), `posture._validate_and_build_rule`
  (v1.9.54), the validator/loader tail (`_validate_fingerprint`,
  `_validate_motif`, `_validate_and_build_signal`, `load_network`) in v1.9.61 to
  v1.9.64, the `validation_runner` pair (v1.9.65), the `cert_providers` queries
  (v1.9.66), the `sources/dns` detectors (v1.9.67 to v1.9.68), and
  `explain_insights` (v1.9.69). 7 markers remain. The remaining monsters are the
  behaviour-heavy functions (`_lookup` at 77, `merge_results` at 64, `_batch` at
  60, the two `cli` doctor / signals-show commands, and the `server` tool pair);
  these take characterization coverage of their own before they are split, since
  they are not pure renderers the golden snapshots can pin.
  Each refactor preserves behavior and is verified against the suite. The
  `PLR` refactor family (too-many-branches / statements / returns / args,
  ~107 hits) is deferred to a later pass so the gate stays focused on
  cyclomatic complexity first. Keep the deliberate 120 line length.
- Deterministic fault-injection / chaos tests at the network boundary.
  recon already carries retry, an adaptive rate limiter, a per-provider
  circuit breaker, and `degraded_sources` handling, and the existing
  resilience suites (`test_ct_pipeline_resilience.py`,
  `test_properties_resilience.py`, `test_fallback_chain.py`) plus the new
  stateful machine (v1.9.33) cover much of the failure surface. Open as a
  deeper follow-up: a dedicated layer injecting malformed and truncated
  provider payloads at the source boundary and asserting the hedged output
  and exception hygiene hold.
- ~~No Hypothesis *stateful* testing.~~ **Shipped in v1.9.33** -
  `tests/test_rate_limit_stateful.py` drives the `AdaptiveRateLimiter` +
  circuit breaker with arbitrary outcome sequences (a
  `RuleBasedStateMachine`) and asserts the interval bounds, a
  model-tracked consecutive-failure counter, bounded cooldown, and the
  open-breaker-implies-threshold invariant after every step. The cache
  state machine is a candidate for the same treatment later.
- ~~Free-threaded 3.14t not exercised.~~ **Shipped in v1.9.33** - an
  Ubuntu `3.14t` matrix entry, marked experimental and `continue-on-error`
  so it surfaces a dependency without a `cp314t` wheel without failing CI.
  recon is asyncio-based with no shared mutable thread state, so this is a
  dependency-readiness probe rather than a correctness change.
- ~~Dev dependencies still live under `[project.optional-dependencies]`.~~
  **Shipped in v1.9.36** - migrated to a PEP 735 `[dependency-groups].dev`
  table, so the dev toolchain is no longer published as an installable
  `recon-tool[dev]` extra. uv treats `dev` as a default group, so
  `uv sync` installs it and the `--no-dev` build/audit steps still exclude
  it; the CI / release `uv sync --extra dev` calls became plain
  `uv sync`. A separate `[tool.uv]` block was not needed (the `dev`
  default-group behavior is built in). README and CONTRIBUTING note the
  pip path (`pip install -e . --group dev`, pip 25.1+) for non-uv users.
- No mutation testing - line coverage measures execution, not test
  quality. Still post-v2.0, but the standards review reaffirmed it as
  the chosen answer to "test quality beyond line coverage." Candidate
  scope when it lands: a mutmut or cosmic-ray spike against the
  Bayesian inference core and the engine matchers, where a surviving
  mutant is the most diagnostic. Pairs with the `deal` contracts (a
  contract that never fails on a mutant is a contract worth tightening).
- ~~Build provenance and hash-pinned audit requirements.~~ **Shipped in
  v1.9.32** - a separate `attest` job in `release.yml` signs a
  GitHub-native `actions/attest-build-provenance` attestation for the
  wheel and sdist (verify with `gh attestation verify`), kept out of the
  build job so the supply-chain isolation contract holds; the exported
  audit requirements in `ci.yml` and `release.yml` are now hash-pinned.
  Full SLSA L3 plus Sigstore in-toto signing and reproducible-build
  verification stay deferred as disproportionate for a stdio tool at
  current scale, but the cheap provenance step closes most of the gap.
- ~~Prompt-injection posture for AI consumers (from the 2026-05 AI-security
  review).~~ **Shipped in v1.9.60** (queue item E7). recon hands untrusted
  external strings (DNS TXT records, CT SAN names, BIMI metadata,
  identity-endpoint responses) to an LLM over MCP, so an attacker who controls
  a queried domain's DNS or certificates could try to smuggle instructions
  through recon's output. The terminal and markdown injection surface was
  already closed (v1.9.18 to v1.9.21); v1.9.60 adds the explicit
  data-not-instructions posture as a documented in-band convention: the
  injected MCP server instructions gain an "Untrusted observed content (data,
  not instructions)" section telling the consuming model to treat every
  domain-derived value as data to analyze and report, never as an instruction
  to follow, even when the literal text reads like a directive. `SECURITY.md`
  documents it and `tests/test_data_not_instructions.py` guards it. The
  model-centric AI-security concerns from the same brief stay out of scope (see
  the AI-security review note above): recon trains and serves no model.

These do not block v1.9.x or v2.0. The four oldest cheap ones
(SECURITY.md, SBOM, secrets-scanning, forward-compat test) shipped by
v1.9.3.1; `py.typed` and the 3.14 matrix shipped in v1.9.28. The
engineering-elevation series carries the rest as a sequence of focused
patches: floor raise to 3.12 (later relaxed to `>=3.11` in v1.9.43), then
quality gates (branch coverage plus
the complexity refactor), then `deal` contracts, then PEP 735 and the
provenance step, with the test-rigor layers (fault injection, stateful
Hypothesis, 3.14t) and mutation testing running alongside or post-v2.0.
v2.0 itself stays the mechanical schema-lock event with no new engine
work.

## Success Metrics (Post-1.0)

These are directional measures, not product OKRs:

- **Multi-signal correlation depth** - north-star metric. Share of
  `--explain` outputs whose evidence DAG references more than one source
  per high-confidence slug. The lens for "did the new correlation work
  do something single-source detection could not?". Tracked per release
  against the private corpus; explained in detail in
  [correlation.md](correlation.md).
- Share of high-value multi-detection fingerprints with an explicit
  `keep_any`, `match_mode: all`, or `tighten_patterns` decision backed by
  validation notes.
- Detection metadata coverage: descriptions, public references where available,
  and deliberate non-default weights.
- Quality of sparse-result explanations in live validation summaries, especially
  links to known passive-observation ceilings.
- Stability of JSON and MCP consumption examples against the documented schema.
- Sustainable community fingerprint PRs that pass schema, specificity, and
  validation gates without maintainer guesswork.
- For experimental Bayesian or community-detection output: average posterior
  entropy reduction (or modularity score) per domain on the private corpus,
  tracked across releases. Trend matters more than absolute number.

## Build plan

The plan is grouped into a small number of meaningful releases, not a long
trail of patches. Each release ships as a complete unit: a coherent feature
set, a catalog growth pass, and a private-corpus validation run that proves
the new behavior on real targets. Build order respects dependencies - the
graph layer needs the signal coverage that comes before it; the
probabilistic layer needs the signals to feed posteriors. Patches happen
when something actually breaks, not as a way to chunk work.

Standing work that runs alongside every release:

- **Catalog growth.** Each release window includes at least one private-
  corpus scan with `validation/scan.py` against a 4k+ domain library,
  candidate triage via the `/recon-fingerprint-triage` skill, and the
  resulting `cname_target` (or other) rules merged with corpus-delta notes.
- **Fingerprint precision.** Walk the multi-detection backlog one batch
  per release; prioritize identity, security, email, infrastructure where
  false positives have the highest downstream cost.
- **Sparse-result diagnosis.** Every new feature documents its
  passive-observation ceiling. When the rule does not fire, output stays
  hedged and the user understands why.
- **Release and docs reliability.** CI gates cover the same checks as
  release; counts and version references stay tight to this file.
- **Per-release calibration aggregate publish.** Each v1.9.x patch
  ships its own one-page validation summary
  (`validation/v1.9.N-calibration.md`) with sensitivity numbers, ECE
  on the synthetic network, and corpus spot-check rate at that point.
  Established for v1.9.0; the practice continues per patch so
  calibration claims are falsifiable across time, not just at the
  v2.0 lock moment.

### v1.7.0 - Hardened-target signal recovery *(shipped - see [CHANGELOG](../CHANGELOG.md))*

Squeezed more out of CT logs and resolution chains, and surfaced what
we already tracked but didn't expose. Surfaces: wildcard SAN sibling
expansion (`cert_summary.wildcard_sibling_clusters`), temporal CT
issuance bursts (`cert_summary.deployment_bursts`), CNAME chain motif
library (`chain_motifs` array), cross-source evidence conflict
surfacing (`evidence_conflicts` array). All shipped as YAML data
files plus minimal engine extension; the v2.0 schema lock promotes
each to stable per the disposition table below.

### v1.8.0 - Graph correlation *(shipped - see [CHANGELOG](../CHANGELOG.md))*

Built the structural layer on top of the v1.7 cert intelligence.
Surfaces: CT co-occurrence graph + Louvain communities
(`infrastructure_clusters`), fingerprint relationship metadata
(`product_family`, `parent_vendor`, `bimi_org` fields → emitted as
`fingerprint_metadata` map), batch-only hypergraph ecosystem view
(`ecosystem_hyperedges` when `--include-ecosystem`),
vertical-baseline anomaly rules (`expected_categories` /
`expected_motifs` on profiles, hedged-observation output),
`get_infrastructure_clusters` + `export_graph` MCP tools.
Zero new network surface; all derived from already-collected
observables. v2.0 promotes each to stable per the disposition table.

### v1.9.0 - Probabilistic fusion *(shipped, EXPERIMENTAL surfaces - see [CHANGELOG](../CHANGELOG.md), `docs/correlation.md` §4.8, `validation/v1.9-validation-summary.md`)*

Layered Bayesian inference on top of the deterministic engine, gated
behind `--fusion`. Surfaces (`posterior_observations`, `slug_confidences`,
`evidence_conflicts`, `--explain-dag`, `chain_motifs`,
`wildcard_sibling_clusters`, `deployment_bursts`,
`infrastructure_clusters`, `ecosystem_hyperedges`) are all marked
EXPERIMENTAL until the v1.9.x bridge milestones below close out and v2.0
locks the schema. The validation gate (corpus entropy reduction tracked
across releases; high-posterior calibration; interval coverage on
sparse-evidence cases) cleared on the v1.9.0 corpus run; per-node
calibration findings drove the v1.9.3 surgery.

### The path to v2.0 - a numbered sequence (historical)

> This numbered sequence (v1.9.4 to v1.9.11) is kept for the record. Every
> version in it shipped; the project is now at v1.9.54. The current remaining
> work is the execution queue under "Remaining work to v2.0" near the top of
> this file, organized by the five hardening tracks plus the lock ceremony, not
> by this sequence.


v2.0 is the "polished and excellent everywhere" release: schema
lock + doc snapshot + zero EXPERIMENTAL labels anywhere. It is not
a parking lot for feature backlog. Every gated feature ships in a
v1.9.x patch first; v2.0 inherits each as already-present and
locks the shape.

The sequence below is the planned order from where we are now
(post-v1.9.3.10) to v2.0. Each version answers four questions:

- **What ships.** The concrete deliverable.
- **Why this is next.** The dependency chain that puts this
  version where it is, rather than later.
- **Quality bar.** Falsifiable acceptance criteria a reviewer can
  check before the patch tags.
- **Validation step.** How we know the patch is good before it
  ships, separate from the quality-bar checks.
- **Refinement.** What we'd revisit if the validation surfaced
  something the quality bar didn't predict.

Patches ship when their one version's work completes, not on a
fixed schedule. Multiple patches in a day is fine; bundled work
that combines unrelated stories is not (see Patch-release
discipline below). Bug-fix patches between versions claim the
next available `v1.9.X.Y` number and do not block the sequence.

Standing work runs alongside every version: catalog growth
(corpus-observed + vendor-doc-sourced), per-release calibration
aggregate published, CI / lint / typecheck / coverage gates.

#### v1.9.2 - UX validation via agentic QA *(shipped - see [CHANGELOG](../CHANGELOG.md) and `validation/v1.9.2-agentic-ux.md`)*

<details>
<summary>Shipped detail - methodology + findings</summary>

We have not validated that operators benefit from credible
intervals. The entire calibration argument is academic if no one
looks at the `posterior_observations` block before making a
decision. Doing this first, before any schema-affecting work,
prevents us from locking a contract whose user-facing value is
unproven.

The original framing called for three human operator interviews
(SOC analyst, security architect, due-diligence reviewer). We
keep that as a future option, but the **primary v1.9.2
methodology is agentic QA** - one of recon's main user personas
*is* the AI agent (the entire MCP integration story), so
simulating that persona with a script gives us:

- Real signal about whether agents read `posterior_observations`,
  whether they distinguish dense from sparse, whether they cite
  `--explain-dag` output, whether they ask follow-up questions
  the credible interval should answer.
- Reproducibility: anyone can rerun the agentic QA suite against
  a future build and compare.
- Speed: today, not "after we recruit three humans."
- Publishability: synthetic / fictional domains, no private data.

**Method.**

- **Personas as prompt scaffolds.** Three personas - security
  analyst triaging an alert, due-diligence researcher writing a
  vendor assessment, ops engineer comparing two domains. Each
  gets a system prompt that defines their role, the question
  they're trying to answer, and the artifacts available
  (`recon <domain> --fusion --json`, `--explain-dag`, MCP tool
  output). No mention of credible intervals, sparse flags, or
  evidence DAGs in the prompt - we want to see whether the
  agent finds and uses those affordances on its own.
- **Test domains.** Two fictional Microsoft examples
  (`contoso.com` for dense, a deliberately-hardened scenario
  built by stripping a normal lookup down to one slug for
  sparse). Synthetic, public, reproducible.
- **Scoring rubric.**
  - *Did the agent read the posterior block?* (binary; check
    transcript)
  - *Did the agent cite the credible interval explicitly?*
    (binary)
  - *Did `sparse=true` change the agent's conclusion?*
    (compare answers on dense vs sparse domain)
  - *Did the agent run `--explain-dag` or call
    `explain_dag` MCP?* (binary)
  - *Did the agent reach a different conclusion than it would
    have without `--fusion`?* (re-run with `--fusion` off and
    diff)
- **Documented in `validation/v1.9.2-agentic-ux.md`.** The
  prompts, transcripts, and rubric all public-reproducible.
  Per-persona summary table makes the result skimmable.
- **Failure modes that change v2.0.** If the agent ignores the
  posterior block on both runs, intervals are not load-bearing
  and v2.0 should consider promoting `posterior_observations`
  to stable but de-emphasizing it in the panel. If the agent
  consistently misreads `sparse=true` as "low confidence" rather
  than "passive ceiling," the field name is wrong and v2.0
  should rename. If the agent uses `--explain-dag` heavily,
  v2.0 should keep it prominent. Each finding maps to a concrete
  v2.0 disposition decision.

**Human interviews remain a future option.** If the agentic QA
surfaces ambiguous results, or if we want a non-agent persona
(SOC analyst clicking through the CLI), the original three-
interview plan is on the shelf. But agentic QA is genuine
validation for the agent persona, not a placeholder for it.

</details>

#### v1.9.3 - Resolve the `email_security_strong` definitional gap *(shipped - see [CHANGELOG](../CHANGELOG.md) and `validation/v1.9.3-calibration.md`)*

<details>
<summary>Shipped detail - topology surgery rationale</summary>

This is *topology surgery*, not parameter tuning. The v1.9.0
spot-check showed 52.6% agreement on this single node; all other
nodes were at 100%. The cause is not miscalibration. The Bayesian
network's CPT for `email_security_strong` is parameterized over
`{m365_tenant, google_workspace_tenant, email_gateway_present}` -
modern-mail-provider presence. The spot-check tested it against
`dmarc=reject + dkim + spf-strict + mta-sts (≥2 of 4)` - policy
enforcement. **These are different claims.** No CPT tuning makes
them agree. Two principled fixes:

- **Option A - Split the node.** Replace `email_security_strong`
  with two nodes: `email_security_modern_provider` (parameterized
  on M365 / GWS as today) and `email_security_policy_enforcing`
  (parameterized on observed DMARC / DKIM / SPF / MTA-STS signals).
  Defenders care about both, but for different reasons. Two nodes
  with two clear definitions beat one with a muddled one.
- **Option B - Pick a definition and align both layers.** Choose
  whichever definition matches the question defenders actually ask
  (likely policy-enforcing), align the deterministic pipeline check
  and the Bayesian CPT to that definition, and live with the choice.

Default: Option A. It's more model surface but more honest. The
implementation is a schema-additive `bayesian_network.yaml`
change, not a CPT tune. **Gate:** both new nodes ship with explicit
definitions in `docs/correlation.md` and a re-run corpus
spot-check matches the definitions.

**Adjacent suspect - `federated_identity`.** The current model
parameterizes federation only on `m365_tenant`, but federation
exists without M365 (Okta + GWS, Auth0 + custom IdP, standalone
SAML setups). The current network systematically under-attributes
federation when the path doesn't go through M365. This is the
same shape of definitional bug as `email_security_strong`. We
either (a) expand `federated_identity`'s parents and re-derive
its CPT, or (b) keep it `experimental` until corpus evidence
shows the under-attribution rate is acceptable. Decide as part
of this milestone.

**Audit-every-node - backlog, not commitment.** The pragmatic
choice is to fix the one we know is broken, ship, and let the
next corpus run tell us which node is broken next. Sequential
model improvement. The rigorous-but-open-ended alternative - audit
every node for definitional clarity in one pass - is in
[Backlog (after v2.0)](#backlog-after-v20) below.

</details>

#### v1.9.4 - Hardened-adversarial behavior validation

The v1.9.0 corpus skewed enterprise. The asymmetric-likelihood
design (§4.8.3) was justified specifically for hardened targets,
which v1.9.0 did not exercise. This milestone validates the
*design property*, not just the calibration:

- **50-domain hardened-adversarial subset** under
  `validation/corpus-private/hardened.txt`: minimal-DNS,
  wildcard-cert-only, heavily-proxied apexes, randomized CNAME
  chains, short-lived certs.
- **Gate (behavioral, not numeric):** on this subset, the layer
  must (a) flag `sparse=true` on most nodes, (b) report wide
  credible intervals, and (c) refuse to report high-confidence
  posteriors on nodes whose evidence bindings did not fire. The
  exact numeric thresholds are less important than the
  qualitative behavior holding up. Document the result with
  illustrative interval shapes in correlation.md.
- **Public failure-mode catalog.** New section in correlation.md
  enumerating the hardening patterns that defeat each layer and
  the language the layer uses to admit it ("layer X reports
  sparse on this pattern"). Honest framing of where the public
  channel really cannot resolve uncertainty.

**Quality bar - exceptionally well (every item must be checked):**

- [ ] Hardened corpus has explicit *inclusion criteria* documented
  at the top of `validation/corpus-private/hardened.txt`: a domain
  qualifies iff it satisfies ≥ 3 of {wildcard SAN ratio ≥ 80%,
  CNAME chain depth ≥ 3, no public IdP metadata, ≤ 2 public DNS
  records beyond the apex, certificate validity ≤ 30 days}. Public
  enough that someone else can build a comparable corpus from
  CT-log scans + heuristics.
- [ ] **Full v1.9.0 91-domain corpus re-run** against the v1.9.3
  topology (deferred from `v1.9.3-calibration.md`), reported in
  `validation/v1.9.4-calibration.md` alongside the hardened subset.
  Trend numbers v1.9.0 → v1.9.3 → v1.9.4 published per node.
- [ ] **Per-hardening-pattern result rows** in the failure-mode
  catalog. Not "the layer reports sparse on hardened targets" but
  "wildcard-cert-only targets: cdn_fronting fires (CNAME path),
  every other node sparse, federated_identity interval [0.0, 0.45];
  randomized-chain targets: chain_motifs fires when motif library
  hits, otherwise sparse; etc." One row per hardening pattern.
- [ ] **Survival rate** quantified: what fraction of high-confidence
  posteriors *survive* migration from soft-target corpus to
  hardened-target corpus, per node. The honest number - most should
  vanish; the ones that don't are the leak surfaces.
- [ ] **No new code-path regressions on the soft corpus** -
  re-running the v1.9.0 corpus shows the same 100% per-node
  agreement (other than `email_security_strong` which is gone).
- [ ] **Failure-mode catalog cross-referenced** to specific
  defensive guidance in correlation.md §4.8.8 (Defensive value):
  "if you're worried about X, the correlation layer that surfaces
  it is Y; if X is hardened away, the layer reports Z."
- [ ] **Reproducibility section** in `v1.9.4-calibration.md` shows
  how an outside reader could build a comparable hardened corpus
  from public sources (CT-log queries against short-lived issuers,
  filter for wildcard SANs, etc.). Anonymized aggregates only.

#### v1.9.5 - Per-node stability criteria (decide, don't ship the field)

The current EXPERIMENTAL label is atomic - the whole `--fusion`
layer carries it. That's over-broad. `m365_tenant`, `cdn_fronting`,
`aws_hosting`, `google_workspace_tenant` were validated at 100%
spot-check on the v1.9.0 corpus. `email_security_strong` was not.
They should not share a label.

This patch is about *deciding the criteria*, not shipping a
field. v2.0 ships with no `experimental` labels anywhere
(see v2.0.0 below), so the `stability: stable | experimental |
deprecated` field has nothing to express at v2.0 release time.
Ship the field when it's actually needed - which is the first
time a post-v2.0 patch adds a node that doesn't immediately
qualify as `stable`.

- **Per-node stability criteria** (behavioral, not engine-tautological):
  - **(a) Evidence-response correctness.** The node's posterior
    moves in the predicted direction when relevant evidence is
    added or removed, and *does not* move when irrelevant
    evidence is varied. This validates the network propagation
    works as designed for that node, not just that the engine
    runs Bayes.
  - **(b) Calibration in both regimes.** When the deterministic
    pipeline classifies the slug `high`, the Bayesian posterior
    is also high (> 0.5) and the credible interval does not span
    the full `[0, 1]` range. When the deterministic pipeline is
    silent (sparse target), the interval widens appropriately
    rather than collapsing on a confident-looking point estimate.
    Two binary checks; no ground-truth probability claim.
    Alongside these, record per-node **proper scoring rules**
    (Brier score, log-score) and **expected calibration error
    (ECE)** computed against the deterministic pipeline's
    high-confidence verdicts as a proxy label. These are
    diagnostic, not gating: a node with bad ECE on the proxy is
    flagged for re-examination of its CPT or topology, not
    auto-failed. The point is to make the verdict numerically
    defensible rather than purely behavioral.
  - **(c) Independent-firing threshold.** The node's evidence
    bindings have fired on at least N independent domains in
    aggregate corpus runs. Without enough firings we don't have
    enough data to support promotion regardless of point-spot-
    check rates.
- **Apply the criteria to the v1.9.0 seed network.** Each node
  gets a verdict - `stable` (clears all three) or `not yet`
  (one or more criteria unmet). The `not yet` verdicts feed
  the v2.0 disposition decisions: split the node, redefine it,
  remove it after a deprecation patch, or keep working on it
  in v1.9.x.
- **The `stability` field itself ships in v2.1 or later**, the
  first time a new node is added that needs the `experimental`
  value. v2.0 ships without the field; the schema disposition
  table accounts for nodes by name, not by per-node label.

**Quality bar - exceptionally well:**

- [ ] **Per-node verdict table** in `validation/v1.9.5-stability.md`.
  One row per node, three columns (a / b / c), explicit pass/fail.
  Pass requires *all three* - partial-pass nodes are `not yet`.
- [ ] **Numeric backing for criterion (b)**: per-node Brier score,
  log-score, and ECE on the v1.9.4 corpus run, with ranges
  documented (e.g., "ECE ≤ 0.20 considered acceptable for
  stable; > 0.20 flagged for re-examination, not auto-failed").
- [ ] **Independent-firing threshold (c) explicit**: N ≥ 10
  independent domains for stable; the table records the actual
  firing count per node from the v1.9.4 corpus, not a self-report.
- [ ] **Criterion-(a) test exists in code** as a parametrized
  test (`tests/test_node_stability_criteria.py`) - for every
  node, an assertion that varying its bound evidence moves its
  posterior and that varying unbound evidence does *not*. The
  test failing is a regression signal, not a v1.9.5-only check.
- [ ] **`not yet` verdicts route to specific dispositions** before
  v2.0:
  - Split the node (per v1.9.3 surgery template)
  - Redefine it (CPT changes with concept comments per v1.9.6)
  - Remove it (one-patch deprecation → next-patch deletion;
    no "experimental at v2.0" allowance)
  Each `not yet` node carries its disposition decision in the
  same v1.9.5-stability.md row.
- [ ] **No fast-tracking on numbers alone.** A node with great ECE
  but only 4 firings does not pass - the threshold is *all three*.

#### v1.9.6 - CPT-change discipline (concept, then parameter)

The v1.9.0 validation report initially recommended "lower
`P(strong|M365+gateway)` from 0.75 → ~0.55 because the corpus
shows 55%." That recommendation was wrong, not in the target
number but in the framing: it would have tuned a parameter to
match a corpus disagreement that turned out to be definitional
(see v1.9.3 `email_security_strong` split). The v1.9.6 discipline
codifies the right ordering: *question the topology first, then
update parameters with documented Bayesian discipline.*

Three categories of CPT change are distinguishable, and the
discipline treats them differently:

- **Structure learning (auto-deciding edges, banned).** Algorithms
  like PC, FCI, or GES that infer the DAG topology from data
  cross the "no autonomous topology change" invariant. Output as
  an *operator-facing proposal* (a human reads the candidate edges
  and decides whether to add them to YAML) is acceptable; the
  auto-apply step is the disqualifier.
- **Automated parameter fitting (banned).** Pipelines that
  read the corpus and auto-emit CPT values into
  `bayesian_network.yaml` without human topology review. EM
  fitting, Snorkel-style weak supervision, gradient descent on
  CPT entries, and similar fall here. The output is opaque in the
  sense that matters: a reader of the committed YAML cannot
  reconstruct what observed counts produced what posterior values
  without re-running the pipeline. This is the "no learned
  weights" invariant in its specific form.
- **Transparent Bayesian parameter updates (allowed, with
  discipline).** A CPT entry can be updated via an explicit
  Dirichlet posterior from documented corpus counts, provided
  the topology has already been verified and three conditions
  are met:
  1. The prior (its hyperparameters and the rationale for those
     hyperparameters) is published in the YAML comment for the
     CPT entry.
  2. The observed counts and the corpus they came from are
     published in the corresponding validation report.
  3. The posterior is computed exactly from the prior plus the
     counts, and the math is verifiable by a reader from the
     published numbers alone.

The first two categories stay banned because both can produce
CPT values whose derivation a reviewer cannot reconstruct from
committed artifacts. The third is allowed because Bayesian
updates from documented counts are transparent probability
theory, auditable end-to-end, and an unbiased statistical
estimator of the modelled population. Human hand-tuning with a
concept comment (the v1.9.6 worked example) remains acceptable
but is *not* the preferred path going forward: explicit Bayesian
updates with published priors and counts are more honest and
less subject to the cognitive biases of the human tuner.

- **Discipline (ordering, not prohibition).** Corpus runs are
  mirrors first, parameter inputs second. The human's job is to
  question the *topology* (is this node asking the right
  question?) before reaching for any parameter change. If the
  disagreement is high, the *first* hypothesis is that the model
  is conceptually wrong (the v1.9.3 `email_security_strong`
  story). Only after the topology is verified do CPT numbers get
  re-examined, and the preferred method for that re-examination
  is the transparent Bayesian update above, not hand-tuning.
- **Iteration cycle is fine; opaque automation is not.**
  Iterating "look at corpus → rewrite mental model → write new
  CPTs" with a human in the loop is the right cycle. An
  automated pipeline that reads the corpus and emits CPTs
  without topology review crosses the invariant.
- **Enforcement.** A contributor-facing note in
  `CONTRIBUTING.md` describes the three-category framework, the
  topology-first ordering, and the publication requirements for
  transparent Bayesian updates. PR review enforces; no automated
  test, because the test would game the comment requirement
  without measuring whether the concept-questioning actually
  happened.

**Quality bar - exceptionally well:**

- [ ] **Worked example in CONTRIBUTING.md**: the v1.9.3 surgery
  used as the canonical case. "We almost tuned a CPT and shipped;
  the right answer was topology surgery. Here's how to recognize
  the same pattern." Concrete enough that a future contributor
  facing a similar disagreement asks the topology question first.
- [ ] **PR-template addition**: a non-blocking checkbox `[ ] If
  this PR changes any CPT entry in bayesian_network.yaml, the
  YAML carries a comment explaining the *concept* this change
  reflects, not just the corpus statistic that motivated it.`
  Pre-filled in `.github/pull_request_template.md`. Reviewer
  enforces; checkbox is the prompt.
- [ ] **Anti-pattern catalog**: explicit list of changes the
  reviewer should reject - "lowered P(X|Y) from 0.75 to 0.55 to
  match corpus rate" without a concept comment is the canonical
  rejection. Three or four worked rejections in CONTRIBUTING.md.
- [ ] **No automated CPT-fitting tooling**: confirm no script
  under `scripts/` or `validation/` has emerged that auto-emits
  CPT values from corpus statistics. Periodic audit, not a
  test (the audit is the discipline).

#### v1.9.7 - Metadata-coverage gate (presence, not coverage)

The v1.9.0 advisory gate measures description coverage as a
percentage. Forcing 70% coverage means writing ~190 description
strings, many of which would be one-line placeholders just to
clear the gate. That's gate-gaming.

- **Reframe the metric.** Replace "≥ 70% description coverage on
  identity / security / infrastructure" with "every detection in
  identity / security / infrastructure has a non-empty
  description." Binary per-detection, not percentage per-
  category. Catches *omission*, not richness; richness is for
  PR review.
- **Implementation.** Modify
  `scripts/check_metadata_coverage.py` to count
  detections-with-empty-or-missing-description rather than
  percentage. Flip from advisory to enforcing when the count for
  gated categories is zero.

**Quality bar - exceptionally well:**

- [ ] **Per-category gap report**: when the script fails, output
  lists the exact slug + detection-rule pair missing a
  description, grouped by category. A contributor sees "fix
  these 7 entries" not "your category coverage dropped to 87%."
- [ ] **Pre-commit hook entry** added to `.pre-commit-config.yaml`
  so the gate fires locally before push, not just in CI. Faster
  feedback loop; aligns with the project's existing pre-commit
  posture.
- [ ] **What-good-looks-like guide** in `CONTRIBUTING.md`:
  description rubric requiring (a) what slug detects, (b) what it
  doesn't detect, (c) common false positives if known. Two or
  three worked examples (good vs placeholder). Empty-string
  presence checks the floor; the rubric raises it for new
  contributions.
- [ ] **Backfill before flip**: zero detections in
  identity/security/infrastructure are missing descriptions
  before the gate flips from advisory to enforcing. The flip is
  the last commit of v1.9.7, not the first; the patch ships with
  zero CI breakage on main.
- [ ] **Reference-presence reporting** added (advisory only at
  v1.9.7): script also reports references-missing per-detection
  count, but does not fail. Sets up the Track B metadata richness
  pass that follows.

#### Patch-release discipline

Each v1.9.x patch ships when *that one milestone* is complete.
This is intentional:

- **One milestone per patch** keeps the diff small and the changelog
  honest. A user reading "v1.9.4 - hardened-adversarial validation"
  knows exactly what shipped and what to test.
- **No bundling.** Two milestones completing on the same day is
  fine; they still ship as separate patches with separate tags.
  Bundled releases hide work and make rollback harder.
- **Numeric order IS delivery order.** The dependency chain is
  the point of the planning: hardened-adversarial validation
  (v1.9.4) informs the per-node stability decisions (v1.9.5),
  which inform CPT-change discipline (v1.9.6), which precedes
  the metadata-coverage gate flip (v1.9.7), which gates the
  richness pass (v1.9.8), which feeds the detection-gap UX
  surfaces (v1.9.9), which the stratified pre-lock validation
  (v1.9.10) verifies, which the doc-polish dry-run (v1.9.11)
  consolidates before v2.0 tags. Skipping ahead because a later
  patch "feels easier" means we're guessing at the dependency we
  just decided to think about.
- **Bug-fix patches use the next available number.** A regression
  fix that lands between v1.9.5 and v1.9.6 ships as `v1.9.5.1`
  or claims the next minor number - whichever the project's
  versioning strategy prefers at that moment. Bug fixes do not
  block bridge milestones, and bridge milestones do not block
  bug fixes; both make linear progress through their own number
  spaces.

EXPERIMENTAL labels come off per-node as the v1.9.4 → v1.9.11
sequence advances, not all at once. By the time v1.9.11 ships
(the doc-polish dry-run), every surface is either `stable`,
explicitly `experimental` (and we know why), or explicitly
`deprecated`. v2.0 then mechanically strips the remaining
EXPERIMENTAL language.

**v2.0 ships with zero EXPERIMENTAL labels anywhere.** This is a
hard rule, not an aspiration:

- Nodes that have cleared the v1.9.5 stability criteria → ship in
  v2.0 as `stable`.
- Nodes that have not cleared the criteria by v2.0 release time →
  **removed from `bayesian_network.yaml` for v2.0**, not shipped
  as `experimental`. They can be re-added in a v2.x patch once
  the corpus exposure validates them. A removed-and-re-added node
  is honest; an "experimental at v2.0" node is not, because v2.0
  is supposed to be the polished release.
- The per-node `stability` field stays in the schema for *future*
  use (post-v2.0 additions ship as `experimental` and graduate
  to `stable` later). It is not a v2.0 label-leftover surface.

This is stricter than the previous draft. The previous draft
allowed `experimental` per-node at v2.0; we removed that
allowance. If a node can't earn `stable`, it doesn't belong in
v2.0, full stop.

#### v1.9.8 - Catalog metadata richness pass (shipped)

**What shipped.** Description quality and reference coverage lifted
across the entire catalog. After this pass every detection in every
category satisfies all three proxy signals of the new advisory
richness audit:
- 100 percent of detections carry a non-empty `description`
  (presence floor from v1.9.7, retained).
- 100 percent of descriptions clear the 80-char length floor that
  proxies signal 1 of the rubric ("what the slug detects").
- 100 percent of descriptions contain scope-narrowing language
  (proxies signal 2: "what it does not detect"). The audit's token
  set was tuned to the catalog's actual writing style - explicit
  negation (`not`, `does not`) plus the idioms the catalog uses to
  narrow scope (`alternative`, `legacy`, `functionally equivalent`,
  `typically paired`, `same semantics`, `cname through`, `chain
  through`, `subdomain cnames into`, `government cloud`, and so on).
- 100 percent of detections carry a canonical vendor `reference`
  URL (vendor product or docs root, chosen conservatively to
  survive deep-link rot).

The original quality bar was "≥ 80 percent description, ≥ 25
percent reference in identity / security / infrastructure." The
shipped pass overshoots that target across every category.

Also shipped:
- `scripts/check_metadata_coverage.py --report-richness` advisory
  audit. Reports the three signals per category and surfaces a
  per-detection worklist; never gates.
- Inline weight rationale comments above every non-default
  `weight:` key (currently four detections in `security.yaml`).
- `CONTRIBUTING.md` rubric pointer refreshed to v1.9.8+.

**Why this was next.** v1.9.7 turned the metadata-presence gate
enforcing; v1.9.8 raised the floor from "every detection has a
description" to "every description is informative and externally
referenceable." Defenders read the `--explain` panel to decide
whether to act on a finding; a slug labelled `auth0` without a
description forces them to know what Auth0's CNAME pattern looks
like. v2.0 is the polished release; shipping with thin descriptions
or unreferenced detections would undercut the explainability
priority.

**Validation.** `validation/v1.9.8-metadata-audit.md` documents the
end-state numbers, weight-rationale table, and scope decisions.
`scripts/check_metadata_coverage.py --report-richness` shows 100
percent on every category and every signal.

#### v1.9.9 - Detection-gap UX surfaces (shipped)

**What shipped.** Three operator-facing surfaces in the default panel
that make the architectural limits of passive DNS collection visible.
No engine code changes, no JSON schema additions. The fourth roadmap
item from the original v1.9.9 scope (multi-apex CT SAN traversal)
deferred to v1.9.9.1 so the external-HTTP behaviour change can land
with its own validation pass.

1. **Passive-DNS ceiling phrasing.** When the default panel is sparse
   on an apex that probably should not be, a one-line teaching footer
   renders under the Services block: "Passive DNS surfaces what
   publishes externally. Server-side API consumption, internal
   workloads, and SaaS without DNS verification do not appear in
   public DNS records." Trigger heuristic is conservative on purpose:
   fires only when `info.services` is non-empty (a different surface
   owns failed runs), `info.domain_count >= 3` (the apex has multiple
   tenant domains, so sparse is genuinely surprising), categorized
   service families are fewer than 5, AND CNAME-chain subdomain
   attributions are fewer than 5. Both halves of the sparse check must
   hold so a domain with short Services but many surface attributions
   does not gain a misleading footer. `--full` / `--domains` suppresses
   the line because those modes already carry the long surface
   section.
2. **Common-prefix wordlist extensions.** The active-DNS probe in
   `recon_tool/sources/dns.py` and the CT high-signal sort in
   `recon_tool/sources/cert_providers.py` both gained eight prefixes
   covering tiers the prior wordlist ignored: `data`, `analytics`,
   `ai`, `ml`, `internal`, `ops`, `tools`, `security`. Each prefix
   maps to a recognised stack tier with vendor-product backing
   (Snowflake under `data`, Vertex AI under `ai`, internal portals
   under `internal`, SIEM consoles under `security`). The CT-side
   additions keep prioritization parity so a CT response surfacing
   `data.contoso.com` sorts to the top of the bounded output rather
   than falling off the cap.
3. **Apex-level multi-cloud rollup indicator.** When the canonicalized
   vendor count across apex slugs and surface attributions is at least
   two, a `Multi-cloud` row joins the key-facts block above
   Confidence: for example `Multi-cloud: 3 providers observed (AWS,
   Cloudflare, GCP)`. A single-vendor apex stays unannotated. Sibling
   slugs collapse: AWS Route 53 plus AWS CloudFront is one AWS vote.
   Firebase rolls up under GCP. The canonicalization map
   (`_CLOUD_VENDOR_BY_SLUG` in `formatter.py`) is the single source of
   truth; two public helpers (`canonical_cloud_vendor`,
   `count_cloud_vendors`) sit on top of it so future panels and JSON
   paths can reuse the canonicalization without duplicating the table
   inline.

**Why this was next.** v1.9.3.10 surfaced the unclassified-chain gap
and per-provider subdomain counts in the default panel. v1.9.9
completes the detection-gap surface story: the panel now shows what it
cannot see (the ceiling), casts a wider net for what it can see
(enumeration breadth), and summarises the distribution (multi-cloud
indicator). After v1.9.9 the default panel is honest about both its
findings and its limits, which is the v2.0 polish target.

**Quality bar - verified at ship.**
- Ceiling phrasing fires only on sparse-services + multi-domain
  apexes. Both `len(categorized) < 5` and `len(surface_attributions) <
  5` must hold; `domain_count >= 3` gates the multi-domain check.
- Subdomain wordlist additions documented per term with inline
  comments naming the stack tier and the vendor-product idiom that
  motivates inclusion. No speculative additions; the eight prefixes
  map one-to-one to recognised stack tiers.
- Multi-cloud indicator counts canonicalized vendors, not slugs. The
  `count_cloud_vendors` helper round-trips through
  `_CLOUD_VENDOR_BY_SLUG` so sibling slugs collapse before the trigger
  threshold is checked.
- Tests: **167 v1.9.9 tests across 20 new test files** covering six
  orthogonal axes (trigger behaviour, test quality, integration,
  robustness, corpus validation, documentation). Full suite at
  **2481 pass / 1 skip / 4 deselect**; coverage 84% total. Deterministic
  under both `--cov` and non-`--cov` runs.

**Validation.** Two memos:
- `validation/v1.9.9-detection-gap-ux.md` - per-fixture trigger
  behaviour, wordlist rationale, canonicalization decisions
  (Firebase under GCP, Replit and Glitch excluded), test-quality
  manifesto with explicit "what we test and what we honestly do not"
  framing.
- `validation/v1.9.9-corpus-run.md` - synthetic 19-fixture corpus
  results: 8/19 multi-cloud fires (42.1%), 11/19 ceiling fires
  (57.9%). The corpus is publicly reproducible from
  `validation/synthetic_corpus/generator.py`; the aggregator at
  `validation/corpus_aggregator.py` mirrors the renderer's trigger
  logic and emits anonymized counts (no apex names).

**Refinement.** Two items moved to v1.9.9.1:
- Multi-apex CT SAN traversal (pull subdomains from all observed apex
  certs, not just the queried apex's). External-HTTP behaviour change
  warranting its own validation pass against the v1.9.4 hardened
  corpus.
- CT-by-org-name search when an organization name is available from a
  prior lookup. Same external-HTTP rationale.

#### v1.9.10 - Stratified-corpus pre-lock validation (shipped)

**What shipped.** A 60-fixture publicly-reproducible synthetic
corpus across six cloud strata (GCP, Azure non-O365, Oracle,
Alibaba, PaaS/Vercel/Netlify, SSE/SASE), plus the v1.9.9 19-fixture
base corpus = 79 fixtures total. Per-stratum aggregator emits
coverage metrics. Bayesian network re-validated against the v1.9.9
evidence-distribution shift; v1.9.6 disposition table holds. Full
write-up at `validation/v1.9.10-pre-lock.md`,
`validation/v1.9.10-bayesian-revalidation.md`, and
`validation/v1.9.10-mutation-status.md`.

**Why this was next.** Up to v1.9.9 the validation was single-
corpus (enterprise M365/AWS-skewed) plus one small rich-stack
empirical pass (v1.9.3.10). v1.9.10 confirms the engine works
across cloud strata, not just the ones the historical corpus
over-represented. Strata-specific behaviour is documented
explicitly in the per-stratum aggregate output rather than hidden.

**Scope discipline (no real customer data).** The roadmap original
quality bar called for "publicly-documented users of that vendor
sourced from vendor case-studies, vendor blog posts, or job
listings". The maintainer's no-real-data discipline (Microsoft
fictional brands only for committed examples) takes precedence:
v1.9.10 ships with **synthetic** stratified fixtures modelled
after public deployment patterns, not with real customer apex
names. The corpus aggregator is reusable against the maintainer's
gitignored private corpus; that real-corpus run produces the
truth-of-record numbers and remains standing work.

**Quality bar - verified at ship.**
- 60 stratified synthetic fixtures (10 per stratum) all
  Microsoft-fictional, deterministic generator at
  `validation/synthetic_corpus/generator.py`.
- Per-stratum coverage metric reported in
  `validation/synthetic_corpus/aggregate.json` and the
  `validation/v1.9.10-pre-lock.md` table.
- Per-stratum unclassified-termini count: zero (synthetic fixtures
  do not include unclassified CNAME chains by construction).
- Bayesian re-validation: 20/20 v1.9.5 stability tests pass on the
  v1.9.9 codebase; the network's evidence bindings are unchanged
  by the v1.9.9 wordlist additions (the wordlist widens slug
  collection at the surface-attribution layer; the network reads
  upstream signals not affected by the wordlist).
  See `validation/v1.9.10-bayesian-revalidation.md`.
- Aggregate findings published in `validation/v1.9.10-pre-lock.md`.

**Items deferred to v1.9.11 or later.**
- **Trend table v1.6 → v1.9.10 per stratum.** Requires re-running
  earlier versions against the new strata. Substantial standalone
  work; v1.9.11 doc-polish pass is the natural home.
- **Real-corpus aggregator run.** The maintainer runs locally
  against the gitignored private corpus and drops the aggregate
  output into `validation/v1.9.10-corpus-run.md`. The script is
  ready (`validation/corpus_aggregator.py`); the data is not
  committed. Tracked in `validation/invariant_audit.md` item 1.
- **Cosmic-ray full sweep on `formatter.py`.** Slipped to the v2.0
  schema lock with a Linux CI runner. Rationale in
  `validation/v1.9.10-mutation-status.md`: the catalog-driven
  Hypothesis tests added in v1.9.9 already caught a real
  pre-existing bug (the `Data & Analytics` KeyError), which is
  stronger evidence of test-quality breadth than a clean
  cosmic-ray run would be. Tracked in
  `validation/invariant_audit.md` item 2.

**Refinement.** None required. Per-stratum firing rates are within
the synthetic-corpus design envelope (multi-cloud rollup fires
0-70% per stratum depending on whether stratum fixtures are
intentionally single-cloud or multi-cloud; ceiling fires 78-100%
per stratum because synthetic fixtures are sparse-by-design). The
gitignored-private-corpus run will produce the truth-of-record
numbers.

#### v1.9.11 - Documentation polish dry-run (shipped)

**What ships.** Every doc reviewed against the v2.0 quality bar
before v2.0 actually tags. correlation.md polished to the v2.0
draft form (defense ↔ correlation mapping table, prior-art
comparison, dependency-floor manifesto, failure-mode catalog).
README, CONTRIBUTING, AGENTS.md, docs/mcp.md, docs/legal.md,
docs/security.md cross-checked for stale references, dead links,
EXPERIMENTAL labels in user-facing text. Migration guide for
v1.x → v2.0 consumers (`docs/migration-v2.md`, skeleton already
in place) populated with the field-by-field promotion list and
the Bayesian-network node disposition.

**Why this is next.** v2.0 should be a mechanical lock-and-tag
event, not a "let's also rewrite docs" event. If the v2.0 docs are
already polished, the lock just bumps the schema version, strips
EXPERIMENTAL labels from code/JSON descriptions, and tags. Doing
the doc work as v1.9.11 separates the rewriting from the
mechanical lock.

**Quality bar (each item links to a concrete artefact).**

- [ ] **Zero EXPERIMENTAL labels in user-facing surfaces.**
  Current baseline: 38 hits across 14 files
  (`validation/v2.0-prep-baseline.md` §1). v1.9.11 brings this to
  zero. Verified by a CI `grep -ri experimental` gate added in
  the release workflow.
- [ ] **Migration guide populated.** Skeleton at
  `docs/migration-v2.md` lists the field-promotion table,
  Bayesian-node disposition section, schema-version bump notes,
  and downgrade-path recommendation. v1.9.11 fills in any
  per-field details that depended on the v1.9.10 → v1.9.11
  decisions (specifically the `okta_idp` disposition outcome).
- [ ] **Trend table v1.6 → v1.9.11 per Bayesian-network node.**
  Compiled from `validation/v1.9.*-calibration.md` per-release
  numbers and the v1.9.10 stratified-corpus aggregate. Anchors
  the v2.0 "engine got better" claim with public per-release
  numbers. Lives in `validation/v1.9.11-trend-table.md`.
- [ ] **Schema disposition test green.** `tests/test_schema_disposition.py`
  (added in v1.9.10.1 prep) passes with zero entries in
  `_V2_KNOWN_SCHEMA_GAPS`. The current single entry
  (`ecosystem_hyperedges` - batch-wrapper field not in schema)
  is the v1.9.11 worklist item.
- [ ] **`okta_idp` disposition applied.** Decision in
  `validation/v2.0-prep-baseline.md` §3 (keep + corpus-expansion,
  deprecate, or split). Whichever is chosen, the
  `bayesian_network.yaml` and the migration guide reflect the
  final disposition.
- [ ] **Real-corpus aggregator run.** Decision in
  `validation/v2.0-prep-baseline.md` §4 (v2.0 blocker vs.
  maintainer attestation). Whichever is chosen, the artefact or
  attestation is in place before v2.0 tags.
- [ ] **Every cited prior-art reference in correlation.md is
  reachable.** No dead links.
- [ ] **Dependency-floor manifesto matches `pyproject.toml`.**
  Every listed dependency is in the lockfile; every excluded
  dependency is genuinely not pulled in transitively.
- [ ] **CONTRIBUTING.md procedure walkthrough.** A maintainer
  follows the "add a new fingerprint" steps end-to-end using only
  the doc text. Gaps surface here, not after v2.0 ships.

**Validation.** Re-run the synthetic corpus aggregator
(`validation/corpus_aggregator.py`) plus the v1.9.3.10 empirical
sample on the v1.9.11 build. Confirm the trend table numbers
match the per-release memos. No code change between v1.9.11 and
v2.0 should be required.

**Refinement.** If the docs review surfaces gaps in the code (e.g.
a docstring describes behaviour the code doesn't implement),
v1.9.11 ships a follow-up code patch before v2.0 starts. The
v2.0 release notes should read like an inventory, not a feature
list - every claim should already be true at v1.9.11 tag time.

_Additive feature candidates (BIMI VMC clustering, MCP delta helper,
self-audit batch mode, non-MCP graph exports, per-node
`n_eff_multiplier`, corpus-driven Hypothesis tests, Hawkes-kernel
CT burst classification, LPA fallback for `infra_graph`, explicit
ignorance mass, noisy-OR / noisy-AND CPT gates) are now in the
[Backlog (after v2.0)](#backlog-after-v20) section. They were
previously framed as "optional v1.9.x feature additions"; under
the v1.9.4 → v2.0 linear sequence, they no longer claim slots in
the path-to-v2.0 plan. Any of them may be promoted into a
post-v2.0 v2.x.y patch when there's a falsifiable defensive case
and corpus evidence to back it._

### v2.0.0 - Maturity

Lock in what the previous releases proved. Promote stable experimental
fields to the v2.0 schema contract; make the catalog community-PR-
friendly; ensure the framework is suitable for sustained corpus-driven
operation.

**Pre-conditions** - the v1.9.4 → v1.9.11 linear sequence has
completed, in order:

1. **v1.9.4** - Hardened-adversarial behaviour validated; 50-domain
   minimal-DNS corpus exercises the asymmetric-likelihood design.
2. **v1.9.5** - Per-node stability dispositions decided for every
   Bayesian-network node; "not yet" nodes either redefined,
   deprecated, or removed.
3. **v1.9.6** - CPT-change discipline documented in
   `CONTRIBUTING.md` and enforced in review.
4. **v1.9.7** - Metadata-coverage gate flipped from advisory to
   presence-enforcing.
5. **v1.9.8** - Catalog metadata richness: 100 percent of detections
   carry substantive descriptions, scope-narrowing language, and a
   canonical vendor `reference` URL across every category;
   advisory `--report-richness` audit shipped.
6. **v1.9.9** - Detection-gap UX surfaces shipped: passive-DNS
   ceiling phrasing, expanded subdomain enumeration breadth,
   apex-level multi-cloud rollup indicator.
7. **v1.9.10** - Stratified-corpus pre-lock validation passed:
   60-domain stratified suite (per-cloud × 6 strata) shows the
   engine works across cloud customers, not just the
   enterprise-M365/AWS-skewed historical corpus.
8. **v1.9.11** - Documentation polish dry-run: every doc reviewed
   against v2.0 quality bar; migration guide drafted; zero
   EXPERIMENTAL labels remain in any user-facing text.

Each version's prose above documents its own quality bar,
validation step, and refinement check.

Already cleared en route to this sequence:

- ~~v1.9.2 (operator UX validation via agentic QA)~~ - see
  `validation/v1.9.2-agentic-ux.md`.
- ~~v1.9.3 (email_security_strong definitional gap)~~ - see
  `validation/v1.9.3-calibration.md`.
- ~~Supply-chain hardening, SBOM, secrets-scanning, forward-compat
  cache test~~ - shipped in v1.9.3.1.
- ~~Top-3 influential edges in --explain-dag~~ - shipped in v1.9.3.2.
- ~~Cloud-vendor coverage gap fill (GCP / Azure non-O365 / Oracle
  / IBM / Alibaba / PaaS / SSE-SASE / identity extras; 29 new
  fingerprints)~~ - shipped in v1.9.3.9.
- ~~Subdomain-level surface intelligence in default panel
  (unclassified-surface section + per-provider counts)~~ -
  shipped in v1.9.3.10.
- ~~Downstream consumption examples (Splunk + Elasticsearch field
  mappings, CI gate against schema drift)~~ - shipped in v1.9.3.8.

**Schema-lock disposition** (every EXPERIMENTAL field gets a verdict):

| Field | Disposition |
|---|---|
| `posterior_observations` | Promote to stable. Pin `name`, `description`, `posterior`, `interval_low`, `interval_high`, `evidence_used`, `n_eff`, `sparse`. |
| `slug_confidences` | Promote to stable. Existing `[slug, posterior]` shape. |
| `chain_motifs` (v1.7) | Promote to stable if v1.9.x corpus runs continue to fire on real targets. |
| `wildcard_sibling_clusters` (v1.7) | Promote to stable. |
| `deployment_bursts` (v1.7) | Promote to stable. |
| `infrastructure_clusters` (v1.8) | Promote to stable. |
| `ecosystem_hyperedges` (v1.8, batch-only) | Promote to stable; document as batch-only contract. |
| `evidence_conflicts` (v1.7) | Already stable shape; formally promote in schema. |
| `--fusion` flag | Drop EXPERIMENTAL label. |
| `--explain-dag` flag | Drop EXPERIMENTAL label. |
| MCP `get_posteriors` / `explain_dag` tools | Drop EXPERIMENTAL label. |
| `bayesian_network.yaml` topology | Lock at v2.0; further changes require schema-version bump. |
| Bayesian-network nodes that clear v1.9.5 criteria | Ship in v2.0. |
| Bayesian-network nodes that do NOT clear v1.9.5 criteria | Remove via deprecation: a v1.9.x patch marks the node deprecated in CHANGELOG and emits a one-time stderr warning when it's used; the next patch removes it from `bayesian_network.yaml`. v2.0 ships without the node. **No node goes from `experimental` directly to "removed" without a deprecated stop in between.** |
| Per-node `stability` field | Not shipped at v2.0. Reserved for v2.1+ when a new node first needs the `experimental` value. |

**v2.0 itself is purely the lock-and-polish ceremony - three items:**

- **Schema lock.** Apply the disposition table above. Bump
  `docs/recon-schema.json` to v2.0; remove EXPERIMENTAL language
  from the promoted fields' descriptions. This is mechanical
  once the v1.9.x patches have validated everything.
- **Bayesian fusion goes default-on with a clean-panel
  disclosure rule.** See the design-decision subsection
  immediately below this list.
- **Documentation snapshot.** [`correlation.md`](correlation.md)
  (currently a living draft) promoted to a polished reference.
  Sections required for the snapshot:
  - **Defense ↔ correlation mapping** table so a defender can
    read across from "what I'm worried about" (shadow
    infrastructure, lookalike domains, sovereignty drift,
    supply-chain motif change) to "which correlation layer
    surfaces it" (rules, wildcard SAN siblings, temporal bursts,
    chain motifs, community detection, posterior shift).
  - **Prior-art comparison.** Existing probabilistic libraries
    (pgmpy, pomegranate, PyMC / Stan / Pyro) - what they are,
    what they do well, and the specific reasons we did not
    import them. Concepts we adopted are already cited inline
    (Jeffrey 1965, Walley 1991, Augustin et al. 2014, Taroni
    et al. 2014, Minka 2001, Naeini et al. 2015, Pearl 1988,
    Russell-Norvig, Zhang & Poole 1994, Koller & Friedman 2009,
    Blondel et al. 2008, Traag et al. 2019); this section makes
    the *implementation choices* explicit so a careful reader sees
    what we considered and rejected, not just what we used.
  - **Dependency-floor manifesto.** Complete runtime dependency
    graph (httpx, dnspython, pyyaml, typer, rich, mcp, networkx,
    pydantic-via-mcp) and the list of widely-used libraries we
    *deliberately do not* depend on (numpy, scipy, pandas,
    pgmpy, pomegranate, PyMC, Stan, Pyro, scikit-learn, Redis,
    SQLite, Celery, FastAPI, Shodan / Censys / SecurityTrails
    APIs, GeoIP / ASN databases) with one-sentence reasons.
    Defensive, adaptive, and coding-discipline posture as one
    artifact.
  - **Failure-mode catalog** carried forward from v1.9.4 with
    additional examples accumulated from corpus runs.
  - **Engineering quality posture** carried forward from this
    roadmap, edited for the polished-doc voice.

**v2.0 design decision: Bayesian fusion goes default-on with a
clean-panel disclosure rule.**

Through v1.9.x the Bayesian inference layer was gated behind
`--fusion` because the math was EXPERIMENTAL. The v1.9.4 →
v1.9.10 stability work was specifically the path from experimental
to "stable enough to ship by default." Once that path completed,
keeping the layer opt-in became a half-promotion that contradicted
correlation.md §5.1 ("hedging is a calibration choice, not
politeness") and made the default panel less honest than the
inference engine knew how to be.

v2.0 flips the default. The math runs unconditionally; the panel
stays clean; users who want a purist rule-based view can opt
out.

*Default behavior at v2.0:*

- **Compute always.** Bayesian inference runs on every lookup.
  Cost is zero network calls (the comment at `cli.py:2365`
  already documents this: "Costs nothing, no network calls, so
  the recompute is cheap").
- **`--json` always emits** `posterior_observations` and
  `slug_confidences`. Schema-additive per the v2.0 contract; no
  consumer that ignores unknown fields breaks.
- **Default panel stays clean.** The rule-based verdict
  (Provider, Tenant, Confidence dots, Services) renders exactly
  as it does today. The credible interval is computed but not
  rendered in the default panel.
- **Default panel speaks up when the layers disagree.** When
  `sparse=true` fires on a node the panel is reporting a
  verdict for, or when the posterior mode disagrees with the
  deterministic slug, the panel surfaces the interval inline
  and demotes the confidence-dot indicator. This is the
  operator-actionable case: the deterministic engine says one
  thing, the Bayesian layer says "but the evidence is thin."
  Quiet on the easy cases, loud when it matters.

*User control surfaces at v2.0:*

- `--verbose` (existing flag): always render the credible
  interval inline in the panel, including on easy cases. For
  operators who want the math visible by default.
- `--explain` (existing flag): full evidence-DAG provenance.
  Unchanged from v1.9.x.
- `--explain-dag` (existing flag): render the Bayesian DAG.
  Unchanged.
- `--no-fusion` (new at v2.0): disables Bayesian computation
  entirely. The `--json` output omits `posterior_observations`
  and `slug_confidences`. Output reverts to the v1.x rule-based
  shape. For SIEM consumers who want stable rule-based output
  without the additive Bayesian fields, and for purists who
  want only the deterministic engine. Documents the opt-out as
  a stable surface; not a deprecation path.
- `--fusion` (existing flag): kept as a deprecated no-op for
  v2.0.x to preserve back-compat with automation that flips it
  on explicitly. Removed in v2.1.

*Why the disagreement-detection rule rather than always-show or
never-show:*

Always-show clutters the default panel for the 70 percent of
cases where rule-based and Bayesian agree (a Microsoft 365 tenant
with 4 sources of evidence does not need a credible interval to
be readable). Never-show contradicts the doc's epistemology and
hides the load-bearing field. The middle path surfaces the math
precisely when it changes the operator's read of the situation:
hardened targets, sparse evidence, conflicting signals. The
v1.9.4 hardened-adversarial validation showed 64 percent
sparse-flag firing on hardened targets, which is exactly the
population this rule is designed to escalate.

*Implementation surface (lock-ceremony work, not earlier):*

- Remove the `if fusion or explain_dag:` gate at `cli.py:2374`.
  Compute Bayesian inference unconditionally.
- Add `--no-fusion` (typer.Option with `--fusion/--no-fusion`
  pair semantics; default True).
- Gate panel-rendering of the credible interval in
  `formatter.py` on `--verbose` OR on the disagreement rule
  (`sparse=true` on a reported node, or posterior-mode/slug
  disagreement).
- Update `docs/stability.md` `--fusion` row from "Opt-in
  Bayesian fusion" to "Bayesian fusion control (default on
  v2.0+; `--no-fusion` opts out)."
- Update the README example panel to show what happens in the
  disagreement case (operator sees the credible interval
  inline) versus the easy case (panel unchanged from today).
- Document `--no-fusion` in `docs/stability.md` as a stable
  surface at v2.0.

This is the only behavioral change in v2.0 beyond the schema
lock. Tests already exercise both code paths (the `--fusion`
code path runs in the test suite); the change is moving the
default and adding the inverse flag, not adding new logic.

**v2.0.1 panel-disclosure design (the disagreement rendering). Shipped v2.0.1.**

v2.0 shipped the default panel unchanged and the math in `--json` and
`--explain-dag`. v2.0.1 adds the panel "speak up" rendering. The design as built:

- **The confidence dots map to a single defined quantity.** When fusion has run,
  the dots reflect a claimed node's posterior support relative to the
  present/absent decision threshold (0.5), in three levels: the whole 80%
  credible interval above the threshold renders `●●●`; the point estimate above
  but the interval dipping below renders `●●○` (thin); the point estimate below
  the threshold renders `●○○` (the evidence leans against the call). The dots are
  one honest reading (posterior support), not a fusion of the source count and
  the interval width; the deterministic corroboration stays in the `(N sources)`
  text. Without posteriors (`--no-fusion`) the dots fall back to the deterministic
  tier and the panel is byte-identical to v1.x.
- **The weakest claimed node drives the line.** A claimed node is one with fired
  evidence (`evidence_used` non-empty), so a declarative node correctly reporting
  absence (for example "not enforcing", no fired binding) does not demote a
  strong verdict. The panel is as confident as its shakiest asserted claim.
- **One plain-language clause, dimmed.** When the weakest claimed node is below
  full confidence the panel adds a dimmed line under the Confidence row: "thin on
  <claim>" at `●●○`, "the evidence does not back <claim>" at `●○○`. The claim is
  named in human terms, not by node id.
- **Accessibility.** Solid versus hollow carries the whole signal with no color;
  a green / blue / terracotta hue is the second channel, never the only one.
  Glyphs are limited to `●` and `○` for terminal-font safety.
- **`--verbose`.** The Evidence Detail section lists each claimed node's
  `posterior [low, high]` (comma range), under a "Posteriors (80% credible
  interval)" heading so the range is not read as a frequentist confidence
  interval.
- **Determinism.** The dot fill is a pure function of the posterior, the interval,
  and the threshold, pinned by a property test (`tests/test_posterior_dots.py`)
  so the renderer cannot drift or recalibrate through the UI.

Deferred past v2.0.1: localized dimming of a disputed claim's span in the Provider
line (the dots plus the named clause already point the operator at the shaky
claim), density glyphs, and sparklines. The panel earns a word only when the
evidence does.

---

That is v2.0. Everything else - feature additions, MCP tools,
exports - ships in v1.9.x patch releases as work completes,
under the same EXPERIMENTAL labelling discipline. By the time the
schema lock runs, the features are already in the wild and their
shapes are known.

**Validation gate for v2.0** - re-run the full corpus with the
locked schema; confirm no field-shape regressions. Trend metrics
across v1.6 → v2.0 demonstrate the correlation engine got better
without overclaiming.

**Quality bar for v2.0 itself - exceptionally well:**

- [x] **Schema lock validates against published consumers.** The
  Track B SIEM examples re-parse without modification on the v2.0
  schema. Pinned by `tests/test_siem_examples.py` (37 tests
  covering field-presence contracts, Splunk + Elastic README
  field-mapping accuracy, search-safety patterns, and severity
  mapping consistency). If a SIEM example breaks, this test
  suite fails first.
- [x] **`validation/v2.0-validation-summary.md` published** -
  full corpus results, trend table v1.6 → v1.7 → v1.8 → v1.9.0 →
  v1.9.3 → v1.9.4 → v1.9.14 per node, security-closure trail
  through v1.9.14. The trend table is the public evidence the
  engine got better. Shipped in commit `ec14bdc` (v2.0 prep);
  real-corpus numbers in `validation/v2.0-corpus-run.md` from
  the v1.9.14 scan (commit `cca815c`).
- [x] **All Track A + Track B pre-conditions ticked off** in the
  v2.0 release CHANGELOG entry, each with a link to its shipping
  patch. Staged in `validation/v2.0-release-notes-draft.md` (12
  pre-v2.0 releases, v1.9.3 → v1.9.14, each row linked to its
  tag and validation memo; security closure table separately
  linked per closing commit). Moves into `CHANGELOG.md` under
  `## [2.0.0]` at lock time.
- [x] **Zero EXPERIMENTAL labels** in any docstring, panel string,
  CLI help text, MCP tool description, or schema field
  description. `grep -ri experimental recon_tool/` returns zero
  user-facing hits (internal test markers excepted). The one
  residual mention in `docs/stability.md:139` is descriptive
  past-tense ("the EXPERIMENTAL qualifier in docstrings and
  schema descriptions is removed") rather than an active label.
- [x] **No "v1.9.x" references** linger in user-facing docs as
  forward-looking commitments. A v1.9.x audit found one
  historical mention in `docs/roadmap.md` ("future v1.9.x
  reports") and one "post-v2.0 surface" deferment note in
  `examples/siem/splunk/props.conf` for `--emit-timestamp`;
  both are honest historical or deferment framing rather than
  forward-looking commitments the user reads as broken
  promises.
- [x] **Polish-doc cross-checks** in correlation.md: every cited
  prior-art reference is reachable (no dead links); every
  dependency in the manifesto matches `pyproject.toml`'s actual
  dependency list (no manifesto/code drift).
- [x] **`recon doctor` updated** to print "v2.0 stable schema" in
  its first line, and to verify the locked schema fields are all
  present in a sample lookup output. Already wired in
  `recon_tool/cli.py:1805-1809`: the doctor preamble flips to
  "v2.0 stable schema" when `__version__.startswith("2.")` and
  shows "pre-v2.0 schema" otherwise. Activates automatically the
  moment the lock-ceremony version bump lands.

### v2.1.0 - Operator-driven catalog growth (closed-loop fingerprint mining)

The first slot after v2.0 lock. Composability is next in priority
order (correctness → reliability → explainability → composability →
features), and v2.0 doesn't advance it - v2.0 is pure
lock-and-polish on what already works.

**Framing.** The recon catalog is shaped by passive-DNS observation
of real corpora. Today, that observation is maintainer-side: the
maintainer runs `validation/scan.py` against a private corpus, the
post-triage gap list surfaces unfingerprinted CNAME terminals, and
catalog patches land in `recon_tool/data/fingerprints/`. The v1.9.11
4,270-apex pre-v2.0 scan was the most recent execution of this
pattern (28 new slugs in one batch, see CHANGELOG). v2.1 promotes
this from maintainer-only tooling to a public operator workflow:
any operator with a private domain list of their own can run the
same scan-aggregate-triage loop locally, add catalog entries to
`~/.recon/fingerprints.yaml` for their environment, and contribute
broadly-useful ones back upstream. This is a composability win
(the catalog becomes user-extendable along the same primitive the
maintainer uses) rather than a feature addition.

The mining primitive already ships. The MCP tool
`discover_fingerprint_candidates(domain)` (live in `server.py`
since v1.7) already does the hard work: resolves the domain,
captures unclassified CNAME chains, applies intra-org / already-
covered filters via `recon_tool/discovery.py::find_candidates`,
and returns a ranked candidate list. The
`/recon-fingerprint-triage` Claude Code skill is already designed
to turn that list into YAML stanzas for `surface.yaml`.

What's missing is *the loop*: a reproducible runner that uses
the existing MCP composition (`lookup_tenant` →
`chain_lookup` → `discover_fingerprint_candidates` →
`test_hypothesis` → `get_posteriors`) to systematically expand
the catalog while measuring whether the expansion actually
tightens correlation depth on the corpus. This makes the "art of
correlation" *executable at scale* instead of a one-time
hand-tuning exercise.

**Primary v2.1 surface:**

- **One new MCP skill:**
  - `run_fingerprint_mining(seed_domains, max_candidates_per_domain=20,
    dry_run=True)` - for each seed, runs the existing chain →
    discovery → hypothesis-test pipeline and draws candidates
    from three already-shipped graph layers: chain motifs (v1.7
    `motifs.yaml` + `discover_fingerprint_candidates`), Louvain
    communities (v1.8 `infrastructure_clusters`), and ecosystem
    hyperedges (v1.8 batch hypergraph). Outputs ranked candidates
    plus the projected impact on the corpus (Δ correlation depth,
    Δ entropy reduction, conflict rate against existing nodes if
    the candidate were accepted). Never writes to committed
    catalogs. `dry_run=True` is the only supported value at v2.1
    ship.
- **One CLI command:**
  - `recon run fingerprint-mining --seed=<domain> --iterations=N
    --dry-run` (alias `recon mine`). Uses the MCP client
    internally so agent and CLI behavior stay identical.
  - Default output is one-line-per-candidate ranked summary
    (rank, candidate suffix, count, projected Δ-correlation-
    depth). `--detail` flag surfaces the full per-candidate
    impact analysis; `--detail --json` emits the structured
    NDJSON for agent consumption. Avoids drowning the operator
    in metrics by default while keeping them one flag away.

**Projection method.** The Δ-metric claims are *empirical*, not
closed-form. For each candidate, the runner constructs a
hypothetical fingerprint (the candidate stanza), uses the
existing `test_hypothesis` MCP path to ephemerally inject it,
re-runs inference on the corpus snapshot, and diffs against the
baseline. No new math, no learned weights, just inference
re-runs over a hypothetical catalog.

**Mining-corpus / holdout-validation-corpus split (load-bearing).**
The most consequential design choice in v2.1 is that the corpus
mined from and the corpus the projected delta is evaluated against
*must not be the same set of domains*. Mining candidates from
corpus $C$ and then computing their projected delta on the same
$C$ is textbook data snooping: the runner systematically prefers
candidates that look good on $C$ because they were mined to look
good on $C$. The projected delta on $C$ does not generalise; any
calibration claim downstream of v2.1 mining (ECE, Brier, survival
ratios in future v1.9.x reports) would be compromised the moment
a mined candidate enters the committed catalog.

The discipline is explicit:

- The private corpus must be partitioned into
  `validation/corpus-private/mining/` and
  `validation/corpus-private/holdout/`. The partition is one-time,
  documented, and stable across releases. The current
  v1.9.4-hardened-adversarial 50-domain corpus and v1.9.0 91-domain
  soft corpus together form the partition input;
  `mining/` should be the larger split (roughly 100 domains) and
  `holdout/` the smaller (roughly 40 domains stratified to mirror
  `mining/`'s posture mix).
- `run_fingerprint_mining` mines candidates only from `mining/`.
  The MCP tool refuses to mine from `holdout/`; the runner's
  configuration explicitly disallows it.
- The projected delta for each candidate is computed *against the
  holdout corpus*, not against the mining corpus. This is the
  unbiased estimate of generalisation.
- Acceptance criterion: a candidate enters the committed catalog
  only if the holdout-corpus projected delta is **statistically
  significant**, not merely "within some fraction of the mining
  delta." A flat shrinkage factor (the original draft proposed
  60%) is arbitrary: a $0.06$-nat entropy reduction on a small
  holdout set might be driven by a single domain rather than
  genuine structural signal.
  - **Test:** paired permutation test on per-domain entropy
    reduction (or per-domain correlation-depth change) on the
    holdout corpus, comparing the inference run with the
    candidate injected against the baseline run without it.
    Null hypothesis: the candidate produces no per-domain
    improvement on average. Two-sided $p$-value computed via
    $10^4$ random sign-flip permutations of the per-domain
    difference vector. Reject the null at $p < 0.05$ for the
    candidate to pass.
  - **Effect-size guard:** the median per-domain entropy
    reduction on the holdout set must be at least one-quarter
    of the median on the mining set. This catches candidates
    that achieve nominal significance from a long tail of
    barely-improved domains without meaningful structural
    impact.
  - **Cross-validation:** if the holdout set is small enough
    that one domain dominates the test ($k$-domain leave-one-out
    instability above 20% on the permutation $p$-value), the
    candidate is held until the holdout corpus grows or the
    operator manually reviews the dominant domain's impact.
  - Candidates that fail any of the three tests are flagged as
    "may not generalise" and require additional review; they do
    not enter the committed catalog through the automatic path.
- After acceptance, the v1.9.x calibration reports that use
  `mining/` lose their unbiased-estimator status for any node
  affected by the newly accepted catalog change. The honest
  reporting move is to re-run all affected calibration on
  `holdout/` and publish *those* numbers as the post-v2.1
  authoritative figures.

Falsifiable: if the operator accepts a candidate and re-runs the
full corpus, the realized delta should match the projected
*holdout* delta to within a documented tolerance. If the realized
delta matches the mining delta but not the holdout delta, the
candidate was over-fit to the mining set and the discipline has
been violated; investigate.

This split is a precondition for v2.1, not a post-condition. The
partition lands in v1.9.10 (the stratified-corpus pre-lock
validation milestone, which already produces strata that can be
allocated to mining or holdout). v2.1 cannot ship before the split
exists; the alternative is shipping a runner whose every output
silently undermines the project's downstream calibration claims.

**Candidate schema (machine-readable).** Each candidate emitted
by the runner is a dict with these fields:

  - `pattern` (str) - the suffix or substring to match.
  - `tier` (`"application" | "infrastructure"`) - attribution
    precedence layer.
  - `suggested_slug` (str) - slug-shaped identifier proposed for
    the new fingerprint.
  - `count` (int) - how many distinct domains in the corpus
    showed this pattern.
  - `samples` (list of `{subdomain, terminal}`) - up to five
    representative chains for human review.
  - `projected_delta` (dict) - `{correlation_depth, entropy_reduction,
    conflict_rate}` from the empirical re-run.
  - `clue_source` (`"chain_motif" | "graph_community" | "hyperedge"`)
    - which already-shipped layer surfaced the candidate. Lets
    PR review trace each candidate back to a specific motif
    match, Louvain community ID, or hyperedge type rather than
    treating the runner as a black box. Carry the source ID in
    the YAML triage stanza so the provenance chain stays intact
    after merge.
  - `triage_yaml` (str) - pre-formatted YAML stanza ready for
    pasting into `recon_tool/data/fingerprints/surface.yaml`
    pending human review.

**Output contract.** Every run emits NDJSON to
`validation/runs-private/<stamp>/mining/` with three
artifacts: ranked candidates (per the schema above), projected
corpus-level metric deltas, and a triage-ready YAML diff that a
human can review and apply (or reject) by hand.

**Secondary v2.1 surface (only if the primary proves out):**

- `run_validation_suite(domains, metrics=[...])` - packages the
  existing corpus metrics into a reproducible call.
- `batch_posterior_query(domains, nodes=[...])` - parallel
  `get_posteriors` with aggregated stats.

These are wrappers over capabilities the MCP server already
ships. They land only after v2.1 mining itself ships and proves
useful - not preemptively.

**Invariants this preserves:**

- 100% passive - runner only calls existing public-signal tools.
- Data-file only - discovered candidates land in a review queue,
  never in a committed catalog. **`run_fingerprint_mining` ships
  with `dry_run=True` as the only supported value in v2.1.** Any
  future "auto-apply" mode requires its own invariant review and
  a separate release.
- No ML, no autonomous LLM agent inside recon. The "agent" in
  this design is the operator running the CLI or an MCP client;
  the runner is a deterministic coordinator over tools that
  already ship. If we ever want LLM-driven discovery, that's a
  separate invariant decision.
- No active probes, no internet crawling beyond what the
  underlying tools already do.

**Failure modes to avoid:**

- Letting the runner auto-edit `recon_tool/data/fingerprints/`.
  The whole auditability story collapses if recon edits its own
  rules in the dark. Human triage is non-negotiable.
- Calling it "agentic self-improvement" in marketing. We did not
  build a self-improving model; we built a *coordinator over
  existing skills* that helps a human curator move faster. The
  framing matters because it sets the right expectations.
- Adding fifteen new MCP skills as part of v2.1. The cap is one
  primary skill (`run_fingerprint_mining`) plus at most two
  secondaries that are wrappers, not new capabilities. Anything
  more is scope creep and breaks the v2.0 schema-lock contract
  we just wrote.
- Shipping before v2.0 schema lock + v1.9.2 agentic QA prove
  agents use the existing posteriors. v2.1 optimizes a surface;
  if the surface isn't useful, optimization is wasted.

**Why this is the right v2.1 move:**

- v2.1 is **the first release where recon's value compounds with
  use.** Every corpus run produces candidates; every accepted
  candidate increases correlation depth on the next run. The tool
  gets better the more it is used. v1.7 through v2.0 ship a
  static engine; v2.1 is where the engine starts learning from
  its own corpus exposure (with humans-in-the-loop, not
  autonomous fitting).
- It directly advances the north-star metric (multi-signal
  correlation depth) without new math, new network code, or new
  fingerprint surfaces.
- It uses what's already shipped - `discover_fingerprint_candidates`,
  `chain_lookup`, `test_hypothesis`, `get_posteriors` - and
  packages them into a feedback loop that measures its own
  impact.
- It is the natural composability move the priority order
  predicts after explainability is locked.
- It does not require v2.0 to be re-opened. It is purely
  additive on top of the locked v2.0 schema.

**v2.2 escalation path (sketch only).** Pure-`dry_run` removes
auto-edit risk but creates friction: every accepted candidate has
to be hand-pasted from the runner's YAML diff into the catalog.
Tedious tasks don't get done. v2.2 considers a
`--propose-pr` mode that opens a draft GitHub PR with the
candidate stanza added, requires human merge, and never auto-
merges. The audit trail moves from local YAML diff → reviewable
PR. Auto-merge is *never* shipped - that line is permanent. We
articulate the v2.2 path here so the v2.1 friction has a known
answer rather than an open question.

This sketch is **not committed.** The actual v2.1 plan gets
written after v2.0 ships and the agentic-QA findings from v1.9.2
inform whether the mining loop is what operators actually want or
whether some other composability primitive is more valuable.

### Backlog (after v2.0)

Items that are real but speculative enough to not commit a slot in the
plan above. Each remains gated by the same invariants and validation
discipline.

**Detection-gap framing & enumeration breadth** *(added 2026-05-11
following empirical validation pass against rich-stack public
companies):*

- **Passive-DNS ceiling phrasing in the panel.** When the default
  panel lists few services on a domain that public knowledge suggests
  uses many, surface a one-line acknowledgement: "Passive DNS surfaces
  X public services; server-side API consumption, internal workloads,
  and SaaS without DNS verification are not observable from public
  DNS alone." Prevents "absence of finding = service not present"
  reading. The architectural Category-1 limit becomes visible without
  needing the operator to know the invariants. Backlog because the
  trigger heuristic ("when does the panel hint at the ceiling?") is
  policy and deserves explicit design.
- **Subdomain enumeration breadth.** Today's related-domain discovery
  is CT-driven + a fixed common-prefix probe. A customer with
  `data-pipeline.example.com → GCP` whose subdomain doesn't appear in
  CT and isn't a common prefix is invisible. Expand: pull SAN sets
  from ALL observed apex certs (not just the queried apex), longer
  common-prefix wordlist (security, ops, internal, ml, ai, data,
  etc.), and a CT search by org name when one is available from a
  prior lookup. None violates passive-only. Backlog because the
  optimal wordlist + CT-query plan needs corpus-driven calibration.
- **Stratified-corpus validation as standing practice.** Single
  private corpus has bias; stratified samples (known GCP-customer set,
  known Azure-customer set, etc.) surface the bias by design. Process
  change in `validation/` - not code. Backlog because the per-cloud
  10-domain reference sets need curation; vendor case-studies are the
  starting input.
- **Cloud-provider rollup at the apex level.** When subdomains span
  multiple clouds (apex on Cloudflare, subdomains across AWS + Fastly
  + Stripe), the v1.9.3.10 "Subdomain" line shows counts. A natural
  extension is a top-of-panel `Multi-cloud` indicator: "5 cloud
  providers observed across the surface". Backlog because the
  threshold for "multi-cloud" is policy.

**Additive feature candidates** *(moved from the former
"v1.9.x optional feature additions" section when the roadmap
restructured to a flat sequence; any of these may be promoted into
a post-v2.0 v2.x.y patch when there's a falsifiable defensive case):*

- **BIMI VMC legal-name clustering** - pairs with the v1.8
  hypergraph view; demonstrates real multi-brand identification on
  a private corpus.
- **MCP delta helper** - `recon_delta(domain_or_json_a,
  domain_or_json_b)` MCP tool. Compares supplied or cached JSON
  only; no hidden network. Optional `include_fusion` flag surfaces
  v1.9 posterior shifts alongside the deterministic diff.
- **Portfolio / self-audit batch mode** - `recon batch --self-audit`
  aggregating vertical-baseline hits, anomaly rules, correlation-
  depth distribution, and gateway / sovereignty consistency across
  many domains in one summary. A lightweight agent-side precursor
  ships today in `AGENTS.md` / `SKILL.md` under the
  "Family-of-companies / portfolio rollup" workflow - agents
  synthesize the rollup from per-domain JSON returned by
  `recon batch --json --include-ecosystem`. Promoting to Python
  gives deterministic, testable output emitted as schema fields;
  worth doing once the agent-side rollup has validated the report
  shape on a private corpus. Operator-supplied apex list in both
  versions; recon never infers the corporate-family relationship.
- **Non-MCP graph exports** - Mermaid diagram output for the v1.8
  cluster graph, plus CSV exports for relationship metadata and
  chain motifs.
- **Per-node `n_eff_multiplier` in `bayesian_network.yaml`** -
  schema-additive field that scales effective sample size on a
  per-node basis. Lets weak-calibration nodes widen their credible
  intervals without globally widening every node. May be promoted
  into a pre-v2.0 patch if v1.9.4 hardened-adversarial findings +
  the existing sensitivity test surface nodes whose interval shape
  only makes sense with per-node scaling.
- **Per-binding conflict penalty (localize the global `n_eff`
  conflict deduction).** Today's
  `_CONFLICT_N_EFF_PENALTY = 1.5` in `recon_tool/bayesian.py`
  applies globally: any cross-source conflict in the merged
  `TenantInfo` widens every node's credible interval, not only the
  nodes whose bindings overlap the conflicting field. The behavior
  is conservative (never under-reports uncertainty) but coarser
  than ideal - a conflict over `display_name` should not widen the
  posterior on `aws_hosting`. The localized form would thread per-
  node conflict overlap so the penalty only fires on nodes whose
  evidence set actually depends on the conflicted field, sharpening
  the layer's output on messy real-world inputs without weakening
  the calibration guarantee. The limitation is already called out
  in `docs/correlation.md` (§ "We acknowledge two open issues") so
  the design rationale and acceptance shape are already documented;
  the missing piece is the implementation plus a falsifiable
  regression test that the global-penalty behavior is preserved as
  the conservative floor when the per-binding analysis returns no
  overlap. Candidate for v2.1 alongside the operator-driven catalog
  growth if a corpus run surfaces nodes whose widening is dominated
  by unrelated conflicts; otherwise v2.2.
- **Corpus-driven Hypothesis tests** - extend
  `tests/test_bayesian_hypothesis.py` with property tests over real
  corpus output. Strengthens the test floor.
- **Hawkes-kernel CT burst classification** - fit a one-parameter
  exponential-decay kernel to each cluster's CT timestamps and
  classify `automated_renewal` vs `manual_deployment`. Surface as
  `cert_summary.deployment_bursts[].kernel_class`.
- **Asynchronous Label Propagation fallback for `infra_graph`** -
  pure-Python LPA replaces the connected-components fallback above
  the 500-node Louvain cap, keeping community structure visible on
  10k+-node graphs.
- **Explicit ignorance mass (epistemic vs aleatoric)** - Dempster-
  Shafer-style mass on the "don't know" state, computed from the
  ratio of unbound to bound evidence nodes for that posterior;
  surfaces in `--explain-dag` as a third quantity alongside
  posterior and interval.
- **Noisy-OR / noisy-AND CPT gates** - schema-additive
  `gate: noisy_or | noisy_and | custom` on multi-parent CPTs in
  `bayesian_network.yaml`. Compact and human-reviewable as the
  network grows beyond ~10 nodes.
- **Public `Factor` / `Node` / `Evidence` API surface plus
  canonical-textbook-example test.** Today the variable-elimination
  primitives in `recon_tool/bayesian.py` (`_Factor` as
  `dict[Assignment, float]`, `_multiply`, `_sum_out`,
  `_query_marginal`) are correct and idiomatic but private; the
  underscored types leak through some public type hints. Promote
  them to a documented public surface with `__all__` discipline
  and add `tests/test_bayesian_ve.py` reproducing the canonical
  Burglary-Earthquake-Alarm joint and marginals from Koller &
  Friedman (2009), *Probabilistic Graphical Models*, Table 9.1
  / §9.3. Lets the project cite "exact inference verified against
  a canonical reference" as a hard claim rather than a docstring
  assertion. Schema-additive; no behavior change.
- **Sensitivity-analysis tooling for CPT entries.**
  `scripts/sensitivity_analysis.py` runs N Monte-Carlo
  perturbations (default ±10% uniform) over every CPT entry and
  every prior in `bayesian_network.yaml`, reports per-node
  posterior-shift histograms and max entropy change, and flags
  fragile edges where small CPT changes produce disproportionate
  posterior swings. Optional `--bayesian-sensitivity` CLI flag
  surfaces the analysis on-demand; runs in under a second at the
  current 9-node scale. Pairs with the v1.9.6 CPT-change
  discipline: tells a contributor *which* edges are worth
  questioning before they consider tuning, and gives the reviewer
  a falsifiable basis to accept or reject a proposed CPT change.
- **Mutual-information surface in `--explain-dag`.**
  `BayesianNetwork.mutual_info(node: str, evidence: EvidenceSet)
  -> float` computes the exact information-theoretic reduction
  $I(\text{node}; O)$ contributed by each piece of observed
  evidence, derived from the joint via variable elimination.
  Feasible at the current 9-node scale (the joint has at most
  $2^9 = 512$ assignments). Surfaces in `--explain-dag` JSON as
  a per-evidence `mutual_information` field alongside the existing
  LLR contributions. Pure information-theoretic derivation, not
  parameter learning, so it stays inside the invariants.
- **`bayesian.py` calibration-constants moved to YAML.**
  Today `_EVIDENCE_N_EFF_CONTRIB`, `_CONFLICT_N_EFF_PENALTY`, and
  `_MIN_N_EFF` are module-level constants in the engine.
  Introduce a top-level `calibration:` block in
  `bayesian_network.yaml` with documented defaults; loader reads
  the block on `load_network()` with the current constants as
  fallbacks. Aligns with the roadmap's "engine code grows only
  when the data file alone cannot express the rule" discipline.
  Schema-additive, backwards-compatible. Small enough to ship as
  a "Good First Roadmap Items" PR, listed there as well.
- **Scaling exact inference past treewidth handling: compile to
  tractable probabilistic circuits (post-v2.0 candidate).** The
  current variable-elimination engine handles the 9-node v1.9.3+
  topology comfortably (treewidth $w = 3$, time complexity
  $O(n \cdot d^{w+1}) \approx 144$ operations per query). If a
  future schema change drives the network past roughly 20 nodes
  or treewidth above 5, the principled engineering refactor is
  to compile the Bayesian network into a **tractable probabilistic
  circuit** (Sum-Product Network, arithmetic circuit, or
  Probabilistic Sentential Decision Diagram). The unified
  modern treatment is
  [Choi, Vergari, and Van den Broeck 2020, "Probabilistic Circuits:
  A Unifying Framework for Tractable Probabilistic Models" (JMLR
  submission / UCLA Tech Report)](http://starai.cs.ucla.edu/papers/ProbCirc20.pdf).
  Once compiled, exact marginal inference takes $O(|C|)$ time in
  the size of the circuit, **bypassing the treewidth bottleneck
  of variable elimination entirely**.

  An adjacent compilation path,
  [Darwiche 2020, "An Advance on Variable Elimination with
  Applications to Tensor-Based Computation"](https://arxiv.org/abs/2002.09320),
  maps functional-CPT BNs to dense tensor-graph operations
  (reshape, transpose, MATMUL) exploitable via SIMD. Both
  approaches are post-v2.0 candidates; the right one depends on
  which compilation cost the network actually hits first.

  Explicit constraints either way:
  - **Not a path to `pgmpy`, `pomegranate`, or any
    probabilistic-programming runtime.** Those cross the
    pure-Python dependency floor. The compilation target stays
    inside pure Python (or pure-Python plus a small numerical
    backend without the heavyweight inference framework).
  - **Not a path to learned parameters.** CPTs stay
    human-authored YAML data files; the compilation is a
    build-time transform from committed data files to efficient
    runtime factor or circuit representations.

  Worth doing only if the network actually outgrows the current
  inference path. The right trigger is "we want to add a node
  whose CPT shape pushes treewidth past 5", not "this paper is
  interesting." Until that trigger fires the current engine is
  the right engine.

  **This is an architectural pivot, not a backend swap.** The
  current variable-elimination engine processes evidence
  dynamically at query time. Tractable probabilistic circuits
  require a compilation phase that runs whenever the topology
  or any CPT parameter changes:

  - **Build-time compilation pipeline.** Recon would need a
    deterministic compiler that maps
    `recon_tool/data/bayesian_network.yaml` to a circuit
    representation, plus a packaging step that ships the
    compiled circuit alongside the YAML in the wheel. The CI
    pipeline grows a compile-and-verify step that ensures the
    committed circuit is byte-identical to a fresh compile of
    the committed YAML.
  - **Per-evidence-set re-evaluation cost.** Circuit inference
    is $O(|C|)$ in circuit edge count for a given evidence set;
    different evidence sets traverse different sub-circuits.
    Recon would need to verify that the empirical evidence
    distribution from real domains does not pathologically miss
    cached circuit paths, which would erode the $O(|C|)$
    benefit.
  - **Stacking Generalized Bayesian Inference or IDM on
    compiled circuits is an open research problem.** The
    correlation.md §4.8.4 framework adopts Generalized Bayes
    for the conflict-penalty term and notes IDM as the
    second-order-uncertainty upgrade path. Both modifications
    are straightforward to apply post-hoc to a VE marginal
    posterior. Applying them to a compiled circuit requires
    deciding *where* in the compilation pipeline the
    loss-calibrated update or interval-valued parameter enters,
    and the literature does not have a settled answer. Adopting
    circuits without resolving this question would lock recon
    into either a calibration regression (drop GBI / IDM) or a
    research project (invent the integration).

  The conclusion: a future move to tractable circuits is a
  fundamental architectural pivot that requires its own
  milestone, not a drop-in optimisation. Keep the current
  engine until the treewidth trigger fires *and* the
  compilation pipeline plus calibration-integration story are
  designed.

- **CPM-based modularity for the graph-correlation layer
  (post-v2.0 candidate).** The current §4.5 Louvain implementation
  maximises standard modularity, which has a proven resolution
  limit ([Fortunato and Barthélemy 2007](https://doi.org/10.1073/pnas.0605965104)):
  communities smaller than $\sqrt{2m}$ edges cannot be detected
  reliably. The principled refinement is the Constant Potts
  Model (CPM) objective from
  [Traag, Waltman, and van Eck 2019](https://doi.org/10.1038/s41598-019-41695-z),
  which replaces the global-edge-weight null model with a tunable
  resolution parameter $\gamma$. CPM is implementable in pure
  Python over `networkx`; does not require the `leidenalg` C
  extension. Surface as `--graph-resolution <gamma>` with a sane
  default tuned against the v1.8 ecosystem corpus. Justified
  whenever the global-CT-ecosystem density on a target apex
  makes the small-community resolution failure a real defect on
  measured data (currently a theoretical concern; v2.0+ corpus
  runs would confirm or refute).

- **Aggregated calibration report HTML
  (`scripts/calibration_report.py` plus
  `docs/calibration_report.html`).** Consolidates the per-release
  calibration narratives (`validation/v1.9-validation-summary.md`,
  `validation/v1.9.4-calibration.md`,
  `validation/v1.9.5-stability.md`,
  `validation/v1.9.6-stability-update.md`, and future analogues)
  into a single readable HTML artifact: headline metrics per
  release, per-node trend tables, sparse-rate histograms, and the
  Brier / log-score / ECE diagnostics from each report. CI
  regenerates on each release tag. Decoration on top of existing
  validation work; ships nothing new mathematically. Useful as
  the citable single-page artifact when sharing recon's
  calibration story externally (the validation `.md` files are
  the source of truth; the HTML is the rollup).

- **Generate `docs/recon-schema.json` from code rather than
  maintaining it manually.** The schema currently lives as a
  hand-maintained JSON file; drift between code and schema is a
  recurring failure mode caught only by
  `tests/test_json_schema_file.py`. Adding a `scripts/generate_schema.py`
  that derives the schema from the `TenantInfo` dataclass plus
  field metadata, then runs in CI to verify the generated schema
  matches the committed one, eliminates the drift class entirely.
  Stays inside the pure-Python floor (the generator runs over
  Python typing introspection, no JSON-Schema framework
  required). Pairs with the v2.0 schema-stability test that
  fails any committed schema change without a major version bump.

**Pre-existing backlog:**

- CT organization-name search (opt-in, exact-match only).
- Wayback Machine temporal enrichment (new public network surface; opt-in).
- Deeper hardening simulation UX (high overreach risk).
- Anomaly detection on issuer-mix changes over time.
- Per-domain inference cache for batch-mode reruns.
- **`--mine-motifs` mode for `run_fingerprint_mining` (v2.2+).**
  Specialised runner mode that proposes new entries for
  `motifs.yaml` (or a sibling `community_motifs.yaml`) by
  clustering recurring chain patterns inside Louvain communities.
  Output includes representative samples and projected
  multi-signal depth gain on the corpus. Same dry-run + human-
  triage discipline as v2.1 mining; never auto-edits the catalog.
  Worth doing only if v2.1 corpus runs show that
  community-scoped motif candidates consistently outscore single-
  domain motif candidates on the north-star metric. The feature
  is additive on top of v2.1's `clue_source: "graph_community"`
  branch and reuses the same projection pipeline.
- **Imprecise Dirichlet Model (Walley 1991) for CPT entries.**
  Replace point-CPT values with intervals derived from
  bounded-prior Dirichlet samples. Yields second-order
  uncertainty on the parameters themselves rather than only on
  the posterior. The lightweight v1.9.x optional ("explicit
  ignorance mass") ships the user-facing epistemic-vs-aleatoric
  distinction first; this entry is the deeper refactor that
  would replace point-CPTs entirely once the lightweight
  version proves operators actually consume the ignorance
  signal. Cited in `correlation.md` §4.8.4 as prior art.

  **This is not a drop-in refactor of `bayesian.py`.** Adopting
  IDM converts the Bayesian network into a **Credal Network**
  (a Bayesian network whose CPTs are interval-valued). Exact
  inference on credal networks is **NP-hard even on trees**
  (treewidth $w = 1$); the standard reference is
  [de Cooman, Hermans, Antonucci, and Zaffalon 2010,
  "Epistemic irrelevance in credal nets" (Int. J. Approximate
  Reasoning)](https://doi.org/10.1016/j.ijar.2010.07.005). The
  practical adoption paths are:

  1. **Accept approximate credal inference.** Use algorithms
     like 2U / GL2P, or the k-reduction technique benchmarked
     in [CREPO (Antonucci et al. 2021)](https://arxiv.org/abs/2105.04158),
     trading exactness for tractability. The pure-Python
     dependency floor admits this but adds substantial
     algorithmic complexity to `bayesian.py`.
  2. **Stick with the post-hoc $n_{\mathrm{eff}}$ widening.**
     The correlation.md §4.8.4 framework now justifies the
     current heuristic as a tractable approximation of
     Generalized Bayesian Inference (Bissiri, Holmes, and
     Walker 2016) with an engineered conflict-penalty loss
     plus a moment-matching Beta wrap. This is formally
     coherent for any bounded loss; it is "not IDM" but it is
     not unprincipled either.

  Path 2 is the current state and the default. Path 1 is worth
  the cost only if v2.0+ corpus runs show that marginal-only
  interval widening systematically misses calibration
  pathologies that interval-valued CPTs would catch. Until that
  evidence materialises, the current engine is the right
  engine, and the IDM citation in correlation.md is honest
  prior-art acknowledgement rather than a deferred refactor
  commitment.
- **Operator-tuned likelihoods as committed data files.** Allow
  operators to supply per-node likelihood overrides via
  `~/.recon/likelihoods.yaml` analogous to the existing priors
  override. Crosses into v1.9.6's "no automated CPT fitting"
  invariant only if a script auto-derives the file; manual
  operator-side tuning with explicit reasoning is fine. Decide
  the discipline before shipping.
- **Cross-vertical generalization study.** The v1.9.0 corpus
  skews enterprise. A future calibration pass on
  consumer-facing / niche-SaaS targets would tell us whether the
  network's prior assumptions transfer; ECE on that subset is
  the metric.
- **Machine-readable CLI surface inventory for downstream skill
  and agent authors.** Two small additions captured here from
  feedback on the v1.9.6 release:
  1. A "Tool surface changes" one-liner per release in the
     CHANGELOG, focused on the user-visible CLI surface (new
     subcommands, new flags). Lets skill maintainers do a
     30-second sync check between releases without diffing
     `recon --help` output. The current CHANGELOG mixes
     surface changes into the narrative; a single-line
     callout per release would be more scannable.
  2. A `docs/cli-surface.md` (or `recon --help-extended`)
     canonical reference: every flag and subcommand with a
     one-line description, suitable for copy-paste into
     skill files or AI agent prompts. Today the info is
     split across README excerpts, `recon --help`, and
     `docs/mcp.md`. A consolidated reference removes that
     scatter.

  Explicitly out of scope for this entry: Claude-skill-
  specific or agent-behavior guidance in the repo. That
  layer lives in the per-client skill files under `agents/`
  (and the portable `AGENTS.md` at the repo root). The
  recon repo's job here is to be the source of truth for
  the tool's surface, machine-readable; the skill's job is
  to translate that surface into agent behavior.

## Good First Roadmap Items

Narrow, useful, aligned with the product shape. Each one is a single PR
with a corpus-delta or audit note. None of these block a release; they
are picked up alongside the build plan above.

- Convert one ambiguous fingerprint to `match_mode: all`, with before/after
  evidence and a regression domain in local validation notes.
- Add one high-confidence fingerprint for a service that publishes a stable
  verification TXT, MX, CNAME, NS, SRV, or CAA pattern.
- Enrich 3-5 fingerprints in an under-described category with descriptions,
  public references where available, and tighter patterns.
- Improve a sparse-result explanation for a known weak area without adding new
  network calls.
- Add a short weak-area note when a fingerprint has common legitimate
  false-negative configurations.
- Add schema or docs examples that make existing behavior easier to consume in
  automation.
- Improve MCP resource consumption examples for `recon://fingerprints`,
  `recon://signals`, or `recon://profiles`; do not add network behavior.
- Add tests around parser, cache, MCP, or formatter edge cases discovered from
  real validation output.
- Add one entry to a new `motifs.yaml` describing a recurring CNAME or NS
  chain pattern (e.g., `cloudflare → akamai → custom-origin`) with a
  before/after corpus delta showing the pattern actually fires.
- Add one vertical-baseline absence rule in `verticals.yaml` with a clear
  passive-ceiling note (e.g., "fintech profile expects WAF motif; absence
  is observable, not a verdict").
- Implement one Bayesian CPT in a draft `bayesian_network.yaml` covering
  email security + M365 federation, with the schema marked EXPERIMENTAL and
  the output gated behind the existing `--fusion` flag.
- Move the n_eff calibration constants
  (`_EVIDENCE_N_EFF_CONTRIB`, `_CONFLICT_N_EFF_PENALTY`,
  `_MIN_N_EFF`) from `recon_tool/bayesian.py` into a top-level
  `calibration:` block in `bayesian_network.yaml`. Loader reads
  the block on startup with the current values as fallbacks.
  Single PR, no behavior change at default values, no version
  bump unless the loader needs schema-version handling. See
  the corresponding Backlog entry for the full motivation.

## Opportunistic Refactoring

`formatter.py`, `server.py`, and `cli.py` carry disproportionate maintenance
burden. Split them when a feature or bug fix naturally opens the seam; do not
create a standalone refactor milestone without behavioral payoff.

## Intentionally Out Of Scope

**Hard no:** active scanning, paid APIs, credentialed access, bundled ML or
embedding weights, bundled ASN/GeoIP datasets, aggregate local databases,
user-code plugins, remote/HTTP MCP transport. The Bayesian and graph
extensions stay inside the box because they ship as data-file CPTs and
algorithms over already-collected observables - never as learned weights or
imported intelligence.

**Statistical methods we deliberately don't use.** External reviews regularly
propose techniques that would cross the invariants. Listed once here so
contributors can see what's off-limits and why, without re-litigating each
proposal:

- *Automated parameter-fitting pipelines* (EM, Snorkel,
  weak-supervision, gradient descent on CPT entries, or any
  `scripts/learn_cpts.py`-style script that auto-emits CPT values
  into committed YAML). Output is opaque in the sense that
  matters: a reader of the committed YAML cannot reconstruct what
  observed counts produced what posterior values without re-running
  the pipeline. This is the "no learned weights" invariant in its
  specific form. v1.9.6 codifies the discipline; the v1.9.6
  refinement distinguishes this from *transparent Bayesian
  Dirichlet posterior updates* (allowed, with publication
  discipline), which are exact probability theory rather than
  opaque automation. The bright line is auditability from
  committed artifacts: an automated pipeline that writes CPT
  values without publishing the prior, the counts, and the
  derivation is banned; a manual Bayesian update with all three
  published in the YAML comment and a validation report is
  acceptable.
- *ML structure learning that auto-applies* (PC algorithm or FCI run as part
  of a build pipeline). Constraint-based causal-discovery output as an
  *operator-facing proposal tool* - a human reads the candidate edges and
  decides whether to add them to YAML - is acceptable; the auto-apply step
  is what crosses the invariant.
- *Cross-organization hierarchical models that share evidence between domains
  the operator did not look up together.* Crosses "no aggregate local
  intelligence database." Within-batch sharing (e.g. v1.8 ecosystem
  hypergraph) is fine because the operator chose the batch; persistent
  cross-run state is not.
- *Bundled scientific-Python stack* (numpy, scipy, pandas, scikit-learn,
  pgmpy, pomegranate, PyMC, Stan, Pyro). Crosses the pure-Python dependency
  floor. Pure-Python implementations of specific techniques are fine when
  the technique is genuinely worth it (e.g. LPA, Hawkes-kernel fitting via
  closed-form MLE).
- *Loopy belief propagation, MCMC, particle filtering as the default
  inference engine.* Variable elimination is exact and fast at current
  network size; the ~20-25-node ceiling is a known scaling boundary, not a
  bug. Approximate inference imports complexity that current scale doesn't
  justify.
- *Tractable-circuit compilation* (SPNs, arithmetic circuits) as a v1.x
  upgrade. Real technique, real scaling story - but solves a problem we
  don't yet have. Noted as a known option for post-v2.0 if the CPT space
  grows past what VE handles.
- *Replacing rule-based signals with a single unified PGM.* Misreads the
  layering: slugs are the evidence layer, the Bayesian network is the
  inference layer, signals are the *presentation* layer (operator-facing
  views over slug evidence). Each abstraction is intentionally addressable
  on its own - collapsing them into one model would lose the audit surface
  the project's defensive posture depends on. See
  [correlation.md § Vocabulary](correlation.md#12-vocabulary).
- *LLM-driven coherence-graph construction in the inference path*
  ([Huntsman 2025](https://arxiv.org/abs/2509.18520)). Uses
  large language models to build weighted coherence graphs from
  propositions, then runs max-cut over the graph. A real
  technique with a real published cybersecurity-application
  story, but recon's invariant is that the inference path is
  deterministic and auditable end-to-end. LLMs are valid as
  *consumers* of recon output (the MCP integration explicitly
  supports that) and as *catalog-construction aids* (a human
  reviews LLM-suggested fingerprints before they enter
  `data/fingerprints/`). LLMs in the inference path itself
  would erase the audit surface; reject.
- *Adversarial risk analysis influence diagrams for
  defender-attacker games* ([Wang and Neil 2021](https://arxiv.org/abs/2106.00471)).
  Hybrid Bayesian networks with utility nodes, backward
  induction for optimal defenses, dynamic observation updates.
  Different problem: ARA computes optimal *responses* against
  modelled attackers; recon reports *what the public channel
  reveals* without modelling adversaries. Adopting ARA would
  force a threat-model commitment recon deliberately avoids,
  and would push the tool from "describe observable structure"
  into "prescribe defensive action", which is downstream of
  recon by design.
- *Belief propagation seeded by external reputation labels*
  ([cGraph, Daluwatta et al. 2022](https://arxiv.org/abs/2202.07883)).
  Uses VirusTotal labels and Alexa rankings as seeds, then
  propagates maliciousness scores over a passive-DNS graph.
  Architecturally parallel to recon's §4.5 graph layer but
  produces maliciousness *verdicts* recon explicitly does not
  produce (no shared reputation database, no external label
  feed, no operator-judgment claim). See
  [correlation.md §3.5](correlation.md#35-ct-co-occurrence-graph--louvain-community-detection)
  for the explicit scope boundary.
- *Workload-aware materialization of junction trees*
  ([Kanagal and Deshpande 2010 / 2110.03475](https://arxiv.org/abs/2110.03475)).
  Query-specific precomputation of shortcut potentials for
  probabilistic-database workloads. Premature optimization at
  9 nodes where inference runs in under a millisecond. Worth
  reconsidering only if the network grows past tens of nodes
  *and* recon develops a query-specific workload pattern; both
  conditions are speculative.
- *Region-based / hybrid exact-Gibbs / recursive-conditioning
  approximations*
  ([Yedidia et al. 2005; Hugin 1302.4968; Darwiche 2001](https://arxiv.org/abs/1302.4968)).
  All speculative for the current scale. The current pure-VE
  engine is exact, fast, and small. Listed here so a reviewer
  who suggests one of these techniques sees the project's
  considered rejection rather than re-litigating.

**Not this tool:** company research, firmographic enrichment, news/funding
feeds, hiring signals, GTM briefings, contact data, maturity scores, HTML
dashboards, TUI, REPL, daemon mode, scheduled monitoring, STIX/Maltego/MISP
exports, Prometheus metrics, Docker image, Homebrew tap, PDF reports.

**Distribution we don't ship:** static binaries (PyInstaller / shiv /
PEX), self-contained installers, OS-native packages (.deb / .rpm /
MSI), Homebrew tap. recon ships as a Python wheel on PyPI; that's
the contract. Static-binary distribution adds signing,
notarization, per-OS verification, and reproducible-build
overhead disproportionate to the audience size. Operators who
need a containerised recon can run it under `uv run` or any
ephemeral Python sandbox; we do not gain from owning that
distribution surface.

Use `--json` (or `--ndjson` for big batches) as the integration surface. If
you need rendered graphs, reports, SIEM ingestion, or company research, pipe
recon output into a tool built for that job.

## Design Choices That Stay

- No confident "maturity" or "zero-trust" verdicts on sparse data.
- No generic subdomain service-name matching such as `grafana.*` or `n8n.*`.
- No ownership or acquisition verdicts from shared tokens or branding.
- No posture or insights layer may trigger a new network call.
- Delta mode reports raw changes; users decide what story, if any, those
  changes imply.
- Correlation extensions describe **observed structure**, not ownership.
  "Co-issued within 60s" is observable; "same owner" is not. Cluster output
  surfaces modularity scores and edge evidence, never verdicts. Bayesian
  layer surfaces credible intervals and the CPT that produced them, never
  point scores without provenance.
- **The `EXPERIMENTAL` label on the `--fusion` Bayesian surface
  is held through v1.9.x and dropped at v2.0 by design.** This is
  a release-engineering posture, not a calibration claim. The
  v1.9.4 hardened-adversarial corpus, v1.9.5 per-node stability
  dispositions, and v1.9.6 topology refinement have all done
  their validation work; the label persists because v2.0 is the
  release that commits to the wire-format and per-field
  stability story, and that is the right moment to drop the
  label, not earlier. External reviewers will sometimes flag
  the label as stale and recommend renaming it to "SHIPPED" or
  "calibration-gated" or similar in v1.9.x. The label stays
  until v2.0 cuts. The `correlation.md` §4.8 header carries a
  "Label vs. status" paragraph that explains the distinction;
  that paragraph is the right place for an interested reader to
  understand the current validation state.

## Implementation discipline for new correlation work

Any item promoted from "Ideas Worth Prototyping" into shipped behavior must:

1. Land first as a YAML schema extension (signals, fingerprints, motifs, or
   a new sibling file). Engine code grows only when the data file alone
   cannot express the rule.
2. Carry a live-corpus before/after delta in the PR description, run on
   the private validation corpus with the discovery loop tooling
   (`validation/scan.py`, `find_gaps.py`, `triage_candidates.py`).
3. Document the sparse-result behavior - when this rule does NOT fire and
   why - alongside the positive case. Output language must remain hedged
   under sparse evidence even when the new feature surfaces nothing.
4. Update `docs/recon-schema.json` and the schema drift test if the JSON
   shape changes. Mark experimental fields explicitly; the v1.0 contract
   stays narrow and stable.
5. Stay deterministic when possible. Where probabilistic output is
   appropriate (Bayesian layer), gate behind an existing flag, mark
   EXPERIMENTAL, and never destabilize the default panel.
