munin/AUSBAUSTUFEN.md
2026-10-19 10:13:56 +02:00

2121 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**.
**Nachmessung über den ganzen Weg (2026-10-06):** `last_test.go` lässt Agenten
über echtes HTTP und MCP arbeiten, mit Key-Prüfung, Protokoll und Suchindex
(Ausführung und alle Ergebnisse in `LASTTEST.md`). 128 Agenten, je 500 Einträge vorab,
30 % Schreiben, 20 umkämpfte Bezeichner je Projekt, 10 Sekunden, Testrechner
mit 10 Kernen, Clients und Server auf derselben Maschine:
| Aufstellung | `remember` je s | `remember` p50 / p99 | `recall` p50 / p99 | Fehler |
|---|---|---|---|---|
| ein Projekt | 1.427 | 68 ms / 269 ms | 4,5 ms / 23 ms | 0 |
| ein Workspace, 4 Projekte | 1.355 | 73 ms / 286 ms | 4,5 ms / 24 ms | 0 |
| 4 Workspaces, je 1 Projekt | 1.328 | 60 ms / 205 ms | 15 ms / 36 ms | 0 |
- Kein Schreibvorgang ging verloren, der Suchindex kannte in jedem Lauf den
letzten Stand.
- Projekte im selben Workspace teilen sich die Schreibverbindung: Mehrere
Projekte verhalten sich wie eines. Getrennte Workspaces schreiben etwas
schneller, der Rechner insgesamt bleibt aber die Grenze (die Suche wird
langsamer, weil alles um dieselben Kerne konkurriert).
- Gegenüber 8.656/s oben kostet der volle Weg etwa das Sechsfache. 1.300
Schreibvorgänge je *Sekunde* stehen einer Vorgabe von 1.000 je *Stunde* und
Projekt gegenüber (Schreibbudget 3.8). Last ist bei echter Nutzung kein Thema.
Dabei gefunden und behoben: Schrieben zwei Agenten gleichzeitig denselben
Eintrag, konnte der Suchindex eine ältere Fassung behalten als die Datenbank.
`Registry.Refresh` liest seitdem nach jedem Schreiben aus der Datenbank nach,
statt den mitgegebenen Stand zu übernehmen. Außerdem hat seitdem jedes Projekt
seine eigene Sperre in der Registry: Der Aufbau eines großen Index nach einem
Neustart lässt die anderen Projekte desselben Workspace nicht mehr warten.
**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 `foldUmlauts` 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.
**Umgesetzt statt Embeddings (2026-10-14):** Die Clients sind selbst
Sprachmodelle und kennen Synonyme. Das nutzt Munin jetzt an beiden Enden:
- **Beim Suchen:** `recall` nimmt `synonyms` an (höchstens 10), die in derselben
Suche mitlaufen. instructions und Werkzeugbeschreibung bitten darum, und ein
leeres Ergebnis rät zuerst zur erneuten Suche mit anderen Begriffen.
- **Beim Schreiben:** Einträge enden mit einer Zeile `Auch gesucht als: …`.
Bewusst im Inhalt statt als Schlagworte: Die Agenten-Vorlagen verlangen
Schlagworte sparsam aus dem bestehenden Bestand, und die Zeile bleibt für
Menschen sichtbar und bearbeitbar.
Ob Agenten das befolgen, zeigt erst der Betrieb — Stichproben in neuen
Einträgen (steht die Zeile da?) sind der einfachste Test.
**Lokales Modell statt Gateway:** geprüft und verworfen. Das Modell braucht
grob 0,5–2 GB Speicher (einmal, nicht je Workspace) und in Go entweder cgo —
dann ist der statische Build weg — oder einen zweiten Dienst. Der Zusatznutzen
gegenüber Synonymen vom Client ist klein.
**pgvector: geprüft und nicht nötig (2026-10-16).** Mit PostgreSQL (4.10) liegt
pgvector nahe. Es löst aber nur den kleineren Teil: Vektoren speichern und
vergleichen. Das Einbettungsmodell, an dem diese Stufe scheitert, bleibt
nötig. Außerdem gibt es pgvector nur in PostgreSQL, die Suche verhielte sich
dann je nach Datenbank anders. Und bei dieser Größe braucht es keinen Index:
10.000 Einträge × 1.024 Werte sind rund 40 MB, ein Vergleich mit allen dauert
in Go wenige Millisekunden. **Falls semantische Suche doch kommt:** Vektoren
als `BLOB`/`bytea` in einer Tabelle (gleich in beiden Datenbanken), Suche im
Speicher wie beim BM25-Index. pgvector erst bei Millionen von Vektoren.
### 2.2a Ausschnitt an der Fundstelle — ✅ umgesetzt (2026-10-14)
`entry.Snippet` suchte die ganze Anfrage wörtlich. Bei Agenten-Fragen („wie
deployen wir …") traf das nie, der Ausschnitt zeigte immer den Textanfang.
Jetzt: erst die ganze Anfrage, sonst das längste Wort (ab 4 Zeichen), das im
Text vorkommt; in `recall` samt Synonymen. Hervorgehoben wird dasselbe Wort.
### 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 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14)
Umgesetzt in `internal/search/variants.go`. Zu jedem Suchwort sucht der Index
mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
| Variante | Beispiel | Gewicht | ab Länge |
|---|---|---|---|
| Wortanfang | „deploy" → Deployment | 0,5 | 3 |
| Endung gekürzt, dann Wortanfang | „deployen" → Deployment | 0,5 | Stamm ≥ 4 |
| Teilwort | „prozess" → Releaseprozess | 0,3 | 5 |
| Tippfehler (nur `SearchFuzzy`) | „deploymnet" → Deployment | 0,3 | 4 |
Je Eintrag zählt nur die beste Variante eines Suchworts.
**Was sich dabei geändert hat:**
- **Fehler behoben:** Der Wortanfang griff früher nur, wenn das Suchwort
nirgends exakt vorkam. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
den „Deployment-Prozess" nicht mehr.
- **Stoppwörter** („wie", „wir", „the" …) werden aus der *Anfrage* entfernt,
nicht aus dem Index. Agenten stellen laut `instructions` ganze Fragen; die
Füllwörter fanden über den Wortanfang sonst Unpassendes („wir" → „Wirkung").
- **Endungen:** bewusst kein vollständiger Wortstammbildner, nur `-en`, `-es`,
`-e`, `-n`, `-s`. Kein `-er`, sonst fände „Server" auch „Service".
- **Tippfehler** gleicht nur die Weboberfläche aus (`SearchFuzzy`), und nur
für Suchwörter, die sonst nichts finden. `recall` bleibt ohne.
**Laufzeit:** Der Wortschatz wird jetzt bei jedem Suchwort einmal durchlaufen,
nicht mehr nur beim Fehlschlag. `bench_test.go` (10.000 Einträge, 20.000
Wörter, Frage mit drei Suchwörtern): 0,37 ms vorher, 1,7 ms nachher, gut die
Hälfte davon Speicherbereinigung. Für Agenten wie für die Vorschläge beim
Tippen unerheblich.
**Offen:** Synonyme („Release" ↔ „Deployment") kann das nicht — dafür wäre 2.2
nötig. Und ob die Gewichte gut gewählt sind, zeigt erst der Betrieb.
---
## 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.
**Messgrundlage ✅ (2026-10-14):** Jeder `recall` landet im Suchprotokoll
(`search_log`, migrations/006, 30 Tage Aufbewahrung). Die Nutzungsseite zeigt
den Anteil der Suchen ohne Treffer, den Anteil mit Synonymen und die häufigsten
Anfragen ohne Treffer. Bewusst eine eigene Tabelle statt `audit_events`: Suchen
sind viel häufiger als Schreibvorgänge und würden das Protokoll zudecken, und
Anfragen sind Inhalt — sie gehen mit dem Projekt und haben eine kürzere Frist.
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
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.
**✅ Umgesetzt 2026-09-25:**
- **Weg:** ZIP-Upload unter `/p/{projekt}/import`, Rolle `admin`. Bewusst kein
Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden
Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können
Projekt-Admins so selbst importieren.
- **Zusammenstoß:** *überspringen* (Standard) oder *überschreiben*. Beim
Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der
importierende Zugang. Ein *Abbrechen* gibt es nicht, dafür eine Vorschau,
die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden,
werden nie geschrieben. Ein wiederholter Import ist damit harmlos.
- **Papierkorb:** Im Modus *überspringen* holt ein Import keinen gelöschten
Eintrag zurück. Das war die Falle, weil `AllEntries` den Papierkorb nicht
sieht.
- **Felder:** Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide
Zeitstempel erhalten (neu: `EntryWrite.CreatedAt/UpdatedAt`). Sonst sähe
nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der
Verlauf wird nicht übernommen, der Export enthält ihn nicht.
- **Fremde Dateien:** Ohne Kopf gilt der Dateiname als Bezeichner und die
erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen,
soweit sie `title` und `tags` betreffen. Kein YAML-Paket, ein kleiner
eigener Leser in `internal/importer/parse.go`.
- **Grenzen:** 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt.
Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv
behauptet.
- **Protokoll:** ein Eintrag `entry_import` je Import mit Zusammenfassung,
nicht einer je Datei. So verzerrt ein einmaliger Import die
Nutzungsübersicht (4.6) nicht.
- **Nebenbei behoben:** Das 1-MB-Limit für Formulare lag über dem ganzen Mux,
auch über `/mcp`. Zwei geschachtelte `MaxBytesReader` begrenzen auf den
kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, und
`MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die
Grenze einmal außen je Route.
### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14)
**Umsetzung:** `internal/duplicates` (bis 2026-10-16 `internal/mcpserver/dubletten.go`;
seitdem eigenes Paket, weil auch die Weboberfläche es nutzt, siehe 5.7). Bei `remember` mit neuem
Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich),
sondern der Anteil gemeinsamer Titelwörter (`search.Coverage`, in beide
Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile
„Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den
„Deployment-Prozess", wenn dort „release" steht.
Ab 50 % Überdeckung: nichts schreiben, `judgment_required` mit bis zu fünf
`candidates` zurückgeben, kein Fehler. `force: true` legt trotzdem an.
Aktualisieren (Bezeichner vorhanden) wird nie geprüft.
**Bekannter Fehlalarm:** „Server-Zugang" gegen „Datenbank-Zugang" (50 %).
Kostet den Agenten einen zweiten Aufruf mit `force`. Ob die Schwelle passt,
zeigt der Betrieb.
**Ursprüngliche Beschreibung:**
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.
**Nachgebessert 2026-10-16: gemeinsames Präfix im Titel.** Im Betrieb mit
Titeln nach dem Muster „Projekt – Thema“ wurde fast jeder neue Eintrag
gemeldet: „Munin – Architektur“ gegen „Munin – Überblick“ ergibt ein
gemeinsames Wort von zweien, also 50 %, genau die Schwelle. Jetzt fällt ein
Präfix vor einem Trenner (` – `, ` - `, `: `, ` | `) aus dem Vergleich, wenn
**beide** Titel dasselbe haben (`withoutCommonPrefix` in
`internal/duplicates`). Verschiedene Präfixe bleiben im
Vergleich, denn dann können sie etwas unterscheiden. Ein Bindestrich ohne
Leerzeichen („Deployment-Ablauf“) ist kein Trenner. Eine echte Dublette unter
gleichem Präfix fällt weiter auf. Tests: `TestWithoutCommonPrefix`,
`TestRememberCommonPrefixNoDuplicate`.
Verworfen: Wörter nach ihrer Häufigkeit im Projekt gewichten. In einem jungen
Projekt mit zwei Einträgen drückt das auch „Deployment-Prozess“ gegen
„Deployment-Ablauf“ unter die Schwelle, also genau den Fall, um den es geht.
Titel ohne Trenner („Munin Architektur“) erfasst die Regel nicht; das bleibt
ein Fehlalarm, den `force` auflöst.
**Zweite Nachbesserung 2026-10-16: Suchbegriffe nur wörtlich, gemeinsame
Wörter in der Antwort.** Beim Dokumentieren in Munin selbst kamen zwei weitere
Fehlalarme mit je 50 %:
1. „Munin – Betrieb mit PostgreSQL“ gegen einen Statuseintrag mit dem
Suchbegriff `postgres-migration`. „postgres“ ist der Wortanfang von
„postgresql“. **Behoben:** Ein Suchbegriff aus „Auch gesucht als“ zählt
jetzt nur wörtlich, Titelwörter weiter mit allen Varianten
(`search.CoverageWithSearchTerms`). Der Kernfall „Release-Ablauf“
gegen den Suchbegriff `release` bleibt erkannt.
2. „Munin – Entscheidungen zur PostgreSQL-Anbindung“ gegen „Munin – Betrieb mit
PostgreSQL“: Beide Titel enthalten „PostgreSQL“. **Bleibt** gemeldet —
strukturell ist das derselbe Fall wie „Deployment-Ablauf“ gegen
„Deployment-Prozess“, den die Prüfung finden soll. Wortvergleiche können
die beiden nicht trennen, nur Bedeutung könnte es. Dafür nennt jeder
Kandidat jetzt die gemeinsamen Wörter (`matched_words`). Steht dort nur ein
allgemeines Wort, sieht der Agent sofort, dass `force` richtig ist, ohne den
Kandidaten erst zu laden.
Tests: `TestCoverageWithSearchTerms`, `TestRememberSearchTermOnlyLiteral`,
`matched_words` in `TestRememberReportsDuplicate`.
### 3.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14)
**Problem:** `remember` überschrieb kuratierte Einträge ohne Rückfrage; der
Haken fiel still weg. Weder der Agent noch ein Mensch bekam es mit, und eine
Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er
aktuell ist).
**Umsetzung** (`internal/mcpserver/curated.go`), Muster wie bei 3.3:
- `remember` auf einen kuratierten Eintrag schreibt nicht, sondern meldet
`judgment_required` mit Handlungsanweisung.
- Mit `override_curated: true` und `reason` wird geschrieben. Der Haken fällt
weg (der neue Text ist ungeprüft), das Protokoll vermerkt
`kuratiert aufgehoben: <Begründung>` — zählt in der Nutzung als
Aktualisierung.
- Die Nutzungsseite listet solche Einträge unter *Zur Prüfung*, bis ein Mensch
den Haken neu setzt (`store.CuratedOverrides`, keine eigene Tabelle).
- instructions: Kuratiertes hat Vorrang vor eigenen Annahmen.
- Suche: kuratierte Einträge mit Faktor 1,2 (`curatedBoost` in
`internal/search/registry.go`) — genug für den Gleichstand, zu wenig, um
einen klar besser passenden Eintrag zu verdrängen.
**Bewusst nicht:** Sperren mit Änderungsvorschlägen. Erst sinnvoll, wenn die
Liste *Zur Prüfung* zeigt, dass kuratierte Einträge oft falsch überschrieben
werden.
**Offen:** `forget` auf kuratierte Einträge hat noch keine Hürde. Weniger
dringend — der Papierkorb hält 30 Tage, und nur Menschen holen zurück —, aber
dasselbe Muster würde passen.
### 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 — kleiner Schritt ✅ umgesetzt
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.
**✅ Kleiner Schritt umgesetzt 2026-09-25:**
- **Regel:** `Entry.Stale` in `internal/store/models.go`. Ein Eintrag, der seit
über einem Jahr (`StaleAfter`) nicht geändert wurde, gilt als veraltet. Das
gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, *wann* geprüft
wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag
liest bald niemand mehr.
- **MCP:** `recall`, `list` und `related` tragen `"stale": true`, `get`
zusätzlich einen `hint` mit Handlungsanweisung. `recall` nennt im Hinweis,
wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch
nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema
sein. Die `instructions` erklären das Feld.
- **Oberfläche:** Auf der Eintragsseite steht die Marke *über ein Jahr alt*.
- **Offen:** der größere Schritt (Frist je Art, „fällige Einträge“,
Bestätigen).
**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_change_history.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 — ✅ umgesetzt
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.
**✅ Umgesetzt 2026-09-25:** MCP-Werkzeug `related(slug, distance?, limit?)`.
Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise
ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt
Abstand, den Eintrag, über den er gefunden wurde (`via`), die Richtung und
`stale`. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als
Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In
einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem
zusammen. `get` liefert weiterhin `links_to`/`linked_by` als nackte
Bezeichner. `related` ergänzt Titel, Datum und die zweite Reihe.
### 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_trash.sql`: `deleted_at` (NULL = normal),
`deleted_by` und ein Teilindex nur über den Papierkorb.
- **Store:** `internal/store/trash.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 `notInTrash`. `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.
`TestTrashInvisible` 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. `purgeTrashAll` 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 (`actorHolder`),
den die Anmelde-Middleware befüllt.
### 4.2 Tests bei jeder Änderung — ✅ umgesetzt (manuell)
Es gibt keine automatische Pipeline (kein GitHub, keine CI). Die Prüfungen
werden vor jedem Commit von Hand aufgerufen:
```
gofmt -l . # muss leer bleiben
go vet ./...
go test -race ./...
CGO_ENABLED=0 go build -o /dev/null .
```
*Korrektur 2026-10-16:* Hier stand früher, `.github/workflows/test.yml` führe
diese Prüfungen automatisch aus. Diese Datei hat es im Repository nie gegeben.
`-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:<workspace-id>:<key-id>` bzw. `platform:<workspace-id>`, 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/<id>.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`,
`.workspace*` 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
`forEachWorkspace` 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/<projekt>`, 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/<workspace-id>/<projekt>/`, 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: `TestExportSeparatesWorkspaces`,
`TestExportButtonInSecondWorkspace`, `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 — geplant, sobald es ein zentrales Monitoring gibt
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
*Stand 2026-10-16:* Ein zentrales Monitoring ist angekündigt, aber noch nicht
da. Bis dahin beantwortet die Systemseite (5.12, 5.16) die Fragen „Ist etwas
zu tun?“ und „Wird Munin genutzt?“. Was erst mit dem Monitoring kommt:
- **Laufzeitwerte, die heute niemand zählt:** MCP-Aufrufe je Werkzeug,
Fehlerquote, Dauer (Median, langsamste 5 %), Treffer auf das Schreibbudget je
Projekt, gesperrte IPs, Go-Laufzeit (Speicher, Goroutinen, GC),
Wartezeiten der Verbindungspools als Verlauf.
- **Weg:** entweder `/metrics` im Prometheus-Textformat (von Hand
geschrieben, ohne Bibliothek machbar) oder OpenTelemetry, je nachdem, was das
Monitoring erwartet. OpenTelemetry brächte eine Bibliothek mit sich und ist
erst zu wählen, wenn klar ist, wohin die Daten gehen.
- **Bewusst nicht in Munin:** eine eigene Ablage für Messwerte über Monate mit
eigenen Diagrammen. Verläufe zeichnet das Monitoring; die Systemseite bleibt
der schnelle Blick.
- **Schutz:** `/metrics` nicht öffentlich — nur aus dem internen Netz oder
mit eigenem Token, wie `/live` und `/ready` ohne Inhalte der Projekte.
### 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_usage.go`), für jede Rolle.
Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle
sieht.
- **Zahlen:** `store.UsageSummaries` (`internal/store/usage.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 — ✅ umgesetzt (Stufe 2: 2026-10-16)
`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`.
**✅ Stufe 2 umgesetzt 2026-10-16: Ablaufdatum.**
- **Migration 007** (`007_key_expiry.sql`, in beiden Ordnern gleich):
`api_keys.expires_at TEXT NOT NULL DEFAULT ''`. Leer = läuft nie ab, wie bei
`revoked_at`. Vorhandene Keys behalten ihr Verhalten, die Migration fasst
keine Daten an.
- **Gilt nicht mehr ab dem Ablauf**, an beiden Stellen, an denen geprüft wird,
ob ein Key gilt: `ResolveKeyHash` (jede MCP-Anfrage, jede Anmeldung;
Bedingung `expires_at = '' OR expires_at > jetzt`) und `sessionFromSubject`
(jede Seite: `APIKey.Valid`). Eine laufende Browser-Sitzung endet also bei
der nächsten Anfrage nach dem Ablauf.
- **Oberfläche:** Feld *Gültig bis* beim Anlegen (optional, gilt bis
einschließlich des Tages). Spalte *Gültig bis*, Status *abgelaufen*, Marke
*läuft bald ab* zwei Wochen vorher (`ExpiresSoonWithin`) und ein Hinweis über
der Tabelle. Ein Datum in der Vergangenheit wird abgelehnt. Das Protokoll
vermerkt das Ablaufdatum bei `key_created`.
- **Abgelaufene Keys sind nie „ungenutzt“** — sie öffnen ohnehin nichts mehr.
- **Nicht dabei:** Ablaufdatum für Workspace-Admin-Tokens (`wa_…`). Bei Bedarf
derselbe Weg in der Kontroll-Datenbank.
- **Tests:** `TestKeyExpiry` (Store, auch gegen PostgreSQL),
`TestKeyWithExpiryDate` (Anlegen, Anzeige, Ablehnung, Ende der Sitzung).
### 4.8 Admin je Workspace — ✅ umgesetzt (2026-09-30)
Bis dahin legte 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).
**Entscheidung (2026-09-30): ohne Keycloak umsetzen.** Ursprünglich stand hier,
4.8 nur zusammen mit 4.3 zu entscheiden. Keycloak wird aktuell aber nicht
gebraucht, das Nadelöhr beim Plattform-Admin dagegen schon. Kommt 4.3 doch,
fallen nur die Token-Tabelle und der Anmeldezweig weg. Die Rechteprüfungen
(`Session.CanManageProjects`, `CanEditWriteBudget`, `CanImport`, die Routen)
bleiben gleich, nur die Herkunft von `IsWorkspaceAdmin` ändert sich.
**Umsetzung:**
- *Eigene Token-Art `wa_…`* in der Kontroll-Datenbank (`workspace_admins`,
`migrations_control/002`), Hash statt Klartext, zurückziehbar, mit letzter
Nutzung. Mehrere je Workspace, jeder mit Namen der Person, damit das
Protokoll sagt, wer etwas getan hat.
- *Nur Weboberfläche:* `APIAuth` akzeptiert nur `wk_…` (`LooksLikeKey`). Ein
`wa_`-Token scheitert dort schon an der Form und liegt außerdem in einer
anderen Tabelle.
- *Eigenes Feld `Session.IsWorkspaceAdmin`* statt `IsPlatformAdmin` mitzunutzen.
Jede Prüfung auf `IsPlatformAdmin` weist ihn damit von selbst ab. Freigegeben
ist nur, was die `Can…`-Methoden ausdrücklich nennen.
- *Darf:* Projekte des eigenen Workspace anlegen, bearbeiten, löschen. Zugänge
aller Projekte dort verwalten. Alle Projekte lesen, dazu Protokoll, Nutzung
und `/admin/audit`.
- *Darf nicht:* Einträge schreiben, importieren, Papierkorb. Außerdem
Schreibbudget (der Server ignoriert das Feld, es wird also nicht nur im
Template ausgeblendet), Export, Workspaces, andere Workspace-Admins anlegen.
- *Anlegen und Zurückziehen* nur durch den Plattform-Admin, auf der
Bearbeitungsseite des Workspace. Beides und jede Anmeldung stehen im
Protokoll des Workspace.
- *Ungenutzte Workspace-Admins* markiert wie bei den Keys (4.7, dieselbe Regel
über `store.ungenutzt`). Die Workspace-Übersicht zeigt je Workspace die Zahl
der aktiven und der ungenutzten Workspace-Admins.
**Bewusst hingenommen:** Wer Zugänge verwalten darf, kann sich einen
`admin`-Key für jedes Projekt seines Workspace anlegen und damit auch
schreiben. Das Verbot, Einträge zu schreiben, ist also keine harte Grenze. Es
sorgt dafür, dass Änderungen über Keys laufen und im Protokoll eindeutig
zuzuordnen sind. Die Rolle gehört deshalb nur an Personen, denen die Abteilung
ohnehin alle ihre Projekte anvertraut (so steht es auch in der Hilfe).
**Tests:** `internal/web/workspace_admin_test.go` (darf / darf nicht,
Workspace-Trennung, MCP abgewiesen, Zurückziehen beendet die Sitzung),
`TestWorkspaceAdmins`, `TestWorkspaceAdminsVanishWithWorkspace`,
`TestWorkspaceAdminUnused`, `TestWorkspaceListCountsActiveAdmins`,
`TestWorkspaceAdminTokenIsNoKey`.
### 4.9 Zugriff über ein Projekt hinaus — aufgeschoben
Zwei verwandte Wünsche, besprochen am 2026-09-25 und bewusst aufgeschoben:
- **Öffentliches Projekt:** Ein Projekt, das jeder gültige Key lesen darf,
auch aus anderen Workspaces. Etwa für firmenweite Richtlinien.
- **Workspace-Zugang:** Ein Key für alle Projekte eines Workspace, mit einem
MCP-Server statt einem je Projekt. Für Teams, die projektübergreifend
arbeiten und Wissen aus mehreren Projekten brauchen.
**Stand heute:** Ein Key gehört zu genau einem Projekt, und jedes Projekt hat
einen eigenen MCP-Server, dessen Werkzeuge fest an dieses Projekt gebunden
sind. Wer mehrere Projekte braucht, trägt mehrere Server ein, je einen Key pro
Projekt. Das Routing Key → Workspace in der Kontroll-Datenbank (4.4) kennt
dagegen schon keine Projektbindung.
**Geht schon heute, ohne Code:** Für ein „öffentliches“ Projekt bekommt jedes
Team einen `read`-Key und trägt das Projekt als zweiten Server ein. Das kostet
Verwaltung, aber keine Entwicklung, und das Protokoll zeigt je Team, wer liest.
#### Öffentliches Projekt
| | Idee | Aufwand |
|---|---|---|
| **A** (empfohlen) | Eigener Endpunkt `/mcp/public/<projekt>`, jeder gültige Key darf lesen (`recall`, `get`, `list`, `related`), schreiben nur mit Keys des Projekts. Schalter nur für den Plattform-Admin. | 2½–3½ Tage |
| **B** | `recall` sucht automatisch zusätzlich im öffentlichen Projekt | 4–6 Tage, ändert den MCP-Vertrag |
| Lesen in der Oberfläche | Menschen aus anderen Projekten sehen es im Browser | +2–3 Tage |
Von Schreibrecht für alle wird abgeraten: Das Schreibbudget (3.8) gälte für
die ganze Firma zusammen, und niemand fühlte sich für die Qualität zuständig.
#### Workspace-Zugang
**Nötige Änderungen:** Workspace-Keys ohne Projektbindung (`api_keys.project_id`
ist heute `NOT NULL`, bei SQLite heißt das Tabelle neu aufbauen),
`Session.CanAccessProject` erweitern, Verwaltung unter `/admin/workspaces`, und
auf der MCP-Seite eine von zwei Stufen:
| | Idee | Aufwand gesamt |
|---|---|---|
| **Stufe 1** | Projekt wählbar über den Pfad (`/mcp/<projekt>`), ein Key für alle. Löst den Zugriff, der Agent muss aber weiter wissen, wo er sucht. | 4½–6 Tage |
| **Stufe 2** | Ein Server je Workspace, `recall` über alle Projekte, jeder Treffer nennt sein Projekt, `get`/`related`/`remember` bekommen einen Parameter `project`. | 7½–10 Tage |
**Risiken:**
- *Sicherheit:* Ein verlorener Workspace-Key öffnet alle Projekte statt einem.
Die Grenze zwischen Workspaces bleibt strukturell dicht (eigene Datei je
Workspace). Innerhalb eines Workspace wird die Projektgrenze aber zu einer
Bedingung in der Abfrage statt einer Closure. Das größte Risiko ist, dass
Projekt-Keys versehentlich über denselben Codepfad laufen und mehr sehen als
heute. Deshalb Projekt-Keys unverändert auf dem heutigen Weg lassen und den
Workspace-Server getrennt aufbauen.
- *Last:* Ein workspace-weites `recall` lädt beim ersten Aufruf die
Suchindizes *aller* Projekte in den Speicher (~65 MB und ~170 ms je 10.000
Einträge, siehe 1.1). Heute nur die, die jemand benutzt. SQLite wird nicht
stärker belastet, alle Projekte eines Workspace liegen schon in einer Datei.
- *Schreibbudget:* Es zählt je Projekt. Ein durchdrehender Agent mit
Workspace-Key könnte in *jedem* Projekt bis zur Grenze schreiben. Dagegen
hilft nur ein zusätzliches Budget je Key oder Workspace, oder kein
Schreibrecht für Workspace-Keys.
- *Qualität:* Agenten schreiben ins falsche Projekt (deshalb keine
Standardangabe für `project` bei `remember`), vermischen Wissen verschiedener
Projekte, legen Dubletten über Projektgrenzen an. Außerdem sind BM25-Werte
getrennter Indizes nicht direkt vergleichbar, das Mischen der Treffer
braucht eigene Logik.
- *Wartung:* zwei Anschlussarten statt einer. Mit Keycloak (4.3) wird aus dem
Workspace-*Key* ein Workspace-*Recht* einer Person, der Rechte-Teil wird dann
umgebaut.
**Warum aufgeschoben:** Keycloak ist absehbar, aber nicht sofort. Mit wenigen
Projekten je Team (etwa zwei) ist ein Server je Projekt stabil und die
Schwäche nur Komfort. Die Risiken oben wiegen das noch nicht auf.
**Auslöser:**
- Teams arbeiten regelmäßig mit vier bis fünf oder mehr Projekten zugleich
(Werkzeuge vervielfachen sich im Kontext des Agenten)
- Die Verteilung von `read`-Keys für gemeinsam genutzte Projekte wird zur
spürbaren Last
- Die Entscheidung zu 4.3 und 4.8 steht an. Dann zusammen entscheiden, nicht
vorher Workspace-Keys bauen und später umbauen.
**Wenn es so weit ist, vorsichtig beginnen:** Workspace-Keys nur `read`,
projektübergreifend `recall`/`get`/`list`/`related`, schreiben weiter über
die Projekt-Server, vorher Speicher und Kaltstart mit echten Projektgrößen
messen. Innerhalb eines Workspace erledigt ein solcher `read`-Key nebenbei
auch das öffentliche Projekt. Übrig bliebe nur „öffentlich über
Workspaces hinweg“ (Variante A oben).
#### Idee: Mehrere Keys in einem MCP-Block (2026-10-06)
Eine Alternative zu Workspace-Keys, nur als Idee festgehalten, nicht
entschieden. Der Client trägt *einen* Server ein und übergibt alle seine Keys:
```json
"munin": {
"url": "https://munin.firma.de/mcp",
"headers": { "X-Munin-Keys": "wk_aaa,wk_bbb,wk_ccc" }
}
```
Der Server löst jeden Key einzeln auf dem heutigen Weg auf (Key → Workspace →
Projekt + Rolle). Ist einer ungültig, scheitert die ganze Anfrage
(fail-closed). Die Werkzeuge bekommen einen Parameter `project`, `recall`
sucht ohne Angabe in allen freigeschalteten Projekten. Ein einzelner Key in
`Authorization: Bearer` verhält sich wie heute.
**Vorteile gegenüber Workspace-Keys:**
- Kein Umbau der Datenbank: `api_keys.project_id` bleibt `NOT NULL`, kein
neuer Key-Typ, keine neue Verwaltung.
- Rechte bleiben je Key: ein `read`-Key bleibt `read`. Sperren, Protokoll
(4.1), Nutzungsübersicht (4.6) und ungenutzte Zugänge (4.7) funktionieren
unverändert.
- Kein größerer Schaden bei einem verlorenen Key: der Client hält dieselben
Keys wie heute, nur in einem statt in N Blöcken.
- Schreibbudget (3.8) bleibt je Projekt.
- Löst das öffentliche Projekt mit: ein `read`-Key aus einem fremden
Workspace ist in der Liste kein Sonderfall, jeder Key wird für sich
geroutet.
- 6 Werkzeuge im Kontext des Agenten statt 6 × N.
**Risiken für die Richtigkeit der Antworten:**
| Risiko | Was passiert | Gegenmittel |
|---|---|---|
| Wissen vermischt | „Wir deployen mit Helm“ aus A wird als allgemeingültig wiedergegeben, obwohl B Ansible nutzt | Jeder Treffer nennt sein Projekt deutlich, Instructions: Aussagen gelten nur für ihr Projekt, bei Widerspruch nachfragen |
| Gleicher Slug in mehreren Projekten | `get deployment` liest das falsche Projekt; schlimmer: `remember` **überschreibt** einen bestehenden Eintrag in B mit Inhalt aus A | `project` ist Pflicht für `get`/`remember`/`forget`/`related`, sobald mehr als ein Key übergeben wird. Fehlt es, Fehlermeldung statt Raten |
| Rangfolge unterschlägt Projekte | BM25-Werte getrennter Indizes sind nicht vergleichbar, ein großes Projekt füllt die Top 10, der Agent schließt „nichts bekannt“ | Treffer je Projekt gruppiert zurückgeben (beste N je Projekt) statt einer gemischten Liste |
| Dubletten | Wissen aus A wird in B neu angelegt und veraltet dort still | Nur über Instructions zu mindern |
Nur der gleiche Slug verändert gespeicherte Daten. Die Pflichtangabe von
`project` schließt das technisch aus. Die anderen drei betreffen die Qualität
der Antworten und lassen sich nur verringern.
**Aufwand (Schätzung, nicht erprobt):**
| | Umfang | Aufwand |
|---|---|---|
| **Stufe 1** | Nur lesend: `recall`/`get`/`list`/`related` über mehrere Keys, schreiben weiter über die Projekt-Server | 3–4 Tage |
| **Stufe 2** | Zusätzlich `remember`/`forget` mit Pflichtangabe `project` | +1–2 Tage |
**Wenn es so weit ist:** mit Stufe 1 beginnen. Dann ist das Überschreiben
ausgeschlossen, und an echten Antworten lässt sich sehen, wie stark sich
Wissen vermischt, bevor Schreiben dazukommt. Speicher und Kaltstart betrifft
nur die Indizes der übergebenen Keys, nicht den ganzen Workspace. Die
Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
### 4.10 PostgreSQL als zweiter Speicherort — in Arbeit (begonnen 2026-10-16)
PostgreSQL soll **neben** SQLite nutzbar sein, umschaltbar über die
Konfiguration. SQLite bleibt der Standard.
**Stand 2026-10-16:** Schritte 1, 2, 3 und 5 sind umgesetzt (siehe
*Reihenfolge und Aufwand* am Ende). Alle Tests laufen gegen beide Datenbanken,
Sicherung und Wiederherstellung sind durchgespielt. PostgreSQL ist damit
produktiv nutzbar, mit einer Schreibverbindung je Workspace. Offen ist
Schritt 4 (mehrere Schreiber).
**Grenze: Auch mit PostgreSQL läuft genau ein Munin-Prozess.** PostgreSQL
ändert den Speicherort, nicht die Architektur. Einiges lebt nur im
Arbeitsspeicher des Prozesses:
- **Suchindex** (`internal/search`): Ein zweiter Prozess sähe nicht, was der
erste geschrieben hat, und fände veraltete Stände, ohne Fehlermeldung.
- **Schreibbudget und Rate-Limiter** (`internal/auth/ratelimit.go`): Jeder
Prozess zählte für sich, die Grenzen gälten doppelt.
Die Anmeldung wäre kein Problem (signierte Cookies, kein Sitzungsspeicher).
Mehrere Prozesse wären eine eigene, deutlich größere Ausbaustufe.
**Ausgangslage, was schon passt:**
- **SQL gibt es nur in `internal/store`.** Handler, MCP-Tools und Templates
arbeiten mit Go-Typen. Genau für diesen Fall ist das Package gebaut (siehe
Kopfkommentar in `store.go`).
- **Die Migrationen folgen portablen Regeln** (Kopf von
`migrations/001_init.sql`): IDs als TEXT aus Go, Zeitstempel als
RFC3339-Text, Wahrheitswerte als 0/1. Kein `AUTOINCREMENT`, kein
`INSERT OR REPLACE`, kein `datetime()`.
- **Die Volltextsuche ist eigener BM25-Code in Go** (`internal/search`), nicht
FTS5 (siehe 1.1). Sie hängt also nicht an der Datenbank.
- `RETURNING` und `ALTER TABLE … ADD COLUMN` können beide Datenbanken.
`isUniqueViolation` erkennt die Fehlermeldungen beider (`models.go`).
**Was sich ändern muss:**
| # | Stelle | Änderung | Aufwand |
|---|---|---|---|
| 1 | Konfiguration (`main.go`) | `DB_DRIVER=sqlite\|postgres` (Standard `sqlite`) und `DATABASE_URL` | gering |
| 2 | Treiber | `github.com/jackc/pgx/v5/stdlib`. Ist reines Go, das statische Alpine-Image bleibt möglich | gering |
| 3 | Platzhalter | PostgreSQL erwartet `$1, $2 …` statt `?`, betroffen sind rund 100 Abfragen. Lösung: `Rebind` im Dialekt (siehe unten), kein Umschreiben von Hand | gering bis mittel |
| 4 | `openPool` (`store.go`) | `_pragma`-Parameter, `_txlock=immediate` und die einzelne Schreibverbindung gelten nur für SQLite. PostgreSQL braucht einen eigenen Öffnungsweg | gering |
| 5 | Workspace-Modell (`internal/workspace`) | Ein Schema je Workspace statt einer Datei, siehe unten | mittel |
| 6 | Migrationen | Eigenes Verzeichnis je Datenbank, siehe unten. Konkreter Unterschied heute: `search_log.id INTEGER PRIMARY KEY` (Migration 006) zählt nur in SQLite von selbst hoch. PostgreSQL braucht `BIGINT GENERATED ALWAYS AS IDENTITY` | gering |
| 7 | Gleichzeitiges Schreiben | Stellen absichern, die heute nur durch die eine Schreibverbindung sicher sind, siehe unten | mittel |
| 8 | Tests | Die Store-Tests zusätzlich gegen PostgreSQL laufen lassen | mittel |
| 9 | Betrieb | `scripts/backup.sh` und `BETRIEB.md` setzen auf `sqlite3`. Für PostgreSQL braucht es `pg_dump` und angepasste Abschnitte | gering |
#### Entscheidung: ein Store mit Dialekt, keine zwei Stores
Naheliegend wären zwei vollständige Umsetzungen hinter einem Interface
(`SQLiteStore`, `PostgresStore`). Das hieße, rund 100 Abfragen doppelt zu
pflegen und jede Methode zweimal zu testen.
Stattdessen bleibt es bei **einem** `Store`. Umgesetzt in drei Teilen:
| Teil | Datei | Aufgabe |
|---|---|---|
| `Backend` | `backend.go`, `backend_sqlite.go`, `backend_postgres.go` | Wo liegen die Daten? Öffnet Kontroll-Datenbank und Workspaces, löscht Workspaces, prüft das Verbindungsbudget. `main.go` wählt ihn anhand von `DB_DRIVER`, danach weiß nur noch er davon. |
| `dialect` | `dialect.go` | Was unterscheidet sich in den Abfragen? Heute nur `rebind` (`?` → `$1`) und der Migrationsordner. |
| `pool`, `transaction` | `pool.go` | Jede Abfrage läuft hier durch `rebind`. An `*sql.DB` kommt außerhalb dieser Datei niemand heran, eine Abfrage am Dialekt vorbei lässt sich also gar nicht schreiben. |
```go
type dialekt interface {
rebind(abfrage string) string // ? → $1, $2 … (SQLite: unverändert)
migrationsSubdir() string // "sqlite" bzw. "postgres"
}
```
Braucht eine einzelne Abfrage später wirklich zwei Fassungen, bekommt der
Dialekt dafür gezielt eine eigene Methode. Der Rest bleibt gemeinsam.
`isUniqueViolation` erkennt die Meldungen beider Datenbanken schon und bleibt
deshalb, wo sie ist.
**Zwei Abfragen mussten umgeschrieben werden:** `CuratedOverrides`
(`usage.go`) und `EmptySearches` (`searchlog.go`) nutzten `MAX()` mit
Spalten ohne Aggregat. Das kann nur SQLite, PostgreSQL lehnt es ab. Beide
nutzen jetzt `ROW_NUMBER() OVER (…)`, das können beide Datenbanken (SQLite ab
3.25). Neuer Test: `TestCuratedOverridesLatestEvent`.
#### Entscheidung: bewusst schlichtes SQL
In PostgreSQL entstehen dieselben Tabellen wie in SQLite, keine JSONB-Spalten,
keine Arrays, keine PostgreSQL-Volltextsuche, keine Erweiterungen (pgvector:
siehe 2.2). Gründe:
- Die Daten haben eine feste Form. Schlagworte und Verweise liegen schon in
eigenen Tabellen, es gibt nichts Frei-Geformtes.
- Jedes PostgreSQL-Feature in einer Abfrage braucht eine zweite Fassung für
SQLite. Damit finge das Auseinanderlaufen an.
- Der Schema-Vergleichstest setzt gleiche Strukturen voraus.
- Die Suche liegt in Go (1.1). Ein zweiter Suchweg in der Datenbank wäre
dieselbe Abwägung wie bei FTS5.
- Ein Umzug bleibt reines Kopieren, in beide Richtungen.
Später denkbar, erst bei konkretem Bedarf: `TIMESTAMPTZ` statt Text,
`BOOLEAN` statt 0/1, `TEXT[]` für die kommagetrennten Felder
`entry_versions.tags` und `search_log.synonyms`.
#### Entscheidung: Migrationen je Datenbank getrennt
```
internal/store/migrations/sqlite/001_init.sql … 006_search_log.sql
internal/store/migrations/postgres/001_init.sql … 006_search_log.sql
internal/store/migrations_control/sqlite/…
internal/store/migrations_control/postgres/…
```
- **Warum:** Jede Datenbank bekommt ihr passendes SQL, ohne die andere
einzuschränken. Laufen die beiden später auseinander, ist das sichtbar und
gewollt statt in Sonderfällen versteckt.
- **Preis:** Jede Schemaänderung wird zweimal geschrieben.
- **Absicherung, zwei Tests:**
- Beide Verzeichnisse enthalten dieselben Dateinamen. So bleibt keine
Migration auf einer Seite liegen. Läuft ohne PostgreSQL.
- Nach allen Migrationen haben beide Datenbanken dasselbe Schema: Tabellen,
Spalten, NOT NULL, Standardwerte, Indizes (SQLite über `pragma_table_info`,
PostgreSQL über `information_schema`). Erst dieser Test merkt, wenn eine
Spalte auf einer Seite anders angelegt wurde. Braucht PostgreSQL, siehe
*Tests* unten.
- **Für den Anfang:** Die PostgreSQL-Fassungen bleiben so nah wie möglich an
SQLite (weiter TEXT-Zeitstempel, 0/1). Dann bleiben die Go-Typen gleich, und
der Umzug von Bestandsdaten ist ein reines Kopieren. Echte PostgreSQL-Typen
(`TIMESTAMPTZ`, `BOOLEAN`) erst in einem späteren Schritt.
#### Entscheidung: ein Schema je Workspace
Heute hat jeder Workspace eine eigene Datei, dazu kommt `control.db` (4.4). In
PostgreSQL entspricht dem am ehesten **ein Schema je Workspace** plus ein
Schema für die Kontroll-Datenbank. Jeder Workspace bekommt einen eigenen Pool
mit `search_path=<schema>` in der Verbindungs-URL. Abfragen und Migrationen
bleiben damit unverändert, die Abschottung ist dieselbe wie heute.
`Manager.Delete` wird zu `DROP SCHEMA … CASCADE`.
**Verworfen: eine gemeinsame Datenbank, gefiltert über `workspace_id`.** Die
Spalte steht zwar an jeder Zeile, aber die Abfragen filtern nicht durchgängig
danach, weil eine Datei bisher immer nur einen Workspace enthält. Jede
vergessene Bedingung wäre ein Datenleck zwischen Abteilungen.
#### Entscheidung: Schemanamen aus der Workspace-ID
| Schema | Inhalt |
|---|---|
| `munin_control` | Kontroll-Datenbank (Workspaces, Key-Routing, Workspace-Admins) |
| `munin_ws_<id>` | ein Workspace, z.B. `munin_ws_mue9bh3x_1c0e1120` |
- **Präfix `munin_`:** Alles von Munin ist in der Datenbank auf einen Blick
erkennbar (`\dn munin_*`), auch wenn dort andere Anwendungen liegen.
- **ID statt Name:** Workspaces lassen sich umbenennen
(`ControlStore.UpdateWorkspace`). Ein Schema mit dem Namen müsste dann
mitumbenannt werden, während offene Pools noch auf den alten `search_path`
zeigen. Dazu kommt: Namen sind freier Text mit Umlauten und Leerzeichen,
sind nicht zwingend eindeutig, und PostgreSQL begrenzt Namen auf 63 Byte.
Derselbe Grund, aus dem der Export die ID als Ordnernamen nimmt (4.4).
- **Umwandlung:** Die ID enthält `-`, das in PostgreSQL überall Anführungszeichen
erzwingen würde. Deshalb `-` → `_`, an genau einer Stelle (eine Funktion im
Dialekt), mit Test.
- **Zuordnung in der Datenbank:** Den lesbaren Namen trägt ein Kommentar am
Schema, `COMMENT ON SCHEMA munin_ws_… IS '<Workspace-Name>'`. Er wird beim
Anlegen gesetzt und bei `UpdateWorkspace` nachgezogen. `\dn+` zeigt ihn
neben dem Schemanamen an.
#### Entscheidung: ein Datenbankbenutzer (2026-10-16)
Munin nutzt **einen** Benutzer für alles: Schemas anlegen und löschen,
Migrationen, Lesen und Schreiben. Er braucht `CONNECT, CREATE` auf der
Datenbank (oder ist ihr Eigentümer); alles Weitere ergibt sich daraus, dass
ihm gehört, was er anlegt. Übersicht je Vorgang: `BETRIEB.md`, „Rechte des
Datenbankbenutzers“.
**Geprüft und verworfen: getrennte Benutzer** für Verwaltung (`munin_admin`:
Schemas, Migrationen, Eigentümer) und Betrieb (`munin_app`: nur `SELECT`,
`INSERT`, `UPDATE`, `DELETE`). Technisch ginge es mit einer zweiten
`DATABASE_ADMIN_URL`, Migrationen über die Verwaltungsverbindung, `GRANT …`
und `ALTER DEFAULT PRIVILEGES` je Schema (etwa 1 Tag). Der Gewinn ist aber
klein:
- Workspaces werden zur Laufzeit über die Oberfläche angelegt, Migrationen
laufen beim ersten Öffnen. Der laufende Prozess bräuchte also **beide**
Zugänge; wer ihn übernimmt, hat beide.
- Geschützt wäre nur der normale Abfrageweg gegen versehentliche DDL. Dort
nutzt Munin durchgehend Platzhalter.
- Den Verwaltungsbenutzer ganz aus dem Prozess zu halten, ginge nur mit
Workspaces per Befehl beim Deployment oder mit `SECURITY DEFINER`-Funktionen
in der Datenbank (PostgreSQL-eigen, kein Gegenstück in SQLite).
**Wenn es doch gebraucht wird** (etwa weil der Datenbankbetrieb getrennte
Benutzer verlangt): obiger Weg. Löschen kann dann nur `munin_admin`, denn
`DROP SCHEMA` verlangt das Eigentum (erprobt). Beim Umstieg überträgt
`REASSIGN OWNED BY munin_app TO munin_admin;` (als Superuser) das Eigentum an
allen bestehenden Schemas und Tabellen auf einmal (erprobt).
#### Entscheidung: Verbindungen fest begrenzt
PostgreSQL erlaubt standardmäßig nur 100 Verbindungen (`max_connections`).
Heute hat jeder Pool 8 Lese- und 1 Schreibverbindung. Bei 40 Workspaces wären
das über 360 Verbindungen. PostgreSQL würde neue Verbindungen ablehnen, und
Anfragen schlügen mit Fehlern fehl.
**Warum kleine Pools reichen:** Eine Verbindung ist nur für die Dauer einer
Abfrage oder Transaktion belegt, also Millisekunden (`get` ≈ 1 ms, `recall`
≈ 5 ms, siehe `LASTTEST.md`). Während ein Agent bzw. das LLM nachdenkt, hält
Munin keine Verbindung. Länger belegt ist eine Verbindung nur beim Aufbau
eines Suchindex oder bei Export und Import, und das ist eine einzelne
Leseverbindung.
**Regeln:**
1. **Feste, kleine Pools unter PostgreSQL:** je Workspace 2 Lese- und
2 Schreibverbindungen, `munin_control` 2. Bis die schreibenden Methoden
abgesichert sind (Schritt 4), bleibt es bei 1 Schreibverbindung. Unter
SQLite bleibt alles wie heute.
2. **Leerlauf gibt Verbindungen zurück:** `SetConnMaxIdleTime` (z.B. 2
Minuten). Ein ruhiger Workspace hält dann gar keine Verbindung.
3. **In Go warten statt in PostgreSQL scheitern:** Ist ein Pool voll, wartet
`database/sql` auf eine freie Verbindung, statt einen Fehler zu liefern.
Begrenzt wird das Warten durch den Kontext der Anfrage. Solange die Summe
aller Pools unter `max_connections` bleibt, kann PostgreSQL also nie
„too many connections“ melden. Schlimmstenfalls wird es langsamer.
4. **Rechnung, die immer aufgehen muss:**
`Workspaces × 4 + 2 ≤ max_connections − Reserve (10)`.
Bei 40 Workspaces sind das 162, also `max_connections = 200`.
5. **Prüfung durch Munin, statt sich darauf zu verlassen:** Beim Start liest
Munin `SHOW max_connections` und rechnet nach. Geht die Rechnung nicht
auf, startet Munin nicht und nennt die nötige Zahl. Das Anlegen eines
Workspaces, der die Rechnung sprengen würde, wird mit derselben Meldung
abgelehnt. So fällt die Grenze beim Einrichten auf und nicht unter Last.
#### Unterschiede im Verhalten
- **Sortierung:** `ORDER BY slug` bzw. `ORDER BY tag` (z.B. `entries.go`)
sortiert SQLite Byte für Byte. PostgreSQL sortiert nach Spracheinstellung,
und bei `en_US`/`de_DE` werden Bindestriche zunächst übergangen (`a-c` vor
`ab`). Deshalb die Datenbank mit `--locale=C` anlegen, dann verhält sie sich
wie SQLite. Die Startprüfung (siehe oben) kann das gleich mit prüfen.
- **`LIKE`** unterscheidet in PostgreSQL Groß- und Kleinschreibung, in SQLite
(bei ASCII) nicht. Die heutigen Stellen in `audit.go` und `usage.go`
vergleichen feste, kleingeschriebene Texte. Bei neuen Abfragen beachten.
- **`NULL` beim Sortieren:** PostgreSQL sortiert `NULL` bei `DESC` nach vorn,
SQLite nach hinten. Betrifft heute nichts (nur `deleted_at` ist nullbar).
- **Ausführungspläne:** Manche Abfragen sind auf SQLite zugeschnitten, z.B.
verzichtet `entries.go` (verwandte Einträge) bewusst auf `ORDER BY`, weil
SQLite sonst schlecht plant. In Schritt 3 die häufigen Wege (`recall`,
`get`, `remember`, verwandte Einträge) mit `EXPLAIN` prüfen. Ob das Tempo
stimmt, zeigt der Lasttest.
- **Start ohne Datenbank:** Eine SQLite-Datei ist immer da, PostgreSQL ist es
vielleicht noch nicht (Container fährt hoch). Beim Start einige Male mit
Pause erneut versuchen, dann mit klarer Meldung abbrechen.
#### Mehrere Schreibverbindungen
**Was die eine Schreibverbindung heute kostet** (`LASTTEST.md`, ein Projekt,
30 % Schreibanteil):
| Agenten | Schreibvorgänge/s | Wartezeit Schreiben p50 / p95 | Fehler |
|---|---|---|---|
| 32 | ≈ 1.370 | 13 / 42 ms | 0 |
| 128 | ≈ 1.430 | 68 / 190 ms | 0 |
Der Durchsatz bleibt gleich, die Schreiber stellen sich an, und die
Wartezeit wächst mit der Zahl der Agenten. Gemessen am Schreibbudget (1.000
je *Stunde*) ist das reichlich. Zu beachten: Seit dem Suchprotokoll (2.4)
schreibt auch jeder `recall` und steht damit in derselben Schlange.
- **Unter SQLite bleibt es bei einer Schreibverbindung.** SQLite lässt
ohnehin nur einen Schreiber gleichzeitig zu. Mehr Verbindungen führen nur zu
„database is locked“ (296 von 320 Schreibvorgängen in einer früheren
Messung, siehe `store.go`).
- **Unter PostgreSQL sind mehrere Schreibverbindungen nötig.** Dort geht jede
Transaktion über das Netzwerk und dauert länger. Eine einzige Verbindung wäre
langsamer als heute mit SQLite.
**Voraussetzung:** Manche Abläufe sind heute nur sicher, weil alle
Schreibvorgänge eines Workspaces nacheinander laufen. Bekanntes Beispiel:
`PutEntry` (`entries.go`) liest `version`, prüft `ExpectedVersion`, sichert die
alte Fassung und schreibt `version + 1`. Laufen zwei Aufrufe gleichzeitig,
lesen unter PostgreSQL (READ COMMITTED) beide denselben Stand:
- **Bestehender Eintrag:** Beide schreiben dieselbe neue Version, und eine
Änderung geht verloren.
- **Neuer Eintrag:** Beide legen an, und der zweite scheitert an
`UNIQUE (project_id, slug)`, statt zu aktualisieren.
**Abhilfe, in dieser Rangfolge:**
1. **`INSERT … ON CONFLICT … DO UPDATE`**, wo es passt. Das können beide
Datenbanken (SQLite ab 3.24), der Code bleibt also gemeinsam.
2. **Gelesene Zeile sperren**, wo erst geprüft werden muss (etwa
`ExpectedVersion`). Achtung: `SELECT … FOR UPDATE` gibt es **nur in
PostgreSQL**, SQLite meldet einen Syntaxfehler. Deshalb über eine
Dialekt-Methode, die bei PostgreSQL `" FOR UPDATE"` liefert und bei SQLite
`""`. SQLite braucht die Sperre nicht, dort schreibt ohnehin nur eine
Verbindung.
Vor dem Freischalten mehrerer Schreiber alle schreibenden Methoden in
`internal/store` auf dieses Muster durchsehen. Danach gehört die Regel in den
Kopfkommentar von `store.go`: *Schreibende Methoden sperren, was sie lesen und
danach ändern, oder nutzen `ON CONFLICT`.* Neuer Code kann sonst unter SQLite
korrekt sein und unter PostgreSQL still Änderungen verlieren. Normale Tests
finden das kaum, weil der Fehler nur bei echter Gleichzeitigkeit auftritt. Den Nachweis liefert der
Lasttest gegen PostgreSQL mit vielen Schreibern: Er prüft bereits, dass kein
Schreibvorgang verloren geht. Bis dahin kann auch PostgreSQL mit einer
Schreibverbindung je Workspace laufen. Das Verhalten ist dann dasselbe wie
heute.
#### Entwicklung mit Docker
`docker-compose.dev.yml` startet PostgreSQL 18 für die Entwicklung: Sortierung
`C`, `max_connections=200`, Port nur auf localhost. Beim allerersten Start
legt `scripts/postgres-dev-init.sql` zusätzlich die Datenbank `munin_test` für
die Tests an.
```
docker compose -f docker-compose.dev.yml up -d
DB_DRIVER=postgres DATABASE_URL='postgres://munin:munin@localhost:5432/munin?sslmode=disable' go run .
```
**Tests:** Die Store-Tests laufen zusätzlich gegen PostgreSQL, wenn
`MUNIN_TEST_PG` gesetzt ist (Datenbank `munin_test`, siehe
`internal/store/postgres_test.go`). Ohne die Variable werden sie übersprungen, und
`go test ./...` braucht weiterhin kein Docker. Jeder Test bekommt ein eigenes,
zufällig benanntes Schema und räumt es am Ende ab, damit parallele Tests sich
nicht stören.
**Manueller Ablauf.** Tests werden von Hand aufgerufen, eine automatische
Prüfung gibt es nicht. Der Gleichstand beider Datenbanken hängt also an
diesem Ablauf:
| Wann | Was |
|---|---|
| Bei jeder Änderung | `go test ./...` (nur SQLite) |
| Nach einer neuen Migration oder geänderten Abfrage | zusätzlich `MUNIN_TEST_PG=… go test ./...` mit laufendem Container. Dabei laufen Store-Suite, Schema-Vergleich und Export/Import (`TestRoundTripPostgres`) gegen PostgreSQL |
| Nach Änderungen am Schreibweg | Lasttest (Auslöser wie in `LASTTEST.md`). Läuft vorerst nur gegen SQLite; die PostgreSQL-Fassung kommt mit Schritt 4 |
| Vor einer Auslieferung | einmal alles gegen beide Datenbanken |
#### Reihenfolge und Aufwand
Schätzung, nicht erprobt:
| Schritt | Inhalt | Aufwand | Stand |
|---|---|---|---|
| 1 | Docker-Compose, Konfiguration, Treiber, Dialekt mit `Rebind`, Migrationen aufteilen, Start mit Wiederholung | 1–2 Tage | ✅ 2026-10-16 |
| 2 | Workspace-Manager auf Schema je Workspace (`munin_ws_<id>`, Kommentar mit Namen), `munin_control`, feste Poolgrößen mit Startprüfung | 1½ Tage | ✅ 2026-10-16 |
| 3 | Store-Tests gegen PostgreSQL, Dialektfehler beheben, `EXPLAIN` der häufigen Wege | 1½–2½ Tage | ✅ 2026-10-16 |
| 4 | Schreibende Methoden absichern, mehrere Schreiber, Lasttest gegen PostgreSQL | 1–2 Tage | offen |
| 5 | Backup-Skript, `BETRIEB.md` | ½ Tag | ✅ 2026-10-16 |
| optional | Umzugswerkzeug SQLite → PostgreSQL für Bestandsdaten | 1 Tag | offen |
Bis Schritt 3 ist PostgreSQL mit einem Schreiber voll nutzbar. Schritt 4 ist
der eigentliche Gewinn, aber auch die heikelste Stelle.
**Ergebnisse Schritt 3 (2026-10-16):**
- Die ganze Store-Testsuite lief ohne einen Fehler gegen PostgreSQL. Gefunden
hat sie nur einen Fehler in einer Test-Hilfsfunktion (aufräumen nach
gelöschtem Workspace).
- Export und Import laufen unverändert, `TestRoundTripPostgres` belegt es.
- Abfragepläne mit 5.000 Einträgen, Schlagworten, Verweisen und Protokoll
(PostgreSQL 18 im Container, MacBook):
| Abfrage | Plan | Zeit |
|---|---|---|
| `get` (Eintrag nach Bezeichner) | Index `entries_project_id_slug_key` | 0,03 ms |
| Liste, neueste 50 | Index `idx_entries_project_updated` rückwärts | 0,03 ms |
| Rückverweise, verwandte Einträge | `idx_entry_links_project`, Primärschlüssel | 0,01 ms |
| Protokoll je Projekt | Index `idx_audit_ts` | 0,04 ms |
| Liste nach Schlagwort | `idx_entry_tags_project`, dann Hash Join | 1,0 ms |
| Schlagwortmenü | ganze Tabelle `entry_tags` | 1,3 ms |
| Suchen ohne Treffer | ganze Tabelle `search_log`, Fensterfunktion | 1,4 ms |
| Suchindex aufbauen | ganze Tabelle `entries` (braucht alle Zeilen) | 0,4 ms |
Alles, was je Anfrage läuft, geht über Indizes. Wo die ganze Tabelle
gelesen wird, braucht die Abfrage ohnehin (fast) alle Zeilen. Kein Index
fehlt, keine Abfrage musste geändert werden. Schreiben: 2,1 ms je
`PutEntry` samt Protokoll und Suchprotokoll.
**Ergebnisse Schritt 5 (2026-10-16):**
- `scripts/backup.sh` sichert mit `DB_DRIVER=postgres` jedes Schema einzeln
per `pg_dump` (`control.dump`, `workspaces/munin_ws_<id>.dump`) und prüft
jede Datei gleich mit `pg_restore --list`. `PSQL`, `PG_DUMP`, `PG_RESTORE`
lassen sich auf einen Container umstellen.
- **Bewusst kein einzelner Dump aller Schemas:** Daraus lässt sich ein
einzelner Workspace nicht zurückholen. `pg_restore --schema=…` legt das
Schema selbst nicht an, alle Tabellen scheitern dann (ausprobiert).
- Durchgespielt: Sicherung, einzelnen Workspace über einen bestehenden
zurückspielen (altes Schema als `munin_vorher_ws_…` zur Seite), komplette
Sicherung in eine neue Datenbank und Munin darauf starten. Die
Identitätsspalte von `search_log` zählt nach dem Zurückspielen richtig
weiter.
- Munin läuft mit einem Benutzer, der nur `CONNECT, CREATE` auf der Datenbank
hat (sie muss ihm nicht gehören), ebenso als Eigentümer der Datenbank. In
beiden Fällen ohne
Superuser-Rechte (ausprobiert).
- `BETRIEB.md` hat für jeden Ablauf die PostgreSQL-Fassung, dazu einen neuen
Abschnitt *PostgreSQL ist weg*. README *Mit PostgreSQL* nennt die
Anforderungen an die Produktiv-Datenbank.
- **Nicht erprobt:** wie sich Munin verhält, wenn PostgreSQL im laufenden
Betrieb neu startet. Erwartet: Anfragen scheitern während des Ausfalls,
danach baut `database/sql` neue Verbindungen auf. Beim nächsten Neustart des
Entwicklungs-Containers mit laufendem Munin nachsehen.
### 4.11 Bereitschaft prüfen und Probe der Sicherung — ✅ umgesetzt (2026-10-16)
Zwei Lücken, die mit PostgreSQL sichtbar wurden: Die Datenbank ist dort ein
eigener Dienst, der wegfallen kann, und die Probe der Sicherung bestand aus
vielen Handgriffen.
**`/live` und `/ready`** (`internal/web/handlers_ready.go`):
| Pfad | Prüft | Daran hängt |
|---|---|---|
| `/live` | nur, ob der Prozess antwortet | Neustart des Containers (`HEALTHCHECK` im Dockerfile). Fehlt nur die Datenbank, hülfe ein Neustart nicht |
| `/ready` | `SELECT 1` auf der Kontroll-Datenbank, Zeitlimit 2 s; sonst `503` | Load-Balancer, Monitoring |
- `/healthz` (früherer Name) bleibt als Alias für `/live`, damit vorhandene
Healthchecks und Monitore nicht ins Leere laufen.
- Die Antwort verrät nichts (`ok` bzw. `nicht bereit`), der Grund steht im
Log. Dort steht nur der **Wechsel** (`Bereitschaft: Datenbank antwortet
nicht …` / `… wieder`), sonst flutete eine Abfrage alle paar Sekunden das Log.
- Alle drei Pfade erscheinen nicht im Zugriffsprotokoll.
- Test: `TestLiveAndReady` (Kontroll-Datenbank geschlossen → `/ready` 503,
`/live` weiter 200, keine Einzelheiten in der Antwort).
**`scripts/sicherung-pruefen.sh`**: spielt eine Sicherung zurück (SQLite mit
`PRAGMA integrity_check`, PostgreSQL in eine leere Probe-Datenbank), startet
Munin darauf mit eigenem Port und eigenen Geheimnissen, wartet auf `/ready`,
vergleicht die Zahl der Workspaces und räumt auf. Ist die Probe-Datenbank
nicht leer, bricht es ab, statt etwas zu überschreiben.
Ausprobiert: SQLite und PostgreSQL bestanden; eine absichtlich beschädigte
SQLite-Datei fällt mit klarer Meldung durch; die echte Datenbank als
`PROBE_DATABASE_URL` wird abgelehnt und bleibt unberührt. Unterwegs gefunden:
`tr … </dev/urandom | head` beendet mit `pipefail` das Skript (SIGPIPE), und
`sqlite3` bricht bei schwerem Schaden selbst ab, bevor die eigene Meldung
kommt. Beides ist abgefangen.
---
## 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 |
| ✅ Ausgewählte Einträge löschen (5.5) | Aufräumen ohne jeden Eintrag einzeln zu öffnen | gering |
| ✅ Verweise in Code ignorieren (5.6) | Beispiele in Code werden nicht zu falschen Verweisen | gering |
| ✅ Ähnliche Einträge beim Anlegen (5.7) | „Gibt es schon?“ auch für Menschen | gering |
| ✅ Rückfrage bei ungespeicherten Änderungen (5.8) | Kein verlorener Text | gering |
| ✅ Verwandte Einträge in der Ansicht (5.9) | Zweite Reihe im Verweisgraphen | gering |
| ✅ Veraltete Einträge sichtbar (5.10) | Gezielt aufräumen | gering |
| ✅ Protokoll filtern (5.11) | „Was hat dieser Zugang gestern gemacht?“ | gering |
| ✅ Systemseite (5.12) | Verbindungen gegen das Budget, Fassung | mittel |
| ✅ Suche über alle Projekte (5.13) | „In welchem Projekt stand das?“ | mittel |
| ✅ Tastenkürzel (5.14) | Schneller pflegen | gering |
| ✅ Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare überarbeitet (5.15) | Orientierung und Lesbarkeit | gering |
| ✅ Systemseite: Zustand, Aktivität, Bestand (5.16) | Auf einen Blick: Ist etwas zu tun, wird Munin genutzt? | mittel |
| ✅ Überblick für Workspace-Admins (5.17) | Was im eigenen Workspace zu tun ist, je Projekt mit Link | gering |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
der erste.
### 5.7 Ähnliche Einträge beim Anlegen — ✅ umgesetzt (2026-10-16)
Agenten bekamen bei `remember` eine Rückfrage mit Kandidaten (3.3), Menschen
im Formular nicht. Das war die letzte Lücke im Schutz gegen doppeltes Wissen.
- Die Prüfung zog dafür aus `internal/mcpserver` in ein eigenes Paket
`internal/duplicates` (`Check`). MCP und Weboberfläche urteilen so mit
derselben Logik; zwei Fassungen liefen mit der Zeit auseinander.
- `GET /p/{projekt}/entries/similar?title=…&slug=…` (Schreibrecht) liefert die
Kandidaten als JSON, samt gemeinsamer Wörter. `static/similar.js` fragt
nach einer Tipp-Pause (400 ms) und zeigt unter dem Titel „Gibt es schon?“.
- **Nur ein Hinweis, keine Sperre:** Ein Mensch sieht die Liste und
entscheidet; speichern geht immer. Die Links öffnen einen neuen Tab, damit
der angefangene Text bleibt.
- Nur beim Anlegen, nicht beim Bearbeiten. Ist der Bezeichner schon vergeben,
ist es eine Aktualisierung — dann gibt es keinen Hinweis.
- Tests: `TestSimilarEntries`, `TestFormAndShortcuts`.
### 5.8 Rückfrage bei ungespeicherten Änderungen — ✅ umgesetzt (2026-10-16)
`static/unsaved.js` an Formularen mit `data-unsaved` (das
Bearbeitungsformular): Verlässt jemand die Seite mit Änderungen, fragt der
Browser nach (`beforeunload`). „Geändert“ heißt: anders als beim Laden — wer
tippt und wieder löscht, wird nicht gefragt. Beim Speichern keine Rückfrage.
Den Wortlaut legt der Browser fest; eigene Texte erlauben moderne Browser
nicht mehr. Bewusst kein Entwurf im Browser-Speicher: Das wären Inhalte, die
nach dem Abmelden auf dem Rechner liegen bleiben.
### 5.9 Verwandte Einträge in der Ansicht — ✅ umgesetzt (2026-10-16)
Unter einem Eintrag steht jetzt neben *Verweist hierher* der Kasten *Auch
verwandt*: die **zweite Reihe** aus `store.RelatedEntries` (dieselbe Funktion
wie MCP `related`), jeweils mit dem Zwischeneintrag. Die erste Reihe fehlt
dort absichtlich: Eigene Verweise stehen im Text, eingehende unter *Verweist
hierher*. Test: `TestAlsoRelated`.
### 5.10 Veraltete Einträge sichtbar machen — ✅ umgesetzt (2026-10-16)
„Veraltet“ (seit über einem Jahr unverändert, 3.5) stand nur in der
Einzelansicht. Jetzt:
- Marke *veraltet* in der Eintragsliste.
- Filterlink *N veraltet* neben der Suche (nur, wenn es welche gibt),
`?veraltet=1` zeigt genau diese.
- Kennzahl auf der Nutzungsseite, verlinkt auf die gefilterte Liste.
- Store: `EntryFilter.ChangedBefore`, `CountStaleEntries`, `StaleCutoff`.
Tests: `TestStaleEntriesFilterAndCount` (auch PostgreSQL),
`TestStaleEntries`.
### 5.11 Protokoll filtern — ✅ umgesetzt (2026-10-16)
Über der Tabelle: Vorgang (mit verständlichem Namen, `store.AuditActions`),
Zugang (Liste aus `store.AuditActors`), Zeitraum (ganze Tage), im Protokoll
über alle Projekte zusätzlich das Projekt. Ein GET-Formular, damit sich eine
gefilterte Ansicht als Adresse weitergeben lässt. Kaputte Werte in der Adresse
ergeben das ungefilterte Protokoll statt einer Fehlerseite. Höchstens 200
Vorgänge wie bisher. Tests: `TestAuditFilter` (auch PostgreSQL),
`TestAuditFilterForm`.
### 5.12 Systemseite — ✅ umgesetzt (2026-10-16)
`/admin/system`, nur Plattform-Admin:
- Datenbank (`Backend.Description`, ohne Passwort) und Bereitschaft wie
`/ready`.
- Offene Verbindungen gegen das Budget: mit PostgreSQL `max_connections` und
die daraus folgende Höchstzahl an Workspaces (`Backend.Capacity`,
`Capacity.MaxWorkspaces`), dazu je geöffnetem Workspace offen / in
Benutzung / lesen-schreiben / gewartet (`sql.DBStats` über
`Store.PoolStats`).
- Fassung aus den Build-Informationen von Go (`vcs.revision`, `vcs.time`,
`vcs.modified`), Go-Fassung, Start und Laufzeit.
Test: `TestSystemPage`.
### 5.13 Suche über alle Projekte — ✅ umgesetzt (2026-10-16)
`/admin/search`, für Plattform- und Workspace-Admins (wer alle Projekte
verwalten darf, darf auch in allen suchen). Durchsucht jeden Projektindex und
zeigt die Treffer **nach Projekt gruppiert**, je Projekt die besten fünf, die
Projekte nach ihrem besten Treffer sortiert. Eine gemeinsame Rangfolge gäbe es
nicht ehrlich: Jedes Projekt hat seinen eigenen BM25-Index, die Werte sind
nicht vergleichbar.
Bewusst getrennt von 4.9: Am Zugriff der Keys ändert sich nichts, ein Key und
ein Agent sehen weiter genau ein Projekt. Test: `TestSearchAllProjects`
(auch: ein Projektzugang kommt nicht dran).
### 5.14 Tastenkürzel — ✅ umgesetzt (2026-10-16)
`static/shortcuts.js`: `/` Suchfeld, `n` neuer Eintrag, `e` bearbeiten,
`Strg+Enter`/`Cmd+Enter` speichern. Das Ziel einer Taste steht im Template
(`data-shortcut="n"` an genau dem Link, den die Rolle sehen darf) — die Rechte
bleiben so an einer Stelle. Buchstaben gelten nur außerhalb von
Eingabefeldern, mit Strg/Alt/Cmd bleiben sie dem Browser.
### 5.15 Hilfe für Workspace-Admins, Hilfe in der Kopfleiste, Formulare — ✅ umgesetzt (2026-10-16)
- **Neue Hilfeseite „Für Workspace-Admins“** (`internal/help/pages/workspace-admin.md`):
Anmelden, was sie dürfen und was nicht, ein Projekt einrichten, Überblick
behalten (Nutzung, Protokoll, Suche über alle Projekte), regelmäßige Pflege,
Projekt löschen. Sichtbar für Workspace- und Plattform-Admin. Dafür ersetzt
eine **Zielgruppe** (`ForEveryone`, `ForAdmins`, `ForPlatformAdmin`) das
bisherige `NurAdmin`. Tests: Sichtbarkeit je Lesergruppe, keine Links auf
Seiten, die der Leser nicht sieht, und neu: **jeder `#Abschnitt`-Link
innerhalb der Hilfe trifft eine vorhandene Überschrift**
(`TestLinkAnchorsExist`).
- **„Hilfe & Einstieg“ steht in der Kopfleiste** links neben „Abmelden“ statt
unten in der Seitenleiste, wo sie bei vielen Projekten und Schlagworten aus
dem Blick rutschte. Auf schmalen Bildschirmen nur „Hilfe“.
- **Formulare:** Die Breitenklassen (`short`, `medium`, `long`) begrenzen jetzt
nur das Eingabefeld, nicht mehr Beschriftung und Hinweis — lange Hinweise
brachen vorher in eine Spalte von knapp 290 Pixeln um. Mehr Abstand zwischen
Feldern, Hinweise mit Zeilenabstand 1,5 und bis zur Lesebreite. Zahlen-,
Datums- und Dateifelder sind gestaltet wie die übrigen (vorher
Browser-Standard). Alle einzeiligen Bedienelemente und Knöpfe haben eine
gemeinsame Höhe (`--hoehe-bedienelement`, 2,5 rem); vorher 36 bis 42 Pixel,
in der Filterleiste standen Beschriftungen dadurch versetzt. Auswahllisten
mit langen Optionen (Rolle, Import-Modus) sind breiter. Hinweise an
Ankreuzfeldern stehen unter der Beschriftung.
- **Schmale Bildschirme:** Die Navigationszeilen scrollen in sich, statt die
ganze Seite zu verbreitern (`min-width: 0` fehlte); ein langer
Workspace-Name wird gekürzt.
- Geprüft mit Screenshots aus Chrome im Hintergrundmodus (1280 und 500 Pixel
breit; schmaler rendert Chrome dort nicht).
### 5.16 Systemseite: Zustand, Aktivität, Bestand — ✅ umgesetzt (2026-10-16)
Die Systemseite (5.12) beantwortete nur „Spielt die Datenbank mit?“. Dazu
kommen drei Abschnitte:
1. **Zustand** — ein Kasten mit allem, was zu tun ist, leer heißt „Alles in
Ordnung“. *Warnungen:* Kapazität ab 80 % der höchstens möglichen
Workspaces; Hinweise des Speichers (`Backend.Hints`, heute: PostgreSQL
sortiert nicht nach `C`); ein Workspace ließ sich nicht auswerten;
WAL-Datei über 64 MB (der Checkpoint kommt nicht durch); ein Workspace auf
einem anderen Schema-Stand als das Programm; der letzte Export
unvollständig, überfällig (Intervall + 2 Std.) oder seit dem Start nach
einer Stunde noch keiner fertig. *Hinweise:* Anfragen mussten seit der
vorigen Zählung auf eine Verbindung warten; Keys laufen in 14 Tagen ab;
Keys seit 90 Tagen ungenutzt (je Workspace genannt); ungenutzte
Workspace-Admin-Zugänge.
2. **Aktivität der letzten 30 Tage** über alle Workspaces: Schreibvorgänge je
Tag (Agenten und Menschen gestapelt) und Suchen je Tag als Balken, dazu
„Als Tabelle“, die zehn aktivsten Projekte und die Projekte ganz ohne
Aktivität. Quelle sind Protokoll (`entry_write`, `entry_delete`) und
Suchprotokoll (recall) — keine neue Zählung. 30 Tage, weil das
Suchprotokoll danach aufgeräumt wird. Tage in UTC.
3. **Bestand je Workspace:** Projekte, Einträge, Fassungen, Papierkorb,
gültige Keys, Platz (SQLite: Datei samt WAL; PostgreSQL: Tabellen des
Schemas samt Indizes über `pg_total_relation_size`), Summenzeile. Unter
„Programm“ zusätzlich Schema-Stand und letzter Export.
**Umsetzung:**
- `internal/store/sysinfo.go`: `Store.Inventory`, `Store.DiskUsage` (über
eine neue Dialekt-Methode `diskUsage`), `Store.Activity`,
`Store.SchemaVersion`. Keys werden in Go gezählt, mit denselben Regeln wie
die Seite „Zugänge“ (`APIKey.Unused`, `ExpiresSoon`).
- `internal/stats`: Der `Collector` fragt alle Workspaces ab und merkt sich
das Ergebnis 5 Minuten (`MinInterval`). Gerechnet wird beim Aufruf der
Seite, ohne Hintergrundlauf. Er öffnet dafür alle Workspaces — das tun das
Aufräumen und der Export beim Start ohnehin.
- `internal/web/chart.go`: Balkendiagramme als SVG, vom Server
ausgerechnet. Kein JavaScript, keine Bibliothek, keine `style`-Attribute
(CSP); Farben über Klassen aus `style.css`, damit das dunkle Schema passt.
Tooltip je Tag über `<title>`.
- `export.Exporter` merkt sich den letzten Lauf über alle Workspaces
(`RecordRun`, `LastRun`); `export.Interval` ersetzt die Konstante in
`main.go`.
**Nicht umgesetzt** (siehe 4.5): Laufzeitwerte wie Aufrufe je Werkzeug und
Dauern — sie brauchen eine neue Zählung und gehören in das künftige
Monitoring. Ebenso nicht: die Sicherung. Sie läuft per Cron außerhalb von
Munin; dafür gibt es das Log des Cronjobs und die Probe (4.11). Freier
Platz auf der Platte fehlt ebenfalls: Er braucht Systemaufrufe je
Betriebssystem und ist ein klassischer Wert für das Monitoring.
Tests: `TestInventory`, `TestDiskUsage`, `TestActivity`, `TestSchemaVersion`
(auch gegen PostgreSQL), `TestCollect`, `TestMinInterval`,
`TestChart`, `TestRound125`, `TestByteSize`, `TestSystemPageState`.
Geprüft mit Screenshots (1280 und 500 Pixel) mit Testdaten über 30 Tage.
### 5.17 Überblick für Workspace-Admins — ✅ umgesetzt (2026-10-16)
`/admin/overview`, für Workspace-Admins und den Plattform-Admin (für den
gewählten Workspace; in der Seitenleiste „Überblick (Workspace)“). Das
Gegenstück zur Systemseite, beschränkt auf das, was ein Workspace-Admin selbst
erledigen kann. Technik (Verbindungen, Platz, Schema, Export) bleibt auf der
Systemseite.
- **Kennzahlen:** Projekte, Einträge, gültige Keys, Schreibvorgänge und
Suchen der letzten 30 Tage.
- **Was zu tun ist**, je Projekt eine Karte, jeder Punkt mit Link genau
dorthin, wo er zu erledigen ist. Projekte mit Dringendem zuerst.
*Dringend:* ein Key läuft in 14 Tagen ab (mit Datum); kein gültiger Key im
Projekt. *Bei Gelegenheit:* kein Key mit Rolle `admin` (das Team muss für
jeden Zugang den Workspace-Admin fragen); Keys 90 Tage ungenutzt; 30 Tage
weder geschrieben noch gesucht (nicht bei Projekten ohne Key — dort steht
schon die Warnung); ab 30 % Suchen ohne Treffer bei mindestens 10 Suchen;
kuratierte Einträge zur Prüfung; Einträge über ein Jahr unverändert.
Jede Prüfung nimmt dieselbe Regel wie die verlinkte Seite.
- **Projekte:** Tabelle mit Einträgen, Schreibvorgängen (Agenten/Menschen),
Suchen, Anteil ohne Treffer, letzter Änderung; der Name führt zur Nutzung.
- **Aktivität:** dieselben Diagramme wie auf der Systemseite, nur für diesen
Workspace.
Umsetzung: `internal/web/handlers_overview.go`;
`stats.Collector.ForWorkspace` (ohne Zwischenspeicher — nach einem neuen
Key soll der Hinweis sofort verschwinden); das Diagramm-Template liegt jetzt
in `partials.html`. Die Startseite des Workspace-Admins bleibt die
Projektliste. Test: `TestWorkspaceAdminOverview` (auch: Warnungen vor
Hinweisen, keine Technik auf der Seite, ein Projektzugang kommt nicht dran).
### 5.6 Verweise in Code ignorieren — ✅ umgesetzt (2026-10-16)
Ein Eintrag, der erklärt, wie man verweist, schreibt dafür ein Beispiel in
Code. Früher wurde auch das als Verweis gezählt: Es erschien unter „Verweise
ins Leere“, und beim Anzeigen stand mitten im Code ein Markdown-Link statt des
Beispiels. Aufgefallen beim Dokumentieren in Munin selbst (`munin-ueberblick`
verwies auf einen Eintrag „bezeichner“).
- `ExtractLinks` und `ReplaceLinks` (`internal/store/links.go`) übergehen
Codeblöcke (```` ``` ```` bzw. `~~~`, mit Info-Zeichenkette, auch bis zum
Textende offen) und Code im Fließtext zwischen gleich langen
Backtick-Folgen.
- Eingerückte Codeblöcke (vier Leerzeichen) bewusst nicht: von eingerückten
Listenzeilen nicht sicher zu unterscheiden, und ein übersehener echter
Verweis wäre schlimmer als ein überflüssiger.
- Bestehende Einträge behalten ihre alten Verweise in `entry_links`, bis sie
das nächste Mal geschrieben werden.
- Tests: Fälle in `TestExtractLinks`, `TestReplaceLinksLeavesCodeAlone`.
### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14)
In der Sammelleiste der Eintragsliste steht neben *Anwenden* der Knopf
*In den Papierkorb* (`POST /p/{projekt}/entries/bulk-delete`, Schreibrecht).
- Selbes Formular wie die Schlagwort-Sammelbearbeitung, anderer Knopf
(`formaction`). Er steht hinter *Anwenden*, weil Enter in einem Textfeld
den ersten Knopf auslöst. Rückfrage über `data-confirm` (static/confirm.js).
- `store.MoveEntriesToTrash`: eine Transaktion, Auswahl über die interne ID;
fremde und schon gelöschte IDs werden übersprungen wie bei
`BulkUpdateTags`.
- Je Eintrag eine Protokollzeile `entry_delete`, damit Nutzungsübersicht und
Protokoll jede Löschung einzeln sehen.
- Die Weiterleitung behält Schlagwortfilter und Suche (gilt jetzt auch für die
Schlagwort-Sammelbearbeitung).
### 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_help.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/help`) 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
`help.Render` rufen nur noch dort hinein. Einziger Unterschied:
`RenderWithAnchors` (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 `anchorIDs` in
`internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe
Regel wie für Eintragsbezeichner. `TestHelpLinksPointToExistingSections`
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 (`coarseDiff` 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, 4.6 und seit 2026-10-14 das Suchprotokoll liefern
die Daten dafür: Wie viele `remember`-Aufrufe legen neu an, wie viele
Suchen bleiben ohne Treffer?
8. ~~**Kandidaten bei Dubletten** (3.3)~~ — erledigt 2026-10-14, zusammen mit
dem Schutz kuratierter Einträge (3.3a). **Bezeichner vorschlagen** (3.4)
bleibt offen; es ändert den MCP-Vertrag.
9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
gebraucht wird.
10. **PostgreSQL als zweiter Speicherort** (4.10) — produktiv nutzbar seit
2026-10-16 (Schritte 1, 2, 3, 5). Offen: mehrere Schreiber (Schritt 4),
erst wenn die Wartezeiten beim Schreiben es verlangen. Den genauen Stand
führt 4.10.
11. ~~**`/live` und `/ready`, Probe der Sicherung** (4.11), **Verweise in Code**
(5.6), **Dublettenprüfung nachgeschärft** (3.3)~~ — erledigt 2026-10-16.
12. ~~**Weboberfläche:** ähnliche Einträge beim Anlegen (5.7), Rückfrage bei
ungespeicherten Änderungen (5.8), verwandte Einträge (5.9), veraltete
Einträge (5.10), Protokoll filtern (5.11), Systemseite (5.12), Suche über
alle Projekte (5.13), Tastenkürzel (5.14), **Ablaufdatum für Keys** (4.7
Stufe 2)~~ — erledigt 2026-10-16.
13. ~~**Systemseite: Zustand, Aktivität, Bestand** (5.16) und **Überblick für
Workspace-Admins** (5.17)~~ — erledigt 2026-10-16. Als Nächstes dort: `/metrics` bzw. OpenTelemetry (4.5),
sobald das zentrale Monitoring steht.
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.
Zusätzlich vorgezogen und erledigt 2026-09-25: **Verwandte Einträge** (3.7),
**Aktualität, kleiner Schritt** (3.5) und **Importer** (3.2).
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, Zugriff über ein Projekt hinaus (4.9). Alle vier sind gute
Ideen für den Fall, dass ihre Auslöser eintreten — und vier gute Wege, Zeit in
Probleme zu stecken, die es noch nicht gibt.