skip to content

When a native object-graph snapshot is rebuilt, why can the resulting object hold state its own type would reject?

level: middleimportance: should knowfreq 40%

answer

  1. fields are filled, not constructed
  2. no entry point, no checks
  3. absent field means zero, not default
  4. derived values restore stale
  5. the post-read hook is the seam

basics

~10 s

Rebuilding fills fields directly instead of going through the type's ordinary construction path, so constructor checks and derived-value computation do not re-run. Restoring invariants falls to the type's post-read hook, if it declares one.

solid answer

~50 s

A native rebuild produces an instance and then writes the stored values onto its fields. Runtimes differ in how much of the ordinary construction path they run, but the family property is the same: **the type's own validation is not automatically re-applied**. So a field added since the snapshot was written comes back as its zero or empty value rather than the value a constructor would assign; a field deliberately excluded from the snapshot comes back empty; a cached or derived field comes back with the value it had when the snapshot was taken, even if the inputs it was derived from have since changed; and cross-field invariants nobody re-checks simply do not hold. None of this needs a hostile writer — it happens with data your own service wrote ten minutes ago. The seam that fixes it is the post-read hook, where a type re-validates and recomputes.

code

pseudocode · 15 lines
pseudocode
function rebuild(record, snapshot):
    type = resolve_type(record.type_name)          // named by the payload
    obj  = make_instance(type)                     // ordinary construction path not taken

    for each (field_name, value) in record.fields:
        if type declares field_name:
            set_field_directly(obj, field_name, value)   // no entry point, no checks
        else:
            discard value                                // writer had a field we no longer declare

    // fields the payload never carried keep the zero or empty value make_instance left

    if type declares a post_read_hook:
        call post_read_hook(obj)                   // the one place invariants can be restored
    return obj                                     // types without the hook are returned unchecked

go deeper

for a junior

Remember the shape of it: rebuilding an object from a snapshot pushes stored values straight into its fields, so the checks the type performs when it is built normally are skipped.

for a middle

Be able to list what that costs: skipped validation, fields added since the snapshot left empty, excluded fields left empty, and derived values restored instead of recomputed.

for a senior

Show you have debugged the downstream version of this — a value the code proves is impossible, turning up after a cache round trip — and that the post-read hook is where you restore the invariant.

for a principal

Argue the design point: with an explicit encoding the decoded payload is plainly data and your own construction path validates it, whereas a native rebuild hands back objects that were never checked at all.

## Construction versus restoration There are two ways an object can come into existence. **Construction** goes through the type's own entry point: arguments are checked, defaults are assigned, derived values are computed, and the object exists only if the type agreed to make it. **Restoration** produces an instance and then puts stored values into its fields. A native object-graph serializer does the second. The exact mechanics differ between runtimes — some bypass the ordinary construction path entirely, some run part of it for parts of the type hierarchy — but the family property holds everywhere: *the checks a type performs when it builds an object are not automatically performed when a snapshot rebuilds one*. The consequence is that a rebuilt object is a shape, not a validated value. ## What actually goes wrong - **Invariants are never re-established.** If the type refuses negative quantities, that refusal lives in the code that constructs it. The rebuild does not consult it, so a snapshot holding a negative quantity produces an object holding a negative quantity. - **Fields added since the snapshot was written come back empty.** There is no value in the bytes for a field that did not exist, so it is left at the zero or empty value for its kind — which is usually *not* what the construction path would have assigned. A field the code assumes is never absent is now absent. - **Deliberately excluded fields come back empty too.** Excluding a field keeps it out of the bytes; nothing puts it back. Any code that assumed it was populated now reads a hole. - **Derived state is restored, not re-derived.** A cached total, a memoised hash, a precomputed index: these are fields, so the snapshot carries the values they had at capture time. If the rules that produced them changed in between, the object now carries an answer computed under rules that no longer apply, and nothing marks it as stale. - **Cross-field agreement is not checked.** Two fields that must agree — a count and a list length, a status and a timestamp — were consistent when written. If either was reshaped since, they are not consistent now, and the rebuild does not notice. ## Two paths compared | | Ordinary construction | Rebuild from a native snapshot | |---|---|---| | Argument checks | run | not re-applied automatically | | Assigned defaults | applied | absent fields left at zero or empty | | Derived values | computed fresh | restored as captured | | Excluded state | populated by the code | left empty | | Cross-field invariants | enforced at the entry point | unchecked unless a hook does it | ## Why this matters even with fully trusted data It is tempting to file this under security and move on. That misses the everyday version of the bug. The writer here is your own service, the bytes are exactly what it wrote, nobody tampered with anything — and the object that comes back still violates a rule the type declares, because the rule lives on a path the rebuild did not take. Teams usually meet this as a mystifying downstream failure: a value that the code proves cannot be negative is negative, a collection that cannot be empty is empty, a total that should match its lines does not. ## The seam that fixes it These formats provide a hook the type can declare that runs after its fields have been restored. That hook is the place to do what construction would have done: 1. **Fill what the bytes could not carry** — fields added since the snapshot was written, and fields deliberately excluded from it. 2. **Recompute rather than trust** anything derived, or drop the cached value so the next read re-derives it. 3. **Re-check the invariants** the constructor enforces, and refuse the object outright rather than hand back a broken one. The catch is that the hook runs only for types that declare it. Every type in the graph that omits one is restored with no checks at all, which is why a graph of dozens of types is very hard to make trustworthy this way. That difficulty is one of the honest arguments for an explicit encoding: with a field-mapped format, the decoded payload is plainly *data*, and your own construction path — checks and all — is what turns it into an object.

  • What value does a field added to the type after the snapshot was written hold afterwards?
    The zero or empty value for its kind. The bytes carry nothing for a field that did not exist when they were written, and the construction path that would have assigned a default was not taken. If that field participates in an invariant, the post-read hook has to fill it.
  • Why is this a correctness problem even when the writer is fully trusted?
    Because the defect is in the path, not the data. Your own service wrote the bytes faithfully, yet the object returns without ever passing the checks its type declares, so a rule the surrounding code relies on can be violated by an entirely honest round trip.
  • Does a post-read hook on the root type protect the whole graph?
    No. The hook runs per type, for types that declare one, so every other object reached from the root is restored unchecked. Validating a large graph means auditing every type in it, which is why the guarantee is weak in practice.

saying these in an interview costs you the question

  • Thinks the type's constructor runs and validates on every rebuild.
  • Assumes a missing field gets the default the constructor would assign.
  • Believes only hostile input can produce an invalid rebuilt object.
  • Treats a cached derived field as recomputed during restoration.
  • Thinks one hook on the root type validates the whole graph.