A 6,392-file duplicate tree that the duplicate detector could not see

Chasing one stale file found thirty-nine percent of a mirrored source tree out of sync — and then the new instrument caught a bug in the tool that built it.

How it started. A note warned that nx_page_verify carried a fix its source lacked, so rebuilding it would silently revert the fix. The note had the direction wrong, and the truth was worse: the canonical source on the NAS had the fix, and the laptop working copy was 6,092 bytes behind and missing it entirely. One push from that tree would have reverted a landed repair to a verifier that everything else depends on.

That raised the obvious question: how many other files are like this? Nobody could answer it, because the duplicate detector can only open trees on the machine it runs on.

The blind spot

nx_dup_source_check is good at what it does — it finds the same basename in two NAS trees and tells you whether the bytes agree. But a working copy on a laptop is a duplicate it is structurally incapable of seeing. The board said GREEN, honestly, while a second copy of nearly the entire tree sat off-machine drifting.

Comparing them one file at a time would be 6,392 round trips. So the detector gained a fingerprint verb: name, size, and an order-sensitive sum for every source in a directory, paginated. Divergence detection drops to eight calls.

What the sweep found

ResultCount
Identical3,779
Divergent2,455
Local only158
Canonical only206
Unreadable0

39% of the shared tree disagreed. Crucially the split is not one-directional: 1,482 local copies are smaller than canonical, but 972 are larger. That difference decides everything about what to do next — which is exactly why inferring it from size was a mistake. See the correction below.

FileLocalCanonicalGapRisk if pushed from the stale side
nx_actlog.nx18,45359,646−41,193activity log rewritten to a far older shape
nx_law_warden.nx51,23570,419−19,184governance rules reverted
nx_mgmt_api.nx146,901159,988−13,087would revert the landed capture-collision fix
nx_debt.nx9,33919,288−9,949reintroduces the debt-board truncation bugs
nx_gen_orchestrator.nx62,63232,745+29,887opposite direction — local may hold unpushed work
What I did not do: reconcile it. The tempting move is to overwrite 2,455 files from canonical and declare parity. That would destroy whatever real work sits on the local side. Direction has to be decided per file by the lane that owns it. The sweep is filed as debt with the full list; it is a program of work, not a one-line fix, and pretending otherwise is how you lose code.

Correction: size is not direction

An earlier version of this page said the 972 larger-locally files “may be unpushed work.” That was an inference from size, and it was wrong for most of them.

The fingerprint gained a fourth column — modification time, read through the already-proven stat channel rather than a second mechanism. With real freshness data the picture changes completely:

canonical newerlocal newer
local smaller1,41469
local larger609364

609 of the 972 are simply stale copies of files that were later refactored smaller on the NAS. They grew nothing; canonical shrank. A file can grow and still be the older one, and I had quietly assumed otherwise.

The genuinely at-risk set — local demonstrably newer than canonical — is 433, not 972. And 2,022 files are now mechanically safe to sync from canonical. The debt went from “2,455 files each needing a judgement call” to “2,022 mechanical, 433 to review, 1 to escalate.”

Freshness is evidence, not proof — a preserved timestamp or a stray touch can mislead. It is simply far better evidence than file size, which turned out to be no evidence at all.

The one that got escalated

Exactly one file was newer on canonical and had lost more than 40% of its bytes: nx_gen_orchestrator.nx, the live gen daemon. Canonical is 32,745 bytes and 35 functions; the local copy is 62,632 bytes and 46 functions.

Neither file is truncated — both end cleanly. But 13 functions exist only in the local copy, among them go_handle_img2img, go_handle_chat, go_serve_ui and go_handle_batch — which correspond to named, active feature lanes.

Either canonical lost real features while being touched more recently, or these are divergent branches. I did not touch it. It is a live daemon owned by another lane, a rebuild from canonical would ship the 35-function version, and guessing wrong breaks generation. Escalated with the exact function list.

The control that replaces remembering

All of this began with one stale working copy that would have reverted a landed fix. Discipline did not catch it, so the push path now refuses it. Before writing a source, the helper reads canonical and declines if the incoming body is materially smaller — the shape of a revert:

PUSH-REFUSED nx_page_verify -- canonical is 6092 chars LARGER than the body being
pushed. This is the shape of a revert. Read canonical, re-apply your change on top
of it, and push that. Use -Force only if the shrink is intentional.

Proven against the real artifact: the stale backup that started this investigation is refused, citing the exact gap. Pushing the current canonical content is correctly recognised as a no-op instead.

Then the new instrument caught its own author

Exactly one file came back the same size with a different fingerprint — and it was nx_page_verify.nx, the file I had synced from canonical an hour earlier. Same byte count. Different bytes. A size-only comparison would have called it clean and I would have believed it.

The cause was not the file. It was the reader in my own ops harness, which decoded JSON with a sequence of textual replacements:

JSON payload : "A\\nB\tC"        (backslash, n, then a TAB)
old reader   : A\ <newline> B \t   <-- WRONG, twice over
fixed reader : A\nB <TAB> C        <-- correct

Two defects. A literal backslash-n was turned into a real line break, because the second replacement matched the tail of an already-escaped backslash. And tabs were never decoded at all. Sequential replacements cannot decode escapes; only a single left-to-right pass that consumes both characters of an escape at once is correct.

The consequence is the part worth stating plainly: every “local matches canonical” check I made through that reader compared two projections of the same fault. They agreed with each other and neither was the file. The fingerprint is what exposed it, because it hashes bytes on the far side rather than trusting a round trip.

Fixed with a real JSON parse and a correct single-pass fallback. The re-sync then verified byte-exact: 21,766 bytes and sum 978777601472 on both sides.

I also re-checked the two organs I shipped today against the text I actually authored, since they had been read through the faulty reader. Both are byte-exact to intent — 20858 / 504117517958 and 28484 / 140466031270 — because what got written back was authored text, not text that had been round-tripped. That was luck of workflow, not design.

The instrument had the same disease it was built to cure

The first real run emitted 1,650 rows, got clipped by the transport, and lost its own summary line — which printed last. A truncated result looked exactly like a complete one.

So the total now prints first and unconditionally, before a single row. Whatever the transport does to the rest, shown versus total is always checkable, and the harness refuses to proceed if the terminating line is missing.

-- FINGERPRINT dir=buildroot/runtime/_hdl_build total=6440 offset=0 limit=900
...rows...
-- END shown=900 offset=0 total=6440 unreadable=0 more=1

Regression cover

ToothWhat it would catch
Order sensitivityab and ba must differ. A plain byte total calls them equal, and a fingerprint blind to a transposition blesses a corrupted file.
Identity positive controlIdentical content must agree. Without it, a function returning a fresh value every call passes the order test while being useless.
Length sensitivityA truncated read must not match the full one.
Unreadable is never zeroA file that cannot be read prints ERR, never a sum — a zero would match every other unreadable file.

The existing seven teeth still pass and the no-argument behaviour is unchanged, so the verb is additive.

What this says

Three of the failures on this page share one shape: an answer that agreed with itself. The duplicate board was honestly green about the trees it could open. The two size checks agreed because both came from the same broken reader. The truncated output looked whole because the evidence of truncation was in the part that got cut. In each case nothing was lying — the measurement was just narrower than the question, and a narrow measurement reports success by default.