Skip to content

fix(ai-review): verify reviewers actually agree; strip blocker authority from a bailed review#9114

Merged
JSONbored merged 1 commit into
mainfrom
fix/9074-9085-9087-ai-consensus
Jul 26, 2026
Merged

fix(ai-review): verify reviewers actually agree; strip blocker authority from a bailed review#9114
JSONbored merged 1 commit into
mainfrom
fix/9074-9085-9087-ai-consensus

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Two ways the gate publicly claimed something it never checked — and closed contributor PRs on that claim.

#9074 — "AI reviewers agree" was never verified. consensusDefectOf returned a defect whenever both reviewers had a non-empty blockers list, and never compared the texts. The doc comment two lines above asserted the property the code didn't implement. So reviewer A's "SQL injection in src/db.ts" plus reviewer B's "the new helper lacks a doc comment" produced a critical finding, published on the contributor's PR as:

AI reviewers agree on a likely critical defect: SQL injection in src/db.ts

…as the stated reason their work was auto-closed under one-shot rules.

Agreement is now established by actually comparing blocker texts (normalized significant-token overlap, with a lower bar when both cite the same path and share another term), matched across any pair from the two lists since reviewers order findings differently.

Two reviewers flagging different defects is neither consensus nor silence — it's now a split, gating on the weaker confidence. Worth calling out: split previously required exactly one side to have flagged, so both flagging produced no finding at all — strictly weaker than one reviewer flagging. A blank blocker entry also no longer counts as "flagged" anywhere, which removes the case where two empty strings manufactured a critical closing finding under the generic "AI reviewers agree on a likely blocking defect" title.

#9087 — a model that said it couldn't read the diff kept blocker authority over it. The system prompt instructs a model that can't read the diff to bail and return empty blockers. A model that bailed, violated that instruction, and happened to write a ≥40-char rationale had its blockers promoted into a usable review by #8789's reclassification. Under combine: "single" (our live claude-code,ollama config) a lone blocker becomes a full ai_consensus_defect — critical, published as agreement — and a close under aiReviewGateMode: block. Blockers are now dropped on reclassification; the valueAssessment (#8789's actual purpose), nits, and suggestions are kept. The published title also stops claiming agreement when only one reviewer ran.

Closes #9074
Closes #9087

#9085 — partially done, and its premise turned out to be wrong

Done: added slop_risk_above_threshold and surface_lane_reject to CONFIGURED_GATE_BLOCKER_SIGNAL_CODES. Both gate real closes but were absent, so their reversals recorded under a different id (or not at all) and the per-rule precision check in downgradeCloseToHold could never apply to them — the same drift shape as the backtest_regression omission that list's own doc comment was written about.

Not done, deliberately. The issue states severity "never determines consequence anywhere on the decision path" and is read only by two presentational functions. That's incorrect: conclusionForSeverity maps critical → action_required. I implemented the severity fix, and it flipped advisory-mode repos from neutral to action_required, breaking four gate tests — i.e. raising these findings to match their consequence would have changed behavior for repos that deliberately run them advisory-only. Reverted.

The mismatch is real and still worth fixing, but it needs that coupling decoupled first (severity should describe the finding; consequence should come from the gate mode). That's its own change, so I've left #9085 open rather than half-fixing it.

Test plan

  • 11 consensus/split cases: unrelated defects are not consensus; the same defect in different words is; matching works across any pair, not just the first of each; the agreed blocker is the one cited; weaker-confidence rule; disagreement becomes a split with min confidence; one-sided split unchanged; a clean pair yields nothing; text with no significant tokens never agrees; shared-path booster works; a single shared path token alone is not agreement
  • Reclassification test proving blockers are dropped while valueAssessment/nits/suggestions survive
  • 3 taxonomy tests (both codes present, no duplicates)
  • 100% line and branch coverage on all added lines — verified, and I removed the fallback branches that became genuinely unreachable rather than leaving them untestable
  • tsc --noEmit, migrations/schema-drift/docs/engine-parity gates green; 985/985 across 9 suites

…thority from a bailed review

#9074 -- consensusDefectOf returned a defect whenever BOTH reviewers had a
non-empty blockers list and NEVER compared the texts, while the doc comment two
lines above asserted the property the code did not implement ("Requiring two
independent models to AGREE is itself the precision mechanism"). Reviewer A's
"SQL injection in src/db.ts" plus reviewer B's "the new helper lacks a doc
comment" produced a critical finding published on the contributor's PR as
"AI reviewers agree on a likely critical defect: SQL injection in src/db.ts" --
a false claim of agreement, given as the reason their work was auto-closed
under one-shot rules.

Agreement is now established by comparing blocker texts (normalized
significant-token overlap, with a lower bar when both cite the same path and
share another term), across ANY pair from the two lists since reviewers order
findings differently. Two reviewers flagging DIFFERENT defects is neither
consensus nor silence -- it is now a SPLIT, gating on the weaker of the two
confidences. Previously `split` required exactly one side to have flagged, so
both flagging produced no finding at all: strictly weaker than one doing so.
A blank blocker entry no longer counts as "flagged" anywhere, which also
removes the case where two empty strings manufactured a critical closing
finding under the generic "AI reviewers agree" title.

#9087 -- the system prompt tells a model that cannot read the diff to bail with
INCOHERENT_DIFF_ASSESSMENT and return empty blockers. A model that bailed,
violated that instruction, and happened to write a >=40-char rationale had its
blockers promoted into a usable review by #8789's reclassification. Under
`combine: "single"` (our live claude-code+ollama config) a lone blocker becomes
a full ai_consensus_defect -- severity critical, published as agreement -- and
a close under aiReviewGateMode: block. Blockers are now dropped on
reclassification; the valueAssessment (#8789's actual purpose), nits, and
suggestions are kept. The published title also stops claiming agreement when
only one reviewer ran.

#9085 (partial) -- adds slop_risk_above_threshold and surface_lane_reject to
CONFIGURED_GATE_BLOCKER_SIGNAL_CODES. Both gate real closes but were absent, so
their reversals recorded under a different id (or not at all) and the per-rule
precision check in downgradeCloseToHold could never apply to them -- the same
drift shape as the backtest_regression omission this list's doc comment was
written about.

NOT done, and the issue's premise is wrong: #9085 claims severity "never
determines consequence" and is read only by presentational code. It is not --
conclusionForSeverity maps critical -> action_required, so raising these
findings' severity to match their consequence flipped advisory-mode repos from
neutral to action_required and broke four gate tests. Reverted; the severity
mismatch needs that coupling decoupled first, which is its own change.

Closes #9074
Closes #9087

Tests: 11 consensus/split cases (unrelated defects are not consensus, same
defect in different words is, any-pair matching, weaker-confidence rule,
disagreement becomes a split with min confidence, one-sided split unchanged,
clean pair yields nothing, unverifiable text never agrees, shared-path booster,
single shared token is not agreement), a reclassification test proving blockers
are dropped while the valueAssessment survives, and 3 taxonomy tests. 100%
line+branch coverage on all added lines; 985/985 across 9 suites.
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@github-actions

Copy link
Copy Markdown
Contributor

Logic backtest

Replayed 0 historical case(s) for linked_issue_scope_mismatch through the base (6dc0e3c) and head (79aae33) versions of its detection logic (corpus checksum 4f53cda18c2b).

Backtest comparison: linked_issue_scope_mismatch

Verdict: unchanged — no comparable axis moved.

Advisory only — this check never blocks merge (#8105).

@JSONbored JSONbored self-assigned this Jul 26, 2026
@JSONbored
JSONbored merged commit 98e103c into main Jul 26, 2026
5 of 6 checks passed
@JSONbored
JSONbored deleted the fix/9074-9085-9087-ai-consensus branch July 26, 2026 21:00
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

❌ 16 Tests Failed:

Tests completed Failed Passed Skipped
21684 16 21668 21
View the top 3 failed test(s) by shortest run time
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > optional collector token (#1285): open when unset; enforced once ORB_INGEST_TOKEN is set
Stack Traces | 0.00734s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/orb-ingest.test.ts:356:50
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 400 for invalid JSON
Stack Traces | 0.00738s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/orb-ingest.test.ts:318:24
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 400 for an empty body
Stack Traces | 0.00761s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/orb-ingest.test.ts:324:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 400 for invalid JSON
Stack Traces | 0.00762s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/ams-ingest.test.ts:138:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 400 for an empty body
Stack Traces | 0.00763s run time
AssertionError: expected 401 to be 400 // Object.is equality

- Expected
+ Received

- 400
+ 401

 ❯ test/integration/ams-ingest.test.ts:144:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > optional collector token: open when unset; enforced once AMS_INGEST_TOKEN is set
Stack Traces | 0.00805s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/ams-ingest.test.ts:159:50
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 413 when the body exceeds the ingest byte ceiling
Stack Traces | 0.00915s run time
AssertionError: expected 401 to be 413 // Object.is equality

- Expected
+ Received

- 413
+ 401

 ❯ test/integration/orb-ingest.test.ts:330:24
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 413 when the body exceeds the shared ingest byte ceiling
Stack Traces | 0.0103s run time
AssertionError: expected 401 to be 413 // Object.is equality

- Expected
+ Received

- 413
+ 401

 ❯ test/integration/ams-ingest.test.ts:150:24
test/integration/orb-oauth.test.ts > GET /v1/orb/oauth/callback (post-install landing) > the new exemption + rate class are path-specific (a later orb path still routes)
Stack Traces | 0.0207s run time
AssertionError: expected [ 400, 413 ] to include 401
 ❯ test/integration/orb-oauth.test.ts:36:24
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > returns 200 + accepted count for a valid batch
Stack Traces | 0.0421s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/orb-ingest.test.ts:312:24
test/unit/check-engine-parity-script.test.ts > check-engine-parity script > runEngineParityMain returns 0 for the real monorepo workspace
Stack Traces | 0.0445s run time
AssertionError: expected 1 to be +0 // Object.is equality

- Expected
+ Received

- 0
+ 1

 ❯ test/unit/check-engine-parity-script.test.ts:798:48
test/integration/ams-ingest.test.ts > POST /v1/ams/ingest route > returns 200 + accepted count for a valid batch
Stack Traces | 0.0464s run time
AssertionError: expected 401 to be 200 // Object.is equality

- Expected
+ Received

- 200
+ 401

 ❯ test/integration/ams-ingest.test.ts:132:24
test/integration/orb-ingest.test.ts > Orb instance registry routes (/v1/internal/orb/instances) > lists ingested instances as unregistered with their stored-signal count
Stack Traces | 0.0694s run time
AssertionError: expected [] to deeply equal [ ObjectContaining{…} ]

- Expected
+ Received

- [
-   ObjectContaining {
-     "instanceId": "inst-a",
-     "registered": false,
-     "signalCount": 1,
-   },
- ]
+ []

 ❯ test/integration/orb-ingest.test.ts:378:23
test/unit/check-engine-parity-script.test.ts > check-engine-parity script > engine version skew > uses default version readers against the real monorepo workspace
Stack Traces | 0.0715s run time
AssertionError: expected [ Array(1) ] to deeply equal []

- Expected
+ Received

- []
+ [
+   "Gate-decision logic change in src/rules/advisory.ts requires either:
+   • a matching edit to the other twin (.../src/advisory/gate-advisory.ts), or
+   • a version bump in packages/loopover-engine/package.json (currently 3.15.1 vs base 3.15.1).",
+ ]

 ❯ test/unit/check-engine-parity-script.test.ts:785:31
test/unit/check-engine-parity-script.test.ts > check-engine-parity script > prints a clean summary and exits 0 for the real repo state when run as a subprocess
Stack Traces | 0.896s run time
Error: Command failed: .../loopover/node_modules/.bin/tsx scripts/check-engine-parity.ts
Engine-parity check found 1 issue(s):
Gate-decision logic change in src/rules/advisory.ts requires either:
  • a matching edit to the other twin (.../src/advisory/gate-advisory.ts), or
  • a version bump in packages/loopover-engine/package.json (currently 3.15.1 vs base 3.15.1).

 ❯ test/unit/check-engine-parity-script.test.ts:803:20

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { status: 1, signal: null, output: [ null, '', 'Engine-parity check found 1 issue(s):\nGate-decision logic change in src/rules/advisory.ts requires either:\n  • a matching edit to the other twin (.../src/advisory/gate-advisory.ts), or\n  • a version bump in packages/loopover-engine/package.json (currently 3.15.1 vs base 3.15.1).\n' ], pid: 7174, stdout: '', stderr: 'Engine-parity check found 1 issue(s):\nGate-decision logic change in src/rules/advisory.ts requires either:\n  • a matching edit to the other twin (.../src/advisory/gate-advisory.ts), or\n  • a version bump in packages/loopover-engine/package.json (currently 3.15.1 vs base 3.15.1).\n' }
View the full list of 1 ❄️ flaky test(s)
test/integration/orb-ingest.test.ts > POST /v1/orb/ingest route > REGRESSION (#8330): a dropped connection mid-upload returns the same clean 413, not a framework 500

Flake rate in main: 100.00% (Passed 0 times, Failed 1 times)

Stack Traces | 0.00726s run time
AssertionError: expected 401 to be 413 // Object.is equality

- Expected
+ Received

- 413
+ 401

 ❯ test/integration/orb-ingest.test.ts:346:24

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant