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...in yields 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:

arm0.42.0 … 1.4.21.5.0
undeclared symbol key vs strictObjectacceptedaccepted
undeclared symbol key in looseObject outputdroppeddropped
undeclared symbol key vs objectWithRest's rest schemanever validatednever validated
undeclared non-enumerable own string key vs strictObjectacceptedaccepted
inherited enumerable key vs strictObjectrejectedrejected
control: ordinary undeclared string keyrejectedrejected
control: constructor (what LF18 is about)acceptedrejected

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:

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.