Skip to main content
日本語

CLOSED Is Not MERGED

Tomohiro Iida · Published August 25, 2026 · Updated August 25, 2026

When AI agents do the implementation work, progress tends to be read off the pull request list. Closed pull requests accumulate and open issues appear to shrink. But a pull request being CLOSED on GitHub does not mean the change reached main. A pull request that was merged and one that was abandoned both appear in the list as a closed pull request. Measured in the Netsujo repository on 2026-08-25, 15 of the 57 closed pull requests numbered #1423 through #1550 had state CLOSED with a null mergedAt, meaning nothing from those pull requests themselves reached main. The population is stated as a fixed number range rather than "the most recent 60" because a sliding window does not reproduce: running the same query twice on the same day shifted the window as new merges landed, and the set changed.

Key takeaways

  • A CLOSED pull request is not evidence that its change reached main.
  • Separate five layers: PR state, mergedAt and the merge commit, main ancestry, patch or semantic inclusion, and the presence of the required behaviour.
  • When a different pull request delivered the change, do not rewrite the original as merged. Record it as a separate state and demand separate verified evidence.
  • A completion report that skips layers is not evidence. State which layer was actually confirmed.
  • Close, superseded, consolidation into another pull request, and manual porting are four different outcomes that a single closed bit cannot distinguish.

Incident card

FieldValue
IncidentPull requests left in CLOSED state could be treated as fixed inside AI agent operations.
SymptomThe issue looks closed in the pull request list while neither main nor production contains the change.
False assumptionIf a pull request is closed, its contents were taken into main.
Root causeThe pull request lifecycle (open or closed) and the content of main (whether the change exists) were treated as one state.
Immediate fixFetch state and mergedAt separately, and treat a null mergedAt as proof that nothing arrived from that pull request.
System fixDefine PR state, merge commit, main ancestry, inclusion, and required behaviour as a five-layer evidence ladder with separate evidence per layer.
Remaining riskSemantic inclusion through another pull request cannot be decided automatically and needs verified evidence supplied separately.

One word, five states

The sentence "that defect is fixed" does not say which of the following it means: a fix was written and a pull request opened, the pull request was closed, the pull request was merged, another pull request absorbed the same content, the change exists on current main, or the behaviour is actually corrected in production. Read as prose those six look like one thing. In GitHub and Git they succeed or fail independently.

Pull requeststatemergedAtMeaning
#1173CLOSEDnullNothing reached main from this pull request.
#1207CLOSEDnullSame as above.
#1425CLOSEDnullTitle states superseded by #1437.
#1437MERGED2026-08-24This is what actually reached main.

All four rows were measured in the repository on 2026-08-25 with gh pr view --json state,mergedAt,title. Looking only at #1425 it reads as handled because it is closed, yet not one line reached main from it. The change was delivered by #1437. Conversely #1173 and #1207 were simply closed, and no replacement by another pull request is automatically guaranteed for them.

The false assumption: a closed pull request was taken in

The first outcome puts nothing on main. The other three put something on main, but what landed is not necessarily identical to the original pull request. The scope can be narrower, the conditions can differ, or the tests can have been dropped. A single closed bit cannot separate these four.

Root cause: conflating the pull request lifecycle with the content of main

The cause is the state model, not the judgement of the AI. A pull request represents the lifecycle of a container of work. Main represents the content of the code. Pushing both into a single word called complete makes it impossible for anyone to say which sense of complete is meant. AI agents surface this structural problem faster, because conversation is natural language, reports are summarised, and often only the pull request number is carried forward.

Immediate fix: read state and mergedAt separately

gh pr view <N> --json state,mergedAt,title — state CLOSED with mergedAt null means PR_NOT_DELIVERED; state MERGED with a non-null mergedAt allows the next layer.

Reading state alone does not decide anything, because a MERGED pull request is also a closed pull request in GitHub terms. The values that decide are mergedAt and the merge commit.

System fix: define the evidence ladder as five layers

In the Netsujo repository this decision lives in the Delivery Truth Gate at scripts/agent-os/delivery-truth.mjs. The layers rise in the order below, and a higher layer is never claimed by skipping a lower one.

LayerWhat it establishesHow it is fetched
1 PR stateOpen, closed, or mergedgh pr view --json state
2 merge commitWhether mergedAt and a merge commit existgh pr view --json mergedAt,mergeCommit
3 main ancestryWhether that commit is reachable from current maingit merge-base --is-ancestor or the compare API
4 patch inclusionWhether the intended diff or an equivalent exists on mainRe-fetched diff plus verified evidence
5 behaviorWhether the required behaviour actually existsTests, execution, or production observation

Do not rewrite superseded as merged

The last code is the important one. Even when the intent of a closed pull request is satisfied by another pull request, prDelivered stays false and changeSource becomes SEMANTIC_EQUIVALENT. Equivalence never holds automatically: it passes only when equivalentEvidenceVerified is supplied as separate verified evidence. The moment this is summarised as effectively merged, which diff from which pull request actually landed becomes untraceable.

Merged does not mean present on current main

The third layer is not automatic either. A merge into the base of a stacked pull request did not target main. A merge that was later reverted still leaves a merge commit in history while the content is gone. The post-merge check therefore confirms separately that the merge commit is reachable from current main. The same idea appears in rollback checking: scripts/deploy-policy/verify-rollback-target.mjs runs git merge-base --is-ancestor against the rollback target and fails when the commit is not in the history of origin/main.

A reusable confirmation procedure

Wording that is allowedLayer it satisfies
A pull request was opened0, not delivered
The pull request was closed1
It was merged2
It is on current main3
The intended change is included4
It is fixed5

Evidence

StatementClassSource
15 of the 57 closed pull requests numbered #1423 through #1550 had state CLOSED and a null mergedAt on 2026-08-25.OBSERVEDgh pr list --state closed --limit 300 --json number,state,mergedAt, filtered to that number range
A sliding-window count (--limit 60) does not reproduce, even within the same day.OBSERVEDTwo runs on 2026-08-25 returned different sets
#1173, #1207, #1423, and #1425 are all CLOSED with a null mergedAt.OBSERVEDgh pr view <N> --json state,mergedAt, measured 2026-08-25
#1425 is titled superseded by #1437, and #1437 was merged on 2026-08-24.OBSERVEDgh pr view 1425 and 1437 --json title,state,mergedAt
Closed and unmerged is treated as PR_NOT_DELIVERED with prDelivered false.IMPLEMENTEDscripts/agent-os/delivery-truth.mjs
An equivalent change on main is not rewritten as merged and requires separate evidence.IMPLEMENTEDequivalentEvidenceVerified in scripts/agent-os/delivery-truth.mjs
After a merge, reachability of the merge commit from current main is checked separately.IMPLEMENTEDevaluateControllerPostMergeTruth in scripts/agent-os/delivery-truth.mjs
Main ancestry is checked with git merge-base --is-ancestor and fails when absent.IMPLEMENTEDscripts/deploy-policy/verify-rollback-target.mjs
Each decision is pinned by regression tests named after the real pull requests.IMPLEMENTEDscripts/agent-os/__tests__/delivery-truth.test.mjs
Delivery Truth runs as the terminal gate of controller integration.IMPLEMENTED.github/workflows/agent-integration-controller.yml
Separating the five layers surfaces false completion reports earlier.INFERREDThe before and after counts are not measured.

Limitations

Remaining risk

Frequently asked questions

If a pull request is CLOSED, how can I tell whether it was merged?
Read mergedAt with gh pr view <N> --json state,mergedAt. A MERGED pull request is also a closed pull request internally, so state alone cannot separate them. A null mergedAt means nothing reached main from that pull request.
If mergedAt is set, can I call it fixed?
Not yet. The merge target may not have been main, and the merge may have been reverted afterwards. Confirm that the merge commit is reachable from current main, for example with git merge-base --is-ancestor, before moving to the next layer.
If another pull request contains the same fix, can the original be treated as merged?
No. Our implementation records that situation as EQUIVALENT_CHANGE_ON_MAIN and leaves prDelivered false on the original. Judging the change equivalent requires separately verified evidence.
What should I check when an AI reports that something is fixed?
Ask which layer was confirmed. Opening a pull request, confirming a merge, re-fetching the diff on current main, and executing the behaviour all mean different things. A completion report that does not name a layer is not treated as evidence.
Do all five layers have to be checked every time?
It depends on the risk of the change. We require all five for changes touching authentication, billing, data deletion, or production configuration, and operate up to layer three for small-impact changes. When the classification is unclear, we take the higher side.

Continue in this series

Previous: Trust the exact SHA, not the pull request number

Next: Do not rerun CI the moment it fails

Back to the twelve-part AI agent incident record

If pull requests, main, deployments, and production have drifted into separate states after adopting AI agents, we help define the completion layers and the evidence each one needs.

Discuss AI implementation and development operations design