Skip to content

Add RFC 7386 JSON Merge Patch - #18

Merged
hellerve merged 1 commit into
mainfrom
claude/rfc7386-merge-patch
Aug 7, 2026
Merged

Add RFC 7386 JSON Merge Patch#18
hellerve merged 1 commit into
mainfrom
claude/rfc7386-merge-patch

Conversation

@carpentry-agent

Copy link
Copy Markdown

The library had the harder, rarer half of the pair: RFC 6902 JSON Patch (#17)
and RFC 6901 JSON Pointer (#12). An API that accepts HTTP PATCH almost always
speaks application/merge-patch+json instead, so this adds RFC 7386.

JSON.merge-patch

Applies a merge patch to a target with the RFC's exact semantics: a non-object
patch replaces the target wholesale, a non-object target is treated as an empty
object, a null member deletes that key (a no-op if absent), and every other
member merges recursively. Arrays are replaced, never merged elementwise.

(JSON.merge-patch doc &patch)

JSON.merge-diff

Builds the smallest merge patch taking a to b, emitting null for members
b drops and omitting the ones it leaves alone. This is the half that makes
the format usable from Carp — hand-writing a merge patch is the easy part.

Both docstrings and the README state the two things the format cannot express,
rather than pretending otherwise: a member cannot be set to null, because
that spelling already means delete, and nothing inside an array can be
addressed without resending the array.

The Map.remove bug

Core's Map.remove decrements a map's length whether or not the key was
actually present:

(update-len (update-buckets m ...) &Int.dec)   ; unconditional

Map.remove! guards on Bucket.contains?; the by-value remove does not. So
removing an absent key yields a map whose Map.length disagrees with its
contents, and JSON.= — which compares object sizes first — then calls two
equal documents unequal. That is exactly the shape of RFC 7386 Appendix A rows
7, 14 and 15, where a null member names a key the target does not have; those
three assertions failed until the guard went in.

JSON.delete-key already had the same bug against its own docstring ("if the
value is not an object or the key is absent, returns it unchanged" — it did
not, under =). Both now go through one private guard, and there is a
regression test for delete-key. Happy to split that out if you would rather
review it on its own.

Tests

All 15 rows of RFC 7386 Appendix A transcribed as assertions, plus the RFC
section 3 worked example and six more round-trip properties over hand-built
nested documents with nulls, empty objects and empty arrays — including an
assert-false pinning the one case that provably cannot round-trip
({}{"a":null}). Comparison reuses JSON.= from #17.

carp -x test/json.carp: 399 passed, 0 failed. carp-fmt --check and angler
are clean; gendocs.carp runs (docs themselves left for a release commit, as
in #17).


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

`JSON.merge-patch` applies a merge patch with the RFC's semantics: a
non-object patch replaces the target, a `null` member deletes a key,
everything else merges recursively, and a non-object target starts out as an
empty object. Arrays are replaced whole, so the format can address neither a
position inside an array nor a member whose value should become `null`; both
docstrings say so rather than pretending otherwise.

`JSON.merge-diff` builds the smallest patch taking one document to another,
which is the half of the pair that is awkward to write by hand. Tests cover
the full Appendix A table plus a round-trip property over hand-built nested
documents, including the case that provably cannot round-trip.

Core's `Map.remove` decrements a map's length whether or not the key was
present, so deleting an absent key left a map whose `Map.length` disagreed
with its contents and compared unequal under `JSON.=`. A merge patch deletes
absent keys routinely, and `JSON.delete-key` already had the same bug against
its documented behaviour; both now go through a guard.

@carpentry-reviewer carpentry-reviewer 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.

Build & Tests

carp -x test/json.carp on 51d751a: 399 assertions, 0 failures. CI green on ubuntu + macOS against this exact head (run 31158295681), pull_request-triggered, with lint and format-check passing. Merge-base is the origin/main tip (3013166, the #17 merge), so this is a clean fast-forward with no stale-branch hazard. Still a draft; leaving it that way.

Findings

None. What follows is what I did to try to find some.

A differential against an independent RFC 7386 implementation

I wrote merge-patch and merge-diff from the RFC in Python, generated 400 random document pairs over a deliberately small key alphabet (so key collisions, nested objects, arrays, and null values are dense rather than rare), and compared parsed results — not strings, since Carp's Map is unordered.

mismatches
JSON.merge-patch vs reference 0 / 400
JSON.merge-diff vs reference 0 / 400

The reference also confirms the docstrings' stated limitation is exactly the whole limitation: over the same 400 pairs, merge-patch(a, merge-diff(a,b)) == b on 377, fails on 23, and every one of the 23 is a b that introduces a null-valued object member. 0 unexpected round-trip failures. So "a b that introduces one does not survive the round trip" is not an approximation — it is the precise characterisation, and the assert-false pinning {}{"a":null} is pinning the only failure mode there is.

The harness was vacuous at first, and that is worth knowing

My first version compared serialized output only, and it reported 0 mismatches with the remove-member guard deleted. The guard corrupts Map.length, and JSON.str walks buckets — it never reads the length, so serialization is completely blind to the bug this PR exists to work around.

I added a self-equality check (every result must satisfy JSON.= against its own reparse) and re-ran:

tree self-equality failures over 400 pairs
51d751a as submitted 0
remove-member guard deleted 38

e.g. pair 345 produces {"b":[0,"t"],"c":0,"bb":false}, which serializes correctly and compares unequal to itself. So the guard is load-bearing and correctly placed, and the harness that says so can actually fail. Flagging the blindness because it is a trap for whoever writes the next test here: a string-comparison test cannot catch a regression in remove-member. The committed test "delete-key on an absent key leaves the object equal to itself" uses =, so it is on the right side of this already — that looks deliberate, and it is the right call.

The core Map.remove bug, confirmed independently

Reduced away from JSON entirely:

(let-do [m  (Map.put (Map.put (the (Map String Int) {}) "a" &1) "b" &2)
         m2 (Map.remove @&m "zzz")]   ; key is ABSENT
  ...)
before: len=2 contains a=1 b=1
after remove of ABSENT key: len=1 contains a=1 b=1
Map.= m m2 (should be true, both hold a,b): 0

(%b renders as 1/0 here, so: both keys still present after the removal, length silently down one, and the two maps compare unequal.)

Exactly as described: core/Map.carp:307 decrements unconditionally, remove! at 319 guards with Bucket.contains?, and Map.= (line 340) compares length before contents, so two maps with identical contents compare unequal. Appendix A rows 7, 14 and 15 are the shape that hits it. The upstream diagnosis holds and is worth the lead it was written up as.

Things I checked that turned out fine

  • The other Map.remove in this file is already safe. json.carp:1086, in the RFC 6902 Edit.Remove path, sits inside (if (Map.contains? &m tok) ...). So delete-key and merge-patch were the only two unguarded sites, and both now route through remove-member. No third site was missed.
  • The paired Map.keys / Map.vals iteration is safe by contract, not by luck. Both are kv-reduce over the same traversal and core documents it: "Order corresponds to order of (vals m)".
  • register-as-forward-declaration is this file's established idiom, not something invented here — json-parse-value, serialize-obj-into!, set-in-at, =, edit-at, value-depth and value-nodes all do it already.
  • The recursion is bounded for parsed input. merge-patch and merge-diff recurse without a depth guard, but the parser caps nesting at json-max-depth 128 and errors with DepthLimitExceeded, so no parseable document can drive them deep. Hand-built values could, but that is equally true of JSON.= and every other recursive function here — not a new exposure.
  • Adversarial shapes behave. Non-object patch replaces wholesale; non-object target is treated as empty; null inside an array survives untouched (arrays are opaque to the format), which the "5"{"a":[1,{"b":null}]} round-trip test already pins.
  • Docs deliberately left for a release commit, as in #17. That matches the repo's precedent; local gendocs does produce three uncommitted JSON.Patch* pages, but that is #17's tail, not this PR's, and CI does not gate on committed docs being current.

Verdict: merge

The semantics are right — 400 random pairs against an independent implementation of the RFC with zero disagreement in either direction, and the round-trip property fails on exactly the class the docstrings say it fails on and nothing else. The Appendix A transcription and the assert-false are doing real work. The Map.remove workaround is minimal, correctly scoped to the two sites that needed it, commented with the reason, and provably load-bearing. Splitting it out for separate review is not necessary from my side — it is four lines, it is right, and the regression test pins it. The upstream bug report is accurate and I would send it on.

@hellerve
hellerve marked this pull request as ready for review August 7, 2026 08:53
@hellerve
hellerve merged commit bcc2341 into main Aug 7, 2026
2 checks passed
@hellerve
hellerve deleted the claude/rfc7386-merge-patch branch August 7, 2026 08:54
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