building vodou.

Two writers, one judge: how a review queue became 90% noise

A memory-conflict queue hit 1,377 rows on a vault with 33 real conflicts. One producer had an LLM judge and dismissed 97% of its own work. The other had none.

Chad Priest / / 9 min read

If your agent has persistent memory, it will eventually store two things that cannot both be true. Last week’s price and this week’s price. The old address and the new one. Something a document said and something the user said. Every system that stores facts over time grows a contradiction problem, and every one of them ends up with the same surface: a queue of rows where a human is asked to pick a side.

Mine had 286 open rows and roughly 90% of them were garbage. Not wrong in an interesting way. Garbage: line numbers, array indices, shell snippets, CPU floats with fifteen decimal places, and the same date written two different ways. I want to describe how that happened, because the cause was not the detector. It was the queue having two writers and only one of them being judged.

Ten cards, each with the evidence folded behind it

What shipped is small on the surface. The conflicts panel now shows one card per disputed value pair, capped at ten, with a source count. If a card says “5 pieces of evidence,” you can expand it and see the five, fetched on expand rather than up front, because most cards are 1x and paying to load evidence nobody opens is how a “very few, highly relevant” queue turns slow. There is a resolved view, so a decision you made is a thing you can go back and read. There is a collapsed receipt for what the judge settled without asking you. And a card whose losing side no longer exists is not offered at all.

Under that, the change that mattered:

promoteor settle silentlyImport scanpairwise, semanticValue mismatchcosine + differing numbersetsstatus = candidateJudgefilters, then decidesstatus = openThe queue a human readsThe second detector used to write straight into the box on the right.
Who is allowed to write 'needs a human'

932 dismissed and 33 open, against 143 dismissed and 253 open

The measurement that changed my mind took one query. Two producers wrote into the conflicts table. I grouped by producer and by status:

import scan · dismissed932 rowsimport scan · open33 rowsvalue mismatch · dismissed143 rowsvalue mismatch · open253 rows
Rows by producer and status

The judged producer dismisses 97% of its own candidates. That is the number I keep coming back to. An LLM pass over a pair of chunks throws away nineteen out of twenty things a similarity function thought were worth a human’s attention. The second producer had no such pass. It wrote status='open' directly, on evidence that amounted to “these two chunks are cosine-similar and their number sets differ.”

And the thing deciding what a “number” was would call any digit-ish token a value. So 1.0 from a run log and 1.0 from a different run log were a conflict. [0] and [1] were a conflict. 8/31/2026 and 2026-08-31 were a conflict, which is the one that made me stop and look properly, because it is not a data problem at all. It is the same day written two ways, compared with exact string equality.

The fix for that one is a canonical fold before the set difference, and it has a constraint I got wrong in an earlier draft: the fold must never change a value’s shape. I had a version that stripped currency symbols, which quietly turned $20/mo into a bare number and broke a comparison that had been working. Both date notations are already the same shape, so folding them is safe. A yearless 7/24 is not the same shape as anything and stays alone.

1,377 cards on a vault with 33 real conflicts

Then the console. The panel’s query returned every row of every status, ungrouped. That is 1,377 cards, 1,075 of which were already dismissed, on a vault that had 33 conflicts a human should look at.

The part I find embarrassing is that the CLI had been right the whole time. The command-line lister had filtered to open and grouped by value pair with a source count since the day it was written. Two readers of one table, one of them correct, and the wrong one was the one users actually saw. So the console adopted the existing grouping rather than growing a second one, and the card key is now spelled once in the engine and mirrored in MCP-servers/Vodou-Console/src/brain/queries.ts instead of being invented twice.

That mirroring is not tidiness. Keying the resolve cascade on the value pair while the console keyed on the slot left four buttons sitting behind “r/AI_Agents subscriber count” after the user had already decided it. Two readers deciding separately what a card is produced a card you could resolve and then resolve again.

Fourteen cards with two dead buttons, and 29 writes with no surface at all

Two more, both found by looking rather than by a test failing.

Resolving a conflict demotes the losing chunk. So when the losing chunk is already gone, there is nothing to demote. The card still renders, because the texts are denormalized and it outlives its chunk, and it renders with two buttons that do nothing. Fourteen of those were open. The predicate for “is this row actually actionable” is now spelled once and shared by both readers, for exactly the reason the card key is. It tests existence rather than archived = 0, because an archived chunk is still there and still resolvable, and filtering it out would hide conflicts the user can act on. The sweep also settles orphans now, so they do not sit open forever behind a filter. Invisible is not finished.

And the judge settles some conflicts without asking, by demoting the older side. On the first run, 29 of those landed with no surface anywhere. The only way to see them was to open the database. Which is how I found out that an early keep-newest rule had been inverted: nothing told me, because nothing showed me. That receipt is now in the panel, collapsed by default, because it is a receipt and not a queue.

Before1,377 cards, every status1,075 already dismissed253 open with no judge14 cards with dead buttons29 silent writes, no surfaceAfter10 cards, open only, groupedevidence fetched on expandunactionable rows not offeredresolved view for what you decidedcollapsed receipt for auto-settled

The property: one writer of “needs a human”, everyone else writes “candidate”

Here is the checkable version, and it is either true or false of your codebase right now.

Exactly one code path may set the state that means “a human must look at this.” Every detector writes a candidate state. Promotion is a separate pass that can refuse. If you have two writers of the terminal state, one of them is going to be the one nobody wrote a filter for, and it will out-produce the good one by an order of magnitude while looking identical in the UI.

The second one, which is the same disease from my earlier notes: a rule that exists only in a document is not implemented. My project instructions have said for months that ephemeral tool output is not a fact and must not feed the contradiction detector. Nothing enforced it. Every one of those 1.0-versus-1.0 conflicts was a run-log line, and the rule that would have stopped them had been written down and never turned into code. Filters now run at the producer and again at the judge.

Run this on your own queue: group by producer, then divide

Five minutes, no tooling of mine, against any review or moderation or flagging table you have.

SELECT producer,
       COUNT(*)                                            AS total,
       SUM(status = 'open')                                AS still_open,
       ROUND(1.0 * SUM(status != 'open') / COUNT(*), 2)    AS settle_rate
FROM review_queue
GROUP BY producer
ORDER BY still_open DESC;

Passing looks like every producer landing in the same band: each one throwing away most of what it proposes, at rates within a factor of two of each other. Failing looks like mine, one row at 0.97 and one row at 0.36, and the low one holding almost all of your open volume. If your table has no producer column at all, that is the finding: you cannot tell which detector is filling your queue, so you cannot tell whether any of them is being judged.

Then the orphan check, which found my fourteen dead cards:

SELECT COUNT(*) FROM review_queue q
WHERE q.status = 'open'
  AND NOT EXISTS (SELECT 1 FROM facts f WHERE f.id = q.loser_id);

Anything above zero is a card in your UI with a button that cannot do its job. Passing is 0.

Last, count how many places spell the predicate for “this row is actionable”:

grep -rn "status *= *'open'\|status *== *['\"]open" \
  --include=*.ts --include=*.js --include=*.py --include=*.sql . \
  | grep -v test | wc -l

One or two is a shared definition. Five is two readers that have already started disagreeing and have not noticed yet.

Deterministic adjudication only helps the rows that reach the adjudicator

The current advice on this class is good and it did not save me. Simon Foster’s agent-dialectic-resolver states the rule plainly: “LLMs can propose. LLMs can object. LLMs can request evidence. LLMs cannot adjudicate.” Mahamudul Hasan Rubel’s three-tier write-up draws the same seam, extraction then deterministic validation then arbitration.

I agreed with both. I had built both. The bug was that a second extraction path bypassed the arbitration tier entirely, and every diagram in this literature is drawn as a single pipeline, so there is no place in the picture for “the other producer.” That is why the Semantic Consensus finding lands for me: roughly 79% of multi-agent production failures come from specification and coordination rather than model capability. Mine was a coordination failure between two pieces of code I wrote, in one process, on one table. No agents disagreed. Two INSERT sites did.

The transferable correction to the diagram is one arrow: the resolver must be the only thing with write access to the terminal state, not merely the recommended path to it.

The tool that lists conflicts still cannot settle one

Live limitation, unfixed today. The MCP tool surface can list conflicts and their statuses and it cannot resolve one. Resolving is still a CLI action. So an agent connected over MCP can see that two of your facts disagree and has no way to act on it, which means the read and the write live on different surfaces and I have not reconciled them.

Related, and deliberate: the license and entity conflicts in my own vault are still sitting open. Those are judgments I want to make myself, so I tested the resolve path on a run-log conflict instead. Which is a fair description of the whole feature: the machine is now good at knowing which arguments it is not qualified to end.