diff --git a/docs/03-auth-und-rechte.md b/docs/03-auth-und-rechte.md
index 76acc8d..d9da54d 100644
--- a/docs/03-auth-und-rechte.md
+++ b/docs/03-auth-und-rechte.md
@@ -99,10 +99,21 @@ Für Abschnitte und Verlaufseinträge gilt dasselbe eine Ebene tiefer: `minClear
nicht über die eigene Stufe heben, und eine Erhöhung wirkt rückwirkend auf alle Versionen
(siehe `docs/05-akten-und-vorlagen.md`).
-**Ausnahmsweiser Zugang** läuft über `CaseParticipant`: wer als Beteiligter eingetragen ist,
-liest die Akte unabhängig von seiner Stufe. Das ist der vorgesehene Weg, jemanden gezielt
-hinzuzuziehen, ohne ihm allgemein eine höhere Stufe zu geben. Beteiligte hinzuzufügen setzt
-`case.edit` voraus und wird protokolliert.
+**Ausnahmsweiser Zugang** läuft über `CaseParticipant`, aber **nur für Arbeitsrollen**. Die
+Beteiligtenliste beantwortet zwei ganz verschiedene Fragen:
+
+| Rolle | Bedeutung | Öffnet die Akte |
+|---|---|---|
+| `LEAD`, `INVESTIGATOR`, `OBSERVER` | bearbeitet den Fall | ja, unabhängig von der Stufe |
+| `WITNESS`, `SUBJECT`, `SUSPECT`, `ACCOMPLICE` | ist Gegenstand des Falls | nein |
+
+Die erste Gruppe ist der vorgesehene Weg, jemanden gezielt hinzuzuziehen, ohne ihm allgemein
+eine höhere Stufe zu geben. Die zweite ist eine Angabe über den Fall und keine Berechtigung an
+ihm — sonst läse ein Betroffener oder Beschuldigter mit Discord-Konto die Ermittlung gegen sich
+selbst mit. Die Liste steht in `lib/cases/terms.ts` als `WORKING_ROLES`; wer sie erweitert,
+entscheidet damit über Zugriff.
+
+Beteiligte hinzuzufügen setzt `case.edit` voraus und wird protokolliert.
Die Gegenprobe bleibt der `CaseExclusion`: er schlägt jede Stufe und auch den Admin.
diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md
index db6222a..159426b 100644
--- a/docs/13-entscheidungen.md
+++ b/docs/13-entscheidungen.md
@@ -1107,3 +1107,41 @@ Fix grün und wurde wieder entfernt — eine Prüfung, die nicht anschlägt, ist
keine. `web/tests/thema-vorab.test.ts` prüft stattdessen die drei Dinge am Skript, die
zusammenpassen müssen: Nonce, Hydrierungsausnahme und derselbe Speicherschlüssel wie im
Umschalter.
+
+## Beteiligte, Suche, Abteilungen
+
+### Nur Arbeitsrollen öffnen die Akte
+
+Anlass war ein einfacher Wunsch: neben Zeuge und Betroffenem sollte man auch **Täter** und
+**Mittäter** eintragen können. Dabei fiel eine Lücke auf, die schon vorher bestand.
+
+Ein Beteiligter durfte die Akte lesen, gleich in welcher Rolle — so stand es in docs/03 und so
+war es umgesetzt. Bei „Betroffener" fiel das nicht auf. Mit „Täter" wäre es offensichtlich
+falsch geworden: der Beschuldigte hätte die Ermittlung gegen sich selbst mitgelesen, sofern er
+ein Discord-Konto hat.
+
+Die Beteiligtenliste beantwortet eben zwei Fragen — *wer bearbeitet den Fall* und *um wen geht
+es darin* —, und nur die erste ist eine Berechtigung. `WORKING_ROLES` in `lib/cases/terms.ts`
+nennt die drei, die die Akte öffnen: Ermittlungsführung, Ermittler, Beobachter. Zeuge,
+Betroffener, Täter und Mittäter öffnen nichts.
+
+Das gilt an allen vier Stellen, die auf Beteiligung geprüft haben: Aktenzugriff,
+Dokumentzugriff über die besitzende Akte, „meine Fälle" auf der Übersicht und der Filter „nur
+meine" in der Suche. Ein Test geht alle vier Nicht-Arbeitsrollen einzeln durch.
+
+### Die Suche zeigt, wo sonst noch etwas liegt
+
+Wer nach einem Namen suchte, landete auf den Fallakten und erfuhr nie, dass es zu demselben
+Namen auch eine Personalakte gibt — die Suche fand sie, aber hinter einem Reiter, auf den
+nichts hindeutete. Die Reiter tragen jetzt ihre Trefferzahl, und ein leeres Ergebnis nennt
+ausdrücklich, wo etwas liegt.
+
+Dabei fiel auf, dass `searchPersonnel` das Recht `personnel.read` **nicht** prüfte. Die
+Kommandopalette tat es, die Aktenseite tat es, die Suche nicht: über `/suche?kind=personnel`
+kam jeder mit ausreichender Stufe an eine Liste aller Personalakten mit Namen, Dienstgrad und
+Etiketten. Die Prüfung steht jetzt in `lib/search/service.ts` und damit an der Stelle, die
+diese Akten herausgibt, nicht in der Seite.
+
+Die Trefferzahl einer Art, die es für den Nutzer nicht gibt, ist `null` und nicht `0`: eine
+Null wäre die Auskunft „hier ist nichts", und die steht ihm nicht zu. Der Reiter fehlt dann
+ganz.
diff --git a/packages/db/prisma/migrations/20260904090000_taeter_und_mittaeter/migration.sql b/packages/db/prisma/migrations/20260904090000_taeter_und_mittaeter/migration.sql
new file mode 100644
index 0000000..902c26b
--- /dev/null
+++ b/packages/db/prisma/migrations/20260904090000_taeter_und_mittaeter/migration.sql
@@ -0,0 +1,13 @@
+-- Täter und Mittäter als Beteiligtenrollen.
+--
+-- Damit wird eine Lücke sichtbar, die es schon vorher gab: ein Beteiligter darf
+-- die Akte lesen, gleich in welcher Rolle. Bei „Betroffener" fiel das nicht auf,
+-- bei „Täter" wäre es offensichtlich falsch — der Beschuldigte läse die
+-- Ermittlung gegen sich selbst mit.
+--
+-- Die Prüfung in lib/access/caseFilter.ts unterscheidet deshalb ab jetzt
+-- Arbeitsrollen (LEAD, INVESTIGATOR, OBSERVER) von Angaben über den Fall
+-- (WITNESS, SUBJECT, SUSPECT, ACCOMPLICE). Nur die ersten öffnen die Akte.
+
+ALTER TYPE "ParticipantRole" ADD VALUE IF NOT EXISTS 'SUSPECT';
+ALTER TYPE "ParticipantRole" ADD VALUE IF NOT EXISTS 'ACCOMPLICE';
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index b8b5e02..f8e8285 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -410,12 +410,21 @@ model CaseParticipant {
@@index([personnelFileId])
}
+/// Rollen eines Beteiligten.
+///
+/// Zwei Gruppen, und der Unterschied entscheidet ueber den Zugriff: LEAD,
+/// INVESTIGATOR und OBSERVER **arbeiten** an der Akte und duerfen sie deshalb
+/// lesen. WITNESS, SUBJECT, SUSPECT und ACCOMPLICE sind Angaben **ueber** den
+/// Fall und verschaffen keinen Zugang — sonst laese der Beschuldigte die
+/// Ermittlung gegen sich selbst mit. Siehe lib/cases/terms.ts.
enum ParticipantRole {
LEAD
INVESTIGATOR
+ OBSERVER
WITNESS
SUBJECT
- OBSERVER
+ SUSPECT
+ ACCOMPLICE
}
/// Schlaegt jede Freigabestufe. Deckt den Fall ab, dass gegen einen hochrangigen
diff --git a/web/app/(app)/suche/page.tsx b/web/app/(app)/suche/page.tsx
index 2798545..34899e2 100644
--- a/web/app/(app)/suche/page.tsx
+++ b/web/app/(app)/suche/page.tsx
@@ -3,7 +3,12 @@ import Link from 'next/link'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { parseFilters, toSearchParams, SEARCH_KINDS } from '@/lib/search/query'
-import { searchCases, searchDocuments, searchPersonnel } from '@/lib/search/service'
+import {
+ searchCases,
+ searchCounts,
+ searchDocuments,
+ searchPersonnel,
+} from '@/lib/search/service'
import { builtinViews } from '@/lib/search/views'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
@@ -44,12 +49,17 @@ export default async function SearchPage({
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
- const ergebnis =
+ const [ergebnis, zahlen] = await Promise.all([
filters.kind === 'document'
- ? await searchDocuments(user.permissions, filters)
+ ? searchDocuments(user.permissions, filters)
: filters.kind === 'personnel'
- ? await searchPersonnel(user.permissions, filters)
- : await searchCases(user.permissions, filters)
+ ? searchPersonnel(user.permissions, filters)
+ : searchCases(user.permissions, filters),
+ // Die Zahlen der anderen Arten. Ohne sie sucht man nach einem Namen, landet
+ // auf den Fallakten und erfährt nie, dass es dazu auch eine Personalakte
+ // gibt — der Reiter sieht leer aus wie jeder andere.
+ searchCounts(user.permissions, filters),
+ ])
const statusTags = filters.kind === 'personnel' ? await listStatusTags() : []
@@ -64,16 +74,26 @@ export default async function SearchPage({
-