# Triage: the review queue
For a task-oriented walkthrough of the commands, see the user guide at
[`for-users/triage.md`](for-users/triage.md).
Unreviewed work — inbound bugs, feature requests, imported issues — can enter a
**triage inbox** before it joins the normal workflow. The inbox is a view over your
own board columns: it is not a second tracker, a second workflow, or a queue
database. Every decision is written into the issue file's `## History`, and the
inbox disappears the moment you remove the configuration.
## Enabling the inbox
The inbox is opt-in: it is the presence of a `[triage]` section that creates it, and
removing the section is what turns it off. `tix init` **writes one** — with the
`triage` and `wontfix` columns it needs, and the `wontfix = "canceled"` mapping the
decline column requires — so a new repository starts with the inbox on, and this
hand-written form is what an older repository adds:
```toml
[triage]
entry_status = "triage" # required — the column that places an issue in the inbox
declined_status = "wontfix" # required — where a decline lands; must map to `canceled`
# accepted_status = "todo" # optional — the default target for an accept
# deferred_status = "triage" # optional — defaults to entry_status
```
Every named column is validated against the active board through the same
category/transition service the rest of `tix` uses, so a typo is a load error
naming the column, not a silently empty inbox. The section is **absent** when a
project does not use triage: a legacy `tix.toml` loads and saves byte-identically,
and a project with no `[triage]` section has no inbox — every triage command
refuses and says so rather than answering with an empty queue.
`entry_status` and `deferred_status` are the inbox. An issue in either column is
*awaiting triage*; the counts below call the first **awaiting** and the second
**deferred**.
## Queue age
An issue entering (or re-entering) the inbox records an entry stamp:
```yaml
entered_at: 2026-09-30T09:12:44Z
source: import # `new` / `import` / `moved` / `defer`
```
Four paths write one, and they are the four ways an issue *enters* the queue:
| `tix create --status <inbox column>` — the CLI, the TUI's `n` form, and MCP's `create_issue` | `new` |
| `tix import` — any policy, whenever the board's mapping lands the issue in an inbox column | `import` |
| `tix move <issue> <inbox column>` or `tix edit <issue> --status <inbox column>` — a status change into the inbox | `moved` |
| `tix triage defer` (a re-entry: the wait restarts) | `defer` |
`moved` names the one origin the vocabulary was missing: `new` would be false for
an issue that has existed for a year, and a missing origin would leave the queue
reporting `unknown` for an issue it is listing. It is stamped only when the status
change actually lands in an inbox column; a move between ordinary columns stamps
nothing, and a same-column re-apply does not restart the wait.
An issue created into an ordinary column gains no entry metadata — it never
entered the queue. An import that *overwrites* an existing local issue into an
inbox column is a status change too, but it is not an entry: it keeps whatever
stamp it had (none, for an issue that was outside the queue) and reports an
**unknown** age until a decision or a move stamps it.
Queue age is measured from that stamp and **only** from it. `created` predates the
queue and `updated` moves on every edit, so neither is used as a proxy. An issue
that recorded no entry time has an **unknown** age, which is reported as
`unknown` — never as `0`, never as `today`:
```console
$ tix triage list
inbox: 4 entries (4 awaiting, 4 deferred)
NUM AGE TITLE
1 2465d Alpha waits
2 unknown Bravo legacy
3 1d Charlie recent
4 2d Delta
```
`awaiting` counts the entry column and `deferred` the deferred one. The fixture
above configures no `deferred_status`, so the two are the same column and both
figures are 4 — they answer different questions ("is it in the inbox" and "is it
in the deferred column"), and a project that separates them sees them diverge. The
**ages** depend on the day you run it; the counts and the `unknown` cell do not.
`--age` sorts oldest first and puts an unknown age **last**, because sorting it as
"entered today" is the one answer that cannot be right.
## The four decisions
Every decision is one of four verbs, and every one of them records actor,
timestamp, prior state, result and reason in the issue's History.
| `accept` | Moves the issue to a normal board column — `--to <column>`, or `[triage].accepted_status`. The target is validated by the board's transition policy. | optional |
| `decline` | Moves it to `[triage].declined_status`, which must map to the `canceled` category. It is **never** deleted or archived. | required |
| `duplicate` | Records a typed `duplicates` relation to another issue (`--of <issue>`). The two files are **never** merged. | required |
| `defer` | Leaves it in the inbox with a refreshed entry stamp, so the wait restarts. It does not erase the earlier decision. | required |
```console
$ tix triage accept 2 --to "in progress" --reason "confirmed scope"
$ tix triage decline 3 --reason "not reproducible on the current release"
$ tix triage duplicate 4 --of 2 --reason "same reproduction and proposed fix"
$ tix triage defer 5 --reason "needs an upstream answer"
```
A missing reason is refused **before** anything is written, with the contract's own
kind — and in the same per-item shape the batch reports it, on stdout, with a
non-zero exit:
```console
$ tix triage decline 3
#3 Charlie recent -> refused: missing_reason: `#3` decline requires a reason
```
The same rule reaches the batch form: a decision that requires a reason is refused
per item, and the item's outcome names `missing_reason`. Both spellings say the
same thing on every surface; only the batch case has other items to name.
### Declining is not finishing
`canceled` is terminal but it is **not** completion. A declined issue is closed as
"not doing this", which is a decision — the counts report it separately from
`accepted`, and `tix status` does not count it as finished. Nothing is archived and
nothing is deleted; the file stays where it was, with a new column and a new History
line.
### Duplicate is a relation
```console
$ tix triage duplicate 4 --of 2 --reason "same bug"
#4 Delta -> applied (2026-01-01-delta.md)
history: triage_duplicated (triage -> duplicates #2; target: #2; source: test; reason: same bug)
```
The source file gains a typed edge (`kind: duplicates`, `to: 2`) and an event; the
target is only read. Merging two issues' bodies, comments and attachments is a
different feature with different failure modes, and it is not this one.
## Batch triage
`tix triage batch` takes a selection and one decision:
```console
$ tix triage batch --select 1,2,3 --decision decline --reason "duplicate intake"
selection: 3 issues (selector, active scope)
#1 Alpha waits
#2 Bravo legacy
#3 Charlie recent
decision: decline
#1 Alpha waits
entered_at: "2020-01-01T00:00:00Z" -> (empty)
source: "test" -> (empty)
status: "triage" -> wontfix
history: triage_declined (triage -> wontfix; reason: duplicate intake)
#2 Bravo legacy
status: "triage" -> wontfix
history: triage_declined (triage -> wontfix; reason: duplicate intake)
#3 Charlie recent
entered_at: "2026-09-30T00:00:00Z" -> (empty)
source: "test" -> (empty)
status: "triage" -> wontfix
history: triage_declined (triage -> wontfix; reason: duplicate intake)
3 of 3 would change
```
`--apply` is the same command with one more flag; without it, that output is the
whole effect.
The first form **previews**: it prints exactly what would change and writes
nothing, appending no History entry and creating no relation. Only `--apply`
writes, and it processes each selected issue independently — an item that cannot be
decided is reported as `refused` with the service's diagnostic, and the items that
succeeded **keep their writes**. There is no rollback, and the report's counts say
so:
```text
1 of 2 applied; 0 already in that state; 1 refused
```
Exactly one outcome per selected issue, always — a batch that silently skipped
anything would read like one that decided everything. (The report says `refused`; the
shared status vocabulary — what the MCP response's `status` field carries — calls the
same outcome `rejected`, and the summary line above counts it as `refused`.)
## Reading the queue
| Queue, with age | `tix triage list [--age]` | `t` opens the inbox | `triage_list` |
| Counts and histogram | `tix triage list --count`, `tix triage status` | the inbox header | `triage_status` |
| One decision or a batch | `tix triage accept/decline/duplicate/defer`, `tix triage batch` | `a`/`d`/`u`/`f` in the inbox, `p` for the marked set | `triage_preview` / `triage_apply` |
All three read the same snapshot through the same service, so the figures and the
error kinds agree: the MCP tools return the CLI's own rendered text alongside a
structured mirror, and the TUI renders the same counts. On MCP, a write requires
`confirm: true`; on the TUI, the first `y` arms and only the second writes; on the
CLI, `--apply` is the confirmation.
### Filtering to the inbox
`tix list --filter` (and `tix search`, and `tix triage batch --filter`) accepts a
`scope:<name>` token. `scope:triage` selects the columns the `[triage]` section
configures — the inbox — so a queue can be filtered without listing its columns
by hand:
```console
$ tix list --filter scope:triage
$ tix search "scope:triage crash"
$ tix triage batch --filter scope:triage --decision defer --reason "revisit later"
```
Like `category:` and `parent:`, the token is answered from the **configuration**
rather than from an issue's bytes, so every surface selects the same set. A scope
this project does not configure is a named error naming the scopes that would
work, not an empty board.
## Imported issues
An import must not silently bypass triage. `tix import` takes an explicit policy:
```console
$ tix import --target gitlab --triage-policy entry
```
- **absent** — the documented default: route into the inbox when the project has a
`[triage]` section, otherwise leave the remote mapping alone (a project without
triage imports exactly as it did before).
- **`entry`** — route into `[triage].entry_status`, and **refuse** when the project
has no `[triage]` section instead of quietly falling back.
- **`remote`** — keep the column the remote mapping named; an explicit, reported
opt-out.
Either way the report names the mapping it used, an imported issue that entered the
inbox gets `entered_at` and `source: import`, and a re-import does not stamp an
entry a second time.
## History
One applied decision appends **one** event; a no-op or a rejection appends none:
```markdown
## History
- 2026-10-01T09:14:02Z — alice — triage_declined — triage -> wontfix; reason: not reproducible
```
The grammar is `prior -> result`, then optional `source:`, `target:` and `reason:`
markers — the same `## History` section comments and status moves use. Events are
append-only: a later decision adds a line, and nothing rewrites what is already
there. Repeating a decision that is already satisfied is a **no-op** with no event.
## What triage is not
- **Not a second workflow.** The inbox is a view over configured columns; statuses,
categories and transition policy all come from the shared boards.
- **Not destructive.** Declining keeps the file. There is no delete, no archive and
no merge on any triage path.
- **Not automatic.** Nothing moves an issue into or out of the inbox except an
explicit decision, and no decision is inferred from a status, a label or a timer.
- **Not a hosted queue.** Triage metadata lives in the issue files, with the rest of
the issue.
If a change would need any of those, it is an escalation rather than a feature: a
second workflow, a changed History grammar, an automatic decision, or destructive
cleanup all mean the design has left this boundary.
## For maintainers
- The service: `src/triage/{config,inbox,decision,history,batch,import_policy}.rs`.
- The surfaces: `src/commands/triage.rs` (CLI), `src/ui/triage.rs` + `src/app.rs`
(TUI), `src/mcp/tools.rs` (MCP tools).
- The evidence: `tests/triage_{queue,decision,batch,cli,import}.rs`,
`tests/tui_state.rs`, `tests/mcp.rs`, and `tests/triage_parity.rs` for the
cross-surface equivalences.