{"slug": "i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have", "title": "I asked one model to review another, and it found a bias I would not have", "summary": "A developer built an autonomous vulnerability remediation system and discovered that a cross-family AI reviewer rejected 65% of triage proposals, revealing a systematic bias in the primary model toward inflating severity ratings. The reviewer, running on Gemma, cited unsupported severity escalations in half of its rejections, a finding the developer says would have been missed without the architecture's design.", "body_md": "I spent five days building an autonomous system that owns the vulnerability\n\nremediation lifecycle — the six weeks *after* a scan, which is where one-person\n\nsecurity programs actually die. Finding vulnerabilities is solved. Chasing the\n\nowner who never opened the ticket is not.\n\nThe part worth writing about is not the pipeline. It is a number I did not\n\nexpect and initially misread as a bug.\n\nEvery triage decision in this system is challenged before it becomes state.\n\nA reasoning agent on Gemini 3.5 Flash proposes a severity, an SLA, and a\n\nremediation path, with cited evidence. A reviewer then either ratifies it or\n\nrejects it with a stated reason.\n\nThe reviewer runs on **Gemma** — deliberately a different model family. The\n\nargument for that is easy to state and hard to verify: a model auditing its own\n\nreasoning shares its own blind spots. I believed it when I designed it. I did\n\nnot have evidence for it.\n\nThe reviewer rejects **65% of the proposals it sees** — 91 rejections across\n\n139 verdicts, measured on 31 August 2026. About half of the findings that\n\nreach a decision are ratified.\n\nThose denominators matter and I will not blur them. A rejected proposal is\n\nre-proposed once with the feedback attached, so one finding can produce more\n\nthan one verdict: 65% is against verdicts, and the roughly-half figure is\n\nagainst decisions. Reporting only the friendlier of the two would have been\n\nthe easier thing to do.\n\nMy first reaction was that something was broken. A 65% disagreement rate looks\n\nlike a defect rate.\n\nIt is not. A reviewer that ratifies everything is indistinguishable from having\n\nno reviewer at all, so the rate is a health metric — and the direction that\n\nshould worry you is the one approaching zero.\n\nI categorised every rejection reason expecting a spread. Instead:\n\n| What the reviewer objected to | Share of rejections |\n|---|---|\n| Severity escalated beyond what the CVSS evidence supports | 50% |\n| Remediation text naming no specific version | 32% |\n| Proposed SLA conflicting with the CISA KEV due date | 20% |\n| Prompt-injection text inside the scanner's own comment field | 12% |\n\nA rejection can cite more than one of these, so the shares total more than\n\n100%. The last row was not a category I went looking for.\n\nHalf of all rejections say the same thing. Verbatim, from the record:\n\n\"The severity is escalated to critical without evidence supporting such a\n\njump from the CVSS base of 7.8, and the remediation is vague.\"\n\n\"The severity is rated as critical despite the scanner's CVSS being 5.4 and\n\nthe NVD description indicating a high (7.8) severity, creating a mismatch\n\nbetween evidence and proposal.\"\n\nThe triage model has a consistent bias toward inflating severity past its own\n\ncited evidence. The split confirms it: rejected proposals skew critical (24\n\ncritical to 24 high — 55% critical), ratified ones lean the other way (22 to\n\n32 — 41% critical).\n\nThis is the part I want to be careful about, because it is easy to overclaim.\n\nI cannot prove a same-family reviewer would have missed it — that experiment is\n\none I did not run. What I can say is narrower and still worth something: the\n\ncross-family reviewer *did* catch it, it caught it systematically rather than\n\noccasionally, and it wrote down its reasoning every time in a form I could count.\n\nI did not find this bias by reading outputs. The architecture found it and\n\nfiled it.\n\nThe system runs itself on a schedule. Cloud Scheduler publishes a tick, Pub/Sub\n\nfans it to two workers, each executing as its own service account.\n\nAt 09:01 UTC on August 28th, unattended, both workers woke on cycle 30693 and\n\ndid nothing. No ticket, no nudge, no escalation.\n\nBeing precise about why, because the honest version is less tidy than the one\n\nI first wrote: the exception agent swept its acceptances and correctly found\n\nnone to act on. The chase agent had an empty set of SLA clocks to walk, so its\n\nrestraint that morning was trivial rather than considered. When there is\n\nsomething to weigh it does log the weighing — cycle 9003 logged `wait=9`\n\n, nine\n\nfindings evaluated and nine deliberately left alone because their deadlines\n\nhad not arrived.\n\nI could have dressed the first version up and left it. The point survives the\n\ncorrection and is better for it: **on the mornings there is something to\nweigh, the autonomous loop's answer is \"not yet,\" and it says so rather than\nmanufacturing activity.** A system that invents work to look busy is worse\n\nThe duplicate-delivery guard I did have to go and provoke, because production\n\nhad not obliged. Publishing the same tick a second time gets this — one line\n\nper worker, both from the same cycle:\n\n```\ntick_already_ran   cycle=9004   rz-worker-chase\ntick_already_ran   cycle=9004   rz-worker-exception\n```\n\nPub/Sub delivers at least once, so a redelivered tick is a real possibility\n\nrather than a hypothetical. The second copy is recognised and does nothing. Every authoritative state-changing\n\ntool takes a key derived from the finding, the action, and the cycle, so a\n\nresumed agent cannot open a second ticket or send a second nudge.\n\nThe obvious argument for that design is \"don't duplicate work.\" The stronger\n\none only appeared under test: **models are not deterministic.** Running the same\n\ncycle twice produces *different decisions* for the same finding — one finding\n\nwas ratified on the first run and rejected twice on the second. Without the\n\nguard, the second run would not merely have duplicated work. It would have\n\nsilently overwritten a decision a human may already have acted on, with a\n\ncontradictory one.\n\nEach agent has its own service account. What that buys is worth stating\n\nprecisely, because the obvious version of the claim is not true: Firestore\n\nNative has no collection-scoped IAM permission, and Security Rules are bypassed\n\nentirely by a server SDK authenticating as a service account. Per-collection\n\nseparation cannot be enforced by IAM here, and claiming it would be claiming a\n\ncontrol that does not exist.\n\nWhat *is* enforceable is a per-database boundary, and that is where the\n\nreporting agent's limit lives: read-only on the operational database, write\n\naccess conditioned to a separate reports database. It is structurally\n\nincapable of writing a ticket.\n\nAsserting that in a README is free. So the check performs the forbidden action:\n\na Cloud Run job whose identity *is* the reporting agent attempts the write and\n\nreports what happened.\n\n```\nexpect DENIED   got DENIED (PermissionDenied)   write a ticket        (as rz-reporting)\nexpect ALLOWED  got ALLOWED                     write a report        (as rz-reporting)\nexpect DENIED   got DENIED (PermissionError)    read the tracker token (as rz-exception)\nexpect ALLOWED  got ALLOWED                     read a finding        (as rz-exception)\n```\n\nHalf of those expect ALLOWED on purpose. An identity that can do nothing proves\n\nonly that it is broken; the control is that the boundary falls in a *specific*\n\nplace.\n\nThe first version of that check was wrong in an instructive way. It used\n\n`--impersonate-service-account`\n\nfrom my laptop, and both identities returned\n\n`PERMISSION_DENIED`\n\n. It looked like proof. It was proof that *I* cannot\n\nimpersonate anyone — which says nothing whatsoever about the secret. Running\n\n**as** the identity rather than borrowing it is the whole difference.\n\nLate in the build I had the control suite audited. One check — the one\n\nverifying that the reviewer catches a prompt injection with the input guardrail\n\nswitched off — had not called a model since the previous day.\n\nIt ran a cycle against a fixed probe number. The cycle skips any finding whose\n\nidempotency record already exists. So after its first successful run it\n\nreturned immediately, and the check re-read the decision that first run had\n\nwritten, found the expected text, and reported PASS. In 0.3 seconds. For 37.8\n\nhours.\n\nThe evidence was in my own timings table the entire time: three checks\n\nmeasured 17.2 seconds, while one triage-and-review round costs 15 to 22 seconds\n\non its own. The number was too good and I read it as good news.\n\nIt now clears its prior record and accepts a verdict only if it was written\n\nduring that invocation. It takes 31 seconds, because it does the work.\n\nThe lesson is not \"write better tests.\" It is that **cheap-and-green is the most\ncomfortable way for a verification suite to fail**, and the suite you trust most\n\nTwo gaps that every reviewer found within an hour, so I would rather name them:\n\nThere is **no cross-asset finding normalization**: real scanner output repeats one CVE across\n\nhundreds of hosts, and the synthetic corpus here has 400 findings with 400\n\ndistinct CVEs, so the problem never arises. That is the next thing.\n\nAnd the closure loop is newer than the rest of the system, which shows. A\n\nrescan closes what it confirms fixed and refuses to close what it could not\n\nexamine — but the scan file's coverage manifest is taken on trust, and a\n\nregression reopening a finding resets that ticket's episode counters rather\n\nthan preserving the whole trail. Both are known and neither is exercised by\n\nthe committed corpus, which is exactly the kind of thing worth saying out loud\n\nabout a feature that is four days old.\n\nADK 2.8 on Vertex AI. Gemini 3.5 Flash for reasoning, Gemma for review,\n\nFirestore for state, Agent Engine Memory Bank across sessions, Pub/Sub with a\n\ndead-letter queue proven by poisoning it, Model Armor on untrusted ingress,\n\nCloud Run, Cloud Trace, Secret Manager, Terraform. Seven service accounts, of\n\nwhich the two scheduled workers genuinely execute as their own identity; the\n\nreasoning graph currently runs under a single client, and the remaining\n\nboundaries are proven by running the checks *as* those identities rather than\n\nassumed at runtime. 623 tests.\n\nI built Remediation Zero for the **All Things Agentic Hackathon**, in the\n\nFortified Enterprise Fleet track, over five days in August 2026. Everything in\n\nthe repository was written during the submission period. The corpus is entirely\n\nsynthetic — reserved ranges only, `.invalid`\n\nhostnames and addresses drawn\n\nfrom `192.0.2.0/24`\n\n, `198.51.100.0/24`\n\nand `203.0.113.0/24`\n\n— with real CVE\n\nidentifiers.\n\nCode, architecture diagram, and a runbook with every step timed:\n\n**github.com/Ayliea/remediation-zero**", "url": "https://wpnews.pro/news/i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have", "canonical_source": "https://dev.to/daviyon_daniels/i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have-54md", "published_at": "2026-08-31 23:35:10+00:00", "updated_at": "2026-08-31 23:52:57.230861+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-safety", "developer-tools"], "entities": ["Gemini 3.5 Flash", "Gemma", "CISA KEV", "Cloud Scheduler", "Pub/Sub"], "alternates": {"html": "https://wpnews.pro/news/i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have", "markdown": "https://wpnews.pro/news/i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have.md", "text": "https://wpnews.pro/news/i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have.txt", "jsonld": "https://wpnews.pro/news/i-asked-one-model-to-review-another-and-it-found-a-bias-i-would-not-have.jsonld"}}