Skip to content

The lifecycle of a case

A case is always in exactly one of five states, and every move between them is either a person’s decision or automation’s report — never both, and never silent. Each move is written to the audit log as “was → became”, with the reason when one was given.

StateMeaningWho puts a case there
draftWritten, not yet in play. Every case a person creates starts here.the person creating it
in_reviewProposed by a machine and approved from the review queue without trusting the source — waiting for a person to confirm it.the queue, on approval
activeIn play: results attach, stability is counted.a person; automation, for a draft it created; the queue, for a discovery from a trusted source
detachedAn automated case whose test is no longer in the code. Kept, with its history.automation (a token)
deprecatedRetired. Taken out of play on purpose, with a reason if one was given. Nothing is deleted.a person only

state is the field; status is an older, three-word view of the same thing (draft, active, archived) that every response still carries for clients written before the five states existed. Read state — it can say more.

The table is enforced on every write, and it tells a session (a person on the dashboard) and a bearer token (automation) apart:

From ↓ · to →draftin_reviewactivedetacheddeprecated
draft=tokenperson, tokenperson
in_review=personperson
activetoken=tokenperson
detachedperson, token=person
deprecatedperson=

Two things the table says on purpose. Only a person retires a case or brings one back: a script that could retire tests would retire the ones that inconvenience it. And a case at in_review waits for a person — approving a proposal put it there, confirming it is a second, separate yes.

A move the table forbids answers 400, and the message names what is allowed from there:

an agent cannot move a case from active to deprecated — allowed from active: in_review, active, detached

Moving a case to the state it is already in is allowed and does nothing — no audit entry, no change to stateChangedAt.

Open a case and the menu offers exactly the moves the table allows a person from where the case stands — and nothing else:

The case isYou see
draftActivate, Retire
in_reviewConfirm, Retire
activeRetire
detachedReturn to work, Retire
deprecatedReturn to work

Each opens the same was → becomes dialog as “Move to…”, with an optional Why. What you write becomes the case’s stateReason and shows beside the state badge, with the date of the move, for as long as the case stays in that state. A blank Why sends nothing — an absent reason is not an empty line. If the server refuses the move, its words appear inside the dialog and the case is untouched.

A retired case keeps its suite, its position, its keys and its results, and “Return to work” gives it back exactly as it was. The reason of the retirement leaves the page then, but not the history: every state_change entry in the audit log carries the reason it was made with.

The list shows cases of every state by default, each with its badge; state=deprecated narrows it to the retired ones, and state=active to the ones in play.

GET /v1/test-cases/{id} answers with allowedStates — the states the caller may move the case to from here, per the table above and per who the caller is. The list endpoint does not carry it.

{
"id": "6f1c…f1",
"title": "Checkout keeps the total right @smoke",
"state": "active",
"stateChangedAt": "2026-09-14T10:12:41.000Z",
"allowedStates": ["deprecated"]
}

A move is a PATCH with state and, optionally, stateReason:

PATCH /v1/test-cases/6f1c…f1
{ "state": "deprecated", "stateReason": "flaky since the checkout redesign; covered by the new flow" }

stateReason must not be empty when sent. The record keeps only the last reason — a later move without one clears it from the case — while the audit log keeps every one. A status in the same body is accepted from older clients and mapped to a state; state wins if both are present.