Symptom: A decision merged with twelve approvals and an empty objection field. Two weeks later, someone reopens it in a thread titled “quick question about the thing we already decided.”
Diagnosis: The review instrument was configured to count approvals. It was never configured to surface objections. Twelve approvals and zero objections are not the same measurement, and reading the second as a null result is the error this article is about.
I am going to walk through this the way I would walk through an incident: pull the artifacts, name what each one actually records, find the system boundary, run one reversible experiment. No blame. The people who clicked approve are not the problem. The instrument is.
What each artifact actually records
Three artifacts get conflated in every post-decision argument. They measure different things.
- The CODEOWNERS file records who was asked to review. It does not record who read the diff.
- The PR thread records who spoke. It does not record who disagreed and stayed quiet.
- The approval count records who clicked. It does not record who examined.
If you have twelve approvals and zero written objections, you have data from the third instrument and nothing from the second. That is not a null result. It is an unmeasured variable. Treating an unmeasured variable as zero is the specific failure mode.
The mechanical fact that makes the gap possible
GitHub’s documentation is explicit about how code-owner review works. Code owners are automatically requested for review when someone opens a pull request that modifies code they own. That is the request mechanism. It is not a reading mechanism.
More importantly: when reviews from code owners are required, an approval from any one of the owners is sufficient to meet the requirement. The documentation gives the example directly — if a line reads *.js @global-owner1 @global-owner2, changes to JavaScript files can be approved by either owner, and approvals from both are not required.
So twelve approvals can mean twelve people were asked. It does not mean twelve people examined. The instrument counts the first and is silent on the second.
There is a second mechanical gap worth checking before you do anything else. If a specified user or team does not exist or has insufficient access, a code owner will not be assigned at all. A path with no matching owner falls through. The review request never fires. The approval count still looks healthy because other reviewers were requested by other rules.
Pull your CODEOWNERS file. Find the contested path. Check whether it has an owner that actually resolves. If it does not, you have found the first contributing factor, and it has nothing to do with anyone’s diligence.
“No objection” is not “no objection recorded”
The IETF has been running consensus processes on technical decisions for decades, and RFC 7282 is the document that explains what rough consensus actually means. It is informational — it describes IETF process, not software teams — but the distinction it draws is the one that matters here.
From the RFC: determining consensus, coming to consensus, and having consensus are different things. A chair who asks “Is everyone OK with choice A?” will get objections. A chair who asks “Can anyone not live with choice A?” is more likely to hear only from people who consider the choice impossible to engineer given the constraints.
Read that again as a statement about review prompts. “Approve or request changes” is the first question. It invites a click. “State your objection in writing by Friday or we proceed” is the second question. It invites a sentence.
Silence in a review thread is the answer to the first question. It is not the answer to the second. Any process that reads the empty objection field as consensus is reading the wrong instrument.
Two failure modes that produce the empty objection field
RFC 7282 names both of them, and both look like agreement in the artifact.
Capitulation. A minority objects, gets tired of arguing, and says “forget it, do what you want.” The RFC is blunt about what this is: they have given up trying to appease the others. That is not coming to consensus. There still exists an outstanding unaddressed objection. In the PR thread, capitulation looks like a comment that stops mid-sentence, or a review that was requested and never submitted, or a thumbs-up on a comment that says “I still have concerns but I don’t want to block.”
Horse-trading. “I object to your proposal for such-and-so reasons. You object to mine for this-and-that reason. If you stop objecting to mine, I’ll stop objecting to yours and we’ll put them both in.” The RFC’s verdict: this produces an agreement of sorts, but instead of one outstanding unaddressed issue, it produces two, ignored for the sake of expedience.
Both failure modes leave the objection field empty. Both leave the decision contested. The artifact cannot tell them apart from genuine consensus, because the artifact was never designed to.
The forensic pass
Here is the sequence I would run on a contested decision. It takes about an hour and requires no meetings.
Step one: pull the CODEOWNERS file. Find the path that was changed. Check whether it has a resolving owner. Check whether the owner is a team that still exists and still has write access. If the owner does not resolve, the review request never fired, and the approval count is measuring something else entirely.
Step two: pull the PR thread. Count distinct commenters, not approvals. If twelve approvals came from three commenters, the review was performed by three people and ratified by twelve. Those are different numbers and the difference is worth knowing.
Step three: read the comments for the two failure modes. Look for capitulation language — “I don’t want to block,” “let’s just move forward,” “I’ll raise it later.” Look for horse-trading — paired concessions where two objections cancel each other out. Both are signals that an objection existed and was not recorded as one.
Step four: check the CI history for the same period. Workflow run logs can be searched, and when you search logs, only expanded steps are included in the results. That detail matters more than it looks: a flaky gate that fails and passes on retry trains reviewers to approve without reading, because the gate’s signal has stopped meaning anything. If the contested PR sat behind a job that was retried three times before going green, you have a contributing factor that has nothing to do with the reviewers.
Step five: pull the git log for the affected path. Git log output is given in reverse chronological order by default, which makes it easy to read the sequence of changes to a file. If the contested decision was reversed and re-applied twice in six months, you are looking at a retry storm at the decision level — the same argument re-litigated because the first resolution never addressed the underlying objection.
One reversible experiment
The fix is procedural, not cultural. Change the prompt.
For the next contested decision, replace the approval request with a written objection prompt. Something like: “This decision affects [path]. If you have an objection, state it in this thread by [date]. If no objection is recorded by then, we proceed.”
Name a recipient. Set a deadline. Record the responses in the thread where they can be audited later.
The rollback is trivial. You have lost nothing but a week of false confidence, and you have gained a record that distinguishes “no objection” from “no objection recorded.” If the experiment produces no objections and no re-litigation, you have learned something real. If it produces objections, you have learned something more useful.
This is the same move the SRE literature makes about postmortems. Google’s postmortem culture chapter states it plainly: an unreviewed postmortem might as well never have existed. The document’s presence is not evidence that the process ran. The same is true of an approval. The click’s presence is not evidence that a review happened.
Why this is a system boundary, not a character flaw
The temptation, when a decision gets re-litigated, is to conclude that twelve people were lazy or that one person was cowardly. That conclusion is almost always wrong, and it is expensive because it produces a fix that does not work.
The blameless postmortem literature is relevant here, and it is worth being precise about what it does and does not claim. Google’s SRE book describes blameless postmortems as focusing on contributing causes without indicting any individual or team for bad or inappropriate behavior. Atlassian’s incident management guide describes the point as removing the fear of looking stupid, being reprimanded, or losing a job, in order to encourage honest, objective, fact-centric communication. Atlassian also notes that detractors question whether blamelessness is achievable and whether it undermines accountability. I am not going to pretend that debate is settled. What is settled is the mechanism: if surfacing an objection carries social cost, objections will not be surfaced, and the artifact will show zero.
The failure is not that twelve people were lazy. The failure is that the review instrument was configured to count approvals and never configured to surface objections. That is a configuration. Configurations can be changed.
What to do with this
Pull the artifact. Find the signal. Trace the failure to the system boundary. Run one reversible experiment with a rollback plan.
In this case the artifact is the CODEOWNERS file and the PR thread. The signal is the gap between who was asked and who spoke. The system boundary is the review prompt. The experiment is a written objection prompt with a deadline and a named recipient.
Twelve thumbs-up is not consensus. It is twelve data points from an instrument that was never calibrated to measure disagreement. Stop reading it as if it were.
FAQ
Does this mean approval-based review is broken?
No. It means approval count and objection count measure different things, and conflating them produces false confidence. Approval-based review works fine for changes where the question is “is this correct?” It works poorly for changes where the question is “does anyone have a problem with this?” Those are different questions and they need different prompts.
What if nobody actually objects?
Then you have a real null result instead of an unmeasured variable. That is strictly better information. The experiment costs a week and produces a record you can point to the next time someone asks why the decision was made.
Is RFC 7282 the right model for a software team?
No, and it does not claim to be. It is an informational document about IETF process. What transfers is the distinction between determining consensus and having it, and the two failure modes — capitulation and horse-trading — that produce the appearance of agreement without the substance. The rest of the IETF process is specific to the IETF.
How do I check whether a CODEOWNERS path is actually owned?
Open the file, find the pattern that matches the path, and check that the listed user or team exists and has write access. GitHub’s documentation notes that if a specified user or team does not exist or has insufficient access, a code owner will not be assigned. You can also hover over a file in the repository to see code ownership details from the CODEOWNERS file for the branch you are viewing.
What if the objection prompt produces objections that are not technical?
Then you have learned that the decision has a stakeholder problem, not a technical one, and you can address it as such. That is still better than discovering it two weeks after the merge.