Skip to content

fix(merge): materialize explicitly empty containers - #12

Merged
b2m9 merged 4 commits into
mainfrom
fix/materialize-empty-containers
Aug 13, 2026
Merged

fix(merge): materialize explicitly empty containers#12
b2m9 merged 4 commits into
mainfrom
fix/materialize-empty-containers

Conversation

@b2m9

@b2m9 b2m9 commented Aug 13, 2026

Copy link
Copy Markdown
Owner

An empty object or keyed list in a delta was silently dropped at a merge position, so

merge({ value: 5 }, { value: {} })   // → { value: 5 }
merge({ value: 5 }, { value: { a: 1 } })  // → { value: { a: 1 } }

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 replace path. So {} had three position-dependent meanings, and "set this field to {}" was unspellable at a merge position.

Generated deltas hit this constantly. JSON.stringify erases undefined, so a partial built from cleared inputs arrives as {} and vanished:

wire frame: {"order":{"filters":{}}}
before → {"order":{"id":"o1","items":[]}}                 ← silently dropped
after  → {"order":{"id":"o1","items":[],"filters":{}}}

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.foldInsert and the mode parameter 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, and finishContainerFold folds into its two call sites.

Behavior changes

Case Before After
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: []}
insert {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, replace boundaries, 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 check green. 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 a replace path; now empties ensure the field exists, removing position-dependent meanings and making JSON-safe partials reliable.

  • Empty containers are data everywhere: merge({ v: 5 }, { v: {} }){ v: {} }; merge({}, { items: [] }){ items: [] }.
  • Operators that find nothing are not data: deleting an absent field or tombstoning an absent item collapses to the base reference and never creates a container.
  • Inserted items fold onto nothing: an inserted item that only deletes no longer leaves an empty container; explicitly spelling { meta: {} } still materializes meta.
  • Emptiness counts only interpreted fields: {} and { field: undefined } are equivalent; unsafe keys (__proto__, constructor, prototype) never count and are never read when judging emptiness.
  • Internal: removes FoldMode and FoldContext.foldInsert; replace any internal foldInsert calls with fold(undefined, ...). The object fold now decides emptiness in its own pass, avoiding a second traversal and any read of unsafe keys.
  • Unchanged: structural sharing, merge(base, {}) === base, replace paths 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.

Review in cubic

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. foldObject now reports the answer it already has, and mentionsAnyField/isUnsafeKey are 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 undefined on 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:

before | reads=2 | {"held":1}
after  | reads=1 | {"held":1,"p":{}}

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.

b2m9 and others added 3 commits August 13, 2026 12:54
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/merge.ts Outdated
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>
@b2m9
b2m9 merged commit 42408e8 into main Aug 13, 2026
3 checks passed
@b2m9
b2m9 deleted the fix/materialize-empty-containers branch August 13, 2026 12:34
@b2m9 b2m9 mentioned this pull request Aug 13, 2026
b2m9 added a commit that referenced this pull request Aug 13, 2026
Behaviour changed since 0.1.1, so this is a minor bump rather than a
patch: explicitly empty containers now materialize (#12), and malformed
option containers are rejected at merger creation (#11).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant