valibot correction pack

Paste into CLAUDE.md, .cursorrules, or any agent rules file on a project that uses valibot.

valibot correction pack · for projects on valibot@^1.2

What we actually measured

5 Claude models were asked for idiomatic valibot code with no tools, purely from training knowledge. 16 reproduced failures across 29 runs, each verified against the release that broke the belief.

ModelStated cutoffvalibot version attribution stopsLag inside the window
Claude Fable 5.12026-061.1.0 · 2025-05-06~16 months
Claude Opus 52026-051.0.0 · 2025-03-19~14 months
Claude Sonnet 52026-011.0.0 · 2025-03-19~10 months
Claude Fable 52026-011.1.0 · 2025-05-06~8 months

A recent cutoff is not a defence. Every model here loses track of this library's release history well before the date it states as its own cutoff, and the stopping points cluster far tighter than the cutoffs do.

Read that column precisely. It is the newest valibot release whose contents the model can correctly attribute to that release — not the newest valibot feature it knows. Past that point a model will often write working code with a newer API while naming the wrong release for it, and that guess runs early — it names a release older than the one that shipped the feature. So the question this pack answers is not "does the model know this API" but "can it be trusted about which version the API arrived in" — which is the question that matters when you are pinned to a version.

How to read an entry

Every entry ends with a Reproduced against line. Where it names models, we have the generated code that got it wrong, dated, with the model's own words in the run write-up. Where it says no model yet, the correction is verified from the release notes but nothing has been probed for it — it is a fix, not a measurement, and the pack says so rather than blurring the two.

The section an entry sits in is the worst case if you act on the stale belief. The severity in brackets after a model's name is what that particular model's output actually did, which can be milder — a model can hold the wrong belief and still, on the day, write code that runs.

Two dates, and they answer different questions. The entries below were last checked against their primary sources on 2026-09-11 — that date is the floor, and it is the only verification claim this file makes. Separately, 3 of the 19 entries have had their own text change since that sweep, the newest on 2026-09-24. A text change is a change to the record — a re-scored severity, a corrected release date, a clarified note — and it is not a re-check against the source. Do not read the later date as the fresher of two checks; per-entry dates are in substance_changed_on in the JSON.

The corrections

Runs, but is silently wrong

Nothing errors. The behaviour is simply not what a model trained earlier will tell you.

email

Stricter in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

From 1.5.0 the email action accepts ASCII letters only. On every earlier release the ladder reaches, 0.42.0 through 1.4.2, EMAIL_REGEX used the i and u flags together. Under Unicode case folding its \w and [a-z] also match two non-ASCII code points: U+017F LATIN SMALL LETTER LONG S (ſ) and U+212A KELVIN SIGN (K, which looks like a capital K). So an address with either character in the local part, the domain or the top-level domain passed email(). A check of every Unicode scalar value in four positions found these two and no others. They matter after validation: "\u212A".toLowerCase() is "k" and "\u017F".toUpperCase() is "S", so an address that passed can case-map to a different, all-ASCII address. 1.5.0 drops the i flag and lists A-Z explicitly, and mixed-case ASCII addresses such as User.Name@Example.COM still pass. rfcEmail rejects both characters on every release that ships it (1.0.0 onward). On a pin below 1.5.0, add an ASCII-only check after email(). After upgrading, a stored address that contains either character fails validation.

The stale belief: That email() has always accepted ASCII letters only, since a non-ASCII address such as é@example.com fails on every release. Below 1.5.0 two non-ASCII letters passed: U+017F and U+212A.

// Stale
import * as v from 'valibot'

// valibot 0.42.x-1.4.x
const Email = v.pipe(v.string(), v.email())
v.safeParse(Email, 'a\u212Ab@example.com').success   // true below 1.5.0: U+212A is the KELVIN SIGN
'a\u212Ab@example.com'.toLowerCase()                 // 'akb@example.com'

// Current
import * as v from 'valibot'

// Upgrade to ^1.5.0. On an older pin, add an ASCII-only check after email():
const Email = v.pipe(
  v.string(),
  v.email(),
  v.regex(/^[\x21-\x7E]+$/, 'Email must be ASCII'),
)
v.safeParse(Email, 'a\u212Ab@example.com').success    // false on every release
v.safeParse(Email, 'User.Name@Example.COM').success   // true on every release

Written 2026-09-11 (JOURNAL/146) from execution, with the 1.5.0 release note and PR #1075 (a follow-up to #1068, which had fixed the same problem in RFC_EMAIL_REGEX on 2025-03-05, before 1.0.0) read first. The shipped regex is /^[\w+-]+(?:\.[\w+-]+)*@[\da-z]+(?:[.-][\da-z]+)*\.[a-z]{2,}$/iu, byte-identical on 0.42.0 through 1.4.2, and /^[\w+-]+(?:\.[\w+-]+)*@[\da-zA-Z]+(?:[.-][\da-zA-Z]+)*\.[a-zA-Z]{2,}$/u at 1.5.0. Probe row LF15a rises at 1.5.0, contiguous on all 11 rungs. It uses the six addresses upstream's own test lists (either character, or both, in the local part, the domain label and the top-level domain), all rejected, with a plain ASCII address accepted in the same row. The LF15b window (all six accepted) holds from 0.42.0 up to 1.5.0. Its start is the ladder's floor, so it is a bound: the behaviour may be older than 0.42.0. LF15c is an invariant and holds on every rung: User.Name@Example.COM passes, and é in the local part or the domain fails. LF15d rises at 1.0.0, dated off that release's note: rfcEmail rejects all six and accepts the mixed-case address. The two-and-no-others claim is exhaustive: 1.4.2's and 1.5.0's EMAIL_REGEX were run on every Unicode scalar value (1,112,064) in four positions (inside the local part, as the whole local part, inside the domain and inside the top-level domain), 4,448,256 tests in all, and they disagree on exactly eight: U+017F and U+212A in each position. The case mappings are JavaScript's own, executed on Node 24.13.0. The stale_code and correct_code were executed as written on all 11 rungs. The stale code gives true and 'akb@example.com' on 0.42.0 through 1.4.2, and false and the same string on 1.5.0. The correct code gives its two commented results on every rung. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.

Reproduced against: Claude Fable 5.1 (S2), Claude Opus 5 (S2) — valibot/v6-a, 2026-09-24; valibot/v6-b, 2026-09-24; valibot/v6-c, 2026-09-24.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot PR #1075 (commit 4acda1b9), merged 2026-09-09, as listed in the 1.5.0 release notes · 2026-09-09 · valibot 1.5.0, shipped package — the doc comment on EMAIL_REGEX in dist/index.d.mts · 2026-09-09 · valibot v1.0.0 release notes — the rfcEmail action the statement contrasts with · 2025-03-19 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF15a-LF15d, 2026-09-11) · 2026-09-11

toNumber / toBoolean / toDate / toBigint / toString

Added in valibot 1.2.0 (2025-11-24) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot ships dedicated string-to-primitive transformation actions since 1.2.0: toBigint, toBoolean, toDate, toNumber and toString. Use toNumber instead of a bare transform(Number): toNumber adds a validation issue when the conversion yields NaN, whereas pipe(string(), transform(Number)) returns NaN with success: true and hands silently-wrong data downstream. toBoolean is the exception and is not a string parser — it applies JavaScript Boolean(), so the string "false" converts to true. For the common case of a "true"/"false" query parameter use parseBoolean (1.3.0) or validate the literals with picklist before transforming.

The stale belief: That valibot has no built-in string-to-primitive conversion at all and every such conversion must go through a hand-written transform(Number) or transform((s) => s === "true").

The code below needs valibot 1.3.0 or later. Between 1.2.0 and 1.3.0 this correction does not apply — see the note.

// Stale
import * as v from 'valibot'

const Query = v.object({
  // Number("abc") is NaN, and this pipe returns it with success: true
  page: v.pipe(v.string(), v.transform(Number)),
})

// Current
import * as v from 'valibot'

const Query = v.object({
  // toNumber raises a validation issue instead of yielding NaN
  page: v.pipe(v.string(), v.toNumber()),

  // NOT v.toBoolean() — that is Boolean(), and Boolean("false") is true.
  // parseBoolean (1.3.0) reads the literal words:
  active: v.pipe(v.string(), v.parseBoolean()),

  // on valibot < 1.3.0, validate the literals first:
  // active: v.pipe(v.picklist(["true", "false"]), v.transform((s) => s === "true")),
})

CORRECTED 2026-09-02 (JOURNAL/035), against the shipped package. This entry previously told readers to write v.pipe(v.string(), v.toBoolean()) for a "true"/"false" query parameter, and justified the whole fact on the claim that the built-in avoids the Boolean("false") === true trap that transform(Boolean) falls into. That justification was false and the recommended code was a bug. valibot 1.4.2's toBoolean is dataset.value = Boolean(dataset.value) — literally the same call — so it maps "false", "0", "no" and every other non-empty string to true. Executed and confirmed: parse(pipe(string(), toBoolean()), "false") returns true. The word-reading action is parseBoolean, which arrived in 1.3.0 (see LF11) and returns false for "false". The fact survives on the toNumber half, where the built-in genuinely is safer than the hand-roll, and that is now the stated mechanism. The error was caught by the battery valibot/v2 pre-registration, which required every scored claim to be re-executed against the installed package — and by six test draws that denied these actions exist while independently warning that Boolean("false") is true. They were right about that and the Index was wrong. ARCHAEOLOGY, 2026-09-02: a subject asserted, inside a run, that valibot USED to have a coerce action and that it was deliberately removed. That history is correct. The v0.31.0 migration guide states that coerce was removed and why, and maps it onto pipe + unknown + transform. So the belief that produced the wrong answer was not a false memory of the removal but the assumption that nothing replaced it: the generic coerce is gone, and the dedicated actions above arrived in 1.2.0, six releases later. RANGED 2026-09-08 (JOURNAL/094): the clause names five actions and the probe executed two, so LF1c was added for the other three. toBigint, toDate and toString are each absent at 1.1.0 and convert at 1.2.0 and above — the same boundary the two interesting actions already had, contiguous over all ten rungs. Nothing moved; four fifths of the clause had simply never been executed.

Reproduced against: Claude Fable 5 (S2), Claude Opus 5 (S2), Claude Sonnet 5 (S2) — valibot/v2-a, 2026-09-02; valibot/v2-c, 2026-09-02; valibot/v2-e, 2026-09-02.

Source: valibot v1.2.0 release notes · 2025-11-24 · valibot 1.4.2, shipped package — the toBoolean implementation · 2026-06-28 · valibot — Migrate to v0.31.0, Coerce method

emoji

Behaviour changed in valibot 1.2.0 (2025-11-24) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

The regular expression behind the emoji validation action carried a ReDoS vulnerability that was fixed in 1.2.0. If you validate user-supplied text with the emoji action, require valibot >= 1.2.0. Advice that recommends the action without that version floor exposes the caller to a denial-of-service on attacker-controlled input.

The stale belief: That the emoji action is safe to run on untrusted, high-volume input at any 1.x version.

// package.json
// "valibot": "^1.2.0"   <- 1.2.0 fixed the ReDoS in EMOJI_REGEX

import * as v from 'valibot'

const Reaction = v.pipe(v.string(), v.emoji())

STRENGTHENED 2026-09-07 (JOURNAL/072), by the fact bisector. This entry previously said the fix was "verified only to the extent the vendor's own release notes state it" and that it did not characterise the exploit, which the notes do not publish. Both the boundary and the mechanism are now first-hand. The vulnerable pattern is the ZWJ-sequence tail of EMOJI_REGEX: a string of N repeats of U+1F44D U+200D followed by one non-matching character backtracks exponentially, because the alternation inside the group ambiguously matches the same emoji three ways. Measured on the installed packages, one .test() call: 1.1.0 takes 5 ms at N=16, 20 ms at 18, 82 ms at 20, 319 ms at 22 and 1265 ms at 24 — doubling per added repeat, from a 48-character input — and 1.0.0, 0.42.1 and 0.42.0 measure the same. 1.2.0 takes under 1 ms at every N, as do 1.3.0 through 1.4.2. The regex source shows why: 1.2.0 inserts a negative lookahead (?![\p{Emoji_Modifier_Base}...]) that removes the ambiguity, and the pattern is byte-identical across 0.42.0-1.1.0 and again across 1.2.0-1.4.2. So the release note is right about which release fixed it, the floor of >= 1.2.0 stands, and the cost of ignoring it is now stated as a number rather than asserted. The probe is tools/audit/probes/valibot.mjs LF2; it is a timing probe and is marked as one. Split into two rows 2026-09-22 (JOURNAL/178), and the second one is the caller's path. Until then the probe timed EMOJI_REGEX directly, and this statement is about the emoji action — which is what a caller reaches for, through pipe(string(), emoji()), never touching the exported regex. That the two are the same object was documented and not measured, and they could have come apart in either direction: an action that stopped using the export, or an export the fix moved without moving the action. LF2a is the regex row unchanged; LF2b runs the same attack input through the action at the same 250 ms threshold. They agree on all eleven rungs — both false at 0.42.0, 0.42.1, 1.0.0 and 1.1.0, both true from 1.2.0 — so the boundary above is the action's boundary and not only the regex's, and the advice this entry publishes is about the object it names. Nothing in the fact moved; the row was added because the clause-coverage sweep (JOURNAL/176) reported emoji as a token this statement names and no row touched.

Reproduced against: Claude Fable 5 (S2) — valibot/v1, 2026-09-01.

Source: valibot v1.2.0 release notes · 2025-11-24

cache / cacheAsync (issue paths)

Behaviour changed in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

On valibot 1.3.0 through 1.4.2, a cache or cacheAsync schema inside a parent schema reported a wrong issue path whenever it answered from the cache. The cache returned the same issue objects on every hit, and each parent added its path item to them again. So object({ key: cache(pipe(string(), minLength(3))) }) reports the path key, then key.key, then key.key.key for the same bad input, and flatten keys the error the same way. One cached schema shared by two parents reports the first parent's key inside the second's path. A hit needs the same input value, so primitives are what trigger it: a new object is a cache miss. A cache at the top level, with no parent, reported stable paths on every release that has it. From 1.5.0 the cache hands out copies of the issues and their paths, and the path is the same on every parse. On 1.3.x or 1.4.x, put cache only around a top-level schema, or upgrade.

The stale belief: That wrapping a nested schema in cache changes only how often it runs, not what it reports. On 1.3.0 through 1.4.2 each cache hit lengthened the issue path.

// Stale
import * as v from 'valibot'

// valibot 1.3.x-1.4.x: each cache hit adds the parent's key to the cached issue again
const Form = v.object({ key: v.cache(v.pipe(v.string(), v.minLength(3))) })
v.getDotPath(v.safeParse(Form, { key: 'fo' }).issues[0])   // 'key'
v.getDotPath(v.safeParse(Form, { key: 'fo' }).issues[0])   // 'key.key' on 1.3.x-1.4.x, 'key' from 1.5.0

// Current
import * as v from 'valibot'

// On 1.3.x-1.4.x, cache the top-level schema, which has no parent to add a path item
const Form = v.cache(v.object({ key: v.pipe(v.string(), v.minLength(3)) }))
const input = { key: 'fo' }
v.getDotPath(v.safeParse(Form, input).issues[0])   // 'key'
v.getDotPath(v.safeParse(Form, input).issues[0])   // 'key', a cache hit, from 1.3.0

Written 2026-09-11 (JOURNAL/150) from execution, with the 1.5.0 release note and PR #1620 read first. The cause is the internal _cloneDataset: on 1.3.0 through 1.4.2 it copies the issue array and keeps the issue objects, and at 1.5.0 it copies each issue and its path array. cache itself arrived in 1.3.0 (LF11). Probe row LF19a rises at 1.5.0, contiguous on all 11 rungs: three parses of object({ key: cache(...) }) each report key, three parses of array(cache(...)) each report 0, and one cached schema shared by object({ first }) and object({ second }) reports first and then second. The LF19b window holds from 1.3.0 up to 1.5.0: the same three parses report key, key.key and key.key.key, the array reports 0, 0.0 and 0.0.0, and the shared schema reports second.first for the second parent. Below 1.3.0 cache does not exist, so 1.3.0 is a real boundary and not the ladder's floor. LF19c rises at 1.3.0: a top-level cache of an object schema, parsed three times with the same input object (a cache hit), reports key each time. cacheAsync was surveyed on every rung and matches cache. flatten on a nested cached schema was keyed nested.key, then nested.key.nested.key, then nested.key.nested.key.nested.key on 1.3.0 through 1.4.2. The issue count did not grow, only the path. The stale_code and correct_code were executed as written on all 11 rungs. The stale code gives 'key' then 'key.key' on 1.3.0 through 1.4.2, 'key' twice on 1.5.0, and throws below 1.3.0, where cache does not exist. The correct code gives 'key' twice on every rung from 1.3.0 and throws below it. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot PR #1620 (commit d65438c9), merged 2026-09-08, as listed in the 1.5.0 release notes · 2026-09-08 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF19a-LF19c, 2026-09-11) · 2026-09-11

literal / value / values / notValue / notValues (NaN)

Behaviour changed in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

From 1.5.0 valibot treats NaN as equal to itself when it compares values. literal(NaN), value(NaN) and values([NaN]) accept NaN, notValue(NaN) and notValues([NaN]) reject it, and intersect merges two branches that both produce NaN for the same entry, or two invalid Date objects. On every earlier release these compared with ===, and NaN !== NaN. So literal(NaN) matched nothing, notValue(NaN) let NaN through, and intersect reported that the two outputs could not be merged. This only matters where NaN reaches the comparison: through nan(), any(), unknown() or a transform, because number() rejects NaN on every release. Three things did not move: +0 and -0 are still equal, picklist([NaN]) accepted NaN on every release, and on 1.5.0 value() with an invalid Date still rejects a different invalid Date (only the same instance matches). To match NaN on any release, use nan().

The stale belief: That literal(NaN) matches NaN and notValue(NaN) excludes it on any release. Below 1.5.0 both compared with ===, so the first never matched and the second never excluded anything.

// Stale
import * as v from 'valibot'

// valibot 0.42.x-1.4.x: literal() compares with ===, and NaN !== NaN
const Reading = v.union([v.literal(-1), v.literal(NaN)])
v.safeParse(Reading, NaN).success   // false below 1.5.0, true from 1.5.0

// Current
import * as v from 'valibot'

// any release: nan() matches NaN without comparing it to anything
const Reading = v.union([v.literal(-1), v.nan()])
v.safeParse(Reading, NaN).success   // true on every release
v.safeParse(Reading, 0).success     // false on every release

Written 2026-09-11 (JOURNAL/150) from execution, with the 1.5.0 release note and PR #1573 read first. The shipped change is a new internal _isSameValueZero, used by literal, by the value actions before their existing ordering comparison, and by intersect's internal merge. Probe row LF17a rises at 1.5.0, contiguous on all 11 rungs: literal(NaN), pipe(any(), value(NaN)) and pipe(any(), values([NaN])) accept NaN, pipe(any(), notValue(NaN)) and pipe(any(), notValues([NaN])) reject it, and a union of literal(-1) and literal(NaN) accepts it. values and notValues do not exist on 0.42.x, so that part of the row is false there for a second reason. LF17b rises at 1.5.0: intersect of two object({ a: nan() }) accepts { a: NaN }, intersect([nan(), nan()]) accepts NaN, and two branches that each turn the string "garbage" into a new Date merge. Below 1.5.0 each of these fails with an intersect issue. The LF17c window holds from 0.42.0 up to 1.5.0 (literal(NaN) and value(NaN) reject NaN, notValue(NaN) accepts it, the nan() intersection fails). Its start is the ladder's floor, so it is a bound. LF17d is an invariant and holds on every rung: literal(0) and value(0) accept -0 and notValue(0) rejects it, picklist([NaN]) and nan() accept NaN, number() rejects NaN, and value(new Date(NaN)) rejects a different invalid Date. The stale_code and correct_code were executed as written on all 11 rungs. The stale line gives false on 0.42.0 through 1.4.2 and true on 1.5.0. The correct code gives its two commented results on every rung. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot v1.5.0 release notes — intersect · 2026-09-09 · valibot PR #1573 (commit 0681f437), merged 2026-09-06, as listed in the 1.5.0 release notes · 2026-09-06 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF17a-LF17d, 2026-09-11) · 2026-09-11

strictObject / looseObject / objectWithRest

Stricter in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

Below 1.5.0, strictObject did not report an unknown key named like an Object.prototype member, such as toString, valueOf, hasOwnProperty, constructor or __proto__. The schemas decided whether an input key was declared with key in entries, and in also finds inherited members. So strictObject({ name: string() }) accepted { "name": "a", "constructor": "x" } from JSON.parse and silently dropped the extra key. looseObject dropped such a key instead of passing it through, and objectWithRest dropped it without running the rest schema. The async variants did the same. From 1.5.0 the check is an own-property check. strictObject rejects all of these keys with a strict_object issue. looseObject passes toString and similar names through, and objectWithRest validates them against the rest schema. constructor, __proto__ and prototype are still dropped from looseObject and objectWithRest output without validation, as on every release. Declared entries with these names work on every release. What 1.5.0 changed is the ENTRIES side of the test and not the input side. The input is walked with for (const key in input) on every release from 0.42.0 to 1.5.0; only the test against the declared entries moved, from key in this.entries to Object.prototype.hasOwnProperty.call(this.entries, key). So the keys the check can reach are the enumerable string keys, own and inherited, and that is unchanged at 1.5.0. Three consequences hold on every release including 1.5.0: an undeclared symbol key is not reported by strictObject, is not copied into looseObject's output, and is never handed to objectWithRest's rest schema; an undeclared non-enumerable own string key is not reported by strictObject either; and an INHERITED enumerable key, which the caller never set on the object, IS reported by strictObject and is dropped by looseObject. A symbol-keyed entry cannot be declared, so there is no way to make strictObject accept one: it is neither validated nor copied. Do not read 1.5.0 as making strictObject reject every key its entries do not declare. On a pin below 1.5.0, reject undeclared keys before strictObject sees the input.

The stale belief: That below 1.5.0 strictObject reported every undeclared key its check could reach. It did not: it accepted any key named like an Object.prototype member, because the test against the declared entries was key in entries, which also finds inherited members. And note what the corrected belief is NOT. strictObject still does not reject every key its entries do not declare, at 1.5.0 or at any release: symbol keys and non-enumerable own keys are outside the check's reach on every release, because the input is walked with for...in.

// Stale
import * as v from 'valibot'

// valibot 0.42.x-1.4.x: `key in entries` also finds Object.prototype members
const User = v.strictObject({ name: v.string() })
v.safeParse(User, JSON.parse('{"name":"a","constructor":"x"}')).success   // true below 1.5.0

// Current
import * as v from 'valibot'

// valibot 1.5.0 and later: strictObject reports every undeclared key
const entries = { name: v.string() }
const User = v.strictObject(entries)
v.safeParse(User, JSON.parse('{"name":"a","constructor":"x"}')).success   // false from 1.5.0

// any release: check the input's own keys before strictObject sees it
const UserAnyRelease = v.pipe(
  v.unknown(),
  v.check(
    (input) => typeof input === 'object' && input !== null && Object.keys(input).every((key) => Object.hasOwn(entries, key)),
    'Unknown key',
  ),
  v.strictObject(entries),
)
v.safeParse(UserAnyRelease, JSON.parse('{"name":"a","constructor":"x"}')).success   // false on every release
v.safeParse(UserAnyRelease, { name: 'a' }).success                                // true on every release

Written 2026-09-11 (JOURNAL/150) from execution, with the 1.5.0 release note and PR #1523 read first. Every input in the probe rows comes from JSON.parse, which creates __proto__ as an own key rather than setting the prototype, as a request body does. Probe row LF18a rises at 1.5.0, contiguous on all 11 rungs: strictObject({ name: string() }) rejects each of toString, valueOf, hasOwnProperty, isPrototypeOf, constructor and __proto__; looseObject keeps toString in its output; objectWithRest({ name: string() }, number()) rejects "toString": "b" and keeps "toString": 7. LF18b is the window below it, from 0.42.0 up to 1.5.0: strictObject accepts all six, looseObject drops toString and objectWithRest accepts "toString": "b". Its start is the ladder's floor, so it is a bound. LF18c is an invariant and holds on every rung: an ordinary unknown key is rejected by strictObject, kept by looseObject and validated by objectWithRest; constructor, __proto__ and prototype are dropped from looseObject and objectWithRest output (__proto__ without changing its prototype); declared entries named toString validate; and the correction's own-key check rejects constructor. The async variants were surveyed on every rung and match (they are not a probe row, because the probes are synchronous). The issue on 1.5.0 has type strict_object, expected never and the key as its path. The stale_code and correct_code were executed as written on all 11 rungs. The stale line gives true on 0.42.0 through 1.4.2 and false on 1.5.0. The correct code gives its three commented results; its first line is true below 1.5.0, as the stale line is. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack. REACH MEASURED AND THE FRAMING REPAIRED 2026-09-24 (JOURNAL/223, BACKLOG 0d-i). Three arms of the valibot/v5 battery, blind to each other, answered a task about this surface by the SYMBOL route rather than the prototype route, and all three were right at current truth — which made the previous stale_belief wording ("that strictObject rejects every key its entries do not declare") a corrected belief that is itself false. Probe row LF18d is the invariant it was missing and holds on all 11 rungs: strictObject accepts an undeclared symbol key and an undeclared non-enumerable own string key; looseObject copies neither a symbol key nor an inherited enumerable key into its output; objectWithRest never hands a symbol key to its rest schema, with a passing value and a failing one both accepted, so the omission is not a silent validation; strictObject DOES reject an inherited enumerable key, the opposite direction; and a symbol-keyed entry is neither validated nor copied. The mechanism is read off the shipped dist/index.mjs at two rungs and is the fourth citation: the input side is the same for...in on 1.4.2 and on 1.5.0. This repair changes no date and no severity — the release note, the boundary and LF18a/LF18b/LF18c are all unmoved — and it is a stale_belief repair rather than a new fact because the behaviour is flat across the whole ladder, has no introduced_in, and is the same loop this fact already describes.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot PR #1523 (commit 95943abb), merged 2026-09-08, as listed in the 1.5.0 release notes · 2026-09-08 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF18a-LF18c, 2026-09-11) · 2026-09-11 · valibot 1.5.0 and 1.4.2 shipped dist/index.mjs, read at the installed rungs (probe row LF18d, 2026-09-24) · 2026-09-09

ulid

Stricter in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

From 1.5.0 the ulid action rejects a ULID above the 128-bit maximum. A ULID is 26 Crockford base32 characters, which can hold 130 bits, so the first character must be 0 to 7, and the largest valid ULID is 7ZZZZZZZZZZZZZZZZZZZZZZZZZ. The ULID spec says any larger value should be rejected. On every earlier release the ladder reaches, 0.42.0 through 1.4.2, ulid() checked only the alphabet and the length, so 8ZZZZZZZZZZZZZZZZZZZZZZZZZ, ABCDEFGHJKMNPQRSTVWXYZ0123 and every other ULID whose first character is 8, 9 or a letter passed. A strict ULID parser downstream then rejects or overflows on a value that passed validation. On a pin below 1.5.0, add regex(/^[0-7]/) after ulid(). After upgrading, a stored value that starts with 8 or higher fails validation.

The stale belief: That ulid() accepts only values a ULID can encode. Below 1.5.0 it accepted any 26 characters from the Crockford alphabet, including values above the 128-bit maximum.

// Stale
import * as v from 'valibot'

// valibot 0.42.x-1.4.x: ulid() checks the alphabet and the length only
const Id = v.pipe(v.string(), v.ulid())
v.safeParse(Id, '8ZZZZZZZZZZZZZZZZZZZZZZZZZ').success   // true below 1.5.0: above the 128-bit maximum

// Current
import * as v from 'valibot'

// Upgrade to ^1.5.0. On an older pin, bound the first character.
// The largest ULID is 7ZZZZZZZZZZZZZZZZZZZZZZZZZ (2^128 - 1).
const Id = v.pipe(v.string(), v.ulid(), v.regex(/^[0-7]/, 'ULID out of range'))
v.safeParse(Id, '7ZZZZZZZZZZZZZZZZZZZZZZZZZ').success   // true on every release
v.safeParse(Id, '8ZZZZZZZZZZZZZZZZZZZZZZZZZ').success   // false on every release

Written 2026-09-11 (JOURNAL/150) from execution, with the 1.5.0 release note, PR #1498 and the ULID spec read first. The shipped pattern is /^[\da-hjkmnp-tv-z]{26}$/iu on 0.42.x, /^[\da-hjkmnp-tv-zA-HJKMNP-TV-Z]{26}$/u on 1.0.0 through 1.4.2 and /^[0-7][\da-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$/u at 1.5.0. Probe row LF16a rises at 1.5.0, contiguous on all 11 rungs: 8ZZZZZZZZZZZZZZZZZZZZZZZZZ, 8ARZ3NDEKTSV4RRFFQ69G5FAV0 (upstream's example), 9 followed by 25 zeros, ABCDEFGHJKMNPQRSTVWXYZ0123 (the value upstream's own test suite had listed as valid) and a lowercase a followed by 25 zeros are all rejected, while 7ZZZZZZZZZZZZZZZZZZZZZZZZZ, its lowercase form, 26 zeros and the spec's example 01ARZ3NDEKTSV4RRFFQ69G5FAV pass in the same row. The LF16b window (all five out-of-range values accepted) holds from 0.42.0 up to 1.5.0. Its start is the ladder's floor, so it is a bound. LF16c is an invariant and holds on every rung: the correction's regex(/^[0-7]/) accepts the maximum and rejects the value above it. The stale_code and correct_code were executed as written on all 11 rungs. The stale line gives true on 0.42.0 through 1.4.2 and false on 1.5.0. The correct code gives its two commented results on every rung. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot PR #1498 (commit 1c4983f7), merged 2026-09-09, as listed in the 1.5.0 release notes · 2026-09-09 · The ULID specification — Overflow Errors when Parsing Base32 Strings · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF16a-LF16c, 2026-09-11) · 2026-09-11

Deprecated, or a better API now exists

Works today. It is the older idiom, and some of it is scheduled for removal.

guard / parseBoolean / isrc / domain / jwsCompact / cache

Added in valibot 1.3.0 (2026-03-17) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot 1.3.0 added the guard transformation action (narrow types with a type predicate), parseBoolean, the isrc, domain and jwsCompact validation actions, and a cache method that caches schema output by input. It also fixed creditCard to accept 13-digit Visa numbers.

The stale belief: That domain-name, ISRC and JWS-compact validation, and schema-level output caching, have no built-in support in valibot.

import * as v from 'valibot'

const Host = v.pipe(v.string(), v.domain())
const Token = v.pipe(v.string(), v.jwsCompact())

PROBED 2026-09-05 by battery valibot/v3 (JOURNAL/055) on the guard and cache halves: all five arms denied both capabilities. Charged twice against Claude Opus 5 on v3-c (F1 guard S3, F2 cache S3). 1.3.0 is admissible against Claude Opus 5 (stated 2026-05) and against Claude Fable 5.1 (stated 2026-06), and below the floor for Claude Sonnet 5 and Claude Fable 5. Both Fable 5.1 arms failed it and neither could be charged: see JOURNAL/055 on the direct-question wording that barred them. The isrc, domain and jwsCompact surfaces were deliberately NOT probed - each is named for the standard it validates, so the probe would measure naming rather than knowledge. CROSS-REFERENCE 2026-09-02 (JOURNAL/035): parseBoolean is the action that reads the literal words "true"/"false" (and 1/0, yes/no, on/off, enabled/disabled, case-insensitively, rejecting anything else). It is not interchangeable with 1.2.0's toBoolean, which is plain Boolean() and maps "false" to true — see the correction on LF1. A project on valibot < 1.3.0 that needs to read a boolean query parameter has no built-in for it and must validate the literals with picklist first. Executed against valibot 1.4.2: parse(pipe(string(), parseBoolean()), "false") returns false; the empty string is rejected with an issue rather than coerced.

Reproduced against: Claude Fable 5.1 (S3), Claude Opus 5 (S3) — valibot/v3-c, 2026-09-05; valibot/v4-a, 2026-09-06.

Source: valibot v1.3.0 release notes · 2026-03-17 · valibot v1.3.0 release notes — creditCard fix · 2026-03-17 · valibot 1.4.2, shipped package — parseBoolean truthy/falsy word lists · 2026-06-28

toCamelCase / toKebabCase / toPascalCase / toSnakeCase

Added in valibot 1.4.0 (2026-05-05) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot 1.4.0 added case-conversion transformation actions (toCamelCase, toKebabCase, toPascalCase, toSnakeCase) and the isoDateTimeSecond validation action. It also made intersect stop mutating its input, so frozen objects and arrays can be merged.

The stale belief: That valibot has no built-in naming-convention conversions, and that intersect cannot be used with frozen inputs.

import * as v from 'valibot'

const Slug = v.pipe(v.string(), v.toKebabCase())

PROBED 2026-09-05 by battery valibot/v3 (JOURNAL/055) on the case-conversion half, in both directions. All five arms denied the capability in the offer direction, and all five rejected a pull request using toKebabCase in the recognition direction - calling a real action an invention. NOTHING CHARGED: 1.4.0 published 2026-05-05, the same month as Claude Opus 5s stated cutoff, so the Index parks it against that subject; Claude Fable 5.1 (stated 2026-06) is the one subject it is admissible against, and both of its arms declined their stated cutoff. Five reproduced failures, zero charged, every one flagged chargeable_miss. Verified this session by installing 1.0.0, 1.1.0, 1.2.0, 1.3.0, 1.4.0 and 1.4.2 and diffing their exports: toUpperCase and toLowerCase are present at 1.0.0, so a draw that says valibot has upper/lower and nothing for camel/kebab/snake states the precise pre-1.4.0 truth. toTitleCase, used as this batterys poison rung, has never shipped in any release and is absent from all six export tables.

Reproduced against: Claude Fable 5.1 (S2) — valibot/v4-a, 2026-09-06.

Source: valibot v1.4.0 release notes · 2026-05-05 · valibot v1.4.0 release notes — intersect · 2026-05-05

isbn

Added in valibot 1.3.0 (2026-03-17) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot ships an isbn validation action covering ISBN-10 and ISBN-13, and it is usable from 1.3.0, not from 1.2.0. The v1.2.0 release notes announce it — the implementation was merged the day before that release — but 1.2.0's library/src/actions/index.ts never re-exports it, so the published 1.2.0 package contains no trace of isbn in its runtime, its types or its bundle. The export line appears at 1.3.0, and 1.3.0's own release notes do not mention isbn at all. On valibot 1.2.x the hand-written check digit is still the only option.

The stale belief: That valibot has no ISBN validator at any version and the check digit must be implemented by hand in a custom check. (Correct for every release up to and including 1.2.x; wrong from 1.3.0.)

// Stale
import * as v from 'valibot'

const Book = v.object({
  id: v.pipe(v.string(), v.check((s) => /^(?:\d{9}[\dX]|\d{13})$/.test(s), 'Invalid ISBN')),
})

// Current
import * as v from 'valibot'

const Book = v.object({
  id: v.pipe(v.string(), v.isbn()),
})

CORRECTED 2026-09-02 (JOURNAL/040), by the Node auditor. This entry was filed at 1.2.0 / 2025-11-24 on the strength of the v1.2.0 release note quoted below, and the audit at 1.2.0 failed with TS2339: Property 'isbn' does not exist. The shipped 1.2.0 package has zero occurrences of the string isbn in index.mjs, index.cjs, index.d.mts, index.d.cts or either minified bundle. Cause, from the tagged source: PR #1097 (feat: ISBN validation) merged 2025-11-23, one day before the tag, and library/src/actions/isbn/isbn.ts is present at tag v1.2.0 — but library/src/actions/index.ts at that tag goes straight from ipv6 to isoDate, so the module was never wired into the public entry point and was dropped from the bundle. The barrel line export * from './isbn/index.ts'; first appears at tag v1.3.0. Neither release's notes are true about which release made this usable: 1.2.0 announces an action its own package does not contain, and 1.3.0 ships it without a word. This is the second instance in the Index of a release note that is wrong about its own release (JOURNAL/027 is the first) and the first that was caught by the auditor rather than by hand. One published finding was withdrawn as a result: valibot--claude-fable-5--v1--2026-09-01 F2 charged Fable 5 (stated cutoff 2026-01) for denying that isbn exists — an answer that was true of every valibot release that existed at that cutoff. It is now recorded as a correct answer in that run's non-findings. The Opus 5 charge (stated cutoff 2026-05) survives the date move and is re-cited here.

Reproduced against: Claude Opus 5 (S3) — valibot/v1, 2026-09-01.

Source: valibot v1.2.0 release notes — the announcement the package does not honour · 2025-11-24 · valibot source at tag v1.2.0 — the action barrel, with no isbn line between ipv6 and isoDate · 2025-11-24 · valibot 1.2.0, shipped package — the export list in dist/index.d.mts skips isbn · 2025-11-24 · valibot source at tag v1.3.0 — the barrel line that first publishes the action · 2026-03-17 · valibot 1.3.0, shipped package — the same export list, now carrying isbn · 2026-03-17

codePoints / minCodePoints / maxCodePoints / notCodePoints

Added in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

valibot 1.5.0 added four validation actions that count Unicode code points: codePoints, minCodePoints, maxCodePoints and notCodePoints. That makes three units of string length in valibot, and they disagree on anything outside the Basic Multilingual Plane. length, minLength and maxLength count UTF-16 code units, so three 😀 count as 6 and fail maxLength(3). The grapheme actions (graphemes, minGraphemes, maxGraphemes and notGraphemes, since 1.0.0) count user-perceived characters, so three 👍🏽 count as 3 and pass maxGraphemes(3), though each is two code points. The new actions count what [...s].length counts, lone surrogates included: three 😀 are 3 and three 👍🏽 are 6. Use the action whose unit matches the limit you are enforcing. Below 1.5.0, check((s) => [...s].length <= n) gives the code-point count, and it gives the same count on every release.

The stale belief: That maxLength counts characters, so it can enforce a limit defined in code points, and that valibot has no code-point count. maxLength counts UTF-16 code units on every release, and the code-point actions arrived in 1.5.0.

// Stale
import * as v from 'valibot'

// maxLength counts UTF-16 code units, not characters
const Name = v.pipe(v.string(), v.maxLength(3))
v.safeParse(Name, '\u{1F600}'.repeat(3)).success   // false: three emoji are 6 units

// Current
import * as v from 'valibot'

// valibot 1.5.0 and later: counts code points, as [...s].length does
const Name = v.pipe(v.string(), v.maxCodePoints(3))
v.safeParse(Name, '\u{1F600}'.repeat(3)).success           // true: 3 code points
v.safeParse(Name, '\u{1F44D}\u{1F3FD}'.repeat(3)).success  // false: 6 code points (3 graphemes)

// any release: the same count by hand
const NameAnyRelease = v.pipe(v.string(), v.check((s) => [...s].length <= 3))
v.safeParse(NameAnyRelease, '\u{1F44D}\u{1F3FD}'.repeat(3)).success  // false on every release

Written 2026-09-11 (JOURNAL/146) from execution, with the 1.5.0 release note and PR #888 read first. Probe row LF13a (all four actions exist; three U+1F600 pass maxCodePoints(3), three U+1F44D U+1F3FD fail it and pass codePoints(6); minCodePoints(2) rejects one U+1F600 and accepts one U+1F44D U+1F3FD; notCodePoints(2) the reverse) rises at 1.5.0, contiguous on all 11 rungs. LF13b rises at 1.5.0: ten strings, among them a lone high surrogate, a lone low surrogate, a reversed pair and a string mixing all three kinds, each pass codePoints([...s].length) and fail it at one more. The shipped counter _getCodePointCount also agreed with [...s].length on 200,000 random strings built from lone and paired surrogates (survey, not a probe row). LF13c dates the grapheme unit at 1.0.0 off that release's note: three U+1F44D U+1F3FD pass maxGraphemes(3) and graphemes(3), and neither action exists on 0.42.x. LF13d is an invariant and holds on every rung: maxLength(3) rejects three U+1F600 and maxLength(6) accepts them, and the by-hand check accepts three U+1F600 and rejects three U+1F44D U+1F3FD. The stale_code and correct_code were executed as written on all 11 rungs. The stale line gives false on every rung. The correct code gives its three commented results on 1.5.0 and throws on every rung below, where maxCodePoints does not exist; its by-hand line alone gives false on every rung. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot PR #888 (commit 4c970739), merged 2026-08-10, as listed in the 1.5.0 release notes · 2026-08-10 · valibot v1.0.0 release notes — the grapheme actions the statement contrasts with · 2025-03-19 · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF13a-LF13d, 2026-09-11) · 2026-09-11

ksuid

Added in valibot 1.5.0 (2026-09-09) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0 · published after every cutoff in this dataset — listed for completeness, no model can be faulted for it yet

valibot 1.5.0 added a ksuid validation action and exports its pattern as KSUID_REGEX. It checks the shape only: exactly 27 ASCII letters or digits. A KSUID is a 160-bit number, and the largest one encodes as aWgEPTl1tmebfsQzFP4bxwgy80V. ksuid() does not check the value, so every 27-character string above that passes, zzzzzzzzzzzzzzzzzzzzzzzzzzz included, and segmentio's reference parser rejects them as out of range. About 41% of all 27-character alphanumeric strings are above the maximum. The length is fixed and the base62 alphabet (0-9A-Za-z) is in ASCII order, so comparing the string with the maximum using <= is an exact value check.

The stale belief: That valibot has no KSUID validator, so it takes a hand-written regex. On 1.5.0, also that ksuid() rejects a string no KSUID can encode. It checks the shape only.

// Stale
import * as v from 'valibot'

// before 1.5.0: a hand-written shape check, with no value bound (v.ksuid() on 1.5.0 is the same)
const Id = v.pipe(v.string(), v.regex(/^[a-zA-Z0-9]{27}$/))
v.safeParse(Id, 'zzzzzzzzzzzzzzzzzzzzzzzzzzz').success   // true, but above the 160-bit maximum

// Current
import * as v from 'valibot'

// valibot 1.5.0 and later. ksuid() checks the shape only, so add the value bound.
// The length is fixed and the base62 alphabet is in ASCII order, so string order is numeric order.
const KSUID_MAX = 'aWgEPTl1tmebfsQzFP4bxwgy80V'
const Id = v.pipe(
  v.string(),
  v.ksuid(),
  v.check((s) => s <= KSUID_MAX, 'KSUID out of range'),
)
v.safeParse(Id, '0uk1Ha7hGJ1Q9Xbnkt0yZgNwg3g').success   // true
v.safeParse(Id, 'zzzzzzzzzzzzzzzzzzzzzzzzzzz').success   // false

Written 2026-09-11 (JOURNAL/146) from execution, with the 1.5.0 release note, PR #1370 and segmentio/ksuid's ksuid.go read first. The shipped pattern is /^[a-zA-Z0-9]{27}$/u, and the action does nothing else. Probe row LF14a rises at 1.5.0, contiguous on all 11 rungs: the README's example KSUID, the minimum (27 zeros) and the maximum pass; 26 and 28 characters and a trailing hyphen fail; KSUID_REGEX is exported. LF14b rises at 1.5.0: the string one above the maximum and 27 zs pass ksuid(), and the correction's <= check rejects both while accepting the maximum. If a later release adds a value bound, LF14b breaks. The maximum was computed rather than copied: 2^160 - 1 in the alphabet 0-9A-Za-z is aWgEPTl1tmebfsQzFP4bxwgy80V, equal to the reference implementation's maxStringEncoded. The same decoder turns the README's 0uk1Ha7hGJ1Q9Xbnkt0yZgNwg3g into exactly the timestamp (107611700) and payload the README prints beside it. The <= comparison agreed with a BigInt decode on 300,000 strings, half of them sharing a random-length prefix with the maximum. The 41% is (62^27 - 2^160) / 62^27. The same release made ulid reject values above its 128-bit maximum (#1498, measured on the ladder: rejected only at 1.5.0); ksuid got no such bound. The stale_code and correct_code were executed as written on all 11 rungs. The stale line gives true on every rung. The correct code gives its two commented results on 1.5.0 and throws below it, where ksuid does not exist. Published after every stated cutoff in the corpus, so this fact charges no subject. It is here for the agent reading the pack.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.5.0 release notes · 2026-09-09 · valibot PR #1370 (commit 17d23a47), merged 2026-07-27, as listed in the 1.5.0 release notes · 2026-07-27 · valibot 1.5.0, shipped package — the whole of the check, dist/index.mjs · 2026-09-09 · segmentio/ksuid, the reference implementation — the encoded maximum its Parse enforces · Executed against the published packages (tools/audit/bisect-facts.mjs rows LF14a-LF14b, 2026-09-11) · 2026-09-11

examples / getExamples

Added in valibot 1.2.0 (2025-11-24) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot has first-class example values since 1.2.0: the examples action attaches them to a schema and the getExamples method reads them back. Do not overload description or a custom metadata action to carry examples.

The stale belief: That valibot's metadata surface is limited to title/description and examples must be stored outside the schema.

// Stale
import * as v from 'valibot'

const Email = v.pipe(
  v.string(),
  v.email(),
  v.description('An email address, e.g. a@b.com')
)

// Current
import * as v from 'valibot'

const Email = v.pipe(
  v.string(),
  v.email(),
  v.examples(['a@b.com', 'c@d.org'])
)

const examples = v.getExamples(Email)

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.2.0 release notes · 2025-11-24 · valibot v1.2.0 release notes — getExamples · 2025-11-24

message

Added in valibot 1.1.0 (2025-05-06) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot ships a message method since 1.1.0 that overrides the error message configuration for one schema locally, without touching global config.

The stale belief: That per-schema message overrides must be passed argument-by-argument to each action, or set globally with setGlobalMessage.

// Stale
import * as v from 'valibot'

v.setGlobalMessage('Invalid input')  // global — affects everything

// Current
import * as v from 'valibot'

const Port = v.message(
  v.pipe(v.number(), v.minValue(1), v.maxValue(65535)),
  'Port must be between 1 and 65535'
)

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.1.0 release notes · 2025-05-06

NanoIdAction / NanoIdIssue

Renamed in valibot 1.1.0 (2025-05-06) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

The NanoIDAction and NanoIDIssue interfaces were renamed to NanoIdAction and NanoIdIssue in 1.1.0, and the old spellings were kept as exported @deprecated type aliases. Write NanoIdAction; NanoIDAction still resolves — it is exported from every release from 1.1.0 through 1.4.2 and points at the new interface — so the cost of the stale spelling is a deprecation, not a broken build.

The stale belief: That the nano-ID action's types spell the acronym in full caps, as they did up to 1.0.0. Correct for 1.0.0 and below as the only spelling; from 1.1.0 it is the deprecated alias.

// Stale
import type { NanoIDAction } from 'valibot'

// compiles on every 1.x release, flagged @deprecated from 1.1.0
function describe(action: NanoIDAction<string, undefined>) {
  return action.type
}

// Current
import type { NanoIdAction } from 'valibot'

function describe(action: NanoIdAction<string, undefined>) {
  return action.type
}

CORRECTED 2026-09-07 (JOURNAL/072), by the fact bisector, against the shipped declaration files. This entry was published as S1 / breaks-build on the claim that "the old casing is not exported and a type import of it fails to compile". Both halves are false. valibot 1.1.0's dist/index.d.ts declares an alias tagged @deprecated Use NanoIdAction instead — type NanoIDAction<...> = NanoIdAction<...> — next to the renamed interface, and both spellings appear in the file's single export { ... } clause; valibot 1.4.2's dist/index.d.mts still does, sixteen months later. So import type { NanoIDAction } from 'valibot' compiles on every release the Index covers. What the release note calls a rename is a rename plus a compatibility alias the note does not mention, which is the same failure mode as LF3 and JOURNAL/027: the note is accurate about the change and silent about what shipped alongside it. Re-dating was not needed — the bisector confirms the new names first appear at exactly 1.1.0 and are absent at 1.0.0 — so this is the first correction in the Index that moves a fact's severity and mechanism while leaving its date intact. No published finding rests on it: the one battery task built on this surface (valibot--claude-opus-5--v1r-a, task 9) passed, and the draw hedged the casing rather than asserting it. A type-level matter either way: the runtime nanoid() action was never renamed.

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.1.0 release notes · 2025-05-06 · valibot 1.1.0, shipped package — dist/index.d.ts keeps the old spelling as a deprecated alias · 2025-05-06 · valibot 1.4.2, shipped package — all four names are still in the export clause of dist/index.d.mts · 2026-06-28

parseJson / stringifyJson

Added in valibot 1.1.0 (2025-05-06) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot ships parseJson and stringifyJson transformation actions since 1.1.0. Parsing JSON inside the pipeline makes a malformed string surface as a valibot issue like any other, instead of as a thrown SyntaxError you have to catch separately.

The stale belief: That JSON must be parsed outside the schema, with its own try/catch, before validation can begin.

// Stale
import * as v from 'valibot'

let data
try {
  data = JSON.parse(raw)
} catch {
  throw new Error('malformed JSON')
}
const config = v.parse(Config, data)

// Current
import * as v from 'valibot'

const Config = v.pipe(
  v.string(),
  v.parseJson(),
  v.object({ port: v.number() })
)

const result = v.safeParse(Config, raw)
// a malformed string and a schema violation are both issues now

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.1.0 release notes · 2025-05-06

summarize

Added in valibot 1.1.0 (2025-05-06) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot ships a summarize method since 1.1.0 that turns an issue list into a pretty-printable multi-line string. Use it instead of looping over issues to build a human-readable error report.

The stale belief: That rendering issues for a human requires a hand-written loop over result.issues or a flatten call.

// Stale
import * as v from 'valibot'

const result = v.safeParse(Schema, input)
if (!result.success) {
  console.error(result.issues.map((i) => `- ${i.message}`).join('\n'))
}

// Current
import * as v from 'valibot'

const result = v.safeParse(Schema, input)
if (!result.success) {
  console.error(v.summarize(result.issues))
}

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.1.0 release notes · 2025-05-06

exactOptional

Added in valibot 1.0.0 (2025-03-19) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot has exactOptional and exactOptionalAsync since 1.0.0, for a key that may be absent entirely but must not be present and set to undefined. optional allows both.

The stale belief: That valibot cannot express the difference between a missing key and a key explicitly set to undefined.

// Stale
import * as v from 'valibot'

// allows { name: undefined } as well as {}
const User = v.object({ name: v.optional(v.string()) })

// Current
import * as v from 'valibot'

// allows {} but rejects { name: undefined }
const User = v.object({ name: v.exactOptional(v.string()) })

Reproduced against: no model yet. Verified from the primary source only — this is a correction, not an Index entry.

Source: valibot v1.0.0 release notes · 2025-03-19

Wrong facts about the library

Not code — versions, minimums and metadata that models state confidently and get wrong.

github.com/fabian-hiller/valibot

Renamed in valibot 1.2.0 (2025-11-24) · boundary measured on 11 releases, valibot 0.42.0 through 1.5.0

valibot's repository is github.com/open-circle/valibot. It moved out of the fabian-hiller personal namespace into the open-circle organisation, and the npm repository field flipped with the 1.2.0 release. The old URL still 301-redirects, so a link to it works — but attributing the project to a personal account is wrong about who maintains it.

The stale belief: That valibot is a personal project living at github.com/fabian-hiller/valibot.

# Stale
https://github.com/fabian-hiller/valibot

# Current
https://github.com/open-circle/valibot

Dated from primary metadata rather than an announcement: the npm repository field reads fabian-hiller/valibot for 1.0.0 and 1.1.0 and open-circle/valibot from 1.2.0 onward. The docs site remains valibot.dev.

Reproduced against: Claude Fable 5 (S4), Claude Opus 5 (S4), Claude Sonnet 5 (S4) — valibot/v1, 2026-09-01.

Source: npm registry metadata for valibot — repository field per version · 2025-11-24

Not corrections — recorded for honesty

Claims seen in a run but not yet verified against a primary source. Never treated as findings:

  • Direct question (b) in this battery asked the subject which cutoff it would trust — the context value or its own sense — and both Claude Fable 5.1 arms repudiated the context value, which barred every charge on this half of the battery. valibot/v2, which asked the plain "What is your training cutoff?", got affirmation plus a density caveat from all six arms and charged three findings. Is the repudiation a property of this subject or of the question? (open since 2026-09-05)
  • Both valibot draws landed one release below valibot/v1 while agreeing exactly with each other. The difference between the runs is how a hedged recall is scored: v1 said it could describe 1.1.0 "with moderate confidence" and was scored at 1.1.0; these draws called 1.1.0 a version number with no content and were scored at 1.0.0. The battery still has no rule for a subject that offers a confident boundary and a hedged one in the same answer - the same gap prisma/v1r-a logged. Two libraries have now hit it. (open since 2026-09-01)
  • This draw asserted that valibot removed a coerce action in the 0.31 redesign and has shipped no coercion since, calling it "a deliberate design position, not a gap". The first half is a claim about pre-1.0 history the Index has never verified; the second half is false as of 1.2.0. Worth pinning from the 0.31.0 release notes at the next valibot touch, because a confidently-stated false history is a different failure mode from a missing recent release and the Index has no category for it. (open since 2026-09-01)

Findings, code and citations: data/valibot/ — one JSON file and one write-up per model, each finding carrying the release that broke the belief, its publication date and a verbatim quote from the primary source. Corrections: data/valibot/facts.json. This file is generated by tools/build-corrections.mjs; if the prose and the data ever disagree, that is a bug in the generator, not a stale pack.


The Stale Priors Index, Unattended Works (https://stalepriors.com/corrections/valibot.md), CC BY 4.0. Retrieved 2026-09-11. Licence: <https://creativecommons.org/licenses/by/4.0/> (SPDX-License-Identifier: CC-BY-4.0). Quoted changelog text belongs to its authors; nothing here grants any right in the libraries described. Terms in full: <https://stalepriors.com/data/LICENSE>.

Honest note on maintenance: this pack is generated, not written by hand. tools/build-corrections.mjs renders it from the verified release facts in data/valibot/facts.json joined to the findings in the runs it cites, and the build fails if a finding names an API no verified fact corrects. Hand-written packs drifted from the data twice before this was automated; the dataset remains the authority.