Skip to content

fix(review): remember a rejected finding for the head it was rejected on - #874

Merged
devops-thiago merged 3 commits into
mainfrom
fix/711-verifier-determinism
Sep 16, 2026
Merged

devops-thiago merged 3 commits into
mainfrom
fix/711-verifier-determinism

Conversation

@devops-thiago

Copy link
Copy Markdown
Owner

What type of PR is this?

  • 🐛 Bug fix
  • ✨ Feature
  • 📝 Documentation
  • 🔧 Refactor
  • 🚀 Performance
  • ✅ Test
  • 🔒 Security
  • 📦 Dependency update
  • 🏗️ CI/CD

Description

The second-pass audit reached opposite verdicts on one claim and one unchanged commit inside a
single dogfood round. On ThrillhouseBot-test#70 at 2659f683 it rejected a SQL-injection
candidate with correct reasoning — the route value is gated by a catalog allow-list before it
reaches the concatenated query — and forty minutes later, on the same commit, published that same
claim as 🔴 CRITICAL on a public route.

What #650 and #871 already fixed, and what is left

Both landed before this change, and both remove reasons the verifier was wrong. Neither touches
the fact that it answers the same question twice.

What is left is instance 1, and it is not a missing-material problem. The round that got the
SQL-injection claim right and the round that got it wrong were given the same code. Making the
inputs identical is not available as a fix either, for reasons the code settles rather than
guesses at:

  • FindingPipeline.refine/refineBatchOutcome pass promptInputs.previousFindings() to the
    verifier, and that section grows with every posted round.
  • The same call passes PrContextBudget.bound(prContext, budgetPlanner.perCallInputBudget(), …),
    so what is truncated moves with the diff and the model's window.
  • ReviewContextLoader reads the pull request conversation, which changes as people comment.

So two rounds on one head never see the same prompt, whatever the sampling settings are. This PR
makes the verdict stable by remembering it instead.

The fix

VerifierRejectionMemory holds, per pull request and head commit, the candidates the audit threw
out. A later round on that head drops those claims in FindingPipeline before the verification
call, so they are neither re-litigated nor paid for.

  • Only rejections are remembered, never confirmations. Re-auditing a confirmed finding costs a
    call; pinning one would repeat a false positive for the life of the head. The asymmetry is the
    safe direction — this store can only ever publish fewer findings than before.
  • A push clears the entry. A rejection is an answer about code, and the code changed. The
    second test below is the control for exactly that.
  • A reworded re-raise is still the same claim. Recall goes through
    FollowUpAnalyzer.isSameFinding, the rule the follow-up passes already use to recognize a
    re-raise, rather than a second recognizer that could disagree with them. That method was the only
    change to an existing class other than the pipeline wiring: private → package-private, with a
    javadoc saying why.
  • Caps and lifetime match the neighbouring stores: 50 rejections per pull request (oldest
    forgotten first), 256 pull requests (least recently written evicted), in memory per replica, so a
    restart falls back to today's re-litigation — the same bound CiHoldRegistry and
    SupersededFindingsCarryover accept.

The fail-open path

Yes, a fail-open path can publish a finding a successful verification would have dropped. The code
is explicit about it: in FindingVerificationService.audit, an empty body, any IOException/
RuntimeException, and the spend-ceiling refusal all return screened — the candidate posts
exactly as the reviewer raised it. That is the path behind the 16:00:29 UTC log line on our own
#871:

Finding verification returned no response body — keeping the 1 unverified finding(s)

The right disclosure already exists and is not changed here: #623's VerificationCoverage records
(candidates, 0) on every one of those paths and VerdictBuilder renders the clause — its own
javadoc records that production measured roughly one review in three publishing findings no second
stage had screened. What was missing is that the disclosure does not drop anything, so a claim the
audit had already rejected still posted. Applying the memory before the call fixes that: the drop
holds on a round that never gets a verdict at all. aRejectedFindingIsNotRePublishedByALaterRoundOnTheSameHead
stubs round two as the pass-through that every fail-open path produces, so it covers both the
"confirmed on the re-roll" and the "no verdict at all" shapes.

Sampling — investigated, deliberately unchanged

Verified in code rather than assumed:

  • FindingVerifier is bound to the concise named model, whose generation parameters are applied
    by ChatModelCustomizers.ConciseChatModelCustomizer through BLOCKING_TUNING.
  • That customizer applies temperature, topP, frequencyPenalty, presencePenalty and seed
    from ActiveModelSettings, each only ifPresent. All five are unset in
    application.properties (the thrillhousebot.ai.models.*.temperature / .seed lines are
    commented-out examples), so the verifier call today runs at the provider's defaults with no seed.

So a deterministic setting is already available on the configured OpenAI-compatible provider
without any code change — thrillhousebot.ai.models.<model>.temperature=0 and .seed=<n> reach the
verifier's lane. This PR does not flip that default, for two reasons. The effect cannot be proven
from a deterministic test — a test can show the builder received temperature(0.0), which is wiring,
not behaviour — and the concise bean is shared with the summary, changelog and reply calls, where
pinning temperature is a prose-quality change nobody has measured. More to the point, greedy
decoding would not have fixed this issue: the two rounds did not send the same prompt, for the three
reasons listed above, so identical sampling would still have permitted different verdicts.

Ordering, batch composition, truncation

Checked and found deterministic given the finding list, so nothing is changed there: candidate ids
are 1-based list positions in renderCandidates, apply reads them back by the same positions, and
byCandidateId collapses duplicate ids first-wins for both apply and candidatesCovered (#735).
Batch composition comes from DiffBudgetPlanner, which is a pure function of the file list and the
budget. The list order itself is model output and varies between rounds, which is one more reason
the stability has to come from memory rather than from input canonicalization.

Related Issues

Fixes #711

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • Manual testing

Red first. FindingPipelineTest#aRejectedFindingIsNotRePublishedByALaterRoundOnTheSameHead
runs two rounds against one owner/repo#70 at 2659f683; round one's verifier rejects the claim,
round two's returns its input unchanged (the shape both a confirmed verdict and every fail-open
path take). Against the unfixed code:

[ERROR] dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.aRejectedFindingIsNotRePublishedByALaterRoundOnTheSameHead -- Time elapsed: 0.148 s <<< FAILURE!
org.opentest4j.AssertionFailedError: a claim rejected on this head must not be published by a later round on the same head ==> expected: <[]> but was: <[Concatenated SQL]>
	at dev.thiagogonzaga.thrillhousebot.review.FindingPipelineTest.aRejectedFindingIsNotRePublishedByALaterRoundOnTheSameHead(FindingPipelineTest.java:3154)

Its control, aRejectionOnAnOlderHeadDoesNotSuppressTheFindingAfterAPush, passes before and after:
it pins the boundary the memory must never cross.

VerifierRejectionMemoryTest covers the store itself — recall on the same head, recall of a
reworded re-raise, a finding the audit never rejected, the head moving, case-insensitive head
comparison, a push replacing rather than extending the entry, accumulation across rounds on one
head, a downgrade not read as a rejection, a finding citing no file, a session with no repository or
head, both caps. ModelSuppliedTextInLogLinesTest#aCraftedPathAndTitleCannotForgeARecordFromTheRememberedRejectionDrop
holds the new INFO line to the same log-forgery guarantee as its neighbours (#742/#755).

Gates, from the worktree root:

./mvnw -B clean compile spotbugs:check spotless:check
[INFO] BugInstance size is 0
[INFO] BUILD SUCCESS

./mvnw -B clean test
[INFO] Tests run: 4054, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Patch coverage against origin/main, every added src/main line and branch:

GAPS: none

Checklist

  • My code follows the project's coding standards
  • I have performed a self-review of my own code
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the documentation accordingly
  • My changes generate no new warnings or errors

Screenshots / Logs

The new drop is readable in the log, so a quiet round is never mistaken for a clean one:

Remembering 1 finding(s) the audit rejected for owner/repo#70 at 2659f683 (1 held); a later round on the same head will not put them to the verifier again
Dropping finding 'Concatenated SQL' (Repositories/ReservationRepository.cs:31): the second-pass audit rejected the same claim on head 2659f683 in an earlier round of this pull request

Additional Notes

The memory is consulted and written in both review lanes — the single-call path in
FindingPipeline.refine and the per-batch path in refineBatchOutcome — so a large pull request
gets the same stability as a small one. Batches partition by file and recall requires the same file,
so one batch's rejection can never drop another batch's finding within a round.

What this does not do is make a wrong rejection right. If the audit rejects a true finding, this
change makes that rejection stick for the head instead of the finding reappearing on a later round.
That is the trade the issue asks for — "a finding verified and rejected on a head could be
remembered for that head rather than re-litigated from scratch" — and the reason #871 had to land
first: it removes the material gap that was producing the wrong rejections in the first place.

No new dependency, no configuration knob, no extra model call.

The second-pass audit reached opposite verdicts on one claim and one
unchanged commit inside a single dogfood round. It rejected a
SQL-injection candidate on the ground that a catalog allow-list gates the
route value before it reaches the concatenated query, and forty minutes
later, on the same commit, published that claim as CRITICAL on a public
route.

A second audit of the same claim on the same code is not a second
opinion. It is the same question put again to a sampled model, and the
answer that reaches the pull request is whichever round happened to post.
The inputs cannot be made identical either: the previous-findings section
grows with every posted round, the pull request context is bounded
against a budget that moves with the diff, and the conversation the
review reads changes as people comment.

Verification also fails open, so a round may reach no verdict at all. An
empty response body, a timeout, a refusal or the review's spend ceiling
keeps the candidate exactly as the reviewer raised it, and a claim the
audit had already rejected then posts with nothing in front of it.

Rejections are now held per pull request and head commit, and a later
round on that head drops the claim before the verification call rather
than putting it to the model again. Only rejections are held, never
confirmations: re-auditing a confirmed finding costs a call, while
pinning one would repeat a false positive for the life of the head, so
the store can only ever publish fewer findings than before. A push
replaces the entry, because a rejection is an answer about code and the
code changed. The store is capped in findings per pull request and in
pull requests, and lives in memory per replica like the CI hold registry
and the superseded-findings carryover.

A remembered rejection is recognized through FollowUpAnalyzer's
isSameFinding, the rule the follow-up passes already use for a re-raise,
so a claim reworded between rounds is still the same claim.
@devops-thiago devops-thiago added this to the v0.6.9 milestone Sep 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot PR Summary

What this PR does

The change adds an in-memory store keyed by pull request and head commit that remembers which candidate findings the second-pass verification audit rejected. Later review rounds on the same head drop those claims before the verification call, so a rejected finding is neither re-litigated nor re-published on a round where verification fails open, and a push to a new head clears the remembered rejections.

Description vs. Implementation

No mismatch found between the PR description and the change.

Control-Flow Diagram

🔀 Show diagram
flowchart TD
  A["validated reviewer findings"] --> B{"remembered rejection on this head?"}
  B -->|"match"| C["drop finding and log via LogSafe"]
  B -->|"no match"| D["kept candidates"]
  C --> D
  D --> E["FindingVerificationService.verify"]
  E --> F["remember candidates minus published"]
  F --> G["rejections stored keyed by pr and head"]
Loading

Changes Overview

  • Files changed: 8
  • Lines added: +617
  • Lines removed: -4

Changed Files

File Change Summary
CHANGELOG.md Modified Adds a Fixed entry describing rejected findings being remembered per head commit and dropped on later rounds of that head.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FindingPipeline.java Modified Wires the memory into both lanes: consult before verify (drop remembered rejections), record candidates-minus-published after.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/FollowUpAnalyzer.java Modified isSameFinding widened from private to package-private with a javadoc, so rejection recall uses the same re-raise rule.
src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemory.java Added New per-pr/per-head store recording audit-rejected candidates and dropping them from later verification inputs on the same head.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/FindingPipelineTest.java Modified Constructor wiring updates plus two pipeline tests: rejection not republished on same head, and suppression ends after a push.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ModelSuppliedTextInLogLinesTest.java Modified Constructor wiring update plus a log-forgery test holding the new drop INFO line to the LogSafe guarantee.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/ReviewOrchestratorTest.java Modified Adds the new VerifierRejectionMemory constructor argument to the two FindingPipeline instances built in setUp.
src/test/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemoryTest.java Added Unit tests for the store: recall, reworded re-raise, head move, case-insensitive SHA, push replace, caps, blank identity.

Risk Assessment

Risk Count
🔴 Critical 0
🟠 High 0
🟡 Medium 1
🔵 Low 0

Things to double-check

1 lower-confidence finding
  • MEDIUM: Javadoc says a fail-open round remembers nothing; the self-retraction screen's removals are remembered (src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemory.java:140) (low confidence — verify before acting)

⚠️ Required CI Checks Status

Some required checks are still pending or have failed:

Check Type Status Detail
test check-run ⏳ Pending -

Automated review by ThrillhouseBot. Reply with /review to re-run.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThrillhouseBot noted 1 lower-confidence item(s) under Things to double-check in the PR summary (not posted as inline threads):

  • MEDIUM: Javadoc says a fail-open round remembers nothing; the self-retraction screen's removals are remembered (src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemory.java:140)
    The remember() javadoc states: "A round whose verification failed open removed nothing and so remembers nothing, leaving the earlier rounds' answers standing." That is contradicted by the very same javadoc's first paragraph, which says the recorded difference is "exactly the audit's own removals — a 'rejected' verdict, and the deterministic self-retraction screen in front of the call". The pipeline calls rejectionMemory.remember(session, candidates.findings(), aiResponse.findings()) after the verify call, comparing the pre-call candidates against whatever verify returned. Per the PR description, FindingVerificationService.audit's fail-open paths (empty body, IOException/RuntimeException, spend-ceiling refusal) all return screened — the output of the self-retraction screen that runs in front of the model call. So on a round where the screen retracted a candidate AND the model call then failed open, the published list is screened (not the full candidate list), the difference taken by removed(candidates, published) is non-empty, and the run DOES record those removals into the store — the opposite of what the sentence asserts. A maintainer reasoning from this javadoc that fail-open rounds never mutate the store (for example when reasoning about cap/eviction effects or writing a control test) would get the behavior wrong in that combination. The screen's removals are only recorded on a successful round when the model's returned findings also drop them. Confidence is low because the audit body itself is not in the provided material — the return screened claim rests on the PR description; verify FindingVerificationService.audit's fail-open returns before rewording.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… diff

The published side of the remembered-rejection difference is read by the
same anchor as the candidate side, and the branch that skips a finding
citing no file there had no test, so patch coverage reported a partial
line.

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check format is pending
  • Check test is pending

Additionally, No new issues in this revision, but 1 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

@thrillhousebot thrillhousebot Bot added bug Something isn't working java Pull requests that update java code labels Sep 16, 2026
@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues in this revision, but 1 previous finding(s) remain unresolved — fix them, or reply on their review thread with why they are deferred. A finding listed only under "Things to double-check" has no thread: clear it by commenting @thrillhousebot resolved path/to/File.java:42 — <the finding's title> on this PR.

…n round records

The remember() javadoc said a round whose verification failed open
remembers nothing, which its own first paragraph contradicts. The
self-retraction screen runs in front of the model call and the fail-open
paths return its output, so such a round does record what the screen
retracted; only the verdict half contributes nothing.
@devops-thiago

Copy link
Copy Markdown
Owner Author

@thrillhousebot resolved src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemory.java:140 — Javadoc says a fail-open round remembers nothing; the self-retraction screen's removals are remembered

Correct, and fixed in 6bf9856. The sentence contradicted the paragraph above it. FindingVerificationService.audit runs demoteHedgedBlockingFindings(dropSelfRetractedFindings(response)) into screened before the model call, and all three fail-open paths — the empty body, the IOException | RuntimeException catch, and the spend-ceiling refusal — return screened. So on a round that lost its call, the published list is the screen's output, removed(candidates, published) is non-empty for anything the screen retracted, and the store does record it.

The javadoc now separates the two halves: the screen sits in front of the call and runs on every round, so its removals are recorded even when the call fails, which is a deterministic drop the next round would make again anyway; the verdict half contributes nothing on such a round, leaving a candidate the model never ruled on for a later round to rule on. Behaviour is unchanged — only the description was wrong.

@thrillhousebot

Copy link
Copy Markdown
Contributor

The next review will close every previous finding this comment names by its path:line and title; anything it does not name stays open.

@thrillhousebot

Copy link
Copy Markdown
Contributor

🤖 ThrillhouseBot — changes since the last review

  • New findings this round: 0
  • Previous findings resolved: 1
    • src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemory.java:140 — Javadoc says a fail-open round remembers nothing; the self-retraction screen's removals are remembered
  • Previous findings still open: 0

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThrillhouseBot found no issues in this PR, but some checks are still pending or failed:

  • Check test is pending
  • Check format is pending

ThrillhouseBot closed 1 previous finding(s) this round:

  • src/main/java/dev/thiagogonzaga/thrillhousebot/review/VerifierRejectionMemory.java:140 — Javadoc says a fail-open round remembers nothing; the self-retraction screen's removals are remembered

@sonarqubecloud

Copy link
Copy Markdown

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required CI is now green for 6bf9856, so the approval the earlier review held back is posted. The code was not re-reviewed: that review found no issues, and only the CI gate held its approval.

@devops-thiago

Copy link
Copy Markdown
Owner Author

/review

@thrillhousebot thrillhousebot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything's coming up Thrillhouse! 🎉

No issues found in this PR.

@devops-thiago
devops-thiago merged commit bdadf75 into main Sep 16, 2026
18 checks passed
@devops-thiago
devops-thiago deleted the fix/711-verifier-determinism branch September 16, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working java Pull requests that update java code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The verifier reaches opposite verdicts on the same claim and unchanged code across runs

1 participant