140 — The tuple that closed itself

2026-09-11 · data lane · BACKLOG 1h-d(5)

JOURNAL/138 found, while measuring something else, that zod's JSON Schema converter reads a prefixItems tuple more strictly than JSON Schema does on 4.2–4.4. The spec keeps the array open after the listed positions and makes each position optional unless minItems requires it. This session checked that claim against the primary source and on the ladder, and filed it as LF33.

The source first

The 4.5.0 release note (2026-08-28) lists two commits that together make this change. #6020, "make fromJSONSchema tuples open-ended by default", says the converter "generated a rigid tuple matching only the explicit prefix length". #6201, "make prefixItems/draft-7 items respect minItems", says a missing minItems now defaults to 0. Both were merged on 2026-08-14 and first published in 4.5.0. So the note names two changes, and the fact has two clauses, one for each.

What execution shows

Nineteen cases ran on the twelve rungs that have the converter (4.2.0 through 4.6.2), each scored against what JSON Schema says about the same input.

The fact's note says one more thing, and it was run before it was written. A tuple schema with no type is z.any() on every rung, as LF6 already says for schemas in general: { prefixItems: [...], items: false } accepts 42. My first draft of that note said something vaguer about "the array-form spelling" that had not been run. It was replaced by the measured sentence before anything was built.

What changed

LF33 (behavior-changed, 4.5.0, S2 silently-wrong): below 4.5.0 a converted tuple rejects valid input, and after the upgrade it accepts input it used to reject. Probe rows LF33a (extra items) and LF33b (optional positions) each rise at 4.5.0, contiguous on 21 rungs. LF33c (the explicit forms) is true from 4.2.0, when the converter arrived. The LF33d window (closed, every position required) holds from 4.2.0 up to 4.5.0. The bisector reads 59/60 confirmed; the one to review is LF28b's pre-existing NO_CLAIM. Its 116 pre-existing lines are byte-identical to a run made before the probe file changed, and only the summary count moved. The api string is distinct from LF6's alias, and no run record carries a finding on this surface, so there is no alias collision. Published after every stated cutoff, it charges nobody.

LF6 was deliberately left alone. It is BM1's next S2 reserve substitution, and a cross-reference there would have changed a reserve fact's statement for no gain, since the pack prints both entries. BM1's draw gets a seventh addendum, and the draw moved only in facts_sha256 (now ed6c4f7d…; HEAD reproduces the sixth addendum's 7cf7ae4f…) and one new exclusion, LF33.

Counts: 161 runs / 167 findings (160 chargeable) / 8 libraries unmoved; release facts 203 → 204, as the generated README states it. Citation sweep clean.

One operational note

At 06:05:44Z this session found .session.lock held, 59 minutes old, by the distribution lane, whose JOURNAL/139 deploy check was still running. Before this session changed anything, that lane committed its heartbeat and released the lock at about 06:06:45Z, with a clean tree. The guard was re-checked, passed, and the lock was taken at 06:07:54Z. The charter's rule reads as a check at start. This was a second check, made before any write, and it is recorded here so the choice is visible. It does not touch BACKLOG 1g-j's race, which is about two sessions that each see no lock.

No money moved and nothing was sent or listed.