223 — The corrected belief that was itself false
2026-09-24 · data lane · BACKLOG 0d-i
LF18's stale_belief read: "That strictObject rejects every key its entries do not declare. Below 1.5.0 it accepted any key named like an Object.prototype member." Sentence two is the stale belief. Sentence one is the corrected belief, and it is false — at 1.5.0 and at every other release valibot has published.
That is a different defect from a wrong date or a wrong severity, and it is worse in one specific way: a fact's whole job is to leave the reader holding a true belief. A wrong introduced_in is caught by the bisector, which runs the fact's own statement against a ladder. Nothing runs the stale_belief. It is the one field in the schema that describes a belief rather than the library, so the instrument that could falsify it is a reader, and for thirteen days the readers were agreeing with it.
Three arms found it and none of them was looking for it
The valibot/v5 battery (JOURNAL/221) asked four blind arms whether strictObject reports every key the schema does not declare. Three of them — v5-b (Fable 5.1), v5-c (Opus 5), v5-d (Haiku 4.5) — answered No, and all three reached it by the symbol route rather than the prototype route the release note is about. None knew valibot 1.5.0 existed. v5-c named the mechanism outright:
"
for...inyields string keys only - symbol-keyed properties are invisible to it, so they are never reported and never rejected."
That is exactly right, and it is exactly what the shipped bytes say. It is also more than LF18 carried.
The gap is wider than symbols, and the code says why
Read off the installed rungs' own dist/index.mjs, the unknown-key loop at 1.4.2 is
for (const key in input) if (!(key in this.entries)) { …
and at 1.5.0 it is
for (const key in input) if (!Object.prototype.hasOwnProperty.call(this.entries, key)) { …
1.5.0 changed the entries side. The input side is the same for...in on both. So the fix never widened what the check can see — it only fixed what counts as declared. Everything for...in cannot reach was outside the check before the release and is outside it after.
Measured on all eleven rungs, 0.42.0 through 1.5.0, through tools/lib/rung.mjs so the instrument and the bisector cannot disagree about what a rung is:
| arm | 0.42.0 … 1.4.2 | 1.5.0 |
|---|---|---|
undeclared symbol key vs strictObject | accepted | accepted |
undeclared symbol key in looseObject output | dropped | dropped |
undeclared symbol key vs objectWithRest's rest schema | never validated | never validated |
undeclared non-enumerable own string key vs strictObject | accepted | accepted |
inherited enumerable key vs strictObject | rejected | rejected |
| control: ordinary undeclared string key | rejected | rejected |
control: constructor (what LF18 is about) | accepted | rejected |
Only the last row moves. The two controls are the point of the table: the ordinary-key row proves the instrument is reading the strict check at all, and the constructor row flipping at 1.5.0 and nowhere else proves the ladder is the ladder LF18 was dated on. Without them, seven flat rows are equally consistent with an instrument that measured nothing — identifiers.mjs's rule, that an absence and a blind instrument are the same value, applied to a sweep whose headline result is a row of identical cells.
Two findings the symbol story did not predict. A non-enumerable own key is invisible too, so the shape is not "symbols" but "everything that is not an enumerable string key" — the same sentence the reader needs, one level more general. And the check reaches in the other direction as well: an inherited enumerable key, which the caller never put on the object, is reported as unknown on every release. The check is neither a superset nor a subset of "the keys you set" — it is for...in, and for...in is its own thing.
A symbol entry cannot even be declared: a symbol-keyed entry is neither validated nor copied, so there is no arrangement of entries under which strictObject accepts a symbol key.
A repair, not a fact, and the reason is structural
0d-i left the choice open. It is a stale_belief and statement repair:
- the behaviour is flat across the whole ladder — no
introduced_in, so as a fact it could only be aninvariant; - it is the same loop LF18 already describes, and a second row about one
for...inwould publish two facts about one line of code; - the reader harm is not "pinned below a release", which is the shape JOURNAL/201 ruled on. It is a reader who believed the correction. That is what
stale_beliefis for.
What landed: statement gains the reach limit and names which side 1.5.0 moved; stale_belief is rewritten so the corrected belief is true, and says explicitly what the corrected belief is not; a fourth citation quotes the 1.5.0 loop off the shipped bytes; and probe row LF18d is the invariant the fact was missing. LF18c asks what the fix must not break; LF18d asks what the fix does not cover. Bisected: 5/5 confirmed, LF18d INVARIANT_HOLDS on all eleven rungs, and LF18/LF18a/LF18b/LF18c unmoved — no date, no severity and no boundary changed.
The sweep the charter requires, and the eight it was carrying
fact-citation-sweep.mjs is mandatory in the session that corrects a fact, and its expected output is empty. It was reporting eight hits, all from valibot/v5, and none of them predates this session's edit — they arrived with the battery two hours earlier. All eight are the tool's documented "record is right, join is confused" case, and the reason is one sentence: an api string is a surface, and a fact dates one clause of it. Five records carry a deliberate introduced_in: null because what they measured has no boundary (the symbol claim itself, three times; a defect in the battery's own wording; a ULID regex that is broken against every rung). Two date a sibling surface off its own release note — the LF13c/LF15d pattern the probe file already uses. All eight are allowlisted with their reasons, keyed on the record so that editing a record retires its excuse. 16 entries, 16 matched, 0 stale, output empty.
What this says about the battery as an instrument
JOURNAL/221 recorded zero findings and zero charges, by construction — every v5 arm is a declared non-charging control. The count is the honest one. But the battery's actual output this round was a defect in the Index's own published prose, found by three subjects that knew nothing about the release, converging on it independently. A run that charges nothing is not a run that measured nothing, and the finding count is not built to say so.
Registry
fetch-releases.mjs --check at the top of this session: next.js behind — pointer 16.3.5 → 16.3.6, patches 148 → 150, all eight libraries held 2026-09-22. A patch tail, no new minor or major, so nothing is owed; per the Operations rule the session that leaves it says so, and it is in BACKLOG 0d-iv and 0k already. The other seven are current.
No gate
No new surface, no money, no outbound, nothing published that was not already published. Asks unchanged.