Summary
Five surfaces rewrite three printable ASCII characters, and no user-facing page says so. Measured on 0.14.1 across U+0021–U+007E:
| surface |
ASCII rewrites |
canonicalize |
|→l, "→'', `→' |
canonicalize_strict |
same three |
strip_obfuscation |
same three |
normalize_confusables |
same three |
catalog_key |
same three, plus the 26 case folds |
search_key, sort_key, ml_normalize |
case folds only — no confusable step |
The rows are faithful TR39 (confusables_to_latin.tsv:2-4, all three in confusables_upstream_sources.tsv). The finding is not that they are wrong; it is that they are invisible to a reader and that the exclusion policy around them is applied to two thirds of one class.
Where it is written down today — three internal places, none of them a doc:
src/confusables.rs:232 — "the latin table maps ASCII source code points (U+007C |→l, U+0022 "→'', U+0060 `→'), so ASCII input is not identity even for target="latin"."
tests/exhaustive_confusables.rs:21-24 — the same sentence, as a justification for keeping ASCII in the sweep.
- Nowhere else. Grepped
docs/, python/disarm/, and every docstring on the five surfaces: zero mentions of U+007C, U+0022, U+0060, "vertical line", or ASCII-not-identity.
What a reader is told instead. docs/limitations.md:225-227 enumerates the ASCII rows disarm deliberately does not apply:
"the set includes five ASCII characters (%, 0, 1, I, m) because TR39 is a skeleton transform whose m→rn, I/1→l and 0→O rows disarm deliberately does not apply — folding a legitimate m to rn corrupts prose."
That passage names the exclusions and never mentions the inclusions, so it reads as "ASCII passes through". It does not.
The class is folded asymmetrically
|, I and 1 are three ASCII members of one TR39 confusable class with prototype l. Two are excluded by the policy above; one is not:
>>> disarm.canonicalize("|"), disarm.canonicalize("I"), disarm.canonicalize("1")
('l', 'I', '1')
>>> disarm.is_confusable("|"), disarm.is_confusable("I"), disarm.is_confusable("1")
(True, False, False)
So paypa|.com collapses onto paypal.com and paypaI.com does not. Whichever behaviour is right, one class should not be half-folded, and unmapped_confusables() does not report | as an exposure because it is mapped.
Composition canonicity
A flat-string normalizer has no notion of a field boundary, so the fold reaches the separator. | is a common composite-key separator (DynamoDB sort keys, Redis keys, tenant-scoped identifiers, log lines), and it is the one that folds:
>>> disarm.canonicalize("user|admin")
'userladmin'
>>> disarm.canonicalize("userladmin")
'userladmin' # a two-field key and a one-field key collide
canonicalize is documented as "For cleaning untrusted input before comparison, this is the entry point" and "scoped to identifiers", which is the shape a caller composes. If the join happens before the call, the decomposition is destroyed and a value containing a literal l can forge a boundary. /, :, ., - and _ all survive; only | does not, which is exactly the case a caller would not predict.
This is the composition-canonicity level of arXiv:2608.06508v1 §7.3 — a composite object is canonical only if the composition is uniquely decomposable and each field value is canonical. disarm gets the second and silently breaks the first.
Scope
- Document the three applied ASCII rows next to the five excluded ones in
docs/limitations.md, and in the docstrings of canonicalize, canonicalize_strict, strip_obfuscation, normalize_confusables and catalog_key. One sentence: these five surfaces are not identity on printable ASCII.
- Decide the
| / I / 1 asymmetry deliberately and record the reasoning wherever it lands. Either extend the docs/limitations.md:226 exclusion to U+007C — consistent with I/1, and it restores the separator — or keep the fold and say why this member of the class differs.
- Say in the docs that these surfaces are not composition-safe: canonicalize each field, then join; do not join, then canonicalize. A worked two-field example belongs in
docs/user-guide/.
- If §2 keeps the fold,
unmapped_confusables() is not the whole exposure surface for ASCII — a companion listing the ASCII rows that are applied would let a caller check either direction.
Refs #703, #593, #587
Reviewed against arXiv:2608.06508v1 §7.3 (composition canonicity vs value canonicity).
Summary
Five surfaces rewrite three printable ASCII characters, and no user-facing page says so. Measured on 0.14.1 across
U+0021–U+007E:canonicalize|→l,"→'',`→'canonicalize_strictstrip_obfuscationnormalize_confusablescatalog_keysearch_key,sort_key,ml_normalizeThe rows are faithful TR39 (
confusables_to_latin.tsv:2-4, all three inconfusables_upstream_sources.tsv). The finding is not that they are wrong; it is that they are invisible to a reader and that the exclusion policy around them is applied to two thirds of one class.Where it is written down today — three internal places, none of them a doc:
src/confusables.rs:232— "the latin table maps ASCII source code points (U+007C|→l, U+0022"→'', U+0060`→'), so ASCII input is not identity even fortarget="latin"."tests/exhaustive_confusables.rs:21-24— the same sentence, as a justification for keeping ASCII in the sweep.docs/,python/disarm/, and every docstring on the five surfaces: zero mentions ofU+007C,U+0022,U+0060, "vertical line", or ASCII-not-identity.What a reader is told instead.
docs/limitations.md:225-227enumerates the ASCII rows disarm deliberately does not apply:That passage names the exclusions and never mentions the inclusions, so it reads as "ASCII passes through". It does not.
The class is folded asymmetrically
|,Iand1are three ASCII members of one TR39 confusable class with prototypel. Two are excluded by the policy above; one is not:So
paypa|.comcollapses ontopaypal.comandpaypaI.comdoes not. Whichever behaviour is right, one class should not be half-folded, andunmapped_confusables()does not report|as an exposure because it is mapped.Composition canonicity
A flat-string normalizer has no notion of a field boundary, so the fold reaches the separator.
|is a common composite-key separator (DynamoDB sort keys, Redis keys, tenant-scoped identifiers, log lines), and it is the one that folds:canonicalizeis documented as "For cleaning untrusted input before comparison, this is the entry point" and "scoped to identifiers", which is the shape a caller composes. If the join happens before the call, the decomposition is destroyed and a value containing a literallcan forge a boundary./,:,.,-and_all survive; only|does not, which is exactly the case a caller would not predict.This is the composition-canonicity level of arXiv:2608.06508v1 §7.3 — a composite object is canonical only if the composition is uniquely decomposable and each field value is canonical. disarm gets the second and silently breaks the first.
Scope
docs/limitations.md, and in the docstrings ofcanonicalize,canonicalize_strict,strip_obfuscation,normalize_confusablesandcatalog_key. One sentence: these five surfaces are not identity on printable ASCII.|/I/1asymmetry deliberately and record the reasoning wherever it lands. Either extend thedocs/limitations.md:226exclusion toU+007C— consistent withI/1, and it restores the separator — or keep the fold and say why this member of the class differs.docs/user-guide/.unmapped_confusables()is not the whole exposure surface for ASCII — a companion listing the ASCII rows that are applied would let a caller check either direction.Refs #703, #593, #587
Reviewed against arXiv:2608.06508v1 §7.3 (composition canonicity vs value canonicity).