# Status Workflow

<!-- status-vocab:source-of-truth
The status names and transitions below are documentation of one thing:
`scripts/status_vocab.py`. That module is what the scripts and hooks actually
execute; this file is what people read. Change the module first.

Nothing generates these tables — they are checked instead. `test_enforcement.py`
("status vocabulary — one source (FLY-1272)") asserts that every status in the
module appears here, that the terminal list matches exactly, and that every
transition documented here is one the code permits. Editing either side alone
fails the suite.

Server-side, the Relay Authority state machine (RLA-1 / FLY-1253) becomes
authoritative once it lands; the module is its client mirror.
-->

## Lifecycle Diagram

```
BACKLOG ──→ READY ──→ IMPLEMENTING ──→ REVIEW ──→ TESTING ──→ COMPLETE
                           ↕               ↕          ↕
                        BLOCKED ◄──────────┘──────────┘
```

## State Meanings

| FlyDocs State | Intent                                   | Assignment Required |
| ------------- | ---------------------------------------- | ------------------- |
| TRIAGE        | Provider inbox — arrives here, moves out | No                  |
| BACKLOG       | Raw capture, not yet refined             | No                  |
| READY         | Refined spec, ready to pick up           | No                  |
| IMPLEMENTING  | Active work in progress                  | **Yes**             |
| BLOCKED       | Cannot proceed, waiting on resolution    | Yes                 |
| REVIEW        | Code complete, awaiting quality review   | Yes                 |
| TESTING       | Review passed, awaiting user acceptance  | Yes                 |
| COMPLETE      | All stages passed, work delivered        | Yes                 |
| ARCHIVED      | Filed away — revivable to BACKLOG        | No                  |
| CANCELED      | Not pursuing — revivable to BACKLOG      | No                  |
| DUPLICATE     | Superseded by another issue              | No                  |

## Valid Transitions

| From         | To           | Trigger                        |
| ------------ | ------------ | ------------------------------ |
| TRIAGE       | BACKLOG      | Accepted, not yet refined      |
| TRIAGE       | READY        | Accepted and already spec'd    |
| BACKLOG      | READY        | Refine stage completes         |
| READY        | IMPLEMENTING | Activate stage completes       |
| READY        | BACKLOG      | De-refined — spec withdrawn    |
| IMPLEMENTING | REVIEW       | Implement stage completes      |
| IMPLEMENTING | BLOCKED      | Blocker identified             |
| BLOCKED      | IMPLEMENTING | Blocker resolved               |
| REVIEW       | TESTING      | Code review passes             |
| REVIEW       | IMPLEMENTING | Code review finds issues       |
| REVIEW       | COMPLETE     | Review passes, QE skipped      |
| TESTING      | COMPLETE     | QE approves, PM closes         |
| TESTING      | IMPLEMENTING | QE finds issues                |
| ARCHIVED     | BACKLOG      | Revived — re-refine from there |
| CANCELED     | BACKLOG      | Revived — re-refine from there |

CANCELED is reachable from every open state — abandoning work needs no route.
ARCHIVED is reachable from every state before REVIEW: work that far along is
completed or canceled, not filed away. DUPLICATE is reachable from the
pre-work states (TRIAGE, BACKLOG, READY), where duplicate detection actually
happens.

TRIAGE is an entry state only: work moves out of it, never into it, so no
transition targets it and `issue.transition <ref> TRIAGE` is rejected. The
relay treats it as an alias that reads as BACKLOG, so `TRIAGE → BACKLOG` is a
legal no-op rather than an error.

### The client map is a hint, not a gate (FLY-1265)

`issue_transition` and `issues.py transition` — one client path, whichever
surface you call it from — warn on an edge that is not in the table above and
**proceed anyway**. The relay's lifecycle service owns rejection: it
reads the live current state from the provider, applies workspace policy, and
answers `409 TRANSITION_ILLEGAL` with `currentStatus`, `currentProviderStatus`,
`requestedStatus` and `allowedTargets`.

The reason is which side knows more. The client judged against a status cached
in a local session file; the relay reads the provider. Every disagreement was
therefore most likely the client being stale — and the client was the side that
refused, with no way past it. The warning keeps the fast half (a typo is named
in milliseconds) and drops the false authority.

Two other server-side rejections to expect on a transition, both 409:
`ASSIGNMENT_REQUIRED` (assign before IMPLEMENTING) and `ACCEPTANCE_INCOMPLETE`
(body carries `checked` / `total` / `deferred` / `unmet[]` — tick or defer them
first, see below). A workspace may allow `policyOverride` (boolean) to bypass a
policy gate; where it does not, that request answers `403
POLICY_OVERRIDE_DENIED`.

## Terminal States

Nothing leaves these. There is no reopen edge — a completed or duplicated
issue that turns out to need more work gets a new issue that references it.

| State     | When                        |
| --------- | --------------------------- |
| COMPLETE  | Work delivered and verified |
| DUPLICATE | See referenced issue        |

## Closed States

Closed means off the board. Terminal is narrower than closed: ARCHIVED and
CANCELED are closed but revivable, and a client that treats them as terminal
refuses a revival the relay would have accepted.

| State     | When                        | Revives to |
| --------- | --------------------------- | ---------- |
| COMPLETE  | Work delivered and verified | —          |
| ARCHIVED  | Deferred — may return later | BACKLOG    |
| CANCELED  | Not pursuing                | BACKLOG    |
| DUPLICATE | See referenced issue        | —          |

## Status Changes Outside `transition`

`issues.py update --state STATUS` is a real status change, not a field write —
it runs the same server-side lifecycle pipeline as `transition`. Three things
follow:

- **Canonical statuses only.** Provider-native names are rejected with `400
INVALID_STATUS` and a `suggestedStatus`; nothing is translated silently.
- **`--comment` becomes the audit comment.** It is not a second, separate note.
  Omit it and the relay posts its own fallback text, so the audit trail exists
  either way — but it says nothing about why.
- **Prefer `transition` for workflow moves.** It carries the comment templates
  and the session-state bookkeeping described below. `update --state` exists
  for the cases that are genuinely a field update carrying a status alongside
  it.

### Session-State Bookkeeping

`transition` mirrors the move into session state — `status` and `status-ref`,
the pair the hooks read — but only for the issue the session is actually
working. Which issue that is comes from `focus.md`, not from the transition:

- **On the focused issue**, the move is recorded as you would expect.
- **IMPLEMENTING on any other issue** claims the session. That is what
  activation means, so `focus.md` moves with the pair and the previous issue's
  acceptance snapshot is dropped rather than inherited.
- **Any other open status on a non-focused issue** — READY, BLOCKED, REVIEW,
  TESTING, BACKLOG — is recorded on the issue and nowhere else. The command
  prints one line saying so. A stale pair left over from that issue is cleared
  instead of updated: a mirror nobody can trust reads better as unknown, which
  every hook degrades through, than as wrong, which they believe.
- **With no focused issue** — no `focus.md`, or one holding no valid ref —
  there is no subject to protect, so the write stands and the pair records the
  move as usual. A workspace that only ever transitions still gets a mirror.
- **A closed status** clears what it owns — the pair when `status-ref` names
  the issue *or names nobody*, `focus.md` and the snapshot when `focus.md`
  does. An absent owner is not a foreign one; there is nothing there to
  protect.

So a review sweep across other issues no longer costs the session its own
workflow state (FLY-1471), and closing one of those issues no longer deletes
it (FLY-1407).

When the pair and the focus do disagree anyway — or when the focused issue has
no recorded status at all — each hook stands down rather than judging one issue
by another's status, and says so on its own channel: the Stop gate as a message
to you at the end of each turn, the prompt context as a line in the agent's
per-turn context, the edit gate once per drift rather than on every edit. The
repair in all three cases is a transition on the focused issue.

### Reconciliation

A transition can land while its audit comment does not (the provider took the
state change and then failed the comment). The relay says so with
`reconciliation: transitioned_comment_pending` on an otherwise successful
response, and the client fires one best-effort repair. The operation record is
durable, so a failed repair leaves the comment pending and repairable on the
next touch rather than lost — nothing needs re-running by hand.

## Provider Mapping

FlyDocs states are provider-agnostic. The workflow scripts map them to provider-specific
state names via `statusMapping` in `.flydocs/config.json`. The workflow never references
provider-specific names directly.

## Deferred Acceptance Criteria

The Stop gate blocks an issue in REVIEW whose acceptance criteria are not all
met. Sometimes a criterion is genuinely only partly met, with the remainder
tracked on another issue. Mark those explicitly:

```markdown
- [ ] (deferred: FLY-1234) Criterion text, unchanged
```

Write the marker with the acceptance surface rather than by hand — it applies
exactly this shape. `issue_acceptance_update` with
`{ criterionId: 5, status: "deferred", deferredTo: "FLY-1234" }`, or the script:

```bash
flydocs run issue.acceptance FLY-100 --defer 5:FLY-1234
```

The gate counts a marked criterion as accounted for rather than unmet. Rules:

- **The issue reference is required.** A bare `(deferred)` still blocks — a
  deferral with no destination is just an unfinished criterion with better
  manners.
- **The box stays unchecked.** The board must never claim work that did not
  happen. Deferral changes what the gate does, not what the issue says.
- **Say why in the review comment**, naming what was delivered and what moved.
  The marker records the destination; the comment records the reasoning.
- **A plain unchecked criterion still blocks.** This is not an escape hatch for
  incomplete work.

Why this exists: a gate that can only be satisfied by ticking an untrue box, or
by moving a finished issue out of REVIEW, teaches people to lie to it. Both
workarounds corrupt exactly the state the gate protects. (FLY-1087)
