Reopening a closed issue needs evidence the fault is newer than the close
Systemorph/MeshWeaver#4577. On 2026-09-17, between 07:4x and 07:5xZ, the log-incident bot reopened 125 closed issues in a few seconds (128 that day in total). Each carried a recurrence comment headed "Reopened — this fault is happening again.".
Measured afterwards across all 166 open issues, by the same evidence the new rule uses: for 53 of them the incident's newest occurrence predated the close it reverted — the oldest by five weeks (last seen 2026-08-10) — so nothing in the incident supported the sentence the comment printed. The other 75 had genuinely been seen after their close and were reopened correctly, if belatedly. The defect is therefore not "the bot reopens too much": it is that the bot could not tell the two apart, and said the same thing about both.
The rule that produced it
LogIncidentFiler.Reopen reopened on exactly one condition:
issue.State == GitHubIssueState.Closed
? repoClient.SetIssueState(…, GitHubIssueState.Open, …).Select(_ => true)
: Observable.Return(false)
There was no comparison of any kind against when the fault was last seen. The sentence the comment prints — this fault is happening again — was therefore never a statement about the fault. It was a statement about the ticket: "this ticket was closed, and I am commenting on it now."
That was a safe enough conflation while the only thing that could trigger a comment was a fresh report. Ingest folded a new occurrence, decided a comment was due, and commented — so "I am commenting" really did imply "something just happened". The implication was structural, and nothing in the code said so.
What removed the implication
The comment-debt clock removed it deliberately, and for a good reason. An incident whose fault has stopped still owes its issue a final count — that is the whole point of the clock, and #1836 has the case where core#3780 sat frozen at occurrence 2 of 69 because the only settlement trigger was a report that would never arrive. So the clock now pays old debt on a timer, stamped in fault time, for incidents where by construction nothing new has happened.
Each of those late comments then met a reopen rule that read "closed ⇒ reopen". Every debt the clock settled on a ticket somebody had closed in the meantime reverted that close. The two changes were individually correct and jointly wrong, which is why neither side's tests caught it: the clock's tests assert the comment lands, and the reopen test asserted a reopen on a fixture whose incident had no relationship to the close time at all.
The rule now
A close is a person's judgement. Reverting it needs positive evidence, and there is exactly one piece of evidence that qualifies: the fault was seen after the close.
issue.ClosedAt is { } closedAt && incident.LastSeen > closedAt
GitHubIssue.ClosedAt is GitHub's own closed_at, already mapped by the platform's issue client,
so this costs no extra call — the reopen path was already reading the issue.
🚨 WHY it was closed outranks WHEN — a close reason is a decision, and a timestamp cannot see one
Systemorph/MeshWeaver.Plugins#2177. The rule above is right and incomplete, and the gap is not a regression of it — it is a case it cannot express.
closed_at separates "the fault came back" from "we are late telling you about a fault you already
fixed". It cannot separate either from "a human decided this ticket is not where this fault is
tracked". And for a fault that is still firing, the occurrence genuinely does postdate a
duplicate close — so the predicate above is satisfied, the reopen proceeds, and the test added for
#4577 passes while a consolidation is undone.
Measured, end to end, on the record
One sweep consolidated 53 bot-filed issues down to 20: 35 closed with state_reason: duplicate
onto 9 roots. Two were reopened by the filer before the sweep had finished:
| ticket | closed as duplicate |
reopened by the filer | gap |
|---|---|---|---|
| MeshWeaver#4913 | 19:06:22Z | 19:20:26Z — "Reopened — this fault is happening again." | 14 min |
| MeshWeaver#4918 | 19:08:49Z | 19:19:26Z — same comment | ~11 min |
Both reopen comments are correct about the fault and wrong about the ticket. And the cost is the same
one #4577 was filed to remove, arriving from the other side: the next reader cannot tell a reverted
consolidation from a genuine regression. For any fault on a ~20-minute cadence a duplicate closure
had no half-life at all.
The input, and the trap inside it
The reason is GitHub's state_reason, which GitHubIssue did not carry. Adding it needed the
platform half first (core, MeshWeaver.GitSync), and it came with a trap worth recording because the
obvious mapping is wrong in the expensive direction:
Octokit models the field as
StringEnum<ItemStateReason>?, and itsItemStateReasonhas exactly three members —Completed,NotPlanned,Reopened. GitHub serves a fourth value,duplicate, which is the only value this rule turns on..ValuethrowsArgumentException: Value 'duplicate' is not a valid 'ItemStateReason' enum value(measured against Octokit 14.0.0), and Octokit'sIssueconstructor cannot express it either — it takesItemStateReason?. So the mapping reads the raw.StringValuethrough a total parse, and the core enum answersUnknownfor anything it has not been taught rather than faulting the whole read.
The rule
state_reason |
what it means | what happens |
|---|---|---|
duplicate |
a decision: the fault is tracked on another issue | left closed, and the comment says which decision held |
not_planned |
a decision: it will not be done | left closed, same |
completed |
a claim that it was fixed | falls through to the close TIME — a defect that returns after a fix is exactly what should notify |
Unknown |
🚨 the reason was not established | falls through to the close TIME |
The Unknown row is the load-bearing one. GitHub omits state_reason for an open issue and for
everything closed before the field existed, and a list read never asks — so most of the corpus reads
Unknown. Treating it as a decision would have silently stopped reopening anything historical, which
is a worse failure than the one being fixed and is invisible from the outside. Unknown is an absence
of evidence about the close, never a synonym for completed.
The reason is read before the time, so a duplicate close whose occurrence also predates it
prints the decision rather than the timestamp: both say "leave it closed", and only one of them is
something a reader can act on.
Control on each side, and the negative half was run
LogIncidentFilingIdempotencyTest, five cases. With the predicate reverted, exactly three fail —
duplicate, not_planned, and the stale-occurrence case that must state the decision rather than the
timestamp. The other two — completed still reopens and Unknown still reopens — pass in
BOTH runs, and that is what they are for: they do not evidence the fix, they bound it. A change that
made either of them go closed would have traded one wrong reopen for a missed regression.
What this deliberately does NOT do
The issue offered three shapes for where the recurrence evidence should go. This is the first — comment
on the closed duplicate, do not reopen — which loses nothing, because GitHub renders the
cross-reference on the survivor. It is not the third: the incident node does not learn its new home
from the close, so a human consolidating still repoints content.issueNumber by hand if recurrences
should fold onto the survivor's thread. state_reason: duplicate does not carry the survivor's
identity (GitHub's duplicate relationship is a separate API, and a human who closed with a prose
pointer set no relationship at all), so following the link needs evidence this change does not have.
What it removes is the asymmetry: a human closing a ticket as a duplicate is making a statement the
filer used to discard.
Both unprovable cases stay closed, and say why
| what GitHub reports | what happens |
|---|---|
| open | nothing to decide; comment as usual |
closed, closed_at known, newest occurrence after it |
reopened — a genuine regression |
closed, closed_at known, newest occurrence at or before it |
left closed, comment says the occurrence predates the close |
closed, no closed_at |
left closed, comment says there is no close time to compare against |
(The reason is read before any of these; see the section above. These rows are what remains once the
state_reason is completed or Unknown.)
The asymmetry is deliberate. A missed reopen stays visible: the comment still lands on the ticket, still carries the counts, and still says the fault occurred — a human reading it can reopen in one click. A wrong reopen is indistinguishable from a real regression, which is what made the wave expensive: 130 tickets that each had to be read to find out they said nothing.
The two "cannot establish" branches are different printed sentences, in the comment and in the log, because "this predates the close" and "there is no close time" are different facts and only one of them is about the fault.
What did not change
The comment still posts, in all four rows above. The occurrences are real history and belong on the
ticket — and the comment is also what settles the debt, so suppressing it on a closed issue would
leave the clock re-asking for that incident forever. LastCommentedAt and
OccurrencesAtLastComment advance exactly as before.
Pinned by LogIncidentFilingIdempotencyTest: the reopen case and the predates-the-close case are
the same test twice over, differing in one value — whether the close falls before or after the
newest occurrence.
How that set was measured
For every open issue in the repository, over the REST API: the most recent reopened event and its
actor, the last closed event before that reopen, and the Last seen: line of the newest
recurrence comment. The verdict is the new rule applied to those two timestamps.
Measured 2026-09-17 over 166 open issues: 136 were last reopened by the incident bot, 128 of them that same day. Of those 128 — 53 predate the close, 75 postdate it, and the remainder cannot be decided from the ticket alone (no close event, or no recurrence comment carrying a last-seen line). An undecidable row is reported as undecidable; it is never folded into either side.
The same read is what makes any remediation of the already-reopened tickets an evidence-based revert of the bot's action rather than a bulk close: it names, per issue, which of the two cases it is, and leaves every issue a human last touched — and every one whose fault really did recur — alone.