jlevy / jlevy/tbd

State model: tbd cannot express canceled or duplicate, and Linear/GitHub both can

Open
#244 3 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
79
Forks
9
Avg merge
1d 14h
Merged PRs (30d)
42

Description

tbd has one terminal status, `closed`. Linear has three terminal state types; GitHub has three terminal close reasons. The collapse is lossy in both directions and silent in both.

Found while reconciling ~105 mirrored epics in a large repository. Two tracked plans whose specs are explicitly `status: Superseded` — one says the approach "was never executed as a standalone effort". Those are **canceled**, not done, and there is no way to say so:

- `tbd close` files them in Linear as **Done** and stamps a completion date (`adapter.ts:134` sends `completedAt` whenever status is `closed`). Linear tracks `completedAt` and `canceledAt` separately and reports on them, so that is wrong data, not just a wrong label.
- The fallback, `deferred` → **Backlog**, understates it. They are not waiting; they are abandoned.

## The three models, as they actually are

All three verified by introspection rather than recollection.

**tbd** (`schemas.ts:90`) — one flat enum:

```ts
z.enum(['open', 'in_progress', 'blocked', 'deferred', 'closed'])
```

**Linear** — team-named workflow states, each carrying a canonical `type`: `triage`, `backlog`, `unstarted`, `started`, `completed`, `canceled`, `duplicate`. Separately, `IssueRelationType` is `['blocks', 'duplicate', 'related', 'similar']`.

**GitHub** — two axes, and importantly *three* enums:

| Enum | Values | Where |
| --- | --- | --- |
| `IssueState` | `OPEN`, `CLOSED` | the state |
| `IssueStateReason` | `REOPENED`, `NOT_PLANNED`, `COMPLETED`, `DUPLICATE` | read side |
| `IssueClosedStateReason` | `COMPLETED`, `NOT_PLANNED`, `DUPLICATE` | **write side**, on close |

`CloseIssueInput` also takes `duplicateIssueId: ID`. `ReopenIssueInput` takes no reason at all — `REOPENED` is derived by GitHub, never set by a client.

### What the comparison actually shows

**1. Both providers model "duplicate" twice.** GitHub: `stateReason: DUPLICATE` *plus* `duplicateIssueId`. Linear: a `duplicate` state type *plus* `IssueRelationType.duplicate`. Two independent designs reached the same conclusion: the reason alone is useless without the pointer. Any tbd design that stores `duplicate` as a bare value repeats a mistake both providers already corrected.

**2. GitHub's read-side reason is not a resolution.** `IssueStateReason` includes `REOPENED`, which describes an *open* issue. The field means "why is it in the state it is in", spanning both ends. The terminal-only vocabulary is the separate `IssueClosedStateReason`.

This matters for naming. If tbd adds a field called `state_reason` that only accepts terminal values, tbd's `state_reason` is **not** GitHub's `state_reason` — same name, smaller domain. A false friend is worse than a different name, especially in an adapter layer whose job is translating vocabularies.

**3. GitHub's state enum is too coarse for tbd, for a reason that does not apply to tbd.** `OPEN`/`CLOSED` works for GitHub because GitHub Projects owns "in progress". tbd has `tbd start`, `tbd ready`, and a `started` mapping to Linear; it needs `in_progress` as a real position. So "align with GitHub" cannot mean adopting its state axis wholesale.

**4. tbd's enum already fuses two axes at the open end, and already pays for it.** `blocked` and `deferred` are not positions; they are modifiers on open work. Linear has no such types, which is exactly why they need carrier labels (`tbd:blocked`, `tbd:deferred`) to round-trip at all. The canceled gap is the same conflation showing up at the closed end.

## Recommendation

Take GitHub's **shape** — two axes, with the terminal vocabulary on the second — while declining GitHub's coarse state axis and its read-side field name.

```
status: closed # unchanged positional enum
resolution: completed | canceled | duplicate # absent reads as completed
duplicate_of: # required when resolution is duplicate
```

Mapping is then total and lossless in both directions:

| tbd | Linear state type | GitHub |
| --- | --- | --- |
| `closed` + `completed` | `completed` | `CLOSED` + `COMPLETED` |
| `closed` + `canceled` | `canceled` | `CLOSED` + `NOT_PLANNED` |
| `closed` + `duplicate` + `duplicate_of` | `duplicate` + duplicate relation | `CLOSED` + `DUPLICATE` + `duplicateIssueId` |

### On the two naming choices

**Field name — `resolution`, not `state_reason`.** It is terminal-only, so it is `IssueClosedStateReason`'s domain, not `IssueStateReason`'s. Borrowing the broader name for the narrower thing invites exactly the confusion the adapter exists to prevent. `close_reason` would be accurate but collides with the existing free-prose `tbd close --reason`, which should stay as prose.

**Values — `canceled`, not `not_planned`.** Both map 1:1 either way, so this is purely about which reads better in tbd's own surface. `canceled` matches Linear's word, matches the common vocabulary across trackers, and reads better in the CLI (`tbd close x --as canceled`). `not_planned` is GitHub's phrasing for the same bucket and carries a slight implication ("we never planned it") that does not fit work that was planned and then abandoned — which is the actual case here.

So: GitHub's structure, Linear's vocabulary, tbd's own field name. That is the combination where each choice is made on its merits rather than by deferring to one provider wholesale.

The same reasoning extends to the open end of the lifecycle — see below, where it turns out to be the more pressing case.

### Why not simply add statuses

Checked every consumer of status: `ready`, `blocked`, the outbound `statuses` selector (`schemas.ts:423`), and every status filter treat terminal work identically regardless of why it ended. Nothing wants canceled work in a different lifecycle *position* — it is the same position reached for a different reason. Encoding it as a status would put a reason in the position field and turn every "is this finished" test into a set membership check for no gain.

It also keeps the door open: `superseded`, `wont_fix`, and `obsolete` are all future resolutions, none of which should become lifecycle positions. (`superseded` is the one this repository actually wanted.)

Consequences worth noting: `status === 'closed'` stays the terminal test, no existing consumer changes, absent resolution reads as `completed` so no backfill is needed, and `completedAt` can be sent only when `resolution` is `completed` — which fixes the false completion stamp directly.

## The other end: pause, and why it is the same bug

A second case from the same cleanup, and it turns out to be the stronger one. Work that is **partly implemented and being put on hold** has no representation. It is not canceled (it was planned, and some of it exists), not backlog (it was started), and not done.

Today the only move is `--status deferred`, which **overwrites `in_progress`**. The fact that the work was started is destroyed, because tbd has no `started_at` and status is the only place that information lived.

### How each tool handles it

| | Pause mechanism | Retains "was started"? |
| --- | --- | --- |
| **Linear** | `snoozedUntilAt` + `snoozedBy` — an orthogonal field, not a state | Yes: `startedAt` persists independently of current state |
| **GitHub** | None. `Issue` has no snooze, no pause, and no started concept at all — only `state`, `stateReason`, `closedAt`. Teams use labels or a Projects field | No — nothing to retain it in |
| **tbd** | `deferred` status + `deferred_until` | **No** — `deferred` overwrites `in_progress`, and there is no `started_at` |
| Jira / Azure DevOps (not introspected, general practice) | A custom workflow status ("On Hold", "Paused") inside the In Progress category | Yes, via the category the status belongs to |

Two things stand out. **Linear models pause as a modifier, deliberately not as a state** — which is the same conclusion this issue reached about `canceled`, arrived at independently by Linear's designers. And **tbd currently loses information Linear would have kept**: mirror a paused-but-started bead and Linear's own `startedAt` survives while tbd's knowledge of it does not.

### The generalization

`blocked` and `deferred` are not positions either. They are modifiers on open work, which is exactly why they need carrier labels (`tbd:blocked`, `tbd:deferred`) to reach Linear at all. The flat enum forces a modifier to overwrite a position, and that is one bug producing three symptoms: no canceled, no pause-with-progress, no backlog-vs-todo.

So the full model is three fields, not two:

```
status: open | in_progress | closed # position — always preserved
resolution: completed | canceled | duplicate # only when closed; absent reads as completed
hold: blocked | paused # only when open/in_progress
hold_until: # optional, with hold: paused
started_at: # set on first entry to in_progress, never cleared
```

Position and modifier are then independent, and the *combination* carries the meaning that is currently lost:

| tbd | Meaning | → Linear |
| --- | --- | --- |
| `open` | not started, scheduled | `unstarted` (Todo) |
| `open` + `paused` | not started, not scheduled | `backlog` (Backlog) |
| `in_progress` | actively being worked | `started` |
| `in_progress` + `blocked` | started, waiting on something | `started` + `tbd:blocked` |
| **`in_progress` + `paused`** | **partly done, on hold** | **`started` + `snoozedUntilAt`** |
| `closed` + resolution | finished, for some reason | `completed` / `canceled` / `duplicate` |

The row in bold is the use case with no representation today, and it maps onto Linear's native snooze rather than a carrier label. The Backlog/Todo gap falls out of the same table for free: the difference is `open` with or without `paused`, not a new status.

For GitHub, none of the open-end distinctions have a native home (no snooze, no started, no in-progress). Carrier labels are the honest answer there, and the asymmetry is real rather than something to paper over.

`started_at` is worth adding regardless of the rest. It is the fact that survives a pause, Linear already keeps it, and tbd currently cannot answer "was this ever started" for any bead that moved on.

### A smaller step, if the above is too much at once

Adding `started_at` alone, and letting `deferred` preserve it, recovers the lost information without touching the status enum. It leaves `deferred` positionally claiming "not started", so queries for in-flight work still miss paused work — but it stops the data loss, and it is compatible with doing the full model later.

## Questions

1. Three-field model (`status` + `resolution` + `hold`), or narrower? The claim is that one conflation causes all three symptoms, so fixing it once is cheaper than three patches.
2. `duplicate_of` as a scalar field, or a dependency edge, given Linear and GitHub both model duplicate as a relation?
3. Should `hold: paused` on `in_progress` map to Linear's `snoozedUntilAt`, or to a `tbd:paused` carrier label? Snooze is native and round-trips, but it is a Linear-specific affordance with no GitHub analogue.
4. Should an inbound `canceled` close a bead that is open locally? It does today via the collapse; with a resolution it deserves an explicit rule.
5. Is `started_at` worth adding on its own merits, ahead of any of this?

Happy to implement whichever shape you land on.

---

**Correction to an earlier draft of this issue:** it stated GitHub's reasons as `completed | not_planned | duplicate`. That is the *write-side* enum. The read-side `IssueStateReason` also includes `REOPENED`, which is what makes the naming argument above necessary.

Context: found during a first-time Linear rollout that also produced #242. Related, filed separately as a bead: `--defer` sets `deferred_until` but leaves status `open`, so deferred beads still surface in `tbd ready`, and there is no bulk path to `deferred` (`--status` is refused for multiple ids), making a superseded subtree a per-bead loop.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with the status schema in schemas.ts:90 and the outbound statuses selector at schemas.ts:423, then inspect adapter.ts:134 and the named tbd close, start, ready, and deferred paths. The issue leaves several design questions open, so the first step is to get a decision on the data model and mappings. Done means the agreed representation preserves lifecycle position and resolution across Linear and GitHub without breaking existing status consumers.

Written by the indexing model from the issue text.

Assessment

Tech stack
github, typescript
Domain
backend, cli, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.