fix(review): meter and ceiling-gate the finding-verifier AI call - #521
Conversation
The token spend ceiling (REVIEW_MAX_TOKENS_PER_REVIEW) is documented as covering every AI call a review makes, but the finding-verifier call - a billed concise-model call made once per batch and once on the single-call path, on by default - was neither metered nor gated: its blocking call never runs under the streaming path's ReviewSessionContext bind, so the observability listener dropped its usage, and nothing on the verifier path consulted the ledger before calling. FindingVerificationService now takes the review's ledger key: it records the call's Result-reported usage straight into the ReviewTokenLedger (before unwrapping, so a truncated-but-billed response still counts), and once the ceiling is reached it skips the call fail-open - the unverified findings are kept and the skip is logged naming the ceiling, so a review at its ceiling degrades in quality but is never lost and never bills another verifier call. The deterministic hedged-finding demotion still runs on the skip path; it costs no tokens.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
🤖 ThrillhouseBot PR SummaryWhat this PR doesMeters and ceiling-gates the finding-verifier AI call. FindingVerificationService.verify now takes the review's ledger key, records the verifier Result's tokenUsage into ReviewTokenLedger before unwrapping the response, and skips the billed call fail-open (keeping unverified findings and logging a WARN) once REVIEW_MAX_TOKENS_PER_REVIEW is reached. FindingPipeline threads the session ledger key through the batch, salvage, and single-call verification paths so both halves of the documented ceiling contract apply to the verifier. Control-Flow Diagram🔀 Show diagramflowchart TD
A["FindingPipeline refine / refineBatchOutcome / salvage"] --> B["FindingVerificationService.verify(session, ...)"]
B --> C["demoteHedgedBlockingFindings"]
C --> D{"verifierEnabled and findings non-empty?"}
D -- "no" --> E["return screened findings"]
D -- "yes" --> F{"ceilingReached(session)?"}
F -- "yes" --> G["WARN: skip verifier, keep unverified findings"]
G --> E
F -- "no" --> H["verifier.verify(...) blocking AI call"]
H --> I["recordUsage(session, input, output)"]
I --> J{"response truncated?"}
J -- "yes" --> K["textOrThrowOnTruncation throws -> catch -> fail-open"]
K --> E
J -- "no" --> L["parse verdicts and apply"]
L --> E
Changes Overview
Changed Files
Risk Assessment
No new issues found in this PR, but the review cannot be approved until CI is confirmed green.
|
| Check | Type | Status | Detail |
|---|---|---|---|
| changes | check-run | ⏳ Pending | - |
| test | check-run | ⏳ Pending | - |
| actionlint | check-run | ⏳ Pending | - |
| format | check-run | ⏳ Pending | - |
| trivy | check-run | ⏳ Pending | - |
| frontend | check-run | ⏳ Pending | - |
| dependency-review | check-run | ⏳ Pending | - |
Automated review by ThrillhouseBot. Reply with /review to re-run.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|



What type of PR is this?
Description
REVIEW_MAX_TOKENS_PER_REVIEWis documented as a ceiling "on the tokens one review may consume across every AI call it makes", with "Once reached no further call is made". The finding-verifier call — a billed concise-model AI call made once per batch (and once on the single-call path), on by default viaREVIEW_VERIFIER_ENABLED— escaped both halves of that contract:AiReviewService.streamOnce'sReviewSessionContextbind, soOtelObservabilityListener.onResponsesaw no session id in the request attributes and dropped its usage. The ceiling undercounted real spend by the whole verification cost — per-call input on the order of the batch review call itself (the prompt repeats the batch diff, findings JSON, project stack and previous findings).ensureCallAllowed's only call site isAiReviewService.runWithRetries; nothing on the verifier path consulted the ledger, so a review at its ceiling kept making billed verifier calls (one per batch outcome — parsed and salvaged — plus the single-call path).Fix (in
FindingVerificationService, the seam both call sites share):verifynow takes the review's ledger key (ReviewTokenLedger.keyFor), threaded fromFindingPipeline.refine/refineBatchOutcome.Result#tokenUsage()is recorded straight into theReviewTokenLedger, before unwrapping — so a truncated-but-billed response still counts. Recording directly (instead of via the listener) is deliberate: the blocking call never runs under the streaming session bind, so there is no double count.ceilingReached, the verifier call is skipped fail-open — the unverified findings are kept (the service's existing error contract) and the skip is logged at WARN namingREVIEW_MAX_TOKENS_PER_REVIEW. The review is never failed and the deterministic hedged-finding demotion (no AI call) still runs.No doc changes: the fix makes the existing README /
.env.example/application.propertieswording ("across every AI call", "once reached no further call is made") true, which is the direction the audit prescribed.Related Issues
Fixes #514
How Has This Been Tested?
Red → green proofs (each new behavioral test fails on unfixed code exactly as claimed, then passes with the fix). Mockito's literal angle-bracket matchers are shown as
⟨any⟩so they survive markdown sanitization.Gate (pipeline level) — the audit's proof test (
Audit3VerifierGateProofTest, 2-batch multi-call plan, ledger at its ceiling for the whole run) with the expectation set to the documented behavior (never()), run on unfixed5c04600:That test is adapted into
FindingPipelineTest.verifierAiCallsAreSkippedOnceTheSpendCeilingIsReached, which wires a realFindingVerificationServiceover a mockFindingVerifierso the assertion sits on the actual AI seam; against the plumbing without the gate it fails the same way:Gate + metering (service level) — new
FindingVerificationServiceTesttests against the unfixed behavior:All of the above pass with the fix; the metering tests use a real
ReviewTokenLedgerand assert the ledger increases by the provider-reported input+output. Additional tests cover the ceiling skip preserving the hedged-finding demotion, aResultwith no usage, and a nullResult(fail-open, nothing recorded).Gates:
spotless:applyclean;clean compile spotbugs:check spotless:check— BugInstance size is 0;clean test— 2573 tests, 0 failures, 0 errors, 0 skipped. Jacoco ∩git diff -U0 5c04600...HEADover changed main code: zero uncovered lines, zero uncovered branches.Checklist
Screenshots / Logs
New WARN on the skip path:
Additional Notes
Scope kept to the verifier seams:
FindingVerificationService(gate + metering),FindingPipelineonly inrefineBatchOutcome/verification call sites (threading the session key throughprocessBatch/salvageTruncatedBatchcallers), and theReviewTokenLedger/salvageTruncatedBatchjavadocs whose "fed by the listener" / "ceiling accounting untouched" claims the fix completes.ReviewResult,VerdictBuilder,DiffBudgetPlanner,countsOnlySummaryand the disclosure seams are untouched (owned by #518/#515).