# ADR 0033 — Sound flatten: collision widening, discriminator support, probe parity

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0033-sound-flatten-collisions.md

---

# ADR 0033 — Sound flatten: collision widening, discriminator support, probe parity

- **Status:** Accepted — completes/repairs [ADR 0031](0031-deep-union-flatten.md).
- **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

1. **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 **widened `z.enum`**;
   otherwise → **`z.unknown()`** (accepts every variant's value; the `.describe()`
   carries the per-variant shape). No `anyOf` is introduced, so ADR 0031's
   "no `oneOf`/`anyOf` at any depth" **still holds** — no reversal.
2. **Discriminator:** collect **all** literal values (multi-value `z.literal`) and
   accept a `z.enum` discriminator; a non-string discriminator / non-object
   variant makes the union **non-flattenable** — `flattenUnionsDeep` leaves it
   untouched instead of throwing (graceful, no mount crash).
3. **Probe parity.** `validateMcpSchemas` takes the mount options
   (`extend` / `flattenUnionInput`) and the `createMcpHandler` build-time check
   forwards them, so the deploy probe vets the **same** schema that ships.
4. **`params` + union input.** Flatten `params` and `input` **separately, then
   merge** — a union input becomes a `ZodObject` and merges with params into one
   object (mountable), instead of an `allOf` intersection.
5. **Deeper walk.** Recurse into plain `ZodUnion` members and `ZodRecord` values
   (a discriminated union nested there now flattens too).
6. **`coerceJsonArgs` recursion.** 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 `anyOf` for 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`) and `z.unknown()` keep zero `anyOf` and 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 `flattenUnionInput` flag; 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` / `default` wrapper — is widened to `z.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 its `oneOf`; `ZodTuple`/`ZodMap`/`ZodSet` members and
  `z.lazy` are not walked (`coerceJsonArgs` likewise stops at a `z.lazy` boundary).
  All are best-effort and invariant-safe (looser advertised / missed coercion that
  validation still rejects — never a false reject).
