#!/usr/bin/env node
/**
 * OpenCRM – Gegenbuch (externe Notarisierung)
 * ===========================================
 *
 * Laeuft NICHT auf dem CRM-Server, sondern auf einem zweiten Rechner. Der
 * Signaturschluessel und das Push-Recht leben dort – nicht in den
 * Deploy-Secrets des CRM.
 *
 * ZENTRALE LEHRE (Pentest R175-01): Append-only und Signieren allein reichen
 * nicht – es braucht einen VERIFIZIERENDEN LESER. Die erste Fassung las ihre
 * Wahrheit aus der lokalen Arbeitsdatei und hat nie eine Signatur geprueft;
 * das `-S` war write-only ohne Konsument. Wer den Notar-Host beschreiben
 * konnte, hat dem Skript beliebige „beglaubigte“ Zeilen untergeschoben und den
 * abgeschnittenen Zustand als neue Wahrheit zementiert – ohne Alarm.
 *
 * Deshalb gilt jetzt:
 *   - Wahrheitsquelle ist der SIGNIERTE Commit-Baum (bevorzugt der Remote-Kopf),
 *     nicht die Arbeitsdatei.
 *   - JEDER Commit der Historie muss eine gute Signatur tragen.
 *   - Weicht die Arbeitsdatei vom signierten Stand ab, wird abgebrochen.
 *   - Geschrieben wird erst nach erfolgreichem, signiertem Commit; schlaegt er
 *     fehl, wird die Datei zurueckgerollt (Pentest R175-03).
 */
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
import { resolve, basename, join, dirname } from 'node:path';
import { homedir } from 'node:os';

const CRM_URL = process.env.CRM_URL;
const CRM_TOKEN = process.env.CRM_TOKEN;
const LEDGER = resolve(process.env.LEDGER_FILE || 'checkpoints.jsonl');
const LEDGER_NAME = basename(LEDGER);
const PUSH = process.env.NOTARY_PUSH !== 'false';
const REMOTE = process.env.NOTARY_REMOTE || 'origin';
// Reiner Pruefmodus fuer Auditoren: fuehrt alle Kontrollen aus, haengt aber
// nichts an und braucht kein Schreibrecht.
const NUR_PRUEFEN = process.argv.includes('--check') || process.env.NOTARY_CHECK_ONLY === 'true';
// Wird gesetzt, wenn ohne externen Anker gearbeitet wird – faerbt jede
// Erfolgsmeldung ein (Pentest R177, Kleinkram).
let NUR_LOKAL = false;

// Ohne Signatur ist das Gegenbuch wertlos: dann kann jeder mit Schreibrecht
// die Reihe faelschen. Der Schalter existiert nur fuer Tests und verlangt eine
// zusaetzliche, bewusste Bestaetigung – der Reflex "Cron hakt, also abschalten"
// soll nicht ausreichen.
const SIGN = process.env.NOTARY_SIGN !== 'false';
// Fingerabdruck des erwarteten Signierschluessels (`%GF`).
//
// NICHT optional (Pentest R177-01): Ohne Pin ist der Vertrauensanker die
// gesamte `allowed_signers`-MENGE, nicht der eine Notar-Schluessel. Jeder dort
// gelistete Zweitschluessel – anderer Nutzer, Alt-Key, global geteilte Datei –
// kann das Gegenbuch dann waschen und force-pushen, und die Pruefung meldet
// „gültig“. Live gezeigt.
//
// Ist nichts gesetzt, wird der Pin aus `user.signingkey` abgeleitet. Klappt
// auch das nicht, wird abgebrochen statt stillschweigend die ganze Menge zu
// akzeptieren.
// `.trim()` beim EINLESEN (Pentest R178): ein Zeilenumbruch aus der
// Umgebungsvariable liess sonst jeden Commit als „anderer Schlüssel“ auflaufen –
// ein Fehlalarm, der auf den korrekten Fingerabdruck zeigt und damit maximal
// verwirrt.
let PIN = (process.env.NOTARY_SIGNER_FINGERPRINT || '').trim() || null;

// Bekannte Mindesthoehe der Checkpoint-Reihe (Pentest R178-01).
//
// Ein Angreifer mit Force-Push-Recht kann die Reihe auf einen AELTEREN,
// echt signierten Praefix zurueckspulen und die Datenbank passend kuerzen.
// Alle Signaturen bleiben gueltig, der Pin passt, die Reihe ist lueckenlos –
// signaturseitig ist daran nichts auszusetzen. Ein Notar-Klon mit lokalem
// Vorlauf merkt es (er erinnert die hoehere seq); ein FRISCHER Auditoren-Klon
// merkt es nicht.
//
// Wer die zuletzt gesehene seq kennt (aus Monitoring, Protokoll, Mail), kann
// sie hier mitgeben – dann faellt das Zurueckspulen auch ohne lokalen Zustand
// auf.
const MIN_SEQ_ROH = (process.env.NOTARY_MIN_SEQ || '').trim();
let MIN_SEQ = Number.NaN;
if (MIN_SEQ_ROH) {
  MIN_SEQ = Number.parseInt(MIN_SEQ_ROH, 10);
  // Laut ablehnen statt still verschlucken (Pentest R179-01): ein Tippfehler
  // ergab frueher `NaN`, wurde ignoriert – und weil die blosse PRAESENZ eines
  // Werts den Vorbehalt ausblendete, sah das Ergebnis anschliessend
  // selbstbewusster aus als ganz ohne Angabe. `0` war sogar ein Freibrief.
  if (!Number.isFinite(MIN_SEQ) || MIN_SEQ <= 0) {
    console.error(
      `NOTARY_MIN_SEQ ist kein sinnvoller Wert: "${MIN_SEQ_ROH}".\n` +
      'Erwartet wird die zuletzt bekannte Checkpoint-Nummer als positive Zahl.\n' +
      'Lieber weglassen als raten – dann sagt die Prüfung ehrlich, dass ein ' +
      'Zurückspulen nicht erkennbar war.',
    );
    process.exit(1);
  }
}

// Zusicherung des Betreibers, dass der Server non-fast-forward-Pushes
// verbietet (Pentest R179-01/-b).
//
// NUR diese Zusicherung darf den Rewind-Vorbehalt ausblenden – nicht die
// blosse Anwesenheit einer Zahl in NOTARY_MIN_SEQ. Der Grund: MIN_SEQ ist eine
// UNTERGRENZE und immer nur so frisch wie die letzte Beobachtung. Wer stuendlich
// beglaubigt, aber woechentlich prueft, laeuft mit einem Wert herum, der um
// ~168 hinterherhinkt – jeder Rewind in dieses Fenster passiert lautlos, und
// der gewaschene Stand wird sogar ausdruecklich bestaetigt.
const REWIND_GESCHUETZT = process.env.NOTARY_REWIND_PROTECTED === 'true';

// Beobachtungsspeicher fuer den Fast-Forward-Monitor (Pentest R179 (a)).
//
// Statt die POLICY abzufragen (Probe-Push), wird das EREIGNIS erkannt, das die
// Policy verhindern soll. Bei jedem Lauf wird der beobachtete Remote-Kopf
// festgehalten; beim naechsten Mal muss der neue Kopf ein Nachfahre des alten
// sein. Ist er das nicht, wurde zurueckgespult – unabhaengig davon, ob es einen
// serverseitigen Schutz gibt oder nicht.
//
// Ein Probe-Push waere hier der falsche Weg: Er misst die eigene
// Push-Identitaet, nicht die des Angreifers (Bypass-Rechte fuer Admins gehen
// genau dann auseinander, wenn es darauf ankommt), er gilt nur fuer den
// geprobten Ref, und die bestaetigende Beobachtung waere derselbe Vorgang wie
// der Schaden.
//
// Die Datei gehoert bewusst NICHT in den Klon – sonst nimmt ein Neu-Klonen ihr
// die Erinnerung. Idealerweise liegt sie auf separatem Speicher.
const STATE_FILE = resolve(
  process.env.NOTARY_STATE_FILE ||
    join(homedir(), '.opencrm-notary', 'beobachtungen.jsonl'),
);
if (!SIGN && process.env.NOTARY_INSECURE_ACK !== 'mir-ist-klar-dass-das-ungeschuetzt-ist') {
  console.error(
    'NOTARY_SIGN=false deaktiviert den gesamten Schutz des Gegenbuchs.\n' +
    'Wenn das wirklich beabsichtigt ist (nur für Tests), zusätzlich setzen:\n' +
    '  NOTARY_INSECURE_ACK=mir-ist-klar-dass-das-ungeschuetzt-ist',
  );
  process.exit(1);
}
const CRM_EMAIL = process.env.CRM_EMAIL;
const CRM_PASSWORD = process.env.CRM_PASSWORD;

// Bestaetigung fuer einen Wechsel der Siegelwurzel (Pentest R185-01).
//
// Ein Wechsel loest Alarm aus – und der Alarm bricht ab, BEVOR der neue Stand
// ins Gegenbuch kommt. Ohne Bestaetigungsweg wuerde deshalb auch ein voellig
// legitimes Siegeln von da an bei jedem Lauf erneut alarmieren, ohne dass der
// Betreiber es je aufloesen koennte. Genau daran stirbt eine Warnung
// (dieselbe Lehre wie R183-03).
//
// Bestaetigt wird deshalb nicht pauschal mit „true“, sondern mit der WURZEL,
// die man akzeptiert – mindestens 16 Zeichen. Ein versehentlich stehen
// gelassener Wert passt beim naechsten Wechsel nicht mehr und kann darum
// keinen weiteren Austausch stillschweigend durchwinken. Das ist der
// Unterschied zu NOTARY_GENESIS_ACK, wo genau diese Falle dokumentiert
// werden musste.
const SEAL_ACK = (process.env.NOTARY_SEAL_ACK || '').trim();
const siegelBestaetigt = (wurzel) =>
  SEAL_ACK.length >= 16 && !!wurzel && wurzel.startsWith(SEAL_ACK);

// Bestaetigung fuer eine Neuberechnung der Kette (Pentest R186, Frage (b)).
//
// Bestaetigt wird mit der ID des juengsten Rehash-Eintrags. IDs steigen
// streng, ein stehen gelassener Wert passt also beim naechsten Rehash nicht
// mehr - dasselbe selbstentwertende Prinzip wie bei NOTARY_SEAL_ACK, nur mit
// einem Wert, den man vorlesen kann.
const REHASH_ACK = (process.env.NOTARY_REHASH_ACK || '').trim();
// Wurde in diesem Lauf eine Neuberechnung ausdruecklich bestaetigt? Dann ist
// der veraenderte Kettenkopf die erwartete Folge und kein eigener Befund.
let rehashBestaetigt = false;

if (!CRM_URL) {
  console.error('CRM_URL muss gesetzt sein.');
  process.exit(1);
}
if (!CRM_TOKEN && !(CRM_EMAIL && CRM_PASSWORD)) {
  console.error(
    'Es fehlt der Zugang zum CRM.\n' +
    'Entweder CRM_EMAIL und CRM_PASSWORD eines Dienstkontos setzen (empfohlen –\n' +
    'das Gegenbuch meldet sich dann bei jedem Lauf selbst an), oder ein bereits\n' +
    'vorhandenes CRM_TOKEN mitgeben (läuft nach 15 Minuten ab, nur für Tests).',
  );
  process.exit(1);
}

// stderr wird bewusst abgefangen statt durchgereicht: sonst stehen git-eigene
// Fehlermeldungen VOR der Erklaerung dieses Skripts und der Betreiber liest
// zuerst etwas Kryptisches. Relevante git-Ausgaben geben wir gezielt weiter.
const GIT_STDIO = ['ignore', 'pipe', 'pipe'];
const git = (...args) =>
  execFileSync('git', args, { encoding: 'utf8', stdio: GIT_STDIO }).trim();
const gitStill = (...args) => {
  try { return { ok: true, out: git(...args) }; }
  catch (e) { return { ok: false, out: String(e.stderr || e.message).trim() }; }
};

// Pin aus der lokalen Signierkonfiguration ableiten, wenn nicht vorgegeben.
if (SIGN && !PIN) {
  const konf = gitStill('config', 'user.signingkey');
  const kandidat = konf.ok ? konf.out.trim().replace(/^key::/, '') : '';
  if (kandidat && existsSync(kandidat)) {
    try {
      // `ssh-keygen -lf` liefert z. B. "256 SHA256:abc… kommentar (ED25519)".
      // Gebraucht wird genau das mittlere Feld – dasselbe Format, das git als
      // %GF ausgibt.
      const zeile = execFileSync('ssh-keygen', ['-lf', kandidat], {
        encoding: 'utf8',
        stdio: ['ignore', 'pipe', 'pipe'],
      }).trim();
      const treffer = zeile.split(/\s+/).find((t) => t.startsWith('SHA256:'));
      if (treffer) PIN = treffer;
    } catch {
      /* faellt unten in den Abbruch */
    }
  }
  if (!PIN) {
    console.error(
      'Der erwartete Signierschlüssel steht nicht fest.\n' +
      'Ohne ihn wäre der Vertrauensanker die gesamte allowed_signers-Liste und nicht\n' +
      'der eine Notar-Schlüssel – jeder dort gelistete Zweitschlüssel könnte das\n' +
      'Gegenbuch umschreiben, ohne dass es auffällt.\n' +
      'Abhilfe: NOTARY_SIGNER_FINGERPRINT setzen (git log -1 --format=%GF)\n' +
      'oder user.signingkey auf die .pub-Datei des Notar-Schlüssels zeigen lassen.',
    );
    process.exit(1);
  }
}

// Access-Tokens leben nur 15 Minuten – ein dauerhaft hinterlegtes Token waere
// beim naechsten stuendlichen Lauf laengst abgelaufen. Deshalb meldet sich das
// Gegenbuch mit einem eigenen Dienstkonto an und holt sich pro Lauf ein
// frisches. Das Konto braucht ausschliesslich die Berechtigung `audit:read`.
let zugangsToken = CRM_TOKEN || null;

async function anmelden() {
  let r;
  try {
    r = await fetch(`${CRM_URL}/api/auth/login`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email: CRM_EMAIL, password: CRM_PASSWORD }),
    });
  } catch (e) {
    console.error(
      `Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
      `  Grund: ${e instanceof Error ? e.message : String(e)}`,
    );
    process.exit(1);
  }
  if (r.status === 401) {
    console.error(
      'Anmeldung am CRM abgelehnt – E-Mail oder Passwort des Dienstkontos stimmen nicht.',
    );
    process.exit(1);
  }
  if (r.status === 429) {
    console.error('Das CRM hat die Anmeldung wegen zu vieler Versuche gebremst. Später erneut.');
    process.exit(1);
  }
  if (!r.ok) {
    console.error(`Anmeldung am CRM fehlgeschlagen: HTTP ${r.status}`);
    process.exit(1);
  }
  const j = await r.json();
  if (!j.success || !j.data?.token) {
    console.error(`Anmeldung am CRM fehlgeschlagen: ${j.error || 'kein Token in der Antwort'}`);
    process.exit(1);
  }
  zugangsToken = j.data.token;
}

async function hole(pfad, methode = 'GET') {
  // Fehler werden hier zu einer erklaerenden Zeile – frueher flog ein
  // Node-Stacktrace hoch, also genau die kryptische erste Zeile, die fuer
  // git-Meldungen schon abgestellt war.
  let r;
  try {
    r = await fetch(`${CRM_URL}${pfad}`, {
      method: methode,
      headers: {
        Authorization: `Bearer ${zugangsToken}`,
        ...(methode === 'POST' ? { 'content-type': 'application/json' } : {}),
      },
      ...(methode === 'POST' ? { body: '{}' } : {}),
    });
  } catch (e) {
    console.error(
      `Das CRM ist nicht erreichbar (${CRM_URL}).\n` +
      `  Grund: ${e instanceof Error ? e.message : String(e)}\n` +
      'Ohne aktuellen Kontrollwert kann nichts beglaubigt werden – das ist ein ' +
      'Verfügbarkeitsproblem, kein Befund.',
    );
    process.exit(1);
  }
  if (r.status === 401 || r.status === 403) {
    console.error(
      `Das CRM hat den Zugriff abgelehnt (HTTP ${r.status}).\n` +
      'Meist ist der Token abgelaufen oder ihm fehlt das Recht audit:read.',
    );
    process.exit(1);
  }
  if (!r.ok) {
    console.error(`Unerwartete Antwort des CRM: ${pfad} -> HTTP ${r.status}`);
    process.exit(1);
  }
  let j;
  try {
    j = await r.json();
  } catch {
    console.error(`Antwort des CRM ist kein JSON (${pfad}) – steht ein Proxy oder eine Fehlerseite davor?`);
    process.exit(1);
  }
  if (!j.success) {
    console.error(`Das CRM meldet einen Fehler: ${j.error}`);
    process.exit(1);
  }
  return j.data;
}

// Lokaler Betrieb ist ein vollwertiger Modus, wenn das Gegenbuch auf einem
// EIGENEN Rechner laeuft: Wer das CRM uebernimmt, kommt dann nicht an dieses
// Buch. Was fehlt, ist der Schutz gegen jemanden, der DIESEN Rechner
// uebernimmt – das gehoert benannt, aber es ist kein „kein Schutz“.
const OHNE_ANKER = () =>
  NUR_LOKAL
    ? '\n  Betriebsart: lokal. Geschützt ist damit der Fall „jemand verändert Daten im ' +
      'CRM“.\n  NICHT geschützt ist der Fall „jemand übernimmt diesen Rechner hier“ – ' +
      'dagegen hülfe\n  nur eine zusätzliche Ablage außerhalb.'
    : '';

// Ohne bekannte Mindesthoehe bleibt ein Rewind auf einen aelteren, echt
// signierten Stand unerkennbar (R178-01). Das gehoert in die Erfolgsmeldung,
// sonst liest sich „OK“ als mehr Gewissheit, als vorhanden ist.
const REWIND_VORBEHALT = () =>
  REWIND_GESCHUETZT || ffBelegt || NUR_LOKAL
    ? ''
    : '\n  Offen: Ein Zurückspulen der Reihe auf einen älteren, ebenfalls signierten Stand ' +
      'lässt sich hier nicht ausschließen.' +
      (Number.isFinite(MIN_SEQ)
        ? `\n  NOTARY_MIN_SEQ=${MIN_SEQ} deckt nur ab, was UNTERHALB dieser Nummer fehlt – ` +
          'ein Rewind oberhalb bliebe unbemerkt, und der Wert hinkt naturgemäß hinterher.'
        : '\n  Ohne NOTARY_MIN_SEQ gibt es dafür gar keinen Bezugspunkt.') +
      '\n  Verlässlich hilft nur serverseitiger Rewind-Schutz (non-fast-forward verbieten);' +
      ' ist er eingerichtet, mit NOTARY_REWIND_PROTECTED=true bestätigen.';


// Rueckgabecode statt blosser Prosa (Pentest R179-b).
//
// Der Vorbehalt stand in stdout, der Exit blieb 0 – und Cron greppt den Code,
// nicht den Text. Der Zustand „Rewind nicht ausschliessbar“ erreichte die
// Automation damit nie. Code 0 gibt es deshalb nur noch bei belegter
// Gewissheit: entweder ist serverseitiger Rewind-Schutz zugesichert, oder eine
// mitgegebene Mindesthoehe wurde erfuellt. Sonst Code 3 – „nicht abschliessend
// feststellbar“, dieselbe Bedeutung wie beim unerreichbaren Remote und
// bewusst NICHT mit dem Manipulationsalarm (2) verschmolzen.
const abschliessendSicher = (anzahl) =>
  REWIND_GESCHUETZT ||
  // Im lokalen Betrieb gibt es keinen Remote, den jemand zurueckspulen koennte.
  // Der Vergleich gegen den eigenen Beobachtungsspeicher ist hier der
  // vollstaendige Nachweis – die Grenze liegt bei der Integritaet dieses
  // Rechners und ist als solche benannt, nicht ungeklaert.
  (NUR_LOKAL && ffBelegt) ||
  // BEWUSST NICHT `ankerBelegt` (Pentest R180-01): Die Anker-Refs zeigen nur
  // den HOECHSTEN NOCH VORHANDENEN Stand – nicht den hoechsten je erreichten.
  // Wer die obersten Refs loescht, senkt damit den Vergleichswert selbst; ein
  // niedrigerer Anker bleibt stehen und sah bisher wie Bestaetigung aus.
  // Perverser Gradient: Wer ALLE Refs loescht, flog auf (Code 4); wer nur die
  // oberen loeschte, kam durch. Anker koennen ein Zurueckspulen also WIDERLEGEN
  // (siehe Alarm oben), aber nie Unversehrtheit BELEGEN.
  ffBelegt ||
  // Belegte Fast-Forward-Kette seit der ersten Beobachtung: das ist ein
  // Nachweis, kein Versprechen – deshalb zaehlt er (R179 (a)).
  ffBelegt ||
  (Number.isFinite(MIN_SEQ) && anzahl >= MIN_SEQ);

const alarm = (text) => { console.error(`ALARM: ${text}`); process.exit(2); };

// ---------------------------------------------------------------------------
// 1) Vertrauenswuerdigen Stand bestimmen: bevorzugt der Remote-Kopf.
// ---------------------------------------------------------------------------
// Auch der allererste git-Aufruf muss erklaerend scheitern koennen – sonst
// steht bei einem kaputten Klon ein Node-Stacktrace statt einer Diagnose.
const zweigErmittelt = gitStill('rev-parse', '--abbrev-ref', 'HEAD');
if (!zweigErmittelt.ok || !zweigErmittelt.out || zweigErmittelt.out === 'HEAD') {
  console.error(
    'Kein ausgecheckter Branch gefunden – ist das hier wirklich ein Klon des Gegenbuchs?\n' +
    (zweigErmittelt.ok ? '' : `  git-Meldung: ${zweigErmittelt.out}`),
  );
  process.exit(1);
}
const zweig = zweigErmittelt.out;
let ref = 'HEAD';
const hatRemote = gitStill('remote', 'get-url', REMOTE).ok;

if (hatRemote) {
  const f = gitStill('fetch', '--quiet', REMOTE);
  if (f.ok && gitStill('rev-parse', '--verify', `${REMOTE}/${zweig}`).ok) {
    ref = `${REMOTE}/${zweig}`;
  } else {
    // FAIL-CLOSED (Pentest R176-02): Frueher fiel der Ablauf hier still auf
    // `HEAD` zurueck – ausgerechnet unter der Bedingung, die einen vorherigen
    // Push-Fehlschlag verursacht hat. Ein nie gepushter Checkpoint galt dann
    // als beglaubigt, und `--check` meldete waehrend eines Remote-Ausfalls
    // gruenes Licht auf nicht notarisiertem Zustand.
    //
    // Ohne erreichbaren Remote laesst sich der beglaubigte Stand schlicht
    // nicht feststellen. Das ist kein Grund, dem lokalen Stand zu glauben.
    console.error(
      `Der beglaubigte Stand ist nicht feststellbar: ${REMOTE}/${zweig} nicht erreichbar.\n` +
      `Ohne Remote kann nicht entschieden werden, ob der lokale Stand jemals notarisiert wurde.\n` +
      `Das ist ein Verfügbarkeitsproblem – kein Freibrief. Erst Verbindung klären, dann erneut prüfen.\n` +
      (f.ok ? '' : `  git-Meldung: ${f.out}\n`),
    );
    process.exit(3);
  }
} else if (process.env.NOTARY_ALLOW_LOCAL === 'true') {
  // Ausdruecklich gewaehlter lokaler Betrieb (Gegenbuch auf eigenem Rechner,
  // ohne zusaetzliche Ablage ausserhalb). Die Erfolgsmeldung benennt, was das
  // abdeckt und was nicht – ein glattes „OK“ ohne Einordnung gibt es nicht.
  NUR_LOKAL = true;
} else {
  console.error(
    `Kein Remote '${REMOTE}' konfiguriert. Ein Gegenbuch, das nur lokal liegt, schützt nichts –\n` +
    `es steht im selben Zugriffsbereich wie alles andere auf diesem Rechner.\n` +
    `Für einen bewussten lokalen Testlauf: NOTARY_ALLOW_LOCAL=true`,
  );
  process.exit(3);
}

// Lokale Commits, die dem Remote voraus sind, sind nicht beglaubigt.
if (ref !== 'HEAD') {
  const voraus = gitStill('rev-list', '--count', `${ref}..HEAD`);
  if (voraus.ok && Number(voraus.out) > 0) {
    alarm(
      `${voraus.out} lokale(r) Commit(s) wurden nie gepusht – das Gegenbuch ist insoweit ` +
      `nicht beglaubigt.\n` +
      `ZUERST PRÜFEN, wie sie entstanden sind: ein abgebrochener Lauf hinterlässt so einen ` +
      `Commit ebenso wie ein falsch signierter (dann darf er NICHT gepusht werden, sonst ist ` +
      `die Kette dauerhaft rot).\n` +
      `  Ansehen mit: git log ${ref}..HEAD --format='%h %G? %GF %s'`,
    );
  }
}

// ---------------------------------------------------------------------------
// 1a) Anker-Refs: das Gedaechtnis liegt im Substrat, nicht auf diesem Rechner.
//
// Jeder verankerte Checkpoint bekommt zusaetzlich einen eigenen Ref
// `refs/notary/seq-N`. Der ueberlebt einen Force-Push auf `main`, weil er ein
// anderer Ref ist. Damit ist die hoechste je existierende Nummer aus dem
// Remote rekonstruierbar – auch dann, wenn der lokale Beobachtungsspeicher
// fehlt.
//
// Das schliesst die Luecke, die ein reiner Host-Speicher offenlaesst: Ein
// Angreifer, der ihn loescht, brauchte sonst nur EIN „erste Beobachtung“
// zu ueberstehen – danach haette der Waechter den zurueckgespulten Stand als
// Wahrheit adoptiert und dauerhaft gruen gemeldet. Ein gutartiger Default, den
// ein Angreifer gezielt erzeugen kann, ist kein sicherer Default.
// ---------------------------------------------------------------------------
const ankerRefs = (() => {
  const r = gitStill('ls-remote', REMOTE, 'refs/notary/seq-*');
  if (!r.ok || !r.out) return [];
  return r.out
    .split('\n')
    .map((z) => z.trim().split(/\s+/))
    .filter((t) => t.length === 2)
    .map(([sha, name]) => ({ sha, seq: Number.parseInt(name.replace(/.*seq-/, ''), 10) }))
    .filter((e) => Number.isFinite(e.seq))
    .sort((a, b) => a.seq - b.seq);
})();
const hoechsterAnker = ankerRefs[ankerRefs.length - 1] || null;

const remoteKopf = git('rev-parse', ref);

let ankerBelegt = false;
if (hoechsterAnker) {
  // Der beglaubigte Checkpoint muss im aktuellen Kopf noch enthalten sein.
  // Ist er es nicht, wurde `main` an ihm vorbei zurueckgespult – und das faellt
  // hier auf, ganz ohne lokalen Speicher.
  const drin =
    hoechsterAnker.sha === remoteKopf ||
    gitStill('merge-base', '--is-ancestor', hoechsterAnker.sha, remoteKopf).ok;
  if (!drin) {
    alarm(
      `Der verankerte Checkpoint ${hoechsterAnker.seq} ist im aktuellen Stand nicht mehr ` +
      `enthalten – die Reihe wurde an ihm vorbei zurückgespult.\n` +
      `  verankert: ${hoechsterAnker.sha.slice(0, 12)} (refs/notary/seq-${hoechsterAnker.seq})\n` +
      `  jetzt    : ${remoteKopf.slice(0, 12)}\n` +
      `Diese Refs überleben einen Force-Push auf ${zweig} – deshalb fällt es auf.`,
    );
  }
  ankerBelegt = true;
}
const remoteKennung = gitStill('remote', 'get-url', REMOTE).out || zweig;

function beobachtungenLesen() {
  if (!existsSync(STATE_FILE)) return [];
  return readFileSync(STATE_FILE, 'utf8')
    .split('\n')
    .filter((z) => z.trim())
    .map((z) => {
      try { return JSON.parse(z); } catch { return null; }
    })
    .filter((e) => e && e.remote === remoteKennung && e.zweig === zweig);
}

const beobachtungen = beobachtungenLesen();
const letzteBeobachtung = beobachtungen[beobachtungen.length - 1];
let ffBelegt = false;

// ---------------------------------------------------------------------------
// 1c) Fehlt das Waechter-Gedaechtnis – und ist das harmlos?
//
// „Kein Speicher“ ist zweierlei, und der Unterschied entscheidet alles:
//   * Es gab hier noch nie etwas → echte Erstinbetriebnahme, legitim.
//   * Es gibt bereits eine notarisierte Historie, aber kein Gedaechtnis mehr
//     → das Gedaechtnis ging NACH der Etablierung verloren. Das ist die
//     Anomalie, nicht der Normalfall – und genau der Zustand, den ein
//     Angreifer nach einem Rewind gezielt herstellt, um die naechste Baseline
//     auf seinen Stand zu setzen.
//
// Deshalb: kein stilles Neu-Baselinen. Eine Baseline zu setzen heisst „ich
// verbuerge mich, DAS ist der wahre Kopf“ – dieser Akt gehoert bestaetigt.
// ---------------------------------------------------------------------------
// Nur der SCHREIBLAUF setzt eine Baseline – nur dort ist die Sperre noetig.
// Ein Auditor mit frischem Klon hatte nie ein Gedaechtnis; ihm Code 4
// vorzuwerfen waere Laerm. Er bekommt stattdessen exit 3 mit Vorbehalt.
//
// Das Kurzschliessen ueber `ankerBelegt` ist entfallen (R180-01): ein stehen
// gelassener niedriger Anker durfte die Sperre nicht aufheben.
if (!letzteBeobachtung && !NUR_PRUEFEN) {
  const remoteHatHistorie = gitStill('show', `${ref}:${LEDGER_NAME}`).ok;
  if (remoteHatHistorie && process.env.NOTARY_ADOPT_ACK !== 'true') {
    console.error(
      'Das Wächter-Gedächtnis fehlt, obwohl bereits eine notarisierte Historie existiert.\n' +
      'Das ist KEIN Normalzustand: Entweder wurde dieser Rechner neu aufgesetzt, oder der\n' +
      'Beobachtungsspeicher wurde entfernt – letzteres ist genau das, was nach einem\n' +
      'Zurückspulen passieren müsste, damit der nächste Lauf den gewaschenen Stand als\n' +
      'Wahrheit übernimmt.\n\n' +
      'Ohne Bestätigung wird hier nichts als Ausgangspunkt akzeptiert. Bitte erst klären:\n' +
      `  * Stimmt der aktuelle Kopf ${remoteKopf.slice(0, 12)} mit dem überein, was zuletzt\n` +
      '    beglaubigt wurde? (Protokoll, Monitoring, zweite Kopie des Gegenbuchs)\n' +
      '  * Gibt es refs/notary/seq-* auf dem Server? Dann lässt sich der Stand daraus\n' +
      '    rekonstruieren, statt ihn zu glauben.\n\n' +
      'Erst wenn das geklärt ist: NOTARY_ADOPT_ACK=true setzen.',
    );
    process.exit(4);
  }
  if (!remoteHatHistorie && process.env.NOTARY_GENESIS_ACK !== 'true') {
    console.error(
      'Erstinbetriebnahme: Es gibt weder ein Wächter-Gedächtnis noch eine notarisierte\n' +
      'Historie. Das ist plausibel – aber die erste Baseline ist ein Vertrauensakt und\n' +
      'wird deshalb einmalig bestätigt:\n' +
      '  NOTARY_GENESIS_ACK=true',
    );
    process.exit(4);
  }
}

if (letzteBeobachtung) {
  if (letzteBeobachtung.sha === remoteKopf) {
    ffBelegt = true;
  } else {
    // Ist der zuletzt gesehene Kopf ein Vorfahre des jetzigen? Dann ist die
    // Kette gewachsen. Sonst wurde sie zurueckgespult oder ersetzt.
    const vorfahre = gitStill('merge-base', '--is-ancestor', letzteBeobachtung.sha, remoteKopf);
    if (vorfahre.ok) {
      ffBelegt = true;
    } else {
      alarm(
        `Der Remote-Kopf ist KEIN Nachfahre des zuletzt beobachteten Standes – ` +
        `die Reihe wurde zurückgespult oder ersetzt.\n` +
        `  zuletzt gesehen: ${letzteBeobachtung.sha.slice(0, 12)} (${letzteBeobachtung.ts})\n` +
        `  jetzt          : ${remoteKopf.slice(0, 12)}\n` +
        `Genau das soll der serverseitige Rewind-Schutz verhindern. Prüfen, wer ` +
        `force-pushen konnte – und ob die Datenbank dazu passend gekürzt wurde.`,
      );
    }
  }
}

const beobachtungMerken = () => {
  try {
    mkdirSync(dirname(STATE_FILE), { recursive: true });
    appendFileSync(
      STATE_FILE,
      JSON.stringify({
        ts: new Date().toISOString(),
        remote: remoteKennung,
        zweig,
        sha: git('rev-parse', ref),
      }) + '\n',
    );
  } catch (e) {
    console.warn(
      `HINWEIS: Beobachtung konnte nicht festgehalten werden (${STATE_FILE}).\n` +
      `  Ohne sie erkennt der nächste Lauf ein Zurückspulen nicht. Grund: ` +
      `${e instanceof Error ? e.message : String(e)}`,
    );
  }
};

// Fehlende Anker nachziehen (Pentest R180-02).
//
// Scheitert der Anker-Push, stand `main` schon auf dem neuen Checkpoint,
// waehrend der Anker fehlte – und niemand holte das nach. Der Normalbetrieb
// senkte damit den Substrat-Hoechststand still um eins und riss genau das
// Fenster auf, das R180-01 ausnutzt. Deshalb wird vor allem anderen
// nachgezogen, was fehlt.
let fehlendeAnker = [];

function ankerNachziehen(bisherLaenge) {
  const vorhanden = new Set(ankerRefs.map((a) => a.seq));
  fehlendeAnker = [];
  for (let n = 1; n <= bisherLaenge; n++) if (!vorhanden.has(n)) fehlendeAnker.push(n);
  if (!fehlendeAnker.length) return;

  // Im PRUEFMODUS wird NICHT nachgezogen (Pentest R181-01/-02).
  //
  // Zwei Gruende, und beide sind grundsaetzlich:
  //  1. Der Pruefmodus sagt zu, nichts zu veraendern und kein Schreibrecht zu
  //     brauchen. Ein Audit, das dabei still das Substrat mutiert, bricht
  //     genau diese Zusage.
  //  2. `refs/notary/*` MUSS serverseitig gegen Schreibzugriff gesperrt sein
  //     (R180-01) – dieselbe Sperre lehnt den Nachtrag ab. Ein read-only
  //     pruefender Auditor bekaeme dadurch dauerhaft einen Fehler auf einer
  //     voellig gueltigen Kette, den er gar nicht beheben kann.
  //
  // Der fehlende Anker wird gemeldet, nicht repariert. Reparieren ist Sache
  // des Notar-Schreiblaufs, dessen Identitaet in der Serversperre eine
  // Ausnahme fuer das ANLEGEN von `refs/notary/*` braucht (nicht fuers
  // Loeschen oder Ueberschreiben – sonst hebelt eine kompromittierte
  // Notar-Identitaet den Schutz aus R180-01 selbst aus).
  if (NUR_PRUEFEN || NUR_LOKAL || !PUSH) return;

  // Die n-te Aenderung am Gegenbuch entspricht Checkpoint n.
  const commits = gitStill('log', '--reverse', '--format=%H', ref, '--', LEDGER_NAME);
  const liste = commits.ok ? commits.out.split('\n').filter(Boolean) : [];
  let nachgezogen = 0;
  const nachgetragen = new Set();
  for (const n of fehlendeAnker) {
    const sha = liste[n - 1];
    if (!sha) continue;
    if (gitStill('push', REMOTE, `${sha}:refs/notary/seq-${n}`).ok) {
      nachgezogen++;
      nachgetragen.add(n);
    }
  }
  if (nachgezogen) console.log(`Fehlende Anker nachgetragen: ${nachgezogen}`);
  fehlendeAnker = fehlendeAnker.filter((n) => !nachgetragen.has(n));
  if (fehlendeAnker.length) {
    console.error(
      `${fehlendeAnker.length} Anker-Ref(s) fehlen und ließen sich nicht nachtragen: ` +
      `${fehlendeAnker.join(', ')}\n` +
      'Ohne sie ist der beglaubigte Höchststand nicht aus dem Server rekonstruierbar –\n' +
      'genau die Eigenschaft, auf der dieser Schutz beruht.\n' +
      'Wahrscheinlichste Ursache: Die Serversperre für refs/notary/* lässt auch die\n' +
      'Notar-Identität nicht anlegen. Sie braucht dort eine Ausnahme zum ANLEGEN –\n' +
      'ausdrücklich nicht zum Löschen oder Überschreiben.',
    );
    process.exit(5);
  }
}

// ---------------------------------------------------------------------------
// 2) Signaturen der GESAMTEN Historie pruefen – das war der fehlende Leser.
// ---------------------------------------------------------------------------
const commits = gitStill('log', '--format=%H %G?', ref);
if (!commits.ok) alarm(`Historie nicht lesbar: ${commits.out}`);
const zeilen = commits.out.split('\n').filter((z) => z.trim());
if (SIGN) {
  // NUR `G` (Pentest R176-01). Bei SSH-Signaturen bedeutet `U` woertlich
    // „gute Signatur, aber kein passender Principal“ – also: der Schluessel
    // steht NICHT in `allowed_signers`. Wer `U` akzeptiert, akzeptiert jeden
    // selbst erzeugten Schluessel und macht damit genau den Vertrauensanker
    // wirkungslos, den er zu pruefen glaubt. Live gezeigt: mit einem fremden
    // Schluessel re-signiertes, force-gepushtes Gegenbuch lief als „gültig“
    // durch.
    //
    // Hinweis fuer GPG statt SSH: dort liefert ein gueltiger, aber nicht
    // ownertrusted Schluessel ebenfalls `U`. Dann Ownertrust auf den
    // Notar-Schluessel setzen – NICHT `U` wieder zulassen.
    const schlecht = zeilen
    .map((z) => z.split(' '))
    .filter(([, status]) => status !== 'G');
  // Der allererste (leere) init-Commit darf unsigniert sein, sonst nichts.
  const relevant = schlecht.filter(([sha]) => {
    const dateien = gitStill('show', '--name-only', '--format=', sha);
    return dateien.ok && dateien.out.includes(LEDGER_NAME);
  });
  // Optional schaerfer: den erwarteten Signierschluessel festnageln, statt sich
  // allein auf die Bewertung von git zu verlassen.
  if (PIN) {
    const fremde = zeilen
      .map(([, ] , i) => i)
      .map((i) => zeilen[i].split(' ')[0])
      .filter((sha) => {
        const dateien = gitStill('show', '--name-only', '--format=', sha);
        if (!dateien.ok || !dateien.out.includes(LEDGER_NAME)) return false;
        const fp = gitStill('log', '-1', '--format=%GF', sha);
        return !fp.ok || fp.out.trim() !== PIN;
      });
    if (fremde.length) {
      alarm(
        `${fremde.length} Commit(s) wurden mit einem anderen als dem festgelegten ` +
        `Schlüssel signiert (erwartet: ${PIN}):\n  ` +
        fremde.map((sha) => sha.slice(0, 12)).join('\n  '),
      );
    }
  }

  if (relevant.length) {
    alarm(
      `${relevant.length} Commit(s) mit Gegenbuch-Änderungen tragen KEINE gültige Signatur:\n  ` +
      relevant.map(([sha, st]) => `${sha.slice(0, 12)} (%G?=${st})`).join('\n  ') +
      `\nDie Reihe ist damit nicht vertrauenswürdig.`,
    );
  }
}

// ---------------------------------------------------------------------------
// 3) Wahrheit aus dem signierten Baum lesen – NICHT aus der Arbeitsdatei.
// ---------------------------------------------------------------------------
const ausBaum = gitStill('show', `${ref}:${LEDGER_NAME}`);
const beglaubigterInhalt = ausBaum.ok ? ausBaum.out : '';
const arbeitsInhalt = existsSync(LEDGER) ? readFileSync(LEDGER, 'utf8').trim() : '';

if (arbeitsInhalt !== beglaubigterInhalt.trim()) {
  alarm(
    'Die lokale Gegenbuch-Datei weicht vom signierten Stand ab. Entweder wurde sie ' +
    'von Hand verändert, oder ein früherer Lauf ist zwischen Schreiben und Commit ' +
    'abgebrochen. Nichts wird angehängt.\n' +
    `  Wiederherstellen mit: git checkout ${ref} -- ${LEDGER_NAME}`,
  );
}

const bisher = beglaubigterInhalt
  .split('\n')
  .filter((z) => z.trim())
  .map((z) => JSON.parse(z));

for (let i = 0; i < bisher.length; i++) {
  if (bisher[i].seq !== i + 1) {
    alarm(`Lücke in der Checkpoint-Reihe an Position ${i + 1} (seq=${bisher[i].seq}).`);
  }
}

ankerNachziehen(bisher.length);

if (hoechsterAnker && bisher.length < hoechsterAnker.seq) {
  alarm(
    `Die Checkpoint-Reihe ist kürzer als der Server beglaubigt hat: ` +
    `verankert bis ${hoechsterAnker.seq}, vorhanden ${bisher.length}.\n` +
    `Die vorhandenen Einträge sind korrekt signiert – es fehlen welche am Ende.`,
  );
}

if (Number.isFinite(MIN_SEQ) && bisher.length < MIN_SEQ) {
  alarm(
    `Die Checkpoint-Reihe ist kürzer als bekannt: erwartet mindestens ${MIN_SEQ}, ` +
    `vorhanden ${bisher.length}.\n` +
    `Die vorhandenen Einträge sind zwar korrekt signiert – aber es fehlen welche am Ende. ` +
    `Das ist das Bild eines Rewinds per Force-Push.`,
  );
}

// ---------------------------------------------------------------------------
// 4) Abgleich mit dem CRM, VOR dem Anhaengen.
// ---------------------------------------------------------------------------
const letzter = bisher[bisher.length - 1];
if (!CRM_TOKEN) await anmelden();
const aktuell = await hole('/api/audit-logs/checkpoint');

// Die Vollpruefung wird IMMER geholt, auch beim allerersten Lauf: Aus ihr
// stammt die Zahl der protokollierten Neuberechnungen, und die gehoert schon
// in den ersten Eintrag - sonst gaebe es beim zweiten Lauf keine Grundlage,
// gegen die sich ein neuer Rehash abheben koennte.
const pruefung = await hole('/api/audit-logs/verify', 'POST');
const rehashListe = Array.isArray(pruefung?.rehashes) ? pruefung.rehashes : null;
const rehashAnzahl = rehashListe ? rehashListe.length : null;
const rehashLetzte = rehashListe && rehashListe.length
  ? rehashListe[rehashListe.length - 1].id
  : null;

if (letzter) {
  if (aktuell.maxId !== null && aktuell.maxId < letzter.maxId) {
    alarm(
      `Das Audit-Log ist geschrumpft. Beglaubigt war maxId=${letzter.maxId}, ` +
      `jetzt maxId=${aktuell.maxId}. Es wurden Einträge entfernt.`,
    );
  }
  // `valid: false` wird HART gewertet, unabhaengig von der erklaerenden Prosa
  // (Pentest R183-02). Ein Cleanup mit abgesenkter Aufbewahrung kann tausende
  // Eintraege endgueltig loeschen und die Luecken per Tombstone als „erklaert“
  // ausweisen – die Meldung liest sich dann harmlos. Fuer das Gegenbuch zaehlt
  // das Feld, nicht der Satz.
  if (pruefung && pruefung.valid === false) {
    alarm(
      'Die Prüfung im CRM meldet die Kette als NICHT unversehrt (valid: false).\n' +
      `  CRM-Text: ${pruefung.message}\n` +
      'Auch wenn dieser Text harmlos klingt: Es wird nichts beglaubigt, solange die\n' +
      'Prüfung nicht sauber ist. Häufigste Ursache ist ein Cleanup mit verkürzter\n' +
      'Aufbewahrung – dann wurden Einträge endgültig gelöscht.',
    );
  }

  const rueck = await hole(`/api/audit-logs/checkpoint?atId=${letzter.maxId}`);
  // Ein fehlender beglaubigter Eintrag ist IMMER ein Befund - auch wenn eine
  // Neuberechnung bestaetigt wurde. Bestaetigt wird der Rehash, nicht das
  // Verschwinden von Zeilen.
  if (rueck.atHash === null) alarm(`Der beglaubigte Eintrag ${letzter.maxId} existiert nicht mehr.`);

  // ---------------------------------------------------------------
  // Neuberechnung der Kette (Rehash) als EIGENE Alarmbedingung.
  //
  // Ein Rehash wurde bisher nur als Nebenwirkung gefangen: Er aendert jeden
  // Hash, also stimmt der beglaubigte Kettenkopf nicht mehr und der Vergleich
  // weiter oben schlaegt an. Das funktioniert - aber es ist ein Zufallstreffer
  // und keine gebaute Warnung. Verschoebe sich der Anker irgendwann (anderer
  // beglaubigter Wert, anderer Vergleich), waere der Melder lautlos weg.
  //
  // Deshalb haengt der Alarm jetzt an der Waffe selbst, nicht an ihrer Spur -
  // dieselbe Lehre wie R184-01 (Gate am Ausloeser statt an der Wirkung) und
  // R185-01 (Wurzelwechsel statt `valid`).
  //
  // Das Restrisiko, das dieser Melder abdeckt: `cleanup` + `rehash` OHNE
  // erneutes Siegeln, ueber Inhalt, der gar nicht versiegelt ist. Dort greift
  // weder das Bestandssiegel noch der Wurzelwechsel.
  if (rehashAnzahl !== null && typeof letzter.rehashCount === 'number') {
    if (rehashAnzahl > letzter.rehashCount) {
      const neue = rehashAnzahl - letzter.rehashCount;
      if (REHASH_ACK && String(rehashLetzte) === REHASH_ACK) {
        rehashBestaetigt = true;
        console.log(
          `Neuberechnung bestätigt (NOTARY_REHASH_ACK=${REHASH_ACK}). ` +
          'NOTARY_REHASH_ACK danach wieder leeren.',
        );
      } else {
        const l = rehashListe[rehashListe.length - 1];
        const vb = l.vorbefund;
        alarm(
          `Die Hash-Kette wurde neu berechnet (${neue} neue${neue === 1 ? 'r' : ''} Vorgang` +
          `${neue === 1 ? '' : 'e'} seit der letzten Beglaubigung).\n` +
          `  zuletzt: ${l.zeitpunkt} (Eintrag ${l.id}, ${l.neuBerechnet} Zeilen)\n` +
          (vb
            ? `  Befund unmittelbar davor: ${vb.manipuliert} beanstandet, ${vb.luecken} Lücken\n`
            : '  Der Zustand davor ist nicht mehr feststellbar.\n') +
          (l.signiert ? '' : '  ACHTUNG: Dieser Rehash-Eintrag trägt keine gültige Signatur.\n') +
          'Ein Rehash verknüpft alle Einträge neu. Lücken, die vorher eine Löschung\n' +
          'sichtbar gemacht hätten, verschwinden dabei aus der Kette – die Prüfung im CRM\n' +
          'meldet danach wieder „lückenlos". Genau deshalb ist das hier ein Alarm und\n' +
          'kein Hinweis.\n' +
          'War es geplant, bestätigen und danach wieder leeren:\n' +
          `  NOTARY_REHASH_ACK=${rehashLetzte}`,
        );
      }
    }
  } else if (rehashAnzahl !== null && typeof letzter.rehashCount !== 'number') {
    // Gegenbuch aus der Zeit vor diesem Melder: Es gibt keine Grundlage, gegen
    // die sich etwas abheben koennte. Stillschweigend zu alarmieren waere ein
    // Fehlalarm bei jedem Bestandsbuch; stillschweigend zu uebernehmen waere
    // eine unsichtbare Annahme. Also: uebernehmen und es sagen.
    console.log(
      `Grundlage für die Rehash-Überwachung wird gesetzt: ${rehashAnzahl} protokollierte ` +
      'Neuberechnung(en) im CRM. Ab dem nächsten Lauf fällt jede weitere auf.',
    );
  }


  // Der Kopf-Hash-Vergleich steht bewusst NACH der Rehash-Pruefung.
  //
  // Zwei Gruende: Erstens benennt der Rehash-Alarm die Ursache, waehrend
  // „Eintrag wurde veraendert“ nur die Wirkung beschreibt - bei gleicher Lage
  // ist die praezisere Meldung die nuetzlichere. Zweitens aendert eine
  // Neuberechnung ZWANGSLAEUFIG jeden Hash; stuende dieser Vergleich davor
  // oder ungeschuetzt dahinter, liesse sich eine bestaetigte Neuberechnung nie
  // aufloesen und die Bestaetigung waere wertlos.
  if (rueck.atHash !== letzter.chainHead && !rehashBestaetigt) {
    alarm(
      `Der Eintrag ${letzter.maxId} wurde nachträglich verändert.\n` +
      `  beglaubigt: ${letzter.chainHead}\n  jetzt     : ${rueck.atHash}`,
    );
  }
  if (letzter.sealRoot && !aktuell.sealRoot) {
    alarm('Das Bestandssiegel ist verschwunden (vorher beglaubigt vorhanden).');
  }
  if (letzter.sealLeafCount > 0 && aktuell.sealLeafCount === 0) {
    alarm('Die Blattwerte des Bestandssiegels wurden entfernt.');
  }
  // Wechsel der Siegelwurzel ist ein ALARM, kein Hinweis (Pentest R185-01).
  //
  // Vorher stand hier ein console.warn und der Rueckgabecode blieb 0 – also
  // genau das Muster, das wir dem CRM zweimal angekreidet haben: die Warnung
  // steht in der Prosa, die Maschine meldet „in Ordnung“. Das ist hier
  // besonders teuer, weil `valid` ein ERSETZENDES Siegel per Konstruktion
  // ueberlebt: Wer eine Altzeile per Datenbankzugriff entfernt und danach neu
  // siegelt, bekommt eine passende Wurzel und eine beglaubigte Luecke. Die
  // Vollpruefung sagt dann `true`. Der Wurzelwechsel ist der EINZIGE
  // maschinell erkennbare Anker dagegen – und der gehoert in den Alarm.
  //
  // Ein legitimes Neu-Siegeln loest hier einmal aus. Das ist gewollt: ein
  // Austausch der Beweisgrundlage soll einmal wehtun und bestaetigt werden,
  // statt lautlos durchzulaufen.
  if (letzter.sealRoot && aktuell.sealRoot && letzter.sealRoot !== aktuell.sealRoot) {
    if (siegelBestaetigt(aktuell.sealRoot)) {
      console.log(
        `Siegelwechsel bestätigt (NOTARY_SEAL_ACK): ${letzter.sealRoot.slice(0, 16)}… → ` +
        `${aktuell.sealRoot.slice(0, 16)}…. Die neue Wurzel wird beglaubigt.\n` +
        '  NOTARY_SEAL_ACK danach wieder leeren.',
      );
    } else alarm(
      'Das Bestandssiegel wurde ERSETZT – die beglaubigte Grundlage ist eine andere.\n' +
      `  beglaubigt: ${letzter.sealRoot.slice(0, 16)}… (${letzter.sealLeafCount} Blätter)\n` +
      `  jetzt     : ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
      'Ein erneutes Siegeln schreibt den AKTUELLEN Stand des Altbestands fest. Wurden\n' +
      'vorher Einträge entfernt, sind deren Lücken danach beglaubigt und die Prüfung im\n' +
      'CRM meldet wieder valid:true – dieser Wurzelwechsel ist die einzige Spur davon.\n' +
      'Wenn das keine geplante Maßnahme war: Im CRM das Ereignis AUDIT_SEAL_CHANGED und\n' +
      'die CRITICAL-Zeile zu /api/audit-logs/seal-backlog ansehen (wer, wann, Vorbefund).\n' +
      'War es geplant, den Wechsel bestätigen und danach wieder leeren:\n' +
      `  NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
    );
  }
  // Erstsiegelung: vorher nichts beglaubigt, jetzt eine Wurzel. Das ist der
  // eine legitime Einrichtungsschritt – aber auch das Fenster, in dem ein
  // beschnittener Altbestand einmalig festgeschrieben werden koennte. Deshalb
  // ebenfalls melden, mit eigenem Text statt stillschweigend zu uebernehmen.
  if (!letzter.sealRoot && aktuell.sealRoot) {
    if (siegelBestaetigt(aktuell.sealRoot)) {
      console.log(
        `Erstsiegelung bestätigt (NOTARY_SEAL_ACK): ${aktuell.sealRoot.slice(0, 16)}…. ` +
        'Die Wurzel wird beglaubigt.\n  NOTARY_SEAL_ACK danach wieder leeren.',
      );
    } else alarm(
      'Erstmals ein Bestandssiegel gesetzt – bisher war keines beglaubigt.\n' +
      `  Wurzel: ${aktuell.sealRoot.slice(0, 16)}… (${aktuell.sealLeafCount} Blätter)\n` +
      'War das dein Einrichtungsschritt, ist alles in Ordnung: Der nächste Lauf läuft\n' +
      'wieder auf 0, sobald diese Wurzel im Gegenbuch steht.\n' +
      'War es das NICHT, dann hat jemand den Stand des Altbestands festgeschrieben –\n' +
      'samt aller Lücken, die zu diesem Zeitpunkt bestanden.\n' +
      'Zum Bestätigen setzen und danach wieder leeren:\n' +
      `  NOTARY_SEAL_ACK=${aktuell.sealRoot.slice(0, 32)}`,
    );
  }
}

// ---------------------------------------------------------------------------
// 5) Anhaengen – mit Ruecknahme, falls der signierte Commit scheitert.
// ---------------------------------------------------------------------------
if (NUR_PRUEFEN) {
  const sicher = abschliessendSicher(bisher.length);
  // Eigener Code fuer den fehlenden Anker (Pentest R181-02, Frage b).
  //
  // „Anker fehlt“ ist NICHT dasselbe wie „nicht feststellbar“: Die Kette ist
  // geprueft und gueltig, nur das Substrat-Gedaechtnis ist unvollstaendig –
  // ein bekannter, benannter, durch einen Notar-Lauf reparierbarer Defekt.
  // Ihn in Code 3 zu werfen hiesse, eine konkrete Handlungsanweisung als
  // Ungewissheit zu melden; dieselbe Trennung wie bei Genesis/Adoption.
  if (fehlendeAnker.length) {
    console.log(
      `Anker unvollständig: refs/notary/seq-${fehlendeAnker.join(', seq-')} fehlt/fehlen.\n` +
      '  Die Kette selbst ist geprüft und gültig – aber der beglaubigte Höchststand ist\n' +
      '  ohne diese Refs nicht vollständig aus dem Server rekonstruierbar.\n' +
      '  Behebbar durch einen Notar-Schreiblauf; im Prüfmodus wird bewusst nichts\n' +
      '  gepusht (dieser Modus verändert nichts und braucht kein Schreibrecht).',
    );
  }
  console.log(
    `OK: ${bisher.length} Checkpoint(s) geprüft – Signaturen gültig, Reihe lückenlos, ` +
    `Arbeitsdatei deckungsgleich mit dem signierten Stand` +
    (letzter ? `, CRM stimmt mit Checkpoint ${letzter.seq} überein (maxId ${letzter.maxId}).` : '.') +
    OHNE_ANKER() + REWIND_VORBEHALT(),
  );
  beobachtungMerken();
  if (fehlendeAnker.length) process.exit(5);
  process.exit(sicher ? 0 : 3);
}

// ---------------------------------------------------------------------------
// 5) Tor VOR dem Anhaengen (Pentest R179 (b)).
//
// Der Schreiblauf erweitert nicht nur die Kette – er signiert damit zugleich
// ueber den Bestand darunter. Ist die Basis nicht feststellbar, waere das
// Anhaengen selbst das Waschmittel: eine frische Signatur ueber einen
// ungeklaerten Vorzustand beglaubigt alles darunter mit.
//
// Ausnahme ist der allererste Lauf: Was man nie gesehen hat, kann man nicht
// verifizieren. Diese Basislinie wird gesetzt, aber ehrlich als solche
// gemeldet – nicht als gruenes Licht.
// ---------------------------------------------------------------------------
const basislinie = !letzteBeobachtung;
if (!abschliessendSicher(bisher.length) && !basislinie) {
  console.error(
    'Die Grundlage für einen neuen Checkpoint ist nicht abschließend feststellbar –\n' +
    'es wird nichts angehängt. Ein Checkpoint über einen ungeklärten Vorzustand würde\n' +
    'diesen mitbeglaubigen.' + REWIND_VORBEHALT(),
  );
  process.exit(3);
}

const eintrag = {
  seq: bisher.length + 1,
  ts: aktuell.ts,
  maxId: aktuell.maxId,
  chainHead: aktuell.chainHead,
  sealRoot: aktuell.sealRoot,
  sealLeafCount: aktuell.sealLeafCount,
  // Grundlage fuer die Rehash-Ueberwachung des naechsten Laufs.
  rehashCount: rehashAnzahl,
  rehashLast: rehashLetzte,
};
const neuerInhalt = [...bisher, eintrag].map((e) => JSON.stringify(e)).join('\n') + '\n';

const zuruecknehmen = () => {
  // Ohne das bliebe eine uncommittete Zeile liegen, die der naechste Lauf
  // absorbiert und damit zementiert (Pentest R175-03).
  const r = gitStill('checkout', ref, '--', LEDGER_NAME);
  if (!r.ok && !ausBaum.ok) writeFileSync(LEDGER, '');
};

writeFileSync(LEDGER, neuerInhalt);
try {
  git('add', LEDGER);
  git('commit', ...(SIGN ? ['-S'] : []), '-m',
    `Checkpoint ${eintrag.seq} – maxId ${eintrag.maxId} – ${eintrag.ts}`);
} catch (e) {
  zuruecknehmen();
  console.error(`Commit fehlgeschlagen, Gegenbuch unverändert zurückgesetzt.\n${e.stderr || e.message}`);
  process.exit(1);
}

// Signatur des frischen Commits gegenpruefen – sonst waere `-S` wieder nur
// Absichtserklaerung.
if (SIGN) {
  const st = git('log', '-1', '--format=%G?', 'HEAD');
  if (st !== 'G') {
    git('reset', '--hard', 'HEAD~1');
    alarm(`Der neue Commit trägt keine gültige Signatur (%G?=${st}). Zurückgerollt.`);
  }
  // Auch der FRISCHE Commit muss vom festgelegten Schluessel stammen
  // (Pentest R177-02). Vorher wurde hier nur `%G?` geprueft: Ein Notar-Host
  // mit falsch konfiguriertem Schluessel meldete „beglaubigt“ und pushte –
  // und ab da war die Kette bei jeder Folgepruefung dauerhaft rot, behebbar
  // nur per Force-Push, den die Branch-Protection gerade verhindern soll.
  // Deshalb: pruefen und zuruecknehmen, BEVOR gepusht wird.
  if (PIN) {
    const fp = git('log', '-1', '--format=%GF', 'HEAD').trim();
    if (fp !== PIN) {
      git('reset', '--hard', 'HEAD~1');
      alarm(
        `Der neue Commit wurde mit einem anderen Schlüssel signiert als festgelegt.\n` +
        `  erwartet: ${PIN}\n  verwendet: ${fp}\n` +
        `Zurückgerollt, nichts gepusht – bitte user.signingkey auf dem Notar-Rechner prüfen.`,
      );
    }
  }
}
if (!SIGN) console.warn('WARNUNG: Commit wurde NICHT signiert – das Gegenbuch ist ungeschützt.');

let verankert = false;
if (PUSH && !NUR_LOKAL) {
  const p = gitStill('push', REMOTE, zweig);
  if (!p.ok) {
    // NICHT exit 0 und nicht exit 1: Der Checkpoint wurde zwar erstellt, ist
    // aber nicht verankert. „Mechanisch erweitert“ ist nicht „nachweislich
    // verankert“ – diese Verwechslung waere der Waschgang (R179 (b)).
    console.error(
      `Checkpoint ${eintrag.seq} wurde erstellt, aber NICHT verankert – der Push ist ` +
      `fehlgeschlagen.\n` +
      `Er liegt nur lokal und zählt nicht als beglaubigt. Der nächste Lauf wird ihn als ` +
      `"nie gepusht" melden, bis er verankert ist.\n  git-Meldung: ${p.out}`,
    );
    process.exit(3);
  }
  // Eigener, unveraenderlicher Ref je Checkpoint. Er ueberlebt einen
  // Force-Push auf den Zweig und macht das Gedaechtnis wiederherstellbar,
  // statt es an diesen Rechner zu binden.
  const ankerRef = `refs/notary/seq-${eintrag.seq}`;
  const a = gitStill('push', REMOTE, `HEAD:${ankerRef}`);
  if (!a.ok) {
    // Kein blosser Hinweis mehr (R180-02): Der Anker IST die tragende
    // Eigenschaft. Fehlt er, ist der Checkpoint zwar im Zweig, aber der
    // Hoechststand nicht mehr aus dem Server rekonstruierbar – und der
    // naechste Auditor saehe einen um eins zu niedrigen Stand als vollstaendig.
    console.error(
      `Checkpoint ${eintrag.seq} ist gepusht, aber der Anker ${ankerRef} konnte NICHT ` +
      `gesetzt werden.\n` +
      `Damit ist der beglaubigte Höchststand nicht mehr aus dem Server rekonstruierbar. ` +
      `Der nächste Lauf versucht, ihn nachzutragen – bis dahin gilt der Stand als nicht ` +
      `vollständig verankert.\n  git-Meldung: ${a.out}`,
    );
    process.exit(3);
  }
  verankert = true;
  beobachtungMerken();
} else if (NUR_LOKAL) {
  beobachtungMerken();
}

const basisHinweis = basislinie
  ? '\n  Erste Beobachtung dieses Gegenbuchs – ab jetzt fällt ein Zurückspulen auf. ' +
    'Für diesen Lauf selbst gibt es noch nichts zu vergleichen.'
  : '';

console.log(
  `${verankert || NUR_LOKAL ? 'OK' : 'HINWEIS'}: Checkpoint ${eintrag.seq} ` +
  `${verankert ? 'beglaubigt' : 'erstellt'} (maxId ${eintrag.maxId}).` +
  OHNE_ANKER() + REWIND_VORBEHALT() + basisHinweis +
  `\n  Zuletzt bekannte Nummer für die nächste Prüfung: NOTARY_MIN_SEQ=${eintrag.seq}`,
);
process.exit(!basislinie && abschliessendSicher(eintrag.seq) ? 0 : 3);
