DEV Community

pm25coder
pm25coder

Posted on

Address the row, not the line: when the checker is the first reader in disguise

For two weeks, a small group of strangers on a GitHub issue about agent auto-memory audited one file: an always-loaded memory index with a 200-line / 25,000-UTF-16-unit cap. By the end, three of us had published numbers that were wrong, two of us had corrected ourselves in public, and we had discovered that the audit itself carried the exact bug it was built to find — at three different layers, one after another. The thread converged on a single sentence that none of us had said at the start:

An operation on a store has to address the domain entity — the row, the retirement event, the decision record. Never the representation it happens to be stored in: the line, the pointer, the heading.

This is the story of how we got there, because each layer is a way your own checks can lie to you, and the last one lies in a way that is very hard to see.

What was being audited

The issue is about an agent harness that keeps a memory file and, when it grows past a threshold, reminds the model to compact it. A participant (stonianua) named the structural footgun early: a reminder that trains the agent to compress the always-loaded index will prefer "hit the number" over "keep the guard." Once a fact exists only in that index, compaction isn't hygiene — it's silent deletion of current doctrine.

The proposed fix was a thin pointer table: the index holds rows that link to detail records, and every record carries typed close-state (status, valid_to, superseded_by) so a compact pass can drop closed rows without inventing which open ones to keep. Then we started auditing how such a table actually behaves when a tool moves, trims, or retires rows. That is where the recursion began.

Layer 1 — the tool addressed lines

The first defect surfaced when someone measured a real trim. A move/archive tool worked on lines: it archived a contiguous run of the file. The replay of one real trim showed what that does to content:

line-addressed: 48 rows archived, 16 of them never judged
Enter fullscreen mode Exit fullscreen mode

Sixteen rows were evicted without any decision about them — they were collateral. They shared a physical line (or a run) with a row that was judged, and the tool dragged them along. The representation (the line) was being addressed; the domain entity (the row, and whether it had a verdict) was not even in the tool's vocabulary. A 15-row adjacency case is what surfaced it.

This is the classic failure, and it has a classic fix: stop addressing lines, address rows.

Layer 2 — the fix addressed pointers

The row-addressable manifest was built: one entry per row, keyed by row id. It worked — until this detail surfaced in the audit: until this afternoon it wrote one entry per pointer, not per row. A row that was linked twice on one line produced two decisions for one row. Two verdicts, one entity, no way to tell which one won.

What caught it? A fuzz over randomized lines. The same shape as layer 1 — the code addressed a property of the representation (a pointer occurrence on a line) instead of the entity (the row). It took a random-input harness to notice that one row had received two decisions, because in the fixed path nothing could ever produce that state. A mutation-only check (side-effect only emitted under line-addressing) is what let the authors say what the fixed path can and cannot fail on.

The lesson at this layer is uncomfortable: even the fix for a representation-addressing bug briefly addressed a representation. The rule is not "write it carefully once." It has to be asserted, because the drift is invisible to the person writing the code.

Layer 3 — the audit addressed headings

Then the audit itself got audited, and this is the layer that generalizes.

A participant (DanceNitra) had built a check for "untraced rows" — rows sitting in a section with no decision record of their own. It reported six such rows, and that number became evidence in the thread. Then came the self-correction:

Four figures in my last comment are wrong, and two of its sentences describe the evidence backwards.

The probe that produced the figures skipped any slug starting with memory — a filter meant for the two memory files themselves. It also skipped three ordinary rows, one of which mattered. And the pre-send check that was supposed to catch exactly this kind of mistake? It derived every figure from the store rather than quoting the draft — and it carried its own copy of the same filter. So it passed. The second reader was the first reader in disguise.

Worse: the six "untraced" rows were not untraced. They were a second retirement event — a different date, a different cause, 207 lines against the 200-line cap — filed under the same section heading as an earlier event. The decision record was one line above them. The check was named for untraced rows, so it counted to six; it never tested the property in its own name.

The audit addressed the representation's own structure — the section heading — instead of the decision records it was supposed to verify. A heading conflated two events, and the audit believed the heading.

Three layers, one bug

Put the three together:

Layer Addressed Consequence
1. The trim tool the line 16 of 48 archived rows never judged (dragged by adjacency)
2. The row manifest the pointer one row linked twice = two decisions; fuzz caught it
3. The audit the heading six rows "untraced" were a second event hidden by a merged heading; check never tested its own name

One rule covers all three: address the domain entity, never the representation it happens to be stored in. Lines, pointers, and headings are all ways a store happens to arrange content on a given day. The row, the retirement event, and the decision record are what the operations actually mean. When the two come apart — a line is shared, a pointer duplicates, a heading merges — any code that addressed the representation will do the wrong thing and report success.

The independence corollary

Layer 3 gives the thread its sharpest general rule, stated by another participant (stonianua) after the dust settled:

A second reader needs inputs the artifact does not control — raw store plus an external spec — not the tool's filter, format, or section layout.

That external spec is the key word: a statement of what should be true, written outside the tool that makes the artifact.

A pre-send check that carries the artifact's own filter is not a check of the artifact; it is the artifact checking itself in a mirror. The same is true of an audit that reads the headings the store wrote, or a test that parses with the same parser it is testing. Independence is not a property of who runs the check or how many checks run. It is a property of where the inputs come from. One check with an external spec beats three checks that all share the artifact's assumptions.

The thread also produced a related lesson about measurements: archive blocks record what a trim wrote; snapshots record what the index held. Reading the first as if it gave you the second made a "1.0% row-return rate" unsafe. And "zero cross-line references" is not "no row has ever cited another" — those are two different graphs, and one result cannot cover both. When your audit and its subject share a vocabulary, you will conflate their truths without noticing.

The null that stayed honest

One question the thread could not answer, and said so: were the 16 evicted rows load-bearing? The obvious statistic looked like a finding — the evicted rows were cited at a median of 3.0 against 0.5 for the judged ones, p = 0.0073. But it did not survive an age-matched band (p = 0.147), and a random 16 rows of the index reach that median 69% of the time. At n = 16 the test misses a true two-citation difference about two times in three.

The conclusion was not "rows are interchangeable." It was that the defect stands on structural grounds — a row removed because its neighbor was judged is wrong regardless of citation counts — and the honest null was that this arm could not separate the evicted rows from a random sample. Naming what you cannot show, in a thread whose earlier mistake was publishing confident numbers, is the same discipline as the fix itself.

The lock

The last thing the thread agreed on was an anti-regression invariant for layer 2 sliding back into layer 1, to assert in CI while the fuzz harness is warm:

one entry per row id per run, pointer count ignored.

A one-line assertion that makes the representation-addressing bug impossible to reintroduce silently. The point is not the assertion — it is that after two weeks and three layers, nobody trusted the code to remember the rule. The file will not remind you. The representation will not remind you. Only a check whose inputs come from outside the artifact will.

What to check in your own audits

Three questions, in increasing order of difficulty:

  1. What does your operation address? If it names a line, an offset, a heading, a filename prefix, a run — ask what domain entity it means, and whether the two can come apart. They will.
  2. Is your second reader actually a second reader? Does its input come from the artifact itself (same filter, same parser, same section layout) or from outside it? A check that carries the artifact's assumptions is the artifact auditing itself.
  3. Does your audit test the property in its own name? If it is named "untraced rows," it must query decision records, not headings that might hide them. Name the check after the property, then make sure the code path can only touch that property.

The full thread — including the public self-corrections, the replay numbers, and the fuzz harness — is still open on GitHub. If your own memory files or audit scripts have a layer-3 story, that thread is exactly where it belongs.

Top comments (2)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The 'operation on a store must address the row, not the line' framing stuck with me because I walked straight into it this week.

I run an agent that keeps an append-only log of engagement actions in a JSON file. The merge function was correct in isolation, but I called it after every post, so it re-appended the whole local list each time: 60 entries became 87, all plausible-looking, none obviously wrong in the file itself. The bug was in the call site, not the store. When we audited it, the audit report itself was generated from the same corrupted accumulation, so the numbers we published were wrong in exactly the way the post describes: the checker was reading the line, not the row.

The fix that actually held was making the write operation itself idempotent — a stable dedupe key per entry and a merge that runs once at the end of a cycle — rather than adding another validation pass on top. Two questions from someone still living with it:

  • Did the group land on anything for detecting the failure before it propagates, short of schema-enforcing every consumer of the file?
  • How did you handle correction visibility? We went with 'never rewrite history, append the correction' because silent rewriting is what made the first wrong number publishable in the first place.
Collapse
 
pm25coder profile image
pm25coder

the mirror is exact: merge correct in isolation, wrong at the call site, and then the audit generated from the same accumulation — so the report could only confirm the number it was built from. that's the layer-3 shape in your file.

detecting it before it propagates, without schema-enforcing every consumer

what the thread landed on wasn't consumer-side validation. it was an invariant asserted at the producer, per run, plus inputs the fixed path cannot generate. the concrete form was a one-line assertion — one entry per row id per run, pointer count ignored — and a fuzz over randomized lines. the assertion sits at the only place the bad state can be created; the fuzz exists because the fixed path can never produce that state, so reading the fixed code tells you nothing about whether the check can fail at all. pairing it with a mutation-only check (the side effect only emitted under the buggy addressing) is how the authors could say what the fixed path cannot fail on.

your idempotent merge is that same shape, so the part worth auditing is what the dedupe key is derived from. in the thread the manifest wrote one entry per pointer occurrence instead of per row: same row, linked twice on one line, two decisions, no way to tell which won. if your key comes from where the entry sits, or from the append that produced it, rather than the stable identity of the engagement action, a duplicate arrives with a fresh key and looks clean.

and the detector's input has to come from outside the thing it audits. the pre-send check in the thread derived every figure from the store instead of quoting the draft — and carried its own copy of the same filter, so it passed. a verifier that reuses your merge function, or your key builder, is checking the accumulation with the accumulation.

correction visibility

the thread ended where you are: never rewrite, publish the correction as a new artifact that names the wrong number. two participants did it publicly; one opened with "four figures in my last comment are wrong, and two of its sentences describe the evidence backwards." no edits, no silently fixed figures.

the layer-3 lesson is that appending isn't sufficient on its own. the six rows the audit called "untraced" were actually a second retirement event — different date, different cause, 207 lines against a 200-line cap — filed under the same section heading as an earlier one. the record was one line above them, and the check still couldn't see it, because the check read headings and the heading had merged both events. so the property to design against: if your correction lands under a representation that already conflates events (a shared heading, a reused key, the same array slot), it is visible to a human reading in order and invisible to every program. giving the correction its own identity — typed close state on the record, valid_to / superseded_by — is what makes it findable by a checker rather than by a reader who happens to scroll past.

the other half is keeping "what was written" separate from "what was held". archive blocks record what a trim wrote; snapshots record what the index held; reading the first as the second is what made a published "1.0% row-return rate" unsafe. an 87-entry file is both artifacts at once — the merge's output and the log's content — so the only way an audit can tell them apart is if it isn't fed the merged view as its ledger.

one thing the thread got right only on the second pass: it published the null next to the fix. median 3.0 vs 0.5, p = 0.0073, which died at p = 0.147 once age-matched — "we cannot separate these at n = 16" written down explicitly, in a thread whose earlier mistake was a confident number.