Notes
Open and closed isn't a workflow
GitHub gives an issue two states, open and closed, so every team quietly rebuilds a real workflow on top with labels. Plain models issues the way teams actually think, with a Todo → In Progress → Done status enum and a first-class priority field, and that one schema decision is what makes filtering, "what's next," and a live board fall out for free.
10 min
A GitHub issue has exactly two states: open and closed. That's the whole state machine. It shipped that way in 2008 and it's essentially unchanged, which is remarkable for a primitive that millions of teams run their week on.
Nobody actually works in two states, so nobody actually uses two states. Open an issue tracker at any company and you'll find the real workflow bolted on top in labels: status: in progress, status: blocked, needs review, ready. The two-state model lost the argument a decade ago. We just stopped noticing the tax, because the workaround became the convention and the convention became muscle memory.
Plain doesn't have an open/closed bit. An issue has a status (todo, in_progress, or done) and a real priority field, the way teams already think and talk. That sounds like a cosmetic difference. It isn't. Once status is a typed field on the issue instead of a string in a label, filtering, prioritization, and even your realtime board stop being features you build and start being things that fall out of the schema.

Labels-as-state is a bug, not a convention
Using labels to track status feels pragmatic. It's also structurally broken in four ways that no amount of team discipline fixes, because the problems are in the data model, not the process.
Labels don't exclude each other. A label set is exactly that: a set. Nothing stops an issue from carrying in progress and blocked and ready for review at once, because labels were designed for the case where many can be true. State is the opposite: an issue is in exactly one place in its lifecycle. Modeling a one-of-N fact with an any-of-N type means every illegal combination is representable, and given enough issues, every representable state eventually happens.
Labels are free text, so they drift. in progress, in-progress, In Progress, wip, doing. Across repos and people you accumulate a small museum of near-synonyms, and a filter for one silently misses the others. There's no schema to violate, so nothing ever errors. It just slowly stops meaning anything.
"Closed" throws away the reason. Done, won't-do, duplicate, and stale all collapse into the same closed event. The single most useful distinction in a tracker (did we ship this or abandon it) is the one the model can't hold, so teams reconstruct it with yet more labels (wontfix, duplicate) layered on top of the closed bit.
There's no source of truth. State ends up smeared across three places: the open/closed boolean, a status label, and a project board column. They disagree constantly. An issue is closed but the board still shows it in review; the label says in progress but it's been closed for a month. When the answer to "what state is this in" depends on which surface you're looking at, you don't have a status field. You have three half-fields and a reconciliation problem.
A status enum is the lifecycle, named
The fix is the boring one: make status a typed field with a fixed set of values.
type IssueStatus = "todo" | "in_progress" | "done";
type Priority = "none" | "urgent" | "high" | "medium" | "low";
interface Issue {
number: number;
title: string;
status: IssueStatus; // always exactly one, enforced by the type
priority: Priority;
labels: string[]; // for what labels are actually good at: area, kind, topic
}One issue, one status, enforced where it's cheapest to enforce: in the type, not in a team norm. The illegal states from the last section aren't policed; they're unrepresentable. in_progress + blocked + closed can't be expressed, so it can't drift into existence. And notice labels don't disappear; they go back to their actual job. area: billing, kind: bug, good first issue are genuinely many-of-N facts, and labels model those perfectly. The mistake was never having labels. It was conscripting them to do a field's job.
Three states is deliberately few. This is where we part ways with the fuller Linear-style ladder of Backlog / Todo / In Progress / In Review / Done / Canceled. Every team we watched either ignored half those columns or spent a standup arguing about which one a ticket lived in. todo → in_progress → done is the lifecycle almost everyone actually has; the rest is usually a workflow tool cosplaying as a methodology. If you need "in review," that's a label or a linked PR, not a fourth column the whole org has to agree on the meaning of.

Priority is a dimension, not a tag
Status is one axis. Priority is a second, orthogonal one, and it suffers the label treatment even more often, with P0/P1/priority: high labels standing in for what should be a field.
type Priority = "none" | "urgent" | "high" | "medium" | "low";The reason priority specifically must not be a label is that priority is an ordered scale, and a label set has no order. urgent outranks high outranks medium. That ordering is the entire point of recording priority, and it's exactly the thing a set throws away. You can't sort a column of labels by importance, because to the label system priority: high and area: billing are the same kind of thing: opaque strings with no relation to each other.
Two fields, two questions. Status answers where is this in its life: not started, being worked, finished. Priority answers how much does it matter. They're independent: an urgent issue can be todo (the fire you haven't reached) or in_progress (the fire you're fighting), and a low one can sit in todo indefinitely without that being wrong. Collapsing both into the label bag loses the independence and the order in one move.
What it does to filtering
Once status and priority are typed fields, filtering stops being string-matching and starts being querying.
// Everything in flight, by definition. Not "issues tagged with a string
// that hopefully matches the spelling someone used last week."
const inFlight = await issues.list({
repo: "plain/web",
status: "in_progress",
});plain issues list --status in_progress --priority urgentCompare the GitHub equivalent, where state lives in a label, so the query is is:open label:"status: in progress", and that's only correct if everyone spelled the label identically and nobody also left the old wip label on. The Plain query can't miss issues to a typo, because in_progress is a value the schema knows, not a string you're hoping to match. And it's consistent across every repo for free: the enum is the same everywhere, so a filter that works in one project works in all of them, no per-repo label taxonomy to learn.
"What should I work on next" becomes a query
This is the question a tracker exists to answer, and it's the one open/closed structurally cannot. With two ordered fields, "what's next" isn't a judgment call you make by scrolling; it's a sort.
const RANK: Record<Priority, number> = {
urgent: 0, high: 1, medium: 2, low: 3, none: 4,
};
function whatsNext(issues: Issue[]): Issue[] {
return issues
.filter((i) => i.status !== "done")
.sort(
(a, b) =>
// what you've already started outranks what you haven't,
Number(a.status === "todo") - Number(b.status === "todo") ||
// then most important first.
RANK[a.priority] - RANK[b.priority],
);
}That function is total and deterministic: hand it any set of issues and it returns an order, every time, with no human in the loop. A label-as-state tracker can't write this function: there's nothing to sort on, because labels have no order and "state" isn't a single value. The best it can do is filter to a guessed-at label and let a human eyeball the rest. The difference between "here's a filtered list, you decide" and "here's the answer" is the difference between a database of issues and a tool that tells you what to do with them.
A clean state is a syncable state
Here's the part that surprised us most: getting the model right is what made realtime cheap. A single typed field is atomic and broadcastable; a state smeared across an open/closed bit, a label, and a board column is none of those things. So the instant, optimistic, no-refresh feel of moving an issue isn't a separate feature we built next to the data model; it's a consequence of the data model.
Moving an issue is one write. Dragging a card from Todo to In Progress sets status = "in_progress". One field, one mutation. The client can render the move instantly and reconcile against a single source of truth, because there is a single source of truth. The label version of "moving" an issue is a multi-step edit (add a label, remove another, maybe toggle closed), and a multi-write is exactly what's hard to apply optimistically without flicker or a half-applied intermediate state flashing on screen.
Concurrent edits converge. Two people changing priority at the same moment is last-write-wins on one enum column: trivially consistent, the loser's value simply isn't the final one. Two people toggling overlapping status labels can converge to a combined set that's nonsense (in progress and done). It's the exclusivity problem from the top of the post, now wearing a concurrency hat: a model that permits illegal states permits illegal merges.
The board is the field, live. A kanban board over this model isn't a separate artifact you keep in sync with the issues; it's a live query.
// Three columns, grouped by the status field, subscribed to changes.
const columns = useLiveQuery(issues.list({ repo }), { groupBy: "status" });
function onDrop(issue: Issue, column: IssueStatus) {
// One field write. The drop is optimistic locally; every other
// client re-renders from the same change, no board to reconcile.
issues.update(issue.number, { status: column });
}GROUP BY status is the board. Drag a card and you write one field; everyone watching that repo sees the card move, because they're all subscribed to the same field on the same row. There's no board state to synchronize separately from the issues, because the board was never state; it's a view of the field, and the field updates in realtime.
The schema that makes it cheap
None of this needs an exotic data model. It's two enum columns.
export const issueStatus = pgEnum("issue_status", [
"todo", "in_progress", "done",
]);
export const priority = pgEnum("priority", [
"none", "urgent", "high", "medium", "low",
]);
export const issues = pgTable(
"issues",
{
id: serial("id").primaryKey(),
title: text("title").notNull(),
status: issueStatus("status").notNull().default("todo"),
priority: priority("priority").notNull().default("none"),
},
(t) => [
index("issues_status_idx").on(t.status),
index("issues_priority_idx").on(t.priority),
],
);Two columns, two indexes, NOT NULL defaults so an issue is never in an undefined state. Filtering is an index scan. Sorting by priority is an ordered column. The database enforces the enum, so an illegal status can't be written even by a buggy client or a stray script; the constraint lives below all of them.
Now price out labels-as-state for comparison. State becomes a row in a many-to-many join table (issue_labels), where the "status" of an issue is whichever string in that join happens to look like a status: a property you reconstruct at query time, every query, with a join and a LIKE and a prayer about spelling. There's no column to index for "give me everything in progress," no order to sort priority by, and no constraint that stops an issue from joining to three contradictory status labels at once. The clean model is cheaper to store, cheaper to query, and impossible to corrupt. The workaround is more expensive on every axis; we just never put the two side by side, because one of them we inherited and the other we'd have had to choose.
The shape of the bet
The obvious objection is that GitHub fixed this: Projects has custom status fields and a board now. It does, and it's a real improvement, but look at where the status lives. It lives in the project, beside the issue, not on it. The issue itself is still open or closed; the rich status is a second system layered alongside, and half your tooling (the API's default issue view, search, notifications, every integration written before Projects) still sees only the open/closed bit. You can buy back the workflow, but you buy it as an adjacent product you now keep in sync with the issues, which is the reconciliation problem from the top of this post sold back to you as a feature.
The bet Plain makes is smaller and, we think, more honest: status belongs on the issue, as a field, with a fixed set of values and a real priority beside it. That's not a board bolted on or a label convention blessed; it's the issue knowing what state it's in. Get that one decision right and the rest stops being features to build. Filtering is a where. "What's next" is an order by. The live board is a group by over a subscription. They all fall out of the same two columns, because they were always the same idea: an issue has a state, and a state is a thing you can name, query, sort, and sync, right up until you decide it's a label.
This is an early take, and the model is still moving. Plain is in early access, and we're looking for a handful of teams to partner with closely as we build it. If that's you, join the alpha.