ADR 0033 — Sound flatten: collision widening, discriminator support, probe parity
- Status: Accepted — completes/repairs ADR 0031.
- Date: 2026-06-22
Context
flattenUnionInput (ADR 0031) advertises a discriminated union as one flat
object so weak models avoid oneOf. A 5-agent audit found the merge was
first-wins (flatten.ts): when two variants share a key with a different
shape, one is silently dropped → the advertised schema is stricter than the
original union → for the losing variant no valid input exists (advertised
rejects one form, the union rejects the other). Confirmed on media: object
(message) vs media: array (mediaGroup). The same defect surfaces on differing
enums/literals (K2), object shapes (K3), defaults (K4) and nested (K5). The audit
also found: a multi-value/enum discriminator was mishandled or crashed the
whole mount via a raw throw; validateMcpSchemas validated the un-flattened
schema (blind to what ships); params + union input produced a non-mountable
allOf; and the deep walk missed plain-union / record members.
Invariant: the advertised (flattened) schema must accept a superset of the original union — never be stricter on any path — so a model can always produce a value that passes both the SDK and validation.
Decision
- Collision widening (superset by construction, zero
oneOf/anyOf). Per key across variants, dedupe by normalized JSON Schema (annotations stripped): one distinct → keep; all string literal/enum → one widenedz.enum; otherwise →z.unknown()(accepts every variant’s value; the.describe()carries the per-variant shape). NoanyOfis introduced, so ADR 0031’s “nooneOf/anyOfat any depth” still holds — no reversal. - Discriminator: collect all literal values (multi-value
z.literal) and accept az.enumdiscriminator; a non-string discriminator / non-object variant makes the union non-flattenable —flattenUnionsDeepleaves it untouched instead of throwing (graceful, no mount crash). - Probe parity.
validateMcpSchemastakes the mount options (extend/flattenUnionInput) and thecreateMcpHandlerbuild-time check forwards them, so the deploy probe vets the same schema that ships. params+ union input. Flattenparamsandinputseparately, then merge — a union input becomes aZodObjectand merges with params into one object (mountable), instead of anallOfintersection.- Deeper walk. Recurse into plain
ZodUnionmembers andZodRecordvalues (a discriminated union nested there now flattens too). coerceJsonArgsrecursion. Coerce double-serialized values at any depth (object fields, array items, the matching variant of a discriminated union) — not just top level — closing the advertise(object)≠coerce(union) gap.
Alternatives considered
- Per-field
anyOffor collisions (the audit’s first instinct). Rejected — it re-introduces the construct ADR 0031 removed and would reverse that ADR + break its test. Value-widening (enum) andz.unknown()keep zeroanyOfand are equally satisfiable. - Hard-reject
.strict()variants. Deferred — strictness is dropped from the advertised hint (validation still enforces it); it is invariant-safe (looser). The residual cross-variant “lure” is a guidance issue the describe hint mitigates; revisit if a second consumer reports it.
Consequences
- Additive — no breaking change. Same
flattenUnionInputflag; the advertised schema is now a sound superset. Ships in 0.15.0. - The whole advertise≠validate class is closed for the confirmed kinds (K1–K5, discriminator, params+union, deep), and the build probe now sees the real schema.
- Collision soundness covers JSON-invisible checks at any depth (0.15.1): a key
that collides across variants and whose kept schema carries any check — directly,
or nested under an object field / array element /
.pipe()output /optional/nullable/defaultwrapper — is widened toz.unknown()(a.refine()/ custom check / pipe constraint does not serialize to JSON Schema, so it would otherwise leak onto a sibling variant). The 0.15.0 check was shallow (top node only); 0.15.1 made it deep. - Known residual (documented, not silent):
.strict()/catchall/object-level refinements are dropped from the advertised hint; a non-flattenable union (non- string discriminator) keeps itsoneOf;ZodTuple/ZodMap/ZodSetmembers andz.lazyare not walked (coerceJsonArgslikewise stops at az.lazyboundary). All are best-effort and invariant-safe (looser advertised / missed coercion that validation still rejects — never a false reject).