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
build_pipeline_output runs _dedup_caller_callee over the confirmed findings and builds
findings from the deduped list, but results.vulnerable is computed from the metrics block and
never re-derived. The same file therefore reports results.vulnerable: 183 alongside 175 entries in
findings, and 175 disclosure documents on disk. The dedup itself is logged, not silent.
The same results dict has a second, independent defect: it folds protected into safe and so
carries no protected key at all.
Evidence
libs/openant-core/core/reporter.py:331 (HEAD b501962), inside build_pipeline_output:
confirmed = _dedup_caller_callee(confirmed, all_results, call_graph_path)
core/reporter.py:129-198 is the helper; its last statement announces what it removed
(core/reporter.py:194-198):
removed = len(confirmed) - len(deduped)
print(f"[Report] Deduplicated {removed} caller/callee finding(s)", file=sys.stderr)
return deduped
On this run it fired and logged:
grep -n "Deduplicated" run/scan.log
# 12098:[Report] Deduplicated 8 caller/callee finding(s)
findings_data is then built from the deduped confirmed list (core/reporter.py:333-334), but
results at core/reporter.py:562-570 is built from metrics, which was read once at
core/reporter.py:304 and is never touched by the dedup:
"results": {
"vulnerable": metrics.get("vulnerable", 0) + metrics.get("bypassable", 0),
"safe": metrics.get("safe", 0) + metrics.get("protected", 0),
"inconclusive": metrics.get("inconclusive", 0),
# F13: errored units are part of `total` (see units_analyzed above), so the
# results buckets must include them or they cannot reconcile to `total`.
"errors": metrics.get("errors", 0),
"total": total_units,
},
The file contradicts itself
RUN=~/.openant/projects/gadievron/raptor-e2e-20260818/scans/7dbf9c691d7/python
python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
print('results :', d['results'])
print('len(findings) :', len(d['findings']))" $RUN/pipeline_output.json
ls $RUN/report/disclosures | wc -l
results : {'vulnerable': 183, 'safe': 5292, 'inconclusive': 0, 'errors': 0, 'total': 5475}
len(findings) : 175
175
183 - 175 = 8, exactly the count in the log line. 175 disclosure documents were produced, matching
findings, not results.vulnerable.
Second defect in the same dict: protected is folded into safe
core/reporter.py:564 adds metrics["protected"] into safe and emits no protected key.
report/prompts/summary.txt:20-26 asks for a five-row verdict table:
| Verdict | Count |
|---------|-------|
| Vulnerable | {n} |
| Safe | {n} |
| Protected | {n} |
| Inconclusive | {n} |
| Error | {n} |
The report producer is given pipeline_output.json as its input data, and that input has no
protected key and errors: 0. The produced report/SUMMARY_REPORT.md, lines 13-19:
13 | Verdict | Count |
14 |---------|-------|
15 | Vulnerable | 183 |
16 | Safe | 5292 |
17 | Protected | 0 |
18 | Inconclusive | 0 |
19 | Error | 0 |
Both non-zero rows are absent from the report:
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['metrics'])" $RUN/results_verified.json
# {'total': 5475, 'vulnerable': 183, ..., 'protected': 414, 'safe': 4878, 'errors': 0}
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['summary']['metrics'])" $RUN/scan.report.json
# {'total': 5475, 'vulnerable': 1, ..., 'protected': 408, 'safe': 4884, 'errors': 48, ...}
python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['summary'])" $RUN/verify.report.json
# {..., 'needs_review': 134, 'error_count': 48}
Protected | 0 is printed where the metric blocks report 414 (results_verified.json) / 408
(scan.report.json); Error | 0 is printed where scan.report.json and verify.report.json both
report 48. The protected count cannot be recovered from results at all, because the fold at
:564 is lossy.
(The 183 and errors: 0 figures inherited from metrics are themselves wrong for a separate
reason — the post-verify metrics recount in core/verifier.py:359-373 — filed as #284. This
issue is about results not being re-derived after the dedup, and about the lossy protected fold.)
Why it matters
pipeline_output.json is the reporting layer's single input, and it is self-inconsistent: the
headline count and the enumerable findings in the same object disagree by 8. A consumer that reports
results.vulnerable and a consumer that lists findings produce different answers from the same
file, and there is no field that explains the difference. Within this repo the disclosure count (175)
and the summary headline (183) diverge for exactly this reason.
The protected fold is a smaller but plainer loss: a verdict the pipeline computes and the report
template asks for is destroyed one line before it would have been written.
Suggested fix
- Re-derive
results.vulnerable from the deduped list rather than from metrics — e.g.
"vulnerable": len(findings_data) — or, if the pre-dedup count is wanted, keep both under
distinct names (vulnerable, vulnerable_before_dedup) plus a deduplicated count so the
difference is explicit rather than contradictory.
- Have
_dedup_caller_callee return (deduped, removed_keys) so the caller can adjust counts and
record what was collapsed, instead of the count only reaching stderr.
- Emit
protected as its own key in results rather than folding it into safe; the summary
template already has a row for it.
- Add an invariant test on
build_pipeline_output: results["vulnerable"] == len(output["findings"])
on a fixture where the dedup fires.
Corrected 2026-08-21 — items 1, 3 and 4 above conflict with merged PR #230. Do not apply them
as written.
results is a partition of units, not a list of findings. tests/report/test_results_bucket_reconciliation.py
states the invariant in its own docstring — "the pipeline_output results block must reconcile to
its own total", where total counts all units — and asserts
vulnerable + safe + inconclusive + errors == total.
- Item 1 (
"vulnerable": len(findings_data)) sets a unit bucket to a finding count. Those
are different populations, so it breaks the partition and the tests that pin it.
- Item 3 ("rather than folding") breaks
test_folds_bypassable_and_protected_then_still_reconciles,
which pins the fold with assert r["vulnerable"] == 2 and r["safe"] == 3 # folded. An additive
change — leave safe as-is and emit a new protected key alongside — is compatible; replacing the
fold is not.
- Item 4's invariant
results["vulnerable"] == len(output["findings"]) is unsatisfiable for the
same reason as item 1.
What survives: the self-contradiction this issue reports is real — results.vulnerable (183)
and len(findings) (175) are printed side by side and read as the same quantity. But the fix is to
make the distinction explicit (name the two populations differently, or emit a deduplicated
count), not to force one number onto the other. Item 2 stands unchanged. Item 5 below is
independent of all of this.
- Add a cycle guard to
_dedup_caller_callee (promoted from the note below on 2026-08-21 — it
was filed as "Latent", which was wrong): skip a removal where the caller is itself in
remove_keys, or where the callee is a caller of its caller. Executing the real function on a
mutually-recursive pair sharing a non-zero CWE returns [] — both findings are deleted. The
removal is logged (core/reporter.py:197 prints [Report] Deduplicated N caller/callee finding(s)), so this is not silent — it is mislabelled: a double-deletion is reported as
ordinary deduplication, and the count alone gives a reader no way to tell the two apart. The
outcome is still a false negative. Add a fixture for that pair.
Reproducible on demand, same helper
Re-scoped 2026-08-21. Filed under "Latent" — the wrong word. The failure is reproducible on
demand; only its occurrence in a given repository is data-dependent. Now fix item 5 above;
this section is the supporting detail.
_dedup_caller_callee (core/reporter.py:129-198) has no cycle guard. It builds remove_keys from
reverse_call_graph entries with exactly one caller, and two mutually-recursive findings sharing the
same non-zero CWE each name the other as their sole caller — so both land in remove_keys and both
are deleted.
Executing the real function on such a pair returns [] — both findings are deleted. As above
this is logged rather than silent, but the log is a bare count that does not distinguish a
double-deletion from ordinary deduplication. For a security scanner the outcome is a false negative.
What was data-dependent is only whether such a pair occurs: none did on this run, and the 8 removals
reconcile exactly to scan.log:12098. That makes it unobserved here, not latent in the code.
Fix: skip a removal where the caller is itself in remove_keys, or where the callee is a caller
of its caller — and add a fixture for the mutually-recursive same-CWE pair.
What I am not claiming
- The 8 dropped findings are not dropped silently.
core/reporter.py:197 logs them and the log
line is present at scan.log:12098. The defect is that results.vulnerable is not adjusted, so
the emitted JSON self-contradicts.
- I am not claiming the 8 removals were wrong. I did not audit whether each collapsed pair was a
genuine caller/callee duplicate; the dedup may be doing exactly what it intends.
SUMMARY_REPORT.md is produced by an LLM from report/prompts/summary.txt. I am attributing the
Protected | 0 and Error | 0 rows to the shape of the input data (results has no protected
key and carries errors: 0), not asserting a deterministic code path from :564 to those exact
characters.
- The blast-radius statement is scoped to consumers within this repo.
Summary
build_pipeline_outputruns_dedup_caller_calleeover the confirmed findings and buildsfindingsfrom the deduped list, butresults.vulnerableis computed from themetricsblock andnever re-derived. The same file therefore reports
results.vulnerable: 183alongside 175 entries infindings, and 175 disclosure documents on disk. The dedup itself is logged, not silent.The same
resultsdict has a second, independent defect: it foldsprotectedintosafeand socarries no
protectedkey at all.Evidence
libs/openant-core/core/reporter.py:331(HEADb501962), insidebuild_pipeline_output:core/reporter.py:129-198is the helper; its last statement announces what it removed(
core/reporter.py:194-198):On this run it fired and logged:
findings_datais then built from the dedupedconfirmedlist (core/reporter.py:333-334), butresultsatcore/reporter.py:562-570is built frommetrics, which was read once atcore/reporter.py:304and is never touched by the dedup:The file contradicts itself
183 - 175 = 8, exactly the count in the log line. 175 disclosure documents were produced, matchingfindings, notresults.vulnerable.Second defect in the same dict:
protectedis folded intosafecore/reporter.py:564addsmetrics["protected"]intosafeand emits noprotectedkey.report/prompts/summary.txt:20-26asks for a five-row verdict table:The report producer is given
pipeline_output.jsonas its input data, and that input has noprotectedkey anderrors: 0. The producedreport/SUMMARY_REPORT.md, lines 13-19:Both non-zero rows are absent from the report:
Protected | 0is printed where the metric blocks report 414 (results_verified.json) / 408(
scan.report.json);Error | 0is printed wherescan.report.jsonandverify.report.jsonbothreport 48. The
protectedcount cannot be recovered fromresultsat all, because the fold at:564is lossy.(The
183anderrors: 0figures inherited frommetricsare themselves wrong for a separatereason — the post-verify metrics recount in
core/verifier.py:359-373— filed as #284. Thisissue is about
resultsnot being re-derived after the dedup, and about the lossyprotectedfold.)Why it matters
pipeline_output.jsonis the reporting layer's single input, and it is self-inconsistent: theheadline count and the enumerable findings in the same object disagree by 8. A consumer that reports
results.vulnerableand a consumer that listsfindingsproduce different answers from the samefile, and there is no field that explains the difference. Within this repo the disclosure count (175)
and the summary headline (183) diverge for exactly this reason.
The
protectedfold is a smaller but plainer loss: a verdict the pipeline computes and the reporttemplate asks for is destroyed one line before it would have been written.
Suggested fix
results.vulnerablefrom the deduped list rather than frommetrics— e.g."vulnerable": len(findings_data)— or, if the pre-dedup count is wanted, keep both underdistinct names (
vulnerable,vulnerable_before_dedup) plus adeduplicatedcount so thedifference is explicit rather than contradictory.
_dedup_caller_calleereturn(deduped, removed_keys)so the caller can adjust counts andrecord what was collapsed, instead of the count only reaching stderr.
protectedas its own key inresultsrather than folding it intosafe; the summarytemplate already has a row for it.
build_pipeline_output:results["vulnerable"] == len(output["findings"])on a fixture where the dedup fires.
_dedup_caller_callee(promoted from the note below on 2026-08-21 — itwas filed as "Latent", which was wrong): skip a removal where the caller is itself in
remove_keys, or where the callee is a caller of its caller. Executing the real function on amutually-recursive pair sharing a non-zero CWE returns
[]— both findings are deleted. Theremoval is logged (
core/reporter.py:197prints[Report] Deduplicated N caller/callee finding(s)), so this is not silent — it is mislabelled: a double-deletion is reported asordinary deduplication, and the count alone gives a reader no way to tell the two apart. The
outcome is still a false negative. Add a fixture for that pair.
Reproducible on demand, same helper
_dedup_caller_callee(core/reporter.py:129-198) has no cycle guard. It buildsremove_keysfromreverse_call_graphentries with exactly one caller, and two mutually-recursive findings sharing thesame non-zero CWE each name the other as their sole caller — so both land in
remove_keysand bothare deleted.
Executing the real function on such a pair returns
[]— both findings are deleted. As abovethis is logged rather than silent, but the log is a bare count that does not distinguish a
double-deletion from ordinary deduplication. For a security scanner the outcome is a false negative.
What was data-dependent is only whether such a pair occurs: none did on this run, and the 8 removals
reconcile exactly to
scan.log:12098. That makes it unobserved here, not latent in the code.Fix: skip a removal where the caller is itself in
remove_keys, or where the callee is a callerof its caller — and add a fixture for the mutually-recursive same-CWE pair.
What I am not claiming
core/reporter.py:197logs them and the logline is present at
scan.log:12098. The defect is thatresults.vulnerableis not adjusted, sothe emitted JSON self-contradicts.
genuine caller/callee duplicate; the dedup may be doing exactly what it intends.
SUMMARY_REPORT.mdis produced by an LLM fromreport/prompts/summary.txt. I am attributing theProtected | 0andError | 0rows to the shape of the input data (resultshas noprotectedkey and carries
errors: 0), not asserting a deterministic code path from:564to those exactcharacters.