Skip to content

fix: embedded schema's additionalProperties swallows the outer schema's declared fields - #53

Merged
giraffesyo merged 2 commits into
canaryfrom
fix-embedded-catchall-promotion
Aug 5, 2026
Merged

fix: embedded schema's additionalProperties swallows the outer schema's declared fields#53
giraffesyo merged 2 commits into
canaryfrom
fix-embedded-catchall-promotion

Conversation

@giraffesyo

Copy link
Copy Markdown
Member

Fixes #50.

What was broken

Go promotes an embedded type's methods, and type shadow T does not stop that: the shadow still embeds the parent. So for a composed schema whose parent has additionalProperties, json.Unmarshal into the child ran the parent's unmarshaler, which swept the child's declared fields into the parent's catch-all map while the typed fields stayed nil. The round trip looked lossless, which is why it hid. The union variant of the same bug was loud: the promoted union marshaler returned whatever Value held, so the splice produced invalid JSON for scalar values and dropped the outer fields for objects. And with two catch-all parents the selector is ambiguous, so nothing was promoted and behavior flipped on parent count.

The fix

A struct with marshaler-bearing embeds (catch-all structs or unions, found transitively through aliases) now gets part-wise marshalers, even when it has no catch-all of its own:

  • Marshal: each bearing embed is encoded through its own marshaler and merged into an ordered member list, followed by the struct's directly declared fields (plain embeds included, so their encoding/json semantics are unchanged) and then the extras. Duplicate keys replace in place, so declared and inherited names are emitted exactly once and the outer fields win. A scalar union value now fails with an error naming the embedded type instead of a bare syntax error.
  • Unmarshal: each embed and the struct's own fields decode from the same object. The embedded unmarshalers ran on the full payload, so their catch-alls are cleaned afterwards: cleared when the outer type collects extras itself, pruned of declared names when it does not (extras then stay on the embedded parents, which is where the schema puts them).

Structs without bearing embeds keep the shadow approach, now assembled through the same merge helpers, which retires the byte-splice and its assumption that the shadow marshal produced an object. dropShadowedCatchAlls is removed: it was the #49 stopgap for exactly this promotion, and with promotion out of the picture a composed schema's own additionalProperties works again. A struct whose only field is a single bearing embed still relies on promotion, which is correct there because it has nothing of its own to lose.

Tests

New generate-compile-run wire tests cover the issue's matrix: the repro (typed field populated, extras on the outer type, embedded catch-all empty), a grandparent chain, two catch-all parents (extras kept on the parents, declared names pruned, each key emitted once), a union embed with object and scalar values, and a union embed on a schema with no catch-all of its own.

…'s declared fields

A struct embedding a type with generated MarshalJSON/UnmarshalJSON inherited
those methods by promotion, and the shadow trick did not stop it: a shadow
still embeds the parent. Decoding a composed type therefore ran the parent's
unmarshaler, which knows only the parent's fields and swept the composed
type's declared properties into the parent's catch-all map. With an embedded
union the promoted marshaler emitted only the union value, dropping the outer
fields or producing invalid JSON for scalar variants, and with two catch-all
parents the ambiguous selector promoted nothing, so behavior flipped on how
many parents a schema composes.

A struct with marshaler-bearing embeds now gets part-wise marshalers, even
when it has no catch-all of its own: each such embed is encoded through its
own marshaler and the resulting objects are merged with the struct's directly
declared fields and extras, so no promoted method ever speaks for the whole
object. Unmarshaling decodes each embed and the struct's own fields from the
same object, then cleans the embedded catch-alls: cleared when the outer type
collects extras itself, pruned of declared names otherwise. Structs without
such embeds keep the shadow approach, now assembled through the same object
merge helpers, which also retires the byte-splice and its assumption that the
shadow marshal produced an object.

dropShadowedCatchAlls is removed: it existed to keep a second catch-all from
fighting the promoted one, and with promotion out of the picture a composed
schema's own additionalProperties works again.

Fixes #50
… lost extras

Post-review fixes to the part-wise marshalers:

- Cyclic allOf schemas made the generated unmarshalers re-enter each other
  with the same bytes until the stack overflowed. A pointer embed that closes
  a reference cycle is no longer decoded, matching encoding/json, which also
  stops its field walk where a type repeats.
- An embedded union's variant keys landed in the composed type's catch-all
  and overrode an updated union value on re-marshal. The catch-all is now
  pruned with the keys the decoded union value actually marshals.
- The embedded catch-all cleanup nilled the maps outright, dropping an extra
  that only a wider embedded map could hold when the outer catch-all is
  typed narrower. Cleanup now removes declared names and the keys the outer
  catch-all collected, leaving the rest with the embed that accepted them.
- The catch-all reset ran after the embed decodes, so a failed re-unmarshal
  into a reused struct kept the previous payload's extras. The reset is back
  at the top of UnmarshalJSON.

Also: the package-wide bearing fixed point is computed once per package
behind a mutex instead of per helper call, the declared-names literal and
alias walks are shared instead of repeated, and new e2e coverage exercises
cyclic schemas, narrowly typed outer catch-alls, and union re-marshal.
@giraffesyo
giraffesyo merged commit 034c2d1 into canary Aug 5, 2026
7 checks passed
@giraffesyo
giraffesyo deleted the fix-embedded-catchall-promotion branch August 5, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An embedded schema's additionalProperties swallows the outer schema's declared fields

1 participant