DEV Community

Mahiro Hirakawa
Mahiro Hirakawa

Posted on

The discriminator between a membrane and a logger

The discriminator between a membrane and a logger

The first version of this test passed for the wrong reason, and it took me a while to notice.

I'd built a small plugin that puts gx, a reversibility layer for agent effects, in front of OpenClaw's before_tool_call hook. The plan was straightforward: intercept a file write, escrow an inverse for it through gx, let the write proceed, and prove afterward that gx could undo it. Four scenarios, twenty-one assertions, everything green. Then I looked at what the passing assertion in scenario A actually checked, and it was this: the file, after the write, contains the content I wanted.

That's true if gx did anything at all. It's also true if gx did nothing and the native write tool just wrote the file itself, the way it would with no plugin installed. A test that passes identically whether or not the thing under test ran isn't testing the thing under test. It's testing that the file system works.

Why the fix isn't obvious

The honest fix looks easy until you run into gx's actual design, which has no "escrow without applying" mode. A commit is one atomic step: escrow the inverse, reconfirm the precondition still holds, apply the change, issue the receipt. There's no half-state where gx has committed to a write but the bytes aren't on disk yet. Practically, that means once the hook admits a write, gx has already put the final bytes in place, before OpenClaw's own write tool ever runs its own code.

So OpenClaw's native tool, unaware of any of this, goes ahead and writes the same content a second time. Two writers, same bytes, same file, one right after the other. If the only assertion you have is "does the file have the right content", you genuinely can't tell which of the two writers made the decision.

The line that actually separates them

The fix is to make the second writer, the native one, tell on itself. Right before OpenClaw's write tool commits its own bytes, it can look at what's already sitting in the file. If gx got there first, that snapshot already matches what the model asked for, before the native tool has written a single byte of its own turn.

[OK  ] gx had already applied it -- the native tool found the change done:
       native tool saw bd53b1ff53596175... (desired=bd53b1ff53596175...)
Enter fullscreen mode Exit fullscreen mode

That assertion only holds in the world where gx wrote it first. If the plugin were a passive logger, one that just watched the write happen and recorded it afterward, the native tool would still be the one laying down the bytes, and the pre-write snapshot would show the old content, not the new one. This single line is the entire discriminator. Everything else in the demo, the block scenario, the undo, the receipts, was already correct; this was the one assertion that had been quietly proving nothing.

Keeping the third value honest

gx returns one of four verdicts for a proposed effect: Admit, Deny, Escalate, or Unknown, where Unknown means the membrane couldn't be reached at all, not that it looked and said no. Collapsing Unknown into Deny is tempting, it turns the plugin's logic into a plain boolean, and it's exactly the mistake a reversibility tool can't make about its own instrumentation without undercutting its own premise: the whole point is telling "checked and refused" apart from "never checked."

That discipline paid for itself almost immediately, by accident. Early on I had the CLI invocation wired backward, the environment variable assignment and the binary path swapped, so gx read env as a subcommand it didn't recognize and failed outright. Because the plugin kept that failure as Unknown instead of quietly reporting it as a policy Deny, the assertion that checks for a named denial reason caught it as a wiring bug, not as "the policy correctly rejected this write." A collapsed verdict would have let a broken invocation pass for a working denial. It didn't, because Unknown stayed Unknown.

None of this needed a line of OpenClaw's source read to build. It needed reading the output of my own test, which said "pass" while proving nothing, and refusing to trust it until it said something that could only be true once.

Repo: github.com/TraceFold/tracefold, Rust, Apache-2.0. Alpha: latest tag v0.1.2-alpha (2026-09-01), published on crates.io as tracefold 0.1.2. Still alpha, and the limits page is longer than the feature list on purpose.

Top comments (0)