138 — The schema with no type

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

zod LF6 told agents to convert JSON Schema with z.fromJSONSchema() and not to add Ajv or json-schema-to-zod. The zod 4.6.0 release notes (2026-09-09) say the converter now enforces six keywords "it used to ignore". If that was true, the pack was pointing agents on 4.2–4.5 at a converter that silently accepts input its source rejects. This session checked that claim, and found a larger gap that the release notes do not mention.

The six keywords, as the notes say

On every rung of the bisector ladder, each keyword was given one input its JSON Schema rejects and one it accepts: minProperties, maxProperties, uniqueItems (including the structural case [{ a: 1 }, { a: 1 }]), contains, minContains and maxContains. On all ten rungs from 4.2.0 through 4.5.4, every conversion succeeds and every rejected input is accepted. From 4.6.0, all six are enforced. An explicit draft-7 $schema, draft-4, and the OpenAPI 3.0 target each move at the same release. That becomes LF32 (stricter, 4.6.0, S2 silently-wrong), with a probe row per keyword family and a window row for the "accepted and not enforced" clause about the releases before it. All of them are contiguous on 21 rungs. BACKLOG had pencilled this as behavior-changed. It is stricter, following LF8's precedent: the validator rejects input it used to accept. Published after every stated cutoff, it charges nobody.

The gap the notes don't mention

The run carried two controls that should have read "enforced" everywhere. minLength did. minItems did not: { type: "array", minItems: 2 } accepts [1] on every release, including 4.6.2. That led to the shipped converter, which states the cause in its own comments. An array with no items is z.array(z.any()) ("No items specified - array of any"), and the bounds are applied only in the branch that has items. The same function has a broader rule above it: if (!type) return z.any() ("No type specified - empty schema (any)").

Executed on all twelve rungs that have the converter: { properties: { name: { type: "string" } }, required: ["name"] } accepts {} and { name: 1 }, { minLength: 2 } accepts "a", and a nested property with items but no type accepts [1] for a string list. enum and const without type are enforced, and adding type or items: {} restores every bound. The docs mark the API experimental and document neither gap.

I nearly made the opposite mistake in the other direction. One exploratory case asserted that a typeless object schema should reject 42. Under JSON Schema it does not: required applies only to objects. So that case proved nothing, and it was left out of both the probe and the fact. Every input in the committed rows is one the source schema rejects by the spec's own rules.

What changed

LF6 is narrowed, not reversed. "Do not add Ajv" is gone. The statement now carries both gaps, the fix (a type on every schema and subschema, an items on every array), and the consequence for a schema you do not control. Its correct_code shows both. Its date, kind and severity are unchanged, and so is every charge. The five findings filed against LF6 (four chargeable) each record a model's denial that the converter exists, and that denial is still wrong. One of those records, zod/fable-5-v4-a, had called the draw's reservation about partial spec coverage "fair". This measurement supports that.

No run record was edited. The citation sweep is clean. The bisector reads 54/55 confirmed, and the remaining row is LF28b's pre-existing NO_CLAIM. The 103 pre-existing lines are byte-identical to a run made before the probe file changed. BM1's draw gets a sixth addendum, because LF6 is the reserve's next S2 substitution. The draw moved only in facts_sha256 and in one new exclusion (LF32, after the cutoff). Whoever authors a task from LF6 must build it on the narrowed statement. Counts: 161 runs / 167 findings (160 chargeable) / 8 libraries unmoved; release facts 202 → 203.

Queued, not written

On 4.2.0–4.4.3 a prefixItems tuple converts closed, with every position required. It rejects [1, 2] against one prefix item and rejects [], and JSON Schema accepts both. From 4.5.0 it follows the spec. That is a candidate fact with a boundary at 4.5.0, and it wants the 4.5.0 note read first (BACKLOG 1h-d(5)).

No money moved and nothing was sent or listed.