Evidence provenance (added 2026-08-22). The run-derived figures in this issue come from a
private scan that a reader cannot reproduce (raptor-e2e-20260818, a 9.85-hour run against
a Python target). They are reported for completeness, not as independently checkable evidence.
Every code claim below is pinned to public source at b5019628 and is checkable; where a
conclusion rests on the private numbers rather than on the code, treat it as my report of what
that run showed. This label was missing when the issue was filed.
Summary
_write_verified_results re-derives the metrics block after Stage 2 by classifying each merged
result on its verdict string. Its only route into the errors bucket is elif r.get("verdict") == "ERROR" —
a value the verify error path never writes. An errored verification keeps its Stage-1
finding: "vulnerable" / verdict: "VULNERABLE", so it is counted as a confirmed vulnerability,
and errors stays 0. The correct buckets are already computed a few lines earlier by
_count_verification_outcomes, and are discarded rather than written to the file.
This is the root of the headline metric divergence. core/reporter.py:304 merely reads
experiment["metrics"] — it consumes what is already wrong. core/reporter.py:562-570 is a
second, different defect (filed separately) and fixing only the reporter would copy a wrong
number more elaborately.
Evidence
libs/openant-core/core/verifier.py:359-373 (HEAD b501962) — the recount:
# Recount metrics after verification
counts = {
"vulnerable": 0, "bypassable": 0, "inconclusive": 0,
"protected": 0, "safe": 0, "errors": 0,
}
for r in merged_results:
# Canonical read: lowercase a PRESENT finding too (not only the
# verdict/default), so a verdict-only result is classified correctly.
finding = str(r.get("finding") or r.get("verdict") or "error").lower()
if finding in counts:
counts[finding] += 1
elif r.get("verdict") == "ERROR":
counts["errors"] += 1
output["metrics"] = {"total": len(merged_results), **counts}
core/verifier.py:274-330 (_count_verification_outcomes) already buckets the same list correctly —
error_count on r.get("error"), needs_review on verification.incomplete — and its result goes
to verify.report.json but never into results_verified.json.
core/reporter.py:304:
metrics = experiment.get("metrics", {})
The two blocks disagree on the same run
RUN=~/.openant/projects/gadievron/raptor-e2e-20260818/scans/7dbf9c691d7/python
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['metrics'])" $RUN/results_verified.json
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['summary']['metrics'])" $RUN/scan.report.json
results_verified.json {total 5475, vulnerable 183, bypassable 0, inconclusive 0,
protected 414, safe 4878, errors 0}
scan.report.json {total 5475, vulnerable 1, bypassable 0, inconclusive 0,
protected 408, safe 4884, errors 48,
verified 223, stage2_agreed 1, stage2_disagreed 40, needs_review 134}
Why errors is 0
Every errored verification carries a truthy error string and its Stage-1 verdict:
python3 - <<'EOF'
import json, collections
d = json.load(open("$RUN/results_verified.json"))
res = d["results"]
err = [r for r in res if r.get("error")]
print("records with .error :", len(err))
print("their verdict values:", collections.Counter(str(r.get("verdict")) for r in err))
print("their finding values:", collections.Counter(str(r.get("finding")) for r in err))
print("records with verdict == 'ERROR':", sum(1 for r in res if r.get("verdict") == "ERROR"))
print("records with verification.incomplete:",
sum(1 for r in res if (r.get("verification") or {}).get("incomplete")))
EOF
records with .error : 48
their verdict values: Counter({'VULNERABLE': 48})
their finding values: Counter({'vulnerable': 48})
records with verdict == 'ERROR': 0
records with verification.incomplete: 182
So the elif at :370 is unreachable on this data, and all 48 errored units land in vulnerable.
The 183 reconciles exactly: 183 = 134 (incomplete, adjudication never completed) + 48 (errored) + 1 (Stage 2 agreed and confirmed).
The writer that produces those records is utilities/finding_verifier.py:755-758 (the comment continues to :762; :759-762 not shown), whose own comment
states the intended contract:
# L4 (PR #69 round-5): record the error ON the result dict, not just
# in the local ``detail``. The downstream counter (core/verifier.py)
# buckets on ``r.get("error")``; without this the errored finding
# falls through to "disagreed" and is folded into the ``safe`` count.
_count_verification_outcomes honours that contract. The recount at :359-373 does not.
Why it matters
results_verified.json is the file the reporting chain reads. Within this repo, anything downstream
of it inherits the wrong numbers: core/reporter.py:304 feeds pipeline_output.json, which feeds
report/SUMMARY_REPORT.md. On this run that surfaced as "183 vulnerable, 0 errors" for a scan in
which 182 of 223 Stage-2 candidates were never adjudicated and 48 hard-errored.
The direction is the dangerous one for a security scanner in both halves at once: an unverified
candidate is presented as a confirmed vulnerability (over-claim), while the fact that verification
failed at all is erased (errors: 0, no needs_review key). A consumer reading only this file
cannot distinguish "Stage 2 confirmed 183" from "Stage 2 confirmed 1 and could not finish 182".
Suggested fix
Write the buckets that are already computed instead of re-deriving them from verdict strings:
- In
_write_verified_results, classify on the same signals _count_verification_outcomes uses —
r.get("error") first, then verification.get("incomplete") — before falling through to the
verdict string. An errored or incomplete unit must not be counted as vulnerable.
- Add
needs_review (and, if useful, stage2_agreed/stage2_disagreed) to the metrics block so
results_verified.json and scan.report.json carry the same schema and can be reconciled.
- Keep
total = the sum of the buckets, and add a test asserting that invariant on a fixture
containing one errored and one incomplete result.
- Consider replacing the recount entirely — have
_write_verified_results take the counts from
_count_verification_outcomes so there is one counter and one definition. If instead the recount
is kept, its elif r.get("verdict") == "ERROR" branch must be retained and a verify-error
branch added beside it (see the correction below).
Corrected 2026-08-21. The sentence that stood here called the
elif r.get("verdict") == "ERROR" branch "dead — a branch no production path can reach."
That is false and the branch must NOT be removed. verdict = "ERROR" has production writers
(core/analysis_core.py:47, :180; core/analyzer.py:185). An analyze-errored record evaluates
to the string "error" by either of two routes — core/analyzer.py:185 writes
"finding": "error" explicitly, and core/analysis_core.py:180 returns a dict with
verdict = "ERROR" and no finding key at all, so the or falls through to verdict. Either way
the value is not a key in counts, and the elif fires correctly. The branch is dead only for verify errors, which never set
verdict = "ERROR" — they write verification = {"incomplete": True} and put the message on
result["error"]. It counted 0 in this run because analyze had 0 errors.
The correct change is to ADD a verify-error branch alongside it, not to delete it.
One further scope note, so nobody applies a change this issue does not ask for: leave
confirmed_findings (core/verifier.py:351-356) alone. Filtering incomplete verifications out of
it would strip 182 of the 183 records in confirmed_findings, which is the source of
pipeline_output.findings (175 after caller/callee dedup) and of the disclosure documents —
reporter.py maps incomplete to "unverified", which core/verdict_taxonomy.py deliberately
places in DISCLOSURE_ELIGIBLE (PR #69 F4). Keeping unadjudicated findings disclosure-eligible is
the over-seed-safe behaviour. This issue is a counting defect only; fixing it must not change any
finding's disclosure eligibility.
What I am not claiming
- I am not claiming
core/reporter.py:562-570 is fine. It is a separate defect (it folds
protected into safe and never re-derives vulnerable after dedup) and is filed separately.
I am claiming that fixing it alone does not fix this.
- I have not verified whether other language targets or other providers produce the same
verdict/finding shapes on error; the evidence above is one Python run on a direct-Anthropic
binding.
- The blast-radius statement above is scoped to consumers within this repo. I have not surveyed
external consumers of results_verified.json.
Summary
_write_verified_resultsre-derives themetricsblock after Stage 2 by classifying each mergedresult on its verdict string. Its only route into the
errorsbucket iselif r.get("verdict") == "ERROR"—a value the verify error path never writes. An errored verification keeps its Stage-1
finding: "vulnerable"/verdict: "VULNERABLE", so it is counted as a confirmed vulnerability,and
errorsstays 0. The correct buckets are already computed a few lines earlier by_count_verification_outcomes, and are discarded rather than written to the file.This is the root of the headline metric divergence.
core/reporter.py:304merely readsexperiment["metrics"]— it consumes what is already wrong.core/reporter.py:562-570is asecond, different defect (filed separately) and fixing only the reporter would copy a wrong
number more elaborately.
Evidence
libs/openant-core/core/verifier.py:359-373(HEADb501962) — the recount:core/verifier.py:274-330(_count_verification_outcomes) already buckets the same list correctly —error_countonr.get("error"),needs_reviewonverification.incomplete— and its result goesto
verify.report.jsonbut never intoresults_verified.json.core/reporter.py:304:The two blocks disagree on the same run
Why
errorsis 0Every errored verification carries a truthy
errorstring and its Stage-1 verdict:So the
elifat:370is unreachable on this data, and all 48 errored units land invulnerable.The 183 reconciles exactly: 183 = 134 (incomplete, adjudication never completed) + 48 (errored) + 1 (Stage 2 agreed and confirmed).
The writer that produces those records is
utilities/finding_verifier.py:755-758(the comment continues to:762;:759-762not shown), whose own commentstates the intended contract:
_count_verification_outcomeshonours that contract. The recount at:359-373does not.Why it matters
results_verified.jsonis the file the reporting chain reads. Within this repo, anything downstreamof it inherits the wrong numbers:
core/reporter.py:304feedspipeline_output.json, which feedsreport/SUMMARY_REPORT.md. On this run that surfaced as "183 vulnerable, 0 errors" for a scan inwhich 182 of 223 Stage-2 candidates were never adjudicated and 48 hard-errored.
The direction is the dangerous one for a security scanner in both halves at once: an unverified
candidate is presented as a confirmed vulnerability (over-claim), while the fact that verification
failed at all is erased (
errors: 0, noneeds_reviewkey). A consumer reading only this filecannot distinguish "Stage 2 confirmed 183" from "Stage 2 confirmed 1 and could not finish 182".
Suggested fix
Write the buckets that are already computed instead of re-deriving them from verdict strings:
_write_verified_results, classify on the same signals_count_verification_outcomesuses —r.get("error")first, thenverification.get("incomplete")— before falling through to theverdict string. An errored or incomplete unit must not be counted as
vulnerable.needs_review(and, if useful,stage2_agreed/stage2_disagreed) to themetricsblock soresults_verified.jsonandscan.report.jsoncarry the same schema and can be reconciled.total= the sum of the buckets, and add a test asserting that invariant on a fixturecontaining one errored and one incomplete result.
_write_verified_resultstake the counts from_count_verification_outcomesso there is one counter and one definition. If instead the recountis kept, its
elif r.get("verdict") == "ERROR"branch must be retained and a verify-errorbranch added beside it (see the correction below).
What I am not claiming
core/reporter.py:562-570is fine. It is a separate defect (it foldsprotectedintosafeand never re-derivesvulnerableafter dedup) and is filed separately.I am claiming that fixing it alone does not fix this.
verdict/findingshapes on error; the evidence above is one Python run on a direct-Anthropicbinding.
external consumers of
results_verified.json.