Skip to main content
← Back to blog
7 min readSystems & Infra

"Not Found" Is Not Evidence of Absence

The inventory script scanned 8,818 records and reported 193 gaps: 130 "content differs", 63 "not present on the other side".

So it refused to delete.

All 193 were on the other side, with identical content SHA-256. Zero gaps. All 193 were false positives.

The previous post asked "did it actually do the thing?" — a system reporting success while doing nothing. This one asks a different question: what makes you say these two are the same record?


Why guessing wasn't an option

The setup, in one line: two machines share a self-hosted knowledge base of about 10,000 entries. One of them was being converted to a pure client, after which every local copy on it would be deleted.

Deletion is irreversible, and loss here is completely silent. No error, no alert. You find out months later when you go looking for something and it isn't there — by which point you have no way to trace where it used to be.

So before deleting, each record had to be proven: for every piece of content I'm about to delete, an equivalent exists over there.

How you decide "equivalent" is the whole of this post.


The script's logic, and the assumptions inside it

# try the id first
if remote.get(namespace, item.id):
    ok()
# fall back to an exact title match, then compare sha256
elif (found := search(title=item.title)):
    compare_sha(found, item)

Reads fine. It assumes at least four things:

  1. ids are stable across machines
  2. titles are approximately unique
  3. search returns all candidates
  4. "not found" means "does not exist"

The first one was already known to be false — file sync assigns new ids on reimport, which is exactly why the title fallback existed.

The problem is that two and three are also false, and not occasionally. Structurally.


In this dataset, a title cannot be a key

The knowledge base slices documents by Markdown heading. And progress-log documents emit the same set of headings every session.

Actual counts within a single namespace:

29×  進度記錄.md § 摘要        (Summary)
22×  進度記錄.md § 下一步       (Next steps)
16×  進度記錄.md § 派工留痕     (Assignment trail)
21×  文件/看板/進度記錄.md § 完成什麼   (What was done)

Those four lines explain the 130 "content differs" without further comment.

The script searched by title and got back one of 29 identically-named siblings — then confidently compared SHA-256 and reported a mismatch. It compared something every time. It just wasn't comparing the record it thought it was.

The second failure is a different mechanism. A title like 進度記錄.md § 下一步 exists in several namespaces. The search didn't scope to a namespace and only took the top 10, so the correct record never entered the candidate set at all — reported as "not present". That's where the 63 came from.

These are two different bugs: one picks the wrong candidate, the other never has the right candidate available. Collapsing them into "title matching is unreliable" loses the shape of the problem.

The fix is to demote the title:

# title is recall only; content decides identity
candidates = search(title=item.title, namespace=item.namespace, limit=50)
match = next((c for c in candidates if sha256(c.content) == sha256(item.content)), None)

Re-run: 193/193 found.

SHA-256 is not a universal identity either

An honest boundary here: a content hash is sufficient only when the thing you're preserving is the content.

If namespace membership, title, timestamps, attachment relationships or ordering are also semantics you can't afford to lose, then "same content" does not prove "safe to delete". In this case the goal was explicitly the content itself, so SHA-256 was enough. A different situation needs the question asked again.

Also: both sides must hash the same byte representation. Newline, encoding and whitespace normalisation rules cannot be left vague, or you'll manufacture a fresh batch of false positives.


A root cause that explained all 193

After the script refused to delete, it wrote a handover document and passed the problem to the knowledge base side.

That investigation was done by another AI assistant. It found that titles on the server carry a relative-path prefix (文件/看板/稽核/xxx.md § 安全性), while the handover's appendix table — for layout reasons — put the path in the header row and only the filename in the data rows.

So it concluded: the comparison must have been dropping the path prefix.

That conclusion explained all 193 records. It sounded reasonable, it was internally consistent, and if true it closed the case.

It was wrong. The script had used full titles all along.

And only the machine running the script knew what string it actually fed into the comparison. The investigating side had a rendered handover document — it was using layout as a proxy for program behaviour, which is the earlier mistake wearing a different coat: taking the presentation for the fact.

Two things deserve pulling out.

First, complete explanatory power is a warning sign, not an acceptance criterion. That root cause explained 193 out of 193, and precisely because of that, nobody went back to check it. When a hypothesis explains things too cleanly, the question to ask is "how would it know that?", not "which case does it fail to cover?"

Second, adjudication belongs to whoever holds the first-hand facts, not to whoever reasons most fluently. What corrected this wasn't a smarter model. It was going back to the side that could observe the actual input and re-running it.

Some readers will want to read that section as an AI story. Here's a quick test: replace "another AI assistant" with "another colleague" — does the passage still hold?

Entirely. A colleague looks at the appendix layout, infers a root cause that explains every symptom, and is wrong. This happens in code review and incident retros every week. AI was just the party executing it this time.

The order matters too: my script used titles as keys first, produced 193 false positives first, and wrote a handover whose layout was misleading first. That root cause was an echo of my mistake, not its origin.


False positives aren't free

It would be easy to land this on "good thing the safeguard caught it". That teaches the wrong lesson.

Straight facts first: not a single record existed only on the machine being wiped. No verifier ever approved a dangerous deletion. This was not a near miss.

But the cost of 193 false positives was real: a correct operation stalled, a misleading handover document produced, a full round of cross-machine investigation burned, and a wrong root cause that nearly made it into the conclusion.

The other branch is worth more attention. Had the call been "to be safe, just write all 193 over there", the result would be 193 duplicate entries in the knowledge base — orphans with no file_key, coexisting with every future file sync, carrying identical titles.

A "let's be safe and add them" driven by false positives manufactures the material for the next round of false positives.

Finally: this erred on the safe side because the default for irreversible actions was set to "refuse unless proven", by design. That's a credit to the default, not to anyone's judgement. The same bug in the other direction — an unreliable key concluding "it's all there" when it isn't — deletes real data, just as silently.


Delete, then go look at it

With zero gaps confirmed, two copies were deleted (189 files and 315 files, about 103 MB). The knowledge now exists in one place, plus git as the off-site layer.

Which is exactly where the previous post's lesson comes due: that git backup had been caught failing to commit while reporting success. It was fixed. But "fixed" and "I have watched it succeed" are different claims.

So: an end-to-end check, using no test files — 23 real knowledge entries written that day. The method compares two hashes:

# the actual bytes of the live file on the server
git hash-object data/vault/.../some-entry.md

# the bytes GitHub has (fetched, not read from local cache)
git rev-parse origin/main:data/vault/.../some-entry.md

First pass: 21/23 identical. Two differed.

Both had been written after the last backup ran. After triggering one, 23/23.

Those 2 mismatches are worth more than the 23/23. Had everything matched on the first pass, I should have suspected I was comparing two copies of the same snapshot. A verifier with discriminating power must be able to say no — and you need to have seen it say no once to know that it will.

It was a naturally occurring negative control. I didn't design it.


Closing

The same mistake showed up three times that day, each at a higher level:

What was taken as evidence of "same record"Result
Inventory scriptThe title string193 false positives
Cross-machine investigationThe handover's layoutA wrong root cause that explained everything
Post-deletion verificationThe content's byte hash21/23 → 23/23

All three are the same sentence: human-readable names — titles, filenames, table layouts — are recall mechanisms, not identity.

They get rewritten, prefixed, reused. Prove "it still exists somewhere" with a mutable key and the false negatives waste your time while the false positives delete your data.

And in front of an irreversible action, what authorises you to press the button isn't your confidence. It's the comparison function. What it accepts as evidence decides whether your data survives.