# Ausbaustufen — Munin Was bewusst **nicht** gebaut wurde, warum, und woran man merkt, dass es Zeit dafür ist. Der Sinn dieses Dokuments ist, dass niemand — auch nicht wir selbst in einem halben Jahr — dieselben Fragen noch einmal von vorne durchdenken muss. Wo Zahlen stehen, sind sie gemessen, nicht geschätzt; die Messumgebung ist jeweils genannt. --- ## Teil 1 — Entschieden und aufgeschoben Diese Punkte sind durchgerechnet. Die Entscheidung lautet jeweils „später", nicht „nie". ### 1.1 FTS5 statt eigenem Suchindex **Stand heute:** Ein handgeschriebener BM25-Index im Arbeitsspeicher (`internal/search`, 464 Zeilen plus 195 Zeilen Tests), einer je Projekt, beim ersten Zugriff aufgebaut. **Gemessen** (10.000 Einträge à 150 Wörter, Wortschatz 5.000, Zipf-verteilt): | | eigener Index | FTS5 | |---|---|---| | Arbeitsspeicher | 65 MB | 0 | | Platte | 0 | 54 MB | | Aufbau | 171 ms | 280 ms | | Suche | 12 µs | 208 µs | FTS5 ist im reinen Go-Treiber verfügbar (`ENABLE_FTS5`), der statische Build bliebe also erhalten. **Warum aufgeschoben:** - Der Suchindex ist **abgeleitete Daten**. Er lässt sich jederzeit aus `entries` neu aufbauen — eine Umstellung später bedeutet keine Datenmigration. Der übliche Grund, so etwas früh zu erledigen, greift hier nicht. - Es löst nicht den eigentlichen Engpass. Der ist die semantische Trefferqualität (siehe 2.2), und die kann FTS5 so wenig wie BM25 von Hand. - FTS5 gibt es nur in SQLite. Der eigene Index ist von der Datenbank unabhängig und hält damit den Weg zu PostgreSQL offen. **Auslöser für die Umstellung:** - Ein Projekt jenseits von grob 50.000 Einträgen (~300 MB Arbeitsspeicher allein für dessen Index) - Der Start dauert spürbar, weil viele große Projekte gleichzeitig angefragt werden - Phrasensuche, `NEAR` oder boolesche Operatoren werden gebraucht — die bringt FTS5 mit, der eigene Index nicht **Aufwand:** ein bis zwei Tage. Virtuelle Tabelle mit `content=`-Verweis auf `entries`, Befüllung aus dem Bestand, `internal/search` entfällt. ### 1.2 Eine SQLite-Datei je Projekt > **Nachtrag:** Mit 4.4 gibt es inzwischen eine Datei je *Workspace* und eine > zentrale Kontroll-Datenbank für die Zuordnung Key → Workspace. Die hier > besprochene Aufteilung je *Projekt* bleibt aufgeschoben. Die Messwerte unten > stammen aus der Zeit mit nur einer Datei und gelten jetzt je Workspace-Datei. **Stand damals:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen zum Lesen, eine zum Schreiben. **Gemessen:** | | | |---|---| | Schreibvorgänge über die eine Schreibverbindung | 8.656/s | | Lesen in Projekt A, ungestört | 82 µs | | Lesen in Projekt A, während drei Projekte im Dauerlauf schreiben | 121 µs | Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100 Schreibvorgänge/s — etwa **1 % der Kapazität**. **Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key → Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt: - Globales Protokoll und „Projekte mit Eintragszahl" werden aus einer Abfrage N Abfragen plus Zusammenführen in Go - Migrationen laufen über jede Datei, auch beim Anlegen eines Projekts - Verbindungsverwaltung mit Verdrängung nötig (50 Projekte × 9 Verbindungen) - Kein gemeinsamer Commit über zentrale und Projektdatenbank — „Projekt anlegen samt erstem Key" wäre nicht mehr atomar **Auslöser:** - Anhaltend über ~1.000 Schreibvorgänge/s (10 % der Kapazität) - Ein Projekt jenseits weniger GB, sodass Sicherung und `VACUUM` unangenehm werden - Verbindliche Löschzusagen je Projekt, bei denen „Zeilen gelöscht" nicht reicht **Billigere Schritte davor:** mehrere Einträge in einer Transaktion bündeln, WAL-Prüfpunkte seltener setzen. ### 1.3 Zeichenketten im Suchindex teilen `internal/search` hält in `docTerms` je Dokument eine Liste **aller seiner verschiedenen Wörter** als eigene Zeichenketten. Bei 10.000 Einträgen sind das über eine Million einzeln abgelegte Zeichenketten, obwohl im Projekt vielleicht nur 20.000 verschiedene Wörter vorkommen. Die Liste ist nötig: ohne sie müsste beim Ändern oder Löschen der komplette Index durchsucht werden. Sie ließe sich aber verkleinern, indem gleiche Wörter dieselbe Zeichenkette teilen. **Geschätzt ein Drittel bis die Hälfte** des Speichers. **Warum aufgeschoben:** Löst dasselbe Problem wie 1.1, nur halb. Wenn der Speicher wirklich drückt, ist FTS5 der gründlichere Schritt. ### 1.4 sqlc für die Datenschicht War empfohlen, dann verworfen: sqlc ist nicht installiert, und ein zusätzlicher Codegenerator bedeutet, dass `go build` allein nicht mehr reicht. Handgeschriebenes `database/sql` ist außerdem besser lesbar für jemanden, der Go lernt. **Nachrüstbar,** ohne eine Zeile SQL zu ändern — das SQL ist bereits so geschrieben, wie sqlc es erwartet. --- ## Teil 2 — Trefferqualität **Hier liegt der eigentliche Engpass.** Alles andere in diesem Dokument ist Ressourcenoptimierung; das hier entscheidet darüber, ob ein Agent findet, was er sucht. ### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt `tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim Suchen (siehe `faltUmlaut` in `internal/search/index.go`). ``` prüfung ↔ prufung ✓ größe ↔ grosse ✓ prüfung ↔ pruefung ✗ ``` **Korrektur zur früheren Fassung dieses Abschnitts:** Dort stand, die Umschreibung mit e (`ue`, `oe`, `ae`) werde mit abgedeckt. Das stimmt nicht. Sie ließe sich nur abdecken, indem man auch `ue` zu `u` zusammenzieht — dann würde aber aus „neue" ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der Nutzen. Die Punkte wegzulassen ist ohnehin die weit häufigere Schreibweise. Gegenüber FTS5 mit `remove_diacritics` ein Vorteil: Das faltet zwar Umlaute, aber kein `ß` — dort findet „grosse" die „Größe" nicht. ### 2.2 Semantische Suche **Problem:** BM25 vergleicht Wörter. Fragt ein Agent „wie deployen wir", findet er einen Eintrag namens „Release-Prozess" nicht — kein Wort passt. **Lösung:** Einbettungen (Embeddings) plus Vektorsuche, kombiniert mit BM25 („hybride Suche"): BM25 ist stark bei seltenen, exakten Begriffen — Fehlercodes, Eigennamen —, die Vektorsuche bei Umschreibungen. **Bauform:** ein eigener kleiner Dienst, angebunden über HTTP. Python hat dafür das deutlich reichere Ökosystem; die Go-Anwendung bliebe ein Binary. **Aufwand:** mehrere Tage, plus laufender Betrieb eines zweiten Dienstes und eines Einbettungsmodells. **Vorher versuchen:** die `instructions` an echten Anfragen schärfen (siehe 3.1). Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts. ### 2.3 Lange Einträge zerlegen `get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern belegt beim Agenten Kontext, von dem er zwei Absätze braucht. **Lösung:** Einträge in Abschnitte zerlegen (an Überschriften) und abschnittsweise zurückgeben, mit Verweis auf den vollständigen Eintrag. **Vorher prüfen:** ob überhaupt so lange Einträge entstehen. Bei einem Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne Problem. ### 2.4 Stoppwörter und Wortstämme Häufige Wörter („und", „der", „ist") blähen den Index auf, ohne zu unterscheiden. Und „Deployment" findet „Deployments" nicht. Beides ist Standardwerkzeug jeder Suchmaschine, kostet aber Genauigkeit bei Fachbegriffen. **Erst messen, ob es im Alltag stört** — BM25 gewichtet häufige Wörter ohnehin bereits herunter. --- ## Teil 3 — Funktionen für Agenten ### 3.1 Die `instructions` an echten Anfragen schärfen Der Text in `internal/mcpserver/server.go` steht bei jedem Client im Kontext und entscheidet darüber, ob ein Agent vor dem Schreiben sucht oder blind Dubletten anlegt. **Vorgehen:** Nach ein paar Wochen Betrieb ins Protokoll schauen — wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren? Ein hoher Anteil Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht. **Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben. ### 3.2 Importer als Gegenstück zum Export Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle Felder im Kopf, ein Einlesen wäre also möglich. **Wozu:** Umzug auf einen anderen Server, Wiederherstellung aus der Sicherung, Erstbefüllung aus vorhandener Dokumentation. **Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert — überschreiben, überspringen oder abbrechen. ### 3.3 Dubletten über Ähnlichkeit erkennen Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da. **Lösung:** Bei `remember` mit neuem Bezeichner den vorhandenen Suchindex über Titel und Inhalt laufen lassen. Liegt ein Treffer über einer Schwelle, den Eintrag **nicht** anlegen, sondern die Kandidaten zurückgeben und den Agenten entscheiden lassen: ```json { "created": false, "judgment_required": true, "candidates": [ { "slug": "deployment-prozess", "title": "Deployment-Prozess", "score": 0.81 } ], "hint": "Passt einer davon? Dann mit dessen slug erneut remember aufrufen. Sonst mit force: true anlegen." } ``` Der entscheidende Punkt ist, die Entscheidung **zurückzugeben** statt sie zu treffen. Ein Agent, der einen Hinweis bekommt, korrigiert sich; einer, dem der Aufruf stillschweigend abgelehnt wird, versucht es mit einem noch schlechteren Bezeichner. Braucht keine Einbettungen — der BM25-Index liegt bereits vor. **Vorbild:** [Engram](https://github.com/Gentleman-Programming/engram) macht genau das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster ist gut und ließe sich mit unserem Index eins zu eins übernehmen. ### 3.4 Bezeichner vorschlagen lassen Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn raten. Ein Werkzeug `suggest_slug(thema)` würde ihm die Arbeit abnehmen: - gibt es zum Thema bereits einen Eintrag → dessen slug zurückgeben - sonst einen neuen vorschlagen, der zu den vorhandenen passt Damit wird aus „hoffentlich wählt das Modell einen guten Bezeichner" ein Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen vor. **Vorbild:** Engrams `mem_suggest_topic_key`. ### 3.5 Aktualität von Wissen Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit demselben Gewicht aus `recall` zurück. **Kleiner Schritt zuerst:** Das Änderungsdatum steht bereits in jeder Antwort, wird aber nicht betont. Einträge jenseits einer Frist zusätzlich mit einem sichtbaren Vermerk versehen: ```json { "slug": "deployment-prozess", "updated_at": "2024-03-11", "stale": true, "hint": "Seit 18 Monaten unverändert — vor Verwendung prüfen." } ``` **Größerer Schritt:** eine Prüffrist je Eintrag, die nach Art unterschiedlich schnell abläuft — ein Zugangsverfahren altert schneller als eine Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein Bestätigen, das die Frist zurücksetzt. **Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung, mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich ausgebauter als das, was wir bräuchten — aber die Richtung stimmt. ### 3.6 Änderungshistorie — ✅ umgesetzt `entries.version` zählt hoch, aber alte Fassungen wurden nicht aufgehoben. Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg. **Umgesetzt:** Tabelle `entry_versions` (`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird. Läuft in derselben Transaktion wie die Änderung selbst, sowohl beim normalen Schreiben (`PutEntry`) als auch bei der Sammelbearbeitung (`BulkUpdateTags`, siehe 5). Löschen eines Eintrags nimmt seine Historie über `ON DELETE CASCADE` mit — konsistent mit Tags und Verweisen. Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn `EXPORT_DIR` gesetzt ist, während die Historie in der Datenbank immer mitläuft. ### 3.7 Verwandte Einträge Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten ein zweiter Weg zum Kontext — neben der Suche. **Aufwand:** gering, die Daten liegen vor. ### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in `internal/web/middleware.go`) — richtig für Menschen hinter einer gemeinsamen Adresse, aber eine Schleife im Agenten hatte damit kein Limit. **Umgesetzt als `auth.WriteBudget`:** ein Fixed-Window-Zähler über schreibende MCP-Aufrufe, Vorgabe **1.000 je Stunde** (`WRITE_LIMIT_HOURLY`), durchgesetzt in `Handler.requireWrite` in `internal/mcpserver/tools.go`. Lesen bleibt unbegrenzt. **Korrektur zum ursprünglichen Vorschlag:** Dort stand „je Key und Stunde". Das Budget hängt jetzt am **Projekt**. Der Grund: Ein Limit je Key lässt sich umgehen, indem man einen zweiten Key anlegt — und das darf jeder Projekt-Admin selbst. Es hätte also nur vor dem Versehen geschützt, nicht vor dem Verbrauch. Einzelne Projekte bekommen unter `/admin/projects` eine eigene Obergrenze; setzen darf sie nur der Plattform-Admin, aus demselben Grund. **Drei Dinge, die beim Bauen auffielen:** 1. **Die Obergrenze darf nicht zwischengespeichert werden.** Die MCP-Server je Projekt sind Closures über eine Projektzeile, die beim ersten Zugriff gelesen wurde — eine Änderung in der Verwaltung wäre dort nie angekommen. Genommen wird deshalb `sess.Project.WriteLimitHourly` aus der Sitzung: Die hat `APIAuth` für genau diesen Request frisch aus der Datenbank geholt. Aktuell, und ohne zusätzliche Abfrage im Schreibpfad. 2. **Jede Ablehnung zu protokollieren hätte das Problem nur verschoben.** Ein Agent in einer Schleife hätte statt der Einträge das Protokoll vollgeschrieben. Deshalb gibt `Consume` als dritten Wert zurück, der wievielte Aufruf über der Grenze das war — vermerkt wird nur der erste je Fenster. 3. **Die Fehlermeldung geht an ein Sprachmodell, nicht an einen Menschen.** Ein nackter Fehler führt dazu, dass es sofort wieder versucht oder auf ein anderes Werkzeug ausweicht. Die Meldung sagt darum ausdrücklich, was zu tun ist: abbrechen und das Limit melden. **Was das Budget nicht ist:** Der Zählerstand liegt im Arbeitsspeicher und ist nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört der Zähler in die Datenbank. ### 3.9 Papierkorb statt endgültigem Löschen — ✅ umgesetzt `forget` löscht heute sofort und endgültig, samt aller Fassungen (3.6, `ON DELETE CASCADE`). Das gilt auch, wenn ein Agent es aufruft. Überschreiben lässt sich über den Verlauf reparieren, Löschen nicht. Damit ist Löschen der schlimmste Fall, den ein fehlgeleiteter Agent heute anrichten kann. **Lösung:** Löschen setzt nur eine Markierung (`deleted_at`, `deleted_by`). Markierte Einträge verschwinden aus Suche, Liste, Graph und MCP-Antworten, bleiben aber samt Verlauf in der Datenbank. Eine Seite *Papierkorb* je Projekt zeigt sie. Menschen mit Schreibrecht können sie dort wiederherstellen oder endgültig löschen. Nach einer Frist (Vorschlag 30 Tage) räumt die tägliche Aufgabe sie endgültig weg, im selben Lauf wie das Protokoll (siehe 4.4, WP12). **Zu bedenken:** - Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt. Schreibt jemand mit demselben Bezeichner neu, muss klar sein, was passiert. Vorschlag: Das stellt den Eintrag wieder her und überschreibt ihn, wobei der alte Stand wie gewohnt in den Verlauf wandert. - Jede Abfrage auf `entries` braucht die Bedingung `deleted_at IS NULL`. Das ist dieselbe Art Risiko, die 4.4 für Workspaces bewusst vermeiden wollte. Hier ist es vertretbar, weil ein vergessenes `WHERE` nur einen gelöschten Eintrag des *eigenen* Projekts zeigt. Trotzdem gehört ein Test dazu, der jeden lesenden Weg (Suche, Liste, `get`, `recall`, Graph, Rückverweise, Export) prüft. **Aufwand:** mittel. **✅ Umgesetzt 2026-09-24:** - **Schema:** `migrations/005_papierkorb.sql`: `deleted_at` (NULL = normal), `deleted_by` und ein Teilindex nur über den Papierkorb. - **Store:** `internal/store/papierkorb.go`. `DeleteEntry` gibt es nicht mehr, an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur `PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im Papierkorb liegt. - **Lesewege:** Alle tragen `nichtImPapierkorb`. `ListTags` schließt die Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen. `TestPapierkorbUnsichtbar` prüft jeden Weg einzeln, `TestForgetMovesToTrash` dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`). - **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf (`Entry.RestoredFromTrash`). Web und `remember` sagen das dem Schreiber, das Protokoll vermerkt `entry_restore`. Ausnahme: Wer eine gelesene Fassung mitschickt (`ExpectedVersion`), bekommt einen Konflikt. Das ist der Fall „Formular offen, währenddessen hat ein Agent gelöscht“, und eine Löschung soll nicht unbemerkt rückgängig werden. - **MCP:** `forget` legt in den Papierkorb und sagt das in Beschreibung und Antwort. Wiederherstellen oder endgültig löschen gibt es über MCP bewusst nicht. `remember` erkennt „angelegt“ jetzt an der Fassung nach dem Schreiben statt über eine Nachfrage vorher, die den Papierkorb nicht gesehen hätte. - **Oberfläche:** `/p/{project}/trash`, nur mit Schreibrecht, auch das Ansehen: Gelöscht wird oft, was nicht jeder sehen soll. Es gibt Wiederherstellen und endgültiges Löschen je Zeile oder für eine Auswahl, dazu einen Filter nach löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am gedrückten Knopf (`event.submitter`). - **Frist:** `store.TrashRetention` = 30 Tage. `papierkorbLeerenAlle` in `main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und schreibt je Eintrag eine Protokollzeile mit Herkunft `system`. Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie dort mitziehen. - **Protokoll:** Neue Aktionen `entry_restore` und `entry_purge`. `entry_delete` bedeutet jetzt „in den Papierkorb“ (Detail sagt es). --- ## Teil 4 — Betrieb ### 4.1 Zugriffsprotokoll — ✅ umgesetzt `LogRequests` in `internal/web/middleware.go` schreibt je Anfrage eine Zeile: ``` 200 POST /mcp 1.475ms claude-desktop 202 POST /mcp 149µs claude-desktop 401 POST /mcp 193µs — ``` Statische Dateien und der Healthcheck bleiben draußen (Rauschen), die Query-Zeichenkette ebenfalls — dort stehen Suchbegriffe, und die sind Inhalt, kein Betriebsdatum. **Zwei Fallstricke, die beim Bauen auffielen:** 1. Der Mantel um den `ResponseWriter` verdeckte `http.Flusher`. Der MCP-Transport kommt über `http.ResponseController` und damit über `Unwrap()` heran, die verbreitetere direkte Typzusicherung `w.(http.Flusher)` hätte aber lautlos fehlgeschlagen — der Aufrufer nimmt dann an, Leeren werde nicht unterstützt. Deshalb ist `Flush()` ausdrücklich implementiert. 2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`), den die Anmelde-Middleware befüllt. ### 4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt `.github/workflows/test.yml` prüft bei jedem Push und jedem Pull Request: Formatierung, `go vet`, `go test -race` und einen Bau mit `CGO_ENABLED=0`. `-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl, sobald jemand eine Abhängigkeit hinzufügt, die C braucht — das statische Binary ist die Grundlage des ganzen Deployments. ### 4.3 Keycloak statt Key-Anmeldung Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf `api_key_id`, nicht auf Klarnamen. Kommt eine Nutzerverwaltung dazu, lässt sich `user_id` danebenstellen, ohne die Historie zu verlieren. **Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an Personen statt an Keys. ### 4.4 Mehrere Workspaces — ✅ umgesetzt **Ausgangslage:** Die Spalte `workspace_id` stand von Anfang an in allen Tabellen, es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald Abteilungen sich gegenseitig nicht sehen sollen. **Entscheidung:** Abteilungen dürfen sich strukturell nie sehen — nicht nur "in der Praxis meistens nicht". Eine gemeinsame Tabelle mit Filterung über `WHERE workspace_id = ?` hängt davon ab, dass diese Bedingung in jeder gegenwärtigen und jeder künftigen Abfrage korrekt steht — bei einem einzelnen Entwickler ohne Review durch Dritte ein zu hohes Risiko für ein einziges vergessenes `WHERE`. Gleichzeitig muss der Betrieb zentral bleiben: ein Deployment für anfangs 10, später bis zu 40 Teams (1–5 Projekte je Team) statt eines eigenen Deployments pro Abteilung, das niemand von Hand pflegen kann. Eigene Backups je Team sind Pflicht. **Konzept:** eine dünne Kontroll-Datenbank (Workspaces, Key-Hash → Workspace-Routing) plus eine eigene SQLite-Datei je Workspace mit dem heutigen, unveränderten Schema. Trennung ist damit strukturell (eigene Datei), nicht durch Disziplin in jeder Abfrage erzwungen. Backups werden dadurch nebenbei trivial — eine Datei je Team lässt sich einzeln sichern. Bei 10–40 Workspaces ist das unproblematisch — deutlich unterhalb der in 1.2 genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde (die galt für *Projekte*, nicht für Workspaces, und für eine viel höhere Zahl). **Arbeitspakete:** 1. ✅ Kontroll-Schema: `workspaces` + `key_index` (hash → workspace_id) — `internal/store/migrations_control/001_init.sql` 2. ✅ `ControlStore`: kleines Store-Objekt für die Kontroll-DB — `internal/store/control.go` 3. ✅ Key-Routing zweistufig: `ControlStore.ResolveKeyWorkspace` (hash → workspace_id) davor, danach die heutige `ResolveKeyHash`-Logik unverändert auf der passenden Workspace-Datei — sowohl beim Web-Login (`handlers_auth.go`) als auch bei MCP-Aufrufen (`APIAuth` in `middleware.go`). `CreateAPIKey`/`RevokeAPIKey` pflegen `key_index` im selben Zug. 4. ✅ Verbindungscache je Workspace: `internal/workspace.Manager` öffnet Workspace-Dateien bei Bedarf und hält sie offen (`map[workspaceID]*Workspace`, ein `sync.Mutex`). Bei 10–40 Workspaces keine Verdrängung nötig. 5. ✅ Workspace-Auflösung pro Request ersetzt das feste `WorkspaceID` in `web.Config`: Das Session-Subject trägt die Workspace-ID direkt (`key::` bzw. `platform:`, siehe `sessionFromSubject` in `middleware.go`) — signiert, also fälschungssicher, und ohne Umweg über die Kontroll-DB bei jedem Request. Für den Plattform-Admin (ein globales `ADMIN_TOKEN`, siehe Entscheidung oben) kommt die Workspace-Wahl über `/admin/workspaces` dazu (`handlers_workspaces.go`) — eine frische Admin-Sitzung ohne gewählten Workspace kommt an keine Projektseite, `requireWorkspace` in `middleware.go` erzwingt das. MCP-Werkzeuge lesen ihren Workspace aus dem Context (`web.WorkspaceFromContext`, siehe `mcpserver/tools.go`) statt aus einer festen Handler-Instanz — ein `mcpserver.Handler` bedient dadurch beliebig viele Workspaces gleichzeitig. 6. ✅ Migrationen laufen für die Kontroll-DB und beim Öffnen jeder Workspace-Datei (`store.Open`/`store.OpenControl`, `main.go` legt `DATA_DIR/control.db` und `DATA_DIR/workspaces/.db` an) 7. Datenmigration für eine bereits befüllte Bestandsinstallation (einmaliges Skript) — bislang nicht nötig, es läuft noch keine produktive Instanz 8. ✅ Workspace-Verwaltung, vollständig: `/admin/workspaces` zeigt alle Workspaces (neueste zuerst, mit Anlegedatum und einer "Aktuell"-Markierung), legt neue an (springt danach direkt zur Projektverwaltung, kein Umweg über die Auswahl) und verlinkt je Zeile auf `/admin/workspaces/{id}/edit` zum Umbenennen. Dort auch die Gefahrenzone: Löschen entfernt erst die Kontroll-Zeile (samt `key_index` über `ON DELETE CASCADE`), dann die Workspace-Datei von der Platte (`workspace.Manager.Delete`) — mit Bestätigung durch Eintippen des Namens, wie beim Löschen eines Projekts. Betrifft das den Workspace der eigenen Sitzung, wird die Sitzung auf "kein Workspace gewählt" zurückgesetzt statt den Admin auszusperren. Dazu ein Workspace-Umschalter in der Kopfleiste (nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`, `.arbeitsbereich*` in `static/style.css`). 9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner, optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es samt Cron-Beispiel. 10. ✅ Tests: `internal/store/control_test.go` (`ControlStore`/Routing), `internal/workspace/workspace_test.go` (Manager cached, trennt Dateien, schließt sauber), `internal/web/web_test.go` (`TestWorkspaceIsolation` — zwei gleichzeitig offene Workspace-Dateien im selben Prozess kommen sich nicht in die Quere) 11. ✅ README, `.env.example`, `Dockerfile`: `DB_PATH` → `DATA_DIR` **Stand:** Alle Arbeitspakete umgesetzt bis auf eines: eine Datenmigration für eine bereits befüllte Bestandsinstallation, falls die je nötig wird (7, bislang läuft keine produktive Instanz). Mehrere Workspaces gleichzeitig in einem Prozess, strukturell getrennt, mit Oberfläche zum Anlegen und Wechseln und eigenem Backup-Skript — fertig und end-to-end geprüft (Tests plus manueller Durchlauf gegen den laufenden Server bzw. das Skript). **Nachgezogen** (beim Abgleich der Doku mit dem Code am 2026-09-24 gefunden und am selben Tag behoben): 12. ✅ Wiederkehrende Aufgaben über alle Workspaces. Vorher bekamen `pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace. Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1) griff dort nicht. Jetzt gehen beide in jedem Durchgang über `fuerJedenWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`. 13. ✅ Export je Workspace. Vorher war der Exporter beim Start fest an den Store des ältesten Workspace gebunden (*Jetzt exportieren* in einem anderen Workspace schrieb nichts), und die Zielordner hießen nur `EXPORT_DIR/`, sodass sich Workspaces mit gleichem Projekt-Bezeichner gegenseitig überschrieben und Dateien wegräumten. Jetzt kennt `export.Exporter` keine Datenbank mehr, der Aufrufer gibt den Store je Lauf mit (`ExportWorkspace`). Die Ordner heißen `EXPORT_DIR///`, dazu `_workspace.md` mit dem Namen. Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner erzeugt. `./munin -export` exportiert alle Workspaces und endet mit Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`, `TestExportKnopfImZweitenWorkspace`, `main_test.go`. Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden. ### 4.5 Kennzahlen Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht. ### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der Oberfläche. Die Hilfe (*Mit Agenten arbeiten*) rät, zu prüfen, ob Aktualisierungen gegenüber Neuanlagen zunehmen. Das lässt sich heute nur mühsam aus dem Protokoll ablesen. **Inhalt einer kleinen Übersichtsseite je Projekt:** - Schreibvorgänge der letzten 30 Tage, aufgeteilt in Neuanlagen und Aktualisierungen, getrennt nach Mensch und Agent - Anteil kuratierter Einträge - Einträge ohne Schlagwort, Verweise auf nicht vorhandene Einträge - zuletzt aktive Zugänge Für den Plattform-Admin dieselben Zahlen als Spalten in `/admin/projects`. So fällt auf, welches Team Munin gar nicht benutzt. **Voraussetzung — ✅ erledigt (2026-09-24):** Das Protokoll vermerkt bei `entry_write` im Feld `detail`, ob der Eintrag `angelegt` oder `aktualisiert` wurde (`store.EntryWriteDetail`, Konstanten `DetailEntryCreated` und `DetailEntryUpdated` in `internal/store/audit.go`), sowohl bei `remember` als auch in der Weboberfläche. Maßgeblich ist die Fassung nach dem Schreiben (1 = angelegt), das stimmt auch bei gleichzeitigem Schreiben. Die Sammelbearbeitung schreibt weiterhin ihren eigenen Text und zählt als Aktualisierung. Schreibvorgänge vor diesem Datum lassen sich nicht unterscheiden. **Aufwand:** gering bis mittel, die Daten liegen im Protokoll und in `entries`. **✅ Umgesetzt 2026-09-24:** - **Seite:** `/p/{project}/usage` (`handlers_nutzung.go`), für jede Rolle. Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle sieht. - **Zahlen:** `store.UsageSummaries` (`internal/store/nutzung.go`) liefert Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt keine eigene Zähltabelle, die auseinanderlaufen könnte. - **Mensch oder Agent:** Unterschieden wird nach Weg: Web = Mensch, MCP = Agent. Ein Mensch, der selbst über MCP schreibt, zählt als Agent. - **Neu oder aktualisiert:** Maßgeblich ist `detail`. `angelegt` ist neu; alles andere außer leer ist eine Aktualisierung („aktualisiert“, „wiederhergestellt: …“, „Sammelbearbeitung: …“). Leer heißt „ohne Angabe“: Das sind Zeilen aus der Zeit vor der Unterscheidung, die nach 30 Tagen von selbst herausfallen. - **Der entscheidende Wert:** der Anteil der Aktualisierungen, mit einem Hinweis zur Deutung. Eine feste Schwelle „gut/schlecht“ gibt es bewusst nicht, denn ein junges Projekt legt naturgemäß vor allem an. - **Weitere Abschnitte:** Verweise ins Leere (meistgenannte zuerst, mit Schreibrecht „anlegen“ über `/new?slug=…`), Einträge ohne Schlagwort, die fünf zuletzt aktiven Zugänge. - **Admin:** `/admin/projects` hat die Spalten *Schreibvorgänge* (30 Tage, mit „keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt. ### 4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt `last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben, oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang. **Lösung, in zwei Stufen:** 1. Unter *Zugänge* Keys markieren, die seit mehr als 90 Tagen (oder nie) benutzt wurden, mit einem Knopf zum Zurückziehen. Gering. 2. Optional ein Ablaufdatum beim Anlegen. Abgelaufene Keys gelten wie zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in `ResolveKeyHash`. Mittel. **✅ Stufe 1 umgesetzt 2026-09-25:** - **Regel:** `APIKey.Unused` in `internal/store/models.go`. Ein aktiver Key gilt als ungenutzt, wenn er seit mehr als 90 Tagen (`UnusedAfter`) nicht benutzt wurde. Wurde er noch nie benutzt, zählt das Anlegedatum. Sonst stünde jeder frische Key sofort als ungenutzt da. Zurückgezogene Keys sind nie ungenutzt. - **Oberfläche:** Unter *Zugänge* trägt so ein Key die Marke *ungenutzt*, über der Tabelle steht ein Hinweis mit der Anzahl. Den Knopf zum Zurückziehen gab es schon. Keine Migration, `last_used_at` reicht. - **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine Grenze von 90 Tagen spielt das keine Rolle. - **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`. - **Offen:** Stufe 2 (Ablaufdatum). ### 4.8 Admin je Workspace Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40 Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen. **Lösung:** Eine Rolle „Workspace-Admin“, die im eigenen Workspace Projekte anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt). **Abhängigkeit:** Hängt eng mit 4.3 (Keycloak) zusammen. Mit Personen statt Keys wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht 4.8 vorziehen und später umbauen. **Aufwand:** hoch. --- ## Teil 5 — Oberfläche | | Nutzen | Aufwand | |---|---|---| | ✅ Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) | | ✅ Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel | | ✅ Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch | | ✅ Vergleich zweier Fassungen | setzt 3.6 voraus | mittel | | ✅ Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering | | ✅ Eingebaute Hilfe unter `/hilfe` | Onboarding ohne Umweg über das Repository | mittel | | ✅ Fassung wiederherstellen (5.1) | Fehler eines Agenten mit einem Klick zurücknehmen | gering | | ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering | | ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering | | ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering | Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist der erste. ### 5.1 Fassung wiederherstellen — ✅ umgesetzt Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus dem Vergleich kopieren und von Hand einfügen. **Lösung:** Knopf *Diese Fassung wiederherstellen* in der Versionsansicht (nur mit Schreibrecht). Technisch ist das ein gewöhnliches `PutEntry` mit Titel, Inhalt und Schlagworten der alten Fassung und `ExpectedVersion` der aktuellen. Der aktuelle Stand wandert dabei selbst in den Verlauf, es geht nichts verloren. Das Protokoll sollte vermerken, dass es eine Wiederherstellung war und von welcher Fassung. **✅ Umgesetzt 2026-09-24:** `POST /p/{project}/e/{slug}/versions/{version}/restore` (`handleEntryRestore`, `internal/web/handlers_entries.go`), Knopf auf der Vergleichsseite und je Zeile im Verlauf, nur mit Schreibrecht. Das Formular schickt die beim Anzeigen aktuelle Fassung als `current_version` mit; fehlt sie, wird abgelehnt (400) statt ohne Konfliktprüfung zu schreiben, bei einem Konflikt kommt die Verlaufsseite mit Hinweis (409). Die Markierung *kuratiert* bleibt, wie sie ist — sie beschreibt den Eintrag, nicht einen Textstand. Im Protokoll steht `wiederhergestellt: Fassung n` (`store.EntryRestoreDetail`); für die Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%'`). ### 5.2 Echte Server-Adresse auf der Zugangsseite — ✅ umgesetzt `keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`. Die Hilfe setzt die Adresse schon ein (`serverURL` in `internal/web/handlers_hilfe.go`). Dieselbe Funktion für `keyListData` verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen. **✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in `renderKeyList`; `keys.html` nutzt es in allen drei Konfigurationsblöcken. ### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt `entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in Einträgen deshalb als Text, in der Hilfe (`internal/hilfe`) dagegen als Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen, Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf der Hilfeseite *Weboberfläche* entfernen und Hilfe und Einträge über denselben Renderer laufen lassen. **✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und `hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied: `RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht — Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche" soll nicht mit einer id der Seite selbst zusammenstoßen können. `markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch `javascript:`-Links durchkommen. Die Hilfe beschreibt jetzt Tabellen, Checklisten, Durchstreichen und automatische Links. ### 5.4 Hilfe an Ort und Stelle verlinken — ✅ umgesetzt Kleine „?“-Links von einer Seite zur passenden Hilfe, zum Beispiel *Zugänge* → `/hilfe/zugaenge`, Editor → Markdown-Abschnitt, leere Eintragsliste → *Agent anbinden*. Dazu Überschrift-Anker in den Hilfeseiten (`parser.WithAutoHeadingID`), damit Links auf einen Abschnitt zeigen können. **✅ Umgesetzt 2026-09-24:** Partial `hilfelink` in `partials.html`, ein kleines „?" neben Überschriften und dem Inhaltsfeld des Editors: Einträge, Editor (Markdown-Abschnitt), Verlauf, Vergleich, Zugänge, Protokoll (je nach Ansicht Projekt- oder Admin-Hilfe), Workspaces, Projekte. Die leere Eintragsliste verlinkt auf *Agent anbinden* und *Mit Agenten arbeiten*. Die Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein ungespeicherter Text verloren geht. Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute einfach fallen („zugnge-verwalten") —, sondern `ankerIDs` in `internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe Regel wie für Eintragsbezeichner. `TestHilfelinksZeigenAufVorhandeneAbschnitte` liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so im Test, welcher Link dadurch ins Leere zeigen würde. **✅ Sammelbearbeitung umgesetzt:** `entries.html` zeigt bei Schreibrecht ein Auswahlkästchen je Eintrag plus eine Sammelleiste (Schlagworte hinzufügen/ entfernen, kommagetrennt). `Store.BulkUpdateTags` (`internal/store/entries.go`) prüft je ID, dass sie wirklich zum aufrufenden Projekt gehört — eine untergeschobene ID aus einem fremden Projekt wird stillschweigend übersprungen, nicht verändert. Nur tatsächlich geänderte Einträge zählen als Treffer und zählen ihre `version` hoch; der Suchindex wird für sie nachgezogen. Kein JavaScript nötig — ein einziges Formular um die ganze Liste. **✅ Fassungsvergleich umgesetzt:** Baut auf 3.6 auf. `/p/{project}/e/{slug}/versions` zeigt den Verlauf, `/versions/{version}` vergleicht eine abgelöste Fassung mit ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine mehr gibt, der aktuell gültigen. Der Zeilenvergleich selbst (`internal/entry/diff.go`, `DiffLines`) ist eine klassische LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für außergewöhnlich lange Einträge (`grobDiff` oberhalb von vier Millionen Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern. **✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte JavaScript im Projekt (`static/preview.js`, ~90 Zeilen) neben `confirm.js`. Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld in `edit.html`; ein Klick auf "Vorschau" schickt den aktuellen Textarea-Inhalt per `fetch` an `POST /p/{project}/preview` (`handleEntryPreview`, `internal/web/handlers_entries.go`) und zeigt das Ergebnis an. Bewusst kein Markdown-Parsing im Browser: Der Endpunkt ruft denselben `entry.RenderHTML` auf wie die Detailansicht, Vorschau und späteres Ergebnis können also nicht auseinanderlaufen — und es ist keine externe JS-Bibliothek nötig, die an der Content-Security-Policy (`script-src 'self'`) vorbeigemusst hätte. Ohne JavaScript bleibt einfach der Reiter "Schreiben" aktiv, das Formular funktioniert unverändert. Kein Live-Rendern beim Tippen, nur auf Klick — hält die Zahl der Aufrufe klein. **✅ Autovervollständigung umgesetzt:** `static/autocomplete.js` erkennt beim Tippen ein noch nicht geschlossenes `[[`, fragt `GET /p/{project}/entries/suggest?q=…` (`handleEntrySuggest`, `internal/web/handlers_entries.go`) und zeigt die Treffer als Liste unter dem Feld — bewusst nicht an der Schreibmarke ausgerichtet, das bräuchte einen Spiegel-Trick zur Pixel-Positionierung im Textfeld, den die Liste unter dem Feld erspart. Tastatur (Pfeiltasten, Enter/Tab, Escape) und Maus bedienbar. Die Suche selbst dupliziert keine Logik: Mit Suchbegriff läuft dieselbe BM25-Volltextsuche wie bei `recall` (MCP) und der Projektsuche (`search.Registry`), ohne Suchbegriff kommen die zuletzt geänderten Einträge. Ausgewählt wird der Bezeichner (slug), nicht der Titel — das ist, was `[[…]]` erwartet. **✅ Graph-Ansicht umgesetzt:** `/p/{project}/graph` (Seite) und `/p/{project}/graph/data` (JSON, `handleEntryGraphData`). Datenseitig neu ist nur `Store.LinkGraph` (`internal/store/entries.go`) — eine Abfrage über den bereits vorhandenen, indizierten `entry_links`. Verweise auf einen Bezeichner ohne Eintrag erzeugen einen eigenen, gestrichelten Knoten (`Exists: false`) statt stillschweigend zu verschwinden — sichtbar macht, wohin schon verwiesen wird, was aber noch fehlt. Begrenzung wie in der Aufwands-/Risikoabschätzung besprochen: `graphNodeLimit = 150` — bei mehr Einträgen bleiben die am stärksten verknüpften übrig (nach Grad sortiert), die Antwort trägt `truncated: true` und die Gesamtzahl, die Seite zeigt einen Hinweis. Das hält sowohl die Kräftesimulation (`static/graph.js`, O(Knoten²) je Durchlauf) als auch die SVG-Darstellung in einem Bereich, der im Browser flüssig bleibt. Kein externes Diagramm-Werkzeug: Die Kräftesimulation (Abstoßung zwischen allen Knotenpaaren, Anziehung entlang der Kanten, schwache Zentrierung) ist selbst geschrieben, läuft einmal beim Laden durch und rendert dann reines SVG. Texte werden ausschließlich über `textContent` gesetzt, nie `innerHTML` — Eintragstitel kommen von Menschen und Agenten und sind damit nicht vertrauenswürdig. Zoom (Mausrad) und Pan (Ziehen) über eine simple Transform-Gruppe, keine Abhängigkeit auf eine Bibliothek. --- ## Empfohlene Reihenfolge Wenn es demnächst weitergehen soll, in dieser Folge — jeweils kleiner Aufwand, sichtbarer Gewinn: ~~1. Zugriffsprotokoll (4.1)~~ · ~~2. Umlaute falten (2.1)~~ · ~~3. Automatisierte Tests (4.2)~~ — erledigt. ~~4. Obergrenze je Projekt (3.8)~~ — erledigt. Als Nächstes: *Stand der Priorisierung: 2026-09-24.* **Vor dem zweiten Workspace** 1. ~~**Offene Reste aus 4.4** (WP12 und WP13): Protokoll aufräumen und Export für alle Workspaces.~~ — erledigt 2026-09-24, zusammen mit der Unterscheidung angelegt/aktualisiert im Protokoll (Voraussetzung für 4.6). **Klein, sofort spürbar für die Teams** 2. ~~**Fassung wiederherstellen** (5.1) und **Server-Adresse auf der Zugangsseite** (5.2). Zusammen ein kleines Paket.~~ — erledigt 2026-09-24. 3. ~~**Mehr Markdown** (5.3) und **Hilfe an Ort und Stelle** (5.4).~~ — erledigt 2026-09-24. **Mittel, wichtig bei vielen Teams** 4. ~~**Papierkorb** (3.9): schließt den einzigen Schaden, der sich heute nicht rückgängig machen lässt.~~ — erledigt 2026-09-24. 5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen, und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24. 6. ~~**Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1.~~ — erledigt 2026-09-25. Stufe 2 (Ablaufdatum) erst, wenn Stufe 1 nicht reicht. **Bewusst entscheiden, nicht nebenbei** 7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung. Das Zugriffsprotokoll und 4.6 liefern die Daten dafür: Wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren? 8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) — beide ändern den MCP-Vertrag. 9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend, sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet. Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwas knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder das Limit zu eng oder ein Agent tut etwas Unsinniges. Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird. **Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken, semantische Suche. Alle drei sind gute Ideen für den Fall, dass ihre Auslöser eintreten — und drei gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt.