ADR 0044 — A collided field keeps its type
- Status: Accepted — narrows the collision rule of ADR 0033 while keeping its invariant; extends ADR 0031
- Date: 2026-08-06
Context
A field declared .int().min(0) in 8 of a contract’s 14 operations was
advertised to the model as a description and nothing else. On a live bot an
agent called the tool sixteen times in ninety seconds, each time sending "1"
where a number was required, each time getting VALIDATION_ERROR. Its own
reasoning log read “I need to pass numbers” — and the next call sent a string
again. Nothing was applied; a broadcast queued to 2192 recipients kept a wrong
button, and it stayed unsent only because the owner never pressed send.
The cause was ADR 0033’s collision rule: a key present in more than one variant
whose kept schema carried any check was widened to z.unknown(). The guard
is right in principle — two variants can normalise to the same JSON and still
validate differently through a .refine() or a pipe’s output side, and
advertising one variant’s hidden rule could reject another’s valid value. What
was wrong is the scope: .int() and .min(0) are visible in JSON Schema, and
the field lost its type because it was described too precisely.
Decision
Hidden is decided by what JSON can show, not by the name of a check. A
constraint is hidden when it cannot appear in the advertised document: a custom
check (.refine(), .superRefine()), an overwrite check (.trim(),
.toLowerCase() — worse than a rejection, it changes the value), a pipe’s
output side, or any node JSON Schema cannot represent at all and degrades to
{}. The kind list is a fast path over that question, not the definition of it.
The kind is read from check._zod.def.check; the top-level .check is the check
function, and comparing that to a string is silently always false — which would
have deleted the guard entirely while looking like a narrowing.
Where a constraint must go, the type stays. A collided field is one every
variant declared, so the type is provably shared, and a bare type is a superset
of every variant by construction. projectToBaseType returns z.number(),
z.string(), z.array(z.unknown()), z.looseObject({}) — loose, never strict,
so an advertised object cannot delete a key a variant keeps (→ ADR 0034).
Nullability is carried through: null is part of the accepted set, not a
constraint on it, and dropping it would advertise a rejection no variant makes.
The same rule applies where the variants disagree. A field that is a number
in every variant with different bounds, or an enum against a free string, used to
reach the model blank for the same reason. Each variant is projected; if they land
on one base type, that is the shared truth. Genuinely different kinds stay
unknown — JSON Schema could say "type": ["string","number"], but no Zod node
emits that and inventing one is a separate decision.
Two hazards keep unknown, and are scanned across every variant. Coercion
and .catch() accept more than their type keyword says, so the base type
would be a narrowing: z.coerce.number() takes "1", and advertising number
would reject it. Crucially the scan covers the whole collision set rather than the
kept schema alone — reading only the first would make the advertised result depend
on the order the variants happen to be written in.
Consequences
- The incident’s field advertises
{"type":"integer","minimum":0}. A model that is told the type stops guessing. - The failure channel moves. A bad call is now rejected by the MCP SDK’s
validateToolInputbefore the tool callback: noafterToolCall, noerrorHint, no audit row, and the client seesMCP error -32602instead of stitchkit’s envelope. The sixteen audit rows are what made this incident visible in the first place — a project that relies on them for detection should know they will not appear for this class any more. - Scalars are still not coerced, deliberately.
coerceJsonArgsonly JSON-parses a string when the schema wants a structure; nothing turns"1"into1, and after this change nothing can on the MCP path, because the SDK parses before stitchkit sees the arguments. A project that wants tolerance must add it outside that parse. Making the advertised schema coercing would contradict the whole point: it would tell the model one thing and accept another. - The CLI is unaffected — it never flattens (
flattenUnionInputdefaults to false), so this class could not reach that surface. - ADR 0033’s Consequences paragraph describing the old widen is annotated rather than left to contradict this one.
The check that ships with it
A schema this wrong converts cleanly, mounts cleanly and is advertised cleanly —
nothing fails until a model is confused by it in production. So
validateMcpSchemas gains requireTypedProperties: every advertised property
must carry type / enum / anyOf / $ref, with allowUntyped for the ones a
project means to leave free-form.
It is off by default and lives in a consumer-facing function on purpose. The framework ships no contracts, so a build-time check here would have nothing to inspect and could never have caught this; the defect lived in a consuming project’s contract, and that is where the check has to run.