fix(merge): materialize explicitly empty containers - #12
Merged
Conversation
An empty object or keyed list in a delta was silently dropped at a merge
position, so `merge({value: 5}, {value: {}})` left the number in place
while `{value: {a: 1}}` correctly replaced it. Generated deltas hit this
constantly: JSON.stringify erases undefined fields, so a partial built
from cleared inputs arrives as `{}` and vanished.
An empty container is now data everywhere: it ensures the field exists.
An operator that finds nothing to do is still not data, so deleting an
absent field or tombstoning an absent item stays a no-op that preserves
the base reference. Emptiness is judged by the fields the fold actually
interprets, so `{}` and `{field: undefined}` agree in memory and on the
wire.
This removes the two fold modes. `foldInsert` existed only to keep
explicit empty containers alive while constructing a keyed item; with
one rule for every position, an inserted item is just a fold onto
nothing. An inserted item that only empties a container no longer
leaves the container behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The emptiness test read a delta key's value before checking whether the key was unsafe, so an own enumerable `__proto__` getter ran even though the fold itself refuses to follow that key. Both sites now share one predicate, which is the durable fix: a future drift between them would recreate exactly this class of bug. Deciding emptiness also moved behind the no-op check, so it is only asked when it can change the answer, and `finishContainerFold` folds into the two call sites it served. Adds the wire-agreement law that motivates judging emptiness by interpreted fields, and pins the DELETE_TOKEN cases in both wire modes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The delta arbitrary could not generate an empty container at a merge
position, so no law exercised materialization. `profile` is optional in
the base and can generate `{}`, which puts the new rule under
idempotence, determinism, non-mutation and operator leakage rather than
only under wire agreement.
Also scopes the AGENTS.md wire claim: a DELETE symbol does not survive
`JSON.stringify` at all, so the guarantee holds for JSON-safe deltas.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 8 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Classifying the delta separately meant walking it twice whenever the fold wrote nothing, reading every safe key's value a second time. Only the fold knows which keys it skipped, so it now reports the answer it already has: a delta that never reaches past both guards spelled no interpreted field, and says so with UNMENTIONED. This retires the shared-predicate workaround for the unsafe-key defect. There is no second traversal left to disagree with the first, so the guarantee that an unsafe key is never read holds by construction rather than by keeping two call sites in step. No behavior change: the full case matrix is identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An empty object or keyed list in a delta was silently dropped at a merge position, so
left a number sitting where the delta declared an object — but only when the object was empty. The same value was materialized in two other positions: inside a newly constructed keyed item, and at a
replacepath. So{}had three position-dependent meanings, and "set this field to{}" was unspellable at a merge position.Generated deltas hit this constantly.
JSON.stringifyerasesundefined, so a partial built from cleared inputs arrives as{}and vanished:The rule
An empty container is data: it ensures the field exists, at every position. An operator that finds nothing to do is not data — deleting an absent field or tombstoning an absent item still collapses to the base reference and never conjures a container to operate on.
Emptiness counts the fields the fold actually interprets, so
{}and{ field: undefined }mean the same thing and a JSON-safe delta behaves identically in memory and after a round trip. A new law pins that agreement.This removes a concept.
FoldMode,FoldContext.foldInsertand themodeparameter existed only to keep explicit empty containers alive while constructing a keyed item; with one rule for every position an inserted item is just a fold onto nothing, andfinishContainerFoldfolds into its two call sites.Behavior changes
merge({}, {v: {}}){}{v: {}}merge({v: 5}, {v: {}}){v: 5}{v: {}}merge({}, {a: {b: {}}}){}{a: {b: {}}}merge({}, {items: []})(keyed){}{items: []}merge({items: "x"}, {items: []}){items: "x"}{items: []}{id, meta: {x: DELETE}}{id, meta: {}}{id}The last row moves the other way: a failed delete inside a newly inserted item no longer conjures a container, which is the same asymmetry closing from the other side.
Unchanged, and covered: existing correctly-shaped containers, all operator-finds-nothing cases,
replaceboundaries, structural sharing,merge(base, {}) === base, reference-idempotence, and no "clear the list" operator — an empty delta over an existing keyed list still returns the base by reference.Review
Reviewed by two independent agents. One found a blocking defect, fixed in 470ecec: the emptiness test read a delta key's value before checking whether the key was unsafe, so an own enumerable
__proto__getter ran even though the fold refuses to follow that key — a violation of "Unsafe keys are never followed". Both sites now share one predicate, which is the durable fix rather than a reordering. A regression test asserts zero reads.Round two found no blocking defects across both reviewers.
Suite 104 → 117.
vp run checkgreen. No changelog or version bump, per repo convention.Summary by cubic
Materializes explicitly empty objects and keyed lists at merge positions. Previously
{}/[]in a delta was dropped unless inside a new keyed item or at areplacepath; now empties ensure the field exists, removing position-dependent meanings and making JSON-safe partials reliable.merge({ v: 5 }, { v: {} })→{ v: {} };merge({}, { items: [] })→{ items: [] }.{ meta: {} }still materializesmeta.{}and{ field: undefined }are equivalent; unsafe keys (__proto__,constructor,prototype) never count and are never read when judging emptiness.FoldModeandFoldContext.foldInsert; replace any internalfoldInsertcalls withfold(undefined, ...). The object fold now decides emptiness in its own pass, avoiding a second traversal and any read of unsafe keys.merge(base, {}) === base,replacepaths take values verbatim, and there is still no “clear the list” operator (an empty keyed-list delta over an existing list leaves it untouched).Written for commit 751bbe3. Summary will update on new commits.
Follow-up: 751bbe3
External review (cubic) flagged that the emptiness test re-walked the delta and re-read every safe key on the collapse path. Correct, and worth fixing for a better reason than the double read: answering the question outside the fold meant reimplementing the fold's skip rules at a second site, which is what produced the blocking defect above.
foldObjectnow reports the answer it already has, andmentionsAnyField/isUnsafeKeyare gone. The unsafe-key guarantee becomes structural — there is no second traversal left to disagree with the first.Both reviewers verified this independently with their own differential harnesses (25,034 and 30,000 cases): no divergence for contract-shaped deltas.
One scope correction to that commit's "No behavior change": it holds for the documented "finite, JSON-shaped trees" contract, not universally. An inconsistent getter — one returning
undefinedon its first read and a value on its second — previously collapsed to the base reference and now materializes, because the value is observed once instead of twice:Out of contract either way, and the old behavior was incoherent in its own terms (the two passes disagreed with each other), so observing once is the more defensible of the two. Recorded here rather than rewritten into history.