The submit button said "No changes to submit." The user had just edited their project description.
That specific flavor of bug — where the thing you built confidently ignores what you told it to care about — is quietly humiliating. Not in a dramatic way. Just in the way where you sit with the code for a few seconds, understand exactly what you did wrong, and then go write six more unit tests to prove you fixed it.
Let me back up.
I've been working on a feature in a work project where candidates can edit their resume and submit those edits for approval before the changes go live. Standard gating pattern: candidate edits, submits, a reviewer approves or rejects with optional feedback notes.
The interesting part was the review step. The reviewer needed to see what changed — not just "something is different," but which specific content moved, what was added, what was removed. I wanted something that felt like a code review diff but for resume sections. Additions in green with a + marker. Removals in red with a -. Unchanged content in plain gray. The kind of format anyone who has looked at a pull request would immediately understand.
So I built it for each section: achievements, skills, experiences, projects, certifications.
The core idea is the same one you see in tools like git: here's before, here's after, here's the delta. It's a good, legible format for this problem.
For simple list fields — achievements, bullet points — the algorithm is straightforward. Before set, after set. If a string appears in both: same. Only in after: added. Only in before: removed. Done.
Structured items are trickier. An experience entry is not just a string — it has a company, a role, a location, date ranges, and a list of bullet points. If a candidate reorganizes their experiences without changing any content, the diff should not explode into a wall of removals and additions. It should recognize "this is the same experience, just moved."
So the algorithm needs a key — a field or combination of fields that identifies an item as "the same item across both versions." For experiences I chose role + company. For projects, title. For certifications, name + issuer.
The algorithm then:
- Build a map from keys to items for the before snapshot.
- For each item in after: look up its key in the before map. If found, it's a match.
- After-items with no before match → added.
- Before-items that weren't matched → removed.
I wrote the code, wrote the tests, raised the PR. All green. Felt good about it.
The Word "Same" Was Doing Two Jobs
Code review came back. Three findings. The first was the one.
"Content-only edits on matched items are silently blocked."
I had to sit with that for a second.
If a candidate changed their project description without changing its title — just rewrote a sentence, added a library to the tech stack, corrected a URL — the diff would still report the project as 'same'. Same key. Same item. Nothing to see here.
And hasSnapshotChanges, which reads the diff output to decide whether the draft actually has changes before allowing submission, would return false. No changes detected. Submission blocked.
The candidate could see the change they made. The diff couldn't.
Here's the specific problem. When a key matched, I wrote { status: 'same', item: afterItem } and moved on. The word "same" was doing two different jobs at once without me noticing. What I meant was: "this key corresponds to a known item from before." What the code did was: "mark this item as identical."
Correspondence is not equality.
If you've used React's list rendering, there's a useful parallel. The key prop lets the reconciler figure out which element in the previous render tree corresponds to which element in the new one — it is a matching signal, not an equality signal. Once React finds the pair, it still compares props to decide whether to re-render. The key answers which item is this? Content comparison answers is this item unchanged?
Two separate questions. I had one answer doing both.
How My Tests Let This Through
Honestly, the tests I wrote were reasonable for the scenarios I imagined.
When I wrote project in both sides → marked as 'same', I used the same project object on both sides. Title and description both identical. The test confirmed: same key, same content → same. Which is correct! The test was not wrong. It just was not testing the interesting case.
"Same title, different description" never occurred to me as a scenario worth covering, because I had already collapsed "same key" and "same content" into one idea in my head. Finding the matching key felt like finding the matching item. I was done when I found the pair. Checking what was inside the pair felt like cleanup.
Tests are a record of what you thought could go wrong. Code review is someone else thinking about what could go wrong from the outside. They are not the same thing, and it turns out they are complementary in a very specific way.
The second finding: the function that applies an approved snapshot to the live resume tables did a delete-all-then-reinsert with no transactional safety. If an insert failed after the deletes had committed, the candidate's live resume would just be gone. Fixed that with a defensive state capture before writing and a best-effort restore on failure.
The third: if a candidate had two projects with the same title — say, two iterations of a "Personal Portfolio" — the diff's Map would collapse them into one entry and silently drop the second occurrence. Fixed with ordinal keys that append an occurrence counter so duplicate titles match positionally rather than overwriting each other.
All three were caught after the tests were green and the PR was raised. That sting a little. But this is how software actually goes sometimes.
Matching Tells You Which. Content Tells You Whether.
None of this is an argument against keyed diffing. It is the right tool for this problem. You genuinely do not want a position shuffle to produce a full removal-and-addition pair for every experience entry — that would bury real changes in noise. Keys solve that.
The mistake was in what I expected the key to guarantee. "Same key" tells you which before-item an after-item corresponds to. That is all it does. It does not tell you whether the item changed. That is a second question, and it has to be answered separately.
It is easy to lose track of this mid-implementation, because "matching" and "unchanged" feel related. You are scanning for pairs of items that correspond, and when you find a pair, the intuitive next move is "great, nothing to see here." The hard work felt like finding the match. Checking content felt like a formality.
But "this before-item and this after-item are the same thing" is not the same claim as "this before-item and this after-item are identical." One is about identity. The other is about equality. The key handles identity. Equality still has to be checked.
A decent mental model: the key is a handle for finding the before-version of an after-item. Once you have the pair, the equality check is not optional. It is the actual point.
The fix was: when a key matches, compare the non-key content. If identical, emit 'same'. If different, emit 'removed' (old version) + 'added' (new version) — the same two-line representation git uses when a line changes. The renderer already knew how to display removal-addition pairs; I just was not generating them for content-changed matched items.
I wrote content equality comparators for each section: experience header fields, project fields, certification metadata. Six new tests, all green. Submit unblocked. The diff now shows what actually changed, even when the identifying name did not.
There is a version of this gap in most comparison logic I have written under time pressure. You find the match, flag it as same, and move on — because finding the match felt like the interesting part. The content comparison looks like ceremony.
It is not.
Tests cover what you imagined. Code review catches what you did not.
The key is a handle.
Equality is still a question.