🔒 fix!: refuse names that no longer reach their property

A rename did not actually take effect. @JsonProperty stopped the old name
from being *mapped*, but not from being *accepted*: unlike @JsonReadOnly,
whose JSON name goes into the blocked set, the old key fell through to the
unknown-key policy, and the default `allow` copied it onto the instance
untouched.

The value therefore landed on a declared property having skipped everything
declared for it:

  @JsonProperty('home_address') @JsonType(() => Addr) @ValidateNested()
  address!: Addr;

  toInstanceSync(Order, { address: { city: 'Paris' } })
    -> address is a plain object, instanceof Addr === false
    -> validateSync() returns []          <- nothing complains

A payload aimed at the previous version of a class was accepted in part, in
silence. Three routes led to the same hole, and all three are now closed:

- the property key of a field renamed with @JsonProperty
- the raw key of a field a naming strategy renders differently
  (`firstName` under snake_case)
- the property key of a field that is both renamed and @JsonReadOnly, which
  was still settable under its own key

Refused, not swallowed. A stale name is a mismatch with whatever produced
the payload, not a deliberate refusal like @JsonReadOnly, so it is kept in
its own map rather than lumped into `blocked`: under unknownKeys: 'error'
it is still reported, and the report now names the property it was reaching
for and what that property is called now.

  "ref" is not a JSON name for Order: property "ref" is mapped to
  "order_ref". Send that name, or add @JsonAlias("ref") to keep
  accepting this one.

@JsonAlias still keeps an old name working, and a key that some *other*
property legitimately answers to is still mapped to that property — both
asserted. The resolution happens once when the name map is built, which is
memoized per class and naming strategy, so deserialization is unchanged at
~45µs for 50 nested orders.

The behaviour this replaces was pinned by tests two commits ago, pending
this decision; those tests now assert the fix, and six more cover the
naming-strategy, read-only, alias and key-collision cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 14:42:44 +00:00
parent 057f008ac0
commit 478f852b42
7 changed files with 186 additions and 76 deletions
+4 -6
View File
@@ -814,12 +814,10 @@ npm install ../cereale/cereale-0.3.0.tgz</code></pre>
<div class="note-item">
<h3>Renaming is not backwards-compatible by itself</h3>
<p>Once a property carries <code class="inline-code">@JsonProperty</code>, its original name
no longer <em>maps</em> to it — but under the default
<code class="inline-code">unknownKeys: 'allow'</code> it is not rejected either. It is
copied onto the instance raw, skipping any
<code class="inline-code">@JsonType</code> or <code class="inline-code">@JsonDeserialize</code>
conversion declared for that field. Add <code class="inline-code">@JsonAlias</code> to keep
older clients working, or <code class="inline-code">unknownKeys: 'strip'</code> to drop them.</p>
no longer reaches it — it is refused rather than copied onto the instance behind the
rename's back. Add <code class="inline-code">@JsonAlias</code> to keep older clients
working. Under <code class="inline-code">unknownKeys: 'error'</code> the stale name is
reported by name, along with what the property is called now.</p>
</div>
<div class="note-item">
<h3><code class="inline-code">abstract</code> and <code class="inline-code">accessor</code> fields cannot be decorated</h3>