dorfteich/docs/vs-nfd/31-issue-entwurf.md
Claude Opus 5 fd07f716f6
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m42s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m47s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts)
Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings,
all verified against the code), the prioritized action plan rev. 2 with
issue references written back to every checkbox, the two-stage issue/ADR
brief, and the full reviewed draft used to create the forge state.

Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture
decisions: no security base functions (par. 52 VSA anchor), HKDF token
key separation, external authentication, page classification, read-access
audit trail (variant A), reproducible offline deployment, plugin trust
model, and backup target restriction.

Forge state created alongside this commit: 11 labels, milestones M24-M31,
issues #188-#236 (docs-only change, no code touched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 01:48:05 +02:00

3193 lines
131 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.

# Entwurf: Meilensteine, Issues und ADRs für die VS-NfD-Ertüchtigung
> **Stufe 2 ausgeführt am 2026-07-30:** ADRs 00190026 geschrieben, 11
> Labels, Meilensteine `M24``M31` und Issues **#188#236** im Forge
> angelegt — die Nummern entsprechen 1:1 den provisorischen dieses
> Entwurfs, der Korrekturschritt entfiel. Issue-Referenzen stehen im
> Maßnahmenplan an den Checkboxen.
> Auftrag: `docs/vs-nfd/30-issue-adr-auftrag.md`.
> Quelle: `docs/vs-nfd/20-massnahmenplan.md` (Rev. 2).
> Stand Stufe 1: 2026-07-29 (Review-Entwurf, Freigabe durch Stefan
> 29./30.07.).
Sprache: Meilenstein-Titel, Issue- und ADR-Texte sind **englisch**
(Repo-Konvention), der Rahmen dieses Dokuments deutsch. Deutsche
Rechtsbegriffe bleiben unübersetzt: VS-NfD, Verschlusssache, VSA,
Geheimschutz.
---
## 0. Befunde zum Auftrag selbst (bitte zuerst lesen)
### 0.1 Die Ist-Aufnahme fehlte — inzwischen nachgezogen
Der Auftrag verlangt, Fundorte **ausschließlich** aus
`docs/vs-nfd/10-ist-aufnahme.md` zu übernehmen. Diese Datei existierte beim
Schreiben dieses Entwurfs **nicht** — im Repo lag nur ihr
Analyse-_Auftrag_ (jetzt `docs/vs-nfd/00-analyse-auftrag.md`). Der
Maßnahmenplan Rev. 2 ist aus einer Analyse entstanden, die nie als Datei
abgelegt wurde.
**Vorgehen stattdessen:** Jede im Plan behauptete Tatsache wurde direkt am
Code verifiziert. Das ist strenger als Kopieren, denn es deckt auch
Abweichungen auf (siehe 0.2). Jeder Fundort unten ist belegt; wo etwas
nicht belegbar war, steht es als `UNKLAR` da.
**Nachtrag (auf Stefans Entscheidung, gleiche Session):**
`docs/vs-nfd/10-ist-aufnahme.md` ist inzwischen geschrieben — 42 Befunde
(5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK) mit Fundort und Bewertung.
Sie bestätigt die Plan-Befunde, enthält die drei Korrekturen aus §0.2 und
liefert **fünf Ergänzungen, die im Plan fehlen** (I-22 bis I-26). Vier
davon sind hier als Issues #233#236 nachgetragen; die fünfte (I-25,
IndexedDB-Kopie auf Endgeräten) ist als Akzeptanzkriterium in #226 und
#231 eingearbeitet, weil sie eine Dokumentationspflicht ist und keine
Codeänderung.
### 0.2 Korrekturen am Maßnahmenplan aus der Verifikation
| Plan-Aussage | Verifikation | Konsequenz |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| „`deploy/compose/.env` prüfen, Beispieldatei statt Realdatei" | Die Datei existiert lokal, ist aber **nicht im Repo**`.gitignore:5` schließt `.env` aus, `git ls-files deploy/compose/` listet nur `.env.example`. | Issue #198 wird zur **Verifikation + Doku**, nicht zur Entschärfung eines Lecks. Aufwand bleibt 0,5 AT. |
| „drawio-Plugin: lädt es eine externe Editor-URL?" | **Nein.** drawio ist vendored: `packages/plugins/drawio/vendor/drawio-30.3.6/`. `manifest.json` nennt keine externe URL (`kind: code`, `permissions: ["blockData","ui"]`, `homepage` ist nur Metadatum). | Kein Ausschlusskriterium, kein Issue. Restprüfung: eigene Fetches der vendorten Webapp — durch CSP `default-src 'self'` blockiert. |
| „Fließen Labels heute in Exporte?" | **Nein.** `export.service.ts:98105` lädt `labelIds` ausschließlich für `permissions.filterPages`; kein Export-Pfad schreibt Labels in die Ausgabe. | Bestätigt die ADR-0022-Begründung: Labels wären als Kennzeichnung schon deshalb untauglich, weil sie die Anwendung nie verlassen. Kein Issue. |
| „MCP-Gate-Duplikat: divergiert es vom zentralen Modell?" | **Nein.** `mcp.service.ts:232253` ruft `PermissionService.hasPondRole` / `canAccessPage`. Eigenständig sind nur die _Schalter_ (`mcp.enabled` + Pond-`mcpEnabled`), nicht die Entscheidungslogik. | Kein Issue. |
| „Collab-WebSocket: Autorisierung über Permission-Modell oder nur Token?" | **Nur Token.** `apps/collab/src/server.ts:108125`: `verifyCollabToken` + Abgleich `claims.pageId === documentName`; das Permission-Modell wirkt nur bei der Token-_Ausgabe_ in der api, Entzug asynchron per `pg_notify`-Access-Listener (`apps/collab/src/index.ts:6270`). | Kein eigenes Issue — fließt als Randbedingung in #222 (Instrumentierung des Collab-WS-Join) ein. |
| „Was bricht ohne Internetzugang?" | Offen — beantwortbar nur durch den Testlauf. | Fließt in #220 ein, kein eigenes Issue. |
Damit sind **vier der fünf** offenen Punkte des Plans in dieser Session
geklärt und brauchen kein Issue; der fünfte ist Teil von M5.
### 0.3 Aufwandssummen: der Plan ist in sich nicht konsistent
Der Auftrag verlangt, Aufwände zu übernehmen und die Meilenstein-Summen
gegen die Tabelle zu prüfen. Ergebnis: Die Meilenstein-Zahlen des Auftrags
sind **konsistent mit den Phasen-Kopfzahlen** des Plans, aber die
Phasen-Kopfzahlen sind **kleiner als die Summe ihrer eigenen
Positionen**:
| Block | Kopfzahl im Plan | Summe der Positionen | Delta |
| -------------- | ---------------- | -------------------- | ------- |
| Phase 2 | 2228 AT | 26,532,5 AT | +4,5 AT |
| Phase 1 · P1-1 | 1012 AT | 1113 AT | +1 AT |
| Phase 1 · P1-2 | 1418 AT | 1418 AT | ±0 |
| Phase 1 · P1-3 | 810 AT | 810 AT | ±0 |
| Phase 3 Var. A | 810 AT | 910 AT | +1 AT |
| Phase 5 | 1520 AT | 1820 AT | +3 AT |
Ich habe **nicht neu geschätzt** (Auftragsregel). Die Milestone-Tabelle in
Abschnitt 2 nennt daher beide Zahlen: die Auftrags-Vorgabe und die Summe
der zugeordneten Issues. Klärungsbedarf, siehe Abschnitt 6.
### 0.4 Forge: Gitea, keine passende CLI
- `git remote -v``git@gitea-fable-5:stwaidele/dorfteich.git`, also
**Gitea 1.22 auf `gitea.101010.cloud`**.
- CLIs: `gh` ist installiert, aber gegen **github.com** authentifiziert
(`gh auth status` → Account `stwaidele`) — für dieses Repo unbrauchbar.
`tea`, `glab`, `forgejo-cli` sind nicht installiert.
- Der Auftrag verbietet, „mit curl gegen eine geratene API-URL zu
improvisieren". Das trifft hier nicht zu: Die **Gitea REST API v1** ist
der im Projekt etablierte, dokumentierte Weg (`CLAUDE.md`, `Handoff.md`
→ „Typische Handgriffe"), inklusive Konto `fable-5` und
Passwort-Handhabung. Lesend bereits in dieser Session genutzt (Labels,
Meilensteine, höchste Issue-Nummer).
- **Vorhandene Labels:** `auth`, `backend`, `blocked`, `collab`,
`deployment`, `docs`, `frontend`, `plugins`, `qa`.
- **Vorhandene Meilensteine:** `M0`…`M23` plus
`Barrierefreiheit WCAG 2.1 AA` (M0M11 offen, M12M23 geschlossen).
- **Höchste Issue-Nummer:** #187. Nächste freie: **#188**.
- **Nächste freie ADR-Nummer:** **0019** (`docs/architecture/adr/`,
vorhanden 00010018).
Daraus folgen zwei Namenskollisions-Fragen an Stefan (Abschnitt 6).
---
## 1. Umfang dieses Entwurfs
| Artefakt | Anzahl |
| ------------ | ---------------------------------------- |
| Meilensteine | **8** |
| Issues | **49** (#188#236, Nummern provisorisch) |
| ADRs | **8** (00190026) |
| Neue Labels | **11** (10 aus dem Auftrag + `area:ops`) |
45 Issues aus dem Maßnahmenplan plus **4 aus der Ist-Aufnahme**
(#233#236, Befunde I-22 bis I-24 und I-26 — im Plan nicht enthalten).
Die 45 liegen über der Auftrags-Schätzung („~30"). Ursache ist keine
Ausweitung, sondern die Auftragsregel selbst: Die sieben Ausgabekanäle aus
P1-2 werden eigene Issues (statt einer Checkliste), und Phase 5 liefert
sechs Dokumentations-Issues. Kürzungsoptionen in Abschnitt 6 — von Stefan
verworfen, die Granularität bleibt.
---
## 2. Meilensteine
Reihenfolge und Titel wie im Auftrag vorgegeben (dependency-sortiert).
| Titel | Issues | AT (Auftrag) | AT (Summe Issues) |
| ----------------------------------- | ------------------------- | ------------ | ----------------- |
| `VS-NfD: security quick wins` | #188#198, #233#235 (14) | 1518 | 21,524,5 |
| `VS-NfD: hardening & supply chain` | #199#203, #236 (6) | 710 | 9,512,5 |
| `VS-NfD: classification metadata` | #204#213 (10) | 1418 | 1418 ✓ |
| `VS-NfD: external authentication` | #214#217 (4) | 1012 | 1113 |
| `VS-NfD: offline/airgap deployment` | #218#221 (4) | 810 | 79 |
| `VS-NfD: read-access audit trail` | #222#225 (4) | 810 | 910 |
| `VS-NfD: compliance documentation` | #226#231 (6) | 1520 | 1820 |
| `VS-NfD: backlog` | #232 (1) | 810 | 810 ✓ |
`offline/airgap` liegt unter der Vorgabe, weil das Digest-Pinning (1 AT)
laut Auftrag nach `hardening & supply chain` wandert.
### Beschreibungstexte (für das Meilenstein-Feld)
**`VS-NfD: security quick wins`**
> Cheap changes with high assessor signal: token key separation, fail-closed
> CSRF, bounded sessions, restricted backup targets, and the data-hygiene
> jobs that are currently missing (pond purge, orphan files, trash in the
> search index, audit retention). No dependencies — can start immediately.
**`VS-NfD: hardening & supply chain`**
> The items pulled forward from the roadmap plus supply-chain evidence:
> attachment integrity hashes, a hard plugin off-switch, a stable SIEM event
> catalogue, SBOM in CI, and image digest pinning. **Digest pinning should
> land before `VS-NfD: offline/airgap deployment`** — the mirror procedure
> and the offline update path both build on immutable references.
**`VS-NfD: classification metadata`**
> `classification` as first-class page metadata (ADR 0022) and its
> pass-through into every output channel. Separating classification _levels_
> stays outside the application (one instance per level); this milestone
> delivers the _marking_. **Prerequisite for `VS-NfD: read-access audit
trail`.**
**`VS-NfD: external authentication`**
> OIDC (Authorization Code + PKCE) against the existing
> `UserIdentity.provider` slot, proxy-header/mTLS as the alternative path,
> and a hard `auth.local.enabled = false` switch including every token flow.
> Delegates the authentication base function to the operator's platform
> (ADR 0019).
**`VS-NfD: offline/airgap deployment`**
> A _verified_ airgap path, not a plausible one: registry mirror procedure,
> network-free reproducible build, a documented isolated test run, and an
> offline update path including migrations. **Depends on image digest
> pinning in `VS-NfD: hardening & supply chain`.**
**`VS-NfD: read-access audit trail`**
> Variant A of the plan: read events **only** for pages with
> `classification = VS_NFD`. **Requires `VS-NfD: classification
metadata`.** Deliberately narrow — clean purpose limitation, and it keeps
> Yjs sync from producing an event flood.
**`VS-NfD: compliance documentation`**
> The §52 VSA delimitation statement, hardening guide, security
> documentation, operations manual, IT-Grundschutz mapping (APP.3.1,
> CON.11.1) and the residual-risk list. **Runs in parallel and starts
> early** — the delimitation statement does not depend on any
> implementation.
**`VS-NfD: backlog`**
> Deliberately deferred: plugin allowlist with hash pinning. The hard
> off-switch in `VS-NfD: hardening & supply chain` already closes the
> "code execution in the VS zone" risk for the offer stage.
---
## 3. Labels
Neu anzulegen (10):
| Label | Farbe (Vorschlag) | Bedeutung |
| ------------------- | ----------------- | ----------------------------------------------------------------------- |
| `vs-nfd` | `#1f3a5f` | Gehört zur VS-NfD-Ertüchtigung |
| `vs-nfd:blocker` | `#8b0000` | Verhindert den Einsatz, bis gelöst (Phase 1) |
| `effort:S` | `#c2e0c6` | ≤ 1 AT |
| `effort:M` | `#fef2c0` | 23 AT |
| `effort:L` | `#f9c9c9` | ≥ 4 AT |
| `area:auth` | `#5319e7` | Authentisierung, Sessions, Tokens, CSRF |
| `area:export` | `#0e8a16` | Ausgabekanäle |
| `area:storage` | `#006b75` | Datenhaltung, Löschung, Suchindex, Audit-Tabellen |
| `area:supply-chain` | `#b60205` | Images, Abhängigkeiten, Offline-Pfad |
| `area:docs` | `#0075ca` | Dokumentation |
| `area:ops` | `#6a737d` | Querschnitt Betrieb: Security-Header/CORS, SIEM-Katalog (Rückfrage 6.3) |
Die Effort-Schwellen sind eine Festlegung dieses Entwurfs (der Plan nennt
nur AT). Zwei Issues fallen in keinen `area:`-Wert saubar: #197
(Security-Header) und #201 (Ereigniskatalog) — siehe Rückfrage 6.3.
---
## 4. Issues
Nummern waren beim Entwurf provisorisch (nächste freie war #188) und
wurden beim Anlegen am 2026-07-30 **1:1 bestätigt** — alle `Depends
on`-Verweise stimmen unverändert.
Alle Fundorte sind in dieser Session am Code geprüft. Zeilennummern stehen
nur dort, wo sie verifiziert wurden.
---
### M1 — `VS-NfD: security quick wins`
#### #188 — [VS-NfD] Separate token keys with HKDF and replace the homegrown JWT with `jose`
**Plan reference:** `docs/vs-nfd/20-massnahmenplan.md` → Phase 2, lines 1+2
**ADR:** ADR 0020
**Effort:** L (35 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:L` `area:auth`
_Deliberately merged from two plan checkboxes — the plan itself justifies
it: both touch `packages/shared/src/token-crypto.ts`, and doing them
separately costs 56 AT instead of 35._
## Context
One secret currently serves two unrelated purposes. Purpose-bound subkeys
derived via HKDF make a compromise of one path non-transferable, and a
vetted JWT implementation removes hand-written crypto from the trust
boundary. This does **not** introduce a new security base function
(§52 VSA) — it narrows crypto the application already performs.
## Current state
- `COLLAB_TOKEN_SECRET` (`packages/shared/src/env.ts:55,142`) signs
collaboration tokens (`apps/collab/src/index.ts:46`,
`apps/api/src/pages/pages.service.ts:383`) **and** unsubscribe tokens
(`apps/api/src/notifications/digest.service.ts:161`,
`apps/api/src/notifications/notifications.controller.ts:51`).
- `packages/shared/src/token-crypto.ts:1,37` implements a compact HS256 JWT
with `node:crypto` (`createHmac`, `timingSafeEqual`). The file header
documents the reason: identical code in the CommonJS api and the ESM
collab server.
- Neither `jose` nor `jsonwebtoken` is a dependency of any workspace
package.
- Partial separation already exists: `apps/api/src/notifications/unsubscribe-token.ts:14`
prefixes a `PURPOSE` constant into the HMAC input.
## Acceptance criteria
- [ ] Subkeys are derived per purpose via HKDF from the configured root
secret; no code path signs with the root secret directly.
- [ ] Collaboration tokens are produced and verified by `jose`, HS256 only,
with the algorithm allowlist asserted by a test.
- [ ] A cross-runtime test proves the same token verifies in the api
(CommonJS) and the collab server (ESM) — the reason the homegrown
implementation existed.
- [ ] Unsubscribe tokens keep a documented **dual-verify window** (old and
new derivation accepted) long enough to cover links already in sent
mail; the window's length and expiry date are documented.
- [ ] Negative tests: token signed with a different purpose's subkey is
rejected; `alg: none` and `RS256` are rejected.
- [ ] `docs/architecture/security.md` §"Secrets & configuration" documents
the key hierarchy; `deploy/compose/.env.example` documents the root
secret's role.
## Out of scope
Rotation automation, moving secrets into an external KMS, and any change to
session cookies (see #190).
---
#### #189 — [VS-NfD] Make the CSRF origin check fail closed
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** S (1 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:S` `area:auth`
## Context
A state-changing request that presents neither `Origin` nor `Referer`
currently passes the same-origin check. An assessor reads that as a
fail-open control regardless of whether SameSite cookies happen to cover
the browser case.
## Current state
- `apps/api/src/auth/auth.guard.ts:108112`: `assertSameOrigin` takes
`origin ?? referer` and returns early when both are absent. The comment
names the intent (non-browser clients such as curl and supertest;
SameSite cookies as the real defence).
## Acceptance criteria
- [ ] Mutating requests without `Origin` and without `Referer` are
rejected with `403 csrf_origin_mismatch`.
- [ ] A documented, deliberate exception path exists for non-browser
clients (PAT/bearer authentication), and cookie-authenticated
requests never benefit from it.
- [ ] Tests: cookie-auth mutation without either header → 403;
PAT-authenticated mutation without either header → success;
mismatching origin → 403 (existing behaviour, kept).
- [ ] The api's own test helpers and e2e fixtures are adjusted rather than
the check weakened.
- [ ] `docs/architecture/security.md` records the fail-closed rule.
## Out of scope
CSRF tokens as a second mechanism — the origin check plus SameSite is the
chosen model.
---
#### #190 — [VS-NfD] Make the session lifetime configurable and add an idle timeout
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** M (12 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:auth`
## Context
A sliding 30-day session is far outside what a VS-NfD operating concept
accepts, and it is a compile-time constant today. The operator must be able
to set both an absolute and an idle bound.
## Current state
- `apps/api/src/auth/sessions.service.ts:8`:
`const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days`,
applied at creation (`:31`) and renewed on every touch (`:50`).
- `apps/api/src/auth/auth.guard.ts:58` sets the cookie `maxAge` to the same
30 days.
- No idle timeout exists; `lastSeenAt` is written but never used as a bound.
## Acceptance criteria
- [ ] Absolute lifetime and idle timeout are separately configurable, with
defaults well below 30 days; the cookie `maxAge` follows the
configured value.
- [ ] Idle expiry is enforced server-side against `lastSeenAt`, not only by
cookie expiry.
- [ ] Tests: session past its absolute bound is rejected; session idle past
the idle bound is rejected; active use renews idle but never exceeds
the absolute bound.
- [ ] Hardening guide (#227) names the recommended VS-NfD values;
`.env.example` documents the settings.
## Out of scope
Forced re-authentication for individual actions, and concurrent-session
limits.
---
#### #191 — [VS-NfD] Remove the feed token from the query string, or allow feeds to be disabled
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** M (2 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:auth`
## Context
Credentials in URLs land in proxy logs, browser history and referrer
headers. In a VS zone the proxy log is exactly the place where a long-lived
read credential must not appear.
## Current state
- `apps/api/src/public/public.controller.ts:28,43` read the credential from
`@Query('token')`; `apps/api/src/public/feed.service.ts:25` documents the
scheme (the query parameter authenticates the request as the token's
user).
- Feed tokens are stored hashed (`apps/api/src/public/feed-tokens.service.ts:73`),
so the exposure is transport/logging, not storage.
- There is no instance switch for feeds — `instance-settings.service.ts`
has `api.enabled` and `mcp.enabled` but no feed equivalent.
## Acceptance criteria
- [ ] Either the token moves out of the query string (header or path
segment with documented cache implications), **or** an instance
switch `feeds.enabled` (default off for the VS-NfD reference config)
makes the whole surface answer 404 — the plan allows either.
- [ ] Whichever path is chosen, the other is documented as rejected with a
reason.
- [ ] Tests: existing feed reader flow still works; with the switch off
every feed route answers 404; no code path logs the token.
- [ ] Hardening guide (#227) lists the setting.
## Out of scope
Replacing feeds with a different notification channel.
---
#### #192 — [VS-NfD] Restrict backup targets to an allowlist and allow remote targets to be disabled at deploy time
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** ADR 0026
**Effort:** M (2 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
A backup destination is an egress path for the full content of the
instance. In a VS zone the set of permissible destinations is decided by
the operator, not by whoever holds Site-Admin. Encryption stays out —
media protection is the platform's base function (ADR 0019).
## Current state
- Remote target is a freely configurable WebDAV/Nextcloud URL:
`backup.nextcloud.baseUrl` in
`apps/api/src/settings/instance-settings.service.ts` (validated as a URL,
no host restriction), consumed via
`apps/api/src/backup/backup-target.service.ts:3,65` and
`apps/api/src/admin/backup-admin.service.ts:27,112,146`.
- A second remote path is the rsync mirror (`apps/backup/src/mirror.ts`,
ADR 0015, issue #84).
- No allowlist and no deploy-level kill switch for either path.
## Acceptance criteria
- [ ] A deploy-level allowlist (env, not a runtime setting) constrains
permissible backup destination hosts; a value outside it is rejected
with a clear admin-visible error.
- [ ] An empty allowlist disables **all** remote targets — WebDAV and rsync
mirror — and the admin UI reflects that they are unavailable, not
merely unconfigured.
- [ ] Tests: destination outside the allowlist rejected; empty allowlist
leaves only the local target; existing configured destination inside
the allowlist unaffected.
- [ ] `docs/architecture/operations.md` and the hardening guide (#227)
document the "local only" reference configuration.
## Out of scope
Application-side backup encryption (deliberately excluded, ADR 0019/0026)
and changes to the restore flow.
---
#### #193 — [VS-NfD] Implement pond purge
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** M (3 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
Deletion must actually delete. A trashed pond that stays in the database
forever is an unbounded residue of classified content and unanswerable in
a "deletion and destruction" chapter of the operations manual.
## Current state
- `Pond.deletedAt` / `deletedBy` implement a pond-level trash
(`apps/api/prisma/schema.prisma:175,186`, ADR 0013).
- Purge exists **only for pages**: `apps/api/src/trash/trash.service.ts`
(`purgeNow:96`, `purgeDuePages:104`, `purgePage:121`) and the endpoint
`apps/api/src/trash/trash.controller.ts:26`. No equivalent for ponds.
## Acceptance criteria
- [ ] Retention-driven and manual pond purge exist and remove, in one
transaction-safe sequence: pages, revisions, `page_updates`,
`page_content_cache` rows (including the search vector),
attachments on disk, labels, links, comments, favorites, watches, and
pond-level grants.
- [ ] Quota counters are corrected; the operation is idempotent and
resumable (a purge that races another is a no-op, as for pages).
- [ ] Audit event recorded for both manual and retention purge.
- [ ] DB test proves nothing referencing the pond survives, and a follow-up
search for content of the purged pond returns nothing.
- [ ] `docs/architecture/operations.md` documents retention behaviour;
operations manual (#229) covers it under deletion and destruction.
## Out of scope
Purge of user accounts (already covered by existing pseudonymization) and
the orphan-file sweep (#194).
---
#### #194 — [VS-NfD] Implement the orphan-file sweep and resolve `Attachment.deletedAt`
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** M (2 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
Files whose page no longer embeds them are never reclaimed, so content can
survive its page indefinitely on disk. An unused nullable column that looks
like a soft-delete marker is itself a finding — a reader cannot tell
whether deletion is soft or hard.
## Current state
- `apps/api/prisma/schema.prisma:530538` documents both facts explicitly:
the `pageId` link is "not touched when an image is later removed from its
page's content — an orphan-file sweep to reclaim those is a separate
future maintenance job (operations.md), not this one", and
"`deletedAt` stays unused for now — purge hard-deletes attachments".
## Acceptance criteria
- [ ] A scheduled sweep identifies attachments no longer referenced by any
live page document and removes row plus file, correcting quota usage.
- [ ] A grace period protects the paste-then-insert window (an upload whose
page does not exist yet) — proven by a test.
- [ ] `Attachment.deletedAt` is either used by the sweep with documented
semantics **or** removed by migration; the schema comment matches the
outcome.
- [ ] Test: file removed from page content is reclaimed after the grace
period; a freshly uploaded, not-yet-embedded file is not.
- [ ] `docs/architecture/operations.md` documents the job; #229 covers it.
## Out of scope
Deduplication by content hash (see #199) and pond purge (#193).
---
#### #195 — [VS-NfD] Remove trashed content from the search index instead of filtering at query time
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** M (2 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
The full-text index holds plaintext of trashed pages and ponds; only the
query hides them. Any future query path that forgets the filter leaks
content, and the index is a content copy that a deletion concept has to
account for.
## Current state
- `apps/api/src/search/postgres-search.provider.ts:41` — the weighted
`tsvector` lives on `page_content_cache.search_vector`.
- `:142143` — the search query joins
`pages p ON … p.deleted_at IS NULL` and
`ponds po ON … po.deleted_at IS NULL`: query-side filtering.
- `:73` shows the vector can be nulled per page
(`UPDATE page_content_cache SET search_vector = NULL WHERE page_id = …`).
## Acceptance criteria
- [ ] Trashing a page or pond clears the search vector of the affected
pages; restoring rebuilds it.
- [ ] The query-side `deleted_at IS NULL` guards **stay** (defence in
depth) and a test asserts both layers independently.
- [ ] Test: a trashed page's unique term is absent from the index rows
themselves, not merely from results; restore makes it findable again.
- [ ] A one-off backfill clears vectors of already-trashed content.
- [ ] `docs/architecture/security.md` records that the index holds no
trashed content (the file has no search section today — add one, or
extend §"Content & upload security").
## Out of scope
Encrypting or removing the plaintext cache for live pages, and any change
of search engine.
---
#### #196 — [VS-NfD] Add a retention job for `audit_log`
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** S (1 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:S` `area:storage`
## Context
An audit trail without a retention rule grows without bound and conflicts
with data-protection requirements the operator has to answer for. A
configurable period is also what the IT-Grundschutz mapping (#230) needs
to reference.
## Current state
- `audit_log` is the `AuditEntry` model
(`apps/api/prisma/schema.prisma:7491`, issue #86), written by
`apps/api/src/audit/audit.service.ts` in addition to the `audit: …`
stdout line.
- No retention or pruning job for the table exists.
## Acceptance criteria
- [ ] Retention period is configurable (instance setting or env, matching
the pattern used for backup retention) with a documented default.
- [ ] A scheduled job deletes entries past the period; the deletion itself
is logged (count, cutoff) so the gap is explainable.
- [ ] Test: entries older than the cutoff are removed, newer ones stay.
- [ ] The read-access trail (#224) is explicitly **not** covered by this
job — it gets its own period.
- [ ] A logging section of `docs/architecture/security.md` and #229
document the period. **Note:** the file has **no** logging section
today, although `apps/api/prisma/schema.prisma:69` already cites
"security.md §Logging" — creating it is part of this issue.
## Out of scope
Export of audit entries to a SIEM (#201) and the read-access trail (M6).
---
#### #197 — [VS-NfD] Add security response headers and an explicitly restrictive CORS policy
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** S (1 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:S` `area:ops`
## Context
Response headers are the cheapest verifiable hardening evidence there is,
and their absence is the first thing an automated assessment reports. CORS
must be a stated decision, not an implicit default.
## Current state
- `helmet` is not a dependency of `apps/api` and appears nowhere in
`apps/api/src`; no CORS configuration was found in the api bootstrap.
- The web tier already ships a strict CSP (`default-src 'self'`,
`script-src 'self'`) — the api's own responses are the gap.
## Acceptance criteria
- [ ] The api sends HSTS, `X-Content-Type-Options`, `Referrer-Policy`,
`X-Frame-Options`/frame-ancestors, and a `Permissions-Policy`, each
value chosen deliberately.
- [ ] CORS is configured explicitly and restrictively (`APP_BASE_URL`
origin only, credentials rules stated); a cross-origin request from
another origin is rejected by test.
- [ ] The plugin sandbox's framing requirements (ADR 0008) are verified not
to break — covered by an existing or new plugin e2e assertion.
- [ ] A test asserts the header set on a representative api response, so
regressions are caught.
- [ ] `docs/architecture/security.md` lists the headers and their reasons.
## Out of scope
Changing the web tier's CSP, and certificate/TLS termination (operator's
reverse proxy).
---
#### #198 — [VS-NfD] Verify that no real `.env` is shipped and document the example as authoritative
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** S (0,5 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:S` `area:supply-chain`
## Context
The plan suspected a real `.env` in the repository. Verification shows it
is **not** tracked — the remaining work is to make that verifiable and keep
it that way, which is what an assessor actually asks for.
## Current state
- `deploy/compose/.env` exists in the working tree but is **not** tracked:
`.gitignore:57` excludes `.env` and `.env.*` while allowing
`.env.example`; `git ls-files deploy/compose/` lists only
`.env.example`, `Caddyfile`, `compose.dev.yml`, `docker-compose.yml`.
- `deploy/compose/.env.example` is present and maintained.
## Acceptance criteria
- [ ] A CI check fails if any `.env` (other than `.env.example`) is ever
tracked, and if a tracked file matches obvious secret patterns.
- [ ] `.env.example` documents every variable the compose files reference,
including the ones added by #188, #190, #191 and #192.
- [ ] The git history is checked once for previously committed secrets, and
the result recorded in the residual-risk list (#231) — either "none
found" or the concrete finding.
- [ ] `docs/self-hosting/README.md` states that `.env.example` is the
reference and real values never enter the repository.
## Out of scope
Introducing a secret manager, and rotating existing secrets.
---
### M2 — `VS-NfD: hardening & supply chain`
#### #199 — [VS-NfD] Add SHA-256 integrity hashes for attachments
**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap)
**ADR:** n/a
**Effort:** M (23 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
Integrity is the one security base function §52 VSA leaves to the
application in the sense of _detecting_ manipulation of its own payloads —
a checksum is not a protection mechanism the platform can supply, because
only the application knows what the file should be. Side benefits:
orphan sweep, dedup, backup verification.
## Current state
- The `Attachment` model (`apps/api/prisma/schema.prisma:530559`) carries
no checksum column; the only hashes in the schema are credential/token
hashes (`token_hash`, session id, `credential`).
- Files are written by `apps/api/src/files/files.service.ts`.
## Acceptance criteria
- [ ] A `sha256` column is added by migration; the hash is computed during
upload (streaming, not by re-reading the file) and stored.
- [ ] Download verifies the hash and fails closed with a distinguishable
error when it does not match; the mismatch is audited.
- [ ] A backfill migration or job hashes existing attachments and reports
progress; unreadable files are reported, not silently skipped.
- [ ] Tests: upload stores the correct hash; tampering with the file on
disk makes download fail; backfill is idempotent.
- [ ] `docs/architecture/security.md` §"Content & upload security" and #229
document the behaviour, including what an operator does when
verification fails.
## Out of scope
Signatures (needs a signing identity — see ADR 0025), content
encryption (ADR 0019), and dedup by hash.
---
#### #200 — [VS-NfD] Add a hard `plugins.enabled = false` switch
**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap)
**ADR:** ADR 0025
**Effort:** M (2 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:supply-chain`
## Context
"Code execution inside the VS zone" is the question a plugin architecture
attracts. A single, verifiable off-switch answers it completely for the
offer stage — much cheaper than the trust machinery in #232, and it is what
the reference configuration will use.
## Current state
- `instance-settings.service.ts` provides master switches for the public
API (`api.enabled`, default false) and MCP (`mcp.enabled`, default
false) — the enforcement pattern to copy is
`apps/api/src/public-api/public-api.guard.ts:64` and
`apps/api/src/mcp/mcp.controller.ts:50,84` (404 while disabled).
- **No equivalent exists for plugins.** Plugin state is per pond plus the
installed set; there is no instance-level kill switch.
## Acceptance criteria
- [ ] `plugins.enabled` (default documented; **off** in the VS-NfD
reference config) makes every plugin surface answer 404: manifest and
asset routes, the frame route
(`/api/v1/plugins/<id>/<version>/frame`), install/uninstall, and the
pond-level toggles.
- [ ] With the switch off, existing plugin blocks in documents render their
declared `fallback` instead of an error, and the editor offers no
plugin blocks.
- [ ] Cache note respected: the settings cache is in-process, so the
documented procedure includes an api restart (or the setting is read
uncached) — verified by test or documented explicitly.
- [ ] Tests: every plugin route 404s while off; a page containing a plugin
block still renders; the switch is visible in the admin UI.
- [ ] Hardening guide (#227) lists the setting; `docs/architecture/plugin-architecture.md`
records the switch.
## Out of scope
Allowlisting or hash-pinning individual plugins (#232), and removing the
plugin architecture.
---
#### #201 — [VS-NfD] Define a stable event catalogue for syslog/SIEM export
**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap)
**ADR:** n/a
**Effort:** L (34 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:L` `area:ops`
## Context
The code part is small — structured JSON on stdout already exists. The
value is a **stable catalogue**: fixed event ids with documented semantics
and fields, so the operator can write SIEM rules that survive our updates.
Without that contract, every release silently breaks their detection.
## Current state
- `apps/api/src/audit/audit.service.ts` defines
`AuditEvent.action: string` — the doc comment calls it a "stable
dot-namespaced id" but nothing enforces it; it is a free-form string.
- 34 distinct actions are in use today (`auth.login_failed`,
`grant.created`, `plugin.installed`, `settings.changed`, …) across 37
call sites in `apps/api/src`.
- The service comment states the deliberate boundary: "Content activity
(pages, files, exports, labels) intentionally stays log-only — the trail
answers 'who changed access/configuration', not 'who edited what'."
## Acceptance criteria
- [ ] The action set becomes a typed union (or equivalent) so an unknown id
cannot be emitted; the existing 34 ids keep their names.
- [ ] A published catalogue documents per event: id, trigger, severity,
actor semantics, target semantics, and every field — versioned, with
a stated compatibility promise (ids are never repurposed).
- [ ] Log output is structured JSON with a stable field set suitable for
forwarding; the documented forwarding path (container stdout →
operator's collector) needs no application-side syslog client.
- [ ] A test fails when an event is emitted that the catalogue does not
describe — the fence that keeps documentation and code together.
- [ ] The catalogue lives in `docs/architecture/security.md` or a dedicated
file referenced from #228 and #230.
## Out of scope
An application-side syslog/TLS shipper, log signing, and read events (M6).
---
#### #202 — [VS-NfD] Produce an SBOM and a license report in CI
**Plan reference:** `20-massnahmenplan.md` → Phase 2
**ADR:** n/a
**Effort:** M (12 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:M` `area:supply-chain`
## Context
An SBOM is the artefact a supply-chain question is answered _with_ rather
than argued about, and the license report is needed for the procurement
side of the same conversation.
## Current state
- `.gitea/workflows/ci.yml` runs install, build, lint, typecheck, tests and
`i18n:check`; there is no SBOM step. No reference to `sbom`, `syft` or
`cyclonedx` exists anywhere in `.gitea/` or the root `package.json`.
- Deployment is by container image (`deploy/compose/docker-compose.yml`), so
the SBOM has to cover both the pnpm workspace and the images.
## Acceptance criteria
- [ ] CI produces a CycloneDX SBOM per released image plus one for the
workspace, and attaches them as build artefacts of the release
workflow.
- [ ] A license report lists every dependency with its license; the job
fails on a license outside a documented allowlist.
- [ ] Runner constraints respected: the CI runner image lacks python and
node tooling outside the workspace, and bind mounts talk to the host
daemon — the chosen tool works under those conditions (documented in
the PR).
- [ ] The SBOM's provenance is documented so an assessor can regenerate it.
- [ ] #228 references where SBOMs are published per release.
## Out of scope
Vulnerability scanning and its triage policy, and signing the SBOM.
---
#### #203 — [VS-NfD] Pin all container images by digest
**Plan reference:** `20-massnahmenplan.md` → P1-3 (moved here per the milestone plan)
**ADR:** ADR 0024
**Effort:** S (1 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:S` `area:supply-chain`
## Context
A floating tag means the deployed artefact is not the reviewed artefact.
Digest pinning is the precondition for both the registry mirror and the
offline update path (M5), which is why it lands here first.
## Current state
`deploy/compose/docker-compose.yml` pins tags, not digests:
- `postgres:17.5-alpine` (`:186`)
- `pandoc/core:3.6` (`:206`)
- `gotenberg/gotenberg:8` (`:221`) — a floating **major** tag, the loosest
of the four
- `caddy:2.10-alpine` (`:237`)
Own images are `${IMAGE_PREFIX:-dorfteich}-{web,api,collab,backup}:${TAG:-latest}`
(`:20,36,105,140`), pinned per release by the deploy workflow.
## Acceptance criteria
- [ ] Every third-party image is referenced as `name:tag@sha256:…`; the
tag stays for readability, the digest decides.
- [ ] A documented, repeatable procedure updates digests (which command,
how the new digest is verified) and a CI check fails on any
third-party image reference without a digest.
- [ ] All stage composes (test/int/prod) are updated — note that CD does
**not** sync stage composes, so the rollout step is part of this
issue's definition of done.
- [ ] `deploy/stages.md` and #229 document the update procedure.
## Out of scope
Building our own base images, and mirroring them (#218).
---
### M3 — `VS-NfD: classification metadata`
#### #204 — [VS-NfD] Add `classification` as a page field with migration and instance default
**Plan reference:** `20-massnahmenplan.md` → P1-2
**ADR:** ADR 0022
**Effort:** M (2 AT)
**Depends on:**
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:storage`
## Context
Marking classified content is the one VS-NfD requirement that genuinely
belongs _in_ the application: only it knows which page carries which
level. Separating the levels stays outside (one instance per level,
ADR 0022) — this field is the marking, not a protection mechanism.
## Current state
- `model Page` (`apps/api/prisma/schema.prisma:263300`) has no
classification field. Metadata is `title`, `slug`, `sortKey`, `parentId`,
timestamps and relations.
- `Label` (`:423437`) is pond-scoped (`pondId`), hierarchical and
user-editable — the reason ADR 0022 rejects labels as the carrier.
- `instance_settings` (`:1723`) is the established place for an
instance-wide default (pattern: `upload.svgPolicy`, `api.enabled`).
## Acceptance criteria
- [ ] Enum field on `Page` with an explicit "unclassified" default;
migration backfills existing pages to it.
- [ ] Instance setting supplies the default for newly created pages;
documented and admin-visible.
- [ ] The value is part of the page API representation and of the page
metadata the frontend already loads (no extra request per page).
- [ ] Permission-relevant behaviour is unchanged by this issue —
asserted by a test, because ADR 0022 makes the ACL claim explicitly
_not_ a protection mechanism.
- [ ] `docs/architecture/data-model.md` documents the field; ADR 0022 is
referenced from it.
## Out of scope
Inheritance (#205), any output marking (#206#212), and read auditing (M6).
---
#### #205 — [VS-NfD] Inherit classification in the page tree; require a dedicated right to downgrade
**Plan reference:** `20-massnahmenplan.md` → P1-2
**ADR:** ADR 0022
**Effort:** M (3 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:auth`
## Context
A subpage of a classified page must not silently be unclassified — that is
how classified content escapes marking in practice. Downgrading is the
sensitive direction and needs its own right plus an audit record.
## Current state
- Pages nest via `parentId` with a max depth of 6 enforced in the service,
cycles rejected at write time (`apps/api/prisma/schema.prisma:255266`);
trashed pages keep `parentId` and purge promotes children explicitly.
- Permission decisions run centrally through `apps/api/src/permissions/`
(deny-wins, default-closed) — the place to add a capability rather than
an ad-hoc check.
## Acceptance criteria
- [ ] A new page inherits the effective classification of its parent; a
page moved under a higher-classified parent is raised.
- [ ] Raising is allowed to any writer; **lowering** requires a dedicated
capability expressed in the central permission model, never an ad-hoc
check.
- [ ] Every raise and lower is audited with old value, new value, actor and
page.
- [ ] Move operations cannot lower a page's classification as a side effect
(proven by test), including the purge-promotes-children path.
- [ ] Tests cover: inherit on create, raise on move, lower denied without
the capability, lower audited with the capability.
- [ ] `docs/architecture/permissions.md` documents the capability.
## Out of scope
Output marking, and a UI for bulk re-classification.
---
#### #206 — [VS-NfD] Show the classification in the web view header and footer
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** S (1 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export`
## Context
The screen is the most-used output channel and sets the visual convention
the other channels copy.
## Current state
Reading and editing views render page metadata (title, pond) without any
classification element; no marking component exists.
## Acceptance criteria
- [ ] Reading view, editor and the public page view show the marking at
the top and bottom of the page content, using the wording fixed in
ADR 0022.
- [ ] Marking is present in de and en (`pnpm i18n:check` clean) and is
**not** a decorative element only: it is announced to assistive
technology, and it survives the theming cascade in light and dark
mode with contrast per ADR 0017.
- [ ] Unclassified pages show no marking (no visual noise) — a deliberate,
documented decision.
- [ ] e2e coverage in the a11y pack for a classified page in both themes.
- [ ] Screenshot in #228 as evidence of the convention.
## Out of scope
Print (#207), PDF (#208) and every other channel.
---
#### #207 — [VS-NfD] Add print CSS with a per-page classification header and footer
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** S (1 AT)
**Depends on:** #206
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export`
## Context
Printing from the browser is the output channel most likely to produce
paper that has to carry a marking on **every sheet** — and it is currently
unstyled entirely.
## Current state
- No `@media print` block exists anywhere in `apps/web/src` — verified by
search. Browser printing therefore reproduces the screen layout including
navigation chrome.
## Acceptance criteria
- [ ] A print stylesheet sets page size and margins, suppresses navigation
and interactive chrome, and handles break behaviour for headings,
tables, code blocks and plugin blocks.
- [ ] The classification appears in a running header **and** footer on
**every** printed page (`@page` margin boxes or an equivalent), not
once at the top.
- [ ] Verified on a multi-page document as PDF-from-browser in Chromium and
one Gecko-based browser; the check is written down so it can be
repeated.
- [ ] Unclassified pages print without a marking.
- [ ] Documented in #228 alongside the other channels.
## Out of scope
Server-side PDF (#208) and print styles as a general design feature beyond
what the marking needs.
---
#### #208 — [VS-NfD] Put the classification into the Gotenberg PDF header/footer template
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** S (1 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export`
## Context
Server-side PDF is the export an authority is most likely to file or
forward, so it must be marked per page rather than once at the top.
## Current state
- `apps/api/src/import-export/pdf-html.ts` builds the export HTML: a
document-level `<header class="pdf-header">` with pond name and title
(`:7679`) plus print CSS for page size and breaks (`:3031`, `:6971`).
This header appears **once**, not per page.
- Page numbers already come from Gotenberg's footer (`:31`), so the
per-page mechanism exists and is the place to extend.
- Renderer: `apps/api/src/import-export/gotenberg.renderer.ts`.
## Acceptance criteria
- [ ] Classification is rendered in Gotenberg's header and footer template
so it appears on every page of the PDF, next to the existing page
numbers.
- [ ] The document-level header keeps working; unclassified pages produce
an unchanged PDF (asserted against the existing PDF fidelity
snapshots).
- [ ] Fidelity test extended per the repo's fixture-first process
(`fixtures/README.md`): fixture first, snapshot regenerated only with
the pinned tool version, committed together.
- [ ] Documented in #228.
## Out of scope
DOCX/ODT (#209) and browser print (#207).
---
#### #209 — [VS-NfD] Give pandoc a reference document with classification header and footer
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** M (23 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:export`
## Context
DOCX and ODT are editable formats an authority will circulate; the marking
has to be part of the document's own header/footer definition, not text in
the body that a user can delete without noticing.
## Current state
- `apps/api/src/import-export/pandoc.converter.ts` drives pandoc; there is
**no** `--reference-doc` / `referenceDoc` usage — verified by search, so
the output uses pandoc's defaults with no header or footer.
- The container is pinned to `pandoc/core:3.6`
(`deploy/compose/docker-compose.yml:206`), which is also the version the
fidelity snapshots are generated with.
## Acceptance criteria
- [ ] A reference DOCX and a reference ODT ship in the repository with
header/footer fields carrying the classification; the converter
passes them for the respective target format.
- [ ] The marking is placed in the document's header/footer definition so
it repeats on every page in Word and LibreOffice — verified by
opening the output in both.
- [ ] Unclassified pages produce output without a marking.
- [ ] Fixture-first fidelity coverage per `fixtures/README.md`, snapshots
regenerated only with `pandoc/core:3.6`.
- [ ] How the reference documents are maintained (they are binary) is
documented next to them.
## Out of scope
Styling the exports beyond what the marking needs, and other formats.
---
#### #210 — [VS-NfD] Mark the Markdown ZIP export with frontmatter and a visible imprint
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** S (1 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export`
## Context
The ZIP export is the bulk egress path: many pages at once, as plain files
that get copied onward. It needs both a machine-readable marker and a
human-visible one.
## Current state
- `apps/api/src/import-export/export.service.ts` builds the pond ZIP
(`Content-Disposition` at `:68`); page selection at `:95108` filters by
read permission.
- `apps/api/src/import-export/export-markdown.ts` emits the Markdown; it
writes **no** YAML frontmatter — frontmatter handling exists only on the
Obsidian _import_ side (`obsidian-vault.ts:203,528`).
- Verified: labels do not appear in any export (see §0.2), so no existing
metadata block can carry the marking.
## Acceptance criteria
- [ ] Each exported Markdown file carries the classification in YAML
frontmatter **and** as a visible line at the top and bottom of the
file.
- [ ] The ZIP contains a manifest listing every file with its
classification, and the highest classification contained is stated
once at archive level.
- [ ] Unclassified pages get no marking; existing round-trip and fidelity
tests still pass (frontmatter must not confuse our own importer —
asserted by a round-trip test).
- [ ] Documented in #228.
## Out of scope
The Obsidian vault export variant's own frontmatter modes, beyond keeping
them working.
---
#### #211 — [VS-NfD] Pass the classification through feeds, public API, search results and the no-JS shell
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** M (23 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:export`
## Context
These four channels emit content without going through the SPA, so each can
leak unmarked classified content. The plan groups them under one effort
figure, so they stay one issue.
_Kept as one issue because the plan gives the four channels a single effort
figure (23 AT) and they share one mechanism: the serializer that renders a
page representation outside the SPA._
## Current state
- Atom feeds: `apps/api/src/public/feed.service.ts`, routes in
`apps/api/src/public/public.controller.ts:28,43`.
- No-JS shell: `apps/api/src/public/html-shell.ts` (server-rendered HTML
for crawlers and no-script clients).
- Public REST API: `apps/api/src/public-api/`, gated by `api.enabled` plus
pond `apiEnabled`.
- Search results: `apps/api/src/search/postgres-search.provider.ts`
(snippets contain page text).
## Acceptance criteria
- [ ] Feed entries carry the classification in a documented element, and
the feed document states the highest classification it contains.
- [ ] Public API page representations include the classification field;
the API documentation (`docs/self-hosting/public-api.md`) is updated.
- [ ] The no-JS shell renders the marking in the same places as the SPA
(top and bottom) — note the shell is a separate render path from the
TipTap view, so it needs its own assertion.
- [ ] Search results show the classification per hit, and a snippet of a
classified page is never shown unmarked.
- [ ] One test per channel; the public API test runs with the instance
switch on.
- [ ] Documented in #228.
## Out of scope
Whether these channels should be available at all in the reference config
(#227 turns them off), and MCP (no content egress beyond the public API's
model).
---
#### #212 — [VS-NfD] Mark attachment downloads by filename prefix and companion file
**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels)
**ADR:** ADR 0022
**Effort:** M (12 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:export`
## Context
An attachment leaves the application as an opaque binary — we cannot write
into arbitrary file formats, so the marking has to live in the name and in
an accompanying file. This is the channel where an honest limitation must
be documented rather than papered over.
## Current state
- Download routes: `apps/api/src/files/files.controller.ts:51,71`;
`Content-Disposition` is set for the pond ZIP
(`import-export/export.service.ts:68`) and the single-page Markdown
download (`apps/api/src/pages/pages.controller.ts:122`).
- Attachments inherit no classification today (the field arrives with
#204 on `Page`, and an attachment links to a page).
## Acceptance criteria
- [ ] A download of an attachment belonging to a classified page carries a
documented filename prefix, and a companion text file (or the
containing archive) states the classification.
- [ ] The classification an attachment inherits is defined unambiguously
for the case where its `pageId` link is unset (paste-then-insert) —
fail closed, and documented.
- [ ] Tests: prefixed filename for a classified page's attachment;
unchanged filename for an unclassified one; unset `pageId` behaves as
documented.
- [ ] The residual risk "the file's own content carries no marking" is
recorded in #231, not hidden.
## Out of scope
Writing markings into file formats (PDF/Office attachments), and blocking
downloads (#213 covers the upload side).
---
#### #213 — [VS-NfD] Warn or block when attaching files to classified pages
**Plan reference:** `20-massnahmenplan.md` → P1-2
**ADR:** ADR 0022
**Effort:** S (1 AT)
**Depends on:** #204
**Labels:** `vs-nfd` `effort:S` `area:storage`
## Context
Uploading to a classified page is the moment a user needs to be told what
they are doing — the file inherits a classification it cannot itself
carry (#212).
## Current state
- Upload path `apps/api/src/files/files.service.ts` with pond and page
scoping; no classification-aware behaviour exists (the field arrives with
#204).
## Acceptance criteria
- [ ] Uploading to a classified page shows a clear warning naming the
consequence, in de and en.
- [ ] An instance setting can turn the warning into a hard block, with a
documented default.
- [ ] Tests: warning shown for a classified page; block enforced
server-side (not only in the UI) when enabled.
- [ ] Hardening guide (#227) lists the setting.
## Out of scope
Scanning file contents, and MIME/extension policy (already
`upload.allowedExtensions` / `upload.svgPolicy`).
---
### M4 — `VS-NfD: external authentication`
#### #214 — [VS-NfD] Implement OIDC Authorization Code with PKCE, Keycloak as reference IdP
**Plan reference:** `20-massnahmenplan.md` → P1-1
**ADR:** ADR 0021
**Effort:** L (56 AT)
**Depends on:** #188
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:L` `area:auth`
## Context
Authentication is a security base function §52 VSA assigns to the
platform. Delegating it to the operator's IdP is the single most important
step in keeping Dorfteich out of the certification obligation under §51
VSA.
## Current state
- The data model has the slot: `model UserIdentity`
(`apps/api/prisma/schema.prisma:562577`) — "`provider` is 'password'
today and 'oidc:<issuer>' later", `@@unique([provider, subject])`,
`credential` holds the Argon2id hash for password identities.
- **No OIDC implementation exists**: a search for `oidc` across
`apps/api/src` and `packages/shared/src` returns nothing outside that
comment. ADR 0007 declares the readiness, not the feature.
## Acceptance criteria
- [ ] Authorization Code flow with PKCE, state and nonce validation,
discovery-based configuration, and JWKS-based token validation using
the vetted library from #188 — no hand-rolled JWT verification.
- [ ] Identity linking follows the existing model:
`provider = "oidc:<issuer>"`, `subject` from the token; an existing
local user is linked by a documented, deliberate rule (not silently
by e-mail).
- [ ] Login, logout (including IdP-initiated single logout or a documented
decision against it) and session creation reuse the existing session
service — no parallel session mechanism.
- [ ] Verified against a Keycloak instance; the setup used is documented so
the test is repeatable.
- [ ] Tests: successful login creates/links the identity; invalid state,
nonce, signature, issuer and audience each rejected; expired token
rejected.
- [ ] `docs/architecture/security.md` and #227 document configuration;
ADR 0021 records the decisions.
## Out of scope
SAML and LDAP, claim-to-role mapping (#217), and disabling local
authentication (#216).
---
#### #215 — [VS-NfD] Support a trusted reverse-proxy header or mTLS client certificate as an alternative path
**Plan reference:** `20-massnahmenplan.md` → P1-1
**ADR:** ADR 0021
**Effort:** M (2 AT)
**Depends on:** #214
**Labels:** `vs-nfd` `effort:M` `area:auth`
## Context
Some authority environments terminate authentication at the perimeter and
expect the application to trust it. Supporting that avoids forcing an IdP
into an architecture that already solved authentication — but a trusted
header is a loaded gun if it is trusted unconditionally.
## Current state
- Authentication is cookie/session based
(`apps/api/src/auth/auth.guard.ts`, `sessions.service.ts`) plus PATs; no
header- or certificate-based identity path exists.
## Acceptance criteria
- [ ] Header-based identity is **off by default** and requires both an
explicit switch and an allowlist of trusted peer addresses; a request
arriving from an untrusted peer with the header is rejected and
audited.
- [ ] The configured header name and the identity mapping are explicit
configuration, never guessed.
- [ ] mTLS variant: the certificate subject/attribute used as identity is
configurable, with the same trust-boundary rules.
- [ ] Tests: header from untrusted peer ignored and audited; header from
trusted peer authenticates; header ignored entirely while the switch
is off; spoofed header alongside a session cookie does not escalate.
- [ ] `docs/architecture/security.md` documents the trust boundary
unambiguously — this is the section an assessor will read closest.
## Out of scope
Terminating TLS in the application, and certificate lifecycle management.
---
#### #216 — [VS-NfD] Add a hard `auth.local.enabled = false` switch covering every local credential flow
**Plan reference:** `20-massnahmenplan.md` → P1-1
**ADR:** ADR 0021
**Effort:** M (2 AT)
**Depends on:** #214
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:auth`
## Context
Delegating authentication only counts if the local path is actually closed
— including the flows people forget: password reset, self-service signup,
personal access tokens and feed tokens. A half-closed local path is worse
than none, because the operating concept then describes something untrue.
## Current state
- Local passwords: `UserIdentity.credential` (Argon2id),
`apps/api/src/auth/`; token flows in
`apps/api/src/auth/auth-tokens.service.ts`,
`apps/api/src/public-api/api-tokens.service.ts` (PATs) and
`apps/api/src/public/feed-tokens.service.ts`.
- Switch precedent to follow: `api.enabled` / `mcp.enabled` in
`apps/api/src/settings/instance-settings.service.ts`, enforced as 404.
- No switch for local authentication exists.
## Acceptance criteria
- [ ] With the switch off: password login, signup, password reset,
e-mail-verification-as-login and any other credential-issuing flow
are unreachable (404/403 consistently with the project's
404/403 policy) — enumerated in the test, not assumed.
- [ ] PAT and feed-token issuance behaviour with the switch off is a
**stated decision** (blocked, or allowed only for IdP-authenticated
users), tested either way.
- [ ] The first-run setup wizard's local admin creation is addressed
explicitly — bootstrapping must remain possible without reopening
the local path in normal operation.
- [ ] A route-enumeration test proves no authentication route is
accidentally left open (extend the existing enumeration fence).
- [ ] The switch is deploy-level, not merely a runtime setting a
compromised Site-Admin could flip back — or, if runtime, that
residual risk is documented in #231.
- [ ] #227 makes "local auth off" part of the reference configuration.
## Out of scope
Migrating existing users to the IdP, and deleting stored password hashes.
---
#### #217 — [VS-NfD] Map IdP groups and roles onto the permission model
**Plan reference:** `20-massnahmenplan.md` → P1-1
**ADR:** ADR 0021
**Effort:** M (23 AT)
**Depends on:** #214
**Labels:** `vs-nfd` `effort:M` `area:auth`
## Context
Without claim mapping, every authority deployment administers permissions
twice — and the second copy drifts. Drifted permissions on classified
content is exactly the finding to avoid.
## Current state
- Permissions are grants evaluated centrally
(`apps/api/src/permissions/`, deny-wins, default-closed, ADR-documented
route-enumeration test). Grants are created through the API — the project
rule is that raw grant rows bypass the `PondPermissionCache`.
- Nothing consumes IdP claims (no OIDC exists yet, #214).
## Acceptance criteria
- [ ] A declarative, admin-visible mapping turns claims into pond roles
and the site-admin flag; the mapping is instance configuration, not
code.
- [ ] Mapped grants are applied through the same service path as manual
grants, so the permission cache stays correct (no raw row writes).
- [ ] Removal of a claim revokes the corresponding grant on next login, and
live collab sessions are terminated by the existing revocation path
(`pg_notify` access listener) — asserted by test.
- [ ] Manually created grants are distinguishable from mapped ones, and a
documented rule says which wins.
- [ ] Every mapping-driven change is audited.
- [ ] `docs/architecture/permissions.md` documents the mapping.
## Out of scope
SCIM provisioning, and just-in-time user creation policy beyond what #214
defines.
---
### M5 — `VS-NfD: offline/airgap deployment`
#### #218 — [VS-NfD] Document the mirror procedure into an internal registry
**Plan reference:** `20-massnahmenplan.md` → P1-3
**ADR:** ADR 0024
**Effort:** S (1 AT)
**Depends on:** #203
**Labels:** `vs-nfd` `effort:S` `area:supply-chain`
## Context
An authority pulls images from its own registry, not from Docker Hub. The
procedure must be written so their operations team can execute it without
us.
## Current state
- Third-party images come from public registries by tag
(`deploy/compose/docker-compose.yml:186,206,221,237`); own images from
the project registry via `IMAGE_PREFIX`/`TAG`.
- `deploy/stages.md` documents stage deployment, not mirroring.
## Acceptance criteria
- [ ] A step-by-step procedure mirrors every required image (ours and
third-party) into an internal registry, by digest, including how the
digest is verified after the copy.
- [ ] Compose files take the registry prefix from configuration so no image
reference needs editing per site.
- [ ] The complete image list is generated, not hand-maintained, so it
cannot drift.
- [ ] Executed once end-to-end and the run recorded as evidence.
- [ ] Documented in `deploy/stages.md` and the operations manual (#229).
## Out of scope
Operating a registry for the customer, and the isolated test run (#220).
---
#### #219 — [VS-NfD] Make the build reproducible without network access
**Plan reference:** `20-massnahmenplan.md` → P1-3
**ADR:** ADR 0024
**Effort:** M (23 AT)
**Depends on:** #203
**Labels:** `vs-nfd` `effort:M` `area:supply-chain`
## Context
"Builds fine offline" is a claim; a documented offline build is evidence.
The plan explicitly allows the cheaper answer — prebuilt images only — as
long as it is a stated decision.
## Current state
- pnpm workspace with a committed lockfile; CI installs with
`pnpm install --frozen-lockfile` (`.gitea/workflows/ci.yml`).
- Images are built in CI with network access; there is no offline store or
vendored dependency set.
- Note for whoever implements: a new workspace dependency also requires
touching the api Dockerfile (`COPY packages/<x>` + build).
## Acceptance criteria
- [ ] Either a pnpm offline store / vendored dependency set makes
`pnpm install` and `pnpm build` succeed with networking disabled,
**or** the decision "prebuilt images only, no customer-side build" is
documented with its consequences (no local patching).
- [ ] Whichever path: reproduced twice from a clean checkout with identical
results, and the procedure written down.
- [ ] Toolchain versions (node, pnpm, base images) are pinned and stated.
- [ ] Documented in #229; the decision recorded in ADR 0024.
## Out of scope
Bit-for-bit reproducible builds as a formal property, and mirroring
(#218).
---
#### #220 — [VS-NfD] Run and document a deployment in a network-isolated environment
**Plan reference:** `20-massnahmenplan.md` → P1-3
**ADR:** ADR 0024
**Effort:** M (2 AT)
**Depends on:** #218, #219
**Labels:** `vs-nfd` `effort:M` `area:supply-chain`
## Context
This is the issue that turns the airgap story from plausible into
verified — the plan's reason for pulling P1-3 forward. It also answers the
plan's open question "what breaks without internet access", which is not
answerable by reading code.
## Current state
- Existing strengths to confirm rather than build: no telemetry, no update
checks, no CDNs, self-hosted fonts (ADR 0016), CSP `default-src 'self'`,
Postgres full-text search instead of an external engine, drawio vendored
(§0.2).
- Whether an egress-blocked deployment is fully functional has never been
tested — the open question in `20-massnahmenplan.md`.
## Acceptance criteria
- [ ] A full deployment runs with egress blocked: install, first-run setup,
login, editing with live collaboration, search, upload, all export
formats (PDF via Gotenberg, DOCX/ODT via pandoc), backup and restore.
- [ ] Every outbound connection attempt is captured and listed; each is
either eliminated or documented as required with its purpose.
- [ ] Behaviour of outbound e-mail (SMTP) without egress is stated
explicitly — it is the one connection an authority may or may not
permit.
- [ ] A written protocol (date, environment, versions by digest, results,
deviations) exists as assessor-facing evidence.
- [ ] Findings feed back into #221 and #229.
## Out of scope
Fixing whatever the run uncovers — that becomes its own issue, referenced
from here.
---
#### #221 — [VS-NfD] Define the offline update path including migrations
**Plan reference:** `20-massnahmenplan.md` → P1-3
**ADR:** ADR 0024
**Effort:** M (23 AT)
**Depends on:** #218, #220
**Labels:** `vs-nfd` `effort:M` `area:supply-chain`
## Context
An installation that cannot be updated safely will not be updated, and an
unpatched instance in a VS zone is the outcome nobody wants. Migrations are
the risky part.
## Current state
- Stages apply migrations on api start (`MIGRATE_ON_START`); there is no
separate migration step and no documented rollback for a failed
migration.
- Production deployment pins `TAG` in the stage `.env` and pulls
(`deploy/stages.md`); rollback today means deploying the previous tag.
- Constraint to respect: `prisma migrate reset` is not an available tool
here — `migrate deploy` is the path.
## Acceptance criteria
- [ ] A documented procedure covers: obtain the update bundle, verify it
(digests), back up, apply, verify health, and roll back.
- [ ] Migration behaviour is explicit: which migrations are irreversible,
what a rollback means for the database, and when a restore is the
only way back.
- [ ] Rehearsed once in the isolated environment from #220, including one
deliberate failed-update rollback.
- [ ] Version skew during the update (api/collab/web) is described:
whether a rolling update is supported or downtime is required.
- [ ] Documented in the operations manual (#229) and
`docs/operations/restore-runbook.md`.
## Out of scope
Automating updates, and long-term support/backport policy.
---
### M6 — `VS-NfD: read-access audit trail`
#### #222 — [VS-NfD] Instrument read paths for classified content
**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A
**ADR:** ADR 0023
**Effort:** L (4 AT)
**Depends on:** #204, #205
**Labels:** `vs-nfd` `effort:L` `area:storage`
## Context
Platform logging cannot answer _which classified page_ was read — proxy
logs know URLs, not classifications. That is the gap this milestone closes,
and only for classified content, which is what keeps the purpose limitation
defensible.
## Current state
- The audit trail is deliberately write-only in scope:
`apps/api/src/audit/audit.service.ts` — "Content activity (pages, files,
exports, labels) intentionally stays log-only — the trail answers 'who
changed access/configuration', not 'who edited what'." No read events
exist (the single `action: 'read'` occurrence,
`apps/api/src/mcp/mcp.service.ts:243`, is a permission-check parameter,
not an audit action).
- Read paths to instrument: SPA page fetch, public API GET
(`apps/api/src/public-api/`), attachment download
(`apps/api/src/files/files.controller.ts:51,71`), exports
(`apps/api/src/import-export/`), no-JS shell
(`apps/api/src/public/html-shell.ts`), and the collab WS join.
- **Constraint for the WS join:** authorization there is token-only
(`apps/collab/src/server.ts:108125` — signature plus
`claims.pageId === documentName`); the collab server has no permission
context and therefore cannot know a page's classification. The api is the
natural emission point, and it is a good one: collab tokens live **60
seconds** (`apps/api/src/pages/pages.service.ts:57,384`), so a live
session re-requests one every minute — that gives per-minute granularity
for free, which the dedup window in #223 then collapses. Decide and
document.
## Acceptance criteria
- [ ] Every listed read path emits an event for pages with
`classification = VS_NFD`, and none for unclassified pages.
- [ ] Each event carries: timestamp, actor (or documented anonymous
marker), page, channel, and the classification at read time.
- [ ] The collab WS join is covered by the decided mechanism, with the
reasoning recorded in ADR 0023.
- [ ] Events survive an in-request failure of the trail without breaking
the read? — **no**: for classified content, a failed write must be a
hard failure or an explicit, documented degradation. State which, and
test it. (This is the deliberate difference from `AuditService`,
which swallows failures.)
- [ ] One test per channel proving both the event and its absence for
unclassified pages.
- [ ] The logging section of `docs/architecture/security.md` created by
#196 documents the channel list.
## Out of scope
Auditing reads of unclassified content (Variant B, rejected in
ADR 0023), and the dedup window (#223).
---
#### #223 — [VS-NfD] Add a dedup window so Yjs sync does not flood the trail
**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A
**ADR:** ADR 0023
**Effort:** M (2 AT)
**Depends on:** #222
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
A live editing session produces continuous traffic; one event per message
is both useless as evidence and a performance problem. One session plus one
page within N minutes is one read.
## Current state
- The live document is `pages.ydoc_state` plus a `page_updates` log; collab
persistence is debounced (~2 s) and sessions are long-lived
(`apps/collab/src/persistence.ts`, session registry in
`apps/collab/src/session-registry.ts`).
## Acceptance criteria
- [ ] Deduplication key (session + page + channel) and window length are
configurable, with a documented default.
- [ ] The **first** access in a window is always recorded, and the record
states that it represents a window, not a single request — so the
evidence is not misread.
- [ ] Reconnects within a window do not create a second event; a new
session does, even for the same user.
- [ ] Load evidence: a realistic editing session produces a bounded number
of events (measured figure recorded in the PR).
- [ ] The window is documented in ADR 0023 and #228, because it defines
what the trail can and cannot prove.
## Out of scope
Buffered writing for a high-volume all-reads variant (Variant B).
---
#### #224 — [VS-NfD] Store read events in their own table with retention and partitioning
**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A
**ADR:** ADR 0023
**Effort:** M (2 AT)
**Depends on:** #222
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
Read events have a different volume profile, a different retention period
and a different legal basis than `audit_log`. Mixing them would force one
policy onto both.
## Current state
- `audit_log` (`apps/api/prisma/schema.prisma:7491`) holds auth and admin
events and has no retention job yet (#196) and no partitioning.
## Acceptance criteria
- [ ] Separate table with an index set matched to the expected queries
("who read page X", "what did user Y read", both within a period).
- [ ] Time-based partitioning, with the partition-maintenance job included
and tested — not left as an operational chore.
- [ ] Own configurable retention period, independent of #196, with a
documented default and the deletion itself logged.
- [ ] A Site-Admin query path exists (or its deliberate absence is
documented) — evidence nobody can read is not evidence.
- [ ] Growth measured and stated (rows and bytes per 1000 reads) so an
operator can size storage.
- [ ] `docs/architecture/data-model.md` documents the table.
## Out of scope
SIEM forwarding of read events beyond the catalogue from #201, and
tamper-proofing.
---
#### #225 — [VS-NfD] Make the read trail switchable and document its purpose limitation
**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A
**ADR:** ADR 0023
**Effort:** M (12 AT)
**Depends on:** #222, #224
**Labels:** `vs-nfd` `effort:M` `area:docs`
## Context
Read logging is employee monitoring in the eyes of a works council. A hard
switch plus a written purpose limitation is what makes it adoptable — the
plan names this as an explicit benefit of Variant A.
## Current state
Not applicable — the feature arrives with #222.
## Acceptance criteria
- [ ] An instance switch enables/disables the trail; off means no event is
written anywhere (including stdout), verified by test.
- [ ] Default is documented and deliberate.
- [ ] A written purpose limitation states: what is recorded, why, who may
read it, for how long, and what it may **not** be used for.
- [ ] The absence of events while switched off is itself explainable (a
startup log line stating the trail is off), so a gap is never
ambiguous.
- [ ] Text ships as part of the security documentation (#228) and is
referenced from the hardening guide (#227).
## Out of scope
Four-eyes access control on the trail, and works-council templates.
---
### M7 — `VS-NfD: compliance documentation`
#### #226 — [VS-NfD] Write the §52 VSA delimitation statement
**Plan reference:** `20-massnahmenplan.md` → Phase 5
**ADR:** ADR 0019
**Effort:** L (3 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:L` `area:docs`
## Context
The most important single document of the whole undertaking: it states
which security base functions Dorfteich does **not** provide and to whom
they fall. It is what keeps the application outside the certification
obligation under §51 VSA, and it does not depend on any implementation —
so it starts first.
## Current state
- ADR 0019 (this entwurf) is its draft; existing material to draw on:
`docs/architecture/security.md`, `permissions.md`, ADR 0007
(auth/sessions), ADR 0015 (backup), ADR 0008 (plugin sandbox).
## Acceptance criteria
- [ ] Each base function (encryption, media protection, network
termination, authentication, and integrity as far as it applies) is
listed with: what the application does, what it deliberately does
not, and which party provides it.
- [ ] Every claim is traceable to code or configuration — no aspirational
statements.
- [ ] The deliberate non-features are argued as _architecture_, not
omission (the plan's explicit instruction: "no encryption in the code
is correct architecture, not a missing feature").
- [ ] Delta list: where the current state does not yet match the statement,
it points at the issue that closes the gap.
- [ ] **Endpoint-side content copies are named as an operator duty**: every
opened page is mirrored into the browser's IndexedDB
(`apps/web/src/editor/use-collab-provider.ts:76`, database
`dorfteich-page-<pageId>`), cleared on leave once synced but
deliberately kept for unsynced offline edits.
Confidentiality of that copy is endpoint media protection, i.e. the
platform's function — but it must be stated, not omitted
(`10-ist-aufnahme.md` → I-25).
- [ ] Reviewed against ADR 0019 for consistency; divergences resolve in
the ADR, not in the statement.
## Out of scope
Legal review by counsel, and the IT-Grundschutz mapping (#230).
---
#### #227 — [VS-NfD] Write the hardening guide with a "VS-NfD operation" reference configuration
**Plan reference:** `20-massnahmenplan.md` → Phase 5
**ADR:** ADR 0019
**Effort:** L (3 AT)
**Depends on:** #191, #192, #200, #216
**Labels:** `vs-nfd` `effort:L` `area:docs`
## Context
One named configuration an operator can adopt wholesale is worth more than
a list of options. It is also the artefact that makes the switches built in
M1/M2/M4 auditable.
## Current state
- `docs/self-hosting/README.md` covers ordinary self-hosting; no hardened
profile exists.
- Existing switches that belong in the profile: `api.enabled` (default
off), `mcp.enabled` (default off), `upload.svgPolicy`,
`upload.allowedExtensions`, backup settings — all in
`apps/api/src/settings/instance-settings.service.ts`.
## Acceptance criteria
- [ ] A complete reference configuration for VS-NfD operation: local auth
off, public API off, MCP off, feeds off, plugins off, backup local
only — each with the exact setting name and value.
- [ ] Every entry states **why**, so an operator can deviate knowingly.
- [ ] Settings added by M1/M2/M4 are included; the guide is updated in the
same PR as each new switch (stated as a rule, not a hope).
- [ ] A verification script or checklist lets an operator confirm the
profile is active on a running instance.
- [ ] Cross-referenced from #226 and #230.
## Out of scope
Hardening the operator's platform (OS, network, reverse proxy).
---
#### #228 — [VS-NfD] Write the security documentation (architecture, data flows, network plan, ports, trust boundaries)
**Plan reference:** `20-massnahmenplan.md` → Phase 5
**ADR:** ADR 0019
**Effort:** L (4 AT)
**Depends on:**
**Labels:** `vs-nfd` `effort:L` `area:docs`
## Context
This is the document an assessor reads first and returns to. It has to be
accurate to the digit on ports, services and trust boundaries.
## Current state
- Substantial material exists and needs consolidating rather than
inventing: `docs/architecture/security.md`, `deployment.md`,
`data-model.md`, `permissions.md`, `realtime-collaboration.md`,
`plugin-architecture.md`, plus `deploy/compose/docker-compose.yml` as the
authoritative service and port list (web, api, collab, backup, db,
gotenberg, pandoc, caddy).
## Acceptance criteria
- [ ] Component diagram with every service, its purpose and its
privileges.
- [ ] Data-flow diagrams for: authentication, editing (api ↔ collab ↔
Postgres `LISTEN/NOTIFY`), export (pandoc/Gotenberg sidecars),
backup, and each read channel from #222.
- [ ] Network plan with all ports and protocols, internal versus
externally exposed, matching the compose files exactly.
- [ ] Trust boundaries named explicitly, including the plugin sandbox
(ADR 0008) and the reverse-proxy boundary from #215.
- [ ] Every content copy is listed — database, uploads, `page_updates`,
`page_content_cache` including the search vector, versions, backups,
export artefacts — because the deletion concept in #229 depends on
this list being complete.
- [ ] Diagrams are maintainable as text (the repo already renders
Mermaid), not binary images.
## Out of scope
Penetration test and threat model as separate deliverables.
---
#### #229 — [VS-NfD] Write the operations manual (installation, update, backup/restore, deletion, role separation)
**Plan reference:** `20-massnahmenplan.md` → Phase 5
**ADR:** ADR 0019
**Effort:** L (45 AT)
**Depends on:** #193, #194, #221
**Labels:** `vs-nfd` `effort:L` `area:docs`
## Context
The operator has to run this without us, including on the day something
fails. Deletion and destruction is the chapter with the most VS-specific
weight.
## Current state
- Partial material: `docs/operations/restore-runbook.md`,
`deploy/stages.md`, `docs/architecture/operations.md`,
`deploy/backup-basel.md`, `deploy/monitoring.md`.
- Gaps that must be closed by their issues before this can be truthful:
pond purge (#193), orphan-file sweep (#194), offline update (#221).
## Acceptance criteria
- [ ] Installation including the airgap variant (referencing #218#221).
- [ ] Update and rollback, with the migration caveats from #221.
- [ ] Backup and restore, including a rehearsed restore and the
restriction on targets from #192.
- [ ] Deletion and destruction: per content type, what deletion does, which
copies it reaches (using the list from #228), how long residues
persist, and how an instance is decommissioned.
- [ ] Role separation: which tasks need Site-Admin, which need platform
access, and what a Site-Admin can **not** do.
- [ ] Every procedure has been executed at least once by its author, and
says so.
## Out of scope
Customer-specific operating concepts, and 24/7 support processes.
---
#### #230 — [VS-NfD] Produce the IT-Grundschutz mapping for APP.3.1 and CON.11.1
**Plan reference:** `20-massnahmenplan.md` → Phase 5
**ADR:** ADR 0019
**Effort:** L (34 AT)
**Depends on:** #226, #227, #228
**Labels:** `vs-nfd` `effort:L` `area:docs`
## Context
The authority's own documentation obligation runs along these building
blocks. Supplying the mapping ourselves saves them the translation work and
prevents them from guessing wrong about us.
## Current state
No IT-Grundschutz material exists in the repository.
## Acceptance criteria
- [ ] Every requirement of APP.3.1 (web applications) and CON.11.1
(Verschlusssachen handling) is classified as **product**,
**operator**, or **not applicable**, with a one-line justification.
- [ ] Product requirements point to code, configuration or test evidence;
operator requirements say what we hand over to enable it.
- [ ] "Not applicable" is argued, never asserted.
- [ ] Open requirements point at the issue that closes them, so the
document doubles as a gap list.
- [ ] The building-block versions used are stated (they get revised).
## Out of scope
Requirements from other building blocks, and the authority's
Sicherheitskonzept.
---
#### #231 — [VS-NfD] Maintain the residual-risk list
**Plan reference:** `20-massnahmenplan.md` → Phase 5
**ADR:** ADR 0019
**Effort:** S (1 AT)
**Depends on:** #226
**Labels:** `vs-nfd` `effort:S` `area:docs`
## Context
Naming what is deliberately left open is a credibility instrument. An
assessor who finds an undocumented gap distrusts the whole submission; one
who finds it already listed does not.
## Current state
- Deliberately unscheduled items are recorded in project documentation
today (external search engine, signup admin approval, plugin network
allowlist); items already identified in this entwurf that belong on the
list: attachment content carries no internal marking (#212), plugin
hash pinning deferred (#232), and whether the local-auth switch is
runtime-flippable (#216).
- From `10-ist-aufnahme.md`: the IndexedDB copy on endpoints (I-25,
`apps/web/src/editor/use-collab-provider.ts:76` — survives a browser
crash and for unsynced offline edits), page titles in digest mails
(I-23), and the `page_links.target_slug` residue if #235 decides to keep
it (I-24).
## Acceptance criteria
- [ ] Each entry states: the risk, why it is accepted, its compensating
control, and who decided.
- [ ] Entries from #212, #216, #232 and the git-history check in #198 are
present.
- [ ] Entries from `10-ist-aufnahme.md` I-23, I-24 and I-25 are present.
- [ ] The list is referenced from #226 and #230, and updated in the same PR
whenever an issue closes with a knowingly open remainder.
## Out of scope
Formal risk scoring, and the customer's own risk acceptance.
---
### M8 — `VS-NfD: backlog`
#### #232 — [VS-NfD] Add a plugin allowlist with SHA-256 hash pinning
**Plan reference:** `20-massnahmenplan.md` → Phase 4
**ADR:** ADR 0025
**Effort:** L (810 AT)
**Depends on:** #200
**Labels:** `vs-nfd` `effort:L` `area:supply-chain`
## Context
Hash pinning is the right answer to plugin trust; it is not the most urgent
one, because #200's hard off-switch already closes the risk for the offer
stage. Real code signing is unavailable without a legal entity to hold a
signing identity (ADR 0025).
## Current state
- Plugins declare a `manifest.json`
(e.g. `packages/plugins/drawio/manifest.json`: `id`, `version`,
`apiVersion`, `kind`, `extensionPoints`, `permissions`, `fallback`) —
there is no hash or signature field.
- Bundles are shipped as zip artefacts
(`packages/plugins/drawio/dist/drawio-1.0.0.zip`); loading is gated by
the sandbox (ADR 0008) and per-pond enablement, not by identity.
- `instance_settings` is the place for the allowlist
(`apps/api/prisma/schema.prisma:1723`).
## Acceptance criteria
- [ ] The manifest carries a SHA-256 over the bundle; the hash is verified
on install **and** on every load, failing closed with a clear error.
- [ ] An allowlist in `instance_settings` names permitted plugin ids with
their pinned hashes; a plugin outside it does not load even if
installed.
- [ ] Admin UI to review the allowlist and the hash actually seen versus
the one pinned.
- [ ] Every rejection is audited (catalogue event per #201).
- [ ] Tests: tampered bundle rejected; unpinned plugin rejected;
version bump requires an explicit re-pin.
- [ ] `docs/architecture/plugin-architecture.md` and #227 document the
model.
## Out of scope
Code signing with a certificate, a plugin marketplace, and network
allowlisting for plugins (deliberately unscheduled).
---
### Nachtrag aus der Ist-Aufnahme (nicht im Maßnahmenplan)
Vier Befunde, die `10-ist-aufnahme.md` zusätzlich zum Plan gefunden hat.
Drei betreffen unbefristete Inhaltskopien, einer die Reproduzierbarkeit.
#### #233 — [VS-NfD] Prune conversion job payloads for every job kind
**Plan reference:** n/a — `docs/vs-nfd/10-ist-aufnahme.md` → I-22
**ADR:** n/a
**Effort:** M (2 AT)
**Depends on:**
**Milestone:** `M24 — VS-NfD: security quick wins`
**Labels:** `vs-nfd` `effort:M` `area:storage`
## Context
The raw bytes of every import and export survive indefinitely in the
database. A deleted classified page therefore lives on inside its last
export — the kind of residue a deletion concept cannot leave unexplained.
## Current state
- `ConversionJob.input` / `.result` are the raw document bytes
(`apps/api/prisma/schema.prisma:746784`). The schema calls them
"transient, not the durable copy an Attachment is" and refers to
"a later maintenance job" for pruning.
- `expiresAt` is documented as set only for data-export jobs (#68) and
"Null for every other job kind, whose result never expires".
- Of the five registered scheduled jobs — `version-thinning`,
`page-compaction`, `trash-purge`, `data-export-purge`,
`notification-digest` — only `data-export-purge` touches conversion job
rows (`apps/api/src/import-export/import-export.module.ts:62`).
## Acceptance criteria
- [ ] Payloads of **all** job kinds are pruned after a configurable period
with a documented default; the row may survive for status/audit
purposes, the bytes must not.
- [ ] A backfill clears payloads of already-finished jobs.
- [ ] Pruning does not break an in-flight job (`lockedAt` recovery path
respected) — asserted by test.
- [ ] Test: finished export job's `result` is null after the period; a
pending job is untouched.
- [ ] `docs/architecture/operations.md` documents the job; #229 lists it
under deletion and destruction.
## Out of scope
Changing where conversion happens, and the data-export purge that already
works.
---
#### #234 — [VS-NfD] Add retention for `mail_outbox`
**Plan reference:** n/a — `10-ist-aufnahme.md` → I-23
**ADR:** n/a
**Effort:** S (1 AT)
**Depends on:**
**Milestone:** `M24 — VS-NfD: security quick wins`
**Labels:** `vs-nfd` `effort:S` `area:storage`
## Context
Sent mails are kept forever, and digest mails contain page titles plus who
edited them. For a classified page the title alone can be protected
information, so this is an unbounded copy of content-adjacent data.
## Current state
- `MailOutbox` stores `text_body` and `html_body` permanently
(`apps/api/prisma/schema.prisma:683697`); no pruning job is registered.
- Digest mails include page titles and actor names:
`apps/api/src/notifications/digest.service.ts:16,125,146`
(`- ${page.pageTitle}: … (${actorNames})`).
- Transactional mails carry no content — greeting, i18n body, link only
(`apps/api/src/mail/mail-templates.ts:2544`).
## Acceptance criteria
- [ ] Sent and permanently failed entries are deleted after a configurable
period with a documented default; the retry logic for pending
entries is unaffected.
- [ ] Test: sent entry past the period removed, pending entry kept,
failed-and-retryable entry kept.
- [ ] `docs/architecture/security.md` (privacy section) and #229 name the
period.
- [ ] Whether digest mails should carry page titles at all for classified
pages is decided and recorded — either suppressed or accepted in
#231.
## Out of scope
Changing the mail templates' content beyond that decision, and mail
delivery logging.
---
#### #235 — [VS-NfD] Decide the fate of `page_links` rows pointing at purged pages
**Plan reference:** n/a — `10-ist-aufnahme.md` → I-24
**ADR:** n/a
**Effort:** S (0,5 AT)
**Depends on:**
**Milestone:** `M24 — VS-NfD: security quick wins`
**Milestone note:** cheapest issue in the set; deliberately a decision, not
necessarily a change.
**Labels:** `vs-nfd` `effort:S` `area:storage`
## Context
After a page is purged, other pages' link rows keep its slug. A slug
carries the page title, and for a classified page that can itself be
protected information.
## Current state
- `PageLink.fromPage` is `onDelete: Cascade`, `toPage` is
`onDelete: SetNull` (`apps/api/prisma/schema.prisma:441461`). Purging a
page nulls `to_page_id` but leaves `target_slug` in place — by design,
because that is what makes a "phantom" link resolve again if a page with
that slug reappears.
## Acceptance criteria
- [ ] A decision is made and documented: either purge-time deletion of
rows whose `target_slug` matches the purged page (losing phantom-link
re-resolution), or keeping them as an accepted residue.
- [ ] If kept: recorded in #231 with its reasoning, and named in the
deletion chapter of #229 so an operator can answer for it.
- [ ] If deleted: a test proves no row referencing the purged slug
survives, and the phantom-link behaviour change is noted in the
release notes.
## Out of scope
Redesigning the wikilink index.
---
#### #236 — [VS-NfD] Pin the Node version
**Plan reference:** n/a — `10-ist-aufnahme.md` → I-26
**ADR:** ADR 0024
**Effort:** S (0,5 AT)
**Depends on:**
**Milestone:** `M25 — VS-NfD: hardening & supply chain`
**Labels:** `vs-nfd` `effort:S` `area:supply-chain`
## Context
A reproducible offline build cannot rest on "any Node ≥ 22". This is a
precondition for the reproducibility claim in #219, which is why it lands
before the offline milestone.
## Current state
- `package.json:710`: `"engines": { "node": ">=22" }` — a lower bound, not
a pin. `"packageManager": "pnpm@11.9.0"` is exact, so the pattern for
pinning is already established in the same file.
- Dockerfiles and CI (`.gitea/workflows/ci.yml`, `actions/setup-node@v4`)
each select a version independently.
## Acceptance criteria
- [ ] One authoritative Node version is declared and consumed by CI, the
Dockerfiles and local development; a drift between them fails CI.
- [ ] The update procedure for that version is documented (it will need
raising for security fixes).
- [ ] The version is stated in #228 alongside the other toolchain
versions.
## Out of scope
Changing the Node major, and pinning image digests (#203).
---
## 5. ADRs
Nummerierung fortlaufend nach 0018; Format wie `0018-color-theming.md`
(`# ADR NNNN: …`, Status/Date-Bullets, `## Context`, `## Decision`,
`## Consequences`, abschließend die umsetzenden Issues).
Status-Vorschlag: **`proposed`** beim Anlegen, `accepted` sobald Stefan die
Richtung bestätigt. Abweichung von den bestehenden ADRs (alle `accepted`),
weil diese acht Entscheidungen ein Vorhaben beschreiben, das noch nicht
begonnen hat — Rückfrage 6.4.
---
### ADR 0019 — `docs/architecture/adr/0019-no-security-base-functions.md`
```markdown
# ADR 0019: No security base functions in the application (§52 VSA)
- Status: proposed
- Date: 2026-07-29
## Context
Dorfteich is to be operable inside an IT environment of a German federal
authority that is approved under the Verschlusssachenanweisung (VSA), for
content classified VS-NfD. **No BSI certification of Dorfteich itself is
sought.**
§51 VSA makes products that provide a _Sicherheitsgrundfunktion_ subject to
certification. §52 VSA enumerates those base functions: encryption, media
protection (Datenträgerschutz), network termination (Netzabschluss), and
authentication. A product that implements one of them itself moves into the
certification obligation — an outcome that would end this undertaking on
cost grounds alone.
Today Dorfteich sits close to the right side of that line, partly by
accident and partly by design: there is no content encryption, no backup
encryption, no own MFA, and no cryptographic primitive of our own beyond
signing short-lived collaboration tokens and hashing credentials. What is
missing is the _decision_ — so that no future feature crosses the line
because nobody had written down where it runs.
## Decision
**Dorfteich does not provide any security base function within the meaning
of §52 VSA. Encryption, media protection, network termination and
authentication belong to the operator's platform.**
Concretely:
1. **No encryption of content**, neither in the database nor on the file
system. Confidentiality of stored data is provided by the platform
(full-disk / volume encryption).
2. **No backup encryption in the application.** Media protection is the
platform's function; the application restricts _where_ backups may go
(ADR 0026) and nothing more.
3. **No own MFA, no own password policy engine.** Authentication is
delegated to the operator's identity provider (ADR 0021). Local
passwords remain available for non-VS deployments and are hard-switchable
off.
4. **No new cryptographic primitives.** Existing crypto is limited to
credential hashing (Argon2id), token hashing (SHA-256) and signing
short-lived tokens, and it uses vetted libraries rather than
hand-written constructions (ADR 0020).
5. **No TLS termination, no network segmentation** in the application.
6. **No application-side separation of classification levels.** Levels are
separated by operating one instance per level; the application only
_marks_ content (ADR 0022).
The application's contribution to security is a different set of
properties, and these it does own: a central, default-closed permission
model; complete absence of outbound connections; verifiable marking of
classified content in every output channel; and an audit trail.
## Consequences
- Deliberate non-features must be argued as architecture, not apologised
for as gaps. "No encryption in the code" is the correct division of
labour under §52 VSA.
- Every feature proposal is measured against this ADR. Any change that
would make the application the bearer of a base function needs to amend
this ADR first — which is the point of writing it down.
- The operator carries obligations that must be handed over explicitly and
in writing. This ADR is therefore the **draft of the delimitation
statement** (Abgrenzungserklärung) that #226 turns into a
reviewer-facing document; the two must not diverge.
- Anything the platform cannot supply because it lacks application
knowledge stays with us. Two cases exist today: marking of classified
content (only the application knows the classification, ADR 0022) and
integrity of the application's own payloads (#199).
- Residual risks arising from delegation are listed in #231 rather than
silently accepted.
## Implementing issues
#226 (delimitation statement), #227 (hardening guide), #228 (security
documentation), #229 (operations manual), #230 (IT-Grundschutz mapping),
#231 (residual-risk list).
```
---
### ADR 0020 — `docs/architecture/adr/0020-token-crypto-key-separation.md`
```markdown
# ADR 0020: Token crypto — HKDF key separation and a vetted JWT library
- Status: proposed
- Date: 2026-07-29
## Context
`COLLAB_TOKEN_SECRET` currently signs two unrelated kinds of token:
short-lived collaboration tokens (issue #34) and long-lived unsubscribe
tokens in outgoing mail. A single secret across purposes means a
compromise in one path is transferable to the other.
`packages/shared/src/token-crypto.ts` implements the compact HS256 JWT by
hand on `node:crypto`. The reason is documented in the file and is a good
one: the identical code has to run in the CommonJS api and the ESM collab
server without module-interop or dependency-version drift. The
implementation is careful — HS256 only, constant-time comparison before
any untrusted field is read. It is nevertheless hand-written crypto in the
trust boundary, which is a finding in any assessment regardless of its
quality.
ADR 0019 states the application implements no security base function.
Token signing is not one — but it is crypto we do perform, so it has to be
minimal, purpose-bound and delegated to a vetted implementation.
## Decision
1. **Purpose-bound subkeys via HKDF.** The configured secret becomes a root
key from which each purpose derives its own subkey (collaboration
tokens, unsubscribe tokens, any future purpose). No code path signs with
the root key.
2. **`jose` replaces the homegrown JWT.** It is maintained, audited,
works in both module systems, and is dependency-free — which matters for
the supply-chain argument. HS256 stays the only accepted algorithm, as
an explicit allowlist rather than an implicit default.
3. **Purpose separation is structural, not textual.** The existing
`PURPOSE` string prefix in `unsubscribe-token.ts` is superseded by key
separation; a token signed for one purpose cannot verify under another
because the key differs.
4. **Long-lived tokens get a documented dual-verify window.** Unsubscribe
links live in mail that has already been sent, so both derivations are
accepted for a stated period with a stated expiry date. The window is a
documented fact, not an accident.
## Consequences
- The cross-runtime property that motivated the hand-written code must be
proven by a test, not assumed — otherwise the reason for the original
decision is lost silently.
- One new runtime dependency. Accepted: `jose` has no transitive
dependencies, so the supply-chain delta is one package.
- Rotating the root key invalidates all derived subkeys at once, which is
the desired behaviour and needs documenting in the operations manual.
- The key hierarchy becomes part of the security documentation (#228) and
the delimitation statement's crypto section (#226).
## Implementing issues
#188.
```
---
### ADR 0021 — `docs/architecture/adr/0021-external-authentication.md`
```markdown
# ADR 0021: External authentication via OIDC; local passwords optional
- Status: proposed
- Date: 2026-07-29
## Context
ADR 0007 established sessions and identities with OIDC in mind:
`UserIdentity.provider` is documented as `"password"` today and
`"oidc:<issuer>"` later, with `@@unique([provider, subject])` already in
place. No OIDC code exists — the readiness is structural only.
Authentication is a security base function under §52 VSA (ADR 0019), so it
belongs to the operator's platform. An authority environment additionally
brings its own account lifecycle: joiners, movers and leavers are managed
in the IdP, and a second account store inside the application would drift
from it.
Some environments terminate authentication at the perimeter instead and
expect the application to trust a header or a client certificate.
## Decision
1. **OIDC Authorization Code with PKCE is the primary path**, configured by
discovery, validated against JWKS. Keycloak is the reference IdP we
verify against; nothing in the implementation is Keycloak-specific.
2. **Identities use the existing slot**: `provider = "oidc:<issuer>"`,
`subject` from the token. Linking an OIDC identity to an existing local
user follows an explicit, documented rule — never silently by e-mail
address, which would be an account-takeover path.
3. **Local authentication is switchable off in full**, via
`auth.local.enabled = false`. "In full" means every credential-issuing
flow: password login, self-service signup, password reset,
verification-as-login, and the token flows (PAT, feed tokens). A
half-closed local path makes the operating concept untrue, which is
worse than not closing it.
4. **Proxy header and mTLS are a supported alternative path, off by
default.** When enabled they require an allowlist of trusted peers; a
request carrying the header from an untrusted peer is rejected and
audited. The trust boundary is stated explicitly in the security
documentation.
5. **Claims map onto the existing permission model** declaratively, and
mapped grants are written through the same service path as manual ones
so the permission cache stays correct. The application gains no second
authorization model.
6. **No MFA, no password policy engine of our own** (ADR 0019). Both are
the IdP's.
## Consequences
- Bootstrapping needs a documented answer: the first-run wizard creates a
local admin, so either it stays exempt with a stated compensating
control, or setup itself runs against the IdP. The choice is recorded in
#216.
- Whether the switch is deploy-level or runtime matters: a runtime setting
can be flipped back by a compromised Site-Admin. If it stays runtime,
that residual risk goes into #231.
- Existing password hashes remain in the database after the switch. Their
deletion is out of scope and, being Argon2id, they are not a
confidentiality problem — but the fact is documented.
- Session handling is unchanged: OIDC produces a session through the same
service, so there is exactly one session mechanism (see #190 for its
bounds).
- SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we
target; adding SAML would be a new decision.
## Implementing issues
#214 (OIDC + PKCE), #215 (proxy header / mTLS), #216
(`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the
vetted JWT implementation.
```
---
### ADR 0022 — `docs/architecture/adr/0022-page-classification.md`
```markdown
# ADR 0022: Classification as first-class page metadata
- Status: proposed
- Date: 2026-07-29
## Context
VS-NfD content must be marked, in every output that leaves the system.
Dorfteich has no classification concept today: `model Page` carries title,
slug, tree position and timestamps, and nothing else that could express a
protection level.
The obvious shortcut is to reuse labels. Verification shows why that
fails:
- `Label` is **pond-scoped** (`pondId`), so the same classification would
be a different object in every pond, with no instance-wide meaning.
- Labels are **user-editable** by any editor; a marking must not be
removable as a matter of routine content work.
- Labels do **not inherit** down the page tree, so a subpage of classified
content would silently be unmarked.
- Labels **never leave the application**: `export.service.ts` loads
`labelIds` only to feed `permissions.filterPages`, and no export path
writes them out. A carrier that does not reach the output channels cannot
serve as a marking.
The second question is architectural: should the application separate
classification _levels_? It must not (ADR 0019, and the plan's Phase 0
guardrails). Separation is a platform property.
## Decision
1. **A dedicated enum field on `Page`**, with an instance-wide default from
`instance_settings`. Not labels, for the four reasons above.
2. **Separation of levels happens outside the application: one instance per
classification level.** The application marks; it does not isolate.
This is the central operational decision of the whole undertaking and
belongs here rather than in a manual, because it defines what the
feature is _not_.
3. **The application-side ACL is order, not a protection mechanism.**
Permissions keep working as they do (central, default-closed,
deny-wins), and the classification field does not change them. Anyone
reading the code must not mistake the field for an isolation boundary —
the test in #204 pins that.
4. **Classification inherits down the page tree.** A new or moved page
takes at least its parent's level. Raising is ordinary editorial work;
**lowering requires a dedicated capability** in the central permission
model and is audited with old value, new value, actor and page.
5. **Every output channel carries the marking**, and each is an
independently closable issue: web view, browser print, server-side PDF,
DOCX/ODT, Markdown ZIP, feeds, public API, search results, no-JS shell,
attachment download. A channel that cannot carry it internally
(arbitrary binary attachments) is marked externally — filename prefix
plus companion file — and the remaining gap is a documented residual
risk, not a silent one.
6. **Unclassified content shows no marking.** Marking everything trains
users to ignore markings.
## Consequences
- Ten issues, because there are ten output paths; that is the honest cost
of "in every output".
- The no-JS shell and the SPA are separate render paths, so each needs its
own assertion. Likewise the TipTap NodeView path and the server-side
`docToHtml` path differ structurally.
- The field is a precondition for the read-access audit trail (ADR 0023),
which is scoped to classified content only.
- Attachments inherit their page's classification. The case where the
page link is not yet set (paste-then-insert) fails closed.
- Because levels are separated by instance, a page can never "move
between levels" inside one deployment — export/import across instances is
the path, and its marking is covered by the export channels.
## Implementing issues
#204 (field + default), #205 (inheritance + downgrade right), #206 (web),
#207 (print), #208 (PDF), #209 (DOCX/ODT), #210 (Markdown ZIP), #211
(feeds/API/search/no-JS), #212 (attachments), #213 (upload warning).
```
---
### ADR 0023 — `docs/architecture/adr/0023-read-access-audit-trail.md`
```markdown
# ADR 0023: Read-access audit trail limited to classified content
- Status: proposed
- Date: 2026-07-29
## Context
The existing audit trail (`audit_log`, issue #86) is deliberately scoped to
"who changed access or configuration", and its own service comment states
that content activity stays log-only. There is no record of _reads_.
For "operable in an approved environment", read logging is not a mandatory
product feature — evidence collection can be a platform function. In
practice platform logging cannot answer the question that matters: a proxy
log knows URLs, not classifications, so it cannot say which _classified_
page was read. Leistungsbeschreibungen tend to list this as a must.
Two variants were considered. Variant B logs all reads (1820 AT) and
brings volume, latency and retention problems, plus the requirement that no
event may be lost. Variant A logs reads of classified pages only (810 AT).
A live editing session is the volume hazard: Yjs sync means continuous
traffic per open document.
## Decision
**Variant A: read events are recorded only for pages with
`classification = VS_NFD`.** Requires ADR 0022.
1. **All read channels are instrumented**, or the feature is worthless:
SPA page fetch, public API GET, attachment download, export, no-JS
shell, collab WS join.
2. **A dedup window** (session + page + channel within N minutes = one
event) keeps Yjs sync from flooding the trail. The recorded event states
that it represents a window, so the evidence is not overread.
3. **Its own table**, with time partitioning and its own retention period —
independent of `audit_log`, because volume, purpose and legal basis all
differ.
4. **Failure is not silent.** `AuditService` swallows write failures by
design; for classified reads a lost event is a gap in evidence, so the
behaviour is either hard failure or an explicitly documented
degradation. Which one is decided in #222 and stated in the security
documentation.
5. **Switchable, with a written purpose limitation.** Off means nothing is
written anywhere; a startup log line states the trail is off so a gap is
never ambiguous.
6. **Variant B is rejected**, and the rejection is recorded rather than
left open: unbounded volume, the no-loss requirement, and a purpose
limitation that is much harder to defend.
## Consequences
- The scope limit is the feature's strongest argument in the works-council
discussion at the customer: only classified content is observed.
- Reads of unclassified content are not evidenced. Deliberate, and it goes
into the residual-risk list.
- The collab WS join is the awkward channel: authorization there is
token-only (signature plus `pageId` match) and the collab server has no
permission context. Either the event carries what the token asserts, or
the api emits it at token issuance. #222 decides and documents; the
choice affects what the trail can prove about live sessions.
- Retention and partition maintenance are operational obligations that
must ship with the feature, not after it.
- Classification at read time is stored with the event: a later
reclassification must not rewrite history.
## Implementing issues
#222 (instrumentation), #223 (dedup window), #224 (table, retention,
partitioning), #225 (switch + purpose limitation). Depends on #204/#205.
```
---
### ADR 0024 — `docs/architecture/adr/0024-reproducible-offline-deployment.md`
```markdown
# ADR 0024: Reproducible offline deployment
- Status: proposed
- Date: 2026-07-29
## Context
A VS zone has no internet egress. "Should work offline" is the answer that
loses a first meeting; "tested, here is the procedure" is the one that
wins it — which is why the plan pulled this out of the roadmap into
Phase 1.
The current state is favourable but unverified. There is no telemetry, no
update check, no CDN; fonts are self-hosted (ADR 0016); CSP is
`default-src 'self'`; search is Postgres rather than an external engine;
the drawio plugin is vendored rather than loaded from a remote editor. What
is missing is evidence, plus two real gaps: images are referenced by tag
(including the floating `gotenberg/gotenberg:8`), and there is no
documented mirror or update path.
## Decision
1. **All third-party images are pinned by digest** (`name:tag@sha256:…`).
The tag stays for human readability; the digest decides what runs. A CI
check rejects any un-digested third-party reference.
2. **The image list is generated, not hand-maintained**, so a mirror
procedure cannot silently miss a service.
3. **An internal registry is the supported source.** Compose takes the
registry prefix from configuration; no site edits image references.
4. **Reproducibility without network is a stated choice between two
paths**: an offline pnpm store enabling `install` + `build` with
networking disabled, **or** prebuilt images only with no customer-side
build. Either is acceptable; leaving it unstated is not, because it
determines whether the customer can patch locally.
5. **The airgap claim is proven by a documented run** in a network-isolated
environment, covering every function including the export sidecars, and
listing every outbound connection attempt observed. This run is the
artefact, and it also answers the plan's open question about what breaks
offline.
6. **The offline update path is part of the decision, not an afterthought**:
bundle, verify by digest, back up, apply, verify, roll back — with the
irreversibility of migrations stated explicitly.
## Consequences
- Digest pinning creates recurring maintenance: security updates now
require an explicit, reviewable change. That visibility is the point.
- Digest pinning must precede the mirror and update work, so it sits in the
`hardening & supply chain` milestone rather than this one.
- The isolated test run will surface findings; each becomes its own issue
referenced from #220 rather than expanding that issue's scope.
- Outbound SMTP is the one connection an authority may or may not permit;
the deployment must be functional without it, and the consequences of
disabling it (no notifications, no verification mail — which interacts
with `auth.local.enabled = false`) are documented.
- CD does not sync stage composes, so digest changes need an explicit
rollout step on the stage hosts.
## Implementing issues
#203 (digest pinning), #218 (registry mirror), #219 (network-free build),
#220 (isolated test run), #221 (offline update path), #236 (pinned Node
version — added from the Ist-Aufnahme, I-26).
```
---
### ADR 0025 — `docs/architecture/adr/0025-plugin-trust-model.md`
```markdown
# ADR 0025: Plugin trust model
- Status: proposed
- Date: 2026-07-29
## Context
Dorfteich has a plugin architecture with a sandbox (ADR 0008): plugins
declare a manifest, run isolated, and hold declared permissions. In a VS
zone the question this attracts is blunt — can code execute inside the
protected area, and who vouches for it?
Two facts shape the answer. First, the manifest has no integrity or
identity field: nothing binds a bundle to what was reviewed. Second, real
code signing needs a signing identity, and without a legal entity behind
the project there is none to be had — a self-generated key that we also
distribute proves nothing.
There is also an asymmetry in cost: a hard off-switch is ~2 AT and closes
the risk completely for a deployment that does not need plugins; a trust
model is 810 AT and only _manages_ the risk.
## Decision
1. **Short term: hard, verifiable off-switch.** `plugins.enabled = false`
makes every plugin surface answer 404 — manifests, assets, the frame
route, install/uninstall, and the per-pond toggles — following the
established pattern of `api.enabled` and `mcp.enabled`. Off is part of
the VS-NfD reference configuration.
2. **Documents stay readable with plugins off.** An existing plugin block
renders its declared `fallback`, never an error. Disabling a feature must
not damage content.
3. **Medium term: hash pinning, not code signing.** A SHA-256 over the
bundle in the manifest, an allowlist of id + pinned hash in
`instance_settings`, verification on install and on every load, failing
closed. A version bump requires an explicit re-pin.
4. **Signing is deliberately rejected for now**, with its reason on the
record: no signing identity is available. Should a legal entity exist
later, signing becomes an amendment to this ADR, not a new discovery.
5. **The sandbox remains the containment mechanism.** Hash pinning answers
"is this the reviewed code", not "what may it do". Both are needed and
neither substitutes for the other.
6. **Network allowlisting for plugins stays unscheduled**, consistent with
the existing project decision; in the VS-NfD profile plugins are off, so
it is not the binding constraint.
## Consequences
- The offer stage can answer the code-execution question with a switch and
a test, without waiting for #232.
- Vendored third-party plugin code (drawio 30.3.6 under
`packages/plugins/drawio/vendor/`) is part of our supply chain and
appears in the SBOM (#202). It loads no external editor URL — verified —
and CSP would block it if it tried.
- Hash pinning makes plugin updates a deliberate act, which is the intended
friction.
- The plugin ecosystem stays small by construction. Accepted.
## Implementing issues
#200 (hard off-switch), #232 (allowlist + hash pinning).
```
---
### ADR 0026 — `docs/architecture/adr/0026-backup-target-restriction.md`
```markdown
# ADR 0026: Backup target restriction
- Status: proposed
- Date: 2026-07-29
## Context
Backups are the largest single egress path in the system: the entire
content of the instance, in one artefact. Today the remote destination is a
freely configurable WebDAV/Nextcloud URL in `instance_settings`, validated
as a URL but not restricted to any host, plus an rsync mirror to a private
host (ADR 0015, issue #84). Anyone with Site-Admin can therefore direct a
full copy of the instance to an arbitrary server.
The tempting answer is to encrypt backups in the application. ADR 0019
rules that out: media protection is the platform's base function, and
implementing it here would move Dorfteich into the certification
obligation under §51 VSA.
## Decision
1. **A deploy-level allowlist constrains permissible backup
destinations.** Deploy-level, not a runtime setting, so a compromised
Site-Admin account cannot widen it.
2. **An empty allowlist disables every remote target** — WebDAV and rsync
mirror alike. "Local only" is the VS-NfD reference configuration.
3. **The admin UI distinguishes "unavailable" from "unconfigured"**, so an
operator is never left guessing whether a missing backup is a
misconfiguration or policy.
4. **No application-side backup encryption**, following ADR 0019. Backup
media are protected by the platform.
5. **Integrity of backup artefacts is in scope**, unlike their
confidentiality: checksums let a restore be verified, which is an
application concern because only we know what the artefact should
contain (see #199 for the same reasoning on attachments).
## Consequences
- Existing deployments that use a remote target must have it added to the
allowlist, or backups stop. This is a breaking change and is called out
in the release notes.
- The delimitation statement (#226) must state plainly that backups leave
the application unencrypted and that media protection is the operator's
duty. That sentence will be read closely; it is the correct one.
- Off-site backup in an airgapped deployment becomes an operator process
(media handling), not an application feature.
- Restore stays unchanged, including the maintenance-mode interlock that
closes collab sessions during a restore.
## Implementing issues
#192 (allowlist + deploy-level disable). Related: #199 (integrity
hashes), #229 (backup/restore chapter of the operations manual).
```
---
## 6. Rückfragen an Stefan — beantwortet 2026-07-29
**Stefans Entscheidungen:**
| Frage | Entscheidung |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 6.0 Freigabe Stufe 2 | **Erst die Ist-Aufnahme nachziehen** (`10-ist-aufnahme.md`), Stufe 2 danach. |
| 6.1 Meilenstein-Titel | **An das Repo-Schema anpassen**: `M24 — VS-NfD: security quick wins``M31 — VS-NfD: backlog`. |
| 6.2 Labels | **Neue `area:*` wie vorgegeben**, bestehende (`auth`, `docs`, …) unberührt. |
| 6.5 Granularität | **45 Issues bleiben** — keine Bündelung. |
| 6.3 / 6.4 / 6.7 | Nicht separat entschieden → die im Entwurf vorgeschlagenen Defaults gelten: `area:ops` als elftes Label für #197/#201, ADR-Status `proposed` beim Anlegen, AT-Kopfzahlen des Plans unangetastet (Positions-Summen zusätzlich ausgewiesen). |
Damit lauten die Meilenstein-Titel für Stufe 2:
| # | Titel |
| --- | ----------------------------------------- |
| M24 | `M24 — VS-NfD: security quick wins` |
| M25 | `M25 — VS-NfD: hardening & supply chain` |
| M26 | `M26 — VS-NfD: classification metadata` |
| M27 | `M27 — VS-NfD: external authentication` |
| M28 | `M28 — VS-NfD: offline/airgap deployment` |
| M29 | `M29 — VS-NfD: read-access audit trail` |
| M30 | `M30 — VS-NfD: compliance documentation` |
| M31 | `M31 — VS-NfD: backlog` |
Die Zuordnungen in Abschnitt 2 und 4 gelten unverändert (M1…M8 dort sind
die Kurzreferenzen dieses Entwurfs, nicht die Gitea-Titel).
Die ursprünglichen Fragen bleiben zur Nachvollziehbarkeit stehen:
### 6.1 Meilenstein-Titel: Namensschema
Die vorhandenen Meilensteine heißen `M0 — …` bis `M23 — …`. Der Auftrag
gibt für die neuen `VS-NfD: security quick wins` etc. vor — ohne Nummer.
Ergebnis wäre eine gemischte Liste.
- **(a)** Auftrags-Titel wörtlich übernehmen (`VS-NfD: security quick
wins`). — Meine Empfehlung: Vorgabe ist Vorgabe, und das Prefix
gruppiert in Gitea ohnehin sauber.
- **(b)** An das Repo-Schema anpassen: `M24 — VS-NfD: security quick wins`
usw.
### 6.2 Label-Dopplung
Es gibt bereits `auth`, `docs`, `plugins`, `deployment`, `backend`,
`frontend`, `collab`, `qa`, `blocked`. Der Auftrag verlangt zusätzlich
`area:auth`, `area:docs`, `area:export`, `area:storage`,
`area:supply-chain` — teils redundant zu den bestehenden.
- **(a)** Neue `area:*`-Labels wie vorgegeben anlegen, bestehende
unberührt lassen (VS-NfD-Issues nutzen nur `area:*`). — Empfehlung:
hält den VS-NfD-Bestand in einem konsistenten Schema.
- **(b)** Bestehende Labels weiterverwenden, wo sie passen
(`auth`, `docs`), nur die fehlenden neu (`area:export`, `area:storage`,
`area:supply-chain`).
### 6.3 Zwei Issues ohne passendes `area:`-Label
#197 (Security-Header/CORS) und #201 (SIEM-Ereigniskatalog) passen in
keine der fünf vorgegebenen Areas. Vorschlag: ein zusätzliches
`area:ops`. Alternativ bleiben sie provisorisch bei `area:auth` bzw.
`area:docs` (so stehen sie derzeit im Entwurf).
### 6.4 ADR-Status `proposed` oder `accepted`
Alle bestehenden ADRs stehen auf `accepted`, weil sie umgesetzte
Entscheidungen dokumentieren. Diese acht beschreiben ein Vorhaben, das
noch nicht begonnen hat. Vorschlag: `proposed` beim Anlegen, Umstellung
auf `accepted` sobald du die Richtung bestätigst — insbesondere ADR 0019,
das die Grundlage aller anderen ist.
### 6.5 45 Issues statt ~30 — kürzen?
Die Zahl folgt aus den Auftragsregeln (sieben Ausgabekanal-Issues, sechs
Doku-Issues). Kürzungsoptionen, falls dir das zu granular ist:
- M3: die sechs kleinen Ausgabekanäle (#206#210, #212) zu zwei Issues
bündeln („Client-seitige Kanäle", „Server-seitige Exporte") → 4
- M7: die sechs Doku-Issues zu drei bündeln → 3
- M1: #196 + #198 (je ≤1 AT) an verwandte Issues anhängen → 2
Mein Rat: so lassen. Jedes Issue ist unabhängig abschließbar, und bei
einem Vorhaben über 45 Monate ist Granularität hilfreicher als eine
kurze Liste. Kürzen ist später schwerer als Zusammenfassen beim Abarbeiten.
### 6.6 Fehlende Ist-Aufnahme nachziehen?
`10-ist-aufnahme.md` existiert nicht (§0.1). Die Belegkette für Prüfer
hat damit eine Lücke — die Fundorte sind verifiziert, aber nur in diesem
Entwurf und in den Issues, nicht im dafür vorgesehenen Dokument. Soll ich
die Ist-Aufnahme in einer eigenen Session aus
`00-analyse-auftrag.md` erzeugen? Die hier verifizierten Fundorte fließen
direkt ein, der Aufwand liegt damit deutlich unter einer Erstanalyse.
### 6.7 Aufwands-Deltas
§0.3: Die Phasen-Kopfzahlen im Plan liegen unter der Summe ihrer eigenen
Positionen (Phase 2: +4,5 AT, Phase 5: +3 AT, P1-1: +1 AT, Phase 3:
+1 AT). Ich habe nicht neu geschätzt. Soll der Plan in Stufe 2 auf die
Positions-Summen korrigiert werden (Gesamtsumme dann ~80101 AT statt
7596 AT), oder bleiben die Kopfzahlen als bewusst gerundete Planwerte
stehen?
---
## 7. Was Stufe 2 tut (nach Freigabe)
1. Acht ADR-Dateien nach `docs/architecture/adr/0019…0026-*.md` schreiben
(Volltexte oben, Status `proposed`).
2. Elf Labels anlegen (zehn aus dem Auftrag + `area:ops`).
3. Acht Meilensteine `M24 — VS-NfD: …` bis `M31 — VS-NfD: backlog` mit den
Beschreibungstexten aus Abschnitt 2 anlegen.
4. 49 Issues über die Gitea-API als `fable-5` anlegen — sequenziell, mit
Nummernprotokoll, damit `Depends on` korrekt nachgezogen werden kann.
Falle beachten: Issue-POST-Antworten können Steuerzeichen enthalten
(Parser-Fehler ≠ Anlage-Fehler; erst die Liste prüfen, nie blind
erneut posten), und Nicht-ASCII-Kombinationen können 422 auslösen →
Bodys per `printf > datei` und `-d @datei`.
5. Issue-Nummern in `20-massnahmenplan.md` hinter jede Checkbox
zurückschreiben (`· #142`) und je Phase den Meilenstein nennen; die vier
Nachtrags-Issues (#233#236) als eigenen Abschnitt „Ergänzungen aus der
Ist-Aufnahme" im Plan führen, damit der Plan Index bleibt.
6. Provisorische Nummern in ADRs und Issue-Bodys auf die echten
korrigieren.
7. `git status` prüfen: außer `docs/` nichts geändert.
## 8. Verifikation dieses Entwurfs
- [x] Jede Checkbox-Zeile des Plans hat genau ein Issue — Ausnahmen
begründet: #188 (zwei Zeilen, vom Plan selbst gebündelt), #211
(vier Kanäle, eine Aufwandsangabe im Plan), die fünf „Zu klärenden
Punkte" (vier in §0.2 geklärt, einer in #220 aufgehoben).
- [x] Jeder Befund der Ist-Aufnahme, der nicht im Plan steht, hat ein
Issue oder ein Akzeptanzkriterium: I-22 → #233, I-23 → #234,
I-24 → #235, I-26 → #236, I-25 → #226 + #231.
- [x] Jedes Issue hat Meilenstein, Effort-Label, Area-Label und
Akzeptanzkriterien — Area bei #197/#201 vorbehaltlich 6.3.
- [x] Jedes Issue mit Architekturbezug verweist auf ein ADR; jedes ADR
listet seine umsetzenden Issues.
- [x] Alle zitierten Pfade in dieser Session am Code geprüft (Abschnitt
§0.1/§0.2); Zeilennummern nur, wo verifiziert.
- [ ] Summe der AT je Meilenstein stimmt mit der Auftragstabelle — **nein**,
Abweichungen dokumentiert in §0.3 und Abschnitt 2, Rückfrage 6.7.
- [x] `git status`: nur `docs/vs-nfd/` betroffen (drei verschobene
Auftragsdateien + dieser Entwurf).