“Old plus no activity equals close” is how you delete a customer’s real, half-finished feature request. It feels like cleanup. What it actually does is throw away the partial fixes, the duplicates that got fixed somewhere else, and the forks where the real work quietly moved to another ticket. The date tells you nothing about whether the work is done. Only the content does.

I run an 880-ticket JIRA backlog with five people. When a queue gets that deep, the instinct is to write a classifier: title plus age plus a few field values, sort into keep and close, walk away. I built that. Then I watched it score a bug high and tee it up as fresh high-value work to assign and spec, when that exact bug class had already been fixed five days earlier under a different ticket. I rewrote the whole thing around one idea: backlog triage is verification work, not classification work.

The trap, concretely

A stale ticket is not a dead ticket. It is a ticket whose surface stopped moving while the underlying problem may or may not have moved on without it. Triaging by title and date treats the surface as the truth. What that surface hides: real customer asks that nobody specced out, sitting at the bottom because nobody touched the comment thread; partial fixes, where half the work shipped here and the rest got split into another ticket so it reads abandoned but is really just half-done; and forks, where the actual fix landed under a different ticket entirely and this one is now a silent duplicate. The date tells you none of that.

If your triage step never opens the commit log and never loads the page, it cannot tell any of those apart from genuine dead weight. It will close all four with the same confidence.

Common-sense pre-checks that catch the obvious mistakes

Before the expensive per-ticket work, a handful of cheap checks kill the categories where an automated pass (AI or human-in-a-hurry) reliably guesses wrong on old tickets. These are the ones that bit me:

  • Escalation requires recency. A ticket flagged “escalate to customer” that has gone more than 60 days with no re-ping is not an active escalation. The flag is stale, not the customer.
  • “Support forwarded a customer email” is not “customer waiting.” A forwarded email is context attached to a feature request. Treating every forward as a live customer-waiting signal floods the wrong queue.
  • A NEEDS-REPRO bug older than a year is suspect, not automatically closeable. It might be a real bug nobody could reproduce, or it might already be fixed. You have to check.
  • Story Points of 0.1 is a TBD placeholder, not an estimate. If you feed it into a RICE score as real effort, you inflate the score roughly 10x, and a placeholder floats to the top of the queue looking urgent.
  • Single-word empty buckets like “Marketing” or “PPC” are navigation artifacts, not work. Those close as stale.
  • “Old plus no signal” still is not auto-close for ops or architecture items. Age is not priority. A quiet infra ticket is not an abandoned one.

Each of these is a place where the date or a single field lies, and a fast classifier believes it.

The cross-reference rule that prevents the worst miss

Here is the one that changed how I think about the whole job. Before recommending any action on a bug, cross-reference two sources: the commit log and the recently closed tickets.

git log --since="6 months ago" --grep=<area>

Grep the area, not the ticket number, because the fix that obsoleted this ticket almost certainly cites a different ticket number. You are looking for the bug class, not the issue key.

The case that justified the rule: a missing-username bug sat in the queue with a high score, recommended for assign-owner-spec, looking like high-value untouched work. The same bug class had been fixed five days earlier under a different ticket. That fix included a data repair across thousands of affected rows. The right call was close-as-superseded with a link to the keeper. Instead the triage was about to assign someone to spec work that was already done, for one reason: it never grepped the commit log. It trusted the score, and the score was computed from a surface that did not know the work had landed.

Five days. A git log --grep would have surfaced it in seconds. That is the entire argument for verification over classification, in one example: the cheapest check available was a grep nobody ran.

The three steps

Once the cheap checks are done, every remaining ticket gets the same three-step pass. None of the steps are optional, and the order matters.

  1. Read fully. Every comment in chronological order, every field, every linked ticket. Not the title. Not the latest comment. The whole thread, because the fork or the split usually shows up as a mid-thread comment that the summary never captured.
  2. Verify in the browser. Reproduce the bug, or confirm the feature actually shipped, or click the Links URL and see what renders. This is the step the date can never replace. You are checking current reality against what the ticket claims.
  3. Categorize. Only after reading and verifying does the ticket get a label: NO CODE, DUPLICATE, SUPERSEDED, SPLIT, TRULY STALE, REOPEN, or NEEDS DECISION. Seven outcomes, and “TRULY STALE” is only one of them. The whole point is that most stale-looking tickets are not in that bucket.

Misleading fields are worse than empty ones

A blank field tells the next reader nothing, which is honest. A populated field that no longer matches reality tells them something false, and they have no reason to doubt it.

The concrete version: a bug ticket with Fix Version set to a release that shipped eight months ago. The bug is still open. The classifier reads that old fix version and bumps priority because it signals this was important enough to plan. What actually happened is someone set that version speculatively, never shipped the fix, and nobody cleared the field. Now the triage pass sorts on a confident-looking field that is describing a plan that never executed. A blank Fix Version would have been far less damaging.

So every populated field gets audited against current reality, not just read. The triage pass treats a stale-but-confident field as a bug to correct, not as data to sort on.

The model case

The clearest example of why verification changes the answer: a ticket that read “add a button to the dashboard.” Old. No activity. By the date-plus-title rule, that is a clean STALE close.

Verify it instead. Load the page, follow the thread. The ask had already been satisfied, but not the way the title said. It shipped as an iPhone-only help article, not a dashboard button. The customer’s underlying need was partially addressed in a place nobody updated the ticket to point at.

Without verification: STALE, close it, and now there is no record that the request was ever real or partially met. With verification: NEEDS DECISION, because someone has to decide whether the iPhone-only help article is actually enough or whether the dashboard button is still owed. A different answer, and the correct one. The only thing that separated the wrong close from the right decision was loading the page.