You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In the single combine strategy — the strategy the live deployment uses — a reviewer that names a real blocker produces a clean pass whenever the blocker's text happens to contain an ordinary word the public-safe filter rejects (score, ranking, reward, cohort, payout, farming, reviewability). The PR auto-merges with the defect unreported.
When synthesizeDefect returns null, the trio is {defect: null, split: false, inconclusive: false} — indistinguishable from "the reviewer found nothing". runAiReviewForAdvisory then pushes no finding at all (src/queue/ai-review-orchestration.ts:897-988), notes are composed from the surviving sentences, and the row is written durably cacheable (:1032-1042, no cacheable field ⇒ true) — so the wrong verdict is replayed on later passes.
The comment calls this "fail-safe". In the consensus branch it is; in single/synthesis it is fail-open.
Why the existing guards do not cover it
The consensus branch is fail-closed for exactly this case: src/services/ai-review.ts:2642 sets split = !defect && realBlockers > 0, converting a dropped defect into a hold. single and synthesis have no equivalent.
composeAdvisoryNotes passes allowBareScoreTerm (:2029-2035), but synthesizeDefect and consensusDefectOf call toPublicSafe with no options (:2242, :2573) — so even a repo on the score-terms allowlist gets strict filtering on the one string that carries the gate decision.
Why this is live, and why it got worse two commits ago
AI_PROVIDER=claude-code,ollama ⇒ one reviewer slot, combine: "single" (src/selfhost/ai.ts:1596-1613). The live path is the vulnerable one.
The rejection set is ordinary review vocabulary matched with .includes(), plus /\bscore\w*\b/i unless the repo is in LOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS. In a codebase whose domain is scoring, ranking and rewards, blocker titles like "computeScore divides by zero when items is empty" or "the ranking comparator drops the tie-break" are routine.
fix(review): filter the AI narrative assessment per sentence, not all-or-nothing #9445 (6269d27a9, two commits before this audit) widened reachability. Before per-sentence filtering, a filtered review often lost its whole narrative, leaving no public notes and routing the PR to an ai_review_inconclusive hold. Now the narrative survives, notes exist, and the pass completes cleanly with no defect. That commit's own data — the filter engaging on roughly 40% of reviews on this repo — is the base rate for how often blocker text carries these words.
Requirements
A blocker that exists but cannot be published must never resolve to "no defect".
The fix must apply to bothsingle and synthesis (both call synthesizeDefect and both currently collapse); consensus already handles it and must not change behaviour.
The unpublishable case must be observable, not silent.
Deliverables
In combineReviews, when blockers.length > 0 && synthesizeDefect(...) === null, return a hold rather than a pass — {defect: null, split: true, splitConfidence: <flagging reviewer's confidence>} (reusing the existing split→hold path) or inconclusive: true. Pick one and state the rationale in the code comment.
Same treatment in the synthesis branch, both the either and both arms.
A counter (e.g. loopover_ai_review_unpublishable_blocker_total) incremented at the drop site, so the rate is measurable.
Correct the synthesizeDefect doc comment: it is fail-safe only for consensus.
Consider whether the gate-bearing title should be filtered with allowBareScoreTerm for allowlisted repos, matching composeAdvisoryNotes. If not, document why the two differ.
Tests (must fail against current main)
single + one reviewer + one blocker whose title contains score (repo NOT allowlisted) ⇒ result holds; asserts defect === null && (split || inconclusive).
Same for each of reward, ranking, cohort, farming, payout.
synthesis/either and synthesis/both equivalents.
consensus unchanged (regression guard).
A publishable blocker still produces a defect in all three strategies.
The cache row written for a dropped-blocker review is not durably cacheable, or is keyed so the hold is reproduced.
Expected outcome
A reviewer-named blocker always produces either a published defect or an explicit hold. It can never produce a silent pass. The rate of unpublishable blockers becomes visible in metrics rather than invisible in merges.
Verification
Reproduce on a scratch PR by configuring a repo off the score-terms allowlist and inducing a blocker whose title contains "score"; confirm the PR holds instead of merging.
Summary
In the
singlecombine strategy — the strategy the live deployment uses — a reviewer that names a real blocker produces a clean pass whenever the blocker's text happens to contain an ordinary word the public-safe filter rejects (score,ranking,reward,cohort,payout,farming,reviewability). The PR auto-merges with the defect unreported.Mechanism (verified at HEAD, 776c414)
synthesizeDefectdrops the entire defect when the blocker title is not publishable:src/services/ai-review.ts:2561-2577combineReviews,singlebranch —src/services/ai-review.ts:2598-2608:When
synthesizeDefectreturnsnull, the trio is{defect: null, split: false, inconclusive: false}— indistinguishable from "the reviewer found nothing".runAiReviewForAdvisorythen pushes no finding at all (src/queue/ai-review-orchestration.ts:897-988), notes are composed from the surviving sentences, and the row is written durably cacheable (:1032-1042, nocacheablefield ⇒ true) — so the wrong verdict is replayed on later passes.The comment calls this "fail-safe". In the
consensusbranch it is; insingle/synthesisit is fail-open.Why the existing guards do not cover it
consensusbranch is fail-closed for exactly this case:src/services/ai-review.ts:2642setssplit = !defect && realBlockers > 0, converting a dropped defect into a hold.singleandsynthesishave no equivalent.composeAdvisoryNotespassesallowBareScoreTerm(:2029-2035), butsynthesizeDefectandconsensusDefectOfcalltoPublicSafewith no options (:2242,:2573) — so even a repo on the score-terms allowlist gets strict filtering on the one string that carries the gate decision.Why this is live, and why it got worse two commits ago
AI_PROVIDER=claude-code,ollama⇒ one reviewer slot,combine: "single"(src/selfhost/ai.ts:1596-1613). The live path is the vulnerable one..includes(), plus/\bscore\w*\b/iunless the repo is inLOOPOVER_PUBLIC_SCORE_TERMS_ALLOWED_REPOS. In a codebase whose domain is scoring, ranking and rewards, blocker titles like "computeScoredivides by zero whenitemsis empty" or "the ranking comparator drops the tie-break" are routine.6269d27a9, two commits before this audit) widened reachability. Before per-sentence filtering, a filtered review often lost its whole narrative, leaving no public notes and routing the PR to anai_review_inconclusivehold. Now the narrative survives, notes exist, and the pass completes cleanly with no defect. That commit's own data — the filter engaging on roughly 40% of reviews on this repo — is the base rate for how often blocker text carries these words.Requirements
singleandsynthesis(both callsynthesizeDefectand both currently collapse);consensusalready handles it and must not change behaviour.Deliverables
combineReviews, whenblockers.length > 0 && synthesizeDefect(...) === null, return a hold rather than a pass —{defect: null, split: true, splitConfidence: <flagging reviewer's confidence>}(reusing the existing split→hold path) orinconclusive: true. Pick one and state the rationale in the code comment.synthesisbranch, both theeitherandbotharms.loopover_ai_review_unpublishable_blocker_total) incremented at the drop site, so the rate is measurable.synthesizeDefectdoc comment: it is fail-safe only forconsensus.allowBareScoreTermfor allowlisted repos, matchingcomposeAdvisoryNotes. If not, document why the two differ.Tests (must fail against current main)
single+ one reviewer + one blocker whose title containsscore(repo NOT allowlisted) ⇒ result holds; assertsdefect === null && (split || inconclusive).reward,ranking,cohort,farming,payout.synthesis/eitherandsynthesis/bothequivalents.consensusunchanged (regression guard).Expected outcome
A reviewer-named blocker always produces either a published defect or an explicit hold. It can never produce a silent pass. The rate of unpublishable blockers becomes visible in metrics rather than invisible in merges.
Verification
Reproduce on a scratch PR by configuring a repo off the score-terms allowlist and inducing a blocker whose title contains "score"; confirm the PR holds instead of merging.