DEV Community

Mahiro Hirakawa
Mahiro Hirakawa

Posted on

I ran 10,373 mutations through a reversibility gate. Tamper detection caught 600 of 600.

Read this before the numbers below convince you of anything. Every number in this post came out of one run, on one machine, with one debug build. Zero of 10,373 mutations failed in the census below, and that number is real, but it's also conditional: an earlier, more naive version of the exact same test failed 140 times out of 300 (46.7%) against the exact same tool. The only thing that changed between the two runs was how I assigned target file paths, not the tool itself. That defect (F1, below) is still there. If you want to break the headline number yourself, submit the same delete against the same path twice in a row.

I'm also not going to call the thing I measured a "delete." The CLI I tested cannot submit a delete as a delete, full stop. What I actually measured is a file getting its content replaced with zero bytes while the file itself stays on disk. That distinction matters enough that it's going here, before the results, not buried at the end.

What I measured

TraceFold is a Rust CLI (gx) that sits in front of filesystem and git writes and gates them: build an inverse for the change, check the precondition against the live state of the world, commit only if both hold, write a signed receipt, let anyone check that receipt offline afterward. I ran two kinds of tests against it.

The first is a side by side: six specific questions, answered once against a plain rm/cp and once through the gate. The second is a census: 10,373 independent mutations pushed through the full six process cycle (submit, plan, verify, commit, checkpoint, offline receipt verify), split across six separate projects (a through f) running in parallel, each with its own ledger and its own keys, sharing nothing.

Everything ran on WSL2, Ubuntu 24.04, 12 CPUs, about 12GB RAM, against a debug build that self reports as gx 0.1.0 (md5 2fad9512ae99ac583d289b6bee0b132). I copied that binary onto the WSL native filesystem before timing anything, because spawning it directly off the Windows mounted drive was slow enough to swamp the measurement:

run 1 2 3 4 5
off the Windows mount 0.426s 0.106s 0.122s 0.125s 0.091s
WSL-native copy 0.006s 0.007s 0.008s 0.017s 0.008s

Both copies had the identical md5, so this is the same binary, just moved. Three unrelated Rust build jobs were running on the same machine for most of the census (measured load average 9.14 to 40.65), so the census timing numbers below are not a quiet machine baseline. I measured one cycle on a near quiet machine separately and I'm reporting both, labeled, rather than picking one.

Before this tool, after this tool

what I checked plain fs operations through TraceFold
Is there anything to reverse the change with, right after it happens? No. rm returns exit code 0. A search for any backup file (*.bak, *.orig, the original name) under the working tree returns zero hits. Yes. The commit receipt's signed payload carries an inverse_delta, escrowed before the write is allowed to land.
If the file changed on disk between when the agent read it and when it acts, does anything notice? No. rm returns 0 whether or not the file still matches what the agent last read. The check does fire: the commit is refused and the file is left untouched. But the error code that reaches the caller is the generic INTERNAL, not a typed "the world moved" error. More on that in F2.
Is there a durable record of what happened? No. The only trace is a shell exit code, and shells don't keep those. Yes: a signed JSON receipt (roughly 1.5KB, one ed25519 signature) plus a separate verdict file.
Can a third party with no access to the original system check that record later? Not applicable, there's no record to check. Yes, and I didn't just trust the --offline flag. I ran the same verify command inside a network namespace with zero interfaces (unshare -rn, DNS dead, ip addr show returning nothing) and it returned the identical valid:true.
If someone edits the backup or the receipt after the fact, does anything catch it? No. Flip one byte in a backup file, restore it with cp, the command returns 0, and nothing reports a problem. Yes. 600 of 600 tampered receipts detected, zero false negatives. Full breakdown below.
Can the change be undone safely? Sort of, if a backup happens to exist, but restoring it will silently overwrite anything a human or another process wrote after the original change, and report success while doing it. Yes, with an actual precondition check: if something else wrote to the file after the original change, undo refuses (exit code 3, PRECONDITION_CHANGED) and leaves the newer write alone rather than clobbering it.

The asymmetry in that table is real and I'm not hiding it: the left column is a genuine rm. The right column, everywhere it says a file was "removed," is 0-byte content annihilation, because that's the only destructive mutation this CLI surface can currently submit (see F3).

The census: 10,373 mutations

Target was 10,000. I let the six workers run until the total passed that, then killed all six at the same wall clock moment.

project mutations started
a 2,314 first, alone
b 2,049 about 20 min later
c 2,050 about 20 min later
d 2,049 about 20 min later
e 957 about 72 min after that
f 954 about 72 min after that
total 10,373

Split: 5,185 deletes (0-byte writes), 5,188 content writes.

Across all 10,373 mutations, every stage of the six process cycle returned exit code 0: submit, plan, verify, commit, checkpoint, offline receipt verify, all zero non-zero results. No gx_code error value appeared anywhere in the run.

I didn't stop at exit codes. I decoded the signed bytes inside every commit receipt payload:

inside the commit receipt payload measured
commit receipt files found 10,376
reversibility = true 10,376 / 10,376
inverse_delta present 10,376 / 10,376
precondition_fingerprint present 10,376 / 10,376
postcondition_fingerprint present 10,376 / 10,376
payloads that failed to decode 0

That's 10,376 receipts against 10,373 logged mutations, a gap of 3. I didn't wave that off. Broken down by worker it's a plus one to each of a, d, and f, and the most consistent explanation is a commit that landed on disk right before its worker got killed, before the result line got written to the log. The gap runs in the direction of the receipts being ahead of the log, not behind it: there's no case of a logged mutation missing its receipt.

Offline verification, run against the specific checkpoint each receipt was actually issued against (not the final tree, which would have made older receipts read as unreachable rather than invalid):

check result
valid:true 10,373 / 10,373 (100.0%)
inclusion:verified 10,373 / 10,373
verification failures 0

Storage cost, measured directly from .gx/ on disk: 61,530,105 bytes total across all six projects (a 13,741,045 / b 12,168,222 / c 12,171,852 / d 12,171,457 / e 5,644,955 / f 5,632,574), which works out to roughly 5,932 bytes per mutation, receipts, journal, ledger, and escrowed inverse all included.

Timing, reported two ways because either one alone is misleading. First, one cycle on a near quiet machine, WSL-native binary, nearly empty ledger:

stage submit plan verify commit checkpoint receipt verify undo total
seconds 0.073 0.044 0.053 0.206 0.046 0.026 0.216 0.720

Second, the distribution across all 10,373 census cycles (six processes each, three build jobs sharing the machine, ledger depth growing from 0 to 2,314 over the run):

min p25 median p75 p95 p99 max mean
0.419s 1.039s 1.625s 2.526s 4.714s 12.987s 42.518s 2.215s

Total cycle time summed across all mutations was 22,981 seconds (about 6h23m) of CPU-adjacent work, consumed inside roughly 98 minutes of wall clock, because I ramped from 1 to 4 to 6 concurrent workers over the run. Neither the quiet-machine number nor the census distribution is "the" per-mutation cost. The census distribution mixes every ledger depth from 0 to 2,314, and depth turns out to matter a lot (F5).

Tamper detection: 600 of 600, plus a third answer that isn't pass or fail

I built a dedicated corpus of 200 mutations, kept each one's checkpoint, and ran a clean control alongside three separate tamper arms in the same pass:

arm how many expected measured
clean control, no tampering 200 valid 200 pass, 0 fail
flip one byte in the signature 200 rejected 200 detected, 0 false negatives
flip one byte in the signed payload 200 rejected 200 detected, 0 false negatives
flip one byte in the checkpoint's root hash 200 rejected 200 detected, 0 false negatives
genuine, untampered checkpoint at the wrong tree height 199 neither pass nor fail 199 landed as unbridged, 0 elsewhere

Total tampered: 600 across the three arms. Detected: 600. False negatives: 0.

The two "flip a byte" arms that touch the signature or its payload don't fail the same way as the one that touches the checkpoint's root hash. Signature and payload tampering breaks the signature check itself ("signature":false, refused with an explicit "no valid signature under key" message). Root-hash tampering breaks anchor authentication and leaves the signature check as null, not false, because it genuinely wasn't evaluated rather than evaluated and failed. That's a real distinction: a tool that quietly turns "didn't check" into "failed" would be lying about what it actually looked at.

The fifth row is the one I think is more interesting than the tamper detection itself. Those 199 checkpoints were never touched. They're real, correctly-signed receipts checked against a real, correctly-signed checkpoint, and the only thing wrong is that the checkpoint is from a different point in the ledger's growth than the one the receipt was issued against. The tool doesn't call that valid, and it doesn't call it tampered either. It calls it unbridged, and tells you exactly what command would connect the two tree heights. Collapsing that case into a plain pass/fail would have either hidden a real gap or manufactured a false alarm on receipts that were never touched.

One more thing worth being precise about: what "tamper detection" catches here is tampering with the receipt or the checkpoint, not a change to the underlying file after the fact. I tested this directly. I hashed a file, hashed it again after moving it, and ran offline verify against the original receipt: it still came back valid:true. That's correct, not a bug, the receipt is attesting to an action that already happened, not to the file's current state. But read quickly, "tamper detection: 600 of 600" could sound like it means the tool watches your files forever. It doesn't. It means the receipt describing what happened is hard to forge after the fact.

Five things that broke

F1: submitting the same delete twice throws an opaque error, and it happened to 46.7% of an earlier run. In the first version of this census, before I redesigned it, 20 files were reused as delete targets across 300 mutation attempts. 140 of those 300 (46.7%) failed with a generic INTERNAL error at the plan stage. Every one of them was a delete against a path that had already been deleted once. The mechanism: this tool derives its internal transaction ID from the target path, the new content, the context, and the actor, and a delete's "new content" is fixed at zero bytes with no room to vary. Delete the same path twice and you get the identical transaction ID both times, so the second attempt collides with a transaction that's already committed. The state machine's response, refusing to re-plan something already committed, is arguably correct. The problem is the class of behavior it's blocking: an ordinary agent retry, on an ordinary path, hits a 500-style opaque failure. There is a workaround (change the --context or the actor key on the retry), but that means inventing a false reason for the action just to get past the collision, which pollutes the very audit trail this tool exists to keep honest. My census avoided this by giving every mutation its own file, which fixes the test, not the tool.

F2: the precondition check does fire, but the operator sees a generic error instead of the specific one that was written for this exact case. I built a controlled pair: one run where nothing else touches the file between verify and commit, and one where something else changes the file's hash in between. The first commits cleanly. The second gets refused, and importantly the world stays safe, the file's live content matches what the human actually wrote and not what the agent tried to write. But the error the caller receives is the generic INTERNAL, not the specific, well-written message that exists in the code for exactly this situation ("was planned against a state of the substrate that no longer holds... run plan again"). Reading the source, that message is reachable, but only if the same process that planned the change is still the one committing it. I ran each step as a separate process, which is a completely normal way to drive a CLI, and that forces a re-plan path that hits the generic error before it ever reaches the specific one. I did not test whether a long-lived server process (this tool ships an HTTP mode) hits the same wall, and I'm flagging that explicitly as unmeasured rather than assuming either answer.

F3: there is no way to submit a delete as a delete. The wire format for a change is content: bytes or null, where null means delete, and the underlying data model has a real "remove" operation defined. But the only path from an agent's stated intent to an actual filesystem plan produces a write, never that remove operation. Every "delete" measured in this entire post, in the table above and in the census, is a write of zero bytes to an existing file. The file survives. Whether that's a bug or a scope boundary is a design call I'm not making here. What I am doing is making sure nobody reads "delete" in this post and pictures unlink().

F4: a correct rejection can take the full two minutes to arrive. In the same world-moved scenario as F2, the undo that should be rejected (because something else wrote to the file after the original change) does get rejected correctly, but only after polling 61 times over the full 120,000ms settle window before giving up and refusing. That's one measurement, not a distribution, so I'm not claiming this is typical, but a two-minute wait for a correct "no" is worth knowing about if you're calling this from something interactive.

F5: cost scales close to linearly with how deep the ledger already is, and it's steep. I killed all six workers at the same wall clock moment, which means each worker's last 200 mutations ran under identical load on an identical machine, differing only in how many leaves their own ledger had already accumulated:

worker leaves at kill last-200 median
e 957 1.643s
f 954 1.646s
b 2,049 3.202s
d 2,049 3.212s
c 2,050 3.214s
a 2,314 3.614s

Same-depth workers agree to three decimal places, and the deeper workers are consistently slower. The slope between the shallow and mid group is 0.001432 seconds per leaf; between mid and deep it's 0.001528 seconds per leaf, close enough to call it one line: roughly 0.27 + 0.00143 x leaves, in seconds. I did not run this out to a 10,000-leaf ledger. Extrapolating that line out that far gives about 14.6 seconds per mutation, but that's a straight-line projection past everything I actually measured, not a result, and I don't have a code-level explanation for why an append-only structure would cost linearly rather than logarithmically. My guess is each process is re-reading more than it needs to on every invocation, but I haven't verified that in the source, so it stays a guess.

Two numbers I got wrong before I got them right

I killed three parallel workers early in this run because I read the combined throughput as roughly 11 mutations per minute versus a solo run's roughly 45 per minute, and concluded parallelism was making things worse. The denominator in that division included the time each worker spent building its 2,500-file tree before it did a single mutation, so I was dividing mutation speed by time spent not mutating. When I measured it correctly, the result was the opposite of what I'd killed the workers over: solo ran at 33.3 mutations per minute, four workers combined ran at 248.7 per minute (a 7.5x combined rate), and the solo worker itself wasn't even slower running alongside the others (33.3 up to 35.8). My first attempt at the corrected measurement was also wrong, for a related reason: my own "is the tree ready yet" check read a leftover results file from the run I'd just killed and declared readiness early, so I measured the tree-building window a second time by accident. It took a third pass to get a clean number. If I hadn't caught this, this entire post would have concluded that the tool doesn't scale, which would have been backwards.

Separately, the M5 "before" cell in the side-by-side table above originally showed a return code I'd mislabeled. The script printed what I called cp's exit code, but the line was actually capturing the exit code of a sha256sum call that ran immediately after. Both happened to be 0, so the printed value was accidentally correct while the label describing it was not. I only caught it because I went back and reread the script rather than trusting that a green result meant the measurement was sound.

How to run this yourself

Repo: https://github.com/TraceFold/tracefold

There's no crates.io release yet, so it's clone and build:

git clone https://github.com/TraceFold/tracefold
cd tracefold
cargo build --release
Enter fullscreen mode Exit fullscreen mode

The internal harness scripts that generated the exact 10,373 and 600/600 numbers above are part of this working session and aren't in the public repo as of this writing, so you won't get those exact figures back byte for byte. What is fully public is the CLI surface those scripts drive, which is the same six verbs used throughout this post:

gx submit <path> <new-content-or-empty-for-delete>
gx plan <transformation-id>
gx verify <transformation-id>
gx commit <transformation-id>
gx log checkpoint
gx receipt verify <receipt-path> --offline --checkpoint <checkpoint> --checkpoint-key <key> --key <key>
Enter fullscreen mode Exit fullscreen mode

Wrapping a loop around those six calls, against files you generate yourself, will get you a comparable census. To reproduce the offline claim specifically, do what I did rather than trusting the flag: unshare -rn -- sh -c '... gx receipt verify ...' on Linux, and confirm zero network interfaces are actually present when it runs.

What this does not cover

Everything in this post is the filesystem substrate only. This tool also has git, MCP, MySQL, and Postgres adapters in its design; none of those were touched, 0 of 4 measured.

The word "delete" throughout this post means 0-byte content annihilation via a normal write, not unlink(). See F3.

Beyond that: I read plan.rs in full (85 lines) and the first 47 and 60 lines of two other adapter files, out of eight files in that adapter's source, call it 2.5 of 8. The apply, commutation, and locator logic in that same adapter: zero lines read. On the CLI side, only the direct command-line surface was exercised; the HTTP server mode, a TUI mode, an SDK, and an MCP wrapper this project also ships all sit at 0 of 4 tested. Every mutation in this run went through the default allow policy; refuse and escalate paths were not exercised at all. A sandboxing feature this tool offers (confine) was never invoked; I only confirmed the receipts honestly reported it as unused rather than claiming it was on. All of this ran on a single WSL2 machine; Windows native, macOS, and CI containers are 0 of 3.

The 10,373 figure is the sum of six independent ledgers, not one ledger holding 10,373 entries. The single deepest ledger reached 2,314 leaves. Whatever happens to a ledger holding 10,000 entries on its own, I did not measure it, only extrapolated it (F5). Every mutation in the census was a single whole-file replacement or a single whole-file zeroing; multi-file transactions, large files, non-UTF8 paths, symlinks, read-only mounts, submodules, and LFS objects: none of them appeared in this run. And every tree in this census was synthetic, generated by the test harness itself. No mutation in this specific run touched a real, pre-existing open source repository.

The deepest ledger I actually measured was 2,314 leaves. Everything past that, including the 14.6-second-per-mutation projection at 10,000 leaves, is a straight line drawn through two slope measurements, not something I ran.

Top comments (2)

Collapse
 
reidmarlow profile image
Reid Marlow

The distinction between truncating to zero bytes and unlinking the inode is a critical caveat that most benchmarks would have glossed over. The failure mode I usually run into with inverse-patch gates is state drift between plan time and rollback. If another process touches the file or metadata in between, applying the inverse patch blind creates a corrupted state rather than a clean rollback. Enforcing precondition verification at reversal time, not just at commit time, is the only way that receipt remains trustworthy offline.

Collapse
 
mahirhir profile image
Mahiro Hirakawa

That is the failure I had to fix rather than argue with, and it is worth being blunt that it used to be exactly as bad as you describe. The read set was checked when the inverse was constructed, not when it was applied. A legitimate write landing in between got overwritten and the run still reported a clean restore: RC 0, "Committed", file back at its pre-image, and the other write gone without a word.

It refuses at reversal time now. A disturbed read set exits 3, and over HTTP it comes back as a 409 PRECONDITION_CHANGED. I re-ran that with the disturbance being a legitimate commit made through our own CLI rather than a third-party write, because a third-party write was the only case my original test ever covered.

Your last sentence is the half I only closed this week, and it is the harder half. Refusing is not enough if the receipt cannot show which of the two happened. So the disposition, either attested or unobservable with the reason it was unobservable, now goes into the signed bytes rather than sitting in a log line:

github.com/TraceFold/tracefold/blo...

Where it is still soft, since you clearly know where to press. The read set staying inside the CAS scope is currently a property of the five shipped adapters rather than a check, so nothing structurally stops an adapter from returning a subject narrower than what the inverse actually touches. And the attestation is exercised on fs only. git, mcp, postgres and mysql are zero.