2026-09-05 Validation pass: four refusal classes
**Later the same day — ranking rule.** research/cohorts.py:cna_statistics now orders eligible publishers by the Wilson 95% LOWER bound (ties by point rate, then name), which is the rule /cna states; it had ordered by point rate, so the page and /api/v1/cna published different #1s. The fields written from that order — years[].cnas[].rank, publishers[].rank, overlapsNext, and cnaRanking.distinguishableThrough on each year and on the headline — are now the single source. Year=all #1 before: CERT-PL (rate 0.0356, lower bound 0.0181); after: microsoft (rate 0.0274, lower bound 0.0232), CERT-PL #2. Per-year #1s are unchanged (microsoft 2023/2024/2026, CERT-PL 2025); distinguishableThrough is unchanged everywhere (0 for year=all and 2025, 1 for the other years). No finding row or class moved. Also in this pass: description_already_stated on an open-start range now considers only HEAD boundaries the publication did not already state, preferring the stated line, so an unchanged sibling line the prose named on day one is not evidence about a line that later moved (negative test beside the CVE-2025-31242 case); 0 of the 1,295 receipts changed. research/euvd_retention.py now catches http.client.HTTPException (IncompleteRead, BadStatusLine) as a failed receipt instead of a traceback.
Mirror heads unchanged from the previous entry (cvelistV5 2f0a1513d3a7, kev-data ac9d37166471, advisory-database 8c29a441a784), so every figure below is the method and nothing else. Motivation: the seeded sample research/out/validation/2026-09-05.json (seed 2026-09-05, 30 counted fix_version_moved rows read back from git) found 4 hard false positives of 30, rate 0.1333, Wilson 95% [0.0531, 0.2968]; a second read-back of 6 counted advisory rows (2026-09-05-part2-hand-check.json) found 2 of 6. Each false positive was traced to a shape, the shape was confirmed against git for every counted row that carried it, and each is now a named class with a positive and a negative test (research/test_amendments.py, research/test_ghsa.py).
Headline before -> after: fix_version_moved 235 records / 375 rows of 53,368 stating a fix (0.44%, median 28.1 d) -> 228 records / 365 rows of 53,368 (0.43%, median 28.0 d); 275 -> 269 events. affected_range_extended 1,073 records / 1,155 rows of 39,099 (2.74%, median 342.0 d) -> 1,074 / 1,156 of 39,099 (2.75%, median 340.4 d). advisory_fix_moved 141 advisories / 163 rows -> 131 advisories / 151 rows (median 11.6 d -> 13.0 d over 124 -> 112 dated rows). No other kind moved.
The classes and keying rules
**boundary_swapped_between_products** (6 rows quarantined, 4 of them previously counted: CVE-2025-49717/49718/49719 SQL Server 2022 CU 19 16.0.1140.6 -> 16.0.4200.1 at 1c5d3e7ae299, CVE-2023-28304 OLE DB Driver 18 18.2.1.1 -> 18.6.5 at 2366c3496fa). The after-value was another product's boundary in the same record at publication and that product now states the before-value: the commit exchanged two values between two product entries. Both halves are required; two products converging on one new build stay counted (research/amendments.py:_swapped_with).
**description_already_stated, open-start variant** (CVE-2025-31242 macOS 13.7 -> 13.7.3 and 14.7 -> 14.7.3, 2 rows). The publication description said "fixed in macOS Ventura 13.7.6"; commit 59a4b3b0acc2 regenerated the entry to < 13.7.3 and < 13.7.6. The matcher paired 13.7 with 13.7.3, which the prose never named. For an open-start range the class now tests every HEAD boundary of the product above the stated one (_described_successor). A description naming a value the record does not state at HEAD is still no evidence.
**changes[] fixes keyed by their own release line** (CVE-2025-6203 Vault Enterprise). 0 < 1.20.2 with changes[] unaffected at 1.19.9, 1.18.14 and 1.16.25 states four branches. Keyed by the parent's "0", each changes[] fix was paired with the open range's lessThan 1.21.2 and three cross-branch rows were counted; keyed by their own major.minor the record's real moves are 1.16.25 -> 1.16.27, 1.19.9 -> 1.19.11 and 1.20.2 -> 1.20.5, all counted, and the vanished 1.18 line yields nothing (3 rows out, 3 in). The same keying surfaced three moves the old pairing had refused as operator_switched (CVE-2024-34383 seopress 7.7 -> 7.7.2, CVE-2024-35684 elasticpress 5.1.1 -> 5.1.2, CVE-2024-54254 1.6.3 -> 1.6.3.1: a changes[] fix previously paired with the ceiling on the same value), and dropped three whose publication fix is still stated on a keyed branch at HEAD (CVE-2024-6156 lxd 5.21.2 -> 6.1, now other_branch_advanced; CVE-2024-7322 EmberZNet 7.4.4 -> 8.1.0) or was withdrawn rather than raised (CVE-2024-22162). A still-stated open boundary is now recognised on any range of the product, not only an open one (_verbatim). 85 operator_switched receipts on the fix_version_moved kind (180 -> 95) were this pairing of a changes[] fix with the ceiling at the same value; none was ever counted. On affected_range_extended, 33 counted rows that paired a raised ceiling with the changes[] fix now pair it with the raised ceiling (same count, the after-value is now the last-affected version, which resolves the "ambiguous" mapsvg row of the 2026-09-05 sample); and a ceiling raised to the release a HEAD fix names (<= 1.4.1 -> <= 1.5.0 beside unaffected at 1.5.0, gavias Krowd CVE-2025-32595) stays operator_switched (_ceiling_is_the_fix). One extension row is new (CVE-2024-7262 WPS Office <= 12.2.0.13489 -> < 12.2.0.16412, previously hidden behind a since-lowered changes[] fix).
**One row per identical (vendor, product, kind, before, after) move** (CVE-2026-68480 Linux 6.6.149 -> 6.6.150 stated as both an open lessThan and a keyed unaffected start; CVE-2024-8404 PaperCut 23.0.9 -> 24.1.7 as a lessThan and a changes[] entry; 2 rows). The previous rule collapsed only the Linux lt/unaffected twin. Two stated-start branches sharing one fix that moved to one value are now one row too (t_distinct_release_starts_with_equal_boundary_values_are_one_row, inverted). A quarantined spelling wins over a counted one; a keyed branch over the open range.
**unshipped_release** (+1 row: CVE-2026-41080 libexpat 2.7.6 -> 2.8.0). research/evidence/unshipped-releases.json now carries the libexpat entry: the GitHub tags API observed 2026-09-05T18:52:20Z lists R_2_7_5 (released 2026-03-17) then R_2_8_0 (2026-04-24) and no R_2_7_6.
**publication_batch_correction** (GHSA, 12 rows quarantined, 163 -> 151). A counted fix move whose original value was replaced within PUBLICATION_BATCH_HOURS (1 h) of the advisory's first commit. Evidence: 12 of 163 counted rows carried inPublicationBatch; the 2 hard false positives in the 6-row advisory read-back were both in that set (GHSA-vrh2-rprg-rgc6, a Go pseudo-version replaced 4 minutes later by the 4.0.5 tag it precedes; GHSA-qgj5-4qvg-2f8c, Liferay Portal 7.4.3.132 replaced 97 s later by the module's own 15.2.1) and the 4 outside it were true by record. All 12 were corrected between 97 s and 35 min after first appearing in the database (wasmvm x3 at 34 min, mattermost/server/v8 x4 at 6-19 min, pterodactyl wings at 6 min, zephyr at 24 min, Blogifier at 35 min). For 3 of them (published 2022, first committed 2024-2026) the lag measured from published — 607, 1,046 and 1,329 days — is a backfill artifact the mirror cannot support: it saw the original value stand for minutes. A coordinate corrected inside the batch that published it is the publication, not a fix that moved after it. The rows ship as suspect with firstReplacementInPublicationBatch; the inPublicationBatch flag on the other advisory kinds is unchanged. The fix_version_moved kind's own inPublicationBatch figure is now 0 by construction and the block carries quarantinedPublicationBatchCorrections.
Sanitation before any index
research/snapshot.py sanitised only the seven row arrays, in finalize(), after vendors[], events[], publishers[], years[].cnas, advisories.comparison.packages and kevImpact had been built from raw names. A 300-character vendor produced a vendors[] entry whose page found zero rows (names on rows are capped at 200) and the gate passed. Rows are now sanitised in build() before any index reads them, and every name-carrying index goes through the same clean_name; pinned by test_snapshot.py:t_indices_are_built_from_sanitised_rows.
Validation against the new snapshot
research/out/validation/2026-09-05b.json (seed 2026-09-05b, 30 of the 365 counted rows, reviewer agent-2026-09-05b): 30/30 classified, hard false positives 1, rate 0.0333, Wilson 95% [0.0059, 0.1667]; by class clean 22, clean_same_day 2, clean_bulk 4, hard_fp 1, ambiguous 1. The hard false positive is CVE-2025-14756 (TP-Link Archer MR600 v5 1.0.0 0.9.1 v0001.0 Build 250930 Rel.63611n -> 1.1.0 ..., 7.8 h): only the leading firmware label changed and the build token is byte-identical, a shape no class sees because the parser stops at the space. One row; not implemented as a class. The ambiguous row is CVE-2023-38171 (the 2024-12-10 uniform +1 rewrite of 107 Visual Studio / .NET records), the same verdict the 2026-09-05 sample gave that record's 17.4 row. Two samples are not a trend: the earlier interval [0.0531, 0.2968] and this one overlap.
Not changed: web/src/lib/signals.ts must register boundary_swapped_between_products and publication_batch_correction before web/src/lib/signals.test.ts will pass against this snapshot.