Zum Inhalt springen

Boards: ein Kanban, das nicht weiss, wo seine Karten liegen

Jedes Board scheitert irgendwann daran, dass es zur zweiten Wahrheit wird: es merkt sich, in welcher Spalte eine Karte liegt, und ab da muss dieses Wissen gepflegt werden. Workbench merkt es sich gar nicht: Spalten sind Regeln, die beim Lesen ausgewertet werden.

Patrick Lehmann
9 Min. Lesezeit
Jede Karte wird der Reihe nach an den Regeln geprüft und bleibt bei der ersten stehen, die hält. Wo sie stehen geblieben ist, schreibt niemand auf.

Im März hat Linear Issue Tracking für tot erklärt und sich um «Kontext und Agenten» herum neu aufgestellt: Agent, Skills, Automations, begründet damit, dass in über 75 % ihrer Enterprise-Workspaces Coding-Agenten installiert sind und ein Viertel der neuen Issues von ihnen stammt. Fünf Monate später ein Board auszuliefern, braucht eine Antwort darauf, und Nostalgie ist keine.

Die brauchbare Antwort stand in der Diskussion dazu, nicht in der Ankündigung: Ein Tracker ist wertvoll, weil er ein System of Record ist, «a sacred space that can’t be polluted», und «issue tracking is not only not dead, it’s a more structured way to handle your agents». Die andere Hälfte formuliert Pathmode sauber: die Aufgabenliste stirbt, das Denken nicht.

Genau daran scheitern Boards aber, seit es sie gibt, und GitLabs eigene Boards sind da keine Ausnahme: Ein Board merkt sich, in welcher Spalte eine Karte liegt. Ab diesem Moment gibt es zwei Stellen, an denen steht, wie weit eine Arbeit ist: das Board und das Werkzeug, in dem tatsächlich gearbeitet wird. Und der ganze Rest des Systems besteht daraus, die beiden Stellen synchron zu halten. Je mehr Akteure Arbeit bewegen (Menschen auf zwei GitLab-Instanzen, Automatisierung, Agenten), desto teurer wird das.

Workbench hat darauf keine bessere Synchronisation gebaut. Es hat die Mitgliedschaft weggelassen.

Spalten sind Regeln, keine Mitgliedschaft

Ein Board speichert nie, in welcher Spalte eine Karte liegt.

— ADR 0006, Workbench

Eine Spalte ist eine Regel über die bestehende Projektion, ausgewertet beim Lesen, erster Treffer gewinnt. Zieht jemand eine Karte, führt das die konfigurierten GitLab-Mutationen der Zielspalte aus: Labels, Assignees, Milestone, Fälligkeit, offen/geschlossen. Die neue Spalte ist danach kein Schreibvorgang, sondern ein Nebenprodukt: Sie fällt heraus, wenn man die Regeln erneut gegen die veränderte Projektion laufen lässt.

Was damit verschwindet, ist die Arbeit, die man sonst gebaut hätte. Keine Mitgliedschaftstabelle, die abgeglichen werden muss. Keine Reihenfolge zwischen «Board schreiben» und «GitLab schreiben», die man richtig hinkriegen muss. Kein Zustand, der von GitLab abdriften kann, während die Projektion veraltet ist. Die Regel aus dem vorherigen Artikel dieser Serie (GitLab ist massgebend, jede lokale Zeile ist abgeleitet) bleibt exakt erhalten, statt für das Board eine Ausnahme zu bekommen.

Der Preis ist eine Fehlerart, die ein Mitgliedschaftsmodell nicht kennt, und sie kommt weiter unten.

OR von AND, und warum das Format langweilig bleiben musste

Sowohl der Scope eines Boards als auch jede Spalte sind dieselbe Datenstruktur: ein OR von ANDs mit genau einer Verschachtelungstiefe. any_of hält Gruppen, das all_of einer Gruppe hält Bedingungen, Bedingungen halten nie Kinder.

Spaltenregel: «In Arbeit», eigenes Team
{
  "version": 1,
  "any_of": [
    {
      "all_of": [
        { "property": "label", "operator": "in", "values": ["status::in-progress"] },
        { "property": "label", "operator": "in", "values": ["team::plattform"] }
      ]
    },
    {
      "all_of": [
        { "property": "label", "operator": "in", "values": ["status::in-progress"] },
        { "property": "assignee", "operator": "in", "values": ["__me__"] }
      ]
    }
  ]
}

Vierzehn Eigenschaften, jede mit den Operatoren, die zu ihrer Art passen und mit keinen anderen: label und scoped_label können in, not_in, is_empty, is_not_empty; title kann contains und sonst nichts; Datumsfelder können before, after, between, within_last, Fälligkeit zusätzlich within_next. Dazu harte Grenzen: acht OR-Gruppen, 25 Bedingungen pro Gruppe, 50 Werte pro Bedingung, 255 Zeichen pro Wert.

Die leere Regel {"version": 1, "any_of": []} trifft alles. Das ist die Identitätsregel, und sie ist das, was die Systemspalte Open und ein Board-Scope über den ganzen Workspace benutzen.

Eine Asymmetrie ist Absicht: state ist im Board-Scope gültig und wird in Spaltenregeln abgelehnt. Ein Board darf sagen «mich interessieren nur offene Dinge»; eine Spalte darf es nicht, weil der Zustand einer Karte bereits festlegt, welche Art Spalte sie überhaupt erreichen kann. Query-Spalten sehen offene Elemente, system_closed sieht geschlossene. Liesse man state in der Spaltenregel zu, könnte man eine Spalte konfigurieren, die nie eine Karte bekommt. Syntaktisch einwandfrei und im Betrieb schlicht kaputt.

Dass das Format langweilig ist, war das Ziel. Es ist die einzige Struktur, die sowohl ein UI bedienen als auch nach SQL kompilieren kann, ohne dass eine der beiden Seiten Sonderfälle bekommt.

First match gewinnt

Query-Spalten werden in aufsteigender Position geprüft. Die erste Regel, die hält, gewinnt; die darunter werden für diese Karte gar nicht mehr betrachtet.#412 · squibble/beispiel-appNeeds triagelabel in [status::triage]kein TrefferReadylabel in [status::ready]kein TrefferIn progresslabel in [status::in-progress]TrefferBlockedlabel in [status::blocked]nie ausgewertetReviewlabel in [status::review]nie ausgewertet
Query-Spalten werden in aufsteigender Position geprüft. Die erste Regel, die hält, gewinnt; die darunter werden für diese Karte gar nicht mehr betrachtet.

Erster Treffer gewinnt heisst: Eine Karte erscheint höchstens einmal. Ohne diese Regel braucht jede Spaltenkonfiguration eine Aussage darüber, was mit Karten passiert, die auf zwei Regeln passen, und jede Antwort darauf ist entweder «sie erscheint doppelt» oder eine Prioritätsregel, die man dann doch wieder erklären muss.

Eine Feinheit, die im Betrieb zählt: system_open steht optisch auf Position 0, wird aber zuletzt ausgewertet. Sonst würde seine Identitätsregel jede Workflow-Spalte überstimmen und alles läge in der ersten Bahn. Offene Elemente laufen also erst durch die Query-Spalten in aufsteigender Position und fallen dann in Open; geschlossene lösen ausschliesslich nach system_closed auf.

Beim Zug gelten zuerst die leave_actions der Quellspalte, dann die enter_actions der Zielspalte. Bei Konflikten auf derselben Eigenschaft gewinnt das Betreten, ausser bei Labels, wo gerechnet statt überschrieben wird:

Merge-Arithmetik für Labels
add    = (leave.add    ∪ enter.add)    − enter.remove
remove = (leave.remove ∪ enter.remove) − enter.add

Das sieht nach Pedanterie aus und ist der Unterschied zwischen «das Status-Label der Quelle verschwindet» und «das Status-Label der Quelle verschwindet, ausser die Zielspalte wollte es gerade setzen».

Und hier ist die Fehlerart, die das Mitgliedschaftsmodell nicht kennt: Die zusammengeführten Aktionen landen die Karte vielleicht gar nicht in der Spalte, in die gezogen wurde. Wer Mitgliedschaft speichert, kann das nicht erleben: er schreibt die Spalte einfach hin. Wer sie ableitet, schon.

Was ein Board nicht anlegen darf

Deshalb simuliert Boards::MoveTransaction, bevor irgendetwas passiert.

Die Simulation läuft, bevor GitLab angefasst wird. Ein Zug, der nicht dort landen würde, wo hingezogen wurde, wird abgelehnt und nennt die Spalte, in der er tatsächlich landen würde.Zusammenführen und simulierenAktionen auf eine Kopie der Projektion, first matcherneut auswertenNach GitLab schreibenworkItemUpdate, Zustandswechsel separatProjektion aus der Antwort aktualisierennicht aus dem, was gesendet wurdeSpalte neu ableitenkein Schreibvorgang, ein Nebenproduktwould_not_landnennt die Spalte, in der die Karte landenwürdemissing_labelLabel existiert in diesem Projekt nichtpartialGitLab hat geschrieben, lokal nicht:Reconciliation
Die Simulation läuft, bevor GitLab angefasst wird. Ein Zug, der nicht dort landen würde, wo hingezogen wurde, wird abgelehnt und nennt die Spalte, in der er tatsächlich landen würde.

Die Simulation wendet die zusammengeführten Aktionen auf eine In-Memory-Kopie der Projektion an, lässt first match erneut über die Spaltenreihenfolge laufen und lehnt den Zug ab, indem sie die Spalte nennt, in der die Karte gelandet wäre. Simulation und echte Projektionsaktualisierung teilen sich dieselbe Funktion, denn zwei Implementierungen von «was tut diese Aktion» driften auseinander, und das Ergebnis wären Karten, die irgendwo auftauchen.

Die zweite Regel: Boards legen in GitLab nichts an. Labels und Milestones werden über den Titel innerhalb des Projekts der Karte aufgelöst, aus bereits projizierten Daten. Existiert das Label dort nicht, wird der Zug mit missing_label blockiert, statt das Label anzulegen. Das macht eine Spalte für eine Karte benutzbar und für die nächste nicht. Und genau das zeigt das UI auch an, mit Grund.

Assignee-Identität hängt an GitLab-Instanz plus externer Benutzer-ID. Zwei Instanzen vergeben dieselben numerischen IDs an verschiedene Personen; ohne die Instanz im Schlüssel wäre die Zuweisung über Instanzgrenzen hinweg eine stille Personenverwechslung.

Geschrieben wird nach GitLab zuerst, und die Projektion wird aus der Antwort aktualisiert, nicht aus dem gesendeten Request: dieselbe Regel wie beim Rest von Workbench. Schlägt der lokale Teil nach einem erfolgreichen GitLab-Schreibvorgang fehl, wird eine Reconciliation eingereiht und der Fehler als partiell gemeldet. Zurückgerollt wird der GitLab-Schreibvorgang nicht: Er ist die Wahrheit.

Der Zustand gehört in die URL

Filter sind zwei Dinge, die gleichzeitig aktiv sein können: benannte Quick Filters, die dem Board gehören, und Eigenschaftsfacetten, die die besitzende Person auswählt. Alles kombiniert mit UND, und alles steht in der URL.

Ein Board-Zustand, den man verschicken kann
/boards/17?filters=mine&filters=overdue&search=migration&order=attention&direction=desc

Das ist der Punkt: Ein Board-Zustand ist ein Link. Höchstens zehn Filter, unbekannte Slugs werden verworfen statt zu einem Fehler zu führen, Duplikate fallen weg, die Suche ist eine Teilstring-Suche über den Titel. Quick-Filter-Slugs sind nach dem Anlegen unveränderlich: genau deshalb bleibt ein verschickter Link gültig, wenn jemand den Filter umbenennt.

Eine Entscheidung, die sich erst im Gebrauch auszahlt: Facetten leiten sich aus dem Board-Scope ab, nicht aus der gefilterten Menge. Andernfalls kollabieren die Filteroptionen auf das, was gerade sichtbar ist, und man kann einen Filter nicht mehr wechseln, ohne ihn erst zu entfernen.

Die Reihenfolge ist board-weit, nicht pro Spalte, und entweder automatisch oder von Hand. Automatisch sortiert nach attention, Fälligkeit, Aktualisierung, Erstellung oder Titel, wobei attention bewusst von der lesenden Person abhängt. Manuell sortiert über Ränge, die Workbench gehören, weil GitLab keine projektübergreifende Reihenfolge hat, aus der man lesen könnte. Ränge liegen dünn (Abstand 65536), ein Board wird erst umverteilt, wenn eine Einfügelücke unter 2 fällt, und sie bleiben erhalten, während automatisch sortiert wird. Wer zurückschaltet, findet seine Handarbeit wieder.

Die Ankerregel dazu ist unauffällig und trägt viel: Eine abgelegte Karte wird unmittelbar hinter die vorhergehende sichtbare Karte einsortiert. Damit ist ihre Position auch gegenüber den herausgefilterten Karten definiert, statt sich zu ändern, sobald jemand den Filter löst.

Was eine Karte zeigt Was einen Zug blockieren kann
Titel, Projekt und IID stehen immer missing_label: Label fehlt im Projekt der Karte
dazu bis zu vier konfigurierte Felder missing_milestone: Milestone fehlt im Projekt
aus assignee, milestone, due_date missing_assignee: Person auf dieser Instanz unbekannt
labels, project, work_item_type state_change_not_permitted: Zustandswechsel nur über Systemspalten
maximal 12 Spalten, 20 Quick Filters would_not_land: die Karte käme woanders heraus

WIP-Limits sind weiche Warnungen und zählen gegen den ungefilterten Board-Scope: ein Limit, das sich wegfiltern lässt, misst nichts. Und Züge per Tastatur oder Menü sind kein Zugeständnis an die Barrierefreiheit, das man hinter Drag-and-Drop nachreicht: Sie laufen durch dieselbe Transaktion mit derselben Simulation und denselben Ablehnungsgründen. Es gibt keinen Pfad, der nur mit der Maus erreichbar wäre.

Ein Umschlag mit Versionsnummer und ein Adapter, der nichts tut

Boards aktualisieren sich über Polling und beim Fokuswechsel. Kein SSE, keine WebSockets. Was dieses Milestone trotzdem ausliefert, ist die Naht dafür: ein versionierter, unveränderlicher Ereignisumschlag und ein Publisher-Port, dessen einziger Adapter nichts tut.

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

Der NoOp-Adapter ist nicht Faulheit, sondern der Grund, warum an den Aufrufstellen keine nil-Prüfungen stehen. Es gibt bewusst keine Ereignistabelle: Sie wäre eine sechste Mandantentabelle mit eigener Aufbewahrungshaftung, für einen Konsumenten, den es noch nicht gibt. Der Reducer im Client, der die Umschläge verarbeiten würde, kommt mit dem ersten echten Transport. Vorher liesse er sich gegen nichts prüfen.

Was das nächste Milestone braucht, ist die Naht. Wie gespeichert wird, darf es selbst entscheiden.

Was ich behalten würde

Was bewusst fehlt, steht im ADR und nicht in einem Ticket: keine Mitgliedschaftsprojektion, kein Anlegen von GitLab-Metadaten, kein Kill Switch, keine Swimlanes, keine board-übergreifenden Züge, keine Ereignistabelle.

Drei Dinge nehme ich mit:

  • Abgeleiteter Zustand kann nicht veralten. Wer keine Mitgliedschaft speichert, braucht keinen Abgleich dafür. Das ist kein Trick, sondern der Verzicht auf ein Feature, das sich als Wartungsvertrag herausstellt.
  • Ableiten heisst, mit dem Ablehnen zu rechnen. would_not_land ist der Preis dafür, dass die Spalte nicht geschrieben wird. Er ist ehrlich und kostet eine Simulation.
  • Langweilige Formate kompilieren. OR von AND mit genau einer Tiefe war die einzige Struktur, die UI und SQL beide ohne Sonderfälle bedienen.

Und damit zurück zur Ausgangsfrage. Wenn Agenten Arbeit bewegen, und sie tun es, hier wie bei Linear, dann ist ein Board, das sich merkt, wo eine Karte liegt, genau das falsche Gegenüber: Es muss informiert werden. Eines, das seine Spalten aus GitLab ableitet, muss es nicht. Es zeigt an, was passiert ist, unabhängig davon, wer oder was es getan hat. Issue Tracking ist nicht tot. Aber das Board, das seine eigene Buchhaltung führt, ist es vielleicht.

Der nächste Artikel dieser Serie geht von hier aus in die andere Richtung: weg von Systemen, die GitLab spiegeln, hin zu einem, das selbst etwas erzeugt.

Patrick Lehmann

Architecture & Governance Lead

Squibble GmbH

Bringt seit über zwanzig Jahren Struktur in gewachsene IT-Landschaften — als Architekt, Entwickler und Betreiber. Schreibt hier über die Systeme, die bei Squibble tatsächlich laufen, und über die Entscheide dahinter.

Weiterlesen

Workbench: ein Delivery-Index, der falsch liegen darf

Wir haben eine mandantenfähige Sicht über mehrere GitLab-Instanzen gebaut, ohne zur zweiten Wahrheit zu werden. Die Regel, die das trägt: die lokale Datenbank ist wegwerfbar, und korrekt macht sie die Reconciliation, nicht die Webhooks.

Patrick Lehmann
5 Min. Lesezeit

Env Manager: ein Secrets-Speicher, der seine eigenen Secrets nicht lesen kann

Ein Security-Review hat darauf hingewiesen, dass unser Secrets-Manager seinen eigenen Administratoren vertraut. Die Antwort war Zero-Knowledge-Ende-zu-Ende-Verschlüsselung, und der ehrliche Teil des Entwurfs ist die Liste der drei Stellen, an denen der Server trotzdem Klartext sieht, und warum wir jede davon akzeptiert haben.

Patrick Lehmann
6 Min. Lesezeit

Observability: das Lesemodell, das handelt

Workbench darf seine Datenbank wegwerfen und neu aufbauen. Diese Anwendung pollt Graylog und legt GitLab-Issues an, und ein Seiteneffekt hat kein Upstream. Warum Dry-Run zur Datenbankspalte wurde, warum das Dashboard keinen Löschen-Knopf hat, und warum die Schutzschranke an der HTTP-Grenze sitzt statt in der Konfiguration.

Patrick Lehmann
5 Min. Lesezeit