A Pinned Version Is Not a Pin

On a stock Python 3.12, "ᎠᎠ".encode("idna") returns b'xn--kz9aa'. In the same interpreter, the PyPI idna package returns b'xn--58da' for the same two characters. Two different domain names, one process. That disagreement is CVE-2026-17084, patched into CPython on 18 August 2026, and it exists because in 2015 the Cherokee syllabary grew a lowercase. I wanted to know how big that class of change is, so I diffed the Simple_Lowercase_Mapping field of UnicodeData-3.2.0.txt against Unicode 15.0. Of the 13,874 code points assigned in Unicode 3.2, exactly 126 have a different lowercase today: 85 Cherokee capitals (U+13A0–13F4), 38 Georgian capitals (U+10A0–10C5), and three singletons — Cyrillic palochka, TURNED CAPITAL F, and ROMAN NUMERAL REVERSED ONE HUNDRED. And all 126 are acquisitions. Not one code point lost a lowercase mapping. Not one had its mapping redirected. In 21 years and thirteen Unicode releases, the only thing that happened to case is that characters which previously mapped to themselves started mapping somewhere else. Uppercase is nearly as clean: 64 pre-3.2 code points changed, 63 of them acquisitions, and exactly one genuine remap in the whole corpus — U+03F2 GREEK LUNATE SIGMA SYMBOL, which stopped uppercasing to Σ in 2005 and started uppercasing to its own lunate capital, U+03F9.

The Cherokee case is the sharpest, and it is a historiographical fact rather than a technical one. Sequoyah’s syllabary, completed around 1821, was encoded in Unicode 3.0 as unicameral — one set of 85 signs. But nineteenth-century Cherokee printers, working with Latin type conventions, cut and used distinct small forms; the Cherokee Nation still uses them. Unicode 8.0 (June 2015) encoded the small letters — 80 in a new Cherokee Supplement block at U+AB70–ABBF, 5 more at U+13F8–13FD — and, in doing so, retroactively made every existing Cherokee capital a capital. A property that had been the identity function for fifteen years stopped being one. Unicode permits this on purpose. Its Character Encoding Stability Policies freeze a great deal — character names since 2.0, the General_Category enumeration since 2.1.3, canonical decompositions, code point assignments. The only thing guaranteed about Case_Folding is a length bound: no string expands more than 3× when folded. The value is explicitly not promised, because casing is a claim about a living orthography and orthographies are still being described.

Which makes the shape of the CPython bug worth looking at closely, because it is not carelessness. RFC 3454 says in plain English: “This document is for Unicode version 3.2, and should not be considered to automatically apply to later Unicode versions.” CPython honoured that. Lib/stringprep.py opened with from unicodedata import ucd_3_2_0 as unicodedata — a real frozen 3.2 database, shipped in the interpreter for exactly this purpose. Category lookups used it. NFKC normalization used it. And then Table B.3’s mapping function ended with return code.lower(). str.lower() is a method on the string object. There is no versioned form of it. That one call reached past the pinned database into whatever UCD the interpreter happened to be built with, and the patch to close it is 257 added lines in stringprep.py — a generated exception table — plus 1,564 changed lines of unicodedata_db.h. The pin held everywhere it could be spelled, and failed at the one operation with no version-qualified spelling.

Here is the claim: the monotonicity is a design lever, and nobody used it for 21 years. Because drift is one-directional, a conforming stringprep never needed a second Unicode database at all. It needed the live .lower() minus a deny-list of code points that gained case after the pin — a list that only grows, and today has 126 entries. That is, structurally, exactly what CPython now ships, discovered by hand two decades late. The generalization is cheap: for any spec pinned to an old UCD, the delta against the current one is a small, append-only table, and it can be generated mechanically at build time rather than rediscovered as a CVE. The IETF’s own answer was blunter — delete the requirement. IDNA2008 removed mapping from the protocol entirely; RFC 5895 pushes it into the user interface, on the grounds that IDNA2003 “without regard to context, locale, or anything about the user’s intentions, mapped them into a particular set of other characters” — the canonical counterexample being Turkish, where I lowercases to ı, not i. That was 2010. Sixteen years later, "x".encode("idna") in the Python standard library still means IDNA2003, still means stringprep, still means Unicode 3.2. Two things I would bet on. First, the same defect is live in other stringprep profiles — SASLprep, LDAP, XMPP’s nodeprep all inherit RFC 3454’s clause, and none of their host runtimes expose a versioned lower() either. Second, 126 goes up: Unicode has kept giving case to scripts previously encoded without it, Georgian Mtavruli in 11.0, Vithkuqi in 14.0, and every such addition lengthens somebody’s exception table. The lesson for spec writers is narrow and practical: a clause of the form apply operation X as of Unicode version N is not a specification, it is an unbounded liability assigned to implementers who have no primitive that can satisfy it.