#!/usr/bin/env python3
"""bullpen-verify — den laufenden bullpen gegen deploy/roster.yaml halten.

WOZU. Das Architekturmanifest nennt als staerkstes Argument fuer einen
deklarativen Erbauer nicht die Bequemlichkeit, sondern die Sichtbarkeit: zwei
stille Drifts (der Kollaps der Zwei-Endpunkt-Architektur und das abgeklemmte
Lease-Tor) blieben monatelang unbemerkt, weil nichts sie ANGEZEIGT hat. Dieses
Werkzeug macht Drift zu einem Diff.

Es AENDERT NICHTS. Wer den Aufbau herstellen will, benutzt `bullpen-up`; dies
hier sagt nur, wo Soll und Ist auseinanderlaufen — und ist damit auch die
Abnahme fuer jeden kuenftigen Erbauer-Lauf.

Rueckgabe: 0 wenn Soll == Ist, sonst 1. Damit ist es in einer Unit oder einem
Timer benutzbar, ohne dass jemand die Ausgabe lesen muss.

Aufruf:  pen-verify [--roster deploy/roster.yaml] [--host hertz]
"""
import argparse
import datetime
import io
import json
import os
import re
import subprocess
import sys

try:
    import yaml
except ImportError:
    sys.exit("pen-verify braucht python3-yaml")


def lauf(host, argv, timeout=180):
    # 60 s war zu knapp: ein `sic` in einen Container, in dem gerade ein
    # Modell rechnet, laeuft hinein — und der Rundgang meldete daraufhin
    # "0 Abweichungen, nichts offen" aus einem abgestuerzten Lauf.
    # KEIN Modellaufruf, deshalb nicht 1800: ein Waechter, der eine halbe
    # Stunde an einem unerreichbaren Container haengt, ist keiner.
    """Ein Kommando auf dem Wirt, ueber sic. Gibt (rc, stdout) zurueck."""
    p = subprocess.run(["sic", host, "sh", "-c", argv],
                       capture_output=True, text=True, timeout=timeout)
    return p.returncode, (p.stdout or "").strip()


class Bericht:
    def __init__(self):
        self.abweichungen = []
        self.geprueft = 0
        # --json (C-1b): jede Pruefung zusaetzlich als Objekt festhalten.
        # id = der gegenstand-Text (stabil, solange das Roster gleich bleibt);
        # Kollisionen bekommen ein #n-Suffix, weil der Envelope Eindeutigkeit
        # garantiert.
        self.checks = []
        self._gesehen = {}

    def pruefe(self, gegenstand, soll, ist, hinweis=""):
        self.geprueft += 1
        cid = gegenstand
        n = self._gesehen.get(cid, 0) + 1
        self._gesehen[cid] = n
        if n > 1:
            cid = f"{gegenstand} #{n}"
        ok = str(soll) == str(ist)
        if ok:
            deviation = None
            print(f"  [ok ] {gegenstand:<38} {ist}")
        else:
            self.abweichungen.append((gegenstand, soll, ist, hinweis))
            zusatz = f"  ({hinweis})" if hinweis else ""
            deviation = f"soll={soll} ist={ist}" + (f" ({hinweis})" if hinweis else "")
            print(f"  [!! ] {gegenstand:<38} soll={soll}  ist={ist}{zusatz}")
        self.checks.append({"id": cid, "description": gegenstand,
                            "status": "pass" if ok else "fail",
                            "deviation": deviation})


def _hook_fehler(detail):
    sys.stderr.write(f"bullpen-verify: error: BV_TEST_RESULTS {detail}\n")
    raise SystemExit(2)


def lade_testresultate(pfad, b):
    """BV_TEST_RESULTS (C-1b §2.4): Ergebnisse aus einer JSONL-Datei statt aus
    dem Live-Rundgang. Unset = exakt heutiges Verhalten; gesetzt = die Datei
    speist BEIDE Ausgabemodi und den Exit-Code. Verletzt eine Zeile die
    Envelope-Regeln, wird sie NICHT zu Ausgabe: exit 2, nichts auf stdout."""
    try:
        with open(pfad, encoding="utf-8") as f:
            zeilen = f.read().splitlines()
    except OSError as e:
        _hook_fehler(f"cannot read file: {e.strerror or e}")
    if not zeilen:
        _hook_fehler("empty file (zero lines)")
    for i, z in enumerate(zeilen, 1):
        if not z.strip():
            _hook_fehler(f"line {i}: blank line")
        try:
            row = json.loads(z)
        except ValueError:
            _hook_fehler(f"line {i}: not valid JSON")
        if not isinstance(row, dict) or set(row) != {"id", "description", "status", "deviation"}:
            _hook_fehler(f"line {i}: wrong keys")
        if not isinstance(row["id"], str) or not row["id"] or "\n" in row["id"]:
            _hook_fehler(f"line {i}: invalid id format")
        if not isinstance(row["description"], str) or not row["description"] \
                or "\n" in row["description"] or "\r" in row["description"]:
            _hook_fehler(f"line {i}: invalid description")
        if row["status"] not in ("pass", "fail"):
            _hook_fehler(f"line {i}: invalid status")
        if row["status"] == "pass" and row["deviation"] is not None:
            _hook_fehler(f"line {i}: pass with non-null deviation")
        if row["status"] == "fail" and (not isinstance(row["deviation"], str) or not row["deviation"]):
            _hook_fehler(f"line {i}: fail without deviation")
        if any(c["id"] == row["id"] for c in b.checks):
            _hook_fehler(f"line {i}: duplicate id {row['id']}")
        b.geprueft += 1
        b.checks.append(row)
        if row["status"] == "fail":
            b.abweichungen.append((row["id"], "", "", row["deviation"]))
        print(f"  [{'ok ' if row['status'] == 'pass' else '!! '}] {row['id']}")


def json_envelope(b):
    """C-1b §2.3: kompaktes Dokument, ein Schreibvorgang, genau ein LF."""
    now = datetime.datetime.now(datetime.timezone.utc)
    doc = {"schema": "bullpen-verify.json/1",
           "generated_at": now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z",
           "total": len(b.checks),
           "counts": {"pass": sum(1 for c in b.checks if c["status"] == "pass"),
                      "fail": sum(1 for c in b.checks if c["status"] == "fail")},
           "checks": b.checks}
    return json.dumps(doc, separators=(",", ":"), ensure_ascii=False)



def vorflug(b, P, eltern, kinder, host):
    """Pre-flight: is the pen able to CARRY a campaign right now?

    Distinct from the roster comparison above, which asks whether the plant
    matches its description. This asks whether the plant can work — and every
    check below is a cause that killed a Phase B run on 2026-08-08:

      * a checkout 21 commits behind, so agents followed instructions that
        named paths abolished hours earlier;
      * LMCP_PROBE_URL unset, so a correct implementation went red on 14 of 15
        checks passing;
      * bullpen-attest present but on no PATH, so a tester reported the tool as
        missing (a correct measurement, a wrong conclusion);
      * eleven worker daemons still running the pre-migration library, so they
        accepted a dispatch, worked, and were refused on every post — the
        cursor advancing all the same. No ack, no reply, no error.

    That last one is why the service/file comparison exists: a file on disk
    being current says nothing about the process that read it at startup.
    """
    kopf = lauf(host, "git -C ~/src/bullpen rev-parse HEAD")[1]
    namen = [k["name"] for k in kinder]

    print()
    print("== Vorflug: traegt die Anlage eine Kampagne? ==")

    # 1. checkouts
    for name in namen:
        rc, h = lauf(host, f"{P} exec {name} -- git -C /opt/bullpen-src rev-parse HEAD")
        b.pruefe(f"{name}: checkout auf main", kopf[:8], (h or "-")[:8],
                 "git bundle hinein und --ff-only mergen")

    # 2./3. the anchor: set, and actually answering
    for k in kinder:
        if not k.get("braucht_anker", True):
            continue          # declared exemption, reason lives in the roster
        # `grep -c` counts LINES mentioning the name, comments included — a
        # correctly configured container reported "2". Ask whether the variable
        # is ASSIGNED, which is the actual question.
        rc, treffer = lauf(host, f"{P} exec {k['name']} -- sh -c "
                                 "'grep -qE \"^LMCP_PROBE_URL=.\" /etc/bullpen-room.env "
                                 "2>/dev/null && echo gesetzt || echo FEHLT'")
        b.pruefe(f"{k['name']}: LMCP_PROBE_URL", "gesetzt", treffer or "FEHLT",
                 "ohne Verankerung sind 14/15 gruen und die Suite trotzdem rot")

    probe = ("import json,pathlib,urllib.request;"
             "e=dict(l.split('=',1) for l in "
             "pathlib.Path('/etc/bullpen-room.env').read_text().split() if '=' in l);"
             "b=json.dumps({'jsonrpc':'2.0','id':1,'method':'initialize','params':"
             "{'protocolVersion':'2025-06-18','capabilities':{},"
             "'clientInfo':{'name':'preflight','version':'0'}}}).encode();"
             "r=urllib.request.Request(e['LMCP_PROBE_URL'],data=b,headers={"
             "'Content-Type':'application/json','Accept':'application/json, text/event-stream',"
             "'Authorization':'Bearer '+e.get('LMCP_PROBE_TOKEN','')});"
             "raw=urllib.request.urlopen(r,timeout=20).read().decode();"
             "raw=[l[6:] for l in raw.splitlines() if l.startswith('data: ')][0] "
             "if 'data: ' in raw else raw;"
             "print(json.loads(raw)['result']['protocolVersion'])")
    ziel = next((n for n in namen if n.startswith("testdesign")), namen[0])
    rc, v = lauf(host, f"{P} exec {ziel} -- python3 -c \"{probe}\"", timeout=90)
    # Compare the SHAPE, not a prose placeholder: any YYYY-MM-DD is an answer.
    # The first version of this line asserted the literal string "eine Fassung"
    # and therefore flagged a perfectly working anchor.
    geantwortet = bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", (v or "").strip()))
    b.pruefe("Verankerung antwortet", "ja", "ja" if geantwortet else "NEIN",
             f"aus {ziel} gegen den Raum: {v or 'stumm'}")

    # 4. the tools reachable BY NAME, not just present
    for prog in ("bullpen-attest", "bullpen-verify"):
        for name in namen:
            rc, wo = lauf(host, f"{P} exec {name} -- sh -c "
                                f"'command -v {prog} || echo -'")
            b.pruefe(f"{name}: {prog} im PATH", "gefunden",
                     "gefunden" if wo != "-" else "NICHT im PATH",
                     "Datei da reicht nicht — der Agent ruft sie beim Namen")

    # 5. per-nick secrets: each container its own, the room all of them
    for k in kinder:
        erwartet = k.get("nicks") or []
        if not erwartet:
            continue
        rc, da = lauf(host, f"{P} exec {k['name']} -- sh -c "
                            "'ls /etc/bullpen/post-secret.d/ 2>/dev/null | tr \"\\n\" \" \"'")
        fehlend = [n for n in erwartet if n not in (da or "").split()]
        b.pruefe(f"{k['name']}: eigene Geheimnisse", "vollstaendig",
                 "vollstaendig" if not fehlend else f"fehlt: {' '.join(fehlend)}")

    # 6. THE ONE THAT WAS MISSING: process older than the code it should run
    print()
    print("== Vorflug: laeuft irgendwo Code von gestern? ==")
    for name in namen:
        rc, lib = lauf(host, f"{P} exec {name} -- sh -c "
                             "'stat -c %Y /opt/bullpen-src/lib/bullpen_worker.py 2>/dev/null || echo 0'")
        rc, units = lauf(host, f"{P} exec {name} -- sh -c "
                               "'systemctl list-units --type=service --state=running "
                               "--no-legend \"bullpen-*\" 2>/dev/null | awk \"{print \\$1}\"'")
        for u in [z for z in (units or "").split() if z]:
            rc, ts = lauf(host, f"{P} exec {name} -- sh -c "
                                f"'date -d \"$(systemctl show {u} -p ActiveEnterTimestamp --value)\" +%s "
                                "2>/dev/null || echo 0'")
            alt = ts.isdigit() and lib.isdigit() and int(ts) < int(lib) and int(ts) > 0
            b.pruefe(f"{name}/{u.replace('.service','')}", "Code aktuell",
                     "VOR der Bibliothek gestartet" if alt else "Code aktuell",
                     "neu starten — sonst laeuft die Datei neu und der Prozess alt")


def _vorgabe_nutzer():
    """Vorgabe fuer `benutzer`, aus der Konfiguration statt hart verdrahtet.

    Stand 2026-08-12 "mfritsche". Vorher "agent" -- ein Platzhalter, den es auf
    dieser Anlage nicht gibt, was eine Pruefung an einem gesunden Container rot
    werden liess.
    """
    # NICHT sys.path anfassen. Ein erster Versuch tat das und schob dabei
    # ~/src/bullpen/lib vor die installierte Kopie -- worauf die TRUST-Pruefung
    # weiter unten plötzlich das Repo las statt des Pakets und 1 Abweichung auf
    # 0 fiel. Nicht behoben, sondern blind gemacht: die veraltete Paketkopie ist
    # genau das, was diese Pruefung finden soll (#119).
    try:
        import bullpen_config as _c   # nur, wenn ohnehin importierbar
        return getattr(_c, "DEFAULT_USER", "mfritsche")
    except Exception:
        return "mfritsche"


def main():
    ap = argparse.ArgumentParser()
    # Im Paket liegt das Roster unter /usr/share/bullpen/deploy, in einer
    # Arbeitskopie daneben. Beides ohne Argument finden — ein Werkzeug, das
    # man mit einem Pfad fuettern muss, benutzt im Zweifel niemand.
    vorgabe = next((p for p in ("deploy/roster.yaml",
                                "/usr/share/bullpen/deploy/roster.yaml")
                    if os.path.isfile(p)), "deploy/roster.yaml")
    ap.add_argument("--roster", default=vorgabe)
    ap.add_argument("--host", default="hertz")
    # Der Vorflug ist NICHT vorgabemaessig an: er stellt Fragen an jeden
    # laufenden Dienst in jedem Container und dauert entsprechend. Vor einer
    # Kampagne ist er die billigste Minute des Tages.
    ap.add_argument("--preflight", action="store_true",
                    help="zusaetzlich pruefen, ob die Anlage eine Kampagne traegt")
    # C-1b: maschinenlesbare Ausgabe. Mehrfachnennung ist erlaubt und wirkt wie
    # einmal (store_true). Exit-Code-Semantik aendert sich NICHT (Invariante I-1).
    ap.add_argument("--json", action="store_true",
                    help="alle Pruefungen als ein JSON-Dokument auf stdout")
    a = ap.parse_args()

    # --json: waehrend des Laufs faengt ein Puffer JEDE normale Ausgabe ab; auf
    # die Leitung geht am Ende genau ein Dokument + ein LF, in einem einzigen
    # write. Der flaglose Pfad bleibt bit-identisch, stderr unangetastet. Ein
    # Abbruch mitten im Lauf laesst stdout leer — nie ein halbes Dokument.
    echt = sys.stdout
    if a.json:
        sys.stdout = io.StringIO()

    # Testhaken (C-1b §2.4): gesetzt -> KEIN Live-Rundgang; die Datei speist
    # beide Ausgabemodi und den Exit-Code. Ungesetzt -> null Unterschied zu heute.
    hook = os.environ.get("BV_TEST_RESULTS")
    if hook:
        b = Bericht()
        lade_testresultate(hook, b)
        return _abschluss(a, b, echt)

    r = yaml.safe_load(open(a.roster, encoding="utf-8"))
    eltern, kinder = r["eltern"], r["kinder"]
    b = Bericht()
    P = f"incus exec {eltern['name']} -- incus"

    print(f"== Eltern: {eltern['name']} auf {eltern['wirt']} ==")
    for schluessel, feld in (("limits.memory", "limits_memory"),
                             ("security.nesting", "security_nesting"),
                             ("security.idmap.size", "security_idmap_size"),
                             ("boot.autostart", "boot_autostart")):
        rc, ist = lauf(a.host, f"incus config get {eltern['name']} {schluessel}")
        soll = eltern[feld]
        b.pruefe(f"eltern {schluessel}", str(soll).lower(), (ist or "-").lower())

    rc, pool = lauf(a.host, f"incus config show {eltern['name']} | sed -n 's/^ *pool: //p' | head -1")
    b.pruefe("eltern pool", eltern["pool"], pool or "-")

    print()
    print("== Kinder ==")
    rc, vorhanden = lauf(a.host, f"{P} list -c ns --format csv")
    ist_zustand = dict(z.split(",", 1) for z in vorhanden.splitlines() if "," in z)
    b.pruefe("anzahl kinder", len(kinder), len(ist_zustand))

    for k in kinder:
        name = k["name"]
        print(f"  -- {name} --")
        b.pruefe(f"{name}: existiert und laeuft", "RUNNING", ist_zustand.get(name, "FEHLT"))
        if name not in ist_zustand:
            continue
        rc, mem = lauf(a.host, f"{P} config get {name} limits.memory")
        b.pruefe(f"{name}: limits.memory", k["limits_memory"], mem or "-")
        rc, auto = lauf(a.host, f"{P} config get {name} boot.autostart")
        b.pruefe(f"{name}: boot.autostart", str(k["boot_autostart"]).lower(),
                 (auto or "-").lower(), k.get("abweichung", "")[:60])

        # Geheimnisse: genau die Nicks, die das Roster diesem Container gibt.
        soll_nicks = sorted(k.get("nicks", []))
        if k.get("geheimnisse") == "alle":
            # Plus the senders that live OUTSIDE the pen but must be verifiable
            # inside it (the operator). They have no container, so they are not
            # anybody's child nick — but the room has to hold their secret or
            # their posts go unstamped and every lurker discards them (R4).
            soll_nicks = sorted([n for kk in kinder for n in kk.get("nicks", [])]
                                + list(r.get("externe_nicks", [])))
        rc, ist_nicks = lauf(a.host, f"{P} exec {name} -- sh -c 'ls /etc/bullpen/post-secret.d 2>/dev/null'")
        b.pruefe(f"{name}: geheimnisse", ",".join(soll_nicks),
                 ",".join(sorted(ist_nicks.split())) or "-")

        # --- und koennen sie auch GELESEN werden? --------------------------
        # Die Pruefung darueber zaehlt Dateinamen. Am 2026-08-10 stimmten alle
        # Namen, und trotzdem konnte kein einziger Lurker posten: nach dem
        # Umzug von root auf einen benannten Nutzer gehoerten Geheimnis und
        # Raum-Umgebung weiter root, und /etc/bullpen war drwx------ -- der
        # Durchgang scheiterte, bevor die 0600 der Datei ueberhaupt zaehlte.
        # Der Lurker liest sein Geheimnis beim Import, bekommt None, postet
        # ohne, und der Raum antwortet "unauthorized". Sichtbar wurde das als
        # @foremans "say failed" und als @deus' Meldung, sein Geheimnis sei
        # beim Neubau geloescht worden. Es war da. Es war nur unerreichbar.
        #
        # Gilt fuer jedes Kind mit motor -- auch fuer deus, das keinen Lurker
        # faehrt, aber sehr wohl posten koennen muss.
        if k.get("motor"):
            nutzer = k.get("benutzer", _vorgabe_nutzer())
            nick = (k.get("nicks") or [name])[0]
            for was, pfad in (("Geheimnis", f"/etc/bullpen/post-secret.d/{nick}"),
                              ("Raum-Umgebung", "/etc/bullpen-room.env")):
                rc, _ = lauf(a.host,
                             f"{P} exec {name} -- su - {nutzer} -c 'cat {pfad}'")
                b.pruefe(f"{name}: Postschluessel lesbar ({was})", "ja",
                         "ja" if rc == 0 else "NEIN",
                         f"{nutzer} kann {pfad} nicht oeffnen -- Posts werden "
                         "mit 'unauthorized' abgewiesen; pruefe auch das "
                         "Durchgangsrecht auf dem Elternverzeichnis")

        # --- Laufzeit, Modell, Nutzer, Weboberflaeche ----------------------
        # Nur fuer Kinder, die einen motor erklaeren.
        #
        # benutzer hat einen VORGABEWERT: fehlt das Feld, ist es "agent". Eine
        # frische Anlage laeuft damit nicht versehentlich als root, und diese
        # Anlage setzt bewusst mfritsche.
        #
        # Warum das geprueft gehoert: motor/modell leben als Marker-Dateien im
        # Lurker-Verzeichnis (.runtime/.model). Die kann jeder von Hand
        # verstellen, und der Raum sieht davon nichts -- ein Container liefe
        # dann eine andere Laufzeit als das Roster erklaert, ohne dass irgendwo
        # etwas rot wird. Genau der Fall ist am 2026-08-09 passiert (#116).
        # Nur Kinder MIT Lurker: deus erklaert zu Recht motor: pi, faehrt pi
        # aber ueber pi-web und hat keinen Lurker. Die Marker-Dateien und
        # das User= der Lurker-Unit gibt es dort nicht -- danach zu suchen
        # erzeugt eine Falschmeldung, und ein Pruefer mit Falschmeldungen
        # wird weggeklickt.
        if k.get("motor") and "bullpen-lurker.service" in k.get("units", []):
            nutzer = k.get("benutzer", _vorgabe_nutzer())
            nick = (k.get("nicks") or [name])[0]

            rc, unit = lauf(a.host, f"{P} exec {name} -- cat /etc/systemd/system/bullpen-lurker.service")
            m = re.search(r"^User=(\S+)", unit or "", re.M)
            b.pruefe(f"{name}: lurker laeuft als", nutzer, m.group(1) if m else "root",
                     "ohne User= laeuft die Unit als root")

            rc, pw = lauf(a.host, f"{P} exec {name} -- getent passwd {nutzer}")
            heim = (pw or "").strip().split(":")[5] if (pw or "").count(":") >= 6 else ""
            b.pruefe(f"{name}: benutzer {nutzer} existiert", "ja", "ja" if heim else "NEIN")

            if heim:
                ld = f"{heim}/{nick}_lurker"
                rc, rt = lauf(a.host, f"{P} exec {name} -- cat {ld}/.runtime")
                # Fehlt die Datei, faellt der Lurker auf claude zurueck -- also
                # ist "fehlt" hier nicht "unbekannt", sondern "claude".
                b.pruefe(f"{name}: motor", k["motor"], (rt or "claude").strip() or "claude",
                         f"{ld}/.runtime")
                if k.get("modell"):
                    rc, md = lauf(a.host, f"{P} exec {name} -- cat {ld}/.model")
                    b.pruefe(f"{name}: modell", k["modell"], (md or "-").strip() or "-",
                             f"{ld}/.model")

        # web: ohne LAN-Anschluss ist der Wirtsname unerreichbar, egal wie gut
        # caddy eingestellt ist. Die vier Lurker hatten am 2026-08-10 GAR keine
        # Geraete -- nur bullseye und deus hingen im LAN.
        if k.get("web"):
            rc, dev = lauf(a.host, f"{P} config device show {name}")
            # Soll und Ist MUESSEN dieselbe Zeichenkette sein. Vorher stand hier
            # "lan-Geraet vorhanden" gegen "vorhanden" -- die Pruefung meldete rot
            # bei erfuelltem Zustand, und vier von zehn Abweichungen waren ihre
            # eigenen. Ein Pruefer, der grundlos rot meldet, wird weggeklickt, und
            # die echten Befunde gehen mit.
            b.pruefe(f"{name}: LAN-Anschluss fuer {k['web']}", "vorhanden",
                     "vorhanden" if re.search(r"^\s*lan:", dev or "", re.M) else "FEHLT",
                     "incus config device add <c> lan nic network=lanmv name=lan")

        # Units: aktiviert, nicht bloss vorhanden.
        soll_units = sorted(k.get("units", []))
        if soll_units:
            rc, ist_units = lauf(
                a.host,
                f"{P} exec {name} -- sh -c \"systemctl list-unit-files --state=enabled --no-legend 2>/dev/null "
                f"| awk '{{print \\$1}}' | grep -E '^(bullpen|bullseye|pi-web|lmcp|caddy)' | sort | tr '\\n' ' '\"")
            fehlend = [u for u in soll_units if u not in ist_units.split()]
            b.pruefe(f"{name}: units aktiviert", "keine fehlen",
                     "keine fehlen" if not fehlend else f"fehlt: {' '.join(fehlend)}")
            # Die andere Richtung. Sie fehlte, und deshalb lief eine ganze
            # Netz-Zugriffsflaeche (workers: lmcp-search.service) und deus'
            # einziges Zugangsfenster (caddy.service) ausserhalb der erklaerten
            # Quelle -- beides mit [ok] quittiert. Ein Neubau streng nach roster
            # haette einen deus ohne Zugang ergeben.
            ueberzaehlig = [u for u in ist_units.split() if u not in soll_units]
            b.pruefe(f"{name}: units im roster verzeichnet", "keine ueberzaehligen",
                     "keine ueberzaehligen" if not ueberzaehlig
                     else f"nicht im roster: {' '.join(ueberzaehlig)}",
                     "laeuft, aber nicht erklaert -- roster nachziehen oder Leiche entfernen")

    # TRUST ist der dritte Verbraucher des rosters, und der einzige, den nie
    # jemand daran angeschlossen hat. Bis 2026-08-09 fehlte `doctor`, standen
    # vier Nicks drin, die es im Pen nie gab (pica/pipi/orca/herder, gemessen
    # 0 Beitraege je in room.jsonl), und der Shell-Zwilling fuehrte eine dritte,
    # nochmals andere Liste. Verwerfen wegen TRUST ist der leiseste Fehlermodus
    # des Systems: room_say meldet ok:true, der Empfaenger schweigt, der
    # Absender erfaehrt nichts.
    print()
    print("== TRUST gegen den roster (die dritte Liste) ==")
    soll_trust = set()
    for k in kinder:
        soll_trust |= set(k.get("nicks", []))
    soll_trust |= set(r.get("externe_nicks", []))
    try:
        # Dieselbe Idiomatik wie bullpen-doctor & Co., plus die Pfade, die es
        # hier wirklich gibt. Relativ zum Skript allein reicht nicht: seit der
        # Pruefer als Operator-Werkzeug unter /opt/herding/bin liegt (damit er
        # keine dpkg-Datei ueberschreibt), existiert ../lib dort nicht.
        for _p in (os.path.join(os.path.dirname(os.path.dirname(
                       os.path.realpath(__file__))), "lib"),
                   "/usr/share/bullpen/lib",
                   "/usr/local/lib/bullpen",
                   os.path.expanduser("~/src/bullpen/lib")):
            if os.path.isdir(_p) and _p not in sys.path:
                sys.path.insert(0, _p)
        import bullpen_config as _cfg
        ist_trust = set(_cfg.TRUST)
    except Exception as e:
        ist_trust = None
        b.pruefe("TRUST lesbar", "ja", f"NEIN ({type(e).__name__})")
    if ist_trust is not None:
        fehlt = sorted(soll_trust - ist_trust)
        zuviel = sorted(ist_trust - soll_trust)
        b.pruefe("TRUST == roster-Nicks", "deckungsgleich",
                 "deckungsgleich" if not (fehlt or zuviel)
                 else f"fehlt: {' '.join(fehlt) or '-'} / ueberzaehlig: {' '.join(zuviel) or '-'}",
                 "roster ist die Quelle -- nicht die Liste editieren, den roster")

    # Der Raum ist der einzige Ort, an dem eine Invariante messbar ist, die
    # sich nicht aus der Konfiguration ergibt: welche Werkzeuge er anbietet.
    raum = next((k for k in kinder if k.get("lmcp_tool_allow")), None)
    if raum:
        print()
        print("== Raum: Werkzeugliste (die Invariante hinter B6) ==")
        rc, allow = lauf(a.host, f"{P} exec {raum['name']} -- sh -c "
                                 f"'systemctl show lmcp -p Environment --value | tr \" \" \"\\n\" "
                                 f"| sed -n \"s/^LMCP_TOOL_ALLOW=//p\"'")
        b.pruefe("room: LMCP_TOOL_ALLOW",
                 ",".join(raum["lmcp_tool_allow"]), allow or "NICHT GESETZT",
                 "ohne die Liste hat jeder Token-Halter eine Wurzelschale im Raum")

    # Speicherdruck: GEMESSEN, nicht aus Deckeln addiert. Deckel sind
    # Sicherungen je Container; ihre Summe ist eine Buchhaltungsgroesse, weil
    # neun Container auf einem Wirt Seitencache, Bibliotheken und COW-Seiten
    # teilen. Entschieden wurde gegen eine Zulassungssteuerung -- dafuer muss
    # der Druck sichtbar sein.
    print()
    print("== Speicher: belegt gegen Deckel (gemessen, nicht addiert) ==")
    for k in kinder:
        rc, roh = lauf(a.host, f"{P} info {k['name']} 2>/dev/null | awk '/Memory .current./{{print $3$4}}'")
        print(f"  {k['name']:<12} belegt={roh or '?':<12} Deckel={k['limits_memory']}")
    rc, eltern_ist = lauf(a.host, f"incus info {eltern['name']} 2>/dev/null "
                                  f"| awk '/Memory .current./{{print $3$4}}'")
    print(f"  {'ELTERN':<12} belegt={eltern_ist or '?':<12} Deckel={eltern['limits_memory']}")
    rc, frei = lauf(a.host, "free -m | awk '/^Mem:/{print $7\" MB verfuegbar\"}'")
    print(f"  {'WIRT':<12} {frei}")

    if a.preflight:
        vorflug(b, P, eltern, kinder, a.host)

    return _abschluss(a, b, echt)


def _abschluss(a, b, echt):
    """Gemeinsamer Epilog beider Ergebnisquellen (Rundgang oder Testhaken):
    Zusammenfassung drucken, unter --json stattdessen den Envelope als einzigen
    Schreibvorgang auf die echte stdout, Exit wie eh und je (I-1)."""
    print()
    print(f"{len(b.abweichungen)} Abweichungen bei {b.geprueft} Pruefungen")
    if b.abweichungen:
        print()
        print("Soll und Ist laufen auseinander. Das ist der Befund, nicht der Fehler —")
        print("entweder das Roster luegt oder der Aufbau ist gedriftet. Beides gehoert")
        print("entschieden, nicht weggeklickt.")
    if a.json:
        sys.stdout = echt
        sys.stdout.write(json_envelope(b) + "\n")
    return 1 if b.abweichungen else 0


def _haupt():
    # §4 Fall 2 (C-1b): kann der Lauf unter --json gar nicht erst arbeiten
    # (Roster fehlt, Abhaengigkeit kaputt), gehoert auf stderr eine formatierte
    # Zeile, kein roher Traceback — und stdout bleibt leer (der Puffer wird nie
    # geleert). Exit wie im flaglosen Fall (unbehandelte Ausnahme = 1), damit
    # I-1 auch fuer Betriebsfehler gilt. Flaglos bleibt der Traceback: er ist
    # das Orakel und darf sich nicht aendern.
    try:
        return main()
    except SystemExit:
        raise
    except Exception as e:
        if "--json" in sys.argv[1:]:
            sys.stderr.write(f"bullpen-verify: error: {type(e).__name__}: {e}\n")
            return 1
        raise


sys.exit(_haupt())
