# DWS E-Rechnungs-Validator

Prüfdienst für ZUGFeRD/Factur-X und XRechnung auf Basis des
[KoSIT-Validators](https://github.com/itplr-kosit/validator).

Welche Validator-Version eine Instanz fährt, steht in ihrer `instances/<instanz>.env`
(`DWS_VALIDATOR_JAR_FILENAME`) — nicht in der Unit. Siehe *Update des Validators (JAR)*.

Der Dienst läuft als **Daemon** und ist bewusst **nicht** Teil eines API-Versions-Ordners: DEVELOP und
PRODUCTIVE sprechen ihn über HTTP an, ein späteres `v2` fasst ihn nicht an.

```
Server:  /var/www/html/dws/validator          (Geschwister von develop/ und productive/,
                                               ausserhalb beider DocumentRoots)
Repo:    dws/validator/
PHP:     Datamat\Webservice\Helpers\EInvoiceValidator
```

**Zwei Dienste teilen sich diesen Ordner**, je Umgebung als eigene Instanz:

| Dienst | prüft | Ports DEV / PROD | Abschnitt |
|---|---|---|---|
| KoSIT-Validator | die XML-Regelwerke (ZUGFeRD/Factur-X, XRechnung) | 8081 / 8082 | dieses Dokument bis *CLI für Ops* |
| veraPDF | PDF/A-Konformanz der PDF-Hülle | 8083 / 8084 (Admin 8085 / 8086) | *PDF/A-Prüfung (veraPDF)* |

Sie sind unabhängig voneinander: eigene Units, eigene `.env`, eigener Regelstand.

## Warum Daemon

Entwicklungsrechner (Zulu JDK 21, Factur-X-EXTENDED-Dokument, 21 KB):

| | CLI (JVM pro Aufruf) | Daemon |
|---|---|---|
| pro Dokument | **12.107 ms** | **86–106 ms** (warm) |
| erster Request nach dem Start | – | 1.289 ms |
| Start des Dienstes | – | 14.923 ms, einmalig |
| Speicher | ~450 MB je Aufruf | 432 MB RSS je Instanz |

Fünf Dokumente in einer CLI-JVM brauchen 12.125 ms — praktisch dasselbe wie eines. Die Zeit ist also
vollständig JVM-Start plus Kompilieren der Schematron-XSLTs (EN16931-UBL 981 KB, EN16931-CII 766 KB,
fünf Factur-X-XSLTs à ~1,3 MB), nicht die Prüfung selbst.

Auf dem DEV/PROD-Server (vmd165566, Inbetriebnahme 2026-07-27) gemessen:

| | Wert |
|---|---|
| Start bis `Daemon started` | **~75 s** — deutlich langsamer als auf dem Entwicklungsrechner |
| Heap belegt je Instanz | **~340 MB** von 768 MB (`-Xmx768m`) |
| Heap committed je Instanz | ~565 MB (Folge von `-Xms512m`, wird nicht zurückgegeben) |
| RSS je Instanz | ~590 MB inkl. Metaspace/Code-Cache/Thread-Stacks |
| beide Instanzen zusammen | ~1,2 GB RSS |

`-Xmx768m` hat damit reichlich Reserve und bleibt so. Praktisch relevant ist die Startzeit: **ein
`systemctl restart` bedeutet gut eine Minute, in der die Instanz nicht antwortet** — Regelwerk-Updates
also nicht im laufenden Betrieb einspielen. `TimeoutStartSec=120` in der Unit deckt das ab.

Die Latenz eines warmen Requests auf dem Server ist noch nicht gemessen; bis dahin gelten die Werte
des Entwicklungsrechners als Anhaltspunkt. Nachholen mit:

```bash
RECHNUNG=$(find /var/www/html/dws/develop -path '*zugferd/tests/assets/xml_extended_2.xml' | head -1)
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w '%{time_total}s  HTTP %{http_code}\n' \
       -H 'Content-Type: application/xml' --data-binary @"$RECHNUNG" http://127.0.0.1:8081/
done
```

**Es gibt bewusst keinen CLI-Fallback zur Laufzeit.** 12 s pro Request wären unter Last kein Fallback,
sondern ein Risiko: N parallele Requests wären N JVMs à ~450 MB. Ist der Daemon nicht erreichbar,
liefert die API einen klaren Fehler; abgesichert wird das über `Restart=always` und den Health-Check
im Monitor. Für manuelle Prüfungen von der Shell siehe *CLI für Ops* weiter unten.

## Verzeichnisse

```
validator/
├── validator-<version>-standalone.jar KoSIT-Validator; je Instanz gewaehlt ueber
│                                      DWS_VALIDATOR_JAR_FILENAME. Waehrend eines Wechsels
│                                      liegen zwei Staende nebeneinander.
├── Mustang-CLI-2.19.1.jar             fuer die spaetere PDF/A-3-Pruefung, derzeit ungenutzt
├── config/                            Repository-Root (-r) beider Konfigurationen
│   ├── report/
│   │   ├── default-report.xsl         KoSIT-Original (siehe Abweichungen)
│   │   └── dws-report.xsl             unsere Overrides - die einzige selbst gepflegte XSL
│   ├── facturx/                       ZUGFeRD/Factur-X, derzeit 2.5 / 1.09
│   │   ├── scenarios.xml              selbst gepflegt, kommt aus keinem Release
│   │   └── resources/                 MINIMUM/ BASICWL/ BASIC/ EN16931/ EXTENDED/
│   │                                  + Factur-X-<PROFIL>.xslt (Wrapper)
│   └── xrechnung/                     KoSIT validator-configuration-xrechnung, derzeit 3.0.2
│       ├── scenarios.xml
│       └── resources/
├── verapdf-rest-<version>.jar         veraPDF-Dienst; selbst gebaut, es gibt keinen Download
├── verapdf/                           alles zum veraPDF-Dienst
│   ├── server.yml                     Dropwizard-Konfiguration, gemeinsam fuer beide Instanzen
│   ├── develop/                       Arbeitsverzeichnis der Instanz; veraPDF legt hier
│   └── productive/                    beim ersten Start ein config/ an (Laufzeitzustand)
├── instances/
│   ├── develop.env                    KoSIT: Port 8081, JAR-Dateiname, Szenariopfade
│   ├── productive.env                 KoSIT: Port 8082, JAR-Dateiname, Szenariopfade
│   ├── verapdf-develop.env            veraPDF: Ports 8083/8085, JAR-Dateiname
│   └── verapdf-productive.env         veraPDF: Ports 8084/8086, JAR-Dateiname
├── systemd/
│   ├── dws-validator@.service         Template-Unit KoSIT, beide Instanzen
│   └── dws-verapdf@.service           Template-Unit veraPDF, beide Instanzen
├── tools/
│   ├── install-facturx.ps1            legt eine neue FeRD-Distribution versionsneutral ab
│   └── build-verapdf-rest.ps1         baut die verapdf-rest-JAR
└── README.md
```

`verapdf/<instanz>/config/` ist Laufzeitzustand des Dienstes und nicht versioniert; die beiden
Ordner selbst sind es über eine `.gitkeep`. Sie dürfen **nicht** mit `config/` verwechselt werden,
das die KoSIT-Szenarien enthält — mehr dazu im veraPDF-Abschnitt.

**Kein Pfad enthaelt eine Regelwerk-Version.** Ordner heissen `facturx` und `xrechnung`, die
Profilordner `MINIMUM` … `EXTENDED`, die Haupt-XSDs `Factur-X-<PROFIL>.xsd`. Ein Versionswechsel
ueberschreibt nur den Inhalt von `resources/`; `scenarios.xml`, `instances/*.env` und die Wrapper
bleiben unangetastet. Vorher trugen zehn `<location>`-Eintraege und fuenf Wrapper-Importe die
Versionsnummer — eine vergessene Stelle liess die gesamte Konfiguration nicht mehr laden.

Ausgenommen sind `ubl/2.1` und `cii/16b` im XRechnung-Baum: das sind Syntaxversionen des Standards,
nicht des Regelwerks, und sie wechseln bei einem Release nicht mit.

Die Version steht nur noch in **Freitexten** (`<name>`, `<resource><name>`). Bleibt die dort einmal
stehen, ist das ein veraltetes Etikett — es bricht nichts.

Nicht moeglich ist es, die Version ueber eine DTD-Entity (`<!ENTITY version "2.5">`) einzusetzen:
der XML-Parser des Validators laeuft mit `disallow-doctype-decl=true`. Getestet — die Datei wird
abgelehnt und die Entity still zu einem Leerstring aufgeloest, der Szenarioname kam als
"ZUGFeRD  Extended" heraus.

Beide Konfigurationen werden gleichzeitig geladen — der Validator nimmt `-s`/`-r` mehrfach, jeweils
als benanntes Paar:

```
-s fx=<config>/facturx/scenarios.xml   -r fx=<config>
-s xr=<config>/xrechnung/scenarios.xml -r xr=<config>
```

Deshalb sind die `<location>`-Angaben in beiden Szenariodateien relativ zu `config/`, also
`facturx/resources/…` bzw. `xrechnung/resources/…` und `report/dws-report.xsl`.

Bei ZUGFeRD gilt immer nur **eine** Version als aktuell — derzeit 2.5 (= Factur-X 1.09). Deshalb
fahren beide Instanzen denselben Regelstand; die Abnahme eines neuen Regelwerks laeuft per CLI
gegen die neuen Dateien, bevor sie den alten Stand ersetzen (siehe *Update eines Regelwerks*).

## Installation

```bash
# 1) Dateien per SFTP nach /var/www/html/dws/validator hochladen (Ziel "DWS Validator")

# 2) Unit installieren
sudo cp /var/www/html/dws/validator/systemd/dws-validator@.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now dws-validator@develop dws-validator@productive

# 3) Abnahme (der Start dauert ~15 s)
curl -s http://127.0.0.1:8081/server/health   # <ns2:status>UP</ns2:status>
curl -s http://127.0.0.1:8082/server/health
curl -s http://127.0.0.1:8081/server/config   # muss beide Szenariosaetze listen
free -m                                        # Erwartung ~0,9-1,2 GB fuer beide Instanzen

# 4) Ein Dokument pruefen
curl -s -o /tmp/report.xml -w '%{http_code}\n' \
     -H 'Content-Type: application/xml' --data-binary @rechnung.xml \
     http://127.0.0.1:8081/
```

**Der HTTP-Status ist kein Fehlersignal.** Der Daemon nutzt ihn, um das Prüfergebnis zu
transportieren, und liefert in allen diesen Fällen einen vollständigen Bericht im Body:

| Status | Bedeutung |
|---|---|
| 200 | Dokument wird zur Annahme empfohlen |
| 406 | Dokument wird abgelehnt (Regel- oder Schemaverstoss) |
| 422 | Dokument konnte nicht geparst werden — nicht wohlgeformtes XML, Schritt `val-xml` schlägt fehl |

`EInvoiceValidator` wertet deshalb **nicht** den Status aus, sondern ob sich die Antwort als
Prüfbericht lesen lässt. Erst wenn das misslingt, ist es ein technischer Fehler. So bleibt auch ein
kaputtes Dokument ein Prüfergebnis mit Begründung statt einer Ausnahme — und die Liste oben muss bei
einem Validator-Update nicht nachgepflegt werden.

## Bericht

Antwort ist der VARL-Bericht (`http://www.xoev.de/de/validator/varl/1`). Das HTML steckt darin
eingebettet in `rep:assessment/rep:explanation` — das CLI-Flag `-h` extrahiert es lediglich in eine
eigene Datei, über den Daemon kommt es ohne Zutun mit. Es wird deshalb **nichts aus HTML geparst**;
`EInvoiceReportParser` liest ausschliesslich das XML.

Pro Meldung liefert der Bericht `@id`, `@code`, `@level`, `@xpathLocation` und bei XSD-Fehlern
zusätzlich `@lineNumber`/`@columnNumber`.

### Regel-Codes: warum `dws-report.xsl` existiert

Die Schematron-Regelwerke verhalten sich unterschiedlich:

| Regelwerk | `svrl:failed-assert/@id` | `@flag` |
|---|---|---|
| EN16931 CII (CEN) | `BR-66`, `BR-52` — echter Regel-Code | `fatal` (777×) |
| XRechnung CII 3.0.2 (KoSIT) | `PEPPOL-EN16931-R001` — echter Regel-Code | `fatal` (70×) |
| Factur-X 1.07.3 (FeRD) | `FX-SCH-A-000280` — generierte laufende Nummer | 1× bei 869 Asserts |

Ohne Nachbearbeitung trügen alle ZUGFeRD-Meldungen also eine Nummer, die sich mit jedem FeRD-Release
ändert, und wären ausnahmslos `error`. Damit wäre `<customLevel>` — das auf `@code` matcht — für
ZUGFeRD unbrauchbar.

Der fachliche Regel-Code steht bei **allen** Regelwerken im Meldungstext als Präfix `[BR-52]-…`.
`dws-report.xsl` zieht ihn heraus, setzt ihn als `@code` und kürzt den Text entsprechend. Damit:

* tragen ZUGFeRD-Meldungen `BR-DEC-19` statt `FX-SCH-A-000047` (verifiziert),
* wirken `<customLevel>`-Einträge auch für die FeRD-Regelwerke,
* bleiben `PEPPOL-EN16931-R001` und XSD-Codes wie `cvc-complex-type.2.4.a` unverändert.

Zusätzlich schreibt `dws-report.xsl` die **wirksame** Stufe in `@level`, also die per `<customLevel>`
konfigurierte, falls vorhanden. Ohne das meldet KoSIT `level="error"`, während `rep:assessment`
dieselbe Regel bereits heruntergestuft hat — Bewertung und Meldungsliste würden sich widersprechen.

Eine Regel herabstufen:

```xml
<createReport>
  <resource>…</resource>
  <customLevel level="warning">BR-DEC-19</customLevel>
</createReport>
```

Hinweis zu `report/@valid`: Das Attribut bleibt die strenge technische Aussage („alle Prüfschritte
fehlerfrei") und ignoriert `customLevel`. Fachlich massgeblich ist `rep:assessment` (accept/reject),
das in der API als `recommendation` erscheint.

## Abweichungen vom KoSIT-Release

Bei jedem Update erneut vorzunehmen:

1. **`config/report/default-report.xsl`** — aus dem Release übernehmen, dann prüfen: die Datei muss
   *unverändert* bleiben, alle DWS-Anpassungen gehören in `dws-report.xsl`. In der bisherigen Fassung
   waren zwei Blöcke einpatcht, die entfernt wurden: ein `rep:tool`-Element (im VARL-Schema nicht
   vorgesehen) und ein `rep:validationSummary`-Block (wertete Stufen roh aus und widersprach damit
   `rep:assessment`; die Auswertung macht jetzt PHP).
   Nicht sicher zuordenbar und daher stehen gelassen: die Bedingung
   `s:scenario/s:name[contains(…,'fallback')]` in der Berechnung von `@valid`. Beim nächsten Release
   gegen das Original diffen und entscheiden.

2. **`config/xrechnung/scenarios.xml`**
   * Alle `<createReport>` zeigen auf `report/dws-report.xsl` statt auf `resources/xrechnung-report.xsl`.
   * Alle `<location>` sind um das Präfix `xrechnung/resources/` ergänzt, und der Regelordner
     `resources/xrechnung/<version>/` heisst bei uns `resources/xrechnung/rules/`.
   * **Das Szenario „EN16931 (CII)" ist entfernt.** Es matcht auf
     `urn:cen.eu:en16931:2017` und kollidiert damit mit „Factur-X 1.07.3/ZUGFeRD 2.3.3 Comfort (CII)".
     Werden beide Konfigurationen gemeinsam geladen, wertet der Validator den Treffer als mehrdeutig
     und meldet „kein Szenario" (verifiziert). Aufgelöst zugunsten von FeRD: ein CII-Dokument mit
     dieser Guideline-ID ist ein Factur-X/ZUGFeRD-Comfort-Dokument und wird gegen die FeRD-Regeln und
     das D22B-Schema geprüft, nicht gegen die CEN-Regeln und D16B. Die Stelle ist in der Datei
     kommentiert.

3. **`config/facturx/scenarios.xml`** ist vollständig selbst gepflegt und kommt aus keinem Release.
   Die Ressourcen darunter legt `tools/install-facturx.ps1` aus dem `Schema/`-Ordner der
   Distribution ab: Profilordner und Haupt-XSDs versionsneutral umbenannt, dazu fünf Wrapper-XSLTs
   (`Factur-X-<PROFIL>.xslt`). Die Wrapper importieren die generierte Schematron-XSLT und stellen
   `schematron-select-full-path` auf die lesbare Pfadform um — ohne sie meldet der Bericht
   Fundstellen als `*:Element[namespace-uri()='urn:...']` statt als `/rsm:CrossIndustryInvoice/ram:...`.

   Nicht umbenannt werden die drei Geschwister-XSDs je Profil: die Haupt-XSD verweist per
   `schemaLocation` auf sie. Nur die Hauptdatei wird umbenannt, ihre Verweise bleiben gültig.

   Die Guideline-IDs je Profil stehen in der Codeliste `cl id="1"` der jeweiligen `*_codedb.xml`
   der Distribution — dort nachsehen, nicht raten. In 1.09 führt jedes Profil ausser EN16931
   zusätzlich die `urn:zugferd.de:2p0:*`-Variante.

4. **Eine bewusste Abweichung vom FeRD-Regelwerk** steht im EXTENDED-Szenario: die neun Regeln
   `BR-FXEXT-{AE,AF,AG,E,G,IC,O,S,Z}-08b` sind per `<customLevel>` auf `warning` gesetzt. FeRD
   kennzeichnet in jeder dieser Familien die Varianten `-08ini` und `-08rev` mit `flag="warning"`,
   vergibt der `-08b`-Variante aber durchgängig kein Flag, wodurch sie nach der KoSIT-Zuordnung zum
   Fehler wird. FeRDs eigener Referenz-Validator (valitool, den Beispielen beigelegt) wertet die
   ganze Familie als Warnung — ohne die Herabstufung würden die offiziellen Beispiele X02, X17 und
   X20 bei uns durchfallen, die dort `isValid=true` tragen. Beim nächsten Regelwerk-Update prüfen,
   ob FeRD das Flag inzwischen selbst setzt.

## Update des Validators (JAR)

Der Validator ist die *Prüf-Engine*, das Regelwerk sind die Schematron-Dateien darunter. Beides wird
getrennt aktualisiert; dieser Abschnitt behandelt nur die JAR.

Die Versionsnummer steht **ausschliesslich** in `instances/<instanz>.env`:

```
DWS_VALIDATOR_JAR_FILENAME=validator-1.6.2-standalone.jar
```

Die Unit löst den Dateinamen relativ zu `WorkingDirectory=/var/www/html/dws/validator` auf. Damit
bleibt die Unit versionsfrei, und — der eigentliche Grund für die Trennung — **beide Instanzen können
unterschiedliche Validator-Versionen fahren**: DEVELOP zieht vor, PRODUCTIVE bleibt auf dem alten
Stand, bis die Abnahme durch ist. Solange liegen beide JARs nebeneinander; die alte ist der Rückweg.

```bash
# 1) Neue JAR nach /var/www/html/dws/validator hochladen (die alte NICHT loeschen)
#    https://github.com/itplr-kosit/validator/releases -> validator-<version>-standalone.jar

# 2) CLI-Optionen gegenpruefen - die Unit benutzt -D -H -P -G -T --log-level -s -r
java -jar validator-<version>-standalone.jar --help

# 3) DEVELOP umstellen
#    DWS_VALIDATOR_JAR_FILENAME in instances/develop.env auf die neue Datei setzen
sudo systemctl restart dws-validator@develop     # ~75 s, siehe oben
curl -s http://127.0.0.1:8081/server/health      # <ns2:version> muss die neue Version zeigen

# 4) Abnahme in DEVELOP: Testmatrix unten laufen lassen, dazu POST /Zugferd/xml/Validate
#    ueber phpdebugsystem gegen DEV

# 5) Erst danach PRODUCTIVE nachziehen (instances/productive.env + restart),
#    dann die alte JAR entfernen
```

Abnahme vor dem Rollout, lokal durchführbar: die neue JAR mit derselben Konfiguration auf einem
freien Port starten und den Bericht gegen den der alten Version diffen. Weicht ausser Version,
Zeitstempel und `documentReference` etwas ab, hat sich die Berichtserzeugung geändert und
`dws-report.xsl` bzw. `EInvoiceReportParser` sind zu prüfen.

**Ergebnis 1.5.2 → 1.6.2 (geprüft 2026-07-27, lokal, Zulu JDK 21):** CLI-Optionen unverändert;
Daemon startet mit unveränderter Konfiguration; alle 61 offiziellen ZUGFeRD-2.5-Beispiele liefern
dieselbe Verteilung wie zuvor (60× 200, 1× 406); der VARL-Bericht eines EXTENDED-Dokuments ist
zeichengleich bis auf `rep:engine/rep:name`, `rep:timestamp` und den laufenden Zähler in
`rep:documentReference`. Warme Anfrage 107–164 ms. Weder `dws-report.xsl` noch die PHP-Seite mussten
angefasst werden.

## Update eines Regelwerks

### ZUGFeRD / Factur-X

Der Ablauf ist skriptgestützt, weil FeRD die Version in Ordner- und XSD-Namen packt:

```powershell
# 1) Neue Distribution entpacken (enthaelt einen Schema-Ordner mit den fuenf Profilordnern)

# 2) Lokal ablegen - benennt Profilordner und Haupt-XSDs versionsneutral um und
#    schreibt die fuenf Wrapper-XSLTs neu. -Archive sichert den bisherigen Stand.
.\validator\tools\install-facturx.ps1 -Source "...\ZF26_DE\Schema" -Archive

# 3) Version in den Freitexten von config\facturx\scenarios.xml nachziehen (nur Labels).
#    Neue Guideline-IDs? -> Codeliste cl id="1" in resources\<PROFIL>\*_codedb.xml pruefen
#    und ggf. <match> sowie develop/v1/.config/einvoice_profiles.json ergaenzen.

# 4) Abnehmen, BEVOR der Stand auf den Server geht: CLI gegen die Beispielrechnungen
#    der Distribution laufen lassen (siehe Testmatrix unten)

# 5) Hochladen, dann auf dem Server
#    systemctl restart dws-validator@develop dws-validator@productive
```

Was das Skript **nicht** anfasst: `scenarios.xml` — die ist selbst gepflegt.

### XRechnung

Ohne Skript, dafür seltener:

```bash
# 1) Release von https://github.com/itplr-kosit/validator-configuration-xrechnung/releases
#    nach config/xrechnung/ entpacken (Inhalt ersetzen)
# 2) resources/xrechnung/<version>/ nach resources/xrechnung/rules/ umbenennen
# 3) Die Abweichungen unten auf die neue scenarios.xml anwenden
# 4) Abnehmen, hochladen, systemctl restart
```

### Testmatrix für die Abnahme

**Bestes Testmaterial ist der `Beispiele/`-Ordner der ZUGFeRD-Distribution** — 61 offizielle
Rechnungen über alle Profile, jeweils mit FeRDs eigenem Prüfbericht (`*_fx_validation_report.xml`)
daneben, an dem sich das eigene Ergebnis gegenprüfen lässt.

Alle durchlaufen lassen (Daemon muss laufen):

```bash
for f in $(find <Distribution>/Beispiele -iname '*.xml' ! -name '*_fx_validation_report*'); do
  printf '%s %s\n' "$(curl -s -o /tmp/r.xml -w '%{http_code}' -H 'Content-Type: application/xml' \
       --data-binary @"$f" http://127.0.0.1:8081/)" "$(basename "$f")"
done
```

Erwartungswert nach dem Stand vom 2026-07-27 (lokal verifiziert): **60× HTTP 200, 1× HTTP 406**.
Die eine Ablehnung ist `XRECHNUNG_Betriebskostenabrechnung.xml` — sie deklariert
`xrechnung_2.1`, wir fahren 3.0.2, also greift korrekterweise kein Szenario. Weicht die Verteilung
ab, stimmt etwas an der Konfiguration nicht.

Zusätzlich prüfen, dass jedes Profil sein Szenario trifft und keines in `noScenarioMatched` landet:

| Guideline-ID | erwartetes Szenario |
|---|---|
| `urn:factur-x.eu:1p0:minimum` bzw. `urn:zugferd.de:2p0:minimum` | Minimum |
| `urn:factur-x.eu:1p0:basicwl` bzw. `urn:zugferd.de:2p0:basicwl` | BasicWL |
| `…#compliant#urn:factur-x.eu:1p0:basic` bzw. `…zugferd.de:2p0:basic` | Basic |
| `urn:cen.eu:en16931:2017` | Comfort |
| `…#conformant#urn:factur-x.eu:1p0:extended` bzw. `…zugferd.de:2p0:extended` | Extended |
| `…#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0` | XRechnung 3.0 (CII bzw. UBL) |

Die Zuordnung dieser IDs auf Profilnamen in der API-Antwort steht in
`develop/v1/.config/einvoice_profiles.json` und muss beim Versionswechsel mitgezogen werden.

## CLI für Ops

Zum Prüfen eines Regelstands, bevor der Dienst neu gestartet wird — nicht im Request-Pfad verwenden:

```bash
cd /var/www/html/dws/validator
java -jar $(grep -h '^DWS_VALIDATOR_JAR_FILENAME=' instances/develop.env | cut -d= -f2) \
  -s fx=config/facturx/scenarios.xml   -r fx=config \
  -s xr=config/xrechnung/scenarios.xml -r xr=config \
  -o /tmp -h -p rechnung.xml
```

Achtung: Dateipfade mit Leerzeichen müssen hier quotiert werden, sonst meldet der Validator
`No test targets found`.

`-h` schreibt den HTML-Bericht zusätzlich als Datei, `-p` gibt eine Zusammenfassung auf stdout aus.

Zwei Stolpersteine, falls das jemals doch aus PHP heraus aufgerufen wird: Der Validator prüft beim
Start `stdin` (`Validator.isPiped()`) und bricht mit `IOException` ab, wenn dort nichts Lesbares
anliegt — bei `symfony/process` also `setInput('')` setzen. Und `2>/dev/null` darf nicht als
Argument-Element übergeben werden, da `Process` keine Shell startet.

## PDF/A-Prüfung (veraPDF)

Der KoSIT-Validator prüft die XML-Rechnung. Die PDF-Hülle eines ZUGFeRD-Dokuments — PDF/A-3 — prüft
[veraPDF](https://verapdf.org), betrieben nach demselben Muster über
[verapdf-rest](https://github.com/verapdf/verapdf-rest): ein Dropwizard-Dienst, der die
veraPDF-Bibliothek per HTTP anbietet.

### Warum auch hier ein Daemon

Entwicklungsrechner (Zulu JDK 21, veraPDF 1.30.2, Factur-X-EXTENDED-PDF, 99 KB), gemessen 2026-08-03:

| | CLI (JVM pro Aufruf) | Daemon |
|---|---|---|
| pro Dokument | **3.089–3.231 ms** | **140–490 ms** HTTP gesamt, davon **6–98 ms** eigentliche Prüfung |
| fünf Dokumente in einem Aufruf | 4.101 ms (~250 ms je weiteres) | – |
| erster Request nach dem Start | – | 1.302 / 3.355 ms (zwei Läufe) |
| Start bis `/healthcheck` | – | 3.677 / 3.978 ms |
| acht Requests sequenziell / parallel | – | 2.085 ms / 1.706 ms |
| Speicher | eine JVM je Aufruf | 214 MB RSS frisch, 244–287 MB nach ~30 Requests |

Die Fixkosten je CLI-Aufruf sind also ~2,9 s JVM-Start plus Profil-Laden, die Prüfung selbst ist
billig. Der Gewinn ist damit kleiner als beim KoSIT-Validator (dort 12 s → 90 ms, weil dessen Zeit
fast vollständig in der Schematron-Kompilierung steckt), aber immer noch Faktor 10–20 — und vor
allem entfallen unter Last N parallele JVMs à ~250 MB.

**Der erste Request nach dem Start ist deutlich langsamer als die folgenden**, weil das
Validierungsprofil erst dann geladen wird. Nach einem `systemctl restart` zahlt also die erste echte
Prüfung drauf; ein Aufwärm-Request ist nicht eingebaut.

### Speicherbudget

Der Server hat **3,9 GB und keinen Swap**, davon belegen die beiden KoSIT-Instanzen bereits ~1,2 GB.
Deshalb ist der Heap knapp bemessen. Gemessen mit `-Xmx384m` nach 20 Prüfungen einer 1-MB-Rechnung:

| | |
|---|---|
| Heap belegt | **61 MB** (16 % von 384) |
| JVM gesamt (inkl. Metaspace/Code-Cache) | 144 MB |
| RSS | 278 MB |

Der Bedarf liegt also fast vollständig **ausserhalb** des Heaps — ein grösseres `-Xmx` kauft nichts
und vergrössert nur den möglichen Ausreisser. Die Unit fährt daher `-Xms128m -Xmx256m` und dazu
`MemoryMax=600M`: geht doch einmal etwas durch, räumt der Cgroup genau diese Instanz ab
(`Restart=always` fängt sie wieder ein), statt dass der OOM-Killer des Systems sich MySQL oder
Apache aussucht. `DWS_VERAPDF_MAX_FILE_SIZE=25` begrenzt zusätzlich, was eine einzelne Prüfung
überhaupt anfassen kann.

Rechnung für beide Instanzen: ~2 × 300 MB im Betrieb, hart gedeckelt bei 2 × 600 MB. Vor dem
Aktivieren der zweiten Instanz trotzdem `free -m` ansehen — und der Server hat weiterhin keinen
Swap, was bei dann vier JVMs die eigentliche Reserve wäre.

Auf dem Server gemessen (Inbetriebnahme 2026-08-03, Instanz DEVELOP in genau dieser Konfiguration):

| | Wert |
|---|---|
| Speicher warm (`systemctl status`) | **213 MB**, Spitze 214,7 MB von 600 MB |
| frisch gestartet, vor dem ersten Request | 2,0 MB |
| erster Request nach dem Start | 2.288 ms |
| warme Prüfung | **319–427 ms** |
| Upload über `DWS_VERAPDF_MAX_FILE_SIZE` | HTTP 400 (geprüft mit 26 MB) |

Der Sprung von 2 MB auf 213 MB ist das Validierungsprofil, das erst mit der ersten Prüfung geladen
wird. Der Cgroup-Wert zählt auch Seitencache mit (gemappte JAR, temporäre Upload-Dateien) — er ist
die Obergrenze, nicht der reine JVM-Bedarf. Wichtig ist der Abstand zu `MemoryMax`: wird er zu klein,
meldet `journalctl -u dws-verapdf@develop` ein `Memory cgroup out of memory` und die Instanz startet
neu. Dann `MemoryMax` anheben, nicht den Heap — der ist nachweislich nicht der Engpass.

Zum Vergleich der Zwischenstand mit dem ursprünglichen `-Xmx512m` und laufender zweiter Instanz:
379 MB und 560–581 ms warm. Der kleinere Heap committet weniger und ist dadurch nicht langsamer,
sondern schneller.

Die warme Prüfung ist auf dem Server rund fünfmal langsamer als lokal — dasselbe Verhältnis wie beim
KoSIT-Validator. Gegen geschätzte 6–9 s für einen CLI-Aufruf auf derselben Maschine bleibt der
Abstand aber deutlich.

**Aktueller Betriebszustand: nur `dws-verapdf@develop` läuft.** PRODUCTIVE ist abgeschaltet
(`systemctl disable --now`), weil dort noch kein Endpunkt den Dienst anspricht — die Instanz wäre
reine Vorratshaltung und kostet ~350 MB auf einem Server ohne Swap. Anzuschalten, sobald die
PDF/A-Prüfung nach PRODUCTIVE promotet wird.

### Die JAR gibt es nicht als Download

Anders als beim KoSIT-Validator veröffentlicht das Projekt nur Tags, keine Release-Artefakte. Die JAR
wird selbst gebaut:

```powershell
.\validator\tools\build-verapdf-rest.ps1               # gepinnter Tag, derzeit v1.30.2
.\validator\tools\build-verapdf-rest.ps1 -Tag v1.31.0
```

Das Skript installiert nichts: es holt Maven bei Bedarf in einen temporären Ordner, checkt den Tag
aus, baut und legt die JAR in `validator/` ab (~40 MB, Build ~2 min). `maven-shade` erzeugt daneben
ein `original-verapdf-rest-*.jar` mit nur den eigenen Klassen — das ist nicht startfähig, das Skript
nimmt die richtige Datei.

Beim Versionswechsel beachten: verapdf-rest bindet die veraPDF-Bibliothek als Versionsbereich ein
(`[1.30.0,1.31.0)`). Ein Neubau desselben Tags kann deshalb eine andere Patch-Version einziehen —
die gebaute JAR aufheben statt „zur Sicherheit" neu zu bauen. Welche Versionen tatsächlich
drinstecken, sagt `GET /api/` und jeder Bericht unter `report.buildInformation.releaseDetails`.

Ansonsten gilt dasselbe wie beim KoSIT-Validator: Der Dateiname steht in
`instances/verapdf-<instanz>.env` (`DWS_VERAPDF_JAR_FILENAME`), nicht in der Unit — DEVELOP kann
also vorziehen, während PRODUCTIVE auf dem alten Stand bleibt.

### Zwei Fallstricke der Einrichtung

1. **Der Dienst braucht ein beschreibbares `config/` in seinem Arbeitsverzeichnis.** veraPDF legt dort
   beim Start `app.xml`, `validator.xml`, `features.xml`, `fixer.xml` und `plugins.xml` an und bricht
   sonst ab mit `IllegalArgumentException: IOException when creating: …/config/validator.xml`. Deshalb
   hat jede Instanz ein eigenes `WorkingDirectory` unter `verapdf/<instanz>/` — bewusst **nicht** das
   Validator-Verzeichnis selbst, sonst schriebe veraPDF seine Dateien mitten in die KoSIT-Szenarien
   unter `config/`.

2. **Die Ports stehen ohne Standardwert in `server.yml`** (`${DWS_VERAPDF_PORT}`). Fehlt die Variable,
   bricht Dropwizard mit `Incorrect type of value at: server.applicationConnectors.[0].port` ab, statt
   still auf den Standardport 8080 auszuweichen — sonst würden sich beide Instanzen um denselben Port
   streiten. Die Env-Substitution ist geprüft, eine `server.yml` genügt für beide Instanzen.

Die eingebaute Web-Oberfläche (`/`, `/swagger`) lässt sich — anders als beim KoSIT-Validator mit `-G`
— nicht abschalten, und CORS steht auf `*`. Beide Instanzen binden deshalb ausschliesslich an
`127.0.0.1`.

### Installation

```bash
# 1) Dateien per SFTP nach /var/www/html/dws/validator hochladen (Ziel "DWS Validator")

# 2) Arbeitsverzeichnisse anlegen (siehe Fallstrick 1). Nur die beiden config/-Ordner
#    gehen an www-data - der Rest bleibt beim SFTP-Benutzer, sonst schlaegt der naechste
#    Upload von server.yml fehl.
sudo mkdir -p /var/www/html/dws/validator/verapdf/develop/config \
              /var/www/html/dws/validator/verapdf/productive/config
sudo chown -R www-data:www-data /var/www/html/dws/validator/verapdf/develop/config \
                                /var/www/html/dws/validator/verapdf/productive/config

# 3) Unit installieren
sudo cp /var/www/html/dws/validator/systemd/dws-verapdf@.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now dws-verapdf@develop dws-verapdf@productive

# 4) Abnahme
curl -s http://127.0.0.1:8085/healthcheck                       # {"deadlocks":{"healthy":true}}
curl -s http://127.0.0.1:8083/api/                              # Versionen der Bibliothek
curl -s http://127.0.0.1:8083/api/profiles/ids                  # muss 3b und 3a enthalten
curl -s -F "file=@rechnung.pdf" http://127.0.0.1:8083/api/validate/3b
```

`/healthcheck` liegt auf dem **Admin-Port** (8085/8086), die Prüfung auf dem Anwendungsport
(8083/8084). Beim Start meldet Dropwizard „THIS APPLICATION HAS NO HEALTHCHECKS" — das ist normal,
`/healthcheck` antwortet trotzdem mit 200 (geprüft).

### Schnittstelle

```
POST /api/validate/{flavour}      multipart/form-data, Feldname "file"
POST /api/validate/url/{flavour}  multipart, Feld "url"
GET  /api/profiles, /api/profiles/ids, /api/profiles/{id}
GET  /api/info, GET /api/
POST /api/sha1                    SHA1 + Länge eines Uploads
```

`{flavour}`: `auto, 1b, 1a, 2b, 2a, 2u, 3b, 3a, 3u, 4, 4e, 4f, ua1, ua2, wt1a, wt1r`.
Das Antwortformat steuert `Accept` (JSON, XML oder HTML).

Bei `auto` entscheidet das XMP-Metadatum des Dokuments über das Profil — unser
Factur-X-EXTENDED-Testdokument wurde damit gegen **PDF/A-3a** geprüft, nicht gegen 3b. „Prüfe, was
das Dokument behauptet" (`auto`) und „prüfe gegen 3b" sind unterschiedliche Aussagen; welche die API
abbildet, ist noch nicht entschieden.

### Bericht

```
report.jobs[0].validationResult.compliant       true / false
                              .profileName      "PDF/A-3b validation profile"
                              .details.failedRules / .failedChecks
                              .details.ruleSummaries[]
    → specification "ISO 19005-3:2012", clause "6.6.2.1", testNumber, description,
      checks[] mit context "root/document[0]" und errorMessage
report.jobs[0].taskException                    wenn die Datei nicht lesbar war
report.buildInformation.releaseDetails          Versionen von core/validation-model/verapdf-rest
```

**Der HTTP-Status ist auch hier kein Fehlersignal** — anders als beim KoSIT-Daemon sogar durchgängig
`200`: ein Upload aus 18 Byte Müll kam als 200 mit `taskException` im Job zurück, nicht als 4xx. Die
PHP-Seite muss deshalb dieselbe Regel fahren wie `EInvoiceValidator`: entscheidend ist, ob sich die
Antwort als Bericht lesen lässt, nicht der Status.

### Was veraPDF nicht abdeckt

Der Dienst prüft PDF/A-Konformanz — nicht die Factur-X-spezifischen Anforderungen an die PDF-Hülle
(XMP-Extension-Schema, `DocumentFileName`/`ConformanceLevel`/`Version`, `AFRelationship` der
eingebetteten XML, Vorhandensein des Anhangs). veraPDF kann so etwas nur über Policy-Checks
(`--policyfile`, Schematron über den Feature-Report), und **die exponiert verapdf-rest nicht**: es
gibt nur `validate`, `profiles`, `info` und `sha1`. Dieser Teil bleibt bei uns (PHP — das XMP lesen
wir in `EInvoiceExtractor` ohnehin) oder bei der Mustang-CLI.

Offen: PHP-Helper (Gegenstück zu `EInvoiceValidator`), Anbindung an einen Endpunkt und Aufnahme in
`monitor/cron.php`.

## Betrieb

```bash
systemctl status  dws-validator@develop
systemctl restart dws-validator@develop
journalctl -u dws-validator@develop -f

systemctl status  dws-verapdf@develop
systemctl restart dws-verapdf@develop
journalctl -u dws-verapdf@develop -f
```

Überwacht wird der KoSIT-Dienst durch `monitor/cron.php` über `/server/health`; für veraPDF
(`/healthcheck` auf dem Admin-Port) steht das noch aus.
Alle Instanzen binden ausschliesslich an `127.0.0.1`; die Daemons haben keine Authentifizierung und
dürfen nicht nach aussen exponiert werden. Beim KoSIT-Validator ist die eingebaute Web-Oberfläche per
`-G` abgeschaltet, bei veraPDF geht das nicht (siehe oben).
