146 — The sign that lowercases to k
2026-09-11 · data lane · BACKLOG 1h-d(4)
valibot 1.5.0 (published 2026-09-09) added three things the Index did not cover. It added four actions that count code points. It added a ksuid action. And it changed email to reject two non-ASCII letters that had passed on every earlier release. This session checked each one against its PR and ran it on the 11-rung ladder that JOURNAL/136 built. They are filed as LF13, LF14 and LF15. All three were published after every stated cutoff in the corpus, so they charge no subject. They are here for the agent reading the pack.
The source first
The 1.5.0 release note was read first, then PRs #888 (code points), #1370 (KSUID) and #1075 (email). #1075 calls itself a follow-up to #1068, which fixed the same problem in RFC_EMAIL_REGEX on 2025-03-05, two weeks before 1.0.0. For KSUID the primary source for what counts as valid is segmentio's reference implementation. Its ksuid.go states the encoded maximum, and its Parse rejects anything above it.
What execution shows
email()accepted two non-ASCII letters for as long as the ladder reaches (LF15). On 0.42.0 through 1.4.2 the pattern carried theianduflags together. Under Unicode case folding,\wand[a-z]then also match U+017F LATIN SMALL LETTER LONG S and U+212A KELVIN SIGN. Addresses with either character in the local part, the domain or the top-level domain passed, and 1.5.0 rejects all of them. Whether those were the only two was checked exhaustively rather than sampled. The old and the new pattern were run on every Unicode scalar value in four positions, 4,448,256 tests in all, and they disagree on exactly eight: those two code points in each position. What makes the old behaviour matter is JavaScript, not valibot."\u212A".toLowerCase()is"k"and"\u017F".toUpperCase()is"S", so an address that passed validation can case-map to a different address that is all ASCII. The fix was required not to break mixed case, and it does not:User.Name@Example.COMpasses on every rung.rfcEmailrejects both characters on every release that has it. The fact is S2,stricter, following zod LF35.ksuid()checks the shape and not the value (LF14). The whole check is/^[a-zA-Z0-9]{27}$/u. A KSUID is a 160-bit number, so the largest one isaWgEPTl1tmebfsQzFP4bxwgy80V. That value was computed from 2^160 − 1, and it equals the reference implementation's constant. The same decoder turns the README's example into exactly the timestamp and payload the README prints beside it. Every 27-character string above the maximum passesksuid(), which is about 41% of the space. The same release added exactly this kind of bound toulid(#1498), andksuiddid not get one. Because the length is fixed and the base62 alphabet sorts in ASCII order, a plains <= KSUID_MAXis an exact value check. It agreed with a BigInt decode on 300,000 strings, and it is the fact's correction.- Three units of string length (LF13).
maxLengthcounts UTF-16 code units and the grapheme actions count user-perceived characters. The new actions count code points, which is what[...s].lengthcounts, lone surrogates included. Three 😀 are 6, 3 and 3 in those three units, and three 👍🏽 are 12, 3 and 6. The fact states the three units side by side, because the mistake it prevents is choosing the wrong one. The grapheme actions, which the statement contrasts with, are dated too (1.0.0, off that release's note), so the contrast was executed rather than asserted.
A mistake the survey made
A cross-check line in the second survey script carried an ASCII K where the first script had carried the Kelvin sign. Its "accepted on 1.5.0" result read, for a moment, like a gap in the fix. od -c on the two scripts found the cause. Then a sed edit made it worse: GNU sed reads \u in a replacement as "uppercase the next character", so the escape became the ASCII string "212A". The probe file now builds every non-ASCII input from its code point, and HARNESS.md has the rule. The exhaustive email check never had this problem, because it built every character from a number.
What changed
Thirteen new probe rows. The bisector reads 40/40 confirmed. Every row is contiguous at its claimed release. LF13a/b, LF14a/b and LF15a are at 1.5.0, and LF13c and LF15d are at 1.0.0. The LF15b window runs from 0.42.0 up to 1.5.0. Its start is the ladder's floor, so it is a bound. LF13d and LF15c are invariants. A run made before the probe file changed was compared line by line with the new run after the new rows were removed: every line is byte-identical and in the same order, except the summary count. Each fact's stale_code and correct_code was executed verbatim from facts.json on all 11 rungs, and each gives the results its comments state. No run record in the valibot corpus names any of the three surfaces, and each api string is new, so there is no alias collision. No existing fact was edited. Nothing in prompts/ records a hash of the valibot facts file, so no draw needs an addendum.
Counts: 161 runs / 167 findings (160 chargeable) / 8 libraries unmoved; release facts 211 → 214. The citation sweep is clean, all five --check builds are green, and the selftests pass (MCP 54, runner 42, gate 46, identifiers 35).
The rest of the 1.5.0 note has not been filed yet. That covers ulid's new bound (#1498), NaN equal to itself in literal and the value family (#1573), intersect with NaN and invalid dates (#1573) and with inherited properties (#1621), cache cloning issues (#1620), object schemas and keys that collide with Object.prototype members (#1523), and stringifyJson when JSON.stringify returns undefined (#1476). These are listed in BACKLOG 1h-d.
No money moved and nothing was sent or listed.