DEV Community

Cover image for The Detector Reported Zero Because It Only Had One Item.
Self-Correcting Systems
Self-Correcting Systems

Posted on AI-assisted

The Detector Reported Zero Because It Only Had One Item.

Two instructions went into an Auditor my agent collaborators and I built to surface conflicts in agent instruction files. Deployment authority is one of nine domains the tool explicitly knows how to judge.

Never deploy without human approval.
Auto-deploy the moment tests pass.
Enter fullscreen mode Exit fullscreen mode

On main at 172d962, that returns:

posture: low_observed_risk
counts: {"items": 1, "labels": {"governs": 1}, "risk_high": 0,
         "conflicts": {}, "gates": 0, "authority_categories": 0}
Enter fullscreen mode Exit fullscreen mode

low_observed_risk is the product's own string, from agents/report_writer.py:110. Not my summary of the output. The output.

One item. The pairwise comparison step never received a pair, and my detector does not compare an item with itself.

After the repair, same input, live service:

{
  "severity": "high",
  "item_id": "M001, M002",
  "type": "authority_collision",
  "finding": "Conflicting governing instructions in deployment: require_human_approval vs allow_automatic.",
  "evidence": "Never deploy without human approval. | Auto-deploy the moment tests pass."
}
Enter fullscreen mode Exit fullscreen mode

posture: needs_review. Two items, one high-severity collision, one verification gate.

The difference between those two outputs is that two lines were touching.

Where the failure actually was

Before anything compares instructions, something has to split the text into separate instructions. Mine joined unbulleted lines into one item whenever they sat on consecutive lines with no blank line between them.

The join is in the tool's first commit, b71892d, authored 2026-06-01 13:06:47 -0400, at agents/memory_extractor.py lines 50–51:

content = " ".join(part.strip() for part in pending_paragraph if part.strip())
if len(content) >= 36:
Enter fullscreen mode Exit fullscreen mode

Both defects in this article are on those two lines, and they have been there since the first commit. Three months.

Zero findings was an answer about a collapsed population. The pairwise loop behaved exactly as written. It never got a pair.

I have been publishing about this class of defect for three months: a count of zero means nothing until you know what reached the counter. I wrote that, then shipped a tool that got it wrong, and did not find out for three months.

How wide the defect actually was

The detector knows nine domains: deploy authority, secrets handling, database source of truth, access scope, customer response, log retention, billing records, refunds, escalation. All hand-written.

(Seven live in a stance table you can read in one glance. Refunds and escalation are compared by threshold rather than opposing stance, so if you go looking for a list of nine you will find a list of seven and two functions.)

The precise scope, because the wider version is wrong: any pair whose two sides were written as adjacent, unbulleted lines with no blank line between them could be collapsed before comparison, in any of the nine domains. That is not "the nine domains were disabled." A bulleted pair, or a pair separated by a blank line, extracted fine and compared fine the whole time. The vulnerable thing was a writing shape, not a domain.

My own commit message on the repair says it worse than this article does — that consecutive lines "silently disabled conflict detection" across the nine domains. That wording is too wide. I am correcting it here rather than rewriting the commit.

Three things about the repair worth more than the repair

One: the tests were themselves tested.

The repair added seven regression tests. Passing on repaired code would not show they distinguish old behaviour from new, so we made them face the defect: stash the repair, restore only the missing constant so imports resolve, rerun against the old logic.

Four of the seven failed. Real behavioural failures, not import errors.

But four red lines are not four proofs, and the reasons matter more than the count:

Test Fails on Is that the defect?
adjacent_instructions_do_not_merge assert 1 == 2 Yes. Two lines glued into one item.
enumerated_domain_still_produces_a_real_collision assert [] Yes. No pair survived, so no collision could fire.
short_high_risk_instruction_is_not_silently_discarded assert 0 == 1 Yes — but only because the injected constant is 12. Inject 36 and it dies on assert 36 <= 16, failing on the constant before it ever reaches the discard.
governing_instruction_..._reports_uncovered_domain assert 'uncovered_domain' in set() No. Old main has no uncovered_domain in the detector at all. That failure is missing new code, not collapsed extraction.

Two of these four prove the extraction bug. One proves it only under the right constant. One does not prove it at all. A negative control whose failures fail for the wrong reasons is the exact defect this article is about, so I would rather print the table than let four red lines carry more weight than they earned.

Two: the first deploy succeeded on the wrong tier.

gcloud reported the truth: the web service deployed and served 100% of traffic. Accurate. I read it as meaning the behaviour had changed. It had not.

The web app is a router. Extraction runs in memory-extractor-agent, a separate Cloud Run service. The revision timestamps are the receipt:

memory-authority-auditor-web-00003-82f   2026-09-02T13:17:10.714842Z
memory-extractor-agent-00002-grk         2026-09-02T13:31:32.807334Z
Enter fullscreen mode Exit fullscreen mode

Fourteen minutes and twenty-two seconds in which a correct success message sat on top of unchanged behaviour. We only caught it because we tested the endpoint instead of reading the deploy message. Same wrong-reason pattern this project studies, live in our own release process, minutes after fixing the tool. The receipt was not false. My reading of what it covered was.

Three: I added an absence instead of a domain.

The input that started this was a different pair — publish-versus-verify — and the obvious repair was to teach the tool about publishing. We did not.

Tuning a ruleset to the case someone just handed you proves only that it catches the known case. So the detector now emits uncovered_domain when a governing instruction matches no rule at all:

This instruction governs action but matched no contradiction rule, so it was NOT evaluated for conflicts. Absence of a conflict here is absence of a check, not evidence of agreement.

Before, no conflicts and never checked rendered as the same sentence. Now they do not.

The original pair still is not solved, and the live output says so

Here is the input that started this, run against the repaired live service:

"items": [
  {"id": "M001", "text": "Current policy: verify the live artifact before publishing."},
  {"id": "M002", "text": "Old note: publish immediately without checking."}
],
"classifications": [
  {"id": "M001", "authority_label": "governs",      "confidence": 0.78},
  {"id": "M002", "authority_label": "context_only", "confidence": 0.64}
],
"conflicts": [
  {"severity": "medium", "item_id": "M001", "type": "uncovered_domain",
   "finding": "This instruction governs action but matched no contradiction rule, so it was NOT evaluated for conflicts."}
]
Enter fullscreen mode Exit fullscreen mode

posture: usable_with_gates. Extraction is fixed — two items, correctly split. It is still not an authority_collision, and it never will be until publishing becomes a listed domain.

And there is a second gap in that JSON I did not know about until I pasted it for this article. M002 is classified context_only at confidence 0.64. "Publish immediately without checking" is an imperative, and the classifier does not consider it strong enough to govern. So the uncovered_domain warning fires on M001 only. The half of the contradiction that tells you to skip the check gets no warning at all, because context_only items are not eligible for one.

The extraction repair is real. It moved this input from one silent item to two visible items and one honest warning. It did not make the tool right about this pair.

What is fixed and what is not

Fixed: finished, unbulleted instructions on adjacent lines no longer merge when the first line ends in terminal punctuation. The minimum item length dropped from 36 characters to 12, because the old floor silently discarded the unbulleted instruction Delete all logs. — sixteen characters, high risk, dropped with no record. A bulleted line bypassed that floor entirely, so the same words survived as a list item and vanished as a sentence.

Not fixed, with the receipts:

  • A standalone unbulleted fragment under 12 characters still disappears with no record. Wipe logs. is ten characters and returns zero items. So does See above. — the floor does not distinguish a command from a cross-reference, it just deletes both.
  • The split now over-fires on hard-wrapped prose. I previously claimed wrapped text was safe because wrapped lines do not end in terminal punctuation. That is a bet on wrapping, not a proof, and here is the counterexample: "Escalate to the on-call engineer within 15 min.\nThen page the team lead if unresolved." is one two-step escalation procedure and the repair returns it as two independent items. Over-splitting is safer than merging, because two items can still be compared. It is still wrong.
  • Still only nine conflict domains. A governing instruction outside them produces uncovered_domain, which names the gap but not the conflict. A context_only item outside them produces nothing at all — see M002 above.

Reproduce it

Both hostnames for the live app route to the same service; I checked with the same payload and the responses are identical, so there is no stale tier to trip over.

The repair is public as a branch, not on main: beae0bb on fix/extraction-merge. Three files, 146 additions and one deletion. main is still 172d962 and still carries len(content) >= 36, so a default clone gets the defect. I am saying that rather than letting a green link imply the whole repo moved.

Suite on the branch: 107 passed, 1 skipped, 1 xfailed in a clean clone. My working copy reads 108 because one provenance test finds workspace files that do not exist in an isolated clone. The clone number is the honest one to print next to a clone command.

Run the negative control:

git clone https://github.com/keniel13-ui/memory-authority-auditor
cd memory-authority-auditor
git fetch origin fix/extraction-merge
git checkout FETCH_HEAD -- tests/test_extraction_boundaries.py

# Restore only the constant the new tests import, so collection succeeds.
# The old extractor below still uses its own literal >= 36 logic.
python3 - <<'PY'
from pathlib import Path
p = Path("agents/memory_extractor.py")
t = p.read_text()
assert "def extract_memories(" in t
p.write_text(t.replace("def extract_memories(", "MIN_ITEM_CHARS = 12\n\n\ndef extract_memories(", 1))
PY

python3 -m pytest -q tests/test_extraction_boundaries.py
Enter fullscreen mode Exit fullscreen mode

4 failed, 3 passed — with the caveats in the table above.

Why this matters past one tool

Agent instruction files accumulate rules written at different times by different people. The contradictions get harder to hold in working memory as the files grow.

The reason to build a tool like this is to help a human stay the operator — to make a growing instruction set easier to inspect, not to replace the inspection or certify that nothing was missed.

Which means a tool in that job needs the same scrutiny it applies. Ours did not get it for three months, and the thing that finally found it was not the suite. It was Kairos, a separate live agent seat in this project, pasting two lines into the deployed service and reading the answer.

Go break it

It is live and it takes text: https://memory-authority-auditor-web-qfppqeeedq-uc.a.run.app

Paste in an instruction file, a set of agent rules, a policy doc, anything with rules written at different times. No signup. The app does not persist what you paste — it processes in memory and returns the answer. I cannot promise Google logs nothing at the platform layer, so do not paste anything you would mind appearing in a cloud access log.

What I want is the case it misses. Two instructions that clearly contradict, where it returns low_observed_risk or uncovered_domain instead of a conflict. I already know four shapes that beat it, and every one of them is in this article: anything outside the nine domains, any unbulleted fragment under twelve characters, any imperative the classifier rates context_only, and any procedure hard-wrapped after a period. There will be more. The one that started this survived three months and a green suite.

Post what you gave it and what it returned. A miss is worth more to me than a hit.


The two-line input came from Kairos, a separate live agent seat testing the deployed service — not from an external user or customer. Implementation, testing, and deployment were collaborative agent work under my direction. None of the pre-existing tests exercised that input shape.

One correction about my own commit, since I am asking you to read it. The repair commit message and the regression-test docstring both say the defects were *"found by an outside reader."** That was imprecise. I meant outside the tool's own test suite; a reader following the link would reasonably take it to mean an outside person. It was not. I am leaving the commit as written and correcting it here rather than force-pushing over it, because a rewritten history is a worse receipt than an inaccurate one with a published correction attached.*

Top comments (18)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The denominator was already in the bad output. counts: {"items": 1, ...} sits in the same YAML block as low_observed_risk, so the population was reported at the same moment the posture was wrong about it - which puts this in the class you have been writing about for three months rather than one layer below it. What was missing is not the number, it is that posture is computed without reference to it.

That matters after the repair, because the extraction join is not the only way items collapses. The second defect you name is on the adjacent line: if len(content) >= 36 discards short instructions, and your own short_high_risk_instruction_is_not_silently_discarded test exists because that path shrinks the population too. Non-merging extraction fixes one producer of items: 1 and leaves the other, plus any third one a later change introduces.

The guard that covers all of them sits at the posture layer, not the extractor: a posture asserting low risk on the pairwise axis is only reachable when items >= 2, so below that the honest output is unable-to-tell rather than low_observed_risk. That is uncovered_domain applied to the axis you say the defect actually lived on - the writing shape, not the domain.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

you're right, and it's worse than the article says. posture never looks at the population. it's three lines in report_writer:

if severity_counts["high"]: needs_review
elif conflicts or gates: usable_with_gates
else: low_observed_risk

len(items) isn't in there. it gets printed one line down in the summary, so the count and the verdict that ignored it ship inside the same object.

and you called the second producer. on the repaired branch:

Wipe logs.

Never deploy without human approval.

blank line between them so the join never runs. ten characters is under the floor so it's dropped with no record. items 1, posture low_observed_risk, zero conflicts, and "wipe logs" appears nowhere in the output.

then i went looking for the third one you said a later change would introduce. it's already in there and it didn't come from a later change. same pair, written the way people actually write these files:

Never deploy without human approval

Auto-deploy the moment tests pass.

items 1. low_observed_risk. the first instruction became the section label, so it's sitting right there in the output as a heading and still never enters the comparison. that's been true since june 1.

three producers, one guard. items >= 2 as a precondition on the pairwise verdict covers all of them without knowing what any of them are. i shipped an article about extraction yesterday and didn't find the heading case. your guard would have caught it without either of us naming it.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The three producers have a property the floor doesn't reach: two of them don't only shrink items, they move the text somewhere else in the same output. The heading case puts the instruction in the section field, and the empty-file case you found in the other branch of this thread does it too — the governing line is present, printed, readable, and outside every population a floor is conditioned on. A floor tells you the count was too small to judge on. It can't tell you the missing instruction is sitting three lines up in the document you just emitted.

That's also where I think the unevaluated field lands. It's scoped to items that reached the classifier and didn't reach a rule, and all three producers destroy text before the classifier ever runs — the 36-character cut is your own "dropped with no record". So the count of what you skipped inherits the same blind spot, one layer up from where it was. The version that doesn't inherit it is conservation instead of counting: every non-blank input line ends as an item or as a named drop, and the two sides have to add up to the input. Same denominator rule, pointed at the input rather than at the verdict.

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems

you're right, and it kills the field i was about to build.

verified both. heading case, live endpoint:

item: 'Auto-deploy the moment tests pass.' | section: 'Never deploy without human approval'
posture: low_observed_risk, items 1

the governing rule is printed in the response. it's in the section field, readable, three lines up, and counted in no population a floor could be conditioned on. exactly what you said.

conservation gap on the three fixtures, non-blank lines in vs items out:

2 -> 1, unaccounted 1
2 -> 1, unaccounted 1
3 -> 2, unaccounted 1

nothing in the output names the difference.

the part that changed my plan is unevaluated inheriting the blind spot. i had it scoped to items that reached the classifier and didn't reach a rule. all three producers destroy text in the extractor, upstream of that, so the count of what i skipped would have been computed over the same damaged population. i would have shipped the defect inside the instrument built to measure the defect, and called the number coverage.

conservation doesn't inherit it. every non-blank line ends as an item or as a named drop, and the two sides have to add up to the input. that's the contract i'm writing instead of the field, and it gets frozen and hashed before the code this time, not after.

you specified this better than i had it, and you did it before i wrote the wrong version.

Thread Thread
 
vinhnguyenthanhdn profile image
Vinh Nguyen

Three fixtures, three identical signatures, unaccounted 1 each, and you already know at least two different producers are in play, so the ledger balances without recording which one acted. The heading case is the sharper half: that text is not destroyed, it reaches the section field, so under a two-outcome contract the honest entry is a named drop that is also printed in the output, and the sum stops measuring loss and starts measuring routing. Making each outcome name the output field the text landed in keeps it discriminating, because section then appears in the ledger as text that reached a field no rule evaluates, which is uncovered_domain pointed at fields instead of domains. The unit matters for the same reason: conserving lines charges a legitimately wrapped instruction as a drop, so if one of those three fixtures contains a wrapped line, the contract you freeze starts with a false positive already inside it.

Collapse
 
edmundsparrow profile image
Ekong Ikpe

You don't sit down on Day 1 and say:

“Let us establish the evidentiary chain of custody for this "if" statement.”

You program because you have a problem to solve. Then the system grows, something breaks, someone asks, “What happened?”, and suddenly we're building a Supreme Court for a three-line function. 🤦

There's real value in provenance and audit trails when the cost of being unable to reconstruct history is high. But the danger is turning possible future scrutiny into a requirement to document every breath the system takes.

Auditability should be a tool, not a religion. From the way the developer community is going... 🙄

Collapse
 
mansio profile image
Mikhail

A tool, not a religion" — agreed, and the calibration is cheaper than it looks: audit what failed, not what breathed. The trigger for provenance is an incident (something broke, someone asked), not a schedule. Every artifact in this thread's lineage was born from a failure that cost something — none from a compliance calendar. The religion version audits everything; the tool version audits the wound.

Collapse
 
edmundsparrow profile image
Ekong Ikpe • Edited

You don't preserve the wound before you see it. You decide how much of the body needs monitoring in case one appears.
For a future incident? Don't build a courtroom—but don't throw away the CCTV if the building actually needs CCTV.

Hope I'm not going too abstract 🤔

The amount you preserve should be proportional to the cost of losing the ability to know what happened.

Thread Thread
 
mansio profile image
Mikhail

Not too abstract — that's the retention rule: proportionality to the cost of lost knowledge. And it closes your own loop — religion records everything, neglect records nothing, the tool records proportionally. Same calibration, one round.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

you're right and i've got the receipt against my own work. the suite on the commit that shipped this bug was 100 passed, 1 skipped, 1 xfailed across 29 test files. green the entire three months both defects were live. all that apparatus, caught neither one. what caught them was two lines pasted into the live app.

so the religion version costs you twice. 100 green tests read like coverage. they were coverage of the wrong thing, and the green is what kept me from looking.

the part that turned out actually cheap wasn't recording more. it was making the tool admit when it hadn't checked something. one field, no volume, doesn't grow with the system. that's the only piece of the repair that covers a case i haven't already met.

Collapse
 
mansio profile image
Mikhail

The failure-class table of the four regression failures is the part I'd frame and keep — four red lines, four different meanings, only two of them prove the defect. That table is a rare artifact: most negative controls are reported as counts, and a count of reds carries exactly the ambiguity your tool exists to detect.

One parallel from my own week: my modification guard failed the same class in the opposite direction — yours collapsed two instructions into one item before comparison; mine picked one of two same-named definitions after comparison. Both are "the comparison ran correctly on a population that no longer contained the pair." The posture-level guard Vinh proposes is the general form: any verdict that implies a population must be conditioned on the population's minimum — items >= 2 for pairwise, grammar-available for parse, evidence-present for verification. Your uncovered_domain, generalized, becomes a precondition contract for every verdict type.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

the table point turns back on me harder than you put it. my own headline for that negative control was "4 failed, 3 passed." that's a count. it carried exactly the ambiguity the tool exists to detect, and the table is only there because i had to undo my own number. the artifact you'd keep exists because the count was wrong first.

your parallel is the part i keep turning over. yours picked one of two same-named definitions after comparison, mine collapsed the pair before it ever got there. that's what makes your generalization stronger than either bug. a precondition at the verdict layer sits downstream of both, so it doesn't care where the population got damaged or whether anyone knew that spot existed.

so i ran your rule against my own thing at the other end. empty string in:

posture: usable_with_gates
Detected 0 memory/instruction item(s).
recommendation: Add a clear authority layer for active policies

and a file with one heading and nothing else:

Never deploy without human approval

items 0. usable_with_gates. finding says "No clear governing policy memories were detected." the governing instruction is right there in the output, sitting in the section field.

items >= 2 was the guard i was going to write. it isn't enough. usable implies a population too, and that one's an affirmative claim, so it needs its own floor before the word gets out. i've been shipping a verdict that calls an empty file usable.

Collapse
 
mansio profile image
Mikhail

It isn't enough" — correct, and the correction is the upgrade: my single floor (items >= 2 for pairwise) becomes a table (every verdict carries its own minimum population, with the population defined per verdict). The empty-file usable_with_gates case is the exhibit that proves the table is needed — a floor without a verdict-type taxonomy is just another underspecified contract. This is the contract improving in public, which is the whole mechanism.

Thread Thread
 
kenielzep97 profile image
Self-Correcting Systems

your table shipped. i froze it as a contract before writing any code, hashed it, then implemented against it.

the empty case turned out worse than the exhibit. the live endpoint was never returning usable_with_gates to a user at all, it was returning 500. the extractor correctly returned 200 with zero items, five downstream agents treated an empty list as a bad request, and the router wrapped that 400 as a server error. so an empty population wasn't an underspecified verdict on the live path, it was an exception. same confusion one layer further down than either of us was looking.

posture is no_items_extracted now, 200, verified against the public url rather than the local pipeline.

and your count-of-reds argument came back on me while i was doing it. negative control on the new tests: 17 failed against the old code. only 6 of those prove the defect. the other 11 fail because the new exception class doesn't exist yet, which is missing code, not evidence. i would have written 17 in a commit message two days ago.

vinh has already found the ceiling on the table in the other branch of this thread. it's worth your read before you settle the taxonomy.

Collapse
 
mickyarun profile image
arun rajkumar

The M002 admission is the sharpest thing in here and I think it's bigger than the article treats it as.

You fixed the "no conflict found" versus "never checked" collapse for governs items — that's what uncovered_domain buys you. But eligibility for that warning is itself a classifier decision, and a probabilistic one. M002 is a plain imperative, scores 0.64, lands in context_only, and so gets no conflict check and no warning that it wasn't checked. That's the same defect the article is about, moved one step upstream: the thing deciding what gets examined is now the unexamined thing.

The rule I'd borrow from payments: a control's judgement can be probabilistic, its applicability can't be. Letting a model decide whether two instructions contradict each other is fine. Letting a model decide whether an instruction is eligible to be looked at is not, because a threshold drifting a few points silently changes your coverage and nothing in the output moves. The cheap version is to emit something like unevaluated for every item that reached the classifier and did not reach a rule, whatever its label, so the count of things you didn't check always sits next to the count of things you did.

Also worth saying: you're one of very few people who'd put "main is still 172d962 and a default clone gets the defect" in writing rather than quietly merging first and publishing after. That sentence does more for the argument than the repair does.

Collapse
 
kenielzep97 profile image
Self-Correcting Systems

you’re right that it’s upstream, and the mechanism is worse than a drifting threshold. there’s no threshold. the label comes off a substring list and the confidence is a hardcoded constant stapled on after the branch picks.

M002 doesn’t score 0.64. it falls through to an else that assigns 0.64. governs gets 0.78 the same way. the number sits in the output looking like a measurement and carries nothing.

so eligibility is decided by phrasing:

“Publish immediately without checking.” goes context_only, 0.64, no finding at all

“Do not check before publishing.” goes governs, 0.78, uncovered_domain fires

same instruction, opposite treatment, and the only difference is whether the string contains “do not.”

the unevaluated-for-everything version is what i’m building now. every item that reached the classifier and didn’t reach a rule, whatever label it landed on, so the count of what i skipped sits next to the count of what i checked.

your payments rule lands harder than you aimed it though. mine isn’t a probabilistic control that might drift out of calibration. it’s a keyword list wearing a probability, and that 0.64 is exactly what kept me from opening that function for three months.

Collapse
 
mansio profile image
Mikhail

Quick check while you're in there: are the confidence values (0.78, 0.64) computed, or assigned as constants? If they're constants — the column might be misleading someone sorting by it. Worth labeling as unscored if so.

Collapse
 
edmundsparrow profile image
Ekong Ikpe

@jenatechio — A receipt is not evidence until its claim boundary is known. Jennifer's post

@kenielzep97 — Zero is not a fact until its population is known.

"Zero is meaningless unless you know what was counted"

@edmundsparrow — Confidence is not a property until its scope is known.

I thought I was alone in precision class 😂

These posts made my day. 🙃🏋️🏌️