DEV Community

Mahiro Hirakawa
Mahiro Hirakawa

Posted on

The enum had six reasons and the code needed a seventh

Up front, the weakest part of this story: when I re-injected the defect I'm about to describe, every runtime test in the workspace stayed green. The only thing that went red was a source scan, a test that reads the pipeline file as text and checks each construction site. If your instinct is that source-scan gates are a smell, hold that thought, because the runtime test that catches this case exists only since the follow-up commit, and I'll show what it took.

Some context. I work on a reversibility gate for agent tool calls. When it refuses to attempt a rollback, that refusal is a first-class value, and the rule is that it has to carry a reason. Rollback::NotAttempted without a cause is the engine shrugging, and a shrug is exactly the kind of confident nothing this project exists to eliminate.

A census found a shrug

On 2026-08-31 a test census walked every construction site of Some(Rollback::NotAttempted) in the pipeline. Eight sites. Seven inserted a cause. The fourth one didn't. The test that caught it is called every_construction_of_the_third_value_names_its_cause, and it went 8 passed, 1 failed, with the silent site printed by line.

The site is the postcondition mismatch path. The forward apply succeeded. Then the digest the adapter reported for the new state disagreed with the digest the plan had promised. The inverse is sitting in escrow, fully settled. But that inverse was derived from the same model that just predicted the wrong outcome, so the engine declines to fire it. Fail-closed: an untrusted undo is worse than no undo.

That refusal is correct. It just had no name.

Why none of the six existing causes fit

The obvious fix is to pick the closest existing variant and move on. I went through all six, and each one would have been a lie about the facts at that site.

EscrowStillPartial says the escrow didn't settle. It settled. NoInverseWasEscrowed says there's no inverse. There is one. RecoveredWithoutRebuilding describes a repair path this isn't on. WorldNeverMoved says nothing changed. Something changed. WorldCouldNotBeRead says the state was unreadable. It read fine, the two digests just disagree.

The tempting one is WorldMovedBeneath, which says a third party wrote under us. But nobody observed a third party. The only evidence in hand is the apply's own answer disagreeing with the plan's promise. Stamping that as "the world moved beneath us" would be a confident report of an observation nobody made. Which is the exact defect class the census test exists to catch. I'd be reproducing the disease inside the cure.

So the enum got a seventh variant: PromisedPostStateWasWrong. The inverse exists, and the engine distrusts the model it stands on. That's the whole sentence, and now the wire can say it.

Cost on the wire: this type never enters the journal or the state root, so adding a variant costs an upgrader nothing. What changes is one new possible string in a diagnostic member, and readers were already specified to treat unknown words as "hold", not "proceed".

One new variant, three new reds

Adding it made three other tests fail, in sequence, and all three reds were the accounting doing its job. The suite that pins test counts noticed r26 grew from 9 to 10 tests. Fixing that meant the public limits page needed a dated correction block. Writing the correction meant the registry test, which requires every suite named on the limits page to be registered, went from 41 entries to 42. Counts move only together with a named ruling, and each gate refused to let one move alone.

The runtime hole, and closing it

Here's the honest part. During acceptance, a reviewer removed the new insert from the construction site and re-ran everything. The source scan went red, as designed. Every runtime test stayed green, including the new behavioural test for the seventh clause, because that test checks the CLI wording layer, not the wiring. One gate, of one kind, was the entire defence.

The follow-up added an end-to-end test on the real engine. The fixture's plan promises a digest that an honest apply will never produce, the engine runs the full submit, plan, verify, commit path, measures the mispredicted apply itself, and the test asserts the aborted state, the declined rollback, and the seventh cause by name. Red-first: with the insert removed, it fails with cause=None printed. There's a negative control too, a correct promise that commits and carries no cause, so the test can't pass by matching the seventh cause everywhere.

Numbers, all from 2026-08-31 and 2026-09-01 on WSL Ubuntu 24.04: the census suite went from 8 passed 1 failed to 10 passed 0 failed. The new E2E is 2 passed, and 1 passed 1 failed under injection. The engine's full test set after the change: 64 suites, 341 passed, 0 failed.

What I'd take from it

If your code has a value that means "we declined to act", check whether it can be constructed without a reason. Mine could, in one of eight places, and the place with no reason was the subtlest one. And when no existing reason fits, the cheap-looking move of reusing the closest word is the expensive one. A new variant cost about ten lines plus a day of count reconciliation. A wrong variant would have shipped a small lie into every receipt that hit that path.

Code is at github.com/TraceFold/tracefold, Rust, Apache-2.0. The limits page carries the dated correction block from this change.

Not released as a package. There's a v0.1.0-alpha tag on GitHub with no binaries attached, and that's the whole distribution story so far. Correction 2026-09-01: a Linux x86_64 tarball landed on that tag about three minutes before this post went out, built outside CI. The overlap was accidental and the sentence was stale on arrival.

Top comments (0)