ADR 0031 — Deep discriminated-union flattening for tool schemas
- Status: Accepted — completes the
flattenUnionInputfeature; refines ADR 0007 / ADR 0014. - Date: 2026-06-22
Context
flattenUnionInput advertises a discriminated union as a single flat object
(discriminator → enum, variant fields → optional + Required if … hint) so a
tool transport carries no oneOf / anyOf — a construct weaker models drop or
mangle. But it only ever flattened the top level:
- The call-site flattened only when the entire merged input was a
ZodDiscriminatedUnion(mount.ts); a normal object input with a union nested inside it was a no-op. flattenDiscriminatedUnionitself is shallow — it copies a variant’s fields verbatim, never recursing into them.
So a contract like broadcast_create — input { name, content } where
content.parts[] is an array of a discriminated union — shipped that nested union
to the model as oneOf. A consuming project proved the impact with a byte-level
A/B on a weaker model: with the nested oneOf, the model dropped the field or
collapsed newlines in strings; with the same schema minus oneOf, output was
correct 3/3. The feature did not deliver what its own docstring promised (“flatten
… for transports that cannot represent oneOf”).
Decision
Make flattenUnionInput deep: a new flattenUnionsDeep walks the merged
input schema and replaces every ZodDiscriminatedUnion — at the top level and
nested inside object fields, array items, and optional / nullable / default /
intersection wrappers — with its flattened object form, recursing into the result
(a variant field may itself hold a union). .describe() text is preserved through
the rebuild. The call-site now always runs the deep walk when the flag is set,
subsuming (and fixing) the old top-level-only check.
Properties kept from the shallow version:
- Advertised-only and lossy. The original
params/inputschemas remain the validation schemas inexecuteToolMethod; only the advertised hint changes, so a mis-flatten degrades a hint, never validation. - Opt-in. Behind the existing
flattenUnionInputflag — no change for anyone not setting it. - Best-effort. Schemas a transform cannot safely rebuild (refined / piped /
lazy / plain non-discriminated unions) are left untouched; a discriminated union
behind one of those keeps its
oneOf(documented).
Alternatives considered
- Document the shallow limit, do nothing (the consumer’s fallback option).
Rejected — the feature exists precisely to remove
oneOffor weak transports; leaving nestedoneOfmeans it silently fails its purpose. The evidence (a rigorous A/B) is strong, and the fix is low-risk because it is advertised-only. isMutation-style heuristics / per-model schema variants. Out of scope — the flat-object form already works for every model (a B-test shows the strong models were fine before and stay fine).
Consequences
- Additive — no breaking change. Same flag, same semantics, applied at depth.
A nested array-of-union now advertises each item as a flat object (all variant
fields optional) rather than
oneOf— messier for a human reading the schema, but what weak models handle, and validation is unchanged. Ships in 0.13.0. flattenUnionInputnow delivers its docstring — nooneOfat any depth.flattenUnionsDeepis exported fromstitchkit/toolsbesideflattenDiscriminatedUnion.