---
title: "When Your Workflow Silently Wipes State"
canonical: https://dxdev.com/blog/2026-09-26_silent-state-mutation-hides-bugs/
datePublished: 2026-07-27
---
Five tickets had no Developer field, and every one of them looked fine the day it was created. That was the whole problem.

Our tracker's main project carries attribution in a custom Developer field (`customfield_10103`). The workflow has a post-function on the close transition that clears `assignee`. So on a closed ticket, assignee is always null, and Developer is the only surviving record of who did the work. Our create command sets both. An agent that hand-rolls the create call sets one, and it picks assignee, because assignee is the field you would naturally reach for.

## Five rows with dev=None

I found it with a listing of the day's tickets, one line each: key, date, `asg=`, `dev=`, summary. Every closed ticket showed `asg=None`, including the healthy ones, so that column told me nothing. The column that mattered was `dev=`. Five rows had `dev=None`, and all five were tickets that scratchpad scripts had created. Those scripts imported our tracker client module and POSTed to `/rest/api/2/issue` with a hand-built field dict, skipping the create command entirely.

The failure never surfaced as an error. The create succeeded and the ticket was visible and assigned. Days later it was closed, the workflow cleared assignee, and the ticket ended up attributed to nobody.

## The first fix, and how wrong the count was

My first move was a hand patch. I wrote a short script with a hardcoded list of ten keys, ran a PUT setting `customfield_10103` to my own username on each, and read the value back after every write. All ten came back correct, and I felt done.

The ten keys were the ones I could see from that day's session. That was a sample, and I treated it as the population. The patch also had a hardcoded name in it. That was fine for ten tickets I had created myself. It would have been wrong for anyone else's.

When I queried every live ticket in the project for an empty Developer, the count was 190, not ten. Many of those belonged to other people on the team. Sweeping them all onto my name would have made the field look complete while making it false. When several of us ship work through the same board, a false attribution is worse than a blank one, because a blank at least shows that someone needs to look.

## Recovering the real owner

The information had not been destroyed. Every issue has a changelog, and the close transition's assignee clear is recorded there as a field change with a `from` value. So for each of the 190, I read the changelog and took the last non-null assignee before the close. I wrote that person into Developer. Where the changelog had no assignee at all, the ticket went on a short list for a human to decide.

The rule, in order:

1. If Developer is already set, leave it.
2. Otherwise, use the last assignee value recorded before the clearing change.
3. If neither exists, list it for a person to decide.

I left 331 archived tickets from 2019 alone. They have the same hole, but nobody reads them, and rewriting five-year-old history is a change nobody had asked for. I decided that with my partner, and it went in the ticket that tracked this work as a recorded decision.

## The guard at the create path

Backfilling only fixes the past. The bug came from a path that anyone can take again, so I put the check at the chokepoint the bypass actually used: the client module's create path.

```python
CF_DEVELOPER = 'customfield_10103'

def _assert_item_create_attribution(fields):
    project = (fields.get('project') or {})
    if project.get('key') != 'ITEM':
        return
    if fields.get(CF_DEVELOPER):
        return
    raise ValueError(
        "refusing to create an ITEM ticket with no Developer (customfield_10103). "
        "The workflow clears `assignee` on close, so an assignee-only "
        "create silently ends up unattributed. Run the create command instead."
    )
```

It raises `ValueError` rather than the client's usual API error class, because it fires before any network call. The failure has to come from the caller's own code, at the moment they write the wrong thing. The message names the field, explains the mechanism, and says what to run instead. An agent that hits it can fix the call without asking anyone.

I wrote tests for it in a scratch file. Every case returns or raises before any request goes out, so they need no credentials.

## A second net for scripts that skip the client

A guard inside the client only covers callers that go through the client. A script that builds its own `requests.post` still passes straight by. So I added a pre-release check that looks for tickets created outside our client and flags them before a release goes out. That check is a detective control. It will catch the bypass late, but it will catch it.

## What the quiet failure taught me

The alarming thing was how quiet it was. A write succeeded, the record looked complete, and a later automatic step erased part of it. Nothing errored anywhere. If a workflow has post-functions, list what each one clears, and check that every create path sets the surviving fields, not only the visible ones.

The count is the other half. I saw five, patched ten, and had 190. Whatever number you can see from where you are standing is a floor, so query the whole table before you decide how big the fix is.
