Skip to content

Boards: a kanban that doesn't know where its cards are

Every board eventually fails the same way — it remembers which column a card is in, and from then on that memory has to be maintained. Workbench never records it: columns are rules, evaluated on read.

Patrick Lehmann
10 min read
Each card is tested against the rules in order and stops at the first one that holds. Where it stopped is never written down.

In March, Linear declared issue tracking dead and repositioned around “context and agents” — Agent, Skills, Automations — on the claim that coding agents are installed in over 75% of their enterprise workspaces and author a quarter of new issues. Shipping a board five months later needs an answer to that, and nostalgia is not one.

The useful answer was in the discussion, not the announcement: a tracker is valuable because it is a system of record — “a sacred space that can’t be polluted” — and “issue tracking is not only not dead, it’s a more structured way to handle your agents.” Pathmode states the other half cleanly: the task list dies, the thinking doesn’t.

But that is precisely where boards have failed for as long as they have existed, and GitLab’s own boards are no exception: a board remembers which column a card is in. From that moment there are two places recording how far along a piece of work is — the board, and the tool where the work actually happens — and the rest of the system exists to keep the two in step. The more actors are moving work — people on two GitLab instances, automation, agents — the more that costs.

Workbench did not build better synchronisation for this. It left the membership out.

Columns are rules, not membership

A board never stores which column a card is in.

— ADR 0006, Workbench

A column is a rule over the existing projection, evaluated on read, first match wins. Dragging a card executes the target column’s configured GitLab mutations — labels, assignees, milestone, due date, open/closed. Its new column is not a write afterwards but a by-product: it falls out of running the rules again against the changed projection.

What disappears with it is the work you would otherwise have built. No membership table to reconcile. No ordering between “write the board” and “write GitLab” that has to be right. No state that can drift from GitLab while the projection is stale. The rule from the previous article in this series — GitLab is authoritative, every local row is derived — survives intact instead of getting an exception carved out for the board.

The price is a class of failure a membership model never sees, and it comes further down.

OR of AND — and why the format had to stay boring

A board’s scope and each of its columns are the same data structure: an OR of ANDs with exactly one level of nesting. any_of holds groups, a group’s all_of holds conditions, and conditions never hold children.

Column rule — “In progress”, my own team
{
  "version": 1,
  "any_of": [
    {
      "all_of": [
        { "property": "label", "operator": "in", "values": ["status::in-progress"] },
        { "property": "label", "operator": "in", "values": ["team::platform"] }
      ]
    },
    {
      "all_of": [
        { "property": "label", "operator": "in", "values": ["status::in-progress"] },
        { "property": "assignee", "operator": "in", "values": ["__me__"] }
      ]
    }
  ]
}

Fourteen properties, each with the operators that suit its kind and no others: label and scoped_label take in, not_in, is_empty, is_not_empty; title takes contains and nothing else; date properties take before, after, between, within_last, with due date additionally taking within_next. Plus hard bounds — eight OR groups, 25 conditions per group, 50 values per condition, 255 characters per value.

The empty rule {"version": 1, "any_of": []} matches everything. That is the identity rule, and it is what the Open system column and a whole-workspace board scope use.

One asymmetry is deliberate: state is valid in a board scope and rejected in column rules. A board may say “I only care about open things”; a column may not, because a card’s state already determines which kind of column it can reach at all. Query columns see open items, system_closed sees closed ones. Allowing state in a column rule would let you configure a column that never receives a card — syntactically fine, and simply broken in practice.

The format being boring was the point. It is the only structure that can drive both a UI and a compilation to SQL without either side acquiring special cases.

First match wins

Query columns are tested in ascending position. The first rule that holds wins — the ones below it are never considered for this card.#412 · squibble/example-appNeeds triagelabel in [status::triage]no matchReadylabel in [status::ready]no matchIn progresslabel in [status::in-progress]matchBlockedlabel in [status::blocked]never evaluatedReviewlabel in [status::review]never evaluated
Query columns are tested in ascending position. The first rule that holds wins — the ones below it are never considered for this card.

First match wins means a card appears at most once. Without that rule, every column configuration needs an answer for cards that satisfy two rules, and every answer is either “it appears twice” or a precedence rule you end up having to explain anyway.

One subtlety that matters in practice: system_open sits at position 0 visually but is evaluated last. Otherwise its identity rule would outrank every workflow column and everything would pile into the first lane. So open items run through the query columns in ascending position first and then fall into Open; closed items resolve only to system_closed.

On a move, the source column’s leave_actions apply first, then the target’s enter_actions. Where the two conflict on the same property, entering wins — except for labels, which are computed rather than overwritten:

Merge arithmetic for labels
add    = (leave.add    ∪ enter.add)    − enter.remove
remove = (leave.remove ∪ enter.remove) − enter.add

That looks like pedantry and it is the difference between “the source’s status label goes away” and “the source’s status label goes away unless the target column was just setting it”.

And here is the class of failure the membership model never sees: the merged actions might not land the card in the column it was dragged to. If you store membership you cannot experience this — you just write the column down. If you derive it, you can.

What a board is not allowed to create

So Boards::MoveTransaction simulates before anything happens.

The simulation runs before GitLab is touched. A move that would not land where it was dragged is refused, and names the column it would actually land in.Merge and simulateapply actions to a copy of the projection, re-runfirst matchWrite to GitLabworkItemUpdate, state changes separatelyUpdate the projection from the responsenot from what was sentRe-derive the columnnot a write — a by-productwould_not_landnames the column the card would land inmissing_labellabel does not exist in this projectpartialGitLab wrote, local did not —reconciliation
The simulation runs before GitLab is touched. A move that would not land where it was dragged is refused, and names the column it would actually land in.

The simulation applies the merged actions to an in-memory copy of the projection, runs first match over the column order again, and refuses the move by naming the column the card would have landed in. Simulation and the real projection update share one function, because two implementations of “what does this action do” drift apart, and the result would be cards showing up in the wrong place.

The second rule: boards create nothing in GitLab. Labels and milestones are resolved by title within the card’s own project, from already-projected data. If the label does not exist there, the move is blocked with missing_label rather than the label being created. That makes a column usable for one card and not for the next — and that is exactly what the UI shows, with a reason.

Assignee identity is keyed on the GitLab instance plus the external user id. Two instances hand the same numeric ids to different people; without the instance in the key, assigning across instance boundaries would be a silent case of mistaken identity.

Writes go to GitLab first, and the projection is updated from the response, not from the request that was sent — the same rule as the rest of Workbench. If the local half fails after a successful GitLab write, a reconciliation is enqueued and the failure is reported as partial. The GitLab write is not rolled back: it is the truth.

State belongs in the URL

Filters are two things that can be active at once: named quick filters that belong to the board, and property facets the viewer picks. Everything combines with AND, and everything lives in the URL.

A board state you can send to someone
/boards/17?filters=mine&filters=overdue&search=migration&order=attention&direction=desc

That is the point: a board state is a link. At most ten filters, unknown slugs dropped rather than raised as an error, duplicates collapsed, search a substring match over the title. Quick filter slugs are immutable once created — which is precisely why a link someone sent stays valid when the filter gets renamed.

One decision that only pays off in use: facets derive from the board scope, not from the filtered set. Otherwise the filter options collapse to whatever is currently visible, and you can no longer switch a filter without removing it first.

Ordering is board-wide rather than per column, and either automatic or manual. Automatic sorts by attention, due date, update, creation or title — where attention deliberately depends on who is reading. Manual sorts by ranks that belong to Workbench, because GitLab has no cross-project order to read from. Ranks are sparse (gap 65536), a board is only rebalanced when an insertion gap falls below 2, and they are preserved while automatic ordering is active — switch back and your manual work is still there.

The anchor rule behind it is unremarkable and carries a lot: a dropped card is placed immediately after the preceding visible card. That defines its position relative to the filtered-out cards too, instead of shifting the moment somebody clears the filter.

What a card shows What can block a move
title, project and IID always missing_label — label absent in the card’s project
plus up to four configured fields missing_milestone — milestone absent in the project
from assignee, milestone, due_date missing_assignee — person unknown on this instance
labels, project, work_item_type state_change_not_permitted — state changes only via system columns
at most 12 columns, 20 quick filters would_not_land — the card would come out elsewhere

WIP limits are soft warnings and count against the unfiltered board scope — a limit you can filter away measures nothing. And keyboard or menu moves are not an accessibility concession bolted on behind drag-and-drop: they run through the same transaction, the same simulation and the same refusal reasons. There is no path reachable only with a mouse.

A versioned envelope and an adapter that does nothing

Boards refresh by polling and on focus change. No SSE, no WebSockets. What this milestone ships anyway is the seam for it: a versioned, immutable event envelope, and a publisher port whose only adapter does nothing.

app_packages/boards/app/services/boards/event_envelope.rb
Boards::EventEnvelope.new(
  version: 1,
  type: "card_moved",
  occurred_at: Time.current,
  workspace_id: workspace.id,
  board_id: board.id,
  actor_id: user.id,
  data: { work_item_id:, source_column_id:, target_column_id: }
).freeze

The NoOp adapter is not laziness; it is why no call site carries a nil check. There is deliberately no events table: it would be a sixth tenant table with its own retention liability, for a consumer that does not exist yet. The client reducer that would consume the envelopes arrives with the first real transport — before that, there is nothing to test it against.

What the next milestone needs is the seam. How it stores things is its own decision to make.

What I would keep

What is deliberately absent is in the ADR rather than in a ticket: no membership projection, no creation of GitLab metadata, no kill switch, no swimlanes, no cross-board moves, no events table.

Three things I am taking with me:

  • Derived state cannot go stale. Store no membership and you need no reconciliation for it. That is not a trick; it is declining a feature that turns out to be a maintenance contract.
  • Deriving means budgeting for refusal. would_not_land is the price of not writing the column down. It is honest and it costs one simulation.
  • Boring formats compile. OR of AND at exactly one depth was the only structure that served both the UI and SQL without special cases.

Which brings it back to the opening question. If agents are moving work — and they are, here as much as at Linear — then a board that remembers where a card is is exactly the wrong counterpart: it has to be told. One that derives its columns from GitLab does not. It shows what happened, regardless of who or what did it. Issue tracking is not dead. But the board that keeps its own books might be.

The next article in this series goes the other way from here: away from systems that mirror GitLab, towards one that produces something itself.

Patrick Lehmann

Architecture & Governance Lead

Squibble GmbH

Has spent twenty years bringing structure to IT landscapes that grew rather than were designed — as architect, developer, and operator. Writes here about the systems actually running at Squibble and the decisions behind them.

Read more

Workbench: a delivery index that is allowed to be wrong

We built a multi-tenant view across several GitLab instances without becoming a second source of truth. The design rule that made it work: the local database is disposable, and reconciliation — not webhooks — is what makes it correct.

Patrick Lehmann
6 min read

Env Manager: a secrets store that cannot read its own secrets

A security review pointed out that our secrets manager trusted its own administrators. The fix was zero-knowledge end-to-end encryption — and the honest part of the design is the list of three places where the server still sees plaintext, and why we accepted each one.

Patrick Lehmann
6 min read

Observability: the read model that acts

Workbench could drop its database and rebuild it. This one polls Graylog and files GitLab issues — and a side effect has no upstream. Why dry-run became a database column, why the dashboard has no delete button, and why the safety guard sits at the HTTP boundary rather than in configuration.

Patrick Lehmann
6 min read