# Changelog

## 2026-08-15 – QAF-V2 Loop-10-Rest Schritt 2: Form- und Kennzahlen-Tabelle auf QafDataTable

- `FormSection` und `MetricsSection` (zusammenfassung-sections.tsx) auf den
  gemeinsamen Renderer migriert — inkl. Explain-Panel-Toggle in der
  Kennzahl-Zelle und Status-Pill als Spalten-Config. Verbleibende
  Hand-Tabellen: nur noch A1-Deltas (Sonderspalten, eigener Folge-PR)
  und die V2-Coverage-Tabelle.
- `QafDataTable` additiv um `rowClassName` erweitert (Summen-Hervorhebung
  der Form-total-Zeilen); Leerstring fällt wie bei `cellClassName` sauber
  weg — kein trailing space (bewusste Verbesserung ggü. dem alten
  Template-Literal, im Test exakt gepinnt).
- Mutations-Probe (rowClassName-Verdrahtung gekappt) → exakt der neue
  tr-Klassen-Test rot, byte-identisch revertiert.

## 2026-08-15 – QAF-V2 Loop-10-Rest Schritt 1: gemeinsamer Tabellen-Renderer (QafDataTable)

- Bestandsanalyse des Rest-Blocks „Table/Filter/Evidence-Engines ·
  Virtualisierung · URL-State" (`docs/qaf-v2/loop10-rest-engines-analyse.md`):
  der Block war nur eine Stichwort-Liste; die einzige echte Engine
  (`table-specs.ts`, 18 Tests) war nur an die V2-Seite verdrahtet, die
  Klassik-Module hatten 8 unabhängige Hand-`<table>`-Kopien desselben
  Klassen-Skeletts.
- Neuer gemeinsamer Renderer
  `components/qaf-differences/detail-sections/qaf-data-table.tsx`
  (`QafDataTable`, Spalten-Config, Server-Komponente): reproduziert das
  Handmuster byte-gleich (Klassen-Strings im Test exakt gepinnt). Bewusst
  NUR Rendering — keine Filter-/Sortier-UI, keine Virtualisierung, keine
  zweite Engine neben `table-specs.ts`.
- Migriert: `OneTimeSection`, `ProductionSection`, `AnomaliesSection`
  (die drei risikoarmen Tabellen). Bewusst vertagt auf Folge-PRs:
  A1-Deltas (Explain-Panel/Provenance-Sonderspalten), Form/Metrics,
  Coverage-Tabelle.
- Tests: 3 Renderer-Tests (exakte Klassen-/Spalten-Pins, title/Zusatzklassen,
  leeres tbody) + 3 AnomaliesSection-Tests (laborRate-Formatweiche mit
  Gegenprobe, INCONSISTENT-Pill-Platzierung, UnavailableNotice-Bestand).
  Mutations-Probe (laborRate-Weiche in der ALT-Spalte gekippt) → exakt der
  Formatweiche-Test rot, byte-identisch revertiert.
- Offene Scope-Fragen (Filter-Dimensionen/Seiten, Virtualisierungs-Dependency,
  Evidence-Panel-Datenmodell) als 3 konkrete PO-Fragen an Kais (TG 10233) —
  dokumentiert in der Analyse, Abschnitt 6.2.

## 2026-08-15 – QAF-V2 Hebel-Belege: LeversSection liest buildLeversRun (letzter Schale-Rechenrest abgelöst)

- Neuer purer Builder `lib/qaf-differences/internal/levers-run.ts`
  (`buildLeversRun`): konsumiert `manufacturingRun.deltas.rows` direkt und
  reichert jeden Hebel um seine A1-Zellbelege an (`altCell`/`neuCell` aus den
  Prozessschritt-Quellzellen, KAR-886/KAR-911). Die Hebel-Fachregel
  (4-KPI-Katalog, 5-%-Schwelle, Forderungstexte) bleibt unverändert in
  `levers.ts` und wird nur aufgerufen.
- Beleg-Form bewusst `altCell`/`neuCell` statt formaler `SourceRef` mit
  leeren `cells` (Doktrin aus Modul 3/4: keine Beleg-Attrappen; U-02 im
  Wortsinn — die Zelladresse ist der Verweis, fehlende Belege bleiben
  ehrlich `null`). `viewmodel_json` 2.2.0 (additiv).
- Schale `qaf-comparison-detail.tsx`: kompletter Hebel-Ableitungs-Block
  (Label-/Unsicherheits-Maps + `leverInput`/`buildLevers`) und die
  `diffs`-Prop entfallen — die Schale rechnet keine Domainwerte mehr
  (Review-#505-M2 vollständig abgeräumt). `LeversSection` zeigt den Beleg
  additiv als Tooltip an den ALT/NEU-Werten.
- Paritäts-Kante gepinnt: Hebel ohne `step_match_id` bleiben `uncertain`
  (Schale-Semantik), obwohl die A1-Zeilen-Specs dieselben Zeilen als nicht
  unsicher führen — Rekonstruktion aus `diff.step_match_id` statt
  Durchreiche von `row.uncertain`, mit beidseitigem Test.
- Tests: 10 neue Builder-Fälle (`levers-run.test.ts`, echte Kette über
  `buildManufacturingRun`, inkl. Äquivalenz-Pin gegen den alten Schale-Weg,
  ID- statt Label-Join bei gleichnamigen Steps, inklusiver 5-%-Schwelle)
  + Parity-Smoke erweitert (Hebel M-01, Forderungstext, Beleg-Tooltip mit
  ehrlichem ALT-„—", Hebel-Fußnote, Gegenprobe). Mutations-Probe
  (ALT/NEU-Beleg vertauscht) → exakt die Beleg-Assertions rot (2 Builder-
  Tests + Parity-Tooltip), byte-identisch revertiert.
- Doku: `view-spec-adoption-analyse.md` + `program-status.md` korrigieren
  die eigene Planungs-Notiz („mfg-Katalog-Verknüpfung als Modul-3-
  Vorarbeit"): die nutzbare Zell-Provenienz kam mit Modul 4/#505, nicht
  aus #504.

## 2026-08-14 – QAF-V2 Loop 10: View-Spec-Adoption Modul 5 — Zusammenfassung (Formular/Buckets/Brücke/Kennzahlen/Treiber/Hochrechnung)

- Neuer purer Builder `lib/qaf-differences/internal/summary-run.ts`
  (`buildSummaryRun`): alle Facts-Zählungen (vorher doppelt per .some+.filter
  im Renderer), Sichtbarkeits-Flags, die Kennzahlen-Richtung (bewusst NUR aus
  deltaAbsolute — Bestands-Semantik gepinnt, kein diffDirection-Fallback),
  Status-Labels, Explain-Attribute und der komplette Treiber/Movers-Aufbau
  (inkl. Label-/Unsicherheits-Auflösung) wandern an die Build-Zeit. Die
  Engine-Funktionen (summary-view.ts, movers.ts) werden aufgerufen, nicht
  neu geschrieben; die Charts konsumieren unverändert BridgeData/
  BucketDatum[]/Movers (kein Chart-Umbau — eigene Etappe, im Builder-Kopf
  begründet).
- Schale (`qaf-comparison-detail.tsx`) verliert alle Summary-Ableitungen
  (kpis/formLines/buckets/bridge/metricsTable/summaryLines/negotiation/
  oneTimeRows/movers/ruleFindingFor) und die Props `summaryDiffs`,
  `plausibility`, `engineVersion`, `baseline-/comparisonTemplateProfile`
  (keine Konsumenten mehr — Restkonsumenten gegrept). Es verbleiben NUR die
  Hebel (Massnahmen-Reiter) mit ihren Label-/Unsicherheits-Maps — deren
  Ablösung gehört zur benannten Hebel-Belege-Baustelle, Review-#505-M2 damit
  bis auf diesen einen Konsumenten abgeräumt. Export-`sections` kommt aus
  den Run-Rohformen (Shape unverändert).
- Damit ist das Loop-10-Exit-Kriterium „alle Ansichten lesen View Specs"
  für alle 5 Detail-Module erfüllt (Module 1–5: #501, #502, #504, #505 +
  dieser PR).
- Tests: 8 neue Builder-Fälle (`summary-run.test.ts`, inkl. Richtungs-
  Semantik-Pin und Movers-Fallback), Paritäts-Smoke um Modul-5-Daten+
  Gegenprobe erweitert (Kennzahlen-Quellzelle, Treiber-Fallback-Text,
  doppelter „Anstieg ▲"-Pin über beide Runs).

## 2026-08-14 – QAF-V2 Loop 10: View-Spec-Adoption Modul 4 — Fertigungskosten (§6/§7/§11/Grenzen/A1)

- Neuer purer Builder `lib/qaf-differences/internal/manufacturing-run.ts`
  (`buildManufacturingRun`): Produktionssicht-/Master-Satz-/Szenario-
  Verfügbarkeit, Facts-Zählungen, „Methodische Grenzen"-Aggregation und die
  kompletten A1-Zeilen-Ableitungen (Step-Label, Unsicherheits-Markierung,
  Zell-/Override-Provenienz, Richtung, Status-Label, Explain-Attribute)
  wandern von Schale + Render-Loop an die Build-Zeit
  (view-spec-adoption-analyse.md, Abschnitt 3). Die vier Provenienz-Maps der
  Schale (source_cells/manual_override/raw_values/match→stepIds) entfallen
  dort; Label-/Unsicherheits-Maps bleiben vorerst für leverInput/movers
  (Modul-5-Bestand, im Schale-Kommentar benannt).
- Richtungs-/Label-Semantik (`diffDirection`/`diffStatusLabel`/
  `DIFF_STATUS_LABEL`/`STRUCTURAL_STATUS`) lebt jetzt kanonisch im Builder
  (portabler Kern); `qaf-diff-status.ts` re-exportiert unter den etablierten
  Namen und behält nur die Tailwind-Klassen-Seite (`diffStatusClass`) —
  gleiches Muster wie `plausibilityBadge` → `plausibilityKindClass` (Modul 3).
  Ebenso kanonisch im Builder: `ruleFindingResolver` (Regel-Befund-Auflösung
  für Explain-Panels; die Schale konsumiert sie nur noch für die
  Kennzahlen-Tabelle) und die Zeilen-Typen (`ManufacturingDiff`/
  `ManufacturingStepRow`, `shared.tsx` re-exportiert als `Diff`/`StepRow`).
- Seite (`app/qaf-differences/[id]/page.tsx`) baut den Run server-seitig aus
  denselben Rohzeilen wie bisher und reicht ihn als `manufacturingRun`-Prop;
  die Einzel-Props `baseline-/comparisonSummaryFieldCandidates`,
  `baseline-/comparisonManufacturingParseConfidence` und
  `engineVersionParser` entfallen an der Schale (einziger Konsument war
  dieses Modul). Keine sichtbare UI-Änderung — Paritäts-Umbau.
- Tests: 13 neue Builder-Fälle (`manufacturing-run.test.ts`), Paritäts-Smoke
  um Modul-4-Daten+Gegenprobe erweitert (echter Builder, Produktionssicht-
  und A1-Zeile inkl. Status-Pfeil). Richtungs-/Label-Regeln bleiben über den
  Re-Export in `qaf-diff-status.test.ts` vollständig geprüft.

## 2026-08-14 – QAF-V2 Loop 10: View-Spec-Adoption Modul 3 — Anhang (A2–A4)

- Neuer purer Builder `lib/qaf-differences/internal/appendix-run.ts`
  (`buildAppendixRun`): Struktur-Gruppierung (new/removed/possible_rename),
  Match-Unsicherheits-Zählung, Feld-Zuordnungs-Sichtbarkeit und
  Plausibilitäts-Schweregrade/-Badges wandern von der Render-Zeit an die
  Build-Zeit — die Anhangs-Sektionen gruppieren, zählen und klassifizieren
  nicht mehr selbst (view-spec-adoption-analyse.md, Abschnitt 5).
- Zeilen-Typen (`AppendixStructureChange/StepMatch/Plausibility`) und die
  Unsicherheits-Regel (`UNCERTAIN_MATCH`/`isUncertainMatch`) leben jetzt
  kanonisch im Builder; `detail-sections/shared.tsx` re-exportiert unter den
  alten Namen. Badge-Einordnung (Blockiert/Nicht prüfbar/Schwere,
  Master-Prompt §13) als semantische `badgeKind` im Builder, Klassen-Mapping
  `plausibilityKindClass` bleibt in `qaf-diff-status.ts` (Klassennamen
  gehören nicht in den portablen Kern) — `plausibilityLabel/Class` entfernt
  (einziger Konsument war die Anhangs-Sektion; Suchnachweis im PR).
- Schreibpfade unangetastet: `QafStepMatching`/`QafFieldMappingOverride`
  erhalten weiter die Roh-Zeilen; nur die Lese-Ableitungen kommen aus dem Run.
- Tests: 17 neue Builder-Fälle (inkl. gepinnter Bestands-Kante „unbekannter
  change_type zählt, wird aber nicht gruppiert"), §13-Badge-Tests an die
  Regel-Stelle mitgezogen, Paritäts-Smoke prüft die Seite→Builder→Schale-
  Kette mit dem echten Builder (Mutations-Probe: Gruppen-Tausch im Builder
  → exakt die Facts-/Gruppierungs-Assertions rot).

## 2026-08-14 – QAF-V2: Composition Engine-Config-treu (P1 aus Review #502) + PO-Entscheidungen

- Die geteilte Komposition (`composition.ts` — V2-Seite, kanonischer
  Export, Klassik-Seite) rechnete den Summary-Lauf config-blind (immer
  heutiger Default), während die Anzeige-Werte unter der PERSISTIERTEN
  Engine-Config des Vergleichs stehen — bei einem Config-Flip wären
  Live-Provenienz und Werte auseinandergelaufen. Jetzt:
  `CompositionComparisonRef.engine_version` (Pflichtfeld),
  `buildOverviewRun` nimmt `formulaEngineEnabled`, composition resolvet
  via `resolvePersistedEngineConfig` — dieselbe Doktrin wie compare.ts.
  End-zu-End-Test (persistierter formulaEngine-Override → kein
  Formel-Befund) mutationsgeprüft (Fix entfernt → Test rot).
- Blocker-Register auf die PO-Entscheidungen vom 14.08. nachgeführt:
  B-1 (Comparison Set bleibt im Multi-QAF-Deferral) und B-7
  (KI-Anbindung erst im BMW-Pilot) sind entschieden.

## 2026-08-13 – QAF-V2 Loop 10: View-Spec-Adoption, Modul 2 (Verhandlung + Einmalzahlungen)

- Neuer purer Builder `negotiation-run.ts` (Muster overview-run): reichert
  die bestehenden Fachzeilen (`buildNegotiation`/`buildOneTimeRows`,
  unverändert) um `SourceRef`-Belege aus dem Katalog des Laufs an
  (`run.summaryRecords` + `summaryDifferenceIdsByMetricKey`) und zieht die
  Einmalzahlungs-Facts-Zeile aus der Schale in den Builder. Die ZAHLEN
  kommen aus derselben Quelle wie bisher — nur die Herkunft kommt dazu;
  die Ein-Quellen-Konsolidierung ist Modul-5-Scope (Analyse-Doc).
- `NegotiationRow`/`OneTimeRow` tragen additiv den Metrik-Schlüssel
  (`key`) — dadurch **ViewModel-JSON-Export-Profil 2.0.0 → 2.1.0**
  (additive Felder in `sections.negotiation`/`sections.oneTimePayments`).
- Anzeige: Verhandlungs-Labels und Einmalzahlungs-Positionen zeigen ihre
  Belege als Quellen-Tooltip — EIN Format über den neuen geteilten Helfer
  `sourceRefTitle` (aus dem Kachel-Renderer extrahiert, dort selbst
  genutzt). Hebel-Belege (LeversSection) folgen mit der
  mfg-Katalog-Verknüpfung (Modul-3-Vorarbeit), bewusst nicht hier.
- 5 neue Builder-Tests (Beleg-Anreicherung, sortierte Kennungen, ehrlich
  leerer Beleg, alle drei Facts-Formulierungen); Alt-Fixture in
  summary-view.test.ts additiv nachgezogen, Export-Profil-Pin-Test auf den
  bewussten 2.1.0-Bump aktualisiert.
- Review-Einarbeitung (#502): Paritäts-Smoke um negotiationRun-Daten +
  Gegenprobe erweitert (die Reviewer-Mutation „leere Facts-Verdrahtung"
  fällt jetzt um). Benannter P1-Folgepunkt in der Analyse-Doc: composition
  ist Engine-Config-blind (Live-Provenienz vs. persistierte Werte —
  Drift-Vektor, betrifft auch #501/V2/Export; eigener fokussierter Fix-PR).

## 2026-08-13 – QAF-V2 Loop 10: View-Spec-Adoption, Modul 1 (Überblick-Kacheln)

- Erster Schritt des Loop-10-Exit-Kriteriums „alle Ansichten lesen View
  Specs" (Gap-Analyse: die 5 Detail-Module lasen `view-specs.ts` bisher
  null Mal). Die 3 Überblick-Kacheln (Angebotspreis/Material/TPC) der
  Klassik-Detailseite kommen jetzt als fertige `KpiTile`-Spec-Objekte aus
  `buildOverviewRun` über die geteilte Komposition (`composition.ts` —
  dieselbe wie V2-Seite und kanonischer Export) und werden mit demselben
  Renderer wie die V2-Übersicht gezeichnet (`QafV2KpiTile`).
- **Bewusste, sichtbare Semantik-Angleichung:** die frühere
  `shared.tsx`-KpiTile leitete den Status inline ab (jede Abweichung ≠ 0
  sofort rot/grün, Prozentwert als Hauptzahl). Jetzt gelten die
  Builder-Schwellen (`statusFor`: neutral-Band bis 2 %, warning ab 2 %,
  critical ab 10 %), die Kachel zeigt Wert + Delta + Anteil und nennt
  ihre Quelle (SourceRef-Tooltip) — identisch zur V2-Übersicht.
- Die alte Inline-Ableitungs-Komponente ist entfernt (Namenskollision
  KpiTile Komponente vs. Spec-Typ aufgelöst). `buildSummaryKpis` bleibt
  für das Export-`sections`-Objekt unangetastet.
- Tests: 5 neue Fälle (neutral-Band-Pin über den ECHTEN Builder
  [Mutations-Probe: alte Ohne-Band-Logik reingebaut → exakt der Pin-Test
  rot], critical-/good-Schwellen, ehrliche Abwesenheit bei leerer Liste,
  SourceRef im title); Paritäts-Smoke um Kachel-Fixture + Gegenprobe
  erweitert.
- Review-Einarbeitung: `composition.hasErrors` fließt in das
  loadError-Banner der Klassik-Seite (sonst sähe ein Query-Fehler der
  Komposition aus wie „kein Preisblock" — Silent-Failure-Härtung analog
  V2-Seite/Export). Die page-level ungetestete Verdrahtung
  (`overviewTiles`-Prop) ist als Lücke in der Gap-Analyse benannt
  (Repo-Konvention Seiten-Ebene; echtes Netz kommt mit der
  E2E-/Screenshot-Infrastruktur B-8).

## 2026-08-12 – QAF-V2 Loop 5: Operation-Lock gegen parallele Replace/Reparse

- Schließt den benannten Parallelitäts-Befund (Silent-Failure-Review 11.08.):
  `replaceComparisonFile` patchte die Datei-Zeiger last-write-wins — zwei
  gleichzeitige Replace/Reparse-Aufrufe derselben Rolle konnten Zeiger und
  abgeleitete Zeilen verschränken.
- Neue Migration `supabase-migration-qaf-operation-lock.sql` (#121, + Rollback):
  Lock-Tabelle `qaf_operation_lock` mit PK `(comparison_id, role)` als
  Mutex-Primitiv, RLS nach dem #115-Muster (wörtlich wie #119). Warum kein
  echter Advisory-Lock: `docs/qaf-v2/replace-parallelitaet-analyse.md`.
- `file-replace-actions.ts`: nicht-exportierte Helfer `acquireOperationLock`
  (PK-Konflikt-Erwerb, TTL-Takeover-CAS 15 min, fail-closed bei
  Infrastrukturfehlern) und `releaseOperationLock` (nur eigenes Lock,
  Release im `finally` über Happy-Path, Guard-Abbrüche und Würfe).
- **Ehrlicher Degrade ohne Apply-Reihenfolge-Pflicht:** fehlt die Tabelle
  (42P01/PGRST205), läuft der Flow wie bisher ohne Lock mit `logger.warn` —
  der Lock wird mit dem Apply wirksam (Blocker-Register B-10,
  deployment_only_blocker).
- 6 neue Testfälle (Konflikt-Abbruch ohne Ingest, Stale-Takeover,
  Release-mit-eigener-opId, Release trotz Guard-Abbruch [mutationsgeprüft:
  Release aus dem finally verschoben → Test rot], Degrade, Release-Fehler
  nur geloggt). MIGRATIONS.md #121, Blocker-Register, program-status
  nachgeführt.

## 2026-08-12 – QAF-V2 Loop 5: compensate-Pfad-Test für replaceComparisonFile

- Schließt die in `program-status.md` benannte Testlücke („replaceComparisonFile
  hat repo-weit keinen eigenen Test; compensate-Pfad ungetestet"):
  `app/qaf-differences/__tests__/replace-comparison-file.test.ts` (8 Fälle,
  ingest-core + recompareComparison gemockt, Mock-Strategie wie der
  Reparse-Wrapper-Test).
- Bewiesen wird: compensate entfernt exakt die eingefügte `qaf_file`-Zeile
  plus das Upload-Objekt (wrong-kind, Sachnummer-Mismatch, Ingest-Throw);
  ein recompare-Fehler rollt die Zeiger auf den FRISCH gelesenen Stand
  zurück, BEVOR compensate läuft (Reihenfolge per Mutations-Probe
  verifiziert); ein fehlschlagendes Zeilen-Delete wird geloggt und das
  Storage-Cleanup läuft trotzdem; der Happy-Path berührt compensate nicht
  (Gegenprobe) und stempelt Audit-Default `replace_file` bzw. benanntes
  `reparse_file`.
- Kein Produktionscode geändert.

## 2026-08-12 – QAF-V2 Loop 10 (Plan Schritt B): Replace/Reparse-Familie ausgelagert

`file-replace-actions.ts` trägt jetzt `replaceComparisonFile` +
`reparseComparisonFile` (KAR-845 Teil 2 + Loop 5 Teil 2, 370 Zeilen) —
wortgleich verschoben, JSDoc/Kommentarköpfe MIT den Funktionen (Naht-Lehren
#494/#496). Ingest-Semantik kommt aus `ingest-core` (Schritt A), der
Recompare bleibt in actions.ts und wird serverseitig direkt gerufen.

- Hash-Beweise: Familien-Block identisch · `recompareComparison` identisch ·
  deferred-Zweige unangetastet (Diff-Treffer nur die zwei Kommentarzeilen
  des gewanderten Blocks selbst).
- `sanitizeStorageName` (geteilt von Upload-Targets und Replace) wohnt jetzt
  in der geteilten `storage-path-shape.ts`.
- actions.ts: 2747 → 2373 Zeilen (Start der Nacht: 5195). Verbleibend dort:
  Upload-Targets/Batch-Analyze, Recompare/Varianten, deferred-Exporte,
  QafExportDownload-Typ.

## 2026-08-12 – QAF-V2 Loop 10 (actions.ts, Schnitt 5 / Plan Schritt A): Ingest-Kern extrahiert

`ingest-core.ts` trägt jetzt `ingestQafUpload` samt kompletter
Nur-Ingest-Hülle (IngestResult, approxManufacturingHeaderRow,
partNumberFromFilename, PARSER_VERSION — zusammen 1406 Zeilen), wortgleich
verschoben und hash-identisch bewiesen (einzige gewollte Abweichung: zwei
export-Präfixe, dokumentiert im Modulkopf). **Bewusst KEINE
'use server'-Datei** — der Kern ist kein RPC-Endpunkt, beide Aufrufer
(analyzeQafBatchFromStorage, replaceComparisonFile) reichen ihre Clients
als Parameter herein.

- Der Helfer-Graph wurde vor dem Schnitt vollständig kartiert: alle vier
  Symbole haben null Nutzungen außerhalb des Ingests (gemessen, nicht
  vermutet); Treffer in Kommentaren wurden als solche erkannt.
- actions.ts: 4224 → 2747 Zeilen (Start der Nacht: 5195). Damit ist
  Plan-Schritt A abgeschlossen; Schritt B (Replace/Reparse) folgt als
  eigener PR.
- Vollsuite inkl. Realdatei-Läufe grün (7302 passed / 0 failed).

## 2026-08-12 – QAF-V2 Loop 10 (actions.ts, Schnitt 4): Summary-XLSX-Export ausgelagert

`xlsx-export-actions.ts` trägt jetzt `exportQafComparisonXlsx` (den
Summary-Export) — wortgleich verschoben, Importer umgestellt. Die drei
deferred-Export-Varianten (G60/Multi-QAF/VvS) bleiben unangetastet in
`actions.ts` (`deferred_by_product_owner`: nicht refactoren) — nur der von
allen vieren geteilte Dateinamens-Sanitizer wohnt jetzt in
`export-filename.ts` (Nicht-Action-Datei, eine Quelle; die Funktionskörper
der deferred-Exporte sind byte-unverändert, diff-verifiziert).
actions.ts: 4368 → 4224 Zeilen (Start der Nacht: 5195).

**Loop 11 Rest (analysiert, bewusst nicht gebaut):** ein statischer
Sentence-Case-Check für Überschriften hätte im Deutschen (großgeschriebene
Substantive) eine hohe Falsch-Positiv-Rate und bräuchte eine gepflegte
Allowlist — das ist ein eigener, sorgfältiger Bau, kein Nebenbei-Gate.
Visuelle Regression bleibt B-8 (neue Dependency, Operator-Entscheidung).

## 2026-08-12 – QAF-V2: Legacy-Markierung (Loop 5) + actions.ts Schnitt 3 (Override-Familie)

- **Legacy-Datensätze sichtbar als eingeschränkt markiert** (Roadmap Loop 5):
  ein Stand ohne aufbewahrte Originalquelle trägt in der V2-Quellen-Sektion
  jetzt die benannte Markierung „Legacy — eingeschränkt" samt Konsequenzen
  (kein Download, kein Neu-einlesen, keine frische Zell-Provenienz) — statt
  einer Fußnote, die nur die Ursache nannte. Keine Migration.
- **actions.ts Schnitt 3** (Muster #490/#491): die Field-Mapping-Override-
  Familie (KAR-912/P4.2) wohnt jetzt in `field-mapping-override-actions.ts`
  — byte-identisch verschoben, Importer umgestellt (recompareComparison
  bleibt bewusst in actions.ts), eigene Import-Leichen entfernt.
  actions.ts: 4519 → 4368 Zeilen.

## 2026-08-12 – QAF-V2 Loop 5 (SRC-002): Beleg-Kette in den Differenz-Tabellen

Die U-08-Tabellen der V2-Seite (Fertigungs- und Material-Differenzen) zeigen
je Zeile jetzt ihre **Beleg-Kette**: Rolle (Vergabestand/Aktueller Stand) →
`Blatt!Zelle` → Formel (als title, erreichbar ohne Platzverbrauch) →
Wertzustand (leer/Fehlerwert/externer Link benannt, normale Werte ohne
Zusatz). Analyse-Doc: `docs/qaf-v2/src-002-analyse.md`.

- Die Kette existierte vollständig im Katalog (`DifferenceCell`) — sie wurde
  in `rowsFromRecords` schlicht verworfen. Der Schnitt reicht durch und
  zeigt; keine DB-Änderung, keine neue Berechnung.
- Ohne Beleg steht ehrlich „—"; die `difference_without_cells`-Zählung
  bleibt die Summenaussage (Gegenprobe getestet).
- Der Ehrlichkeits-Hinweis der Seite ist nachgezogen (er behauptete pauschal
  fehlende Zellnachweise — jetzt beschreibt er den Ist-Zustand in beide
  Richtungen).
- Bewusst offen: interaktives Evidence-Panel (V1-§17-Muster) für U-08-Zeilen
  als Ausbaustufe; DB-Lücke MFG/MAT-sheet-Spalte unverändert benannt
  (operator-applied, nicht trivial ableitbar).

## 2026-08-12 – QAF-V2 Loop 10 (actions.ts, Schnitt 2): Quelle/Kanonisch-Familie ausgelagert

`canonical-source-actions.ts` trägt jetzt die signierte Quell-URL (Loop 5,
SRC-001) und den kanonischen JSON-Export (Loop 6) — wortgleich verschoben
(diff-verifiziert), 4 Importer direkt umgestellt (inkl. ai-package-actions
und PPTX-Export, die den kanonischen Export konsumieren). actions.ts:
4772 → 4519 Zeilen.

- `qafStoragePathShape` (der Objekt-Pfad-Shape aus createQafUploadTargets)
  wohnt jetzt in `storage-path-shape.ts` — eine NICHT-'use server'-Datei,
  von Ingest/Replace UND der neuen Familie importiert. Das ist das Muster
  für die Ingest-Helfer-Extraktion, die der Replace/Reparse-Schnitt braucht.
- Die XLSX-Export-Familie bleibt bewusst unangetastet: sie enthält die
  Multi-QAF/G60/VvS-Exporte (`deferred_by_product_owner` — nicht refactoren).

## 2026-08-12 – QAF-V2 Loop 10 (actions.ts, Schnitt 1): Meta-Familie in eigene Action-Datei

`actions.ts` (5195 Zeilen, mischt laut CLAUDE.md Fachlogik und Datenzugriff
in einer 'use server'-Datei) verliert die erste Familie:
`comparison-meta-actions.ts` trägt jetzt Titel, Tags, Review-Status,
Vergleichsart-Deklaration, gemessene Zykluszeiten und die
Kalkulator-Parameter (6 Actions + lokale Kalkulator-Helfer, 4779 Zeilen
verbleiben).

- **Byte-identisch verschoben** (diff-verifiziert), Importer direkt
  umgestellt — bewusst KEINE Re-Exports aus der alten Datei: jeder
  Wert-Export einer 'use server'-Datei ist ein RPC-Endpunkt, und eine
  Zerlegung, die die RPC-Oberfläche verdoppelt, wäre keine.
- `uuidSchema` lokal dupliziert statt exportiert (gleiche Begründung).
- Muster für die nächsten Schnitte: Export-Familie, Replace/Reparse
  (braucht vorher die Ingest-Helfer-Extraktion in eine Nicht-Action-Datei),
  Ingest zuletzt.

## 2026-08-12 – QAF-V2: Export-Profil-Versionierung, Reparse im Audit-Log, Blocker-Register

Drei Reste aus den Loop-Statusblöcken:

- **Loop 12 — EIN Versionsregister für alle Exportprofile**
  (`export-profiles.ts`, 7 Profile, Semver, getestet): ViewModel-JSON trägt
  jetzt ein `export_profile`-Feld (das bestehende `version: 2` bleibt für
  Bestands-Leser), die Excel-Mappe den Stempel als Workbook-Property UND
  README-Zeile, Copilot MD/DOCX eine sichtbare Kopfzeile. Kanonisches
  JSON/PPTX/AI-Paket trugen ihre Version schon — das Register führt alle
  sieben an einer Stelle.
- **Loop 5 — Reparse ist im Audit-Log von echtem Replace unterscheidbar:**
  `replaceComparisonFile` nimmt eine benannte Absicht entgegen
  (`auditAction`), der Reparse-Wrapper stempelt `reparse_file`. Default
  unverändert, `action` ist TEXT ohne Constraint — kein Migrationsbedarf.
  Der compensate-Pfad von `replaceComparisonFile` bleibt benannt ungetestet
  (eigener Mock-Harness-Bau).
- **`blocker-register.md`**: alle offenen Blocker mit Policy-Klasse,
  Inhaber und Wirkung an EINER Stelle (B-1…B-9; kein globaler Blocker).

## 2026-08-12 – QAF-V2 Loop 13: AI-Paket mit hartem Gate und Antwort-Wächter

Die KI-Schicht bekommt ihre deterministische Grenzfläche — vor und nach dem
Modell, das selbst bewusst NICHT angebunden wird (API-Aufruf = Kosten =
Operator-Entscheidung):

- **`ai-package.ts`** (pur, 10 Tests): `buildAiPackage(kanonisches JSON)`
  leitet das AI-Paket ausschließlich aus dem kanonischen Dokument ab —
  Fakten, Differenz-Kennungen, Bereichszustände, Manifest-Auszug, dazu der
  **versionierte System-Prompt** (`AI_PROMPT_VERSION` 1.0.0: nie rechnen,
  Difference-IDs zitieren, `not_computed` heißt „nicht geprüft", candidate
  nur im Konjunktiv).
- **`safe_to_summarize` ist ein hartes Gate:** steht es auf false, entsteht
  KEIN Paket — die Verweigerung nennt die blockierenden Prüfungen
  (getestete Gegenprobe). PII-Muster (E-Mail/Telefon) im Serialisat
  verweigern ebenfalls.
- **`validateAiSummary`** prüft Modell-Antworten gegen das Paket: jede Zahl,
  die in keinem Fakt-Wert steht, wird abgelehnt (Halluzinationstest);
  Entwarnungs-Formulierungen bei `not_computed`-Bereichen ebenso;
  Kennungen (DIF-…, M-01) sind vom Zahlen-Check ausgenommen, weil der
  Prompt sie fordert. Damit ist das Roadmap-Exit „KI kann keine Zahl
  erzeugen, die nicht als Fact vorliegt" eine ausführbare Prüfung an der
  Abnahme-Grenze, keine Absicht.
- **Server-Action** `exportQafComparisonAiPackage` (eigene Datei, nicht in
  die 5195-Zeilen-actions.ts): ruft den bestehenden kanonischen Export
  (Auth, RLS, Schema-Validierung, Hashes) und transformiert pur.
  **V2-Seite:** Knopf „AI-Paket (JSON)" neben dem kanonischen Export;
  eine Verweigerung erscheint sichtbar mit Grund.

Bewusst offen (Kosten-/Produktentscheidung): der Live-Modell-Aufruf selbst
und die Anzeige einer generierten Zusammenfassung in der UI.
## 2026-08-12 – QAF-V2 Loop 15 (Beginn): Funktionsparitätsmatrix V1↔V2

`docs/qaf-v2/paritaetsmatrix-v1-v2.md` — der Pflichtstand hinter der Regel
„Keine Entfernung einer V1-Funktion ohne Paritätsnachweis" (MIG-001):

- 14 V1-Sektionen, Kopf-Aktionen und alle Exportprofile je mit belegtem
  V2-Stand (vorhanden / anders gelöst / fehlt / nur V2) — Erhebung am Code,
  nicht aus Erinnerung.
- Abschaltkriterien D1–D5 (u. a. Shadow-Mode gelaufen, Rollback getestet,
  Golden akzeptiert) — V1 wird erst read-only, wenn alle erfüllt sind.
- MIG-002-Ist-Stand dokumentiert: Koexistenz über eigene Route statt Flag,
  Rollback = Revert (alle V2-Arbeiten additiv).

Damit haben auch die zwei RTM-Anforderungen ohne Roadmap-Loop (MIG-001/
MIG-002, siehe program-status.md „Anforderungen ohne Loop") ihren Träger.
Offen am Loop: Shadow-Mode-Mechanik, Rollback-Test, Golden-Akzeptanz.

## 2026-08-12 – QAF-V2 Loop 10 (Teil 3): Detail-Ansicht in View-Module je Hauptreiter zerlegt

`qaf-comparison-detail.tsx` war mit 2178 Zeilen die größte Detailkomponente —
Loop-10-Exit „keine zentrale Detailkomponente über vereinbartem Größenlimit"
(Limit 800, `audit/loop-roadmap-aktualisiert.md`). Jetzt:

- **Kompositions-Schale** (680 Zeilen): hält die Ableitungen (ausschließlich
  lib-Funktionen, keine eigene Fachrechnung) und komponiert die Sektionen.
- **Sechs View-Module** unter `detail-sections/`, geschnitten nach den
  Loop-8-Hauptreitern: `ueberblick`, `zusammenfassung`, `fertigungskosten`,
  `massnahmen-sbm`, `anhang` + `shared` (Zeilen-Typen, de-DE-Formatter,
  Badges, Listen). Explizite Props je Sektion sind die View-Adapter-Naht.
- **JSX wurde verschoben, nicht neu geschrieben.** Parität sichert
  `qaf-detail-sections-parity.test.tsx`: jede Registry-Sektion erscheint,
  datengebundene Anhänge erscheinen mit Daten und verschwinden ohne
  (Gegenprobe), der Loop-8-Vertrag „ausblenden statt entfernen" bleibt
  (CSS-Klassen-Probe).
- Toter Bestand entfernt: `MATCH_STATUS_LABEL` hatte repo-weit keinen
  Aufrufer (Nutzungssuche vor Entfernung).
- Lint-Baseline um 3 Bestandsfehler besser (react/no-unescaped-entities der
  verschobenen Texte als Entities statt eingefroren) und per --update
  nachgezogen.

Offen bleibt am Loop 10 (unverändert benannt): `actions.ts` (5195 Zeilen),
eigene Detailsichten für Material/Logistik/Rohstoffrisiken/Zusatzmodule,
gemeinsame Table/Filter/Evidence-Engines, Virtualisierung, URL-State für
Filter. `qaf-multi-qaf-detail.tsx` bleibt `deferred_by_product_owner`.

## 2026-08-12 – QAF-V2 Loop 9: Kopf auf Entscheidung und Kontext reduziert

Die Kopfzeile der normalen QAF-Vergleichsansicht hatte acht Aktionen in einer
Reihe (Review, Kalkulator, 2× Datei ersetzen, Tauschen, Wertstrom, Excel,
Copilot) plus Titel- und Tag-Editor. Jetzt (Roadmap Loop 9):

- **Fachlicher Titel aus Daten** (`fachlicherTitelDe`, getestet): Nutzer-Titel
  vor Sachnummer vor ehrlichem "Vergleich"; Lieferant als Untertitel.
- **Paarungswarnung als Status**: dieselbe abgeleitete Vergleichbarkeits-Ampel
  wie auf der V2-Seite (`QafComparabilityChip`, aus `qaf-v2-header.tsx`
  exportiert statt dupliziert) — gleiche Datenbasis (persistierte
  Identitäts-Befunde + configVersion-Stempel), beide Köpfe können nicht
  widersprechen.
- **QAF-Dokumente im Kopf**: Vergabestand/Aktueller Stand mit Dateinamen.
- **Drei sichtbare Hauptaktionen** (`qaf-header-actions.tsx`): Prüfen
  (Review-Toggle) · Exportieren (Sheet: Excel, Copilot [profil-gated],
  Link auf kanonischen Export/PPTX der V2-Ansicht) · Weitere Aktionen
  (Sheet: Titel/Tags bearbeiten, ALT/NEU ersetzen, Tauschen, Kalkulator,
  Wertstrom-Übernahme). Die verschobenen Aktionen sind die bestehenden,
  unveränderten Bausteine — Upload-Flow, Confirm und Fehlerpfade inklusive.
- Tests: 5 Fälle Titel-Vorrangfolge + 5 Fälle Sichtbarkeit/Erreichbarkeit
  (jede verschobene Aktion bleibt erreichbar; Copilot-aus zeigt keinen toten
  Knopf; Wertstrom nur mit Quellen).
- Scope: nur der Summary-Zweig. G60-, Multi-QAF- und Varianten-Köpfe
  unverändert (`deferred_by_product_owner`).

## 2026-08-12 – docs(qaf-v2): Programmstatus auf Ist-Stand + sechs nachgetragene Loop-Reports

- `program-status.md` gegen den Repo-Stand korrigiert (Regel der Datei: „Kein
  Loop darf still verschwinden"): Loop 3 stand als „nicht begonnen", war aber
  seit 07.08. über #452–#457 gemergt; Loop 8 Teil 2 (#480), Loop 10 Teile 1+2
  (#481–#483), Loop 11 Teil 1 (#470) und die konkreten Merge-SHAs für Loop 4
  (#463/#465), 5 (#475/#476), 6 (#474/#477), 12 und 14 (#473) nachgetragen.
- Loop-Reports nachgezogen für Loop 3, 4, 5, 6, 11 (Teil 1) und 12 — jeweils
  ausdrücklich als Nachtrag markiert, nur belegte Felder (Merge-Commits,
  PR-Beschreibungen, Vorbefunde, HANDOFF); nicht Rekonstruierbares ist als
  solches benannt statt erfunden. Loop 0/1 sind über `audit/baseline/` + ADR-000
  dokumentiert, Loop 2/8/10/14 hatten bereits Reports.
- Kein Produktcode geändert.

## 2026-08-11 – QAF-V2 Loop 10 (Teil 2): Status- und Richtungslogik testbar gemacht

`qaf-comparison-detail.tsx` trägt `tdd-guard:skip` mit der Begründung
„presentational". Sechs der dort definierten Funktionen waren aber keine
Darstellung, sondern **Regeln** — und damit faktisch ungetestet:

- `diffDirection` — Vorrangfolge absolut → Prozentpunkte → Prozent
- `diffStatusClass` — färbt nach **Günstigkeit**, nicht nach roher Richtung
  (`HIGHER_IS_BETTER_FIELDS`, KAR-833). Ein Vorzeichenfehler in der Zeile
  `(dir === "up") !== higherIsBetter` färbt **jede Kostensenkung rot**.
- `diffStatusLabel` — Pfeil nur bei richtungsbehafteten Status
- `severityClass`, `plausibilityLabel`, `plausibilityClass` — „Blockiert" und
  „Nicht prüfbar" als eigene Zustände (Master-Prompt §13: eine nicht
  durchführbare Prüfung darf weder wie eine bestandene noch wie ein
  gewöhnlicher Hinweis aussehen)

Alle sechs liegen jetzt in `components/qaf-differences/qaf-diff-status.ts` mit
**21 Tests**. Bewusst nicht im portablen `lib/qaf-differences`-Kern: sie liefern
Tailwind-Klassen, sind also Präsentationsschicht — nur eben eine mit fachlichen
Regeln darin.

Verschiebung ohne Verhaltensänderung: die Funktionsrümpfe wurden gegen den
Stand auf `main` normalisiert verglichen, alle identisch. Die Signaturen nutzen
jetzt benannte Typen (`DiffDirection`, `DiffDeltaFields`,
`PlausibilityBadgeInput`) statt Inline-Literale.

Die Komponente schrumpft dadurch um 136 Zeilen (2314 → 2178).

## 2026-08-11 – QAF-V2 Loop 10 (Teil 1): Einmalzahlungs-Summe raus aus dem Client

Erster Schnitt an Loop 10 („UI-Komponenten zerlegen", Exit-Kriterium **„keine
fachliche Berechnung im Client"**). `qaf-comparison-detail.tsx` enthielt genau
eine echte Geldrechnung:

```
oneTimeRows.reduce((sum, r) => sum + (r.deltaAbsolute ?? 0), 0)
```

Sie ist als `sumOneTimeDeltas` nach `lib/qaf-differences/internal/summary-view.ts`
gezogen, neben ihren `buildOneTimeRows`-Erzeuger — und dabei **ehrlich gemacht**:

- Das `?? 0` zählte jede Position **ohne** bestimmbares Delta (nicht berechenbar,
  blockiert, nur eine Seite belegt) als Null mit. Die angezeigte Gesamtsumme las
  sich damit wie „alle Positionen zusammen", obwohl Positionen fehlten — genau
  die stille Null, die der Master-Prompt für jede quantitative Aussage verbietet.
- `sumOneTimeDeltas` summiert nur bestimmbare Deltas, zählt die übergangenen
  Positionen und liefert `total: null` statt `0`, wenn es überhaupt nichts zu
  summieren gibt. `0` wäre dort eine erfundene Aussage.
- Die Sektion zeigt jetzt „Summe nicht bestimmbar" bzw. zusätzlich
  „N Positionen ohne bestimmbares Delta, nicht in der Summe".
- `NaN`/`Infinity` gelten wie fehlend, statt die Summe zu vergiften.

6 neue Tests, darunter die Unterscheidung „echte Null-Summe" (+40/−40) von
„nichts bestimmbar".

## 2026-08-11 – QAF-V2 Loop 8: Hauptreiter in QAF-Terminologie (normale QAF)

Die Roadmap (`06_Autonomous_Loop_Roadmap_v1.0.md`, Loop 8) fordert eine primäre
Navigation in QAF-Terminologie statt einer 14-Sektionen-Langseite. Umgesetzt für
die **normale QAF-Vergleichsansicht** (`comparison_mode = summary`).

- **Hauptreiter** mit den Bereichen der Roadmap: Überblick · Zusammenfassung ·
  Material · Fertigungskosten · SBM / Vorrichtungen (FWZ) · Logistik und Zoll ·
  Rohstoffrisiken · Zusatzmodule · Qualität und Maßnahmen — plus
  **„Alle Sektionen"** als der von der Roadmap geforderten Kompatibilitätsansicht.
  Ohne `?bereich=` ist diese aktiv, das Verhalten ist damit unverändert zu vorher.
- **Nur verfügbare Reiter.** `qaf_file.g60_meta.capability_matrix` wird seit
  KAR-959/P2 beim Ingest persistiert und war bis heute **von keinem UI gelesen**
  (`coverage-view.ts` hatte nur Barrel-Export und eigenen Test). Loop 8 verdrahtet
  sie: ein Bereich erscheint, wenn eines seiner Module vorhanden, partiell,
  ableitbar oder `PARSE_FAILED` ist. `PARSE_FAILED` zählt bewusst mit — das Blatt
  ist da, es ließ sich nur nicht lesen; es zu verstecken würde den Fehler
  mitverstecken.
- **Beide Vergleichsseiten vereinigt, nicht geschnitten** (`mergeCoverage`): ein
  Modul, das nur der neue Stand trägt, ist genau das, was ein Vergleich zeigen
  soll.
- **„Nie erhoben" ≠ „nicht vorhanden".** Ohne persistierten Stand werden alle
  Bereiche angeboten und die Leiste sagt ausdrücklich, dass das keine geprüfte
  Aussage ist.
- **Deep Links** je Bereich (`?bereich=<id>`), serverseitig gegen die tatsächlich
  sichtbaren Reiter aufgelöst — ein alter Link auf einen Bereich, den diese Datei
  nicht trägt, erzwingt keinen leeren Reiter.
- **Bereiche ohne eigene Detailsicht** (Material, Logistik, Rohstoffrisiken,
  Zusatzmodule haben auf der Langseite keine eigene Sektion) sagen das selbst,
  statt leer dazustehen und wie „keine Daten" auszusehen.
- Ausgeblendete Sektionen bleiben im DOM und im Ausdruck (`hidden print:block`).

Neu: `qaf-primary-navigation.ts` (pur, 26 Tests), `qaf-primary-nav.tsx`,
`hidden`-Prop an `Section`/`PlaceholderSection` (additiv — G60, Multi-QAF und
Variant-vs-Standard rendern unverändert).

**Beinahe-Fehler, festgehalten:** Prettiers Tailwind-Plugin hat beim Formatieren
das trennende Leerzeichen vor `${hidden ? …}` entfernt (`scroll-mt-14hidden`).
Das rendert fehlerfrei und hätte schlicht nichts ausgeblendet. `qaf-section-hidden.test.tsx`
prüft die Klassen jetzt über `classList` als eigene Tokens.

## 2026-08-11 – QAF-V2: Loop 6 Etappe 3 — eine Lade+Katalog-Komposition für Seite und Export

Die V2-Seite und `exportQafCanonicalJson` luden bislang spaltenidentisch
dieselben sieben Quellen und riefen dieselben Producer — zwei Kopien
desselben Lade-Glues (im exportQafCanonicalJson-JSDoc seit Etappe 1b als
bekannte Duplizierung benannt). Jetzt:

- **`app/qaf-differences/composition.ts` (neu, Server-Code ohne
  `'use server'`):** `loadComparisonComposition` lädt die sieben Quellen
  und komponiert OverviewRun, MFG-/MAT-Kataloge, Tabellen-Namen und die
  kanonische `summary.<key>`-metric_id-Zuordnung GENAU EINMAL. Parität
  Seite↔Export ist damit strukturell erzwungen, kein nachträglicher
  Abgleich.
- **Fehlersemantiken beider Aufrufer exakt erhalten:** die Komposition
  reicht Quellen-Fehler benannt durch und lässt die Producer mit leeren
  Daten weiterlaufen — die Seite zeigt wie bisher den Ladefehler-Banner
  über leeren Sektionen (Silent-Failure-Härtung 10.08.), der Export
  bricht wie bisher bei jedem Quellen-Fehler ab.
- Bewusst NICHT in der Komposition: sha256/byte_size-Downloads
  (Export-Pflicht, bleibt on-Klick — kein Storage-Zugriff pro
  Seitenaufruf), die Seiten-Presentation-Producer (Buckets/Brücke/
  Partition/Coverage/Comparability — die kanonischen analysis_areas sind
  erst 6/24 befüllt, „Seite liest Areas" wäre heute eine Lüge) und die
  seiten-eigenen Selects (Vergleichsart-Deklaration, Evidenz-Befunde).
- Klassik-Seite und `buildViewModelExport` bleiben unberührt
  (Paritätsregel — Ablösung ist Loop-15-Territorium). Tests: 3 Fälle
  (gesunde Komposition inkl. metric_id-Konvention, benannter
  Quellen-Fehler + Producer-auf-leer, Determinismus).

## 2026-08-11 – QAF-V2: Loop 5 Teil 2 — Reparse aus der Originaldatei

„Neu einlesen (aktueller Parser)" je Stand auf der V2-Seite — SRC-001-
Abnahme „Reparse jederzeit möglich" wird real erfüllbar:

- **`reparseComparisonFile(comparisonId, role)`**: bewusst KEIN eigener
  Parse-/Persist-Pfad. Die Originaldatei wird bucket-kopiert (eigener
  UUID-Pfad) und die Kopie durch den bestehenden
  `replaceComparisonFile`-Pfad geführt — Parse vor jedem DB-Schreiben,
  Kind-/Sachnummer-Guards, Override-Carry-Forward, insert-first-Rollback
  und `refreshPlausibility:true` werden geerbt statt als zweite Wahrheit
  dupliziert.
- **Warum die Kopie sicherheitskritisch ist:** der Replace-Pfad räumt
  `upload.path` in seinen compensate-Zweigen per `storage.remove` weg —
  mit dem Original-Pfad hätte ein fehlgeschlagener Reparse die
  Originaldatei gelöscht. Die Kopie hält das Original in jedem Ausgang
  unantastbar: per Code-Trace verifiziert (copy liest das Original nur,
  jedes remove zielt auf die Kopie); der Test beweist es für den frühen
  Abbruch des inneren replace, der compensate-Fall ist durch
  Konstruktion abgedeckt (compensate entfernt ausschließlich
  `upload.path` = Kopie).
- Gates: nur Summary-Paarvergleiche (G60/Multi-QAF deferred; Server-Gate
  + gespiegeltes UI-Gate, kein toter Button), Legacy-Datei ohne Quelle →
  benannte Meldung, `qafStoragePathShape`-Prefix-Guard vor jedem
  Storage-Zugriff (Confused-Deputy-Abwehr), Register-Eintrag für
  `storage.copy`/`storage.remove`.
- Nutzen: frische Zell-Provenienz (sheet/formula/value_state) für
  Alt-Läufe → mehr kanonische Facts (Loop 6 Etappe 2a). Tests: 5
  Fehlerpfad-Fälle der Wrapper-Logik (Modus-Gate, Legacy-Quelle,
  Prefix-Gegenprobe, Kopie-Fehler, Original-nie-removed beim frühen
  replace-Abbruch); der Erfolgs-Durchstich läuft über den produktiv
  bemusterten Replace-Pfad. Benannte Befunde der Loop-Analyse und des
  Silent-Failure-Reviews (Stempel-/Zeilen-Drift, geerbtes
  Parallelitäts-Risiko, Replace-Testlücke, Audit-Ununterscheidbarkeit)
  in program-status dokumentiert; Review-Fixes eingearbeitet
  (logger-Argumentreihenfolge am Guard-Log, try/catch in Action und
  UI-Insel gegen Full-Page-Errors).

## 2026-08-11 – QAF-V2: Loop 6 Etappe 2a — Entity/Fact-Graph im kanonischen Export

Die Graph-Arrays des kanonischen Dokuments waren seit Etappe 1 bewusst leer —
jetzt trägt der Export den Teil, der heute wahr sein kann
(`lib/qaf-differences/internal/canonical-graph.ts`, pure):

- **Entities:** ein `part`-Entity je Dokument (Labels aus qaf_part:
  Sachnummer, Benennung, Variante, Lieferant) + `same_part_as`-Relationship.
- **Facts NUR aus der SUM-Schicht** — der einzigen Quelle mit persistierter
  Zell-Provenienz (sheet/formula/value_state seit der Provenance-Migration).
  `fact.source.sheet` ist Schema-Pflicht: Metrik-Seiten ohne Blattnamen
  (Bestandszeilen) werden **withheld gezählt**
  (`extensions.facts_withheld`), nie erfunden. MFG-/MAT-Facts bleiben
  Etappe 2b: die Diff-Tabellen führen keine sheet-Spalte (DB-Lücke,
  dokumentiert). `raw_value` trägt die Engine-Zahl exakt (String, kein
  Rundungsverlust); `normalized_value` einheitenbewusst nur, wo die Einheit
  aus den Differenz-Records ableitbar ist (eine Quelle, keine zweite
  Registry). value_state-Mapping intern→Schema exhaustiv Compile-erzwungen.
- **`difference.base/comparison_fact_ids`** verketten SUM-Differenzen mit
  ihren Facts; leere Verweise werden weggelassen, nicht erfunden.
- **`entity_graph_integrity`-Validation-Check:** ajv prüft nur die Form —
  `validateCanonicalGraph` prüft Querverweise (fact→entity,
  difference→fact, Relationship-Enden, sheet-Pflicht) als benannter
  pass/fail-Check im Dokument.
- mapping_groups bleiben begründet leer (Cross-Dokument-Identität grenzt an
  deferred Multi-QAF). Tests: 11 neue Fälle inkl. 3 Schema-Gegenproben
  (fehlende source, fremder data_type, eingeschmuggeltes Property),
  Withheld einseitig + beidseitig, unit-unbekannt (normalized_value fehlt
  statt geraten), Determinismus- und Referenz-Integritäts-Proben;
  Mutations-Probe am value_state-Mapping (3 rot wie erwartet,
  zurückgesetzt grün).

## 2026-08-11 – QAF-V2: Loop 14 — Wertstromintegration (V2-Einstieg + Abschluss-Nachweis)

Kernbefund der Loop-Analyse: die QAF→Wertstrom-Brücke (QVS P1–P7; P1–P5 =
KAR-970–974, Flag-on P7 = KAR-976) war bereits gebaut und ist seit
2026-07-18 flag-on produktiv —
Loop 14 war damit Verifikation statt Neubau. Dieser PR schließt die eine
echte Lücke und dokumentiert den Nachweis:

- **V2-Seite: „Als Wertstrom übernehmen" in Klassik-Parität**
  (`app/qaf-differences/[id]/v2/page.tsx`): `isQvsEligibleComparisonMode`-
  Gate (nur summary/g60 — Multi-QAF ist P4-Territorium und bleibt ohne
  Einstieg), Capability-Prüfung je Seite (ALT/NEU), `QvsEntryPointButton`
  in der Aktionszeile; nicht geeignete Seiten werden weggelassen, nie
  deaktiviert gerendert. Ohne diesen Baustein hätte die V1-Abschaltung
  (Loop 15) eine Funktion verloren.
- **comparison-Select der V2-Seite gehärtet:** echter Query-Fehler wird
  jetzt geworfen statt still als 404 zu erscheinen (Klassik-Parität; RLS
  liefert 0 Zeilen ohne Fehler — nur das ist ein 404).
- **Exit-Kriterien-Nachweis** als Loop-Report
  (`docs/qaf-v2/loop-reports/loop-14-2026-08-11.md`): alle 4 Kriterien mit
  file:line-Belegen; RTM-ID-Widerspruch (keine der 9 Loop-14-IDs zeigt auf
  die Wertstrom-Brücke, VSM-001 hängt an Loop 15) dokumentiert, nicht
  umgedeutet. `program-status.md`: Loop 14 abgeschlossen; Übersichtstabelle
  für Loop 4/5/6/8/11/12/14 an die Detailsektionen angeglichen
  (Review-Befund: die Tabelle war seit Loop 4 nicht nachgezogen).

## 2026-08-11 – QAF-V2: Loop 12 (PR 2) — PPTX-Präsentation als reine Wiedergabe-Schicht

Der PPTX-Export der V2-Seite („Präsentation (PPTX)"-Knopf) — strukturell so
gebaut, dass die Loop-12-Exit-Kriterien erzwungen sind:

- **Reine Wiedergabe-Kette:** kanonisches Dokument (bestehende
  `exportQafCanonicalJson`-Action) → `presentation_package.slides` →
  `qaf-presentation-adapter.ts` (pure: überträgt die Folien 1:1 ins
  generische ExportConfig-Format; kanonische decimal-Strings bleiben
  WÖRTLICH, fehlende Werte werden „—", nie fabrizierte Zahlen; unbekannte
  Folien-Arten erscheinen als ehrlicher Hinweis statt zu verschwinden) →
  bestehender `exportToPptx`-Renderer (lib/export/export-service.ts, im
  Repo 5-fach bemustert). **Keine Exportvariante rechnet selbst; jede
  Tabellenzeile trägt ihre Referenz-Kennung als erste Spalte.**
- Kein neuer pptxgenjs-Code, kein neuer Server-Code — die dünne Schicht
  aus dem Loop-12-Design-Memo, wortwörtlich.

## 2026-08-11 – QAF-V2: Loop 12 (PR 1) — presentation_package im kanonischen Export

Der `presentation_package`-Bereich des kanonischen Vertrags war ein leerer
Slot (state not_computed) — jetzt trägt er eine deterministische
Folien-Struktur, gebaut als pure Funktion aus den BEREITS gemappten
kanonischen Sammlungen (keine Neuberechnung — jede Zahl existiert
wortgleich in `differences[]`/`actions[]`):

- **`buildPresentationPackage`:** Überblicks-Folie (Zähler + alle
  Referenz-IDs), Top-N-Differenzen nach |Δ| (deterministischer Tiebreak
  über die Kennung), Datenqualität und Maßnahmen nur, wenn Inhalte
  existieren (Gegenprobe: nichts wird fabriziert). **Jede Folie
  referenziert Difference-/Finding-/Action-IDs** (Exit-Kriterium
  „Presentation Package referenziert Difference IDs"); ein Test beweist
  die Referenz-Integrität gegen die kanonischen Sammlungen.
- Der PPTX-Renderer folgt als PR 2 — eine dünne Schicht, die
  AUSSCHLIESSLICH diese Struktur wiedergibt („keine Exportvariante rechnet
  selbst", „alle Zahlen stimmen mit dem kanonischen JSON überein").

## 2026-08-11 – QAF-V2: Loop 11 (Teil 1) — Token-Audit als Gate + tote Font-Klasse repariert

Analyse-Befund: Das Loop-11-Exit-Kriterium „Token-Audit besteht" hatte kein
Skript, das es bestehen könnte — und genau deshalb blieben drei Klassen von
Design-Drift unentdeckt:

- **`font-display` war app-weit eine tote Klasse** (~32 Dateien, auch
  layout.tsx): nie definiert, von Tailwind verworfen — alle
  „Display"-Überschriften erbten den Body-Font. Jetzt als Utility definiert;
  Headings tragen erstmals wirklich BMW Group TN Condensed Pro (die überall
  bereits geschriebene Absicht wird wahr — sichtbare, gewollte Korrektur).
- **`check:design-tokens` (neu, in der Portability-Kette):** schlägt fehl,
  wenn eine referenzierte `--qaf-*`/`--status-*`/`--vsm-*`-Var in
  globals.css nicht existiert (der Fallback-Hex griffe immer = lose Farbe)
  oder ein `var(--x,#hex)`-Fallback vom definierten Light-Wert abweicht
  (irreführende Doku). Gegenprobe im Bau: der alte `--qaf-stand-alt`-
  Fallback-Drift wird exakt gefangen.
- **Waterfall-Richtungs-Töne** (`--qaf-bucket-increase`/`-decrease`) waren
  nie definierte Vars — der harte Fallback griff immer. Jetzt dokumentierte
  Token (Light + Dark, Werte = bisheriger Ist-Zustand; eine Farb-Änderung
  wäre ein eigener Design-Entscheid); die Waterfall-Klassen verlieren ihre
  Fallback-Hex (Klassik-Chart-Muster).
- **BMW-002:** die 8 V2-Überschriften von Uppercase auf Sentence Case
  (Eyebrows/Spaltenköpfe bleiben bewusst uppercase).
- Bewusst offen: visuelle Regression (TST-003, keine Vorarbeit) und der
  app-weite Sentence-Case-Sweep — program-status führt Loop 11 teilweise.

## 2026-08-11 – QAF-V2: Loop 6 (Etappe 1a) — kanonischer JSON-Vertrag, schema-valide

Erster implementierter Stand des kanonischen Vertrags
(`schemas/qaf-comparison-canonical-v1.0.json`, byte-identisch zur
Baseline-Vorgabe 03): Bisher existierten drei nicht-interoperable
JSON-Formen; ab jetzt gibt es genau eine, gegen das echte Schema-Dokument
validierte Zielform.

- **`canonical-export.ts` (neu, pure):** `buildCanonicalExport` erzeugt das
  21-Key-Dokument für den Standard-Paarvergleich. Maßgebliche Dezimalwerte
  als Strings (GENAU eine Formatierungsschicht — nicht Float-Rundung plus
  Cast); vollständige, Compile-erzwungene Enum-Tabellen (DifferenceKind 9 →
  difference_status 8; Capability-Status mit DERIVABLE→partial;
  Plausibilitäts-Severity; Reconciliation 3→7). `deriveAnalysisStatus`:
  complete NUR wenn alle 24 Bereiche einen zulässigen End-Zustand haben UND
  die Validierung besteht. Graph-Arrays (entities/facts/relationships/
  mapping_groups) in Etappe 1 ehrlich leer (Schema-konform); nicht gebaute
  Bereiche stehen als not_computed — kein Bereich wird still ausgelassen.
- **Validierung gegen das Original:** ajv v8 (Draft 2020-12) + ajv-formats
  als devDependencies; der Test kompiliert die echte Schema-Datei — bewusst
  KEINE Zod-Nacherzählung (das wäre die verbotene parallele Registry).
- **Dokumentierte Abweichungen** (Design-Memo): finding/action haben im
  Schema kein difference_ids-Feld → Differenz-Bezüge im Schema-eigenen
  `extensions["supplierpulse.current_engine"]`-Block; run_manifest-Policy-
  Versionen tragen vorerst alle den einen engineConfig-Stempel; RTM ordnet
  die JSN-Anforderungen Loop 8 zu, der Roadmap-Text Loop 6 (benannt, nach
  Roadmap gebaut). Nur Paar-Form — Multi-QAF bleibt deferred.
- **Etappe 1b (gleicher Tag):** `exportQafCanonicalJson` — der klickbare
  kanonische Export auf der V2-Seite. Die Differenzen kommen aus GENAU den
  puren Producern der Seite (buildOverviewRun.summaryRecords — neu
  exponiert — plus differencesFromManufacturing/-Material via buildCatalog);
  sha256/byte_size beider Originale werden zur Exportzeit aus dem Storage
  gelesen (Admin-Download NUR nach dem qafStoragePathShape-Prefix-Guard —
  dieselbe Confused-Deputy-Abwehr wie der Quellen-Download; Register-Zeile
  ergänzt). Ohne hinterlegte Quelle bricht der Export mit klarer Meldung ab
  statt einen Hash zu erfinden (sha256 ist Vertrags-Pflicht). metric_id der
  Summary-Differenzen aus der echten metricKey-Zuordnung des Laufs.
- **Bewusst offen (Etappe 2/3):** Entity/Fact-Graph; Umzug der
  V2-Seiten-Komposition auf die Export-Funktion (der Lade-Glue existiert
  bis dahin zweimal — benannt); ViewModel-Export-Ablösung.

## 2026-08-10 – QAF-V2: Loop 5 (Teil 1) — Originalquelle verknüpft und zugreifbar

Analyse-Befund: Die Original-Workbooks werden seit KAR-840 im privaten
`qaf-uploads`-Bucket bewusst aufbewahrt, und `qaf_file.storage_key` existiert
seit der Gründungs-Migration — aber keine Insert-Stelle hat die Spalte je
befüllt: Die Bytes lagen unauffindbar im Bucket. Loop 5 Teil 1 schließt den
Kreis ohne jede Migration:

- **`storage_key` wird an allen drei `qaf_file`-Insert-Stellen geschrieben**
  (Summary, Multi-QAF, G60) — jeder NEUE Upload ist ab jetzt rückverfolgbar.
  Pfade sind UUID-eindeutig je Upload; Originale werden nie überschrieben
  (Replace lädt unter neuem Pfad — Exit-Kriterium).
- **`createQafSourceDownloadUrl` (neu):** kurzlebige (300 s) signierte
  Lese-URL nach RLS-geprüfter `qaf_file`-Sichtbarkeit — Signed-URL-only-
  Modell des Buckets, Register-Eintrag ergänzt
  (service-role-intent-register.md).
- **V2-Seite:** je Stand „Original herunterladen" — oder die explizite
  Aussage „Originalquelle nicht hinterlegt (Upload vor der
  Quellen-Aufbewahrung)" (Exit-Kriterium „fehlende Legacyquelle wird
  explizit angezeigt"; für ALLE Bestandsdateien der Normalfall, ehrlich
  benannt statt Edge-Case).
- **Bewusst offen (Teil 2):** automatischer Reparse-Flow aus dem
  storage_key (heute deckt `replaceComparisonFile` den Re-Ingest über
  frischen Upload) und die durchgängige SourceRef-Kette (SRC-002) —
  program-status.md führt Loop 5 als teilweise.

## 2026-08-10 – QAF-V2: Loop 8 (Teil 1) — Abdeckungs-Sicht der QAF-Bereiche

Der beim Ingest persistierte Capability-Stand (KAR-959, `qaf_file.g60_meta.
capability_matrix`) wird erstmals gelesen — bisher hatte ihn kein UI je
angefasst, und für 5 der 8 Bereiche (SBM, Logistik/Zoll, Rohstoffrisiken,
LC-CN, CO₂e) ist er die einzig mögliche Quelle (keine Diff-Tabellen).

- **`capability/coverage-view.ts` (neu):** Lese-Boundary des persistierten
  JSONB — Datei ohne Stand (Alt-Ingest) ist „kein Capability-Stand" (null),
  NIE als „nicht enthalten" umgedeutet; fremde Status-Werte passieren
  sichtbar statt zu crashen; DE-Anzeigenamen nach der Excel-/Roadmap-
  Terminologie (erste Modul→Anzeigename-Map, gab es nirgends).
- **V2-Seite:** Sektion „Abdeckung der QAF-Bereiche" (8 Bereiche × beide
  Stände) — nicht berechnete Bereiche sind damit sichtbar (Exit-Kriterium);
  der `qaf_file`-Select lädt den Schlüssel als JSONB-Pfad-Alias, nicht das
  gesamte g60_meta.
- **Bewusst offen (Loop 8 Teil 2):** „jede QAF ist navigierbar" als echtes
  Reiter-/Anker-System — es existiert bisher kein Tab-Bauteil im Repo; das
  ist UI-Zerlegungs-Territorium (Loop 10) und wird nicht im Coverage-PR
  miterfunden. program-status.md führt Loop 8 entsprechend als teilweise.

## 2026-08-10 – QAF-V2: Loop 4 abgeschlossen — abgeleitete Vergleichbarkeits-Ampel + Reviewstatus

Loop 4 der Roadmap (Datenqualitäts- und Vergleichsklassifikation) war durch
D15/D16/D17 (#461) und R-03 (#462) bereits substanziell erfüllt — die
Baseline-RTM wusste es nur noch nicht (Abweichung in program-status.md
benannt). Dieser Abschluss liefert die zwei fehlenden Exit-Kriterien:

- **`comparability.ts` (neu):** Die Kopf-Ampel der V2-Seite wird aus den
  persistierten Identitäts-Befunden ABGELEITET statt hart „ungeprüft" —
  `part_number_mismatch` → nicht vergleichbar · Lieferant/Nummer/Variante
  abweichend → eingeschränkt · sonst vergleichbar, ehrlich begründet mit
  „keine Identitäts-Befunde" (die Ampel behauptet nie mehr als ihre
  Grundlage; Hinweis-Typen bewegen sie per Gegenprobe nicht). Der
  `ComparabilityStatus`-Typ zieht als eine Quelle ins lib-Modul (Header
  aliasiert — dasselbe Muster wie R-03).
- **Sichtbarer Reviewstatus:** „Einordnung ausstehend — Vergleichsart ist
  noch nicht deklariert" steht als Status in der Vergleichsart-Sektion,
  solange niemand deklariert hat.
- program-status.md: Loop 4 auf abgeschlossen, mit Exit-Kriterien-Belegen
  und benannter RTM-Abweichung.

## 2026-08-10 – QAF-V2: Zusammengeführte Vergleichs-Ansicht (PO-Feedback 10.08.)

Kais' Befund an beiden PDFs: „zu unübersichtlich — zusammenführen, grafisch wo
sinnvoll, Unverändertes nicht auflisten (aber im JSON/Backend behalten)". Die
V2-Seite übernimmt jetzt die reife Klassik-Optik und verdichtet die Tabellen —
ohne dass irgendetwas aus Katalog, Persistenz oder Exporten verschwindet:

- **Identität im Kopf:** `qaf_part`-Select wie auf der Klassik-Seite — der
  Lieferant steht wieder neben der Sachnummer (war hart `null`).
- **Kostenstruktur-Balken** (`QafCostStructureChart`) und **Preisbrücke** aus
  GENAU dem Summary-Diff, aus dem auch die KPI-Kacheln und der Waterfall
  gebaut sind (`run.summaryDiffs` → `rowsFromMetricDiffs` →
  `buildBuckets`/`buildBridge`, dieselben puren Funktionen wie die
  Klassik-Seite). Bewusst KEIN zweiter `qaf_summary_diff`-Select: zwei
  unabhängig berechnete Stände derselben Metriken auf einem Screen können
  einander widersprechen (Review-Befund; Ein-Quellen-Invariante von
  summary-view.ts).
- **Brücken-Gate statt blindem Chart-Tausch:** die reiche Klassik-Brücke
  (`QafBridgeChart`) rendert NUR, wenn die Brücken-Reconciliation innerhalb
  der Toleranz schließt (`residualWithinTolerance`) — sonst zeigt weiterhin
  der V2-Waterfall den ehrlichen Diagnose-Zustand. Damit bleibt die
  nicht verhandelbare Regel („kein Restbalken kaschiert eine ungeklärte
  Differenz", Befund F-01) auch in der schönen Darstellung in Kraft.
- **`record-partition.ts` (neu):** Ansicht-Partition der Katalog-Sätze in
  drei Gruppen — Wert-/Strukturänderungen (Haupttabelle), Formelbefunde ohne
  Wertänderung (eigene aufklappbare Gruppe — nach Messung am HICE-Golden
  besteht die bisherige 95-Zeilen-Wand zu 100 % daraus; Master-Prompt §12.4:
  gruppiert, nie versteckt) und „ohne nennenswerte Wertänderung"
  (Klappzeile). Bewusst NICHT `TableFilter.changedOnly`: das arbeitet auf
  TableRow, wo der Formelbefund schon verloren ist, und würde added/removed
  (delta artbedingt null) mit einklappen. Meldewesen (`validateTraceability`)
  läuft weiter über die volle Menge; Excel-/Copilot-/JSON-Exporte importieren
  `table-specs` nicht und bleiben strukturell unberührt.

## 2026-08-08 – QAF-V2: R-03 — Vergleichsart deklarierbar (Spec Kap. 21)

Der V2-Kopf zeigt die Vergleichsart seit Kap. 21, aber hartkodiert
„Vergleichsart nicht bestimmt". R-03 (P0 der Spec-Erhebung, PO-Linie
„flexibel — deklarierbar, das System rät nie") macht sie zur
Menschen-Entscheidung mit sichtbarer Evidenz:

- **`declared-comparison-type.ts` (neu):** die Kap.-21-Werteliste als eine
  framework-freie Quelle (`temporal_change`/`supplier_benchmark`/
  `site_comparison`/`variant_comparison`/`undetermined`) mit asymmetrischen
  Boundaries — Lesen normalisiert Fremdwerte ehrlich zu `undetermined`,
  Schreiben lehnt sie ab (ein Tippfehler darf nicht wie eine bewusste
  Rücknahme aussehen). Der Header-`ComparisonType` ist jetzt ein Alias darauf.
- **Bewusst NICHT `comparison_mode`:** das ist das geschlossene technische
  Modus-System (comparison-mode.ts, Dispatch je Modus) — die fachliche
  Deklaration ist orthogonal und lebt in der eigenen Spalte
  `qaf_comparison.declared_comparison_type` (**Migration #120**, additiv
  nullable, kein CHECK — konsistent mit status/value_state).
- **Server-Action `declareComparisonType`:** einziger Schreibweg, validiert
  gegen die geschlossene Liste, auditiert (`comparison_type_declared`);
  vor dem Apply meldet sie den Migrations-Stand als klare Meldung (42703).
- **V2-Seite:** Deklarations-Control + Evidenzliste (Dateinamen beider
  Stände, Sachnummer, Identitäts-/Herkunfts-Befunde des Laufs aus
  `qaf_plausibility_issue` — die D15/D16-Detektoren liefern hier). Der
  Spalten-Select ist bewusst separat und fehlertolerant: **kein
  deployment_only_blocker** — Deploy darf dem Apply vorausgehen, die Seite
  degradiert bis dahin ehrlich auf „unbestimmt".

## 2026-08-08 – QAF-V2: D15/D16/D17 als weiche Befunde — Identität, Dateiname, Fertigungstiefe

Drei P0-Anforderungen der Spec-Erhebung (Block 6), die die Spezifikation
fordert, aber nirgends definiert — umgesetzt nach der PO-Linie (Kais,
08.08.2026): **weiche Befunde statt harter Gates, das System liefert Evidenz,
die Bewertung trifft der Mensch.** Alle drei speisen den bestehenden
`qaf_plausibility_issue`-Kanal, kein Schema-Change; kein Vergleich wird
blockiert.

- **D16 Identitätsfelder** (`plausibility.ts`,
  `checkSummaryIdentityPlausibility`): die vier restlichen Kopf-Felder werden
  alt↔neu geprüft — `supplier_mismatch`/`supplier_no_mismatch` (prüfen; Name
  und Nummer bewusst getrennt: Umfirmierung vs. Werkswechsel sind zwei
  Evidenzen) und `request_version_changed`/`change_index_changed` (Hinweis;
  zwischen Ständen legitim beweglich). Beide Werte stehen als Evidenz im
  Befund; nur wenn beide Seiten vorhanden sind (kein Raten bei Lücken).
- **D15 Dateiname vs. Inhalt** (`checkFilenamePartNumber`, je Seite): meldet
  `filename_part_number_mismatch` (Hinweis), wenn die geparste Sachnummer im
  Dateinamen fehlt — beides auf [A-Z0-9] normalisiert (Trenner-tolerant),
  Mindestlänge 6 gegen Zufalls-Kollisionen, bei fehlenden Werten still.
- **D17 Fertigungstiefen-Indiz** (`checkMakeOrBuyIndication`): Verschiebung
  des Material-/Fertigungsanteils an den Herstellkosten zwischen ALT und NEU
  über die Schwelle → genau EIN `make_or_buy_indication`-Befund (Hinweis) mit
  beiden Anteilspaaren als Evidenz. Schwelle als **versionierte
  Engine-Config-Sektion** `makeOrBuyIndication` (Startwert 10 Prozentpunkte,
  konservativ — Startwert-Charakter wie die Reconciliation-Toleranzen);
  `configVersion` 1.4.0 → **1.5.0**, Delta-Persistenz und
  `resolvePersistedEngineConfig` erweitert (historische Vergleiche fallen auf
  den aktuellen Default zurück — dokumentierter Tradeoff aller Sektionen).

Die Material-Zuordnungs-Maschinerie (`buildMaterialMappings` +
`classifyMaterialEffects`, inkl. der frischen `scope_removed`-Taxonomie) hatte
null Produktions-Aufrufer — dieselbe Klasse „gebaut, nie verdrahtet" wie der
Katalog im Loop-3-Vorbefund. Jetzt:

- **(2a) `mappablePositionsFromMaterialRows`:** die Parser→Mapping-Brücke,
  **empirisch am Golden-Fall fixiert** statt semantisch geraten — Messlauf
  beider Namens-Kandidaten: `materialDesignation` reproduziert `price_change`,
  `scope_activated` und `scope_removed` EXAKT (Zählung und Betrag),
  Positionszahlen 49/56 exakt; `partDesignation` verfehlte alle. `cost` =
  `materialCost` (CORE, evidence `mat_calc_material_cost`), `row` aus der
  Zell-Provenienz mit festem Feld-Vorrang, `group` = null (der Parser kennt
  keine Baugruppen-Spalte — gemessene Folge: eine consolidation-Zuordnung
  weniger, deren Positionen ehrlich als Zu-/Abgang erscheinen; als feste
  Zahlen im Golden-Test festgehalten).
- **(2b) `compareQafPair` führt die Zuordnungen erstmals aus** — über
  dieselben tri-state `materialRows`, die Rule-Engine und Reconciliation
  schon lesen; Blattdelta der Positionsebenen-Reconciliation ist der
  Summary-Materialkosten-Diff. `QafComparisonResult.materialEffects` optional:
  undefined nur ohne Material-Parse; „nicht geparst" bleibt von „nichts
  gefunden" unterscheidbar. HICE-Golden bewusst aktualisiert (Strip-Probe:
  ausschließlich der neue Key differiert, 61 Mappings am HICE-Fall).
- **(2c) Migration `supabase-migration-qaf-material-diff.sql`** (+ Rollback,
  MIGRATIONS.md #119): eine Zeile je Zuordnung, `effect`/`zero_cost` bewusst
  persistiert (Reproduzierbarkeit), Reconciliation bewusst nicht
  (deterministisch rehydrierbar). RLS von Geburt an nach dem exakten
  #115-Muster. Persistenz-Kette: `ComparisonRowset.materialDiffs` +
  Inserts in beiden Pfaden (Erst-Vergleich + Recompare;
  `RECOMPARE_REPLACED_CORE_TABLES` erweitert). **⚠ Apply vor Code-Deploy
  (deployment_only_blocker, Sammel-Paket mit #118).**

- **(2d) `differences-from-material.ts`:** der dritte datenbankfähige
  Katalog-Produzent. `kind` aus matchType × Wirkungsart — `renamed`/`merged`/
  `split` nativ, `scope_activated` → `newly_filled`, auf null gefallen →
  `cleared` (die Spiegel-Vokabeln existieren genau dafür); ersatzloser und
  bepreister Wegfall teilen `removed`, die Beschreibung trennt sie
  deterministisch. Nullkosten-Positionen bleiben satzlos (ihr Befund läuft im
  D02-Kanal — keine zweite Quelle derselben Aussage); unbekannte Kombinationen
  benannt statt verschluckt. Zellen nach Weg 4 leer (Zeilen führen keine
  Zelladressen), gezählt gemeldet.
- **(2e) Material-Sektion auf der V2-Seite:** dieselbe U-08-Tabelle wie die
  Fertigung, mit den **echten Positionsnamen je Stand** aus den Zuordnungen
  (Umbenennungen sichtbar!). Der Select ist tolerant gegenüber der noch nicht
  applied-en #119-Tabelle (Preview-Deploys vor dem Apply zeigen eine leere
  Sektion statt zu brechen).
- (f) entfiel als eigener Schritt: der Produzent konsumiert das DB-Zeilen-
  Subset direkt — es gibt nichts separat zu rehydrieren; die
  Positionsebenen-Reconciliation bleibt bewusst dem Vergleichslauf
  vorbehalten.

## 2026-08-07 – QAF-V2: Kriterium 2 — Zellnachweise auf beiden Pfaden (Provenance-Migration)

Die letzte benannte Lücke des Loop-3-Vorbefunds schließt: „jede Difference ID
besitzt mindestens eine Source Reference" — bis Blatt, Zelle, Formel und
Wertzustand.

- **Migration `supabase-migration-qaf-summary-provenance.sql`** (+ Rollback):
  drei nullable-Spalten `sheet`/`formula`/`value_state` an
  `qaf_summary_metric`. Editor-tauglich, kein Rewrite der 1012 Bestandszeilen
  (bleiben NULL = „nicht erhoben" — der Katalog meldet sie weiterhin ehrlich).
  **⚠ deployment_only_blocker: Operator-Apply MUSS vor dem Deploy dieses
  Code-Stands erfolgen** (Insert/Select nutzen die Spalten).
- **Ableitung an genau einer Stelle** (Parser, der die Grid-Existenz kennt):
  Formel → `formula_and_cached`, Wert ohne Formel bei erhobenem Grid →
  `constant`, unresolved/ohne Grid → kein Zustand (nichts wird behauptet).
  `formulas` ist bei erhobenem Grid jetzt immer gesetzt ({} = „erhoben,
  nichts gefunden") — der Unterschied trägt die Ableitung.
- **Rehydrierung rekonstruiert volle Provenienz**: `buildFormulaProvenance`
  ist deterministisch über dem Rohtext (normalized + hash neu gerechnet) —
  damit ist auch die Formel-Engine auf dem DB-Pfad arbeitsfähig.
  DB-Fremdwerte im Wertzustand werden an der Boundary verworfen (nur die
  fünf Spezifikations-Zustände passieren).
- **`SummaryMetricDiff` trägt Zell-Provenienz je Stand**; der SUM-Produzent
  baut daraus echte `DifferenceCells` — Zelle nur bei vollständiger Herkunft
  (Weg 4 bleibt für Bestandsdaten in Kraft).
- End-zu-Ende belegt: Golden-Lauf gegen echte Dateien zeigt Kacheln mit
  Fundstellen (Blatt/Zelle/Formel/Zustand), die „ohne Zellnachweis"-Meldung
  verschwindet; DB-Runde per Test; Mutations-Probe auf die Ableitung
  (constant-Zweig raus → genau der Ableitungs-Test rot, 1/30).

## 2026-08-07 – QAF-V2: Nachtrag zu #451 — Deployment-Protokoll + Arithmetik-Messung

Zwei Altlasten aus der Loop-2c-Übergabe, bewusst aus #452 herausgehalten
(CHANGELOG-Kollision):

- **`docs/qaf-v2/loop-reports/deploy-451-2026-08-06.md`:** das von der
  AUTONOMOUS MERGE AND DEPLOY POLICY verlangte Deployment-Protokoll zu #451
  (Merge `d76672b`, Deployment `5782425966`, Prüfergebnisse).
- **Arithmetik-Messung als Kommentar** an
  `manifest-arithmetik.test.ts` (Logik byte-identisch, Test grün): Die
  200er-Bereichsgrenze ist NICHT der begrenzende Faktor — mit
  Zellzahl-Grenze stiege die Zahl prüfbarer Summen von 1 auf 1, null
  Gewinn, Umstellung verworfen. Festgehalten, damit die nächste Person die
  Messung nicht wiederholt (und als Lehre: eine Wache messen ist nicht die
  Kette messen).

## 2026-08-07 – QAF-V2: Loop 3 Schritt 5 — `scope_removed` schließt die Wirkungsarten-Taxonomie

`MaterialEffectBucket` kennt jetzt den ersatzlosen Wegfall (DIF-005, P0:
„Struktur- und Kostenstatus trennen").

- **Regel zahlenbasiert, als PO-Entscheidung dokumentiert** (Kais, 07.08.2026,
  „Option a"): `matchType removed` mit Vergabekosten ≈ 0 → `scope_removed` —
  der Spiegelfall der added-Regel aus Spec Kap. 8.5, den die Spezifikation für
  `removed` nicht ausformuliert. Bepreiste Wegfälle bleiben `price_removed`.
- Fixture-Gate umgebaut: `NOT_YET_IMPLEMENTED`-Mechanik entfernt — Assertion 8
  läuft erstmals **ohne Ausnahmen** über alle Wirkungsarten des Referenzlaufs.
- **Neuer Zählungs-Test** trennt beide Wegfall-Klassen gegen die Referenz:
  Assertion 8 vergleicht Beträge, und der ersatzlose Wegfall trägt genau
  keinen — eine Fehlklassifikation bliebe dort unsichtbar. Mutations-Probe:
  Regel entfernt → genau dieser Test rot (Assertion 8 blieb grün — der
  Zählungs-Test ist notwendig, nicht dekorativ).
- Bekannte Abweichungen zur Referenz: von drei auf zwei reduziert (die
  verbleibenden sind die Spec-8.5-Nullkosten-Zugänge).

## 2026-08-07 – QAF-V2: Loop 3 Schritt 4 (MFG) — Fertigungs-Differenzen im Katalog und sichtbar

Der zweite datenbankfähige Produzent plus sein Verbraucher — `table-specs`
verliert damit den Status „gebaut und nie aufgerufen".

- **`differences-from-manufacturing.ts` (neu):** persistierte
  `qaf_manufacturing_diff`-Zeilen (Subset-Interface nach dem
  `summary-view`-Muster) → `RawDifference`. Wertbänder → `changed`,
  `neu`/`entfallen` → `added`/`removed`; die zwei persistierten Formelbefunde
  werden aus `status` rekonstruiert (der dritte ist in-memory-only — benannt
  abwesend).
- **Das Rechen-Trio erzeugt bewusst keine Sätze**, mit Primärquellen-Beleg:
  `nicht_anwendbar` ist laut differ.ts „not a structural add/removal",
  `blockiert` rechnet ausdrücklich kein Delta (Befund läuft als
  Plausibilitäts-Issue), beidseitig leer trägt keine Aussage. `nicht_anwendbar`/
  `blockiert` werden je Zeile benannt zurückgegeben (`withheldDe`) — der Katalog
  erfindet keine Aussagen, die die Engine verweigert. Konsequenz ehrlich
  dokumentiert: `deltaNullReason` bleibt auf diesem Pfad ungesetzt.
- **U-08-Tabellen-Sektion auf der V2-Seite (neu):**
  `qaf-v2-differences-table.tsx` rendert `buildTable`-Specs mit
  Filter-Transparenz und Delta-Summe; `rowsFromRecords` bekommt Station und
  Feld-Label über die Namens-Zuordnung per Sortierschlüssel
  (`tableNamesByDifferenceId`) — `TableRow` kennt kein `descriptionDe`.
- Query der V2-Seite lädt jetzt `qaf_step_match` + `qaf_manufacturing_diff`
  (inkl. `source_alt`/`source_neu` für die Ankerzeilen) + Stationsnamen aus
  `qaf_manufacturing_step`; das Meldewesen läuft mit (Sätze ohne Zellnachweis
  gezählt, verweigerte Aussagen benannt).
- Exporte statt Kopien: `KIND_BY_STATUS`/`VERB_BY_STATUS`/`sortKeyTuple`
  (SUM-Produzent) und `STEP_FIELD_LABELS` (rule-engine) werden geteilt.
- **Bewusst offen:** Material hat keine persistierte Diff-Tabelle — MAT braucht
  eine Produktentscheidung (Migration, ggf. im Paket mit `formula`/`value_state`
  für Kriterium 2, oder Mappen-Pfad). Schritt 5 (`scope_removed`) folgt.

## 2026-08-07 – QAF-V2: Loop 3 Schritt 3 — der fünfte Sortierschlüssel löst die Anzeigetext-Kopplung

`assignDifferenceIds` sortiert jetzt fünffach: Bereich → Ankerzeile → Art →
Beschreibung → **erste Fundstelle** (`sheet!cell` aus `cells[0]`).

- Der Vorbefund hat gemessen, dass die Eindeutigkeit der Kennungen **heute
  schon** an `descriptionDe` hängt (ohne sie: 1 Kollisionsgruppe am realen
  Golden-Paar) — ein Anzeigetext als Eindeutigkeits-Anker bricht bei der
  ersten Umformulierung. Die Fundstelle ist fachlich und läuft-stabil.
- Als Tie-Breaker **hinter** den vier bestehenden Schlüsseln: keine heutige
  Kennung kippt (Modul-Suite unverändert grün), er greift nur beim kompletten
  Gleichstand — dem Normalfall der kommenden MAT/MFG-Produzenten (mehrere
  Felder je Zeile).
- Permutationsprobe auf **echtem** Gleichstand ergänzt — der ältere
  Permutationstest deckt ihn nicht (seine Sätze unterscheiden sich in der
  Ankerzeile). Mutations-Probe: Schlüssel entfernt → genau dieser Test rot.
- Sätze ohne Fundstelle (Weg 4, leere `cells`) behalten das bisherige
  Verhalten; ihr Gleichstand bleibt eingabereihenfolge-abhängig — für den
  SUM-Pfad trennt die Beschreibung (Label je Kennzahl), per Test belegt.

## 2026-08-06 – QAF-V2: Loop 3 Schritt 2 — der SUM-Produzent verdrahtet Katalog und Übersicht

Erstmals referenzieren die **datenbankgespeisten** KPI-Kacheln und die
Preisbrücke Difference IDs — Exit-Kriterium 1 ist für den SUM-Bereich erfüllt,
im Format der Vergabe (`D-SUM-nnn`), nicht in dem der Referenzseite.

- **`differences-from-summary.ts` (neu):** übersetzt `SummaryMetricDiff` in
  `RawDifference` — Wertbänder → `changed`, `neu`/`entfallen` →
  `added`/`removed`, `konstant` → kein Satz; der Formelbefund läuft orthogonal
  über `formulaFindingKind` (der dritte Formel-Fall behält sein Wertband).
  Statuswerte ohne Ziel werden benannt zurückgegeben statt still übersprungen.
- **Keine Zelle ohne vollständige Herkunft (Vorbefund Weg 4):** der Diff kennt
  die Adresse, aber weder Formel noch Wertzustand — es entsteht keine Zelle,
  `validateTraceability` meldet die Sätze ohne Fundstelle, und der Lauf trägt
  die Meldung sichtbar. Kriterium 2 bleibt damit **benannt und gezählt** offen,
  bis `formula`/`value_state` persistiert sind.
- **`buildOverviewRun`:** `buildCatalog(...)` statt der fest leeren Listen;
  Kennungs-Zuordnung über die vier Sortierschlüssel (`sortKeyTuple`), nicht
  über den Index — permutationsfest, per Test belegt.
- **`OverviewRun.traceabilityDe` (neu):** Rückverfolgungs-Lücken getrennt von
  `degradationsDe` (das bleibt die U-04-Aussage über die Eingaben; beide
  Bestandsverträge unverändert grün). Die V2-Seite zeigt die Meldungen an.
- `einheitFür` ist zum Produzenten umgezogen (eine Definition statt zwei).

## 2026-08-06 – QAF-V2: Loop 3 Schritt 1 — der Katalog ist sprachfähig (Formelbefund + Delta-Grund)

`DifferenceRecord` kann jetzt ausdrücken, was der Summary-Vergleich produziert —
als **zwei orthogonale Angaben**, nicht als Enum-Anbau. Begründung aus dem
Vergleich selbst: bei `formel_geaendert_wert_geaendert` behält
`summary-metrics.ts` das Wertband und setzt den Formelbefund dazu; ein einziges
Vokabular müsste eine der beiden Aussagen verwerfen (Vorbefund, „Der Kern von
Schritt 1").

- **`formulaFindingKind: FormulaFindingKind | null`** — per `Extract` an
  `FormulaComparisonKind` (formula-engine.ts) gebunden: die drei
  berichtspflichtigen Arten, keine dritte Literal-Kopie. Der menschenlesbare
  Text bleibt beim Plausibilitäts-Befund (persistiert); der Katalog führt nur
  das geschlossene, auswertbare Vokabular.
- **`deltaNullReason: DeltaNullReason | null`** — `nicht_berechenbar` ·
  `nicht_anwendbar` · `blockiert` als `Extract` aus `DiffStatus`: Gründe für
  `delta === null`, keine Änderungsarten.
- **Pflichtfelder, nullable statt optional:** jeder Produzent muss eine bewusste
  Aussage machen. Tooling-Produzent, Findings-Produzent und Referenzfall führen
  `null` mit begründendem Kommentar.
- **`validateTraceability` meldet neu `delta_reason_contradiction`** (Grund für
  ein fehlendes Delta neben einem vorhandenen Delta). Per Mutations-Probe
  verifiziert: Prüfung ausgeschaltet → genau der eine Melde-Test rot (1/26).
- **Sortierung und ID-Vergabe unangetastet** — die neuen Felder sind keine
  Sortierschlüssel; der fünfte (fachliche) Schlüssel ist bewusst Schritt 3.
- **Bewusst noch nicht erfüllt:** „Formeländerungen getrennt von Wertänderungen
  führen" hat drei Teile — ausdrücken, setzen, zeigen. Dieser Schritt liefert
  das Ausdrücken; SUM-Produzent (Schritt 2) und Anzeige folgen.
- Barrel-Bestandsaufnahme (20 Deep-Imports gegen ADR-019 Regel 4) im
  `governance/scorecard/bypass-log.md` mit Sunset-Bedingung protokolliert —
  benannt statt still vergrößert, wie der Vorbefund es verlangt.

## 2026-08-06 – QAF-V2: Loop-3-Vorbefund — der Differenzkatalog ist nicht angeschlossen

Vor dem Bauen gemessen, nicht aus der Roadmap gelesen. Neu:
`docs/qaf-v2/loop-3-vorbefund.md`, verlinkt aus `program-status.md`.

- **Exit-Kriterium 1 von Loop 3 („jede UI-Aussage referenziert Difference IDs")
  ist heute von keiner datenbankgespeisten UI-Aussage erfüllt.**
  `overview-run.ts:121` (KPI-Kacheln) und `:180` (Wasserfall-Schritte) setzen
  `differenceIds: []` **fest**. Kette Ende zu Ende belegt:
  `app/qaf-differences/[id]/v2/page.tsx:19` importiert `buildOverviewRun`, `:70`
  ruft es auf, `chart-specs.ts:117`/`:192` sortieren die leere Liste folgenlos —
  und `:42-57` speist genau diesen Pfad aus `qaf_comparison`, `qaf_file` und
  `qaf_summary_metric`.
  **Präzisiert nach einem Review:** eine erste Fassung schrieb „keine einzige".
  `app/qaf-differences/referenz/page.tsx` zeigt sehr wohl IDs
  (`reference-case.ts:63-77` trägt nicht-leere Listen,
  `qaf-v2-reference-section.tsx:52` rendert `Zellnachweis · …`, Test „U-01" hält
  es fest) — aber mit sichtbar als erfunden gekennzeichneten Beispieldaten. Diese
  Seite ist damit die **Vorlage** für die Verdrahtung, nicht der Gegenbeweis: ihr
  Kopf sagt, sie nutze „denselben Funktionen, die auch ein echter Lauf benutzt".
- **Der Katalog ist gebaut und hat keinen Aufrufer.** `assignDifferenceIds`,
  `buildCatalog`, `buildTraceabilityIndex` und `DifferenceCatalog` kommen
  außerhalb von `all-differences.ts` ausschließlich in Testdateien vor; der
  Barrel `lib/qaf-differences/index.ts` hat **182 Export-Anweisungen mit 1048
  benannten Symbolen** und **null** Verweise auf `all-differences`. Damit ist die
  nicht verhandelbare Regel aus `CLAUDE.md` („jede quantitative Aussage benötigt
  Difference IDs und Source References") heute nicht erfüllt.
  **Zahl korrigiert:** die erste Fassung nannte 203 aus `grep -c 'export'`, das
  20 Kommentarzeilen mitzählte (darunter Pfade wie `'./internal/export-envelope'`).
  Per TypeScript-AST nachgezählt; die tragende Aussage „null Verweise" ist per AST
  unabhängig bestätigt.
- **Die Lücke erzeugt kein Signal** — leere Listen sind typkorrekt, die
  Sortierung arbeitet fehlerfrei, kein Test verlangt eine nicht-leere Liste. Wer
  das Kriterium prüft, muss nach einer **nicht-leeren** Liste im
  datenbankgespeisten Pfad suchen, nicht nach dem Feld.
  **Auch hier eine Korrektur:** die erste Fassung schrieb, die Lücke stehe „in
  keinem Dokument". Falsch — `reference-case.ts` und `referenz/page.tsx`
  beschreiben sie in ihren Köpfen. Neu ist, dass dieser Satz **überholt** ist: es
  gibt inzwischen einen datenbankgespeisten Weg, und dort sind die IDs leer. „An
  keine Datenquelle angeschlossen" war der alte Zustand; „angeschlossen, aber
  ohne Herkunft" ist der heutige und der gefährlichere, weil er wie
  Fertigstellung aussieht.
- **Die Barrel-Umgehung ist ein Bestandsmuster, keine Einzelstelle:** 20
  Produktionsdateien in `app/` und `components/` importieren aus
  `qaf-differences/internal/`, 32 über den Barrel. `docs/adr/019-new-module-golden-path.md`
  Regel 4 verbietet das ausdrücklich („Cross-module access goes through
  `index.ts` only — no deep imports"), `eslint.config.mjs` hat **keine Zone** für
  `qaf-differences` (`lib/**` ist ein generischer `module`-Typ), und
  `governance/scorecard/bypass-log.md` führt **keinen** Eintrag. Die Rails-Doku
  verlangt für eine Umgehung ohne Sunset-Bedingung einen ADR. Kein
  Loop-3-Blocker, aber vor dem Verdrahten zu benennen statt still zu vergrößern.
- **Exit-Kriterium 4 gemessen statt vermutet:** `assignDifferenceIds` vergibt
  Positionszähler nach vier Sortierschlüsseln; bei Gleichstand entscheidet die
  Eingabereihenfolge. Am realen Golden-Paar erhoben: mit allen vier Schlüsseln
  **16 Sätze, 16 verschiedene Schlüssel, null Kollisionen** — die ID-Vergabe ist
  heute also stabil.
  **Verschärft nach einem Review:** eine erste Fassung nannte das ein „Phantom".
  Zu bequem. Ohne `descriptionDe` kollidieren **heute schon** zwei Sätze
  (15 verschiedene Schlüssel, 1 Kollisionsgruppe; die beiden unterscheiden sich
  in der Einheit — Stückumlage gegen Einmalzahlung derselben Position). Der vierte
  Schlüssel trägt also real, und damit hängt die Eindeutigkeit der Kennungen
  **bereits jetzt** an einem Anzeigetext. Eine Umformulierung dieser
  Beschreibungen würde heute zwei Kennungen vertauschen. Mit den zwei neuen
  Produzenten wird es schlimmer, weil Material- und Fertigungspositionen mehrere
  Sätze je Zeile erzeugen: `canonical-fields.ts` führt **29** Einträge in
  `MATERIAL_FIELD_KEY_TO_CANONICAL` und **22** in `QAF_FIELD_KEY_TO_CANONICAL`
  (dem Fertigungsblatt) — selbst ausgezählt. Ein Review hatte diese Zahlen als
  „29 `mat_`- und 22 `mfg_`-Feld-IDs" bezeichnet; die Zahlen stimmen, die Präfixe
  gibt es nicht. Empfehlung: fünfter **fachlicher** Schlüssel (Blatt plus
  Zelle aus `cells[0]`), nicht `descriptionDe` eindeutig machen; Probe =
  Eingabereihenfolge permutieren bei gleichgemachten Schlüsseln. Der heutige
  Permutationstest prüft Sätze mit unterschiedlicher `anchorRow` und deckt den
  Gleichstand deshalb nicht ab.
- **Wie klein die Verdrahtung wirklich ist, und welcher Bereich sie braucht.**
  `overview-run.ts:231` **ruft `buildKpiTiles` bereits auf** — mit `[]` als
  Katalog. Und `buildKpiTiles` (`view-specs.ts:92`) löst korrekt auf: Map über
  `differenceId`, `cells` der Treffer in `sourceRef.cells`. Es fehlen also **zwei
  Werte, nicht eine Verbindung.**
  Dazu eine Korrektur an der eigenen Empfehlung: eine erste Fassung riet, „den
  vorhandenen SBM-Pfad bis in die UI zu verdrahten". Gemessen erzeugt
  `differences-from-tooling.ts` aber ausschließlich `area: 'SBM'`, während die
  KPI-Kacheln und die Brücke aus `diffSummaryMetrics` kommen, also aus **SUM** —
  und `area: 'SUM'` erzeugt außerhalb von `reference-case.ts` (den erfundenen
  Beispieldaten) **niemand**. Der erste Schritt ist deshalb ein **SUM-Produzent**,
  dessen Zielform `reference-case.ts:139`/`:152` schon vorführt.
- **Empfohlene Reihenfolge weicht von der Roadmap ab:** erst diesen SUM-Produzenten
  bauen und einsetzen (Kriterium 1 für einen Bereich belegen), dann
  der fünfte Schlüssel, **dann** `differences-from-material.ts` und
  `-manufacturing.ts`. Die Roadmap beginnt bei Schritt 3; wer so anfängt, baut
  zwei Module, die genauso unangeschlossen bleiben wie der Katalog heute.
- **Eine Korrektur am eigenen Befund:** eine erste Fassung führte
  `differences-from-tooling.ts` als „keine Testdatei, billiger erster Schritt".
  Das war eine Namenssuche, keine Abdeckungsmessung —
  `all-differences.test.ts:146` trägt einen eigenen
  `describe('differencesFromTooling')`-Block mit sieben Zusicherungen und ohne
  `skipIf`. Die Roadmap-Aufgabe „bestehende Übersetzungen prüfen" ist damit eine
  fachliche Prüfung gegen die Spezifikation, kein Nachziehen von Tests.
- Beim Verdrahten anstehende Entscheidung dokumentiert: die V2-Seite importiert
  direkt aus `internal/`, nicht über den Barrel. Heute gültig, aber damit ist der
  Barrel faktisch nicht die Vertragsfläche zwischen `app/` und
  `lib/qaf-differences`. Gehört in einen ADR, nicht in einen Import.

## 2026-08-06 – QAF-V2: Blanko-Anker läuft in der CI, Fixture-Umstellung ausgeschöpft

**Erstes Exit-Kriterium von Loop 2 erstmals bewegt: 80 → 75 Tests in 23 Dateien**
laufen in keinem CI-Workflow.

- `blank-template-negative-anchor.real-files.test.ts` ist **nicht mehr
  env-gated**. Statt einer lokalen Realdatei prüft der Anker die drei Fixtures
  mit `art: 'blanko_vorlage'`, ausgewählt über das Manifest, nicht über
  Dateinamen. Aus 1 gegateten Test werden 4 ungegatete. Die reale Vorlage wird
  weiterhin mitgeprüft, wenn sie vorliegt; `QAF_FORCE_FIXTURES=1` blendet sie
  aus, damit der Fixture-Zweig auf einer Maschine mit Korpus überhaupt
  verifizierbar ist (die Lücke aus `#450`).
- **Der Grund, warum das vorher als unmöglich galt, war seit `#441` überholt.**
  `triage-env-gated-tests.md` führte die Suite mit „die Quelle trägt 34 Befunde,
  Einchecken wäre der sechste Datenvorfall". `#441` hatte genau diese Quelle
  anonymisiert eingecheckt — und die Folgerungsliste **derselben Datei** nannte
  das Umhängen als Punkt 1. Beide Sätze standen einen Tag lang nebeneinander.
  Ein Nein braucht dasselbe Nachprüfen wie ein Ja.
- **Tiefe je Achse gemessen, nicht behauptet.** Mutationsprobe: das
  `^kalkulation$`-Muster der Umlageschema-Erkennung auf `^summary$` aufgeweitet
  → alle drei Fixtures rot. Sonde: alle vier Multi-QAF-Signaldetektoren laufen
  und melden `strength: 'none'`, Ergebnis `standard_qaf` bei `confidence 0.05`.
  Die **G60-Achse ist flach** — keine Blanko-Vorlage hat ein `INPUT`-Blatt,
  `detectG60` endet am ersten Tor; nachgewiesen durch Entfernen des
  Summary-Ausschlusses in `parser.ts`, der Test bleibt grün. Ein Review hat
  zusätzlich Tor 2 geöffnet und gezeigt, dass das Detailreiter-Muster `^\d+_2`
  unabhängig blockt. Das galt für die reale Vorlage genauso, die Umstellung
  verliert also keine Tiefe.
  **Zuordnung berichtigt:** der Summary-Ausschluss stammt laut `git blame`
  (`15c5eeb`) aus `#269` (KAR-888), nicht aus `#328`. `#328` hat nur die
  `inputSheetNameMatch`-Konfiguration ergänzt; sein Regressionsfall liegt
  ungegated in `g60/__tests__/parser.test.ts` (nachgeprüft: null `skipIf`).
- **Kopfkommentar der Testdatei berichtigt.** Er nannte die Quelle
  „content-free form template (no supplier/business data)" — sachlich falsch,
  gemessen in der Triage. Der Kommentar beschrieb, was der Test zur Laufzeit
  liest, nicht was in der Datei steht.
- **Die vier übrigen Kandidaten einzeln gemessen und begründet abgelehnt:**
  `kar-960-hebel` (`FILE_CAP = 40` über „jede" Datei) · `qaf-type-detector`
  (`it.each` über den Standard-Satz, hat als einzige einen dokumentierten
  CI-Ersatz) · `cost-allocation-family` (verlangt **`.xlsm`**, alle Fixtures
  sind `.xlsx`; dazu zwei Unterprofile `wide`/`narrow` ohne Fixture) ·
  `qaf-candidate-visibility` (Sweep über eine **zweite** Quelle). Damit ist der
  Weg über Fixture-Umstellung ausgeschöpft, mit einem belegten Kriterium statt
  einer Aufzählung: **umstellbar ist genau, was Repräsentanten sucht** — nicht,
  was eine Menge behauptet.
- **Nebenbefund, vorher in keinem Dokument:** die env-gated Suiten holen ihre
  Eingaben aus **drei** verschiedenen Wurzeln plus einer Umgebungsvariablen, nicht
  aus „dem Korpus" (21 Suiten `qaf-compare-kar824/input`, 10 `qaf-corpus/*`, 10
  `qaf-compare-v2-golden`, 1 über `SIMVSM_CORPUS_DIR`). Nur die letzte ist ohne
  Codeänderung umzuhängen: von **44** Suiten tragen **41** einen absoluten Pfad
  als Literal, 2 gehen über das gemeinsame `real-corpus.ts`. Portabilitätsschuld,
  relevant für die Übergabe.
- **Drei eigene Messfehler dokumentiert**, jedes Mal sah die Ausgabe vollständig
  aus: die Zeichenklasse `[a-z0-9/_.-]` traf das Segment `QAFs` nicht · ein
  Muster für einfache Anführungszeichen übersah einen Pfad in doppelten · und
  **die Fassung, die diese beiden als behoben auswies, zählte selbst falsch**,
  weil `find` nur `*.real-files.test.ts` und nicht `*.real-files.test.tsx`
  suchte (42/39/19/17 statt 44/41/21/19). Ein Review hat den dritten gefunden.
  Ein Absatz über den eigenen Messfehler schützt nicht vor dem nächsten.
- **Begründung einer Wache berichtigt.** Der Kommentar behauptete, ohne die
  Wache liefe `it.each([])` still durch und die Suite meldete grün. Falsch:
  vitest lässt eine Datei ohne ausgeführten Test fehlschlagen („Test Files 1
  failed", „Tests no tests"), `passWithNoTests` ist nicht gesetzt — mit einer
  Minimaldatei selbst nachgestellt. Die Wache verbessert die Diagnose, sie
  verhindert kein stilles Grün.
- Zählungen in `loop-2-abnahmestand.md`, `testinventar.md`, `program-status.md`
  und `triage-env-gated-tests.md` nachgezogen. Historische Messpunkte bleiben
  mit Datum stehen, die Übersichtstabellen nennen den aktuellen Wert.

**Nachgetragen:** `#449` (Einzelmessung der 18 offenen Tests) und `#450`
(`capability-detector` ohne lokalen Korpus) haben trotz der Regel in `CLAUDE.md`
keinen CHANGELOG-Eintrag bekommen. Ihre Inhalte stehen in
`triage-env-gated-tests.md` beziehungsweise im Dateikopf der Suite; die Lücke
hier ist benannt statt stillschweigend gefüllt.

## 2026-08-06 – QAF-V2: Loop-2C-Handover

Abschlussbericht der Nacht vom 05. auf den 06.08., `docs/qaf-v2/loop-reports/loop-2c-2026-08-06.md`.
Damit sind die Punkte D bis H abgearbeitet (D `#435`, E `#432`, F `#444`,
G `#440`/`#441`, H hier). Zur Herkunft dieser Buchstaben: sie stammen aus der
Auftragsliste des Product Owners und stehen im Repository nirgends — die
Vollständigkeit ist von hier aus nicht nachprüfbar, der Bericht sagt das
ausdrücklich.

- **Acht PRs**, alle gemergt und einzeln auf `main` verifiziert (`#440`–`#447`),
  Ausgangsstand `3450664`, Endstand `8c81bac`.
- **Die zwei nützlichsten enden mit einer Absage:** `#445` stoppt einen Bauplan vor vier
  unabhängigen Blockern, `#446` beseitigt eine stille Falschaussage über vier
  eingecheckte Fixtures.
- **Warnung an den nächsten Durchgang:** Punkt D stand in der Auftragsliste als offen und
  war seit `#435` erledigt — beinahe doppelt gebaut. Auftragslisten altern, das
  Repository nicht.
- **Die Fehlerklasse der Nacht benannt:** geprüft wurde, was ein Werkzeug leicht zeigt,
  statt was die Behauptung verlangt — Namensgleichheit als Strukturbeweis, gezählte statt
  gelesene Trefferlisten, ein Beleg dessen einziger Fundort der eigene Satz war, und eine
  „Regressionssperre", die zwei Felder aus derselben Zeile verglich.
- **Zum Verfahren:** die schweren Befunde kamen fast alle aus den Reviews — mit einer
  Ausnahme, die der Bericht nennt, weil zu große Bescheidenheit genauso falsch wäre wie
  zu große Großzügigkeit: die Fixture-Inkonsistenz (`#446`) war ein eigener Fund. Die
  Reviews haben angegriffen statt gelesen — Umgehungen als echte Dateien gebaut,
  Mutationen mit Anker-Verifikation, Zahlen mit eigenen Skripten nachgerechnet.
- **Der schwerste Fall der Nacht fehlte in der ersten Fassung** und ist nachgetragen: in
  `#441` steckten reale SharePoint-Mandanten-Kennungen in allen vier Fixtures, drei davon
  bereits auf `main`. Die erste Fassung nannte vier epistemische Fehler und ließ den
  konkreten Beinahe-Datenvorfall aus — kuratiert, nicht verschwiegen, und damit die
  unangenehmere Sorte Auslassung. Der Vorfall ist in keinem anderen Dokument festgehalten.
- **`program-status.md` korrigiert:** es führte `real_pair_reference_corpus` weiter mit
  „ausdrücklich freigegeben, autonom zu beginnen" und „54 Tests in 10 Dateien" — beides
  durch `#445` derselben Nacht widerlegt. Jetzt 48 Tests in neun Suiten, Freigabe
  zurückgenommen, Verweis auf den Befund. Ein Nachfolger hätte sonst gegen einen
  dokumentierten Blocker gebaut.

## 2026-08-06 – QAF-V2: Manifest sagt jetzt, dass die Fixture-Arithmetik nicht stimmt

Selbst gefundener offener Punkt aus dem Machbarkeitsbefund, geschlossen.

- **Vier eingecheckte Fixtures waren arithmetisch inkonsistent, und das Manifest
  verschwieg es.** `--caches=replace` ersetzt Eingabewerte und Formel-Caches
  unabhängig voneinander — eine Summenzelle trägt danach nicht die Summe ihrer Posten.
  Das Werkzeug verlangt dafür ausdrücklich `arithmetic_consistency: false` im Manifest;
  `build_manifest.py` schrieb das Feld nie.
- **`erzeugung` führt jetzt `caches_modus` und `arithmetic_consistency`**, abgeleitet
  statt hartkodiert nebeneinander: das Flag ergibt sich aus dem Modus.
- **Der Schutz misst die Fixture-Bytes, nicht sich selbst.** Eine erste Fassung des
  Tests verglich nur die beiden Manifest-Felder — ein Review hat live gezeigt, dass das
  nichts schützt: beide entstehen aus derselben Zeile, `"recompute"` + `true` ließ sich
  eintragen, ohne die Fixtures anzufassen, und alles blieb grün. Genau die
  Tautologie-Falle, die der Machbarkeitsbefund einen Tag zuvor beschreibt.
  `__tests__/manifest-arithmetik.test.ts` prüft jetzt zwei Klassen ohne
  Formel-Auswertung: eine reine Zellreferenz (`=J6`) muss den Zielwert tragen, eine
  `IF(…,0,1)`-Formel kann nur 0 oder 1 ergeben. Gemessen **je Datei**: 2716 von 3704 in
  `qaf-9.1-standard-en` und noch einmal ebenso viele in `-de`, dazu 1 von 1 in
  `qaf-9.1-zeilen-de` — zusammen **5433 von 7409**. In `qaf-8.8-hice-vergabe` greift
  keine der Klassen (0 Fälle); die Prüfung summiert über den Korpus und trägt deshalb
  trotzdem. Beide Richtungen
  sind geprüft — eine falsche `true`-Zusage wird rot, ein zu pessimistisches `false`
  ebenfalls.
- **`golden-corpus.md`** hält die Lesart fest: die Fixtures tragen Struktur, nicht
  Beträge. Ein Golden Test über Positionen, Kennzahlschlüssel und Hashes ist belastbar,
  ein Vergleich erwarteter Summen nicht.

## 2026-08-06 – QAF-V2: Machbarkeitsbefund zu `real_pair_reference_corpus`

Der naheliegende Weg trägt nicht. **Vier** eigenständige Blocker, alle selbst
nachgemessen: drei aus einem unabhängigen Review (zwei davon standen bereits in
Dokumenten auf `main`, eines aus diesem Programm), der vierte kam beim Nachmessen der
eigenen Korrektur heraus.

- **Der eigentliche Blocker liegt vor dem Fixture-Bau.** `--caches=recompute`
  („arithmetisch konsistent") ist im Anonymisierer **„vorbereitet, aber nicht
  verdrahtet"** und bricht ab. Der Default `--caches=replace` sagt über sich selbst:
  „die Arithmetik der Mappe stimmt danach aber nicht mehr." Auf arithmetisch
  inkonsistenten Mappen trägt weder ein Wert-Golden noch die vorgeschlagenen
  Invarianten — sie prüften eine Rechnung, die das Werkzeug zerstört hat.
  `loop-2-abnahmestand.md` stellt das bereits fest („kann mit einer anonymisierten
  Fixture auch nicht existieren"); der erste Bauplan hat die eigene Feststellung
  übergangen. Reihenfolge deshalb umgekehrt — und „verdrahten" wäre zu optimistisch,
  es ist Neubau (siehe unten).
- **Die Referenz ist nicht codegeneriert.** `buildV2Export()` liefert alles unter
  `areas`; die reale Referenz ist **flach** (23 Top-Level-Schlüssel, kein `areas`, kein
  `section_state`). `compareQafPair()` liefert andere Felder. `buildV2Export()` hat
  **null Produktionsaufrufer** (`befunde-loop0.md`, B-05). Und der Kopf von
  `material-mapping-golden-case` sagt: „forensisch von Hand" erstellt. Der Fehler
  entstand aus vier übereinstimmenden Feldnamen — Namensgleichheit als Sachbefund.
- **Nicht alle zehn Suiten brauchen ein Tripel:** 7 ja, 1 nur das Mappenpaar
  (`overview-run`, null `reference`-Vorkommen), 2 nur die Referenz ohne Mappe.
  `triage-env-gated-tests.md` hatte genau das für `overview-run` schon korrigiert — der
  erste Bauplan wiederholte den Fehler.
- **Vertraulichkeitsinventur nachgezogen:** `metadata` hat **21** Schlüssel statt der
  genannten 6, `executive_summary` **13** statt 4. Ganz gefehlt hatte
  `metadata.kontakt.bearbeiter_lieferant` — **personenbezogen**, nicht nur
  firmenbezogen. Eine unvollständige Inventur ist gefährlicher als keine: sie sieht wie
  eine Freigabe aus. Jetzt ausdrücklich als nicht erschöpfend gekennzeichnet.
- **Zweiter, unabhängiger Blocker — der Anonymisierer ist für Paare nicht gebaut.** Eine
  erste Fassung behauptete, derselbe Seed sichere die Korrespondenz zwischen den Mappen.
  Gemessen ist es **falsch herum**: `replace_number(value, seed, sheet, coord)` hasht
  `seed|sheet|coord`, also **positionsbasiert** — derselbe Wert an verschiedenen
  Positionen wird verschieden ersetzt, verschiedene Werte an gleicher Position gleich.
  Die Textersetzung nutzt einen **Zähler** (`Pos001`…), der Seed wird gespeichert aber
  nie gelesen, und `build_fixtures.sh` startet je Datei einen eigenen Prozess mit leerer
  Zuordnungstabelle. Ein gemeinsamer Seed hilft nicht; für Paare braucht es eine
  wertbasierte, dateiübergreifend konsistente Ersetzung, die es heute nicht gibt.
  `ANONYMISE_SEED` fehlt zusätzlich (`loop_local_blocker`), ist aber nicht der
  begrenzende Faktor.
- **Schritt 1 ist Neubau, nicht Anschluss:** der Anonymisierer sagt zu
  `--caches=recompute`, der Modus „entfernt die Caches — neu gerechnet wird bisher
  nirgends", Ergebnis wäre „eine Fixture mit 1 von 20 Kennzahlen". Eine
  Formel-Auswertung existiert nicht: `formula-engine.ts` vergleicht Formeltext
  ausdrücklich ohne Neuberechnung, das Python-Paket `formulas` ist nicht installiert.
  Eine von mir genannte Laufzeit („rund 2 s für gut 9000 Knoten") war **im Repository
  nirgends belegt** — der einzige Treffer war der eigene Satz. Gestrichen.
- **Vierte Alternative ergänzt:** rein strukturelle Prüfungen auf anonymisierten Paaren
  ohne Wert-Invarianten, nach dem Muster der vier Einzelfixtures. Braucht weder
  Rechenschritt noch Arithmetik-Konsistenz, bedient aber nur einen Teil der Tests.
  Und die Zahl dort korrigiert: **48 Tests in neun Suiten** hängen an einer Referenz,
  nicht 54 in zehn.
- Offener Nebenpunkt: das Fixture-Manifest trägt kein `arithmetic_consistency`-Flag,
  obwohl der Anonymisierer es für den Default-Modus verlangt.

## 2026-08-05 – QAF-V2: Portabilitäts-Gate für den fachlichen Kern

Die Produktentscheidung vom 05.08.2026 trennt `transitional_persistence_implementation`
(heutige Supabase-Ablage, austauschbar) von `portable_product_core` (QAF-Engine und
kanonisches Datenmodell, muss die Migration überleben). Die daraus folgende Auflage
— keine Datenbankabfrage, kein Supabase-Client, kein Cloud-SDK in der deterministischen
QAF-Domainlogik — war bisher faktisch erfüllt, aber durch nichts abgesichert.

- **Neu: `scripts/check-qaf-core-portability.mjs`**, eingehängt in `check:portability`
  (und damit in beide Profile der CI-Portability-Matrix). Prüft `lib/qaf-differences`,
  `lib/qaf` und `lib/qaf-value-stream` gegen vier Inhaltsklassen: `supabase`,
  `cloud-sdk`, `datenbank-treiber`, `framework-laufzeit`. Nach der Form des Imports
  entschieden, nicht über eine Namensliste konkreter Pakete.
- **Gemessener Ist-Stand:** 159 Dateien geprüft. Der deterministische Vergleichskern
  (`lib/qaf-differences`, 126 Dateien), `lib/qaf` und `lib/qaf-parser.ts` sind
  vollständig frei von Kopplung. **8** `import type`-Kopplungen an `SupabaseClient`:
  sechs in `lib/qaf-value-stream/internal` (`creation`, `duplicates`,
  `multi-qaf-context`, `preview`, `qaf-source`, `reimport`), zwei in
  `lib/simvsm-import/internal` (`duplicates`, `persist`). Kein Laufzeit-Client.
- **Korrektur einer falschen Behauptung:** Eine erste Fassung schrieb „keine in der
  öffentlichen Modul-API". Das ist falsch. `lib/qaf-value-stream/index.ts` importiert
  den Typnamen zwar nicht selbst, re-exportiert aber zehn Funktionen, die
  `supabase: SupabaseClient` als ersten Parameter verlangen (u. a. `loadQafSourceRows`,
  `createValueStreamFromQaf`, `loadMultiQafVariantContext`). Die Kopplung ist Teil des
  öffentlichen Vertrags, nicht nur der Implementierung — der Vertrag
  `QafSourceArtifactRepository` muss also auch die Aufrufer ändern, nicht nur die acht
  Stellen. Geprüft worden war nur, ob `index.ts` das Wort „Supabase" enthält; das ist
  eine Suche nach dem Namen, keine nach der Semantik der Exporte.
- **Bestand eingefroren statt beseitigt** (`scripts/qaf-core-portability-baseline.json`),
  Mechanik wie `check-lint-baseline.mjs`. Grund ist eine zweite, gleichrangige Auflage:
  Multi-QAF ist `deferred_by_product_owner` und darf nicht refactored werden — das trägt
  allerdings nur für **eine** der Stellen (`multi-qaf-context.ts`); für die übrigen trägt
  die Scope-Disziplin „dieser Beitrag fügt Erkennung hinzu, kein Refactor". Zwei
  Begründungen, nicht eine. Das Gate
  verhindert Neues, macht Bestehendes sichtbar und verhindert, dass es still mitwandert.
  Die Entkopplung selbst gehört zum Vertrag `QafSourceArtifactRepository`.
- **Alle Importformen erfasst,** nicht nur `from '…'`: eine Gegenprobe über fünf
  Schreibweisen zeigte, dass `await import('@supabase/supabase-js')`, `require('pg')`
  und der Side-Effect-Import `import '@supabase/supabase-js'` durchlaufen. Syntax und
  Paketmuster sind jetzt getrennt. Im Ist-Stand findet die breitere Fassung keine
  zusätzliche Stelle — es gab keine versteckten dynamischen Importe, und keine
  Fehlalarme.
- **41 Regressionstests** (`scripts/__tests__/check-qaf-core-portability.test.ts`), die
  das Gate als Prozess aufrufen statt seine Muster nachzubauen. Schutzmaßnahmen einzeln
  einzeln mutationsgeprüft. Dabei aufgedeckt und behoben: die erste Fassung der
  `__tests__`-Probe prüfte Verzeichnis-Ausnahme und Endungsfilter gleichzeitig — das
  Entfernen der Verzeichnis-Ausnahme machte keinen Test rot, weil der Endungsfilter
  die Datei ohnehin fing.
- **Der zeilenweise Scan war mit einem Zeilenumbruch umgehbar.** Ein unabhängiges Review
  hat zehn Umgehungen mit echten Dateien ausgemessen; sieben liefen durch, darunter genau
  die drei Formen, die der Beitrag zuvor als geschlossen auswies — nur mit einem
  Zeilenumbruch zwischen Schlüsselwort und Pfad (`import { createClient } from\n
  "@supabase/supabase-js"`). In CI läuft kein Formatierungszwang, der das vorher
  normalisiert. Jetzt wird die **ganze Datei** gescannt, Kommentare vorher über einen
  Zustandsautomaten entfernt (ein Regex hätte `'https://…'` mitten im String
  abgeschnitten und den Importpfad danach unsichtbar gemacht). Der Backtick gehört jetzt
  zu den Anführungszeichen — `await import(\`@supabase/supabase-js\`)` lief sonst durch.
- **Vier Grenzen bleiben und stehen im Skriptkopf:** berechneter Modulname
  (`'@supabase/' + 'supabase-js'`), Zugriff ohne Importsatz (`globalThis`), Indirektion
  über ein Modul außerhalb des Scopes (auch `check:boundaries` deckt das nicht ab, weil
  „module → module" erlaubt ist), und ein Fehlalarm bei Import-Text in Strings. Ein
  grüner Lauf heißt „keine versehentliche neue Kopplung in diesen Pfaden", nicht „das
  Repository ist portabel". `app/**` ist ausdrücklich nicht abgedeckt, insbesondere
  `app/qaf-differences/actions.ts` (4771 Zeilen, Fachlogik und Datenzugriff gemischt).
- **Drei Fehlalarme behoben:** ein Import in einem Blockkommentar, eine URL in einem
  String, ein Modulname als Objekt-Wert.
- **Dritte Review-Runde: der Textansatz ist aufgegeben.** Der Fehlermodus ließ sich
  erneut auslösen, diesmal gegen das eigene Sicherheitsnetz: steht ein Pfad-Regex nach
  einer schließenden Klammer, liest die Heuristik „Division" und `\/*` öffnet einen
  Blockkommentar — den ein beliebiger späterer JSDoc-Kommentar wieder schließt. Damit
  endete der Automat regulär, das Endzustand-Netz sah nichts, und ein echter Import war
  spurlos verschwunden. Drei Textfassungen, drei Lücken, alle von Reviews gefunden: das
  ist strukturell, denn Regex gegen Division zu unterscheiden braucht Parser-Kontext.
- **Jetzt liest das Gate den Syntaxbaum.** `typescript` (bereits Projekt-Abhängigkeit)
  liefert die Modulangaben echter Import-Knoten — statischer Import, Re-Export,
  Side-Effect-Import, `import(…)`, `require(…)`, `import x = require(…)`. Damit fällt die
  ganze Fehlerklasse weg, und die Muster prüfen nur noch Modulnamen statt Syntax. Als
  Nebenwirkung entfällt der frühere bewusste Fehlalarm bei Import-Text in Zeichenketten.
- **Bekannte Grenze, im Skriptkopf als Punkt 5 benannt:** ein Alias auf `require` umgeht
  die Prüfung, weil der Bezeichner-*Name* erkannt wird und nicht seine Bindung — also
  `const req = require` und, praktisch relevanter weil idiomatisch,
  `createRequire(import.meta.url)`. Ein Review hat das gefunden. Der Schaden bleibt
  lokal: nur dieser Aufruf ist unsichtbar, alle übrigen Importe derselben Datei werden
  weiter gelesen. Das zu schließen hieße Bindungsauflösung über `ts.createProgram` — für
  dieses Gate unverhältnismäßig und dieselbe Grenze wie der berechnete Modulname.
- **Netz an der neuen Stelle:** meldet der Parser Syntaxfehler, gilt die Datei als
  **ungeprüft** (Code 2), nicht als sauber. Bewusst nur `parseDiagnostics` — ein
  „Modul nicht gefunden" darf eine Datei nicht ungeprüft machen.
- **Zur Einordnung des Review-Testfalls:** die dort gezeigte Datei ist selbst kein
  valides TypeScript (`tsc` lehnt sie ab), `build-check` hätte sie gefangen. Der Fund
  bleibt berechtigt — das Gate meldete „sauber" statt zu scheitern — aber der Nachweis
  wurde mit einer *validen* Variante wiederholt (`if (cond) /…/.test(…)`), und die wird
  jetzt korrekt erkannt.
- **Der frühere Fund, der zu dieser Runde führte: ein falsches Negativ im Tokenizer.** Ein gewöhnliches
  Pfad-Regex (`/^\/api\/*$/`) ließ den Automaten in den Blockkommentar-Zustand kippen,
  weil er `\` im Code-Zustand nicht als Escape behandelte und danach `/*` sah. Ab dieser
  Stelle wurde der **komplette Rest der Datei verworfen** — inklusive eines
  unverschleierten `import … from "@supabase/supabase-js"`. Exit 0, „sauber", ohne jede
  Umgehungsabsicht. Behoben durch einen eigenen Regex-Zustand mit
  Divisions-Unterscheidung nach dem letzten bedeutungstragenden Zeichen.
- **Netz darunter:** eine gültige TypeScript-Datei endet im Code-Zustand. Endet der
  Automat anders, gilt die Datei als **nicht geprüft** und das Gate bricht mit Code 2 ab,
  statt sie stillschweigend zu überspringen. Ein grüner Lauf, der eine ungeprüfte Datei
  einschließt, wäre der schlimmste Fehlermodus dieses Gates.
- Der Abschnitt „Was dieses Gate nicht sieht" beansprucht **keine Vollständigkeit** mehr —
  er ist der Stand des Wissens nach zwei Review-Runden, nicht eine abgeschlossene Liste.
- **Zwei Schutzmaßnahmen waren ungeprüft, eine wirkungslos.** Die
  `node_modules`-Ausnahme machte bei Entfernung keinen Test rot (jetzt geprüft). Der
  Datei-Scope-Test war grün, obwohl die Maßnahme entfernt war — ein `ENOTDIR`-Absturz
  liefert dieselbe Exitrichtung und denselben Pfad in der Meldung wie ein Fund; er prüft
  jetzt auf die Gate-Meldung. Und der zusätzliche Whitespace-Kollaps war redundant, weil
  `\s*` den Zeilenumbruch schon deckt — entfernt, statt ihn als Schutzmaßnahme zu führen.
- Ein Scan, der keine Datei erreicht, beendet mit Code 2 statt „sauber" zu melden.
## 2026-08-05 – QAF-V2: Traceability-Zuordnung und Abdeckungsklassifikation für Loop 2

Zwei benannte Lücken geschlossen, beide aus den Dokumenten selbst.

- **Die Traceability-Zuordnung ist gemacht.** `testinventar.md` führte sie als
  „**nicht gemacht** und nächster sinnvoller Schritt". Für die sechs Anforderungen, die
  ihre Abnahme über Tests formulieren (`DET-003`, `FOR-002`, `ACC-003`, `AI-001`,
  `TST-002`, `ARC-003`), ist jetzt belegt, ob der tragende Test in CI läuft: zwei tun
  es, eine teilweise, zwei gar nicht, eine ist nicht testförmig.
- **`DET-003` ist nicht abgesichert, mit Zahl:** kein Rundungsprofil im Repository
  (`roundingProfile`/`rounding_profile` haben null Treffer), aber **71 Rundungsstellen
  in 26 Dateien** Produktivcode von `lib/qaf-differences` (13 × `Math.round`,
  54 × `toFixed`, 4 × `maximumFractionDigits`). Die von der Matrix genannte `format-de.ts` rundet
  selbst nicht, sondern formatiert über `Intl.NumberFormat` mit einem
  `decimals`-Parameter je Aufruf. Die Determinismustests belegen Hash-Stabilität,
  nicht Versionierung der Rundung.
- **`FOR-002` fehlt der Produktivcode, nicht der Test:** `D16` und `D17` existieren in
  `data-quality.ts` als Typvariante und Gewichtung, werden aber nirgends gesetzt.
- **Achtstufige Abdeckungsklassifikation** in `loop-2-abnahmestand.md`, wie vom Product
  Owner gefordert. Nicht vermischt: in CI ausgeführt (Hauptsuite 423/6776, echte
  Datenbank 10/146) · durch anonymisierte reale Fixtures (2/11) · synthetisch · nur
  lokal (48/170) · `deferred_by_product_owner` (21/84) · begründet
  `local_real_file_only` (3/6) · noch nicht abgesichert (24/80).
- **Zwei Vorbehalte ausdrücklich benannt:** „korpusweit lokal" ist kein disjunkter Topf
  (die Gate-Mengen 20/5/13 überschneiden sich und werden nicht addiert), und die Zeile
  „synthetisch" ist ein Komplement, keine Messung — sie trägt kein Abnahmekriterium.
- **Veraltete Angaben korrigiert:** drei Fixtures → vier, „eine Datei mit 5 Tests" →
  zwei Dateien mit 11 Tests.
- **Ein Beleg war ein Werkzeugbefund — und die erste Korrektur enthielt denselben
  Fehler noch einmal.** Die Aussage „eine Suche nach den Fixture-Namen findet nur diese
  beiden Dateien" trug nicht. Die Korrektur behauptete dann, *beide* Leser nennten keinen
  Dateinamen und der Pfad komme außerhalb nur in **drei** Erzeugungsskripten vor — auch
  falsch: `golden-corpus.test.ts` nennt fünf Fixture-Namen wörtlich, und in
  `anonymise_workbook.py` ist der einzige Treffer der Seed-Standardwert
  `"qaf-golden-2026"`, kein Pfadbezug. Es sind zwei Skripte. Zweimal eine
  Substring-Trefferliste gezählt statt gelesen; ein Review hat beide gefunden. Die
  belastbare Aussage ist die schwächere: keine Testdatei außerhalb des
  Fixture-Verzeichnisses liest die Fixtures.
- **`TST-002` zitierte einen nicht gemergten PR als CI-Beleg.** Die Regressionssperre des
  Portabilitäts-Gates liegt in einem offenen Pull Request und existiert im gemessenen
  Commit nicht. Entfernt, bis der PR gemergt ist. Ebenfalls korrigiert: „vier
  Golden-Corpus-Suiten" sind zwei Dateien.
- **Gate-Zahlen präzisiert:** gezählt werden Gates, nicht Erwähnungen. `INPUT_DIR` wird in
  21 Dateien deklariert, aber nur in 20 als `existsSync`-Gate benutzt; von 13 Dateien mit
  „CORPUS" hat eine gar kein Gate (Variablenname `REAL_CORPUS_NODE_CLASSES`), also 12.
- **`triage-env-gated-tests.md`** als teilweise überholt markiert: es spricht von drei
  Fixtures, seit #441 sind es vier. Der historische Messpunkt bleibt stehen.

## 2026-08-03 – QAF-V2: Aria-Operating-Pack installiert, Loop 0+1 abgeschlossen

**Repo-Pack** (`QAF_Aria_kadi-v2_Repo_Ready_v1.0`, von Kais via GitHub bereitgestellt)
per Installer eingespielt: projektspezifische Skills (`qaf-loop`, `qaf-golden-corpus`,
`qaf-determinism-gate`, `qaf-review`, `qaf-loop-handover`, `qaf-confidentiality-guard`,
`qaf-bmw-ui-review`), sechs Subagenten, Operating-Model-Doku, kanonisches Schema
(`schemas/qaf-comparison-canonical-v1.0.json`), Verify-Skripte (`scripts/qaf-v2/`),
markierte Blöcke in CLAUDE.md/AGENTS.md/.gitignore/.worktreeinclude. Reale
Referenzdateien liegen gitignoriert unter `.local/qaf-reference/`.

**Loop 0 (reproduzierbare Baseline)** auf `main` = `d639c0b`: Typecheck/Tests/Build/
Portability-strict grün (6.916 Tests, 124 skipped), Lint-Rot als vorbestehend
klassifiziert (61 Errors app-weit, CI continue-on-error). HICE-Referenzvergleich aus
den Originaldateien reproduziert: Brücke 397,9058 → 410,8689 (Δ +12,9631) exakt,
zwei Läufe = ein SHA-256 (`audit/__tests__/hice-reproduction.test.ts`).

**Loop 1 (Architekturgraph)**: 190 Module / 679 interne Kanten / 18 Import-Zyklen
(`audit/architecture/module-graph.json`), 22 harte Modus-Gates außerhalb der Registry,
11 Deep-Import-Dateien, Parallel-Registry HEADER_TO_KEY, Persistenz- und Exportfluss
dokumentiert (`audit/architecture/*.md`).

**Entscheidung**: Strangler-V2 bestätigt durch ausgeführten Audit
(`audit/decisions/ADR-000-baseline.md`, Challenge 4 Wege × 11 Kriterien in
`audit/strangler-v2-challenge.md`). Kernbefunde B-01…B-12 in
`audit/baseline/befunde-loop0.md`; Gap-Matrix in 7 Kategorien (`audit/gap-matrix.md`);
aktualisierte Roadmap mit Exit-Kriterien (`audit/loop-roadmap-aktualisiert.md`) —
neu: Lint-Baseline vor Loop 2, Loop 5 verkleinert (storage_key existiert), Loop 6
beginnt mit 24-Bereiche-Governance-Entscheid.


## 2026-08-02 – QAF-Compare V2: der erste Lauf aus echten Dateien

Die Anzeigeebene war fertig, aber an keine Datenquelle angeschlossen: `buildKpiTiles`,
`buildWaterfall` und `validationBanner` hatten zusammen null Aufrufe ausserhalb von Tests.
`buildOverviewRun` ist das fehlende Bindeglied — aus zwei geöffneten Arbeitsmappen entsteht,
was die Übersicht zeigt.

**Die Brückenschritte kommen aus der Zeilenrollen-Registry**, nicht aus einer Liste im
Orchestrator. `additiveRowSpecs()` trägt selbst den Hinweis, dass sie „die einzige zulässige
Grundlage für Preisbrücke und Additivitätsprüfung" ist — eine zweite Liste daneben wäre genau
die zweite Wahrheit, vor der dieser Satz warnt.

**Gerechnet wird nichts selbst.** Deltas und die Frage, ob die Brücke schliesst, entscheiden
`diffSummaryMetrics` und `buildWaterfall`. Bleibt ein Rest, zeigt die Anzeige die Diagnose;
der Orchestrator korrigiert das nicht weg.

**Zeilen, bei denen eine Seite keinen Wert trägt, bewegen die Summe am stärksten.** Ein
Posten, der im Vergabestand bepreist war und jetzt leer steht, ist eine Senkung um seinen
vollen Betrag; umgekehrt eine erstmalige Bepreisung. Ihr `deltaAbsolute` ist nicht rechenbar
— wer sie deshalb wegfiltert, bekommt eine Brücke, die nicht schliesst, ohne zu wissen warum.
Genau das war der erste Stand dieses Moduls. Dieselbe Doktrin gilt auf der Materialebene.

Am Referenzfall belegt: Mit der Regel schliesst die Brücke über sieben Schritte, einer davon
ein entfallener Kostenblock, der als solcher beschriftet ist. Ohne die Regel schliesst sie
nicht — das ist zugleich die Antwort auf die Frage, ob der Posten entfallen oder nur nicht
erhoben war: Wäre er nicht erhoben, ginge die Summe auch mit der Regel nicht auf.

Fehlt ein Preisblock ganz, entsteht kein halber Lauf: Die Brücke steht auf „nicht gerechnet",
das Banner sperrt die Zusammenfassung, und der Grund steht im Klartext daneben.

Mutations-Proben: Wegfall wieder weggefiltert (2 rot), Total auf die Zwischensumme umgebogen
(2 rot), Schritte aus allen statt nur den additiven Zeilen (2 rot).

### Verdrahtet: `/qaf-differences/[id]/v2`

Die Originaldateien sind nach dem Ingest nicht mehr adressierbar — `qaf_file` führt keinen
Storage-Pfad. Ein Lauf direkt aus den Mappen ist für bestehende Vergleiche also gar nicht
möglich. `summaryMetricsFromRows` baut den Preisblock stattdessen aus den persistierten
`qaf_summary_metric`-Zeilen zurück, denselben Weg, den ein Re-Compare schon benutzt. Damit
lässt sich jeder bestehende Vergleich als V2-Übersicht rendern, ohne Migration und ohne
Originaldatei.

Deshalb nimmt `buildOverviewRun` jetzt den Preisblock entgegen statt der Arbeitsmappe: Beide
Quellen — frische Dateien und gespeicherte Zeilen — münden in denselben Lauf.

**Was der Rückbau nicht liefert, steht auf der Seite.** Aus der Datenbank kommen Werte und
Zellen zurück, aber keine Formeln und keine Blattnamen (die Spalte führt `K10`, nicht
`SUMMARY!K10`). Kennzahlen und Preisbrücke sind vollständig, der Zellnachweis noch nicht.
Ein Test hält diese Grenze fest, damit sie beim Erweitern auffällt statt in der Anzeige.

Aus demselben Grund steht die Vergleichsart auf „nicht bestimmt": Lieferant und Standort
kennt die App nur aus Stammdaten, die Spezifikation verlangt sie aus den Dateien — eine
Ableitung wäre ein Zirkelschluss, weil die Paarung über die Sachnummer läuft.

Mutations-Proben dazu: fehlenden Preisblock still schlucken (1 rot), Degradation nicht
melden (1 rot).

## 2026-08-02 – QAF-Compare V2: Referenzansicht der Übersicht (View-Phase V1)

Die V2-Anzeigeebene war gebaut, aber an keine Datenquelle angeschlossen: `buildWaterfall`,
`buildKpiTiles`, `evidenceFor`, `buildAiReady`, `sectionHeaders` und `validationBanner`
hatten zusammen **null** Aufrufe ausserhalb von Tests. In der App führte kein Weg von zwei
Dateien zu einem Lauf — die Ansichten liessen sich weder ansehen noch gegen U-01…U-08
abnehmen.

`/qaf-differences/referenz` schliesst diese Lücke, bis die Integration sie richtig löst.
Die Seite zeigt die Übersichts-Sektion aus Kap. 21 erstmals als Ganzes: Kopf, fünf
KPI-Kacheln, Preisbrücke und Zellnachweis.

**Der Referenzfall liefert Eingaben, keine Ergebnisse.** Kacheln, Brücke und Zellnachweise
entstehen aus denselben Funktionen, die ein echter Lauf benutzt. Hartkodierte Ergebnisse
würden zeigen, was sich jemand ausgedacht hat, statt was die Anzeigeebene tut.

**Der zweite Fall schliesst absichtlich nicht.** Ein Referenzfall, der nur den guten
Zustand zeigt, verschweigt genau die Eigenschaft, für die das Modul gebaut wurde: dass es
sich weigert zu zeichnen, wenn ein Betrag unerklärt bleibt. Die beiden Fälle unterscheiden
sich in genau einem Schritt, sonst wäre der Unterschied nicht zurechenbar.

Zur Abnahme nennt die Spec in U-06 Snapshot-Tests. Hier stehen stattdessen gezielte
Zusicherungen: Das Repo führt kein Snapshot-Werkzeug, und ein Snapshot hält fest, *was*
gerendert wird, ohne zu prüfen, *ob es stimmt* — er ist nach jeder Änderung mit `-u`
erneuerbar, ohne dass jemand hinsieht. Geprüft sind U-01 (jede Kachel trägt ihre Quelle),
U-03 (kein Runden vor dem Anzeigen) und U-04 (Banner sichtbar, Zusammenfassung gesperrt).

Mutations-Proben: Quellenangabe der Kachel entfernt, Zellnachweis zugeklappt, Brücke aus
der Sektion entfernt — 1/1/2 rot, danach wieder vollständig grün.

Alle Daten sind frei erfunden; ein Test wacht grob darüber, weil die Datei mit ausgeliefert
wird. Ein anonymisierter Abzug eines echten Laufs hat dort nichts zu suchen.

### Nach dem ersten Blick auf die Ansicht: vier Korrekturen

**Der Anfangsbalken der Brücke wurde nicht gezeichnet.** Bei beschnittener Skala fiel die
untere Skalenkante mit dem Anfangsstand zusammen — er ist bei einer steigenden Brücke die
kleinste Kante überhaupt. Damit bekam er die Höhe null und verschwand; was links stand, war
bereits der erste Anstieg, und die Brücke wirkte verrutscht. Die beschnittene Skala bekommt
jetzt auch nach unten Luft. Bei voller Skala bleibt die Null die Grundlinie.

**Die Konnektoren fehlten**, die Kap. 22 verlangt. Sie sitzen auf dem aufgelaufenen Stand.
Dass `base` bei einer Senkung bereits der Stand danach ist und bei einem Anstieg der davor,
löst `runningLevel` auf — sonst träfe die Linie den Balken dort, wo er nicht endet.

**Grosse Beträge werden gekürzt:** aus `77.406.000,00 EUR` wird `77,41 Mio. EUR`, ab einer
Milliarde entsprechend `Mrd.`. Nur bei Einheiten, bei denen Millionen vorkommen — ein
Stückpreis in Mio. wäre eine Falschaussage über die Grössenordnung.

**Die Diagnose der nicht schliessenden Brücke zeigte rohe Gleitkommazahlen** („es bleibt ein
Rest von 1.370000000000004"), weil die Formatierung nur in der Komponente lag und der Kern
nicht dorthin greifen darf. `format-de.ts` liegt jetzt im Kern, wo beide Seiten sie
erreichen. Die bisherigen Zusicherungen prüften nur, *dass* das Wort „Rest" vorkommt, nie in
welcher Form.

Nachkommastellen: zwei überall, vier bei Stückpreisen. In dieser Grössenordnung entscheiden
die hinteren Stellen über den Betrag. Der Eintrag für Prozentwerte stand auf sechs, während
die Anzeige seit jeher eine benutzte — ein toter Widerspruch, jetzt aufgelöst.

Mutations-Proben: Luft nach unten entfernt, Konnektor auf die falsche Kante, Kürzung
abgeschaltet, Diagnose wieder roh interpoliert — je 1 bis 2 rot, danach wieder grün.

### Zweiter Blick: die Brücke war immer noch falsch

**Die Konnektoren verbanden nichts.** Mit den mittig gesetzten, auf 80 px begrenzten Balken
liegen zwischen zwei Balken rund 126 px; die Linie lief aber nur über die Flex-Lücke von
6 px. Sie schwebte frei in der Lücke und berührte keinen der beiden Balken — schlimmer als
gar keine Linie, weil sie wie ein Darstellungsfehler aussah. Sie läuft jetzt von Balkenmitte
zu Balkenmitte und hinter den Balken durch.

**Der Anfangsbalken war ein Strich neben einem Klotz.** 87,42 und 92,15 liegen 5 %
auseinander, wurden auf der beschnittenen Skala aber als 4,5 % und 92,5 % hohe Balken
gezeichnet. Das liest sich, als hätte der Vergabestand fast nichts gekostet. Die
beschnittene Skala bekommt jetzt einen echten Sockel: Der niedrigere der beiden Stände
erhält mindestens ein Viertel der Zeichenfläche. Damit ist der Anfangsstand ein Balken, und
die Schritte bleiben sichtbar (der kleinste liegt bei 2,3 %).

Neu abgesichert ist auch, dass die Treppe lückenlos schliesst: Wo ein Balken endet, beginnt
der nächste. Klafft dort eine Lücke, behauptet das Bild einen Sprung, den die Zahlen nicht
hergeben — geprüft über die ganze Kette, nicht nur an einem Übergang.

Mutations-Proben: Sockel entfernt (2 rot), Konnektor auf die Flex-Lücke zurückgesetzt
(1 rot), Treppe zerrissen (3 rot).

### Dritter Blick: die Ursache lag im CSS, nicht in der Geometrie

**`margin-bottom` in Prozent rechnet gegen die Breite, nicht gegen die Höhe.** Der Balken
wurde so positioniert, der Konnektor über `bottom` — das korrekt gegen die Höhe rechnet.
Beide massen gegen verschiedene Bezugsgrössen, weshalb die Linien über den Balken schwebten
und keine der drei vorherigen Nachbesserungen das Bild geradezog. Der Balken ist jetzt
absolut positioniert. Ein Test hält fest, dass er `bottom` trägt und kein `margin-bottom`.

**Die Skala beginnt bei null.** Die beschnittene Skala hielt kleine Schritte sichtbar, log
dafür über die Grössenordnung, und zwei Nachbesserungen haben das gemildert statt behoben —
die Verzerrung steckte in der beschnittenen Skala selbst. Stände stehen jetzt in ihrem
tatsächlichen Verhältnis.

**Die Achse ist beschriftet**, mit gleichmässigen Werten von null bis zum Maximum und
gestrichelten Gitterlinien. Ohne sie ist ein Balken nur eine Fläche: Man sieht, dass etwas
grösser ist als etwas anderes, aber nicht um wie viel. Die Werte stehen zusätzlich an den
Balken.

**Schritte, die zu dünn für eine Fläche sind, werden zur Linie mit ihrem Wert.** Ein Balken
von einem halben Pixel ist weder sichtbar noch ehrlich; ein künstlich vergrösserter wäre
gelogen.

Der Referenzfall hat neue Zahlen (2,46 auf 3,53 EUR/Stück), bei denen die Brücke ihre Stärke
zeigt. Daneben steht jetzt ein dritter Fall mit kleinen Schritten auf hohem Niveau — 87,42
auf 92,15, die übliche Lage bei einem echten QAF, in der die Schritte zu Linien werden. Eine
Referenzansicht, die nur die dankbare Grössenordnung zeigt, ist Werbung.

Mutations-Proben: Null-Skala aufgehoben (4 rot), Haarlinien abgeschaltet (2 rot), der
CSS-Fehler zurückgesetzt (1 rot).

## 2026-08-02 – QAF-Compare V2: die ersten Anzeigekomponenten (Teil D, V1)

Kopfbereich, KPI-Kachel, Preisbrücken-Wasserfall und Zellnachweis-Panel — die Sichtbarkeits-
Phase V1 aus Kap. 27. Die Komponenten sind dünn: Was Entscheidungen trifft, liegt in den
geprüften Vorlagen (`view-specs`, `chart-specs`) oder als reiner Helfer daneben, und genau
diese Helfer sind getestet. Dasselbe Muster, das `qaf-provenance.tsx` schon verwendet.

**Der Wasserfall zeichnet nicht, was nicht schliesst.** Bleibt ein Restposten, erscheint
statt des Diagramms die Diagnose. Ein Balken „Übrige" versteckt genau den Betrag, dessen
Herkunft niemand kennt — das war Befund F-01.

**Das Vorzeichen steht immer, auch bei Grün.** Farbe allein trägt die Aussage nicht: nicht
für jemanden, der Rot und Grün nicht unterscheidet, und nicht in einem Ausdruck. Aus
demselben Grund trägt der Senkungs-Balken zusätzlich einen Rand.

**„Vergleichbarkeit nicht geprüft" bekommt nicht Grün.** Wo niemand hingesehen hat, ist
nichts in Ordnung — es ist nur nichts bekannt.

**Der Zellbezug ist kopierbar** und setzt Blattnamen mit Sonderzeichen in Hochkommata; ohne
sie versteht Excel den eingefügten Bezug nicht. Und der Wertzustand steht im Klartext:
„fester Wert" statt `constant` — der Unterschied zu „gerechnet" ist der Punkt, an dem eine
Verhandlung ansetzt.

Mutations-Proben: Hochkommata im Blattnamen, „nicht geprüft" in Grün, Anteil ohne
Prozentumrechnung, fehlendes Pluszeichen, beschnittene Skala bei null, Senkung nur über
Farbe, Senkung mit vorzeichenbehafteter Höhe. Jede traf ihren Test.

**Nachgezogen: die Weigerungen selbst waren ungeprüft.** Getestet waren die reinen Helfer,
und auf der Datenebene, dass `buildWaterfall` bei offenem Restposten `not_reconciled`
meldet. Dass die Komponente daraus die Diagnose *statt* eines Diagramms macht, prüfte
nichts — die Zusicherung aus Befund F-01 hing an einer Zeile, die jederzeit unbemerkt
hätte verschwinden können. Dasselbe galt für das Fehlerbanner im Kopf und den Leer-Hinweis
des Zellnachweises.

Der Rendering-Test deckt genau diese drei Stellen ab, mit Gegenprobe: bei schliessender
Brücke *muss* das Diagramm erscheinen, sonst wäre „zeigt kein Diagramm" auch dann erfüllt,
wenn die Komponente nie eines zeichnet. Drei Mutations-Proben gegen je genau ihre Weigerung
(Wasserfall 2 rot, Banner 1 rot, Leer-Hinweis 1 rot); nach der Wiederherstellung wieder
vollständig grün.

## 2026-08-02 – QAF-Compare V2: Tabellenvorlagen der Anzeigeebene

Material, Fertigung und Werkzeuge zeigen dieselbe Tabelle mit denselben Spalten, denselben
Filtern und derselben Sortierung. Drei eigene Implementierungen wären drei Gelegenheiten, es
verschieden zu machen — und der Nutzer müsste dreimal lernen, wie er filtert.

Zwei Entscheidungen prägen `table-specs.ts`:

**Die Sortierung ist die Blattreihenfolge, nicht der Betrag.** Wer eine Position sucht, sucht
sie dort, wo sie in der Datei steht; eine nach Grösse sortierte Tabelle zwingt zum Suchen.
Nach Betrag sortieren bleibt möglich, ist aber nicht der Ausgangszustand. Bei gleicher Zeile
entscheidet die Kennung — sonst wandert die Reihenfolge zwischen zwei Läufen.

**Filter verstecken, sie löschen nicht.** Jede gefilterte Ansicht nennt, wie viele Zeilen sie
ausblendet und welche Summe damit aus dem Bild fällt. Eine Tabelle, die stillschweigend die
Hälfte weglässt, ist die bequemste Art, eine Verhandlung zu verlieren. `validateTable` prüft
zusätzlich nach, dass angezeigte plus ausgeblendete Summe die Gesamtsumme ergibt — das ist
der Fehler, den ein Filter am leichtesten macht.

Auch hier gilt U-03: erst summieren, dann runden.

Mutations-Proben: instabile Sortierung, ausgeblendete Zeilen ohne Hinweis, erst runden dann
summieren, verschwundener Betrag, Zeile ohne Bezug an falscher Stelle, Nullkosten-Filter,
Suche ohne Kennung. Jede traf ihren Test.

## 2026-08-02 – QAF-Compare V2: Diagrammvorlagen der Anzeigeebene

Die Diagramme rechnen so wenig wie die Tabellen: Sie bekommen fertige Balken, fertige
Beschriftungen und einen fertigen Zustand. Was hier entschieden wird, entscheidet sich
einmal — und nicht je Renderer neu.

`chart-specs.ts` macht zwei Regeln der Spezifikation zu Code, beide Reaktionen auf konkrete
Fehlgriffe der Vorgängerfassung:

**Eine Brücke, die nicht schliesst, wird nicht gezeichnet.** Sie bekommt einen Fehlerzustand
mit Diagnoseliste — niemals einen Balken „Übrige". Ein solcher Balken versteckt genau den
Betrag, dessen Herkunft niemand kennt, und lässt das Bild vollständig aussehen.

**Eine Auswahl nennt, wovon sie eine Auswahl ist.** „Top 5 von 56 · Rest −0,42" gehört ins
Bild, nicht nur ins JSON. Ohne diese Angabe liest sich eine Top-Liste wie eine vollständige,
und der ausgeblendete Rest verschwindet aus der Diskussion.

Dazu die Leermeldungen (U-04): „geprüft und leer", „nicht anwendbar" und „nicht berechnet"
sehen auf dem Bildschirm gleich aus und bedeuten Verschiedenes. „Keine Auffälligkeiten" bei
einer nie gerechneten Sektion ist die gefährlichste Falschaussage des ganzen Berichts, weil
sie beruhigt.

### Ein Fehler in der eigenen Schnittstelle

Die Gegenprobe am echten Fall — Schritte entfernen, Bild muss verweigert werden — schlug
fehl: `buildWaterfall` glaubte dem mitgegebenen Restposten mehr als den Balken. Ein Aufrufer,
der veraltete Schritte mit einem alten Restposten kombiniert, hätte einen Wasserfall
bekommen, dessen Balken nicht zum Ende führen. Der Rest wird jetzt immer selbst nachgerechnet;
ein mitgegebener zählt zusätzlich, nie stattdessen.

### Abnahme

Am Referenzfall wird die Einmalzahlungs-Brücke gezeichnet, trägt dieselben drei Schritte,
beginnt und endet auf den ausgewiesenen Summen und führt lückenlos durch. Wird sie künstlich
verletzt, entsteht kein Bild.

Mutations-Proben: Brücke trotz Rest, Rest nicht nachgerechnet, mitgegebener Rest schlägt die
Nachrechnung, Senkung auf falscher Kante, beschnittene Skala ohne Kennzeichnung, instabile
Sortierung, Auswahl ohne Grundgesamtheit, „nicht berechnet" als positive Meldung. Jede traf
ihren Test.

## 2026-08-02 – QAF-Compare V2: die Datenverträge der Anzeigeebene (Teil D, V1)

Leitgedanke der Spezifikation: ein kanonischer Datenbaum, ein Renderer je Sektion. Die
Anzeige rechnet nichts selbst; jede angezeigte Zahl trägt einen Verweis in den Kern, und
Webansicht, Foliensatz, Arbeitsmappe und Offline-Ansicht lesen dieselbe Vorlage.

Der Grund ist nicht Ordnungsliebe. Sobald eine der vier Ausgaben selbst rechnet, laufen sie
auseinander — und zwar unbemerkt, weil niemand eine Folie Zeile für Zeile mit dem Bildschirm
vergleicht.

`view-specs.ts` macht zwei Invarianten zu Code:

- **U-02 — keine Anzeigezahl ohne Verweis in den Kern.** `validateViewSpec` meldet jede
  Kachel, die einen Wert zeigt und keine Quelle nennt.
- **U-03 — Anzeigesummen sind Kernsummen.** `displaySum` summiert erst und rundet dann. Die
  Reihenfolge ist der Unterschied zwischen einer Spaltensumme, die zur Kernsumme passt, und
  einer, die um Cent daneben liegt und jede Rückfrage auslöst.

Dazu das **Zellnachweis-Panel** als eine Auflösung für das ganze Modul (Datei, Blatt, Zelle,
Formeltext, Wertzustand, verknüpfte Befunde). Vier Sektionen mit vier eigenen Panels wären
vier Gelegenheiten, es verschieden zu machen.

Und die **Sektionszustände** (U-04): Bei „nicht berechnet" steht der Hinweis sichtbar dabei.
Eine leer wirkende Sektion, die nie gerechnet wurde, liest sich sonst als Entwarnung. Das
Fehlerbanner sperrt die KI-Zusammenfassung sichtbar, statt sie wegzulassen — eine fehlende
Zusammenfassung erklärt sich niemandem von selbst.

`deep-links.ts` macht jeden Befund teilbar (U-07). Eine unbekannte Sektion oder eine Kennung
in fremdem Format wird zurückgewiesen, statt still auf die Übersicht zu leiten: Der Empfänger
glaubt sonst, er sehe den zitierten Befund.

### Ein Fehler, den ein Test gefangen hat

Ein Befund im aktuellen Stand hängte sich auch an die gleichnamige Zelle des Vergabestands —
beide Stände tragen dieselben Zellbezüge. Der Nachweis hätte dort etwas behauptet, das dort
nicht gilt. Die Datei-Rolle gehört in den Schlüssel.

Mutations-Proben: erst runden dann summieren, Datei-Rolle im Schlüssel, nicht bestimmbares
Delta, Kachel ohne Verweis, sichtbarer Hinweis bei „nicht berechnet", fremde Kennung,
unbekannte Sektion. Jede traf ihren Test.

## 2026-08-02 – QAF-Compare V2: die ai_ready-Fläche

Die Übergabe an die Sprachschicht — bewusst so gebaut, dass dort nichts mehr zu rechnen ist.
Zwei Doktrinen tragen `ai-ready.ts`:

**Zahlen entstehen nie im Modell.** Jede spätere Aussage ist die Umformulierung eines Fakts,
der hier fertig steht: mit Werten, Einheit, Differenz-Kennungen und Zellen. Ein Sprachmodell,
das eine Zahl selbst bildet, bildet früher oder später eine falsche; eines, das nur
umformuliert, kann das nicht.

**„Nichts gefunden" und „nicht berechnet" sind zweierlei.** Ohne diese Unterscheidung
schreibt die Sprachschicht „keine Auffälligkeiten", wo in Wahrheit ein Modul gar nicht
gelaufen ist — der Fehler, der in einer Verhandlung am teuersten wird, weil er wie eine
Entwarnung klingt. Der Zustand kommt deshalb aus `computed` und nicht aus der Zahl der
Fakten: Null Fakten sehen bei einer geprüften und bei einer übersprungenen Sektion gleich aus.

Der Leseleitfaden steht im Ergebnis und nicht in einer Dokumentation daneben — was nicht in
der Datei steht, kommt beim Modell nicht an.

`validateAiReady` prüft drei Zusagen nach:

- **Jede Zahl im Satz steht auch als Wert daneben.** Der unbequemste Punkt und der
  wichtigste: Sonst muss die Sprachschicht sie aus dem Satz herauslesen, und genau dabei
  entstehen die Zahlendreher, die dieser Schicht die Glaubwürdigkeit nehmen.
- **Jeder Fakt führt auf eine Zelle.**
- **Eine Vermutung ist im Satz als solche erkennbar.** Steht sie im Indikativ da, liest die
  Sprachschicht sie als Tatsache — die Kennzeichnung im Feld daneben hilft dann niemandem.

Scheitert eine Prüfung des Kerns, wird die Fläche nicht unterdrückt, sondern gesperrt: Die
Fakten bleiben sichtbar, die Zusammenfassung ist es nicht, und der Grund steht dabei. Eine
fehlende Sektion sähe aus wie ein sauberer Lauf.

### Abnahme

Die Kette läuft von den echten Dateien bis an die Übergabe: einlesen → zuordnen →
Differenzen → Hebel → Fakten. Der Betrag im Fakt ist derselbe wie der Brückenschritt des
Referenzlaufs, jeder Fakt führt auf Zellen, und die eigene Prüfung findet nichts.

Mutations-Proben: Zustand aus der Faktenzahl abgeleitet, Fakt ohne auffindbaren Beleg, Zahl
im Satz ungeprüft, Vermutung im Indikativ, gescheiterte Prüfung ohne Sperre. Jede traf ihren
Test.

## 2026-08-01 – QAF-Compare V2: Verhandlungshebel mit belegtem Betrag

In der Vorgängerfassung standen unter „Hebel" Sätze ohne Betrag und ohne Quelle (Befund
F-16). Damit kann niemand in eine Verhandlung gehen: Ein Hinweis, dessen Wirkung unbeziffert
bleibt, wandert in der Vorbereitung nach hinten, egal wie berechtigt er ist.

Neu ist `negotiation-levers.ts` mit einer Regel, die der ganzen Datei ihre Form gibt:
**Der Betrag eines Hebels ist die Summe seiner Belege.** Er wird nicht danebengeschrieben,
sondern aus den Differenzen gerechnet, auf die der Hebel verweist. `validateLevers` prüft
das auf den Cent nach — eine Zahl, die sich nicht aus ihren Belegen ergibt, ist bei der
ersten Rückfrage weg, und mit ihr die Glaubwürdigkeit des ganzen Berichts.

Sieben Regeln mit Textvorlagen; Parameter werden eingesetzt, sonst nichts. Kein Freitext,
keine KI — die Formulierung muss über hundert Vergleiche hinweg dieselbe sein, sonst liest
sie niemand mehr als Kategorie, sondern als Einzelmeinung. Bleibt eine Vorlagenstelle
stehen, ist das ein Prüfbefund: Ein Hebel, in dem `{amount}` als Text steht, ist in einer
Verhandlung eine Blamage.

Fehlt ein Beleg, entsteht kein Hebel mit geschätztem Betrag, sondern gar keiner. Und Belege
verschiedener Einheiten werden nicht addiert — EUR und EUR je Stück zusammenzuzählen ergäbe
eine Zahl, die es nicht gibt.

Dazu `differences-from-findings.ts`: Nicht jede Differenz stammt aus einer Zuordnung.
Verschwundene Auslegungsangaben, eine widersprüchliche Infozeile, ein Werkzeug ohne
Stücklistenposition — das sind Unterschiede zwischen den Ständen, auch wenn keine Position
den Besitzer wechselt. Sie gehören in denselben Katalog, sonst müsste eine Folie, die sich
darauf stützt, an der Rückverfolgung vorbei belegt werden. Bewusst **nicht** jeder Befund:
Ein hart codierter Satz oder ein Textdatum ist eine Eigenschaft der Datei, kein Unterschied
zwischen den Ständen.

### Abnahme

Die Kette wird von den echten Dateien bis zum Hebeltext durchgezogen: einlesen → zuordnen →
Differenzen → Hebel. Der Betrag des Zweitwerkzeug-Hebels ist derselbe wie der zugehörige
Brückenschritt des Referenzlaufs, die Positionszahl dieselbe wie dessen Zählung, und die
eigene Prüfung findet nichts zu beanstanden.

Mutations-Proben: fehlender Beleg, vermischte Einheiten, nicht belegter Betrag, stehen
gebliebene Vorlagenstelle, Sortierstabilität, abweichende Positionszahl, sowie drei zu den
Befund-Differenzen. Jede traf ihren Test.

## 2026-08-01 – QAF-Compare V2: Differenzkatalog und Rückverfolgung

Jede festgestellte Differenz bekommt einen Satz mit eigener Kennung und ihren Fundstellen.
Das ist die Ebene, auf der später jede Kachel, jede Folie und jede Aussage belegt wird:
Aussage → Differenz-Kennungen → Zellen.

Neu sind `all-differences.ts` (Katalog, Kennungsvergabe, Rückverfolgungs-Index und dessen
Prüfung) und `differences-from-tooling.ts` (die Übersetzung von den Werkzeug-Zuordnungen in
das bereichsübergreifende Vokabular des Katalogs).

Zwei Eigenschaften machen den Katalog überhaupt brauchbar:

- **Stabile Kennungen.** Sortiert wird nach Bereich, dann Ankerzeile, dann Art und
  Beschreibung. Die Zeile ändert sich zwischen zwei Läufen über dieselben Dateien nicht,
  während jede Reihenfolge, die sich aus der Verarbeitung ergibt, das sehr wohl kann.
- **Kein Satz ohne Fundstelle.** `validateTraceability` prüft ausdrücklich beides: dass
  jede genannte Kennung existiert und dass jede Differenz nachschlagbar ist. Ein Index, der
  ins Leere zeigt, ist schlimmer als keiner — er sieht vollständig aus.

### Die Stückumlage ist eine eigene Preisdimension

Beim Abgleich fehlten zwei Sätze. Ursache: Vorrichtungen tragen keine Einmalzahlung,
sondern eine Umlage je Stück. Bleibt sie an der Einmalzahlung hängen, verschwindet jede
Position, deren Umlage sich ändert, während der Einmalbetrag gleich bleibt — im Anlassfall
genau die Vorrichtung, deren Kostenbasis gelöscht wurde. `ToolMapping` trägt die Umlage
jetzt mit, und der Katalog führt sie als eigenen Satz.

Beim Nachziehen fiel eine zweite Sache auf: Eine Position ohne jeden Einmalbetrag bekam
trotzdem einen Satz in EUR — mit einem Betrag, den es nicht gibt. Sie stand damit zweimal
im Katalog. Solche Sätze entfallen jetzt.

### Abnahme

Gegen den Referenzfall stimmen die zuordnungsbasierten Sätze überein: sieben neue
Werkzeuge, vier Zweitwerkzeuge, eine Preisänderung, zwei Stückumlagen.

Ausdrücklich **nicht** geprüft wird die Gesamtzahl. Der Referenzlauf führt drei Sätze, die
nicht aus Zuordnungen stammen, sondern aus Datenqualitäts-Befunden; die entstehen an
anderer Stelle und fehlen hier noch. Eine Zahlengleichheit käme nur zustande, wenn sich zwei
Abweichungen gegenseitig aufheben — ein Test darauf würde Vollständigkeit behaupten, die
nicht da ist. Stattdessen hält ein eigener Test die Lücke fest.

Mutations-Proben: Zeile als Sortierkriterium, Zähler je Bereich, Differenz ohne Fundstelle,
Verweis ins Leere, verschobene Zeile im Katalog, Fundstelle je Stand, Zellzustand. Jede traf
ihren Test.

## 2026-08-01 – QAF-Compare V2: zellgenaue Datenqualitäts-Detektoren

Ein Betrag allein sagt nicht, woher er kommt. Ob eine Zelle rechnet, ob jemand das Ergebnis
von Hand überschrieben hat, ob sie auf eine fremde Datei zeigt, die niemand mitliefert —
das entscheidet, wie belastbar die Zahl ist.

Neu sind drei Dateien:

- `cell-state.ts` — die fünf Zellzustände der Spezifikation (Kap. 6.3) und die Regeln, die
  sie erkennen.
- `workbook-cells.ts` — die einzige Stelle, die wissen muss, wie ExcelJS Formeln,
  Ergebnisse und ausgeblendete Spalten ablegt. Die Detektoren arbeiten auf Momentaufnahmen
  und bleiben so ohne Datei prüfbar.
- `data-quality-cells.ts` — sieben Detektoren: D01 Datum als Text · D03 Fehlerzellen · D04
  externe Verknüpfungen · D05 neu ausgeblendete Spalten · D06 Summenzeile gegen Detailzellen
  · D08 Basis gelöscht · D13 hart codierter Satz.

Damit ist der Katalog D01–D14 abgedeckt.

### Was der erste Lauf gegen die echten Dateien zutage förderte

**D08 traf den Fall nicht, für den es den Detektor gibt.** Die Spezifikation nennt
„Formel → Konstante"; im Anlassfall ist es der unauffälligere zweite Weg: Einzelpreis und
Anzahl sind leer, daneben steht ein Stückwert. Die Wertzelle selbst hat sich dabei nicht
verändert. Jetzt zählen beide Wege — und der Detektor findet genau die zwei Zellen, die der
Referenzlauf nennt.

**Der Fehlerwert steckt verschachtelt.** ExcelJS legt `#N/A` als `{ result: { error: '#N/A' } }`
ab. Ungefiltert galten alle sechs Zellen der Plausibilitätsspalte als gewöhnliche
Formelzellen — der Detektor fand nichts.

**Verbundene Bereiche meldeten dreifach.** Ein verbundener Bereich trägt seinen Wert in
jeder überspannten Zelle; das Angebotsdatum stand damit dreimal im Befund.

**Das Einlesen stürzte ab.** `cell.text` ist bei ExcelJS berechnet und wirft, wenn die
führende Zelle eines verbundenen Bereichs leer ist.

**Datum und formatierter Text sind auch Objekte.** Beide landeten in der Formelbehandlung
und kamen dort ohne Wert wieder heraus.

### Abnahme

Gegen den Referenzfall stimmen überein: das eine als Text abgelegte Angebotsdatum an
derselben Zelle, die sechs Fehlerzellen des aktuellen Stands, alle vom Referenzlauf
genannten neu ausgeblendeten Spalten, und die beiden Beträge ohne Herleitung.

Beim Vergabestand findet die eigene Prüfung eine Fehlerzelle mehr als der Referenzlauf; der
Test hält deshalb die belastbare Richtung fest — keine der dort genannten Zellen fehlt.

Mutations-Proben zu jeder Regel: verschachtelter Fehlerwert, verbundener Bereich, gemeinsam
genutzte Formel, Datum als Formelzelle, Anzeigetext-Absturz, geleerte Basis, Fehlerwert
hinter Formel, externer Bezug, Typprüfung beim Textdatum, Zellbezug beim hart codierten
Satz, neu-versteckt gegen immer-versteckt, Summe ohne Detailwerte. Jede traf ihren Test.

## 2026-08-01 – QAF-Compare V2: Datenqualitäts-Detektoren, Teil 1

Ein Vergleich, der nur Zahlen gegenüberstellt, sagt nichts darüber, ob die Zahlen tragen.
Die Detektoren beantworten genau das: Wo ist eine Angabe verschwunden, wo widerspricht eine
Summe ihren Bestandteilen, wo trägt eine Position einen Namen aber keinen Preis.

Neu ist `lib/qaf-differences/internal/data-quality.ts` mit dem Gerüst und sieben
Detektoren aus dem Katalog (Kap. 8.7): D02 benannte Position ohne Kosten · D07 Infozeile
gegen ihre Komponenten · D09 Zeilenverschiebung · D10 mehrfach vergebene Namen · D11
benannte Zeile ohne Parameter · D12 Ankerlabel weicht ab · D14 Vergabe-Platzhalter ohne
Preis.

Zwei Regeln gelten für jeden Detektor. **Kein Befund ohne Fundstelle** — ein Hinweis, den
niemand nachschlagen kann, ist in einer Verhandlung wertlos. Und **die Schwere kommt aus
einer Matrix**, nicht aus dem Einzelfall: dieselbe gelöschte Angabe wiegt im Rechenpfad
schwerer als in einem Stammdatenfeld. Die Matrix deckt alle 17 Detektoren in allen drei
Kontexten ab; eine Lücke wäre ein Befund ohne Schwere und damit einer, der in keiner
Rangfolge auftaucht.

Die Kennungen (`DQ-<A|B|AB>-<NNN>`) werden deterministisch vergeben — Schwere, dann
Detektor, dann erste Fundstelle. Zwei Läufe über dieselben Dateien vergeben damit dieselben
Kennungen, und Verweise aus Sichten und Folien bleiben gültig.

### Zwei Befunde, die die Regeln geschärft haben

**Die Gruppengrenze.** D07 vergleicht eine Infozeile mit der Summe ihrer Komponenten. Die
naheliegende Zuordnung — alles bis zur nächsten Infozeile — lieferte am Anlassfall einen
zweiten Befund über 3,2 Mio, den es nicht gibt: die letzte Gruppe zog einen angehängten
Block an sich, der zu keiner Baugruppe gehört. Im Blatt trennt eine Leerzeile die
Baugruppen; der Zeilenlauf überspringt sie, weshalb sie sich nur noch an der nicht mehr
fortlaufenden Zeilennummer zeigt. Mit der Lücke als Grenze rekonsilieren drei der vier
Gruppen auf den Cent, und es bleibt genau der eine Befund des Referenzlaufs.

**Komponenten ohne Betrag.** Sie ergeben nicht die Summe null, sondern gar keine Summe. Als
0 gelesen entstünde ein Widerspruch zur Infozeile, den es nicht gibt. Aufgefallen, weil eine
Mutations-Probe grün blieb — die Bedingung, die den Fall abfangen sollte, war toter Code.

### Abnahme

- `data-quality-golden-case.real-files.test.ts` — prüft beides: dass der Befund des
  Referenzlaufs gefunden wird, **und** dass sonst keiner dazukommt. Ein Detektor, der zu
  viel meldet, ist so schädlich wie einer, der zu wenig meldet.
- `data-quality-fixture.test.ts` — dieselbe Blattstruktur anonymisiert, damit die CI die
  Gruppengrenze prüft.
- Mutations-Proben: Fundstelle als Sortierkriterium, Namensbedingung bei D02, Zeilen vor der
  ersten Infozeile, Komponenten ohne Betrag, „nicht geprüft" bei D12, Preisbedingung bei
  D14, Gruppengrenze an der Zeilenlücke, Toleranz. Jede traf ihren Test.

## 2026-08-01 – QAF-Compare V2: Werkzeuge, Zweitwerkzeuge und die Einmalzahlungs-Brücke

Die Einmalzahlung ist die zweite Preisdimension eines Angebots und wird regelmäßig
stiefmütterlich behandelt: eine Summe, ein Delta, fertig. Damit bleibt unbeantwortet,
wofür die Differenz anfällt — und genau darin liegt der Unterschied zwischen „neuer
Umfang wurde beauftragt" und „derselbe Umfang kostet mehr".

Neu ist `lib/qaf-differences/internal/tooling-analysis.ts`. Es ordnet die Werkzeuge beider
Stände einander zu, klassifiziert jeden Betrag nach seiner Ursache (neuer Umfang,
Zweitwerkzeug, Preisänderung, Vorrichtung mit Stückumlage) und baut daraus eine Brücke,
die von der ausgewiesenen Einmalzahlung des Vergabestands zu der des aktuellen Stands
führt. Bleibt ein Restposten, steht er als solcher da.

**Die Zuordnung läuft in zwei Durchgängen, und die Reihenfolge ist tragend.** Zuerst werden
namensgleiche Positionen gepaart — das fängt auch verschobene Zeilen, die wirtschaftlich
unverändert sind. Erst danach wird bei den übrig gebliebenen ein Zählsuffix abgestreift
(„Halter (KF1) 2" → „Halter (KF1)"); findet sich die Basisposition, ist es ein
Zweitwerkzeug. Andersherum schnappte sich das Zweitwerkzeug die Basisposition, und das
verschobene Original wäre plötzlich das zusätzliche Werkzeug. Die Brückensumme stimmte
dann zwar weiterhin, aber genau die Aussage, um die es geht, wäre vertauscht.

Ein zweites Werkzeug ist es nur, wenn die Basisposition im aktuellen Stand noch steht. Ist
sie verschwunden, wurde nichts verdoppelt, sondern ersetzt.

**Der verhandlungskritische Befund** entsteht erst aus der Kombination: ein weiteres
Werkzeug für ein bereits bewerkzeugtes Bauteil, bei dem gleichzeitig Kavitäten und
Standzeit nicht mehr angegeben sind. Jedes für sich wäre erklärbar; zusammen ist es eine
Frage, die gestellt gehört. `secondToolsWithoutSpec` zählt genau diese Fälle.

### Der SBM-Parser las die ausgelieferten Formblätter nicht

Beim Abgleich gegen den Referenzfall lieferte `parseSbmWorksheet` **eine** Position statt
82. Vier Ursachen, alle behoben:

- Der Blattkopf läuft über mehrere verbundene Zeilen; die Wiederholungen darunter landeten
  als Positionen im Ergebnis.
- Eine einzelne Leerzeile beendete die Tabelle. Diese Formblätter setzen Trennzeilen
  zwischen Baugruppen und eine Abstandszeile unter dem Kopf. Jetzt beendet erst ein
  längerer Leerlauf die Tabelle.
- Die Zeilenidentität hing allein an der Positionsnummer. Die Spalte bleibt in der Praxis
  leer, während die Bauteilbezeichnung durchgehend gepflegt ist — ein vollständig
  ausgefülltes Werkzeugblatt wurde so als leer gemeldet.
- Ohne Halt am Blattfuß wären Gesamtsummenzeile und Vorlagenhinweise als Positionen
  mitgelesen worden, was die Werkzeugkosten verdoppelt hätte.

### Abnahme

Gegen den Referenzfall stimmen jetzt überein: Positionszahlen beider Stände, die Trennung
von neuem Umfang und Zweitwerkzeugen, alle vier Zweitwerkzeuge mit ihren Beträgen, die
verschobene Zeile — und die Brücke schließt ohne Restposten, mit denselben Schritten und
denselben Beträgen wie der Referenzlauf.

- `tooling-golden-case.real-files.test.ts` — Abnahme gegen die echten Dateien, zweigeteilt
  nach Klassifikation und Parser, damit eine Abweichung zuzuordnen ist. Übergeht sich ohne
  die operator-lokalen Eingaben.
- `tooling-analysis-fixture.test.ts` + `fixtures/tooling-anonymised.json` — dieselbe
  Struktur pseudonymisiert und wertskaliert, damit die CI ein echtes Gate hat.
- Mutations-Proben: Durchgangsreihenfolge, Trennerbedingung des Zählsuffixes, fortbestehende
  Basisposition, Summenzeilen-Ausschluss, Vorrichtungserkennung, und je eine für die vier
  Parser-Regeln. Jede traf den zugehörigen Test.

## 2026-08-01 – QAF-Compare V2: verlagerte Untervorgänge und die Zeitgutschrift-Frage

Untervorgänge sind die beschreibende Ebene des Fertigungsblatts: „Bauteil auf Vorrichtung
legen", „zwei Schrauben anziehen". Sie tragen keine eigene Zeit und keine eigenen Kosten —
deshalb übersieht man leicht, dass ihr Verschwinden eine Frage aufwirft.

Fallen Arbeitsschritte weg, während die Taktzeiten der zugeordneten Stationen unverändert
bleiben, ist das ein Verhandlungspunkt: Entweder waren die Schritte nie zeitrelevant, oder
die Zeit steckt noch im Preis. Beides ist möglich, aber nur eines ist richtig — und wer die
Frage nicht stellt, bekommt keine Antwort.

Der Vergleich ordnet über den Namen zu, mehrfach vorkommende Schritte über die erhaltene
Reihenfolge; dieselbe Logik wie bei den Gleichteilen der Stückliste, und aus demselben
Grund. Was danach auf beiden Seiten übrig bleibt, gilt als Umbenennung statt als Wegfall
plus Zuwachs.

Die Frage wird nur gestellt, wenn beides zutrifft — Wegfall **und** unveränderte Taktzeiten.
Bei geänderten Taktzeiten ist die Zeit sichtbar verrechnet, da gibt es nichts zu fragen.

Gates: 6512 Tests grün (0 rot, vorher 6502), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Stationskosten unabhängig nachrechnen

Die Zelle im Formblatt sagt, was eine Station kostet. Die Parameter danebendran sagen, was
sie kosten müsste. Solange niemand beides gegeneinander rechnet, fällt eine defekte Formel
nicht auf.

Die Kosten je Stück ergeben sich aus Personal, Lohn, Sozialkostensatz, Maschinen- und
Gemeinkostensatz, Zykluszeit und Teilen je Takt. Diese Rechnung läuft jetzt unabhängig von
der Zelle, und wo beide auseinandergehen, ist das ein Befund — kein Anlass, still die eine
oder andere Zahl zu bevorzugen.

An den Stationen des realen Referenzfalls geprüft: **alle 56 stimmen** (25 im Vergabestand,
31 im aktuellen), keine Abweichung, keine als nicht prüfbar. Die rekonstruierte Formel ist
damit belegt die des Formblatts — und der bekannte Defekt liegt, wie der Referenzlauf
festhält, in der Summenzeile für Personal, Maschine und Gemeinkosten, nicht in den
Einzelzellen. Deshalb bildet die Auswertung diese Aufteilung selbst aus den Parametern statt
sie von dort zu übernehmen.

Zwei Unterscheidungen, die den Unterschied machen: Ein fehlender Zuschlagssatz wird als
„fällt nicht an" gelesen — so ist das Formblatt gemeint. Eine fehlende tragende Angabe
blockiert dagegen die Rechnung, statt sie mit Annahmen zu füllen; das Ergebnis heißt dann
„nicht prüfbar" und zählt weder als bestanden noch als Befund.

Gates: 6502 Tests grün (0 rot, vorher 6490), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: die Materialebene bekommt ein echtes CI-Gate

Die Abnahme gegen den realen Referenzfall lief bisher nur dort, wo die vertraulichen Dateien
liegen — in der Fertigungsstrecke übersprang sie sich. Die stärkste Prüfung, die es gab, war
damit kein Gate: Wer die Materialebene beschädigt hätte, wäre nicht aufgefallen.

Neu ist eine anonymisierte Ableitung desselben Falls im Repository. Namen sind
pseudonymisiert, Beträge mit einem festen Faktor skaliert — alle Verhältnisse, Deltas und
Summen bleiben exakt erhalten. Erzeugt wird sie von einem Skript, das operator-lokal neben
den echten Daten liegt und dabei prüft, dass kein Originalname, keine Originalgruppe und kein
Originalbetrag durchrutscht.

**Der schwierige Teil war nicht die Anonymisierung, sondern die Struktur.** Eine Fixture, die
nur umbenennt, prüft hinterher nichts mehr. Sie brauchte vier Anläufe, bis sie dieselben
Regeln in denselben Häufigkeiten auslöste wie das Original:

- Der erste Anlauf schrieb den aufzählenden Sammelnamen in einen gewöhnlichen um — die
  Aufzählungsregel ließ sich danach abschalten, ohne dass ein Test es merkte.
- Der zweite scheiterte daran, dass alle Pseudonyme das Wort „Teil" teilten: damit galt jeder
  Sammelname für jedes Teil als Treffer.
- Erst Pseudonyme aus einem einzigen Wort und Teilnamen, die ein Wort **außerhalb** des
  Sammelnamens tragen, reproduzierten das Original.

Alle drei Fehler kamen durch denselben Vergleich ans Licht: welche Regel-IDs löst die Fixture
aus, welche das Original. Genau diese Prüfung steht jetzt als Zusicherung in der Suite — sie
ist schärfer als der Vergleich der Zuordnungsarten, weil dieselbe Verteilung auch über andere
Wege entstehen könnte.

Die Abnahme gegen die echten Daten bleibt daneben bestehen. Es gibt jetzt beides: ein Gate,
das überall läuft, und die belastbare Prüfung gegen das Original.

Gates: 6490 Tests grün (0 rot, vorher 6481), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Stationsmapping und Parameteridentität

Die Kernaussage einer Fertigungsanalyse lautet oft „die Hauptlinie ist unverändert, das
Delta kommt aus neuen Stationen". Das ist eine starke Behauptung — und sie lässt sich
belegen oder widerlegen, wenn man die Parameter vergleicht statt der Namen.

Zwei Stationen mit identischer Zykluszeit, Personalzahl und identischen Sätzen sind dieselbe
Station, auch wenn jemand sie umbenannt hat. Zwei Stationen mit gleichem Namen, aber anderen
Parametern sind es nicht — dort hat sich etwas geändert, und das wird ausgewiesen statt
eingeebnet. Der Name ist das schwächste Merkmal einer Station.

Die Toleranzen sind nach Größenordnung getrennt: Zykluszeiten stehen in ganzen oder halben
Sekunden, Stundensätze mit vier Nachkommastellen. Eine gemeinsame Toleranz wäre für das eine
zu grob und für das andere zu fein.

Eine einseitig fehlende Angabe gilt als Unterschied, eine beidseitig fehlende als Gleichheit:
zwei Stationen ohne Gemeinkostensatz unterscheiden sich darin nicht, aber wo eine Angabe
verschwunden ist, hat sich etwas geändert.

Das Ergebnis trägt die Aussage `mainLineUnchanged` — und sie ist ausdrücklich `false`, wenn
es gar kein zugeordnetes Paar gibt. Ohne Paar ist „die Hauptlinie ist unverändert" nicht
wahr, sondern gegenstandslos.

Dazu die Rekonsiliation gegen die Fertigungszeile mit dem Anteil, der auf neue Stationen
entfällt — im Referenzfall erklären sie das gesamte Delta.

Gates: 6481 Tests grün (0 rot, vorher 6463), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Stationsbegriff, Takt und Kapazität

Im Fertigungsblatt liegen zwei Ebenen übereinander: beschreibende Untervorgänge ohne Zeit
und Kosten, und kostenwirksame Stationen. Wer beide zusammenwirft, zählt je nach Zählweise
31 oder 32 Stationen — ein Streit, der sich nicht diskutieren, sondern nur definieren lässt.

Kostenwirksam ist eine Zeile mit Zykluszeit, Mitarbeitereinsatz oder Kosten. Eine benannte
Zeile ohne jeden Parameter ist keine Station: sie als solche zu zählen überzeichnet die
Fertigungstiefe, sie stillschweigend fallen zu lassen verliert einen Befund. Sie bekommt
deshalb eine eigene Klasse und bleibt sichtbar.

Dazu Takt und Kapazität: Taktzeit im Spitzenjahr und im Mittel, Jahreskapazität je Station,
Auslastung, und die Zahl der Stationen, die parallel stehen müssen. Eine Station über 100 %
ist nicht knapp, sondern rechnerisch unmöglich.

Am Referenzfall geprüft: Taktzeit 33,04 s im Spitzenjahr, 694.286 Stück Jahreskapazität und
84,7 % Auslastung für die Beispielstation, zwei bzw. drei parallele Stationen für die
überlasteten. Dass kein Verfügbarkeitsfaktor enthalten ist, steht als Feld am Ergebnis —
ohne diesen Hinweis liest jemand die Auslastung als Endergebnis, obwohl sie eine Untergrenze
ist.

Eine Mutations-Probe hat dabei toten Code aufgedeckt: eine Untergrenze beim Aufrunden der
parallelen Stationen konnte nie greifen, weil Stationen ohne Zykluszeit vorher
herausgefiltert werden. Entfernt statt kommentiert.

Gates: 6463 Tests grün (0 rot, vorher 6446), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: die Materialebene trifft den Referenzfall

Alle sechs Wirkungsarten treffen jetzt den forensisch von Hand erstellten Referenzlauf auf
die vierte Nachkommastelle:

| Wirkungsart | ist | soll |
|---|---|---|
| consolidation | −15,2172 | −15,2172 |
| price_change | +13,0276 | +13,0276 |
| price_removed | −1,4495 | −1,4495 |
| scope_added | +3,5894 | +3,5894 |
| scope_activated | +9,8397 | +9,8397 |
| unchanged | 0,0000 | 0,0000 |

Zwei Ergänzungen haben den Ausschlag gegeben.

**Wegfall auf null.** Eine Position, die auf null fällt, ist wirtschaftlich weggefallen und
keine Preisänderung. „Der Posten kostet nichts mehr" ist in einer Verhandlung eine andere
Aussage als „der Posten ist billiger geworden". Das spiegelt die erstmalige Bepreisung:
beide Male wechselt eine Position zwischen „im Preis enthalten" und „nicht enthalten".

**Aufzählende Sammelnamen.** Manche Sammelposition zählt ihre Bestandteile auf, ohne sie
vollständig zu benennen. Die strenge Regel — jedes Wort des Teils steht im Sammelnamen —
findet dann nur eines von zweien und bricht ab. Trägt der Name Aufzählungszeichen, gibt er
sich selbst als Aufzählung zu erkennen; dann genügt je Teil ein aussagekräftiges Stichwort.
Drei Bedingungen halten die Lockerung eng: Aufzählungszeichen im Namen, Stichwörter von
mindestens drei Zeichen und nicht rein numerisch, und jedes Teil muss ein eigenes Stichwort
aufgreifen. Alle drei sind einzeln durch Mutation abgesichert.

Vier Zuordnungen weichen weiterhin ab, alle mit Delta 0,000 und damit ohne Wirkung auf eine
Summe: drei Nullkosten-Positionen (dort folgt die Umsetzung ausdrücklich der Spezifikation,
nicht dem Referenzlauf — beide widersprechen sich an dieser Stelle) und ein ersatzloser
Wegfall, der laut Spezifikation eine Registry-Regel braucht.

Gates: 6446 Tests grün (0 rot, vorher 6437), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Zusammenfassungen über die Baugruppe erkennen

Der Namensweg findet nur, was der Sammelname auch nennt. Im Referenzfall gehen fünf
Leitungspositionen in einer Position „AC Lines" auf — ihre Namen stehen dort nirgends. Über
den Namen ist das nicht zu sehen, über die Baugruppe schon: alle fünf tragen dieselbe Gruppe
wie die Sammelposition.

Die Regel greift bewusst nur im eindeutigen Fall: Bleiben in einer Gruppe auf der einen Seite
mehrere und auf der anderen genau eine Position übrig, ist die Zuordnung zwingend. Bleiben
beidseitig mehrere übrig — im Referenzfall die große Gruppe mit 40 gegen 52 Positionen —
wird nichts entschieden. Dort wäre jede Zuordnung geraten.

Wirkung, am Referenzfall gemessen: „Zuwachs" fiel von +48,05 auf +10,18 (Soll +3,59),
„Wegfall" von −39,81 auf 0,00 (Soll −1,45), die Zahl erkannter Umbuchungen stieg von einer
auf zwei von drei. Die beiden großen Ausreißer waren dieselbe Verschiebung von zwei Seiten
und sind damit verschwunden.

Gates: 6437 Tests grün (0 rot, vorher 6431), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Wirkungsarten und Rekonsiliation der Materialebene

Die Zuordnung sagt, welche Position zu welcher gehört. Die Wirkungsart sagt, was das
bedeutet — und das ist in einer Verhandlung der Unterschied zwischen „der Lieferant hat den
Preis erhöht" und „wir haben mehr bestellt".

Sechs Klassen: Umfangszuwachs, erstmalige Bepreisung, Preisänderung, Wegfall, Umbuchung,
unverändert. Die Prüfreihenfolge ist nicht beliebig: eine neu hinzugekommene Position mit
Preis 0 ist kein Umfangszuwachs, sondern ein Datenbefund. Würde man erst auf das Delta
prüfen, fiele sie als „unverändert" durch und niemand sähe sie an.

Dazu die Rekonsiliation gegen die Materialzeile der Zusammenfassung. Geht die Summe der
Positions-Deltas dagegen nicht auf, ist das ein Befund und keine Formsache: dann erklärt die
Positionsebene die Blattzahl nicht, und jede Aussage über „den größten Treiber" steht auf
tönernen Füßen. Ohne Blattzahl wird nicht geprüft — das steht als „nicht geprüft" da, nicht
als bestanden.

**Gemessen am Referenzfall**, nicht behauptet: Die Positionsebene erklärt das Blattdelta
vollständig (Residual praktisch null), jede Position ist genau einmal zugeordnet, und die
erstmalige Bepreisung — der größte Einzeltreiber des Falls — wird auf die vierte
Nachkommastelle getroffen. Die Verteilung auf die übrigen Klassen weicht noch ab, weil zwei
von drei Zusammenfassungen über den Namen nicht erkennbar sind; der Test gibt den Abstand je
Klasse aus, statt ihn zu verschweigen.

Gates: 6431 Tests grün (0 rot, vorher 6415), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Umbenennungen im Material-Mapping

Eine Position wird anders benannt, bleibt aber dieselbe: aus „Sound Insulation (left)" wird
„(back)", aus „(right)" wird „(front)" — bei gleichzeitig geändertem Preis. Ohne diesen
Durchgang erscheinen beide als Streichung plus Neuzugang.

Über den Preis ist das nicht zu finden, denn er ändert sich. Über den Namen allein auch
nicht, denn die Klammerinhalte widersprechen sich sogar. Zwei unabhängige Kriterien tragen
die Zuordnung: die Position steht zwischen denselben bereits zugeordneten Nachbarn, und
beide Namen teilen sich einen Wortstamm (oder der Preis ist unverändert).

Eines allein genügt nicht — Sequenzlage ohne Namensbezug würde beliebige Nachbarn
verheiraten, Namensbezug ohne Sequenzlage würde „Screw" quer durch die Liste zuordnen.

Das Ankerfenster wird dabei über die Zuordnung benannt, nicht über Zeilennummern: die
verschieben sich zwischen zwei Ständen, im Referenzfall Zeile 50 links gegen 47 rechts. Ein
Fenster über Zeilennummern hätte nie zusammengefunden.

Gates: 6415 Tests grün (0 rot, vorher 6410), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Material-Positionsmapping, Durchgänge 1 bis 3

Der Vergleich sagte „Material +9,79" und blieb die Antwort schuldig, welche Position das
treibt — genau die Frage, für die er existiert. Schlimmer noch: eine Zusammenfassung zweier
Positionen erschien als Streichung plus Neuzugang, die Aussage „Position X ist weggefallen"
war damit schlicht falsch.

Die Positionsebene beginnt mit der Namensnormalisierung: Unicode-Normalform, typografische
Varianten, Mengenpräfix abgetrennt ohne es zu verlieren, kuratierte Synonyme. Klammerinhalte
bleiben erhalten, weil „(links)" und „(rechts)" zwei echte Positionen unterscheiden; sie zu
verschmelzen fiele erst in der Summe auf und wäre dann kaum zu finden.

Darauf drei Durchgänge:

- **Eindeutige Namensgleichheit** — ein Name, der auf beiden Seiten genau einmal vorkommt.
- **Gleichteile** — Schrauben und Clips tragen denselben Namen und unterscheiden sich nur
  durch ihre Stelle; zugeordnet über die erhaltene Reihenfolge. Bei ungleicher Anzahl bleibt
  der Überhang ungepaart: welche Schraube weggefallen ist, lässt sich nicht entscheiden.
- **Zusammenfassung und Aufteilung** — erkannt über den Namen der Sammelposition, nicht über
  Kostengleichheit. Ändert sich gleichzeitig der Preis, wäre der Fall über Kosten nicht zu
  finden, und genau dann ist er interessant.

Jede Zuordnung trägt Regel-ID und konkreten Beleg; eine Zuordnung allein auf einen
Ähnlichkeitswert zu stützen ist ausgeschlossen.

Gates: 6410 Tests grün (0 rot, vorher 6376), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: summary_lines, Abschluss von Phase 1

Die Kennzahlen-Tabelle zeigt Werte, sagt aber nichts über die Zeilen selbst: welche Nummer
sie im Preisblock tragen, ob sie in eine Summe eingehen, in welcher Einheit sie stehen, ob
ihr Label von der Datei gedeckt ist. Das Fehlen genau dieser Angaben ist der gemeinsame
Nenner von drei Befunden — eine Memo-Zeile wurde als Summand eingebucht, ein
Konfigurations-Label ohne Deckung angezeigt, Zeilen ohne Vollständigkeitsdeklaration
weggelassen.

Die Registry kennt Rolle, Blocknummer und Einheit seit dem ersten Schnitt; die Ausgabe
nutzte das bisher nicht. `summary_lines` liefert jetzt alle Zeilen des Preisblocks in
Registry-Reihenfolge, vollständig in jedem Lauf — mit Rolle, Blocknummer, Einheit,
Anzeige-Label (Datei schlägt Konfiguration), Datei-Label und Verifikationsstand je Seite,
Werten, Delta und Quellzellen. Eine Zeile ohne Daten erscheint leer statt zu fehlen.

Die Kennzahlen-Tabelle bleibt daneben als reine Anzeigesicht bestehen; sie hat einen
anderen Zweck. Die Validierung bekommt die erwartete Zeilenzahl aus der Registry, statt
diese Prüfung als „nicht anwendbar" zu überspringen.

Damit ist Phase 1 der Spezifikation abgeschlossen. Zwei Einschränkungen bleiben bewusst
offen und sind im Code begründet: der Vergleichstyp meldet „unbestimmt", bis die
Prämissenprüfung die Identitätsmerkmale je Datei liest, und die Rundung bleibt konservativ,
solange die Sektionen ihre Einheit nicht mitführen.

Gates: 6376 Tests grün (0 rot, vorher 6369), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: kanonische Zahlen, Determinismus-Hash, Validierung

Drei Lücken des Ausgabeformats, die zusammen dafür sorgten, dass ein fehlerhafter Lauf
nicht als solcher erkennbar war.

**Gleitkomma-Artefakte.** Eine Einmalzahlung stand als `5579440.0003921185` im Dokument —
ein Rechenartefakt, kein Betrag. Beträge ab 1000 mit weniger als 0,005 Abstand zur ganzen
Zahl werden jetzt gerundet; das trifft genau diese Fälle und lässt Stückpreise unangetastet,
wo Runden ein echter Informationsverlust wäre. Alles Übrige auf sechs Nachkommastellen.
Nicht darstellbare Werte werden `null` statt `0` — ein stillschweigend zu 0 gemachter
Fehlwert wäre schlimmer als ein sichtbares „nicht vorhanden".

**Kein Determinismus-Nachweis.** Zwei Läufe über dieselben Dateien ließen sich nicht als
identisch belegen. Das Dokument trägt jetzt einen SHA-256 über seinen kanonisierten Inhalt,
ohne Lauf-ID, Zeitstempel und den Hash selbst. Zwei Läufe über dieselben Eingaben liefern
denselben Hash — auch dann, wenn einer einen Betrag exakt und der andere ihn mit Artefakt
ausrechnet.

**Keine Validierung.** Es gab keine Kontrollsummen und keine Toleranzdefinition; das
Brücken-Residual wurde verschluckt statt gemeldet. Neu sind fünf benannte Prüfungen mit
Messwert und Grenze: schließt die Brücke, deckt sich die Treiber-Liste mit ihrer Quellzeile,
ist die Zeilenmenge vollständig, widerspricht ein Label der Datei, liegt ein Inhalts-Hash vor.

Wo die Grundlage für eine Prüfung fehlt, steht „nicht anwendbar". Ein Check, der mangels
Daten immer „bestanden" meldet, erzeugt Vertrauen, das er nicht deckt — genau deshalb konnte
die Vorgängerversion unbemerkt falsch sein.

Damit ist Phase 1 der Spezifikation abgeschlossen.

Gates: 6369 Tests grün (0 rot, vorher 6345), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: Envelope Version 2 mit Vergleichstyp und Sektionszustand

Das Ergebnis-JSON war ein Deckel über einer Sektionsliste. Wer es las, musste alles Übrige
mitbringen: in welche Richtung ein Delta zeigt, ob Prozent ein Anteil oder eine Zahl ist,
in welcher Einheit ein Betrag steht, ob eine leere Sektion nichts gefunden hat oder nie
gerechnet wurde. Wer es falsch mitbrachte, las dieselben Zahlen falsch.

Das Dokument beantwortet diese Fragen jetzt selbst:

- **`conventions`** — Delta-Richtung, Vorzeichen-Bedeutung, Toleranzen, Einheiten-Vokabular,
  Rundung je Einheit, null-Semantik. Ein Test hält die Brücken-Toleranz mit der Engine
  synchron; eine Konvention, die etwas anderes behauptet als der Code tut, wäre schlimmer
  als keine.
- **`meta.comparison_type`** — zeitlicher Vergleich, Standort- oder Lieferantenvergleich,
  deterministisch aus den Identitätsfeldern abgeleitet, mit Grundlage und Belegen. Beim
  zeitlichen Vergleich sind geänderte Prämissen ein Störfaktor, beim Standortvergleich der
  Analysegegenstand — bisher unterschied nichts die beiden Fälle.
- **`section_state`** — je Sektion vier unterscheidbare Zustände mit Begründung: befüllt,
  geprüft und nichts gefunden, nicht anwendbar, nicht berechnet. Vorher sahen „keine Hebel
  nötig", „Daten fehlen" und „nicht implementiert" identisch aus.
- **`engine`** — Name und Version, ohne die sich zwei Läufe gar nicht vergleichen lassen.

Die Sektionsinhalte bleiben unverändert; ergänzt wird nur, was zu ihrer richtigen Lesart
nötig ist.

Der Vergleichstyp meldet vorerst „unbestimmt": Lieferant und Standort führt die App nur
einmal je Teil aus den Stammdaten, nicht je Datei. Die Merkmale aus den Stammdaten
abzuleiten wäre ein Zirkelschluss, weil die Paarung selbst über die Sachnummer läuft. Die
Ableitung ist vollständig und getestet — sie bekommt ihre Eingaben, sobald die
Prämissenprüfung die Merkmale je Datei liest. Bis dahin ist „unbestimmt" die richtige
Aussage und keine Notlösung.

Gates: 6345 Tests grün (0 rot, vorher 6324), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: die Treiber-Liste rekonsiliert sich

Ein Lauf zeigte drei Prozessschritte mit zusammen +1,95 bei einem Fertigungs-Blattdelta
von +1,06. Die Differenz von +0,89 war nirgends erklärt. Wer die Summen gegeneinander
prüft, findet einen Widerspruch von 84 % — und verliert damit das Vertrauen in alle
übrigen Zahlen, auch die richtigen.

Zwei Lücken: die Top-N-Auswahl schnitt die Liste ab, ohne zu sagen, wie viel dabei wegfiel,
und es gab überhaupt keinen Abgleich gegen die Blattzeile, aus der die Zeilen stammen.

Die Liste trägt jetzt ihre Gesamtsumme und einen Abgleich gegen ihre Quelle. Der Status ist
dreiwertig: bestanden, Widerspruch, oder **nicht geprüft** — ohne Referenz ist eine Liste
weder richtig noch falsch, und sie darf sich nicht als geprüft ausgeben. Die Auswahl
deklariert Umfang, gezeigte Summe und Rest, mit der Invariante, dass beide zusammen die
Gesamtsumme ergeben.

Sichtbar wird das dort, wo es zählt: Passt die Summe nicht zur Blattzeile, steht über dem
Tornado, was die Positionen erklären, wogegen geprüft wurde und wie groß die Differenz ist.
Die Auswahlzeile nennt zusätzlich den weggelassenen Rest.

Gates: 6324 Tests grün (0 rot, vorher 6317), Typecheck, Build, check:portability strict.

## 2026-08-01 – QAF-Compare V2: FWZ ist ein Kostenblock, kein Rest

Die Zusammensetzung des aktuellen Preises kannte vier Blöcke — Material, Fertigung,
Ausschuss, Zuschläge — und schob alles Übrige in einen Sammelposten. Im Anlassfall war
dieser Sammelposten exakt der FWZ-Wert: 2,1385 aus Zeile P17 „Vorrichtungen und
Folgewerkzeuge", ein benannter, additiver Block des Preisblocks. Ein Balken hieß „Übrige"
und war in Wahrheit ein Block mit Namen.

Dazu die Inkonsistenz, die derselbe Befund beschreibt: die Bucket-Tabelle kannte vier
Blöcke, die Preiszusammensetzung einen fünften Sammelposten — zwei Sichten auf dieselben
Zahlen mit zwei Definitionen.

`FWZ` ist jetzt ein eigener Block, gespeist aus derselben Zeile wie der Brückenschritt.
Die Blockreihenfolge folgt dem Preisblock (1. Material · 2. Fertigung · 3. FWZ ·
4. Ausschuss · 5. Zuschläge), und Bucket-Tabelle, Brücke und Zusammensetzung benutzen
dieselbe Definition.

Farbe: eigene CSS-Variable in Light und Dark, gedecktes Aubergine — gleiche Sättigung wie
die übrigen Bucket-Töne, im Farbkreis weit genug von Petrol, Braun und Grün entfernt,
damit die Blöcke im Stapel unterscheidbar bleiben. Der Wasserfall nimmt den Block
automatisch auf, weil er seine Balken aus der Zusammensetzung ableitet.

Mutations-Probe: nimmt man FWZ aus der Zusammensetzung, fällt der Test mit einer Differenz
von 2,1385 — dem Originalbetrag des Befunds, auf die Kommastelle. Die Abnahme gegen den
realen Referenzfall prüft zusätzlich, dass der Sammelposten diesen Betrag nicht mehr trägt.

Gates: 6317 Tests grün (0 rot, vorher 6308), Typecheck, Build, check:portability strict
(inklusive check:hardcoded-colors — die neue Farbe liegt als CSS-Variable vor, nicht als
loses Hex).

## 2026-08-01 – QAF-Compare V2: strenge Preisbrücke ohne Restposten

Die Preisbrücke schloss nicht selbst, sondern über einen Sammelposten „Übrige". Im
Anlassfall betrug er −1,055 und kaschierte exakt zwei Fehler zugleich: die
nachrichtliche Zeile „Enthaltene Verpackung und Transport" (+0,295) wurde als Schritt
eingebucht, obwohl sie laut Template in keiner Summe auftauchen darf, und ein echter
additiver Block (−0,76) fehlte ganz. Der Sammelposten glich beides aus, und die Brücke
sah geschlossen aus. Ein Restposten dieser Größe ist in einer Verhandlung nicht
vertretbar.

**Zwei Ursachen, beide behoben:**

Die Schrittliste war handgepflegt (V11-Erbe) und enthielt die beiden nachrichtlichen
Zeilen. Sie kommt jetzt aus der Zeilenrollen-Registry — nur `additive`-Zeilen werden zu
Schritten, und eine zweite Liste kann die Fehlklasse nicht zurückbringen.

Der fehlende Block war ein einseitig leerer Wert: `computeNumericDelta` liefert dafür
bewusst `null`, denn ohne Gegenwert lässt sich keine Veränderung *messen*. Für die
Brücke gilt aber ein engerer Zusammenhang — der Angebotspreis IST die Summe seiner
additiven Blöcke, also senkt ein wegfallender Block den Preis um genau seinen bisherigen
Betrag. Das ist keine Annahme über die Datei, sondern die Definition der Summe; die
Brücke prüft sich selbst daran.

**Kein Ausgleichsposten mehr:** Was die additiven Zeilen nicht erklären, steht als
`residual` samt `residualWithinTolerance` im Ergebnis, statt als Schritt eingebucht zu
werden. Ein Rest ist damit ein Befund und keine geschlossene Bilanz.

Die Verhandlungslisten behalten ihren bisherigen Umfang und heißen jetzt eigenständig:
eine nachrichtliche Zeile darf in keiner Summe stehen, ist als Verhandlungsthema aber
sehr wohl relevant — im Anlassfall stieg sie um 54,6 %.

Der G60-Zweig weist seinen Rest ebenfalls aus, behält den Sammelposten aber vorerst: die
Additivität des Preisblocks ist im Template festgeschrieben, für die G60-Kostenblöcke
gibt es diese Zusicherung und keinen abgenommenen Referenzfall — der Umbau wäre dort eine
Vermutung.

Neu ist eine Abnahme gegen den realen Referenzfall (`*.real-files.test.ts`, überspringt
sich ohne die operator-lokalen Daten): Angebotspreise, geschlossene Brücke, jeder additive
Block gegen seinen forensisch validierten Sollwert, und die nachrichtlichen Zeilen
außerhalb der Schritte. Die Sollwerte stehen nicht im Code, sondern werden aus der
Referenz gelesen; die Dateinamen kommen aus einem Manifest neben den Daten.

Gates: 6308 Tests grün (0 rot, vorher 6297), Typecheck, Build, check:portability strict.

## 2026-07-31 – QAF-Compare V2: Datei-Label persistieren und anzeigen

Schließt Befund F-02 sichtbar. Der Vergleich beschriftete eine Zeile des Preisblocks mit
einem Label aus der Konfiguration, das die Datei nicht trägt: angezeigt wurde die
Zollzeile, im Template steht dort „JIS". Das ist ein anderes Verhandlungsthema mit einem
anderen Ansprechpartner.

Der Parser kennt das richtige Label seit Phase 1a. Es verfiel aber beim Speichern — die
Anzeige speist sich aus persistierten Diff-Zeilen. Migration #116 (bereits appliziert)
legt die Spalten an, dieser Schritt füllt sie und stellt die Anzeige um:

- `buildSummaryMetricRows` und der Diff-Mapper schreiben `label_file` / `label_verified`
  mit, je Datei und je Vergleichsseite getrennt.
- `buildMetricsTable` zeigt das Label der aktuellen Datei, hilfsweise das der Vergabe,
  und erst zuletzt das Registry-Label — letzteres nur noch für Vergleiche, die vor der
  Label-Erhebung gespeichert wurden.
- `MetricsTableRow.labelVerified` trägt die Verifikation in die Anzeigeschicht. `null`
  heißt „nicht prüfbar oder nicht erhoben" und darf keine Warnung auslösen; nur ein
  echtes `false` ist eine Aussage.

Anders als die Zellquellen sind die Label bewusst **nicht** an einen vorhandenen Wert
gekoppelt: gerade die Zeile ohne Wert — der entfallene Posten — muss zeigen, wie sie in
der Datei heißt. Sonst fehlt die Beschriftung genau dort, wo die Aussage „X ist
weggefallen" gemacht wird.

## 2026-07-31 – QAF-Compare V2 Phase 1b: stabile Zeilenmenge der Metriktabelle

Drei reale Vergleichsläufe lieferten 14, 15 und 13 Zeilen in derselben Tabelle. Wer die
Ausgabe weiterverarbeitet — Oberfläche, Export, Regressionsvergleich, später eine
Sprachschicht — konnte sich auf die Struktur nicht verlassen; in einem Lauf fehlte
ausgerechnet die Transportzeile, deren nachrichtlicher Posten um 54,6 % gestiegen war.

**Ursache** war eine Zeile in `buildMetricsTable`: `if (!row) continue`. Eine Metrik ohne
persistierte Diff-Zeile wurde weggelassen statt mit `null` ausgegeben. Der Parser kennt
alle Metriken — es war nie ein Parser-, sondern ein Anzeigeproblem.

Die Tabelle führt jetzt in jedem Lauf die vollständige kanonische Zeilenmenge; „nicht
vorhanden" ist eine Aussage, „gar nicht erst aufgeführt" ist keine. Dazu ein neues Feld
`present` je Zeile: seit die Menge konstant ist, unterscheidet `alt === null` nicht mehr
zwischen „keine Daten" und „Wert ist null".

`present` löst zugleich eine Folge dieser Änderung: die Vergleichsansicht rendert die
Sektion und ihren Zähler über die Zeilenzahl. Ohne Unterscheidung hätte ein Vergleich
ohne Summendaten eine Tabelle aus lauter Leerzeilen gezeigt, wo vorher gar keine Sektion
stand. Beides läuft jetzt über die Zahl der tatsächlich befüllten Zeilen.

Gates: 6287 Tests grün (0 rot, vorher 6279), Typecheck, Build, check:portability strict.

## 2026-07-31 – QAF-Compare V2 Phase 1a: Zeilenrollen-Registry + Label-Verifikation

Grundlage für den Umbau des Vergleichskerns nach der Fachspezifikation V2 (KAR-995).
Dieser Schnitt schließt die beiden als kritisch eingestuften Befunde F-01 und F-02 auf
der Ebene, auf der sie entstehen — nicht durch Korrektur eines Einzelfalls.

**F-01: Die Preisbrücke schloss über einen Restposten „Übrige".** Sie summierte nach dem
Kriterium „hat ein Delta" statt „ist additiv", buchte damit die Memo-Zeile „Enthaltene
Verpackung und Transport" ein und ließ eine echte additive Zeile aus; der Restposten
kompensierte exakt beide Fehler. Neu trägt jede Zeile des Preisblocks in
`lib/qaf-differences/internal/summary-row-registry.ts` eine Rolle — `additive`, `memo`,
`subtotal`, `total`, `one_time`, `one_time_total`, `info`. Eine Brücke darf ausschließlich
aus `additive`-Zeilen entstehen. Die Blöcke 4 und 6 belegen je zwei Blattzeilen, die
Blocknummer ist deshalb bewusst kein Primärschlüssel.

**F-02: Ein Config-Label wurde angezeigt, das die Datei nicht trägt.** `locateRow` fällt bei
null Label-Treffern auf die im Template erwartete Zeile zurück (Confidence 0,6) — der Wert
dort stimmt, die Beschriftung nicht: Block 7 heißt im realen Template „JIS". Der Parser
liefert jetzt `labelFile` (Label wörtlich aus der Datei) und `labelVerified` mit drei
Zuständen: bestätigt, nicht bestätigt, oder `null` für strukturell nicht prüfbar. `null`
gilt für die Ausschusszeile, die im Template kein eigenes Label trägt — dort wäre „nicht
bestätigt" ein Fehlalarm in jedem Lauf.

Rein additiv: kein bestehendes Feld geändert, keine Verhaltens- oder Schemaänderung. Die
sichtbare Korrektur der Beschriftung braucht einen Weg für das Datei-Label durch die
Persistenz und ist bewusst nicht Teil dieses Schnitts.

Testdaten synthetisch — Struktur real, Absolutwerte verschoben, keine echten
Kalkulationswerte im Repo.

Gates: 6279 Tests grün (0 rot), Typecheck, Build, check:portability strict.

## 2026-07-31 – QAF-Batch: Paarungs-Kaskade statt Sachnummer-allein

Zwei zusammengehörende Standard-QAFs (Version 8.8, gleicher Umfang, gleiche Anfragenummer,
gleicher Lieferant) wurden nicht verglichen: die eine Datei trug eine Sachnummer, die andere
ließ das Feld leer. Ergebnis waren zwei Gruppen mit je einer Datei und zweimal „Sachnummer
fehlt".

**Ursache** stand wörtlich in `batch.ts`: *„Groups STRICTLY by part number. Different part
numbers are never compared. Files without a recognizable part number are bucketed
separately."* Die Absicht ist richtig — zwei fremde Teile dürfen nie verglichen werden — nur
greift sie zu hart, wenn eine Seite das Feld nicht ausgefüllt hat.

**Neu: eine mehrstufige Kaskade.** Entscheidend ist, dass sie *zweistufig* arbeitet und nicht
einfach mehr Schlüssel kennt: eine naive Kaskade hätte den Anlassfall nicht gelöst, weil die
Datei *mit* Sachnummer und die *ohne* nie denselben Schlüssel bekommen hätten.

1. Gruppierung nach Sachnummer (unverändert)
2. Nur die danach **allein gebliebenen** Dateien werden nachgepaart — über die Anfragenummer
3. Danach über Teilebenennung **und** Lieferant zusammen

**Drei Schutzregeln**, damit aus der Lockerung kein stiller Fehlvergleich wird:

- Zwei **verschiedene, jeweils gesetzte** Sachnummern werden nie gepaart — auch wenn alle
  anderen Merkmale übereinstimmen
- Eine Datei, die über ihre Sachnummer bereits einen Partner hat, wird nie neu zugeordnet
- Bei drei oder mehr Kandidaten mit demselben Schlüssel wird **nicht geraten**, sondern gar
  nicht gepaart

**Ausgewiesen statt implizit:** `BatchGroupResult.pairedBy` trägt die Grundlage, sie wandert
in `engine_version`, und ein über ein schwächeres Merkmal entstandener Vergleich bekommt
einen sprechenden Titel („Vergleich (über Anfragenummer gepaart — Zuordnung prüfen)") sowie
`needsReview: true`. Eine harte Regel gegen eine stille Annahme zu tauschen wäre der
schlechtere Fehler gewesen.

**Verifikation:** 12 Tests (9 neu) — der Anlassfall, beide Fallback-Stufen,
Groß-/Kleinschreibungs-Toleranz und vier Gegenproben. Dazu **drei Mutations-Proben**: jede
der drei Schutzregeln einzeln im Produktivcode deaktiviert; jedes Mal fällt genau der
zugehörige Test und die übrigen elf bleiben grün.

Testdaten sind anonymisiert (Lieferant A/B, Baugruppe X/Y, PN-4711, REQ-8899) — keine echten
Lieferanten- oder Teiledaten im Repo.

Gates: 6265 Tests grün (0 rot, vorher 6256), Typecheck, Build, `check:portability` strict.


## 2026-07-30 – Lieferanten-Benchmark: Fix nach dem ersten Live-Test (KAR-993)

Der Benchmark lief nach P2 durch, zeigte an Realdaten aber **jede** Kennzahl als „nicht
ermittelbar". Die Engine arbeitete korrekt — sie meldete ehrlich Lücken statt Zahlen zu
erfinden. Nur kamen keine Daten an.

**Ursache, an zwei Realdateien über den Produktivpfad gemessen:**

1. `totalProductionCosts` (Summe Herstellkosten) fehlt im persistierten Container
   **durchgängig**: 0 von 25 bzw. 0 von 11 Varianten. Das war die Bezugsgröße für Material-
   und Fertigungsanteil.
2. `currency` ist auf fast allen Summary-Positionen `null`, obwohl die Währung in der Datei
   steht. Die fail-closed-Regel („unbekannter Code = Konflikt") blockierte damit alles.

**Mein Verfahrensfehler dahinter:** Die P1-Verifikation lief gegen Zahlen, die ein eigenes
Excel-Skript aus den Dateien zog — nicht gegen die Form, in der der Produktiv-Ingest sie
ablegt. Die Zahlen stimmten, der Ablageort nicht. Deshalb waren die Tests grün und die Praxis
leer. Dieselbe Klasse wie der Merge-Propagations-Fehlschluss vom 27.07.: ein selbstgebautes
Messwerkzeug bildet den Produktivpfad nicht ab.

**Zwei Änderungen:**

- **Bezugsgröße:** Anteile gehen primär auf den **Angebotspreis** (verfügbar: 12 bzw. 11
  Varianten). Die Herstellkosten-Bezüge bleiben als eigene Gruppe erhalten und füllen sich
  automatisch, sobald der Parser die Zeile liefert; die Ansicht zeigt diese Gruppe nur, wenn
  mindestens eine Seite eine der Kennzahlen tragen kann — eine Tabelle, in der jede Zelle
  „nicht ermittelbar" sagt, ist Rauschen statt Information.
- **Währungsregel:** Ein Konflikt liegt nur vor, wenn **beide** Codes bekannt und verschieden
  sind. Begründung: die Summary-Positionen bilden eine Summenkette (Material + Fertigung
  ergibt die Herstellkosten, daraus den Angebotspreis) — was addiert wird, steht zwingend in
  einer Währung. Ein fehlender Name widerspricht einem vorhandenen nicht. Die Regel gilt
  ausdrücklich nur für Summary-Positionen; im Fertigungsblatt („Angaben in
  Beschaffungswährung" gegen „Angaben in Angebotswährung") ist der Zwei-Währungs-Fall echt
  und braucht für Block B eine eigene, strengere Prüfung.

**Ein Fehler, den der Fix selbst erzeugt hat und der beim Real-Check auffiel:** Die
Lockerung galt zunächst nur in `ratioOf`. `markupOf` reichte bei `currency: null` weiter in
den Konflikt-Pfad, der den Zielwert **ohne Differenz** übergibt — dadurch erschien 0,5 %
Aufschlag als 100,5 % (Quote statt Aufschlag). Beide Stellen nutzen jetzt dieselbe
Bedingung, mit Regressionstest.

**Verifikation über die Produktivkette** (Datei → Container → Adapter → Engine): Aufschlag
Basispreis→Angebotspreis 0,5 % gegen 29,8 % — dieselben Werte wie im ursprünglichen Entwurf,
jetzt aus dem echten Pfad. Ausschuss 1,4 % gegen 0,8 %, Zuschläge 15,1 % gegen 10,9 %.

**Bewusst offen (Parser, nicht Benchmark):** Bei Seite A sind die Materialdaten keiner
Variante zugeordnet (0 von 25), bei Seite B fehlen die Fertigungskosten. Beides ist die
Varianten-Zuordnung im Multi-QAF-Container. Die Ansicht sagt dort „nicht ermittelbar" mit
Grund statt zu raten.

Gates: 6256 Tests grün (0 rot, vorher 6251), Typecheck, Build, `check:portability` strict.


## 2026-07-30 – Lieferanten-Benchmark: Anlage und Ansicht (KAR-993 P2, UI)

Macht den Benchmark erreichbar: Anlage-Fläche in der Vergleichsliste, Darstellung der
beiden Blöcke in der Detailseite, Lade-Action für die wählbaren Dateien.

**Anlage** (`qaf-supplier-benchmark-create.tsx`): zwei Auswahlen, ein Klick. Bewusst
schlanker als die Variante-vs-Standard-Fläche, die nach der Datei noch eine Variante wählen
lässt — ein Benchmark nimmt immer alle Varianten einer Seite, weil er Verteilungen
vergleicht. Die Auswahl zeigt je Datei, ob es ein Multi-QAF mit N Varianten oder ein
Standard-QAF ist, damit sichtbar ist, worauf die Median-Spanne später beruht. Dieselbe Datei
auf beiden Seiten wird nicht angeboten (die Action lehnt es zusätzlich ab).

**Lade-Action** `listBenchmarkableFiles`: listet nur, was danach auch ein Ergebnis liefert —
G60-Detail-QAFs sind ausgeschlossen (kein Summary-Blatt für Anteile), ebenso Dateien ohne
jede Kostenposition. Die Kennzahl-Existenz wird in einem Zug geprüft, nicht mit N
Einzelabfragen.

**Ansicht** (`qaf-supplier-benchmark-detail.tsx`): reine Präsentation ohne Client-State, die
Kennzahlen kommen serverseitig gerechnet. Darstellungs-Doktrin: eine nicht ermittelbare
Kennzahl wird **benannt, mit Grund** ("nicht ermittelbar (Währungen verschieden)") und nicht
als 0, Strich oder leere Zelle gezeigt — ein Benchmark, der Lücken glättet, wäre genau das
Werkzeug, dessen Unbrauchbarkeit diesen Auftrag ausgelöst hat. Die Spanne erscheint nur, wenn
sie etwas aussagt: bei einer Variante ist min = max = median, eine Spannenangabe dort wäre
Scheingenauigkeit.

Der Modus bekommt einen eigenen, kurzen Zweig in der Detailseite — ohne Rehydrieren aus
`engine_version`, weil der Benchmark keine Diff-/Match-Tabellen liest oder schreibt, sondern
aus den beiden `qaf_file`-Datensätzen frisch gerechnet wird.

Gates: 6251 Tests grün (0 rot, vorher 6246 — 5 neue Rendering-Tests), Typecheck, Build,
`check:portability` strict.

## 2026-07-30 – Lieferanten-Benchmark: Modus, Adapter, Actions (KAR-993 P2, Backend)

Verdrahtet die P1-Engine: neuer Vergleichs-Modus `supplier_benchmark`, Adapter von den
persistierten QAF-Formen auf die Engine-Eingabe, zwei Server Actions, Feature-Flag.
**Ohne UI** — die Ansicht folgt als eigener Schritt.

**Registry-Schema erweitert (additiv):** `ComparisonModeFileRoleRule.expectedKind` →
`expectedKinds: readonly IngestedQafFileKind[]`. Der Benchmark ist der einzige Modus mit
mehr als einem Eintrag je Rolle (Multi-QAF **oder** Summary auf beiden Seiten), weil er nur
Kostenpositionen je Variante liest und nie Varianten zuordnet. G60 bleibt ausgeschlossen:
dort gibt es kein Summary-Blatt, aus dem sich Anteile bilden ließen. Die vier bestehenden
Modi behalten je genau einen Eintrag, ihr Verhalten ist unverändert.

**Ein Fund bei der Umstellung, der eine Verhaltensfalle verhindert hat:** Die
Sachnummern-Gleichheitsprüfung in `replaceComparisonFile` hing an
`roleRule.expectedKind === 'summary'`. Diese Ableitung hielt nur, solange jede
Summary-akzeptierende Rolle auch die Sachnummern-Bindung wollte. Für
`supplier_benchmark` hätte sie **genau falsch** gegriffen — der Modus nimmt Summary-Dateien
an, vergleicht aber absichtlich Angebote für *verschiedene* Teile; die Prüfung hätte den
Zweck des Modus abgewiesen. Jetzt ein explizites Registry-Feld `partNumberMustMatch`, dessen
Werte für die vier Vorgänger-Modi 1:1 aus der alten Bedingung übernommen sind. Ein Test
friert diese Werte ein, damit die Umstellung nichts verschiebt.

**Adapter** (`benchmark-input.ts`): Multi-QAF-Container-Varianten und
Standard-Summary-Metriken auf `BenchmarkVariantInput[]`. Damit endet die Formatkenntnis vor
der Engine, und Multi-QAF gegen Standard funktioniert ohne Zusatzlogik (ein Standard-QAF ist
schlicht eine Seite mit einer Variante). Ausschuss: der Container führt eine Position, die
Standard-Metriken zwei (`scrapMaterial`/`scrapManufacturing`) — addiert wird nur bei
gleicher Währung, sonst bleibt die Position offen statt eine Summe über Währungsgrenzen zu
bilden.

**Actions** (`supplier-benchmark-actions.ts`, eigene Datei analog KAR-985 —
`app/qaf-differences/actions.ts` blieb tabu): `createSupplierBenchmarkComparison` und
`loadSupplierBenchmark`. Beide prüfen zuerst das Flag, arbeiten ausschließlich über den
RLS-gebundenen Client und laden beide Seiten *vor* dem Insert, damit kein halbfertiger
Vergleich entsteht. Bewusst ohne Sachnummern- oder Projekt-Gleichheitsprüfung zwischen den
Dateien; geprüft wird nur, dass beide Seiten überhaupt Kostenpositionen tragen — eine Seite
ohne Zahlen ergäbe einen Bericht, der überall "nicht ermittelbar" sagt.

**Flag** `qafSupplierBenchmark`: `default` und `bmw` auf `true`, `_template` auf `false`.
`bmw` ist das Produktionsprofil — ein Flag nur in `default` wäre für den Betrieb unsichtbar.

Keine Migration: der Benchmark rechnet bei jedem Laden neu aus Daten, die im
`qaf_file`-Datensatz ohnehin liegen. Persistenz erst bei Bedarf, dann additiv analog
`qaf_comparison.user_inputs`.

Gates: 6246 Tests grün (0 rot, vorher 6227 — 19 neue), Typecheck, Build,
`check:portability` strict, `check:profiles` 3 Profile validiert.

## 2026-07-30 – Lieferanten-Benchmark: Rechen-Engine (KAR-993 P1)

`lib/qaf-differences/internal/supplier-benchmark.ts` — neue, reine Rechen-Engine, die zwei
Angebote **verschiedener Lieferanten** gegenüberstellt. Keine UI, kein Modus, keine
Migration in dieser Phase.

**Warum überhaupt:** Der bestehende `multi_qaf`-Modus ist ein *Versions*-Vergleich
(dasselbe Teil, zwei Angebotsstände). Am 30.07. wurden damit zwei verschiedene Angebote
verglichen — verschiedene Lieferanten, Teile, Projekte, Währungen. Der Bericht war
technisch korrekt und praktisch unbrauchbar: 4 von 19 Varianten „gematcht" (die Treffer
hingen an *einer* Dimension bei 10 % Abdeckung), kein Jahres-Impact (USD gegen EUR —
korrekt verweigert), 184 Material-Zeilen „neu" gegen 60 „entfallen".

**Zwei Entscheidungen, die den Unterschied machen:**

1. **Kein Varianten-Matching.** Der Benchmark vergleicht Verteilungen über alle Varianten
   einer Seite (Median + Spanne) gegen die der anderen. Nichts wird zugeordnet, also kann
   nichts falsch zugeordnet werden — genau die fragilste Mechanik des Pakets
   (`variant-matcher.ts`, 4-stufige Kaskade) entfällt. Das ist der Grund, warum die Engine
   ohne Kalibrierung für beliebige Multi-QAF-Generationen funktioniert.
2. **Ausschließlich Verhältnisse.** Absolutbeträge zweier Angebote sind nicht vergleichbar.
   Ein Verhältnis aus zwei Positionen *derselben* Datei ist einheitenfrei und damit über
   Lieferanten hinweg lesbar. Harte Regel: ein Verhältnis wird nur gebildet, wenn Zähler
   und Nenner in derselben Währung stehen — ein unbekannter Währungscode zählt
   ausdrücklich **nicht** als Übereinstimmung (fail-closed). Sonst wäre die Zahl
   einheitenfrei aussehend, aber inhaltlich falsch.

**Inhalt:** Block A Kostenstruktur (Material-, Fertigungs-, Ausschuss-, Zuschlags-,
Rüstkostenanteil), Block C Aufschlag-Kette (Herstellkosten → Basispreis → Angebotspreis).
Jede Kennzahl trägt Median, Spanne, Zahl der beitragenden und übersprungenen Varianten
sowie — wenn nicht ermittelbar — einen `unavailableReason`
(`no_variants`/`missing_values`/`mixed_currency`/`zero_denominator`) statt einer 0.
Zweisprachige Hinweise machen sichtbar, wenn die Seiten verschieden denominiert sind, eine
Seite mehrere Währungen trägt oder eine Kennzahl nur einseitig vorliegt.

**Tests:** 15 Fälle, Fixtures aus dem Anlassfall abgeleitet (Verhältnisse real, Absolutwerte
verschoben — keine echten Lieferanten-Kalkulationswerte im Repo, Trennung analog
`SYNTHETIC_IDENTITY_FIELDS`). **Drei Mutations-Proben gefahren, jede traf präzise:**
Währungs-Gate deaktiviert → nur die zwei Währungstests fallen (mit dem verbotenen
Verhältnis 240 USD / 284 EUR als Beweis); Median durch Mittelwert ersetzt → nur der
Median-Test fällt; Aufschlag-Differenz weggelassen → nur die zwei Aufschlagstests fallen,
um genau 1,0 verschoben. Der Median-Test wurde nachträglich auf drei Varianten mit
Ausreißer umgebaut, weil er bei zwei Varianten die Eigenschaft nicht beweisen konnte
(dort sind Median und Mittelwert identisch) — die Mutation wäre unbemerkt durchgelaufen.

Gates: 6227 Tests grün (0 rot), Typecheck, Build, `check:portability` strict, Lint auf den
neuen Dateien ohne Befund, Vertraulichkeits-Grep leer.

## 2026-07-28 – Regressionstest für den Merge-Cap (`maxMergeSpan`)

Der in Runde 3 eingezogene Cap für verbundene Bereiche war der einzige der drei
Struktur-Schutzmaßnahmen des BIFF-Pfads **ohne** Regressionstest — der strukturgleiche
Vektor (aufgeblähter Dimensions-Record) hatte einen, dieser nicht.

Nachgestellt und beziffert: eine 3.584-Byte-Datei mit auf 65.000 × 16.000 gepatchtem
MERGECELLS-Record lädt mit Cap in ~0,5 s, ohne Cap in ~17 s (bis der Zellzähler die
Reißleine zieht) — derselbe Denial-of-Service-Vektor wie beim Dimensions-Record, nur
über einen anderen Record.

Zwei Tests, bewusst getrennt:

- **Kappungs-Test** (deterministisch): kleiner deklarierter Bereich (21 × 21),
  `maxMergeSpan: 4`, harte Zell-Assertionen auf die letzte Zeile innerhalb und die erste
  jenseits der Grenze. Ohne Kappung fällt er über die Assertion (`expected 'Anker' to be
  null`) — nicht über ein anderes Limit, das vorher zuschlägt.
- **DoS-Regression**: riesiger deklarierter Bereich, Default-Limits, Zeitschwelle 5 s.

Mutations-Probe gefahren (Cap zurück auf die Blattmaß-Grenzen, also der Stand vor dem
Fix): beide Tests rot, der erste über die Assertion, der zweite mit 9,6 s. Kein
Produktivcode geändert.

## 2026-07-27 – Legacy-BIFF-Lesepfad (.xls) über einen Reader-Shim

`loadExcelWorkbook()` verzweigt jetzt am **Container** statt an der Dateiendung: Puffer mit
OLE2-Signatur (`d0cf11e0…`) gehen über den neuen `legacy-workbook-shim.ts`, alles andere
unverändert über ExcelJS.

**Warum:** ExcelJS ist zip-only und wirft bei echten `.xls`-Dateien
("Can't find end of central directory"). In einem einzigen Realupload (27.07.2026) waren das
1076 von 2475 Dateien — bisher als `legacy_xls_unsupported` abgewiesen, also 43 % des Materials
unsichtbar. Die Endung trägt die Information nicht: 46 Dateien mit `.xlsx`-Endung sind
OLE2-Container (verschlüsselt), 2 `.xls`-Dateien sind reguläres OOXML.

**Abgebildete Oberfläche** (nur was der Lesepfad benutzt — Styles bewusst nicht, die werden
ausschließlich im Export geschrieben):
`worksheets`/`getWorksheet` · `name`/`rowCount`/`columnCount`/`getRow`/`getCell`/`eachRow` ·
`eachCell`/`getCell`/`number` · `value`/`formula`/`result`/`formulaType`/`numFmt`/`master`/
`address`/`text`.

**Zwei Semantiken, an denen der Shim sonst still falsch läge:**
- `cell.master` — jede Zelle eines verbundenen Bereichs liefert den Ankerwert, wie
  `resolveCell()` es erwartet (KAR-928). SheetJS legt den Wert nur in den Anker; ohne
  Propagation sieht `qaf-type-detector` ein strukturell anderes Blatt (gemessen: 55 statt 359
  Textzellen im Kopfbereich).
- `eachCell({ includeEmpty: true })` iteriert lückenlos. Übersprungene Spalten hinterlassen in
  `worksheetToGrid()` (`cols[colNumber - 1]`) Löcher, die als Spaltenüberschrift weitergereicht
  werden und in `normalizeCanonicalLabel()` auf `raw.replace` laufen.

**Drei Ladepfade**, alle verzweigt: `workbook-adapter.loadExcelWorkbook`,
`workbook-adapter.parseSummarySheet` und `qaf-parser.parseQAFTemplate`. Letzterer importiert den
Shim direkt statt über den Adapter — der Adapter importiert seinerseits `parseQAFTemplate`
(Zyklus).

**Sicherheit:** Der ZIP-Bomb-Guard in `workbook-safety.ts` inspiziert die ZIP-Central-Directory
und greift bei BIFF konstruktionsbedingt nicht. Der Shim bringt eigene Strukturgrenzen mit
(`DEFAULT_LEGACY_LIMITS`: 64 MB, 256 Blätter, 4 Mio. Zellen), damit der zweite Lesepfad nicht
schwächer geschützt ist als der erste.

**Dependency:** `xlsx` (SheetJS) 0.20.3 als `file:vendor/xlsx-0.20.3.tgz` — der Tarball liegt
im Repo (2,3 MB). Die npm-Registry führt nur die drei Jahre alte `0.18.5` mit CVE-2023-30533
und CVE-2024-22363; aktuelle Versionen gibt es ausschließlich über das Hersteller-CDN.
Vendoring statt CDN-URL (Kais-Entscheid), damit `npm install` keinen Zugriff auf einen
externen Host braucht — relevant für die Adesso-Übergabe hinter Proxy. Die Herkunft ist
prüfbar statt Vertrauenssache: der SHA-512 des Tarballs ist identisch mit dem
`integrity`-Hash, den npm beim Bezug vom CDN aufgezeichnet hat, und dieser Hash steht
weiterhin im Lockfile — `npm ci` verifiziert ihn bei jeder Installation. Nachprüfbefehl in
der Setup-Checkliste.

**Mitgenommen (Kais-Entscheid, gleicher PR):** `cellToString()` warf bei einem ungültigen
`Date` (`new Date(NaN)` ist `instanceof Date`, `toISOString()` wirft darauf
`RangeError: Invalid time value`) und riss damit den kompletten Summary-Parse ab, statt nur
dieses eine Feld leer zu lassen. Latenter Bestandsfehler — auf `main` mit leerem Working Tree
reproduziert; sichtbar geworden, als die env-gated Korpus-Tests neue Realdateien sahen. Jetzt
`null` statt Absturz, mit 6 Tests abgedeckt (auch verschachtelt über `{ result }`).

**Belege:** 18 Unit-Tests, 3 env-gated Real-Korpus-Tests. Vollauf über 1076 echte Altdateien:
1075 geladen (100 %), alle mit Blättern, 279 `confirmed_multi_qaf` + 282 `probable_multi_qaf`,
Profile 895 Legacy-DE / 171 V9 / 9 ohne Match, 267 s gesamt. Die Multi-QAF-Zahlen decken sich
mit einem unabhängigen Konvertierungsweg (278/282) — zwei Verfahren, dasselbe Ergebnis.
Bekannte Grenze, dokumentiert im Test: SheetJS' BIFF-*Writer* schreibt keine Formeln, eine
synthetische `.xls` mit Formel ist nicht herstellbar — der Formel-Lesepfad hängt daher am
Real-Korpus-Test (dort 683.496 Formelzellen, alle mit gecachtem Wert).

## 2026-07-27 – Review-Runde 3: sechs ungeschützte Kopien, ein wirkungsloser Fix

**Der Datums-Schutz saß in 1 von 7 Kopien.** `toNum()` existiert byte-identisch in
`qaf-parser.ts` und in sechs Facetten-Parsern (`co2e`, `lccn`, `logistics`, `material`, `sbm`,
`rmr`). Runde 2 hatte nur die erste gefixt. Alle sechs lesen Zellwerte aus `worksheetToGrid` —
also genau die, die seit `cellDates: true` als `Date` ankommen. Korpus-Scan: **964 von 1076
Dateien tragen Datumszellen**, davon 480 im Blatt „Material", 478 in „SBM-SEKOF-FWZ", 419 in
„Rohmaterial" — den Blättern dieser Parser. Guard jetzt in allen sieben.

**Der `hasValue`-Fix aus Runde 2 war wirkungslos.** `eachCell` fragte
`cell.master.hasValue` statt `cell.hasValue` — über den Anker gefragt greift der merge-bewusste
Zweig nie, weil der Anker keinen eigenen `mergeAnchor` hat, und fällt auf den reinen Wert-Check
zurück. Bei einem Anker ohne eigenen Wert zählte `eachCell` 0 von 4 Zellen statt 4.

**Merge-Bereiche haben jetzt einen eigenen, engen Cap** (`maxMergeSpan: 512`). Vorher galten die
Blattmaß-Grenzen: ein einzelner manipulierter MERGECELLS-Record konnte bis zum gemeinsamen
Zellbudget (4 Mio.) Objekte erzeugen, gemessen ~5 s aus einer winzigen Datei. Größter realer
Merge im Korpus: 79 × 59.

**`text` delegiert jetzt an den Anker** — ExcelJS kommt indirekt dorthin (`Cell.text` ist
`this._value.toString()`, `MergeValue.toString()` gibt den stringifizierten Ankerwert). Kein
lebender Konsument im Lesepfad, aber Formgleichheit ist der Maßstab dieses Moduls.

**Zwei Tests prüften nicht, was sie behaupteten** (drittes Vorkommen in diesem PR): Der
Datums-Test baute seine Datei als `.xlsx` und nahm damit den ExcelJS-Zweig — der Shim kam nie
vor. Der Cap-Test verglich gegen ein zweites hartkodiertes Literal statt gegen
`QAF_MAX_FILE_BYTES`. Beide gefixt, beide mit gezielter Mutation belegt.

**Eine Lücke steht bewusst ohne Test da:** Der Fall „Merge-Anker existiert, trägt aber keinen
Wert" ist synthetisch nicht herstellbar (SheetJS' Writer verwirft wertlose Zellen, der Roundtrip
liefert `undefined`). Ein erster Versuch mit leerem String bewies nichts — ein leerer String ist
ein Wert. Statt einen Schein-Test stehenzulassen, steht die Lücke als Kommentar im Testfile.

Tests: 6210 grün (0 rot). `check:portability` strict grün, Typecheck grün, Lint 0 Fehler.

## 2026-07-27 – Review-Runde 2: DoS geschlossen, Shim auf ExcelJS-Semantik gebracht

**Denial-of-Service über den deklarierten Zellbereich.** Der Modulkopf behauptete, BIFF sei
„linear zur Dateigröße" — falsch. Eine BIFF-Datei deklariert ihren Zellbereich in einem eigenen
Record (Zeilenfeld 4 Byte, bis ~4,29 Mrd.), SheetJS übernimmt ihn ungeprüft nach `!ref`, und der
Loader lief ihn Zelle für Zelle ab. Nachgestellt: **3584 Bytes, vier echte Zellen,
Dimensions-Record auf 200000×2000 gepatcht → 265 Sekunden.** Der Zell-Zähler griff nie, weil er
nur *gefundene* Zellen zählt. Fix: Iteration über `Object.keys` des Blatts statt über den
deklarierten Bereich — dieselbe Datei jetzt **45 ms**. Dazu gekappte Blattmaße und gekappte,
budgetierte Merge-Bereiche (gleicher Vektor).

**Shim tat mehr als ExcelJS.** Der Merge-Fix aus Runde 1 leitete *alle* Wert-Getter über den
Anker um. ExcelJS delegiert aber nur `value`: `MergeValue` definiert ausschließlich
`get value()`, während `Cell.formula/result/formulaType` auf `this._value.*` zugreifen (auf einer
MergeValue `undefined`) und `numFmt` aus dem Style kommt. Folge: `worksheetToFormulaGrid` sah bei
`.xls` duplizierte Anker-Formeln, die `.xlsx` nie liefert (korpusweit 24.158 Zellen in 756 von
1066 Dateien). An den heutigen Konsumenten wirkungslos, aber Formgleichheit ist die Grundlage,
auf der die 16 unveränderten Parser überhaupt funktionieren dürfen.

**`hasValue` war konservativer als ExcelJS.** Dort gatet `eachCell({includeEmpty:false})` auf
`cell.type !== Null`; eine Merge-Folgezelle trägt immer `type = Merge` und zählt mit, auch bei
leerem Anker. Der Shim schloss sie aus (13 von 1076 Dateien betroffen).

**Der Merge-Test war eine Tautologie.** Er verglich `resolveCell(cell)` mit
`resolveCell(cell.master)` — `resolveCell()` löst als erste Zeile selbst über `.master` auf,
verglichen wurde also der Anker mit sich selbst. Zweiter Anlauf nach demselben Fehlermuster wie
in Runde 1. Jetzt werden die Getter der Folgezelle direkt verglichen (Formelzellen strukturell,
weil `.value` bei jedem Zugriff ein frisches Objekt liefert). **Mutations-Probe mit dem echten
Fehler: `value`-Delegation entfernt → 2 Tests rot** (vorher blieb der Test grün).

**`toNum()` ohne Datums-Schutz.** `Number(new Date(...))` liefert Millisekunden-Epoch statt
`NaN` — eine Zahlenspalte mit datums-/zeitartigem Zellformat (eine Zykluszeit als „mm:ss"
genügt) käme als Milliardenzahl in der Kalkulation an. Durch `cellDates: true` für `.xls` neu
erreichbar, für `.xlsx` bestand die Lücke bereits. Jetzt `null`, mit Mutations-Probe.

Dazu: UI-Texte auf `.xls` nachgezogen (Picker und Gate akzeptierten es, der Hilfetext sagte das
Gegenteil), zwei Doku-Stellen zu `LEGACY_FORMAT_UNSUPPORTED` korrigiert, die weiterhin
behaupteten, das Gate weise Altdateien ab.

Tests: 6210 grün (0 rot). `check:portability` strict grün, Typecheck grün.

## 2026-07-27 – Review-Runde zum BIFF-Lesepfad: Ingest geöffnet, drei Semantik-Fehler behoben

Der adversariale Review zu PR #372 fand einen Befund, der den PR im Kern entwertet hätte, und
drei echte Semantik-Fehler. Alle vier sind hier behoben.

**Der Shim war im Produktpfad unerreichbar.** `isAllowedQafFileName` ließ nur `.xlsx`/`.xlsm`
durch, und selbst eine umbenannte Datei wäre an `runPreParseWorkbookSafetyCheck` gescheitert
(`inspectZipStructure()` wirft für jeden Nicht-ZIP-Puffer). Der Volllauf über 1076 Realdateien
lief über den Batch-Runner, der die Module direkt aufruft und genau diese Tore umgeht — er
konnte die Lücke nicht zeigen. Jetzt: Accept-Liste um `.xls` erweitert (Kais-Entscheid), die
Vor-Parse-Prüfung verzweigt am Container, `accept`-Attribute der drei Upload-Oberflächen
mitgezogen. Neuer Test `legacy-biff-ingest-path.test.ts` geht die drei Tore in derselben
Reihenfolge durch wie `ingestQafUpload`; Mutations-Probe: Gate wieder geschlossen → 2 Tests rot.

**`cell.value` lieferte bei Merge-Folgezellen `null`.** ExcelJS' `MergeValue` definiert
`get value() { return this._master.value }` — wer dort direkt liest, bekommt den Ankerwert
eingebaut. Genau darauf verlässt sich `qaf-parser.ts:655`, die einzige Stelle im Lesepfad ohne
vorherige `.master`-Auflösung — ausgerechnet im Fertigungskosten-Parser, wo eine fehlende
Pflichtspalte den ganzen Facet auf `steps: []` degradiert. Alle Wert-Getter des Shims lesen
jetzt über den Anker.

**Datumszellen kamen als Excel-Seriennummer.** `cellDates` fehlte beim SheetJS-Read (Default
`false`), ExcelJS liefert dagegen immer ein `Date`. Aus einem Angebotsdatum wurde still
`"45732"` — und der frisch eingebaute `instanceof Date`-Zweig in `cellToString()` konnte für
Altdateien nie feuern.

**Ein eigener Test bewies nichts.** Der Real-Korpus-Test zur Merge-Propagation zählte
wiederholte Texte in einer Zeile — das trifft auch auf gewöhnliche Duplikate zu ("EUR" in
Beschaffungs- und Angebotswährung). Im Review-Experiment blieb er grün, obwohl die Propagation
komplett abgeschaltet war. Jetzt prüft er die Eigenschaft selbst (65142 Merge-Folgezellen im
Korpus, alle mit Ankerwert); Mutations-Probe: Propagation aus → 4 Tests rot.

**Sicherheit:** Neue `runPreParseLegacyBiffCheck` als BIFF-Gegenstück zum ZIP-Guard —
Größengrenze blockierend (Default = Upload-Cap, lässt also nie mehr durch als das Tor davor),
VBA-Erkennung über den OLE2-Streamnamen `_VBA_PROJECT`. Externe Verknüpfungen werden
ausdrücklich als **ungeprüft** ausgewiesen (`externalLinksChecked: false`) statt als „keine
gefunden": BIFF kodiert sie in SUPBOOK-Records, deren Erkennung ohne vollen Parser zu
unzuverlässig wäre. Ein eigener Plausibilitäts-Hinweis macht das im Vergleich sichtbar.

Kein Entpack-Cap wie im ZIP-Pfad, und das ist Absicht: eine Zip-Bombe erzeugt aus 1 MB
Gigabytes, bei BIFF ist der Zusammenhang linear — die wirksame Grenze ist die Puffergröße, und
die greift vor dem Parse.

Tests: 6198 grün (0 rot). `check:portability` strict grün, Typecheck grün.

## 2026-07-25 – KAR-987 §24 P8.2d: Playwright Component Testing (browser-echte Editor-Tests)

Neue, strikt vom bestehenden `e2e/`-Scaffold (weiterhin fixme/unwired, unberührt) getrennte
Test-Ebene: `@playwright/test` + `@playwright/experimental-ct-react` mounten `VsmEditor` DIREKT
in echtem Chromium (eigene `playwright-ct.config.ts`, `testDir: e2e-ct/`) — kein `next dev`, kein
App-Stack, keine Supabase-Calls (ABSOLUTE TABUS gehalten). Schließt die P8.2b-jsdom-Lücken
(`p8-2b-builder-report.md`, Abschnitt „jsdom-Lückenliste") mit echten Browser-Assertions statt
struktureller Behauptungen: echter Touch-Drag inkl. schnellem Boxaustritt + Nachbar-Node-Querung,
`pointercancel` (echtes `PointerEvent` via `dispatchEvent`, sauberer Gesture-Cleanup), echtes
Multi-Touch via CDP `Input.dispatchTouchEvent` (`isPrimary`/`activePointerIdRef` mit ZWEI
gleichzeitigen, browser-eigenen Kontakten), `getBoundingClientRect()`-basierter Palette-Drop an
realer Canvas-Position (empirisch gemessener Zoom, kein hartkodierter Pixelwert), echtes
`:focus-visible` nach Tab-Traversal + Feld-Commit über echtes `focusin`/`focusout`. Zusätzlich
Undo/Redo/Copy-Paste/Connect-Gesture/Auto-Layout (Flag AN), DE↔EN-Laufzeit-i18n-Wechsel (P8.2c)
und Touch-Emulation (Tap-Select, Touch-Drag, `touch-action:none`-CSS-Vererbung, KAR-662-Lehre) —
nach der Fix-Runde (K1–K7 unten) 24 Specs über 9 Dateien (a–i), alle 2× hintereinander grün
(finale Artefakte: `p82d-ct-final-run1.json`/`-run2.json`, Scratchpad).

- **Fix-Runde (K1, critical Review-Finding "spec-a-capture-mechanism-not-discriminating"):**
  `a-pointer-capture.spec.tsx`s 3 Maus-Tests wären bei komplett entferntem `setPointerCapture`-Aufruf
  identisch grün geblieben — `vsm-editor.tsx` trackt jeden Drag ausschließlich über zwei
  document-weite `pointermove`/`pointerup`-Listener, die rein über `pointerId` gaten, nicht über
  Capture/`e.target`. Diese Tests beweisen also Maus-Drag-Robustheit der Doc-Listener-Architektur,
  NICHT Capture-Semantik (Datei-Header + Bericht-Mapping entsprechend korrigiert). Die tatsächliche
  Touch-Capture-Semantik wird jetzt von einem NEUEN, echten CDP-Touch-Test in
  `h-touch-emulation.spec.tsx` bewiesen (Finger verlässt die Node-Box in einem Sprung, quert
  Prozess B, Gesture reißt nicht ab, sauberes Release) — 2× stabil grün.
- **Fix-Runde (K2, major Review-Finding "spec-g-badges-silently-dropped"):** Brief verlangte für
  Spec g explizit „Toolbar/Palette/**Badges**" — getestet wurde nur Toolbar/Palette/Panel-Feld. Die
  unconditionale Node-VA-Badge (`showVaIndicator`/`vaBadgeState`, kein Flag-Gate) wechselt jetzt
  hart geprüft „Ungeklärt" ↔ „Unclassified" (de/en.json:696) auf beiden Fixture-Nodes.
- **Fix-Runde (K3, major Review-Finding "P82D-01"): design-lint-Gate-Wahrheit** — Bericht
  behauptete `node .claude/skills/design-lint/scripts/detect.mjs e2e-ct --json` → 0 Findings;
  dieser Pfad existiert im Repo NICHT (das Skill liegt nur global unter
  `/home/aria/.claude/skills/...`), UND ein Lauf gegen den kompletten `e2e-ct/`-Baum (inkl. des von
  JEDEM CT-Lauf frisch regenerierten `playwright/.cache/`) liefert 57 Findings (2 CRITICAL,
  Regel L3 „h-screen/100vh"). Ursache: `detect.mjs`s `SKIP_DIRS` kennt `.cache` nicht — anders als
  `.gitignore`/`eslint.config.mjs`, die dieses Verzeichnis bereits bewusst ausschließen. Alle 57
  Findings liegen in EINER generierten Tailwind-Bundle-Datei, KEINE davon in tatsächlich
  e2e-ct-autorisiertem Code (verifiziert: 0 Treffer in *.spec.tsx/fixtures/helpers/stubs; jede
  betroffene Utility-Klasse — `min-h-screen`, `bg-black/*`, `backdrop-blur`, `uppercase`,
  `tracking-wide*`, `text-[Npx]` u. a. — stammt nachweislich aus dutzenden VORBESTEHENDEN, von
  P8.2d unberührten Produktionsdateien, z. B. `app/layout.tsx`, `app/(auth)/login/page.tsx`,
  `components/ui/dialog.tsx`, `components/wertstrom/vsm-lsc-import-modal.tsx`). Korrekter,
  reproduzierbarer Befehl (die tatsächlich lintbaren, autorisierten e2e-ct-Dateien, ohne
  `.cache`): 0 Findings. Volle Klassen-Tabelle der 55 Nicht-CRITICAL-Findings im Fix-Report
  (Scratchpad).
- **Fix-Runde (K4, major Review-Finding "P82D-02"):** Neue Spec `i-lsc-import-modal.spec.tsx` —
  `VsmLscImportModal` hat KEIN Datei-Feld (anders als Excel/QVS) und war mit der bereits
  vorhandenen `projects`-Fixture trivial mountbar. Mount + Kern-Interaktion (Projekt wählen
  aktiviert „Importieren", Titel/Dropdown nicht abgeschnitten) + i18n-Stichprobe (de/en) + Konsole
  sauber — „Importieren"/„Import" selbst wird nie geklickt (TABU: kein Supabase-Call), nur
  „Abbrechen"/„Cancel".
- **Fix-Runde (K5, minor Review-Finding "det-hyg-01"): eslint-Overlays auf e2e-ct ausgeweitet** —
  die repo-eigenen Overlays (boundaries/no-console/react-hooks/security), vorher nur auf
  app/components/lib/hooks/config gescoped, decken jetzt auch `e2e-ct/**` ab (boundaries: Specs
  matchen die bestehende `tests`-Klassifizierung automatisch, fixtures/helpers/stubs jetzt
  EXPLIZIT als `tests` klassifiziert statt zufällig unclassified; no-console: ganzer e2e-ct-Baum
  wie `__tests__/` behandelt; react-hooks/security: echte, generische Regeln, jetzt auch hier
  aktiv). 0 neue Findings — repo-weites eslint (60 Fehler/221 Warnungen) ist vor wie nach der
  Änderung identisch. **Ergänzung über den Finding-Wortlaut hinaus:** `scripts/check-boundaries.mjs`
  (das eigentliche `npm run check:boundaries`/portability-Sub-Gate) hat sein EIGENES, von
  eslint.config.mjs unabhängiges `SCAN_PATTERNS`-Array — ohne e2e-ct wäre GENAU dieses CI-Gate
  weiterhin blind geblieben. Symmetrisch nachgezogen.
- **Fix-Runde (K7, minor Review-Finding "det-hyg-03"): Fixture-ID-Determinismus** —
  `fixtures/vsm-fixtures.ts`s `nodeSeq`/`connSeq` waren modul-globale, nie zurückgesetzte Zähler
  (shared state über Test-Worker-Prozesse hinweg, aktuell inert — kein Spec assertet auf den
  ID-Wert). `fixtureId()` ist jetzt eine reine Funktion des Node-Namens/Connection-Endpunkte, kein
  Zähler, kein shared state mehr.
- **Setup-Findings (kein Produktionscode angefasst):** Tailwinds automatische Content-Detection
  löste unter Playwright-CTs eigenem Vite-`root` falsch auf (Utility-Klassen kamen im Bundle NICHT
  an) — behoben über eine CT-lokale `ct-globals.css` mit explizitem `@source`-Directive, die das
  echte `app/globals.css` unverändert importiert. `@tailwindcss/vite` (nicht das bestehende
  `@tailwindcss/postcss`) + ein explizit gepinntes `@vitejs/plugin-react` (sonst nur transitive
  Phantom-Dependency von `@playwright/experimental-ct-react`).
- **roving-tabindex (Baustein 4) bewusst NICHT umgesetzt:** Der WAI-ARIA-Composite-Widget-Standard
  ("Pfeile navigieren zwischen Elementen") kollidiert direkt mit dem bereits gebauten, getesteten
  P8.2b-Verhalten (Pfeiltasten bewegen den FOKUSSIERTEN Node um 10px/100px). Eine saubere Lösung
  bräuchte ein neues Tastatur-Schema (z. B. ein Modifier für Roving-Navigation) — eine
  Produkt-Entscheidung außerhalb Builder-Ermessen, kein reiner Umsetzungsaufwand. Ohne diese
  Entscheidung wäre jede naive Umsetzung eine echte A11y-Regression (nur noch EIN Node überhaupt
  per Tastatur erreichbar). Bleibt der bestehende, akzeptierte Tab-Wall-TODO.
- **Ein echter, dokumentierter Fund (nicht gefixt, außerhalb Testing-Scope):** Ein Panel-Feld per
  Klick auf leeren Canvas zu verlassen räumt den Undo-Eintrag für die gerade committete Änderung
  teilweise NICHT ein — `selectNode(null)` im selben `pointerdown`-Handler unmountet das
  Feld synchron, bevor der native `focusout` durch Reacts Event-System läuft. Tab-Exit ist
  unbetroffen. Näheres im Abschlussbericht (Scratchpad).
- **CT deckt NICHT ab (ehrlich begrenzt):** SSR/Hydration der Server-Page, Persistenz-Roundtrip
  gegen echte API/DB, komplette Golden-Path-Flows über mehrere Seiten, Excel-/QVS-Import-
  Dateidialoge (echter Datei-Upload — eigener, ungefähr gleich großer Baustein, bewusst ausgelassen;
  Fix-Runde K4: LSC-Import-Modal HAT kein Datei-Feld und ist jetzt browser-echt abgedeckt, siehe
  oben). Die Timeline-Band-Sub-Komponente (`VsmCanvasTimelineBand`, nur hinter `wertstromUxV2`
  gerendert) hat einen eigenen Pointer-Drag-Handle mit derselben globalen Drag-Mechanik wie der
  Node-Drag — von keinem der Specs berührt; benannter Follow-up-Kandidat, kein Code-Zwang aus
  dieser Fix-Runde.

## 2026-07-24 – KAR-878/KAR-986/KAR-987 P8.2c: Wertstrom DE/EN-Übersetzung

Alle nutzer-sichtbaren deutschen UI-Strings des Wertstrom-Moduls (`components/wertstrom/**`,
`app/wertstrom/**`) über die bestehende `lib/i18n`-Infrastruktur DE/EN-fähig gemacht — neuer
Namespace `wertstrom.*` (869 Keys), professionelles Lean/VSM-Fachvokabular in Englisch. Kein
Zahlen-/Datums-/Einheiten-FORMAT angefasst (`toLocaleString('de-DE')`, Excel-numFmt, Komma-Dezimal
bleiben exakt wie vorher). ~1700 bestehende targeted Tests bleiben grün.

- **Die zentrale Falle gelöst (Lösung A):** `I18nContext`s Default-Wert (kein Provider gemountet —
  genau der Fall, in dem alle Wertstrom-Tests rendern) gab bisher `key` statt `fallback` zurück.
  Ein-Zeilen-Patch (`lib/i18n/i18n-context.tsx`): `t: (key, fallback) => fallback ?? key`. Jeder
  Callsite wird zu `t('wertstrom.x.y', 'Deutscher Text')` — die Tests sehen weiterhin exakt
  denselben deutschen Text, ohne dass ein `I18nProvider` in ~20 Test-Render-Helpern nachgerüstet
  werden musste. Ein neuer Unit-Test (`lib/i18n/__tests__/translations.test.ts`) vergleicht jeden
  Wertstrom-Fallback im Quellcode gegen `de.json` — sowohl literale `t('key', 'fallback')`-Aufrufe
  ("Form A") als auch das dynamische `t(XXX_LABEL_KEYS[code], XXX_LABELS[code])`-Muster ("Form B",
  das dominante Muster für das Enum-Vokabular des Editors — Node-Typen, KPI-Namen, Workshop-Status
  u. Ä.), inkl. der abweichend geformten Sonderfälle (`NODE_CONFIG[type].label`, Array-Paare wie
  die Wizard-Steps, objekt-array-basierte Gruppenüberschriften, KPI-/Feld-Label, deren
  DE-Fallback aus Funktionsaufruf-Argumenten statt einer Companion-Map stammt). Fallback/JSON-Drift
  ist damit für beide Aufruf-Formen strukturell ausgeschlossen, nicht nur per Review geprüft — mit
  einer einzigen, explizit gepinnten Ausnahme (`vsm-ct-takt-chart.tsx`s `CATEGORY_LABEL.near`, das
  einen benannten numerischen Konstantenwert statt eines reinen String-Literals einbettet).
- **Architektur:** "Engine emittiert stabile Codes, UI übersetzt" — durchgängig angewendet, auch
  eine Ebene früher als vom Brief verlangt (vsm-config.ts/qvs-field-labels.ts, reine Datenmodule
  ohne React, bekommen companion `*_LABEL_KEYS`-Maps statt eigenem Hook). Export-Module
  (vsm-export-pdf.ts, vsm-export-xlsx.ts, vsm-export-svg.ts) sind hookfrei — die aufrufende
  Komponente (vsm-export-dialog.tsx) übergibt `t` als (defaulteten) Parameter.
- **Bewusst NICHT übersetzt (deklarierte Grenzen — die folgende Liste ist vollständig):**
  Engine-komponierter Fließtext (`computeManagementAnalysis`/`computeTimelineLadder`s
  zusammengesetzte deutsche Analyse-Sätze, `MetricExplain.formula/dataBasis/exclusions` im ganzen
  `lib/vsm-engine`) — Rework wäre eine Kernänderung an der Engine, außerhalb des Briefs; das
  komplette Excel-Roundtrip-Dateiformat (Sheet-Namen, Spaltenköpfe, Beispielzeile,
  Status-/Provenance-Zellwerte) bleibt als EIN frozen Vertrag deutsch, sonst bricht Re-Import über
  einen Locale-Wechsel; externe Datei-Spaltennamen (`vsm-excel.ts`, die einfache
  Excel-Paste-Zuordnung) sind Matching-Schlüssel gegen vom Nutzer selbst gelieferte Dateien, keine
  UI-Labels; `lib/simvsm-import`s `*De`-Felder (fremder Typ-Vertrag); Seed-/Referenzdaten,
  `lsc_tooltips`, Server-seitige Next.js-Metadata (`layout.tsx`'s `<title>`, kein SSR-Locale ohne
  neue Cookie-Infrastruktur — explizit nicht gebaut); `lib/copilot-export`s Wertstrom-DOCX/
  Markdown-Export (außerhalb des `components/wertstrom/**`/`app/wertstrom/**`-Datei-Scopes, siehe
  TODO.md).
- **es/zh:** 1:1-Kopie der EN-Werte (deklarierte Übersetzungsschuld, kein Auto-Translate von ~700
  Domain-Strings ohne Review) — TODO für eine spätere, dedizierte Übersetzungsrunde.
- **Neuer Key-Paritäts-Test** (`lib/i18n/__tests__/translations.test.ts`) schützt alle 4
  Übersetzungsdateien (de/en/es/zh) inkl. der 7 bestehenden Namespaces vor erneutem Drift.
- **Review-Fix-Runde (P8.2c-Review, 16 CONFIRMED Findings über 7 Kerne):** Fallback-Parity-Test um
  Form B erweitert (s. o.); `"(Kopie)"`-Node-Paste-Suffix, ein Canvas-Node-aria-label
  (Node-Typ-Anteil), eine Präsentationsmodus-KPI-Delta-Zeile und zwei „Soll-Zustand erstellen"-
  Fehlermeldungen nachträglich über `t()` geführt; `missingFieldReasonLabel()`s
  `String.replace()`-Aufruf (interpretierte `$`-Muster in importiertem Rohtext) durch
  `interpolate()` ersetzt; zwei QVS-Dialoge (`qvs-reimport-dialog.tsx`, `qvs-import-preview-
  dialog.tsx`) hielten eine bereits übersetzte Fehlermeldung in State statt des Fehler-Codes — fror
  bei Sprachwechsel in der alten Sprache ein, jetzt Code in State + Übersetzung erst beim Rendern;
  „Stock" → „Inventory" (Konsistenz mit dem restlichen Namespace) und ein unübersetzter
  PCE-Kennzahlname im DE-Locale korrigiert.

## 2026-07-24 – KAR-878/KAR-986/KAR-987 P8.2b-Review Fix-Runde (15 verifizierte Findings, 10 Kerne)

Adversarielles Multi-Verifier-Review des vorigen Eintrags (Wertstrom P8.2b: Canvas-Input-Layer
+ §24-Interaktions-Rest, 5 Bausteine) — 15/15 CONFIRMED Findings (4 critical, 8 major, 3 minor)
über 6 Dimensionen auf 10 Kerne verdichtet, je ein thematischer Commit (K9+K10 gebündelt).
Ein bindender PARENT-ENTSCHEID revidiert die ursprüngliche Baustein-2-Deklaration
(A11y-Gating-Umkehr, siehe K2). Keine Migration, keine neue Berechtigung, kein Profil-Wechsel.

- **K1 (critical) — Undo-Vollständigkeit über alle Mutationspfade:** vier ungeschützte
  `nodes`/`connections`-Mutationsstellen (Connect-Geste, "Verbindungen löschen" beide
  Panel-Varianten, "Automatisch anordnen", LSC-/Excel-Bulk-Import) liefen ohne History-Push —
  eine Aktion war damit nicht nur selbst nicht undo-fähig, ein Redo NACH einem vorherigen Undo
  überschrieb den unveränderten `historyFuture`-Stand und warf die neue Änderung ersatzlos weg
  (`appendHistoryEntry` leert `historyFuture` nur, wenn es überhaupt aufgerufen wird). Eigener
  Vollständigkeits-Audit (alle 24 `setNodes`/`setConnections`-Call-Sites klassifiziert) fand zwei
  weitere, nicht als eigene Findings benannte, aber strukturell identische Lücken
  (`linkMeasureToNode`, Tabellen-Ansicht-Add-Callback) und instrumentiert sie mit demselben
  Muster, da der Auftrag wörtlich "alle Mutationspfade" verlangt. 5 neue Tests, inkl. des
  konkreten Datenverlust-Szenarios (Kante nach Undo gezogen übersteht ein Redo).
- **K2 (critical) + K2b (major) — A11y-Gating-Umkehr (PARENT-ENTSCHEID) + Fokus-Management:**
  die Baustein-2-Deklaration "Node-Interaktions-A11y ist unconditional" ist REVIDIERT — `tabIndex`,
  `role="button"`, `aria-label`, Fokus-Ring, `onKeyDown` kommen jetzt hinter
  `wertstromUxV2Enabled` (konsistent mit der bereits gegateten Kanten-A11y). Grund: weil jeder Node
  unconditional `tabIndex` trug und `handleNodeMouseDown` nirgends `e.preventDefault()` aufruft,
  hinterließ ein GEWÖHNLICHER Maus-Klick den DOM-Fokus auf dem Node-Div — jede folgende Pfeiltaste
  bewegte ihn danach lautlos, auch bei Flag AUS, ohne sichtbaren Fokus-Ring und ohne
  Undo-Möglichkeit. Zusätzlich (K2b, Brief-Pflicht "kein Fokus-Verlust ins Nichts"): Enter/Space
  springt nach Panel-Öffnung mit dem Fokus auf das erste Panel-Feld (nur Tastatur-Pfad, Maus
  unverändert), Node-Delete per Tastatur gibt den Fokus an den Canvas-Container zurück. Der
  verbleibende Tab-Wall (roving-tabindex) ist als P8.2d-TODO deklariert, nicht gebaut. 7 Tests
  umgestellt, 2 Bugs-als-Soll-kodierte Tests korrigiert, 5 neue Tests.
- **K3 (major) — touch-action nach KAR-662-Lehre:** repo-eigene Erkenntnis aus
  `components/notes/note-canvas.tsx` widerlegt die "Ancestor-touch-action genügt"-Annahme im
  VSM-Canvas — Safari vererbt touch-action nicht zuverlässig an DOM-Nachfahren. Fix: CSS-Wildcard-
  Regel (`.vsm-canvas-surface`) + non-passive `touchmove`-Fallback, analog note-canvas.tsx.
- **K4 (major) — Pfeiltasten-Auto-Repeat flutet History nicht mehr:** eine gehaltene Pfeiltaste
  feuerte 40-60+ `keydown`-Events (Browser-Auto-Repeat) und konnte den `HISTORY_LIMIT=50`-Stack in
  ~2s komplett fluten/eviktieren — echte, ältere Undo-Schritte gingen dabei verloren. Fix:
  `moveNodeBy` pusht nur noch bei `!e.repeat`, die Bewegung selbst bleibt bei jedem Tick erlaubt.
- **K5 (major) — Paste-Stapelung behoben:** wiederholtes Ctrl+V ohne erneutes Ctrl+C stapelte alle
  Kopien deckungsgleich auf demselben +24/+24-Punkt (unsichtbare Duplikate, die in
  Engpass-/Kapazitätsberechnungen einfließen). Fix: `clipboardNodeRef` zeigt nach jedem Paste auf
  die frisch eingefügte Kopie — Versatz kumuliert jetzt statt zu stapeln.
- **K6 (major) — Compare-Badge-Flag-Lücke geschlossen:** der komplette preferred-Pfad der
  Compare-Ansicht hing nur an `wertstromScenariosEnabled`, obwohl Baustein 5 laut Brief
  "komplett hinter `wertstromUxV2Enabled`" gedacht war — nur die Liste war korrekt gated. Ein
  Profil mit `{scenarios:true, uxV2:false}` (echter Präzedenzfall für auseinanderlaufende Flags
  bereits in `config/profiles/bmw.ts` dokumentiert) zeigte den "Bevorzugt"-Badge weiter an. Fix:
  Badge + `preferredScenarioId`-Durchreichung zusätzlich an `wertstromUxV2Enabled` gekoppelt (page.tsx
  + vsm-scenario-compare-view.tsx), gleiche Konvention wie vsm-list-client.tsx.
- **K7 (major) — fabrizierte Multi-Touch-Guard-Testbehauptung durch echte Tests ersetzt:**
  Test-Kommentar UND Builder-Abschlussbericht behaupteten unabhängig voneinander einen
  `isPrimary:false`-Test, der im gesamten Diff nicht existierte — der von Baustein 1 selbst als
  eigenständige Sicherheitsmaßnahme hervorgehobene `activePointerIdRef`-Mechanismus war komplett
  ungetestet. Falschen Kommentar korrigiert, 4 echte Tests ergänzt (isPrimary:false startet keinen
  Drag/Pan, zweiter Pointer stört laufenden Drag nicht, pointercancel räumt vollständig auf).
- **K9 (minor) — duplicate-Route strippt preferredScenarioId:** `app/api/wertstrom/[id]/
  duplicate/route.ts` (auf diesem Branch bisher unberührt, Alt-Stand aus PR #363 — bewusste
  Scope-Erweiterung als Review-Folge) kopierte `layout` 1:1 inkl. `preferredScenarioId` in die neue
  Szenario-Zeile, wo es semantisch bedeutungslos ist. Fix: destrukturierendes Strippen, analog zur
  bestehenden `stripMeasureRefsForDuplicate`.
- **K10 (minor) — Test: Node-Delete mit Kante + Undo:** der bestehende Delete-Undo-Test nutzte
  eine Fixture ohne Connections — die kaskadierte Kanten-Restaurierung war ungetestet
  (Implementierung selbst bereits korrekt). Neuer Test: Node mit ein- und ausgehender Kante löschen
  → Undo stellt Node UND beide Kanten gemeinsam wieder her.
- **Gates**: `npx vitest run components/wertstrom lib/vsm-engine lib/api app/wertstrom
  app/api/wertstrom lib/demo` grün (1716), `npx vitest run` (repo-weit) 6149 passed / 124 skipped /
  0 failed (Basis vor dieser Runde: 6127 passed / 124 skipped, +22 neue Tests exakt über die 10
  Kerne), `npx tsc --noEmit` 0 Fehler, `CHECK_FORBIDDEN_LEVEL=error npm run check:portability`
  grün, eslint auf geänderten Dateien grün, Vertraulichkeits-Grep leer, design-lint keine neuen
  Treffer über die 3 bereits deklarierten LOW hinaus — siehe Abschlussbericht für die vollständige
  Kern-für-Kern-Aufschlüsselung.

## 2026-07-23 – KAR-878/KAR-986 P8.2a-Review Fix-Runde (8 verifizierte Findings, 6 Kerne)

Adversarielles Multi-Verifier-Review des vorigen Eintrags (Wertstrom P8.2a: Altlasten-PR,
4 Bausteine) — 8 CONFIRMED Findings auf 6 Kerne dedupliziert (K2≡C2+C8, K4≡C5+C6), je ein
thematischer Commit. Keine Migration, keine neue Berechtigung, kein Profil-Wechsel.

- **K1 (major) — classifyVaState voller K8-Spiegel:** Baustein 1's `classifyVaState`
  (`lib/vsm-engine/internal/timeline-ladder.ts`/`timeline-sequence.ts`) hatte den K8-Badge-Fix nur
  HALB gespiegelt — nur der explizite Literal `vaClass==='unknown'` bekam einen eigenen Posten; ein
  NIE klassifizierter Node (weder `vaClass` noch `isValueAdded` gesetzt — real erreichbar über
  `lib/simvsm-import` für jede der 5 SimVSM-Prozessklassen ohne `isValueAdding`-Parameter) fiel
  weiterhin still auf `'nonVa'` zurück, obwohl `resolveVaBadgeState` (vsm-config.ts) für denselben
  Input längst `'unknown'` liefert — derselbe Node zeigte Badge "Ungeklärt", aber rote NVA-Zeit in
  Zeitlinie/Leiter/PDF. Fix: `if (n.isValueAdded === true) return 'va'; if (n.isValueAdded === false)
  return 'nonVa'; return 'unknown'` ersetzt den alten einzeiligen Fallback — voller Spiegel von
  `resolveVaBadgeState`, in BEIDEN Dateien. **Deklarierte Abgrenzung (PRODUCT_SPEC.md):**
  `computeVaClassBreakdown` (vsm-metrics.ts) bleibt bewusst VIERWERTIG (`derivedVa`/`derivedNonVa`
  getrennt von explizitem `nva`/`nnva`/`unknown`) — die Engine-Zeitlinie ist jetzt ehrlich
  DREIWERTIG (va/nonVa/unknown), keine vollständige Angleichung an den Breakdown, das war nie das
  Ziel. **Zahlen-Konsequenz am Referenz-Seed:** 0 Sekunden — verifiziert (Skript-Scan über
  `lib/demo/seeds/wertstrom-seed.ts`), alle 19 process/machine/timevalue-Nodes in JEDEM der 9
  Demo-Wertströme (inkl. `DEMO_VSM_REFERENCE_ECU`) setzen `isValueAdded` explizit — keiner trifft den
  "beides fehlt"-Zweig. Die reale Auswirkung entsteht ausschließlich über den SimVSM-Import-Pfad,
  nicht über diesen Seed (deklariert, keine beschönigte Zahl).
- **K2 (major) — Selektions-Zustandsmaschine:** `selectedConnectionId` war trotz eigenem
  "mutually exclusive"-Kommentar nicht wirklich exklusiv zu `selectedNodeId` — `selectAndRevealNode`
  (Zeitlinien-Segment/CT-Takt-Diagramm), `addNode`, `deleteNode` und `VsmTableView`s rohe
  `onSelectNode={setSelectedNodeId}`-Weiterreichung räumten die jeweils ANDERE Selektion nicht mit
  auf — eine Kante blieb nach Node-Wahl optisch "ausgewählt", und ein stale Verbindungs-Panel konnte
  nach Node-Löschung wieder auftauchen. Fix: EIN zentrales Setter-Paar (`selectNode(id)` /
  `selectConnection(id)`, `components/wertstrom/vsm-editor.tsx`), das die jeweils andere Selektion
  IMMER nullt — ALLE Selektions-Pfade (Canvas-Klick, Zeitlinien-Segment, CT-Chart-Kopplung,
  Tabellen-Zeile, `addNode`, `deleteNode`, Escape) laufen jetzt darüber, statt dass jeder Aufrufer die
  Disziplin von Hand einhalten muss.
- **K3 (major) — Kanten-Hit-Area:** die neue Kanten-Klick-Interaktion (Baustein 3) hatte keine eigene
  Hit-Area — der sichtbare 2px-Pfad WAR die gesamte Klickfläche, bei Zoom 0.32 (Referenz-Seed-
  Viewport) ≈0,64 Bildschirm-Pixel, praktisch nicht treffbar. Fix: unsichtbarer Geschwister-Pfad
  (`stroke="transparent"`, identische `d`-Geometrie), `strokeWidth={12 / zoom}` — counter-skaliert
  gegen den LIVEN Zoom (dasselbe 1/zoom-Muster wie vsm-canvas-timeline.tsx's Band-Text/Drag-Griff),
  sodass die effektive Bildschirmbreite konstant ~12px bleibt. `pointerEvents` sitzt jetzt NUR auf dem
  Hit-Pfad (der sichtbare Pfad hat explizit `pointerEvents:'none'`); Z-Order bewiesen (nicht nur
  behauptet): die Connections-`<svg>` schließt VOR dem "Nodes"-Block, Node-Divs liegen im Paint-Order
  darüber — kein Node-Klick-Diebstahl möglich. Hit-Pfad ebenfalls hinter `wertstromUxV2Enabled`
  (Wechselwirkung mit K4 geprüft und bestätigt).
- **K4 (major→dokumentiert, war als "minor" bewertet, hier vollständig behoben) —
  Flag-Disziplin Rendering:** sowohl die flowControl-Overlay-Icons (Push/Kanban) als auch die
  konfidente PPS-Beschriftung ("Produktionssteuerung (PPS)") waren ungeflaggt gerendert; der
  profil-unabhängige Referenz-Seed liefert `flowControl:'kanban'` UND `isPps:true` an jedes Profil —
  eine `wertstromUxV2:false`-Instanz (z. B. eine frische Kundeninstanz aus
  `config/profiles/_template.ts`), die den Referenz-Seed lädt, hätte neue visuelle Elemente gesehen,
  die `main` nie kannte (derselbe Fehler-Typ wie der C12-Präzedenzfall, dort "major"). Fix: DATEN
  bleiben additiv/ungeflaggt (sparse Felder in DB/Seed sind ok), RENDERING beider Neuerungen jetzt
  hinter `wertstromUxV2Enabled` — Overlay-Icon-Block direkt gated;
  `resolveNodeSymbolLabel` (vsm-config.ts) bekommt einen neuen `confidentLabelEnabled`-Parameter
  (Default `true`, damit außerhalb dieses Fixes liegende Aufrufer wie die Vergleichs-Ansicht
  unverändert bleiben), beide zuvor ungeflaggten `vsm-editor.tsx`-Aufrufstellen
  (Canvas-Node-Header, altes Flat-Panel) übergeben jetzt `wertstromUxV2Enabled` explizit. RTL-Beweis
  mit kombinierten Seed-Feldern (kanban + isPps zusammen, Flag AUS) grün.
- **K5 (major) — Guard-Fehler-Ehrlichkeit:** `linkMeasureToNode`s optimistischer
  `measureRefs`-Patch wurde bei einem echten PUT-Fehler (409/5xx) NIE zurückgerollt — der Picker
  (`VsmLinkMeasureDialog`) liest `alreadyLinkedIds` direkt aus demselben `nodes`-State und rendert
  dadurch sofort "bereits verknüpft" statt der echten Fehlermeldung (Ghost-Link: UI meldet
  Erfolg/schon-da, Server hat nie etwas persistiert). Fix: bei jedem Nicht-Erfolg wird die
  measureId aus dem AKTUELLEN `nodes`-State gefiltert (funktionales Update, kein Zurücksetzen auf
  einen alten Snapshot — zwischenzeitliche andere Edits bleiben unangetastet); die Konflikt-
  Fehlermeldung verliert den "Seite neu laden"-Rat (dieser Dialog legt nie etwas neu an, siehe
  eigener Datei-Kommentar "kann sicher erneut versucht werden" — jetzt auch tatsächlich wahr).
- **K6 (minor) — Font-Konsistenz:** die neuen Steuerungsart-Buttons im Connect-Zeit-Picker
  (`text-xs`, 12px) auf `text-[10px]` angeglichen — dieselbe Größe wie die Kantentyp-Toggle-Zeile
  und das Frequenz-Input direkt darüber im selben schwebenden Toolbar-Cluster.
- **Gates**: `npx vitest run components/wertstrom lib/vsm-engine lib/api app/wertstrom
  app/api/wertstrom lib/demo` grün, `npx vitest run` (repo-weit) grün, `npx tsc --noEmit` 0 Fehler,
  `CHECK_FORBIDDEN_LEVEL=error npm run check:portability` grün, eslint auf geänderten Dateien grün,
  Vertraulichkeits-Grep leer, design-lint keine neuen Treffer — siehe Abschlussbericht für die
  vollständige Test-Bilanz vorher/nachher je Kern.

## 2026-07-23 – Wertstrom P8.2a: Altlasten-PR (4 Bausteine, KAR-878/KAR-986)

Vier eigenständige, deklarierte Alt-Lücken aus PR #367/#363 geschlossen — je ein
thematischer Commit. Keine Migration, keine neue Berechtigung, kein Profil-Wechsel.

- **Baustein 1 — isVaNode-unknown-Alignment**: `vaClass='unknown'` ("Ungeklärt") fiel in
  `lib/vsm-engine/internal/timeline-ladder.ts`/`timeline-sequence.ts` bisher still in den
  NVA-Bucket (die alte binäre `isVaNode`) — `computeVaClassBreakdown` (vsm-metrics.ts) und
  der K8-gefixte VA-Badge behandelten `'unknown'` schon als eigene Klasse, die
  Engine-Zeitlinie widersprach dem. Fix: `classifyVaState` (tri-state) + neuer Posten
  `unknownTimeSec`/Kategorie `'unknown-process'` — additiv (DLZ/BZ-Summen unverändert,
  nur die Bucket-Beschriftung korrigiert), neutrale Darstellung (`--status-grey`, nicht
  NVA-Rot) in Canvas-Zeitlinie/Timeline-Leiter/PDF-Export; `management-analysis.ts`s
  Konfidenz-Check zählt den neuen Posten jetzt mit. Copilot-Exporte nutzten bereits
  `computeVaClassBreakdown` (schon korrekt); XLSX-Export zeigt keine VA/NVA-Aufschlüsselung
  (nichts anzugleichen, beides geprüft).
- **Baustein 2 — PPS als echtes Datenfeld**: `VsmNode.isPps?: true` (sparse, true-only,
  additiv/passthrough, kein DDL) — löst den in K6 (PR #367) deklarierten P8.2-Kandidaten
  ein. `resolveNodeSymbolKey` (vsm-config.ts) prüft das Feld VOR der strukturellen
  Heuristik ("nur Info-Kanten") und lässt es gewinnen, auch wenn der Node zusätzlich
  Materialfluss-Kanten hat (Judgment-Call). Neues `resolveNodeSymbolLabel` differenziert
  die Anzeige: Feld gesetzt → "Produktionssteuerung (PPS)", nur Heuristik → unverändertes
  ehrliches "Informationsknoten (z. B. Produktionssteuerung/PPS)". SimVSM-Import
  (`productionControl`) setzt es jetzt mit; Referenz-Seed-PPS-Node markiert. Bewusst NICHT
  Teil des Excel-Roundtrips (wie jedes andere P2+/P8.1-Feld).
- **Baustein 3 — Push/Pull/Kanban-Kantensemantik**: `VsmConnection.flowControl?: 'push' |
  'pull' | 'kanban'` (nur materialFlow, Schema-validiert) verdrahtet
  `VsmSymbolPushArrow`/`VsmSymbolKanbanPull` (seit PR #367 gebaut, aber ungerendert — 2 der
  4 K10-Symbol-Lücken) — Overlay-Icon an der Kanten-Mitte, bestehender Marker bleibt
  pixel-identisch ohne Feld. Judgment-Call: vor diesem Baustein gab es GAR KEINE
  Möglichkeit, eine bestehende Verbindung im Canvas zu bearbeiten (nicht einmal
  transportTimeSec/batchSize seit P0/P1) — neu eine minimale Verbindungs-Auswahl +
  Experten-Panel NUR für flowControl (keine retroaktive Vollbestückung), plus Auswahl beim
  Zeichnen einer neuen Kante. Referenz-Seed: `flowControl: 'kanban'` auf den zwei
  Eingangssupermarkt-Kanten (wörtlicher Bestandstext "Kanban/Pull-Prinzip"); bewusst kein
  `'push'` im Seed (keine wortgleiche Zuordnung in wertstrom-kais.txt). Symbol-Lücken-Liste
  (K10) von vier auf zwei reduziert.
- **Baustein 4 — Bestehende Maßnahme verknüpfen**: löst den TODO.md-P8.2-Kandidaten aus der
  PR #363 Fix-Runde (K1) — der zweite Pfad in `measureRefs` neben "erstellen". Neue
  `vsm-link-measure-dialog.tsx` pickt eine bereits vorhandene `workshop_actions`-Zeile des
  Projekts (kein neuer Query) und verknüpft über dieselbe `linkMeasureToNode` (K1
  Sofort-PUT) — kein Orphan-Risiko bei Fehlschlag, da nichts neu angelegt wird. Dieselbe
  `canManageMeasures`-RLS-Grenze wie "erstellen" (Nicht-Owner sehen den Button nicht).
  Duplikat-Verknüpfung auf zwei Ebenen verhindert: Picker-Filter (`isMeasureAlreadyLinked`,
  neu, unit-getestet) + idempotentes `linkMeasureToNode`. Bewusst NICHT angefasst: die 5
  §15.5-Felder (root cause/target state/expected effect/actual effect/linked KPI) — Stufe-4-
  Migration mit Kais.
- **Gates**: `npx vitest run components/wertstrom lib/vsm-engine lib/api app/wertstrom
  app/api/wertstrom lib/demo` grün, `npx vitest run` (repo-weit) grün, `npx tsc --noEmit`
  0 Fehler, `CHECK_FORBIDDEN_LEVEL=error npm run check:portability` grün,
  Vertraulichkeits-Grep leer, design-lint auf geänderten Komponenten: 2 deklarierte
  T10-Fehlalarme (Wide-Tracking) auf neuen, korrekt uppercase+tracking-wider gestylten
  Panel-Labels (dieselbe Konvention wie 63 bereits bestehende Labels in vsm-editor.tsx) —
  siehe Abschlussbericht.

## 2026-07-23 – KAR-878 Referenzwertstrom-Fixrunde 2 (21 verifizierte Findings, PR #367)

Adversarielles Multi-Verifier-Review der VSM-Standard-Visualisierung (voriger Eintrag) —
17 CONFIRMED aus 6 Dimensionen + 3 gerettete Zeitlinien-Findings (R1-R3), 21 auf 13 Kerne
dedupliziert. Ein CRITICAL, acht major, vier minor. Eine explizite NO-FIX-Deklaration
(s. u.).

- **K1 (CRITICAL)**: `informationEdgeRoutes`-`<g>` (vsm-editor.tsx) erbte
  `pointer-events:none` vom Canvas-`<svg>` und reaktivierte es nie — die `<title>`-Tooltip
  mit Kanten-Label/Frequenz konnte in echten Browsern NIE feuern, das vor diesem PR
  permanente Text-Label war ersatzlos unerreichbar. Fix: `style={{pointerEvents:'auto'}}`
  — exakt das Muster, das `vsm-canvas-timeline.tsx` für denselben geerbten Wert bereits
  nutzt. Entscheidung: Hover-only bleibt die Lösung (Kais' §12-Progressive-Offenlegung-
  Doktrin), kein permanentes Kompakt-Element.
- **K2 (major/critical)**: alle Band-Texte (Segment-Dauer, §8-Summenbox, Nacharbeits-
  Hinweis) lebten im pan/zoom-transformierten Welt-SVG — beim geseedeten Referenz-Zoom
  (0,32) praktisch unlesbar (~3px effektiv). Fix: jedes Text-/Griff-Element gegen-skaliert
  per `1/zoom` (Chrome-Element-Technik) + Basisgrößen auf 12-13px; `zoom` neuer optionaler
  Prop (Default 1, No-Op für jeden Bestandsaufrufer).
- **K3 (major)**: `layout.timelineOffsetY` wurde bei JEDEM Save geschrieben, auch bei
  `wertstromUxV2Enabled=false` — Bruch der "Flag AUS ⇒ byte-gleich"-Doktrin. Fix:
  `...(wertstromUxV2Enabled ? {timelineOffsetY} : {})` in `handleSave` UND der
  30s-Autosave.
- **K4 (major)**: `resolveTimelineOffsetY` clampte nur nach unten — kein oberer Deckel,
  Drag-Verstärkung bis 5x beim Seed-Zoom. Fix: `TIMELINE_BAND_OFFSET_MAX=2000` +
  Server-Merge-Clamp (route.ts) + großzügige Zod-Sanity-Ceiling (±1.000.000, damit
  Alt-Client-Riesenwerte geklemmt statt mit 400 abgelehnt werden) + "Zeitlinie
  zurücksetzen"-Control + `aria-valuemin`/`aria-valuemax`/`aria-valuetext` am Drag-Griff.
- **K5 (major)**: Zeitlinien-Segment-`<g>` ließ mousedown ungebremst zum Canvas
  durchfallen — startete einen Pan-Drag UND löschte die Node-Selektion. Fix:
  `onMouseDown={(e) => e.stopPropagation()}`, gated auf `onSelectNode`.
- **K6 (major)**: PPS-Symbol war geometrisch fast identisch zu Machine (nur ~7-12%
  Radius-/Tick-Differenz) UND die Heuristik ("nur Info-Kanten") versprach mehr Sicherheit
  als das Datenmodell hergibt. Fix: echtes Fadenkreuz (2 Linien durch den Kreis) +
  ehrliches Label "Informationsknoten (z. B. Produktionssteuerung/PPS)" statt "PPS".
  Schema-Feld `role`/`isPps` bleibt P8.2-Kandidat.
- **K7 (major)**: Kais §10.5 ("per Tooltip erklärbar") an keiner Haupt-Render-Stelle
  erfüllt. Fix: `VSM_SYMBOL_LABELS` + title/aria-label an allen 5 VsmSymbolByKey-Stellen
  in vsm-editor.tsx; Vergleichs-Ansicht löst Symbole jetzt über `resolveNodeSymbolKey`
  auf (Supermarkt/FIFO-Treue; Versand-Zweig bleibt generisch, da `connections`
  dokumentiert nicht geladen wird, Review-Fix F6/PR #356).
- **K8 (major)**: VA/NVA-Canvas-Badge zeigte "NVA" auch für nie klassifizierte UND für
  explizit "Ungeklärt" gewählte Nodes — Verfälschung einer expliziten Nutzereingabe. Fix:
  `resolveVaBadgeState` liefert `'va'|'nva'|'unknown'`; Fußleisten-Balken entfällt bei
  `'unknown'`. Deklarierte Restlücke: `lib/vsm-engine`s interne KPI-Klassifikation faltet
  `'unknown'` weiterhin in ihren NVA-Bucket (nur der Badge-Text ist korrigiert).
- **K9 (major)**: `computeInformationEdgeRoutes`' Sammel-Lane kollidierte mit dem obersten
  Node, wenn dieser selbst der Multi-Kanten-Hub ist (Kais' eigenes §5-PPS-Szenario) — am
  echten Referenzwertstrom empirisch bestätigt. Fix: reservierter Gap wächst dynamisch mit
  der Kantenanzahl, sodass auch die unterste gefächerte Lane oberhalb des Hub-Nodes bleibt.
- **K10 (major→Doku)**: `VsmSymbolPushArrow`/`VsmSymbolKanbanPull` sind gebaut+getestet,
  aber nirgends gerendert — PRODUCT_SPEC.md/CHANGELOG nannten nur 2 (QualityCheck/
  InfoManual) statt 4 unverdrahtete Symbole. Fix: Datei-Header + PRODUCT_SPEC.md korrigiert
  auf die vollständige Lückenliste; NICHT verdrahtet (Push-Marker-Tausch wäre eine weitere
  ungeflaggte sichtbare Kanten-Änderung, Pull/Kanban bräuchte ein neues VsmConnection-Feld).
- **K11 (minor)**: VA-Legende + Zeitlinien-Steuerleiste bei 9-10px in offenem Chrome ohne
  Node-Box-Zwangslage. Fix NUR an diesen zwei Flächen: `text-xs` (12px),
  `tracking-wide` statt `tracking-wider` (löst T10 UND T6 gleichzeitig sauber),
  ±-Klickfläche 20px -> 24px.
- **K12 (minor)**: `handleTimelineOffsetHandleMouseDown` prüfte nie auf einen aktiven
  Connect-Gesture — brach die Geschwister-Konvention. Fix: `if (connectingFrom) return`
  vor `stopPropagation` (mirrors `handleNodeMouseDown`), lässt den Mousedown zu
  `handleCanvasMouseDown` durchbubbeln, dessen eigener Cancel-Zweig den Gesture sauber
  auflöst.
- **K13 (minor)**: keiner von Kais' zwei Interaktionspfaden (Drag am Band-Griff ODER
  +/−-Steuerung) hatte einen Ende-zu-Ende-Test. Drei neue Tests, alle über den echten
  PUT-Body geprüft (Drag mit Seed-Zoom 0,32 inkl. K4-Deckel-Nachweis; Stepper-Schrittweite;
  K4-Boden bereits während des Schrittens, nicht erst am Ende).
- **Explizite NO-FIX**: Bausteine 1/3/4 (Emoji->SVG-Symbole, VA-Badge, Info-Kanten-Routing)
  bleiben unflagged — verifiziert per `git show 2336ba6`, dass die ersetzten Renderpfade
  bereits VOR diesem PR unconditional waren; ein Flag-Gate würde doppelte Renderpfade für
  nie geflaggten Bestand schaffen. Parent-Entscheid nach Verifier-Dissens (ein Verifier
  REFUTED, einer bestätigte minor — beide auf demselben Sachverhalt).
- **Gates**: gezielt `npx vitest run components/wertstrom lib/vsm-engine lib/api
  app/wertstrom app/api/wertstrom` 1439/1439 (63 Dateien), `npx vitest run` (repo-weit)
  5994/5994 (+124 skipped, 397 Dateien), `npx tsc --noEmit` 0 Fehler,
  `CHECK_FORBIDDEN_LEVEL=error npm run check:portability` komplett grün (profiles/
  forbidden/boundaries/openapi/csp/secrets/hardcoded-colors/bmw-fonts), `npm run lint`
  auf allen 15 geänderten Dateien 0 neue Errors/Warnings (1 bekannter, PR-367-eigener
  `react-hooks/exhaustive-deps`-Warnhinweis unverändert), Vertraulichkeits-Grep gegen
  `git diff 2336ba6` leer (beide Muster), design-lint auf vsm-editor.tsx T11 129->123
  (-6) und T10 64->63 (-1) (K2s Zieldatei vsm-canvas-timeline.tsx zeigt 0 Treffer vor UND
  nach dem Fix — das Tool parst nur String-literale, keine rohen SVG-`fontSize={N}`-Props;
  dedizierte Tests decken diesen Fall stattdessen ab), vsm-scenario-compare-view.tsx/
  vsm-symbols.tsx unverändert bei ihren jeweiligen Baseline-Werten.

## 2026-07-23 – KAR-878: VSM-Standard-Visualisierung — Symbolbibliothek, Canvas-Zeitlinie, VA-Sichtbarkeit, Informationsfluss (Kais-Live-Feedback 23.07.)

Direktes Kais-Feedback auf dem geöffneten Referenzwertstrom (23.07., KAR-878): die Emoji-Optik trägt
nicht, die Zeitlinie ist "nicht nach dem Standard" (muss direkt unter dem Wertstrom sitzen statt in
einer separaten Sektion), der Informationsfluss-Bereich ist unübersichtlich, Wertschöpfend-Status ist
visuell schwach. Vier Bausteine, EIN Feature-Commit, `wertstromUxV2`-Flag für Baustein 2 (wie bisher);
Bausteine 1/3/4 unflagged (sie ersetzen/verbessern bereits-immer-gerenderte Canvas-Bestandteile, kein
neues optionales Feature). Keine Migration, keine Profil-Änderung.

- **Baustein 1 — SVG-Symbolbibliothek (`components/wertstrom/vsm-symbols.tsx`, neu):** 19
  wiederverwendbare, stroke-basierte SVG-Komponenten für Kais' §10-Liste (20 Punkte, 2 bewusste
  Zusammenlegungen: Lieferant+Kunde → EIN Fabrik-Piktogramm wie im Rother/Shook-Original, Pull+Kanban →
  EIN Kreis-Symbol, s. Brief). `currentColor` + `--vsm-*`-CSS-Vars (Dark-tauglich, keine losen Hex-Werte),
  24x24 `viewBox`, `size`-Prop, `aria-hidden` (Tooltip/Label lebt am Aufrufer). `vsm-config.ts`'s
  `NODE_CONFIG.icon` wechselt von rohem Emoji-Zeichen auf einen typisierten `VsmSymbolKey`; ein neues
  `resolveNodeSymbolKey(node, nodes, connections)` verfeinert das TYP-Icon anhand ECHTER Strukturdaten
  (nie Namens-Raten): `inventoryKind` → Supermarkt-/FIFO-Symbol, ein Transport-Node mit
  Materialfluss-Kante zu einem `customer`-Node → Versand- statt generisches Lkw-Symbol, ein
  `process`-Node ausschließlich über `information`-Kanten verbunden → PPS-Symbol (dieselbe Signatur,
  die `lib/vsm-engine`'s `isInformationOnlyNode` schon nutzt). Emoji ersetzt in Sidebar-Palette,
  Canvas-Nodes, Properties-Panel, Kaizen-/Engpass-Badges (P8.1), Tabellen-View, Szenario-Vergleichsview.
- **Baustein 2 — Canvas-integrierte Standard-Zeitlinie (`components/wertstrom/vsm-canvas-timeline.tsx`,
  neu, ersetzt `vsm-timeline-zigzag.tsx`):** die Zeitlinie lebt jetzt ALS BAND im selben `<svg>` wie die
  Connections — teilt sich automatisch denselben pan/zoom-Transform des Canvas ("zoomt mit"). Jedes
  Segment nimmt sein x/width DIREKT vom eigenen Node (`computeTimelineBandSegmentPositions`,
  `vsm-geometry.ts`) — nie eine laufende Sequenz-Index-Anordnung wie vorher. Vertikale Position via
  `layout.timelineOffsetY` (additiv, neuer `layout`-Key, keine Migration) — Drag am Band-Griff ODER
  +/−-Steuerleiste (beide verdrahtet auf dieselbe `resolveTimelineOffsetY`-Klammerung, die verhindert,
  dass das Band in die Node-Reihe hineinrutscht). Die alte separate `<details>`-Sektion (`VsmTimelineZigzag`)
  entfällt ersatzlos; ihr Durchlaufzeit-Hilfetext wandert an die neue fixe Zeitlinien-Steuerleiste.
  Rework-Loop-Hinweis nutzt jetzt `VsmSymbolReworkLoop` statt des rohen "↺"-Zeichens. Reine
  Rendering-Schicht — `computeTimelineSequence`/`computeTimelineLadder` unverändert.
- **Baustein 3 — VA/NVA-Sichtbarkeit:** vorher zeigte ein Prozesskasten NUR bei `isValueAdded===true`
  einen Hinweis ("✓ Wertschöpfend") — `false`/`undefined` sahen identisch (nichts) aus. Jetzt IMMER
  eine von zwei Zeilen für jeden VA-fähigen Node-Typ (process/machine/timevalue, dieselbe Population wie
  `lib/vsm-engine`'s Timeline-Engines) — Text ("✓ Wertschöpfend"/"NVA", AA-konform, nicht farbausschließend)
  + ein dezenter grüner/grauer Balken am Kastenfuß (`--success`/`--status-grey`, bestehende Vars). Neue
  `resolveIsValueAdded()` (vsm-config.ts) spiegelt dieselbe vaClass-Prioritätsregel wie die Engine — der
  Canvas-Badge zeigt nie einen anderen Status als die tatsächlich gezählten KPI-Zahlen. Dezente Legende
  in der Sidebar-Palette.
- **Baustein 4 — Informationsfluss entwirren:** `computeInformationEdgeRoutes` (`vsm-geometry.ts`, neu) —
  orthogonales Bündel-Routing NUR für `kind: 'information'`-Kanten (materialFlow-Routing unangetastet):
  von der Quelle nach oben zu einer gemeinsamen horizontalen Sammelhöhe über dem obersten Node, dann
  seitlich, dann runter ins Ziel; deterministischer Lane-Fächer (sortiert nach Quell-/Ziel-x-Mittelpunkt,
  Round-Robin um die Basis-Lane) statt frei kreuzender Bezier-Kurven. Der immer sichtbare, teils sehr
  lange Text-Label (Kais-Seed: einzelne Labels >80 Zeichen) weicht einem `VsmSymbolInfoElectronic`-Icon
  auf der Kante + einer nativen `<title>`-Tooltip mit dem vollen Text — Icons tragen die visuelle Last
  statt Dauer-Text, direkt Kais' "Icons usw. würden hier helfen".
- **Kanten-Stil (Baustein 1):** neue Pfeilspitzen-Marker `vsm-arrow-material` (breit/stumpf, Push-Stil)
  und `vsm-arrow-info` (schlank/offen, elektronisch) ersetzen den einen `arrowhead`-Marker; das nie
  referenzierte `arrowhead-red` entfällt (totes Markup, per Grep verifiziert vor dem Löschen).
- **API/Persistenz:** `layout.timelineOffsetY` (Zod `VsmLayoutSchema`, PUT-Merge in
  `app/api/wertstrom/[id]/route.ts`) — sparsames numerisches Feld, einfacheres Wire-Contract als
  `shiftModel` (nur "abwesend = unberührt lassen" / "vorhanden = überschreiben", kein
  Null-Lösch-Dreiklang, da 0 immer ein gültiger Ruhewert ist, s. `resolveTimelineOffsetY`-Doku).
- **Tests:** `vsm-geometry.test.ts` (Timeline-Band-Baseline/Offset-Clamping/Segment-Positionen inkl.
  Parallelpfad-Kollisions-Nudge, Info-Routing inkl. Determinismus + Dangling-Connection-Schutz),
  `vsm-symbols.test.tsx` (alle 19 Symbole: viewBox/aria-hidden/Größen-Prop/kein Hex-Literal),
  `vsm-canvas-timeline.test.tsx` (Klick-/Tastatur-Selektion, Rework-Loop-Symbol, Drag-Griff-/
  Offset-Stepper-Verdrahtung, Referenz-Seed-Smoke über alle 24 ECU-Nodes ohne NaN/Crash),
  `schemas.test.ts` + `route.test.ts` (timelineOffsetY-Merge-Verträge). Bestehende Suite: 2
  Test-Kollisionen behoben, die die neuen, immer sichtbaren SVG-Elemente aufgedeckt haben — ein
  `getByText(nodeName)`-Konflikt (das Band hatte testweise ein zweites Namens-Label; entfernt, da mit
  x-Ausrichtung ohnehin redundant) und ein `querySelectorAll('line')`-Konflikt (die neue Symbolbibliothek
  nutzt `<line>` legitim in mehreren Icons; die Zonen-Trennlinie trägt jetzt einen eigenen
  `data-vsm-zone-divider`-Testhook statt sich auf "einziges `<line>` im Baum" zu verlassen).
- **Bekannte Lücke (dokumentiert, nicht stillschweigend übergangen):** zwei Elemente mit identischem
  Canvas-x (echte Parallelzweige, z. B. die beiden Eingangssupermärkte im Referenz-Seed) erzeugen
  Band-Segmente, die sich kosmetisch um ein paar Pixel gegenseitig verschieben statt einer echten
  Kollisionsvermeidung — dieselbe "dokumentiert, nicht gelöst"-Haltung wie `computeTimelineSequence`'s
  eigene Gap G6 ("Parallelpfade werden nicht taktkorrekt abgebildet").

## 2026-07-23 – KAR-878: Referenz-Beispielwertstrom — Fix-Runde (PR #365, adversarielles Multi-Verifier-Review)

18 CONFIRMED Findings aus dem adversariellen Multi-Verifier-Review von PR #365 behoben, dedupliziert
auf 13 Defekt-Kerne (starke Überlappung — PPS-in-Zeitlinie und Nacharbeits-Rückfluss-Erkennung
tauchten je unabhängig in 3 bzw. 2 der geprüften Dimensionen auf). Keine Migration, keine
Profil-Änderung (Profile-Diff 0), keine neue Permission.

- **K-ZONEN (major, löst 3 Findings) — Firmengrenzen (§11) unimplementiert + CT-Takt-Diagramm zeigt
  2nd-Tier-Prozesse (§9):** neue, additive `layout.zones`-Struktur (`VsmZone[]`,
  `parseZonesFromLayout`/`computeZoneBounds`/`computeZoneDividers`, `vsm-geometry.ts` — selbes
  "neuer Key im bestehenden layout-jsonb, keine Migration"-Muster wie `layout.shiftModel`).
  `vsm-editor.tsx` rendert dezente vertikale Trennlinien + Zonen-Labels auf dem Canvas, nur wenn
  `zones` definiert ist. Ein optionales `role`-Feld je Zone treibt zusätzlich den neuen
  `tier1NodeIds`-Filter in `vsm-ct-takt-chart.tsx` (Kais §9 "Prozesse DES TIER-1-LIEFERANTEN") — EIN
  Mechanismus löst beide Lücken. Referenz-Seed definiert 3 Zonen (AluForm/E-Motion/BMW).
- **K-PPS (major, löst 3 Findings) — PPS-Node erscheint als chronologisch unsinniger "?"-Block NACH
  der BMW-Auslieferung in der Zickzack-Zeitlinie:** `computeTimelineSequence`
  (`lib/vsm-engine/internal/timeline-sequence.ts`) schließt jetzt jeden Node, der ausschließlich über
  `kind: 'information'`-Kanten verbunden ist, strukturell aus (`isInformationOnlyNode`) — analog zum
  bestehenden customer/supplier-Ausschluss. Ein Node ganz ohne jede Kante (echte Insel) bleibt davon
  unberührt (pre-existing "disconnected island"-Verhalten).
- **K-REWORK (major, löst 2 Findings) — Nacharbeitsschleifen-Erkennung greift nicht am echten Seed
  (Kais §7 verletzt):** `findReworkLoopNodeIds` erkannte bisher nur einen literalen Host↔Node-Zyklus
  (P→N→P). Der ausgelieferte Seed modelliert Nacharbeit bewusst als Seitenpfad (EOL → Nacharbeit →
  Prüfpuffer, mit einer direkten EOL → Prüfpuffer-Kante daneben — kein Graph-Zyklus). Die Erkennung
  ist jetzt generalisiert: ein Node mit genau 1 eingehender + 1 ausgehender Materialfluss-Kante gilt
  auch dann als Rückfluss-Anhängsel, wenn sein Host die Zielkante des Nodes ZUSÄTZLICH direkt
  erreicht (paralleler Seitenpfad). Generischer Test + Negativtest (ein normaler Transport-Node ohne
  Parallel-Kante bleibt Hauptfluss) in `lib/vsm-engine/__tests__/timeline-sequence.test.ts`.
- **C6 (major) — Live-Takt-Vergleich nur einseitig:** der neue Live-Takt-Fallback lief nur auf der
  "current"-Seite jedes Ist/Soll-Vergleichs, nicht auf der `compareTarget`-Seite
  (`app/wertstrom/[id]/page.tsx`) — erzeugte einen falschen "unterschiedlicher Kundentakt"-Alarm für
  zwei projektlose Wertströme mit identischem shiftModel+Bedarf. Beide Seiten leiten den Takt jetzt
  symmetrisch aus IHREM EIGENEN shiftModel+Bedarf ab.
- **C12 (major) — Live-Takt-Fallback ignorierte `wertstromUxV2Enabled`:** brach "Flag AUS ⇒
  byte-gleich" für jeden bestehenden projektlosen Wertstrom mit bereits vollständigem
  shiftModel+Kundenbedarf. Fallback jetzt hinter dem Flag, auf beiden Seiten jedes Vergleichs (C6).
- **C7 (major) — CT-Takt-Diagramm ohne A11y-Anbindung:** `role="img"` + dynamisches `aria-label` am
  Chart, plus eine `sr-only`-Tabelle als tastatur-/screenreader-bedienbare Alternative zur
  bidirektionalen Balken↔Prozess-Kopplung (Recharts-Balken sind in diesem Chart-Setup weder
  fokussierbar noch — wie das gesamte Chart — in jsdom klickbar testbar).
- **C11 (major) — Kais-§12-Hilfetexte doppelt:** Taktzeit/Zykluszeit/Durchlaufzeit rendern über die
  neuen Sektionen hinweg zweimal (FieldHint im Wrapper + natives `title`-Attribut im Kind). Jeder
  Text lebt jetzt genau einmal: Taktzeit → Management-Analyse, Zykluszeit → CT-Takt-Diagramm,
  Durchlaufzeit → Zickzack-Zeitlinie; die nativen `title`-Duplikate entfernt.
- **C13 (major) — ↺-Nacharbeitsschleifen-Text WCAG-AA-Kontrastbruch:** `--status-yellow` (Badge-,
  keine Textfarbe, ≈1,3:1 auf `--card`) ersetzt durch das im Repo etablierte AA-sichere
  `amber-700`/`dark:amber-400`-Textmuster (≥4,5:1) — als `fill-amber-700 dark:fill-amber-400`.
- **C14 (minor) — PRODUCT_SPEC.md-Kausalfehler:** die Behauptung, PPS' Process-Typisierung senke die
  Engpass-Konfidenz von `'high'` auf `'medium'`, war empirisch falsch (Konfidenz ist bereits ohne PPS
  `'medium'`, wegen des vorbestehenden oee/capacityPerHour-Gates in `bottleneck.ts`) — korrigiert; die
  Aussage "ein neuer NodeType hätte das vermieden" gestrichen.
- **C15 (minor) — de-DE-Zahlenformat verletzt:** `112.5s`/`72.0%`/`1.1 : 1` (Punkt) statt
  `112,5s`/`72,0%`/`1,1 : 1` (Komma) in beiden neuen Komponenten — auf das im Modul etablierte
  `Intl.NumberFormat('de-DE', …)`-Muster umgestellt.
- **C16 (minor) — native `title`-Attribute nicht zugänglich:** dieselben Duplikate wie C11 — mit
  behoben (die FieldHint-Instanz in `vsm-editor.tsx` ist der einzige zugängliche Weg).
- **C17 (minor) — hartcodierter Takt-Text im Seed:** `kaizenNote` und die VSM-`description` der
  EOL-Station buken die AM-SEED-ZEITPUNKT berechneten Takt-/Engpass-Zahlen als Freitext ein — nach
  einer erlaubten Live-Bearbeitung von Kundenbedarf/Schichtmodell (§3/§13/§22.18) wären sie falsch
  geblieben, während Taktlinie/Engpass-Badges live neu rechnen (§22.23). Beide Texte jetzt neutral
  formuliert, ohne eingebackene Zahlen.
- **C18 (minor) — CT-Takt-Diagramm-Balken↔Prozess-Zuordnung ungetestet:** Recharts' `Bar`-`onClick`
  ist in jsdom nicht auslösbar (`ResponsiveContainer` misst 0×0). Die neue `sr-only`-Tabelle (C7) ist
  echtes, testbares DOM und ruft denselben `onSelectNode`-Callback wie die Balken — jetzt automatisiert
  getestet (Klick/Enter ⇒ korrekte Node-ID; Node-Auswahl ⇒ `aria-current` auf der passenden Zeile).
- **Tests:** 44 neue/geänderte Tests über `lib/vsm-engine/__tests__/timeline-sequence.test.ts`,
  `lib/demo/seeds/__tests__/wertstrom-seed-reference-ecu.test.ts`,
  `components/wertstrom/__tests__/vsm-geometry.test.ts`,
  `components/wertstrom/__tests__/vsm-ct-takt-chart.test.tsx`,
  `components/wertstrom/__tests__/vsm-timeline-zigzag.test.tsx`,
  `components/wertstrom/__tests__/vsm-editor.test.tsx`,
  `app/wertstrom/[id]/__tests__/page.test.ts`.
- **Gates:** targeted vitest (`lib/vsm-engine lib/demo components/wertstrom app/wertstrom`, 1210
  Tests) grün, `tsc --noEmit` grün, `CHECK_FORBIDDEN_LEVEL=error npm run check:portability` grün,
  eslint grün (0 neue Fehler/Warnungen), Vertraulichkeits-Grep leer, Profile-Diff 0.

## 2026-07-22 – KAR-878: Wertstrom Referenz-Beispielwertstrom "Elektronisches Steuergerät" (Kais-Auftrag)

Vollständig autonom umgesetzter Referenz-Wertstrom auf direkten Kais-Auftrag: AluForm Components
GmbH (2nd Tier, fiktiv) → E-Motion Systems GmbH (Tier 1, fiktiv) → BMW Group (Kunde, das reale
Produkt dieses Repos). Additiv-only: kein neuer `NodeType`, kein neues `VsmNode`/`VsmConnection`-
Feld, keine Migration, keine neue Route, keine neue Permission, keine Profil-Änderung.

- **Seed (`lib/demo/seeds/wertstrom-seed.ts`, `DEMO_VSM_REFERENCE_ECU`):** 24 Nodes / 30
  Verbindungen (23 Materialfluss, 7 Informationsfluss), 9. Eintrag in `DEMO_VSMS`
  (`DEMO_VSM_IDS[8]`), standalone (`project_id: null`). Jede Zahl (Zykluszeiten, Rüstzeiten,
  Verfügbarkeiten, Ausschuss, Bestände, Losgrößen, Arbeitszeitmodell) wörtlich aus Kais' Dokument
  §3–§9, nur die Firmennamen AluForm/E-Motion sind fiktiv erfunden.
- **Taktzeit live berechnet (§4):** `vsm-editor.tsx` fällt auf eine frisch aus `layout.shiftModel`
  + Kundenbedarf berechnete Taktzeit zurück (`lib/vsm-engine` `computeTakt`), wenn kein
  projektgebundener Takt vorliegt — reproduziert Kais' Rechenbeispiel exakt (900 min/Tag →
  54.000 s → 112,5 s). Byte-identisch für jeden bestehenden Wertstrom mit Projekt-Takt.
- **Zickzack-/Treppen-Zeitlinie (§8, §22.7):** neues `lib/vsm-engine/internal/timeline-sequence.ts`
  (`computeTimelineSequence`, Kahn-Sortierung über den Materialfluss-Graphen, Nacharbeitsschleifen-
  Erkennung) + `components/wertstrom/vsm-timeline-zigzag.tsx` — die bestehende Timeline-Leiter
  zeigt nur einen proportionalen Summenbalken, keine klassische Zickzack-Form; das war eine echte
  Lücke, keine Umbenennung.
- **Zykluszeit-Taktzeit-Diagramm (§9):** neues `components/wertstrom/vsm-ct-takt-chart.tsx` —
  Recharts-Balken je Prozess, Taktlinie, Unter-/Nahe-/Über-Takt-Färbung + textuelle Engpass-
  Kennzeichnung (A11y), bidirektionale Klick-Hervorhebung inkl. Canvas-Zentrierung
  (`panToNode`/`selectAndRevealNode`). Zeigt zusätzlich kumulierten First Pass Yield und die
  theoretische Tageskapazität des Engpasses (§15) — sonst nirgends im Editor sichtbar.
- **Hilfetexte (§12):** Kais' drei Beispieltexte (Taktzeit/Zykluszeit/Durchlaufzeit) als lokale
  `FieldHintContent`-Objekte über die bestehende `FieldHint`-Komponente, keine neue
  `lsc_tooltips`-DB-Zeile/Migration.
- **Nacharbeits-Rückfluss (§7):** EOL → Nacharbeit → derselbe Prüf-/Freigabepuffer, den die
  i.O.-Teile auch erreichen — bewusst kein Graph-Zyklus (verifiziert azyklisch), damit
  `runValidation` unbedingt sauber bleibt. Der Nacharbeits-Node ist `type: 'timevalue'` (nicht
  `'process'`) — behebt eine reale Engpass-Fehlklassifizierung (`isStationNode` zählt sonst jeden
  `process`/`machine`-Node als 100%-Kapazitätsstation).
- **PPS/Produktionssteuerung (§5):** plain `process`-Node, `isValueAdded:false`, ohne Zykluszeit,
  über `kind:'information'`-Kanten mit BMW/AluForm/Tier-1-Prozessen verbunden — kein neuer
  `NodeType` (Präzedenzfall: `top-management-demo-vsm.ts`).
- **Tests:** `lib/vsm-engine/__tests__/timeline-sequence.test.ts` (9), `__tests__/vsm-ct-takt-
  chart.test.tsx` (8), `__tests__/vsm-timeline-zigzag.test.tsx` (5),
  `lib/demo/seeds/__tests__/wertstrom-seed-reference-ecu.test.ts` (29, verifiziert Takt/
  Reichweiten/Engpass/FPY/Zyklenfreiheit direkt gegen die Seed-Daten).
- **Bekannte, dokumentierte Einschränkungen:** `computeTimelineLadder`/`computeTimelineSequence`
  gewichten die 180s Nacharbeitszeit linear mit vollem statt wahrscheinlichkeitsgewichtetem
  Anteil (vorbestehende Engine-Eigenschaft, Gap G6). `vsm-auto-layout.ts`s `topoOrder` hat kein
  Zyklen-Ausschluss (anders als die neue `computeTimelineSequence`) — für diesen Wertstrom
  irrelevant, da vollständig hand-platziert, aber ein Klick auf "Automatisch anordnen" würde bei
  einem echten Graph-Zyklus in einem ANDEREN Wertstrom die gleiche Lücke treffen.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom P8.1 Kaizen-Marker + Maßnahmen — Fix-Runde (PR #363)

14 CONFIRMED Findings aus dem adversariellen Review von PR #363 behoben, dedupliziert auf 6
Defekt-Kerne (starke Überlappung — z. B. tauchte der Orphan-Kern unabhängig in allen 5 geprüften
Dimensionen auf). `wertstromUxV2` unverändert AN im default-Profil, keine Profil-/Flag-Änderung
(Profile-Diff 0).

- **K1 (major, 5-facher Cluster) — Orphan-Maßnahme, Risikoeinordnung war falsch
  (`vsm-measure-dialog.tsx`, `vsm-editor.tsx`):** die ursprüngliche Einordnung "gleiche
  Risiko-Klasse wie jede andere unsaved Panel-Änderung, kein neues Verhalten" (TODO.md/
  CHANGELOG.md) stimmte nicht — anders als ein reines Node-Feld hinterlässt eine verlorene
  measureRefs-Verknüpfung ein bereits committetes Fremdobjekt (`workshop_actions`-Zeile) UND ein
  echtes Duplikat-Risiko bei erneutem "Maßnahme erstellen" (§15.5-Doktrin-Verstoß). Gefixt,
  dreiteilig: (1) `onCreated` löst nach erfolgreichem Insert SOFORT einen gezielten PUT nur für
  `nodes` aus (`linkMeasureToNode`, kein `connections`/`layout` im Payload — Schema erlaubt das
  unabhängig-optionale Feld), statt auf Speichern/30s-Autosave zu warten; der Dialog bleibt bis
  zum PUT-Ergebnis im Ladezustand. Bei 409/sonstigem Fehler bleibt der Dialog offen mit einer
  spezifischen Meldung (nennt Titel + Maßnahmen-Nummer, sagt explizit "nicht erneut über diesen
  Dialog anlegen") statt zu schließen — das Formular wird durch die Meldung + "Schließen"
  ersetzt, ein Duplikat-Insert per Retry ist damit strukturell ausgeschlossen. (2)
  `beforeunload`-Guard bei `isDirty` ergänzt (repo-weit geprüft: 0 bestehende Treffer, neu
  eingeführtes Muster, Browser-Standard-Warnung). (3) TODO.md/CHANGELOG.md-Risikotext korrigiert
  (dieser Eintrag); "bestehende Maßnahme verknüpfen"-UI als P8.2-Kandidat in TODO.md notiert,
  NICHT gebaut (Fix-Scope war "Fenster verengen", nicht "jeden Restfall reparierbar machen").
- **K2 (critical/major, 4-facher Cluster) — RLS-Sichtbarkeits-Ehrlichkeit: null ≠ [] für
  Nicht-Owner (`app/wertstrom/[id]/page.tsx`, `vsm-measures.ts`, `vsm-editor.tsx`,
  `vsm-management-analysis-view.tsx`, `lib/vsm-engine`):** `workshop_actions` hat KEINE
  demo-shared-read-RLS-Policy (anders als `value_stream_maps`/`projects`) — ein Nicht-Owner, der
  legitim eine geteilte/Demo-Karte öffnet, bekam für die Maßnahmen-Query still `[]` statt eines
  Fehlers, ununterscheidbar von "Projekt hat wirklich 0 Maßnahmen". Node-Panel zeigte dadurch
  echte Maßnahmen fälschlich als "nicht mehr vorhanden", Management-Analyse fälschlich "Keine
  offenen Maßnahmen hinterlegt." (Konfidenz "hoch") — direkter Verstoß gegen die im PR selbst
  zitierte null≠0-Doktrin. Gefixt, dreiwertig: (a) echter Query-Error ⇒ "Maßnahmen konnten nicht
  geladen werden"; (b) measureRefs nicht-leer UND Query liefert 0 Zeilen ohne Error ⇒ "nicht
  einsehbar (kein Projekt-Zugriff)", NIE "tote Referenz" (kein Entfernen-Button — ein
  unbestätigter Ref darf nicht wie ein bestätigt toter behandelt werden); (c) echte tote Refs nur
  noch, wenn ANDERE Refs derselben Query auflösbar waren (RLS ist projekt-weit alles-oder-nichts
  — partielle Auflösung beweist Sichtbarkeit). Management-Analyse rendert im Fall (b) "nicht
  bewertbar, weil … nicht einsehbar" statt einer fabrizierten "0". "Maßnahme erstellen" ist für
  einen Nicht-Owner jetzt serverseitig vorab erkannt (page.tsx vergleicht `project.user_id` gegen
  die Session — exakt die Bedingung, die `workshop_actions_own`s RLS-Policy selbst prüft) und
  ausgegraut mit ehrlichem Hinweis, statt den Insert scheitern zu lassen.
- **K3 (major, 2-facher Cluster) — amber-600 statt amber-700 (`vsm-editor.tsx` ×2,
  `vsm-table-view.tsx`):** alle drei Kaizen-⚡-Indikatoren (Canvas-Badge, Node-Panel-Label,
  Tabellen-Spalte) zitierten im Code-Kommentar korrekt das AA-sichere "amber-700/amber-400"-Muster
  aus PR #362, verwendeten im Light-Theme aber versehentlich das hellere `amber-600`
  (≈3,19:1, WCAG-AA-4,5:1-Fail) statt `amber-700` (≈5,05:1, besteht) — Implementierungsfehler,
  Kommentar und Code widersprachen sich. Alle drei Stellen auf `text-amber-700` korrigiert.
- **C3 (major) — Closure-Race bei gelöschtem Ziel-Node (`vsm-editor.tsx`):** der
  measureRefs-Patch wurde aus einem `nodes.find(...)`-Snapshot des Render-Closures berechnet,
  nicht innerhalb eines `setNodes`-Updaters — löschte der globale (nicht dialog-bewusste)
  Delete/Backspace-Handler den Ziel-Node, während der Maßnahme-Dialog noch offen war (per
  Tastatur-Fokus auf Submit/Close-Button erreichbar, kein exotischer Timing-Race), verpuffte die
  Verknüpfung als stiller No-Op ohne jede Meldung. Gefixt als Teil von K1s `linkMeasureToNode`:
  Node-Existenz wird gegen einen laufend synchron gehaltenen `nodesRef` geprüft (gleiches
  "current-value-at-fire-time"-Muster wie `updatedAtRef`/`panRef`/`zoomRef` in dieser Datei) —
  fehlt der Node, liefert die Funktion `'node-gone'` und der Dialog zeigt eine explizite Meldung
  ("Element wurde inzwischen gelöscht", nennt die Maßnahme) statt zu verpuffen. Der globale
  Keydown-Handler selbst wurde NICHT dialog-bewusst gemacht (Fix-Scope: den Patch hart machen,
  nicht die Interaktion verhindern).
- **C9 (major) — action_number-Formel wich von workshop-client.tsx ab
  (`vsm-measure-dialog.tsx`):** `max(action_number)+1` statt der Bestandskonvention
  `actions.length+1` aus `workshop-client.tsx`s `addAction()` — bei einer Lücke in der Sequenz
  (z. B. nach einer Löschung) ergaben beide Formeln unterschiedliche Nummern für dieselben
  `existingMeasures`, ein Kollisionsrisiko bei paralleler Nutzung von Wertstrom-Editor und
  Workshop-Modul (keine DB-UNIQUE-Constraint fängt das ab). Auf `existingMeasures.length + 1`
  korrigiert — jetzt exakt dieselbe Formel wie workshop-client.tsx, für dieselbe Eingabe
  garantiert dieselbe Zahl.
- **C14 (minor) — "byte-gleich"-Test bewies keine Byte-Gleichheit
  (`vsm-editor.test.tsx`):** der Flag-AUS-Test prüfte nur vier gezielte
  Einzelelement-Abwesenheiten, nie einen tatsächlichen Struktur-/HTML-Vergleich — eine unbedachte
  Änderung an anderer Stelle im Flag-AUS-Baum wäre unentdeckt geblieben. Bestehender Test
  unverändert belassen (weiterhin nützlich für gezielte Fehlermeldungen), zusätzlich gehärtet:
  neuer Test vergleicht `container.innerHTML` desselben Node-Fixtures mit und ohne `kaizenNote`
  (beide Flag AUS) auf tatsächliche Identität.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom P8.1 Kaizen-Marker + Maßnahmen-Verknüpfung

Baustein 1–5 des P8.1-Auftrags (execution-prompt §15.5 "Measure Management", Capability-Matrix
A18), vollständig hinter dem bestehenden `wertstromUxV2`-Flag (unverändert AN im default-Profil)
— keine Profil-Änderung, kein neuer Permission-Code. **Architektur-Entscheidung: KEIN DDL** —
`workshop_actions` bleibt unangetastet, die Verknüpfung lebt additiv auf der VSM-Seite (`nodes`-
JSONB).

- **Datenmodell (additiv, nullish, P2-Konvention):** `VsmNode` bekommt `kaizenNote?: string`
  (Kaizen-Marker mit Kurztext — Präsenz des Felds, auch `''`, IST der Marker) und
  `measureRefs?: string[]` (`workshop_actions.id`-Referenzen) — `lib/vsm-types.ts` +
  `lib/api/schemas.ts` `VsmNodeSchema`.
- **Kaizen-Marker (Baustein 1):** Panel-Aktion "Kaizen-Chance markieren"/"Entfernen" (Node-Panel,
  P4-Muster, jeder Node-Typ — nicht nur Prozess), dezenter Blitz-Badge auf dem Canvas-Node +
  eigene Kaizen-Spalte in der Tabellen-View (Icon, Text on hover), amber-700/amber-400 AA-Muster
  aus PR #362 (statt des kontrastschwachen `text-warning`), Dark-Variante, title/aria-Label.
- **"Maßnahme erstellen" (Baustein 2):** neuer `vsm-measure-dialog.tsx` — EIN Dialog (Radix
  `Dialog`, kontrolliert von `vsm-editor.tsx`), aufrufbar sowohl vom Node-Panel (sobald eine
  Kaizen-Chance markiert ist) als auch vom Engpass-Absatz der Management-Analyse. Titel/Herkunft
  aus Kontext vorbefüllt ("Engpass X entlasten" bzw. die Kaizen-Notiz); Owner/Fälligkeit/
  Priorisierung über die BESTEHENDEN workshop_actions-Felder (`responsible`/`target_date`/
  `effort`+`benefit` — kein neues "Priorität"-Feld, workshop_actions hat keins). Anlage über den
  bestehenden Anlage-Pfad: client-seitiger Supabase-Insert wie
  `components/workshop/workshop-client.tsx` (RLS-enforced, kein Server-Action/keine Route) —
  Repo-Realität-Abweichung vom Brief deklariert (der Brief vermutete einen Server-Action/Route-
  Pfad). `description` trägt Titel + Herkunft-Text (kein eigenes Titel-Feld möglich ohne DDL).
  Nach Erfolg wird `measureRefs` am Node über den normalen `updateNode`-Pfad ergänzt (isDirty →
  bestehender Speichern-Button/30s-Autosave, CAS-PUT via `handleSave`) — kein separater Sofort-
  Save (Judgment-Call, siehe PR-Beschreibung). Nur bei projekt-verknüpfter Karte — sonst
  ausgegrauter Hinweis "Karte zuerst einem Projekt zuordnen" statt eines Fehlers erst beim
  Absenden.
- **Anzeige (Baustein 3):** Node-Panel listet verknüpfte Maßnahmen (Titel aus `description`,
  Status, Link ins Workshop-Modul); tote Refs (gelöschte Maßnahme) werden ehrlich als "nicht mehr
  vorhanden" mit Entfernen-Option gezeigt, nie still gefiltert.
- **P7-Anschluss (Baustein 4):** `app/wertstrom/[id]/page.tsx` lädt `workshop_actions` der
  verknüpften Projekt-Karte (ein Query, projekt-gebunden, RLS) und übergibt sie an
  `VsmManagementAnalysisView` — `computeManagementAnalysis`s `openMeasures`-Parameter (P7, bis
  heute nie befüllt) wird erstmals real bestückt: "offene Maßnahmen" zeigt Zahl + Titel-Beispiele
  statt "nicht bewertbar". Bleibt "nicht bewertbar" ohne verknüpftes Projekt, wird "Keine offenen
  Maßnahmen hinterlegt." bei einem verknüpften Projekt ohne offene Maßnahmen (`null`≠`[]`-
  Doktrin, wie überall in diesem Modul).
- **Szenario-Verhalten (Baustein 5):** `POST /api/wertstrom/[id]/duplicate` kopiert `kaizenNote`
  unverändert (Inhaltsfeld wie `notes`), entfernt aber `measureRefs` beim Kopieren jedes Node —
  eine Maßnahme gehört zum Ist-Zustand; eine kopierte Referenz würde in der Management-Analyse
  als offene Maßnahme gegen zwei verschiedene Wertstrom-Karten doppelt zählen.
- **Dokumentierte Lücke (§15.5-Felder-Delta, TODO.md "P8-DoD"):** §15.5 nennt für eine Maßnahme
  auch root cause/target state/expected effect/actual effect/linked KPI — `workshop_actions` hat
  dafür keine Spalten und bekommt bewusst KEINE (kein DDL in diesem PR).
- **PRODUCT_SPEC.md** um den Wertstrom-P8.1-Abschnitt ergänzt.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom Excel-Roundtrip-Import — Fix-Runde (PR #362)

16 Findings aus dem adversariellen Review von PR #362 behoben (15 bestätigt + Verifier-Repros,
1 finder-reproduziertes Finding dessen Verify-Agent technisch abbrach; 1 weiteres Finding
REFUTED — siehe TODO.md "Wertstrom P8" —, bewusst nicht gefixt). `wertstromUxV2` unverändert AN
im default-Profil, keine Profil-/Flag-Änderung (Profile-Diff 0).

- **CRITICAL — Prozent-numFmt 100×-Fehler (`vsm-import-xlsx.ts` `readNumericCell`):** eine
  Prozent-formatierte OEE/Ausschuss-Zelle (Excel-Autoformat, z. B. getipptes "85%" ⇒ intern
  0.85 mit `numFmt: '0%'`) wurde unverändert als `oee: 0.85` übernommen — ein stiller ~100×-
  Kapazitätsrechnungsfehler (`lib/vsm-engine/internal/capacity.ts` behandelt oee als 0–100-
  Skala), ohne jede Warnung. Jetzt: `%`-numFmt ⇒ ×100 normalisiert + Info-Hinweis; ohne
  %-numFmt UND Wert in (0, 1) ⇒ Plausibilitäts-Hinweis NUR bei OEE (Ausschuss bekommt bewusst
  keine <1-Heuristik — 0,5 % Ausschuss ist real), Wert wird unverändert übernommen.
- **CRITICAL — "Andere Datei wählen" graftet auf alte Karte (`vsm-import-xlsx-dialog.tsx`):**
  nach POST-erfolgreich/PUT-fehlgeschlagen blieb `created` (die ID der alten Karte) über einen
  Dateiwechsel hinweg gesetzt — Bestätigen für die NEUE Datei schrieb deren Stationen per PUT
  auf die ALTE Karte, statt eine neue anzulegen. `handleChooseDifferentFile` setzt `created`
  jetzt zurück; der bestehende Retry-ohne-Neu-POST-Pfad (gleiche Datei, F2) bleibt unverändert.
- **MAJOR — Platzhalter-Phantom-Station (`vsm-export-xlsx.ts`):** Export einer Karte ohne
  zeittragende Stationen schrieb den Fließtext "Keine Prozessschritte erfasst." als Datenzeile
  in die Name-Spalte — Reimport erzeugte daraus einen echten Phantom-Prozess-Node. Jetzt: `rows:
  []` (nur Kopfzeile) statt Platzhalterzeile.
- **MAJOR — "Bestand davor" = 0 verlor den Inventory-Node (`buildImportedVsmGraph`):** Gate war
  `stockBefore !== undefined && stockBefore > 0`, verletzte damit die eigene leer≠0-Doktrin.
  Jetzt `!== undefined` — ein gemessener 0-Bestand bleibt ein echter Inventory-Node.
- **MAJOR — machine→process-Degradierung (`vsm-import-xlsx.ts`/`vsm-export-xlsx.ts`):**
  Maschinen-Stationen wurden beim Reimport unbedingt zu `type: 'process'`, da keine Spalte den
  Knotentyp trug. Additive Spalte "Typ" (Prozess/Maschine, Default Prozess, case-insensitiv
  gelesen) auf dem Stationen-Blatt — Export schreibt sie, Import respektiert sie.
- **MAJOR — Bereichsprüfung + PUT-Reject-UX (`vsm-import-xlsx.ts`/`vsm-import-xlsx-dialog.tsx`):**
  Werte außerhalb der `VsmNodeSchema`-Grenzen (OEE/Ausschuss 0–100, Zykluszeit/Mitarbeiter/
  Bestand ≥0) passierten die Vorschau unbemerkt; PUT verwarf serverseitig ATOMAR alle Stationen
  mit einer nichtssagenden Meldung. Jetzt: Bereichsprüfung schon beim Parsen (Grenzen aus
  `VsmNodeSchema` übernommen, nicht geraten) ⇒ `VsmImportWarning.severity: 'error'`; die
  Vorschau zeigt den Wert weiterhin (nicht verworfen) UND blockiert "Bestätigen" mit einer
  klaren Zusammenfassung, statt den Server-Bulk-Reject als einzige Bremse zu haben.
- **MAJOR — Fokus-Rückgabe fehlte (`vsm-import-xlsx-dialog.tsx`):** nur die "Fokus stiehlt beim
  Öffnen"+Tab-Trap-Hälfte des P7-C9-Musters war repliziert, nie die Rückgabe an das auslösende
  Element beim Schließen (WAI-ARIA Dialog/Modal Pattern, bereits in `vsm-presentation-mode.tsx`
  etabliert). Ergänzt — als eigener, VOR dem "Focus-follows-step"-Effekt deklarierter Mount-
  Effekt (Reihenfolge ist bindend: sonst erfasst er das bereits fokus-gestohlene Heading statt
  des echten Auslösers).
- **MAJOR — `text-warning` AA-Kontrast (`vsm-import-xlsx-dialog.tsx`):** `#D48830` auf `#FFFFFF`
  (~2,85:1) unterschreitet WCAG AA (4,5:1) im Light-Theme; kein `--warning-text`-Äquivalent zu
  `--success-text` existiert. Ersetzt durch das bereits AA-sichere, repo-weit etablierte
  `text-amber-700 dark:text-amber-400`-Muster (KEINE neue Farbe erfunden) — nur an den 2
  Fundstellen dieses Dialogs; ~14 weitere `text-warning`-Stellen im Repo bewusst unangetastet
  (siehe TODO.md).
- **MAJOR — Meta-Blatt verschluckte unbekannte Labels (`parseWertstromMetaSheet`):** ein nicht
  erkanntes Label in Spalte A des "Wertstrom"-Blatts wurde stillschweigend übersprungen —
  inkonsistent zu jeder anderen Nichterkennungs-Klasse in derselben Datei. Jetzt: ein Hinweis
  je nicht erkanntem Label mit den erwarteten Bezeichnungen; die Blatt-Überschrift selbst (Zeile
  1 jedes echten Exports) ist explizit von dieser Prüfung ausgenommen.
- **MINOR-Batch:** Blatt-Erkennung case-/whitespace-tolerant (`findWorksheetCaseInsensitive`,
  analog zur bestehenden Spalten-/Label-Erkennung) · Datums-/Zeit-Zellen in Zahlen-Spalten
  erzeugen jetzt eine lesbare Warnung statt der rohen JS-`Date.toString()`-Repräsentation ·
  "(unbenannt)"-Anzeige-Platzhalter wird beim Import wie ein leerer Name behandelt (mit
  Hinweis), nicht als echter, dauerhaft persistierter Name · Vorschau-Tabelle formatiert
  Dezimalzahlen jetzt mit `Intl.NumberFormat('de-DE', ...)` statt rohem `String()` · mehrere
  Inventory-Vorgänger an einer Merge-Station werden beim Export jetzt SUMMIERT (mit sichtbarer
  Status-Spalten-Notiz "Bestand: mehrere Puffer summiert") statt nur den ersten zu behalten,
  kein stiller Datenverlust mehr · der Roundtrip-Pflichttest ist jetzt zusätzlich gegen LITERALE
  (nicht aus dem Produktivcode re-importierte) Header-/Blattnamen-Strings geprüft, damit ein
  Format-Drift den Test bricht statt intern selbstkonsistent grün zu bleiben.
- **REFUTED (nicht gefixt, dokumentiert):** die Behauptung, die neue Blatt-Reihenfolge breche
  den alten `vsm-editor.tsx`-Excel-Import (`worksheets[0]`, positionsbasiert) — der alte Import
  konnte P7-Exportdateien strukturell schon vor diesem PR nicht lesen (Section-Banner-Zeile 1).
  Als vorbestehende Alt-Feature-Schwäche in TODO.md ("Wertstrom P8") festgehalten.
- **Tests:** targeted `vitest run` (`components/wertstrom` + `lib/vsm-engine` + `lib/copilot-
  export`) 996/996 grün; `tsc --noEmit` clean; `CHECK_FORBIDDEN_LEVEL=error npm run
  check:portability` clean (inkl. `check:hardcoded-colors` — `text-amber-*` ist kein
  Forbidden-Pattern); `eslint` auf allen geänderten Dateien clean; Vertraulichkeits-Grep
  laut Fix-Brief clean; Profile-Diff 0.
- **PRODUCT_SPEC.md** (Wertstromanalyse-Excel-Roundtrip-Abschnitt) und **TODO.md** aktualisiert.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom Excel-Roundtrip-Import (Kais-Auftrag TG9112/TG9113)

Hinweis zur Phasen-Nummerierung: bewusst OHNE "P8"-Label — der bestehende P7-Abschnitt in
PRODUCT_SPEC.md verwendet "P8/TODO" bereits informell für den DOCX-Export (Nicht-Scope-Punkt).
Dieser Auftrag bekommt daher einen eigenen, nummerierungsfreien Titel; die Phasen-Zählung für
ein etwaiges nächstes Wertstrom-Los bleibt eine offene Entscheidung (siehe Abschlussbericht).

Vorlage herunterladen -> ausfüllen -> hochladen -> Wertstrom wird erzeugt. Komplett
client-seitig (ExcelJS war seit P7 bereits im Editor-Client-Bundle — verifiziert, kein
Server-seitiger Umweg), Anlage über die BESTEHENDEN `POST /api/wertstrom` + `PUT
/api/wertstrom/[id]`-Routen (keine neue Route, kein neues Gate, kein Idempotenz-Apparat).
Vollständig hinter dem bestehenden `wertstromUxV2`-Flag (unverändert AN im
default-Profil) — eigener Button, unabhängig vom SimVSM-Flag (P6).

- **Vorlagen-Format = Export-Format (Baustein 1, `vsm-export-xlsx.ts`):** additiv ein
  neues "Wertstrom"-Blatt (Titel, Kundenbedarf, Arbeitszeitmodell — dieselben
  `ShiftModelInput`-Felder wie P4) VOR dem Stationen-Blatt. Das Stationen-Blatt bekommt
  additiv 3 neue Spalten (OEE %, Ausschuss %, Bestand davor Stk) — die 3 Felder aus der
  bindenden Kais-Zusage (TG9113), die das bisherige 5-Spalten-Blatt nicht hatte; die
  bestehenden 5 Spalten bleiben byte-identisch. **Repo-Realität-Abweichung (deklariert):**
  das bestehende Blatt hieß `'Prozessschritte'`, nicht `'Stationen'` wie die Kais-Zusage
  und dieser Auftrag durchgehend annehmen — umbenannt auf `'Stationen'` (reine
  Tab-Caption, Spalten/Reihenfolge/Semantik unverändert), damit die ausgelieferte Datei
  zur Kundenzusage passt und Baustein 3 einen sinnvollen Blattnamen zum Nachschlagen hat.
- **Vorlagen-Download (Baustein 2, `buildVsmImportTemplateConfig`):** leere Vorlage mit
  EINER klar markierten, exakt-erkannten Beispielzeile + eigenem "Hinweise"-Blatt
  (Pflichtfeld nur Name, Einheiten, "leer lassen = nicht erfasst — NICHT 0 eintragen").
  Sichtbar als Aktion sowohl im neuen Import-Dialog als auch (neu) im bestehenden
  Export-Dialog.
- **Parser (Baustein 3, `vsm-import-xlsx.ts`):** liest beide Blätter, Zahl-Zellen nativ,
  Text-Zellen über die P4-Kanonisierung — `numericCellOrUndefined` (vsm-excel.ts) und
  `parseGermanTolerantCell` (vsm-table-paste.ts, jetzt exportiert) WIEDERVERWENDET, nicht
  neu erfunden. Leer bleibt unset (nie 0), eine echte 0 bleibt 0. Unbekannte Spalten
  werden ignoriert mit EINEM Hinweis pro Spalte; fehlendes Wertstrom-Blatt ⇒ nur
  Stationen (Titel aus Dateiname-Vorschlag); fehlendes/unlesbares Stationen-Blatt ⇒ 0
  Stationen mit Hinweis statt Absturz. Ergebnis `{ meta, stations[], warnings[] }` mit
  Zellbezügen ("Stationen!C4: …").
- **Wertstrom-Erzeugung (Baustein 4, `buildImportedVsmGraph`):** lineare Kette
  Lieferant→Prozesse→Kunde (spiegelt `buildWizardGraph` aus P3 exakt — inkl.
  unconditional Lieferant/Kunde-Boundary-Nodes und `isValueAdded: true` auf jedem
  Prozess, damit KEINE zweite Graph-Konvention neben der des Wizards entsteht),
  `buildLinearConnectionPairs`+`computeAutoLayout` wiederverwendet. "Bestand davor" > 0
  ⇒ Inventory-Node vor dem Prozess. `provenance: 'imported'` nur, wenn der Node
  mindestens einen echten importierten Skalarwert trägt. Kundenbedarf landet als
  `demand` auf dem Kunde-Node — exakt die Wizard-Konvention (kein zweites Feld).
- **UI-Flow (Baustein 5, `vsm-import-xlsx-dialog.tsx`):** Datei wählen (client-seitige
  .xlsx-/2 MB-Prüfung) → Vorschau (Titel editierbar, Projekt optional, Stations-Tabelle,
  Hinweise mit Zellbezug, "X Stationen, Y Bestände werden angelegt") → Bestätigen
  (POST+PUT, spiegelt die F1-F3-Fehlerhärtung des P3-Wizards byte-für-byte: kein
  Doppel-POST bei Retry, Netzfehler hält den Dialog bedienbar, Recovery-Link nach
  erfolgreichem POST + danach gescheitertem PUT) → Erfolg-Schritt mit Link zur neuen Karte.
  A11y: echte Headings, Escape/Fokus-Trap wie der Wizard, Warnungen als `role="alert"`,
  `text-warning` (nicht das nicht-existente `text-warning-text`).
- **Roundtrip-Pflichttest:** Karte mit bekannten Nodes → echter `exportToXlsx`-Durchlauf
  (ExcelJS, kein Mock) → echter Parser-Durchlauf ⇒ äquivalente Stationen/Meta
  (Provenance-Spalte toleriert) — `components/wertstrom/__tests__/vsm-import-xlsx.test.ts`.
- **PRODUCT_SPEC.md** um den Wertstrom-P8-Abschnitt ergänzt.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom P7 Management-Analyse, Präsentationsmodus, Export-Familie

Baustein 1–3 des P7-Auftrags (execution-prompt §11.6/§18/§19), vollständig hinter dem
bestehenden `wertstromUxV2`-Flag (unverändert AN im default-Profil) bzw. dem bestehenden
`copilotExports`-Gate (unverändert `false` überall) — keine Profil-Änderung, kein neuer
Permission-Code, keine Migration.

- **Management-Analyse (A15, §18):** neues Engine-Modul
  `lib/vsm-engine/internal/management-analysis.ts` (`computeManagementAnalysis`) — rein
  regelbasierter, deterministischer Klartext-Generator (kein LLM) aus bereits vorhandenen P1-
  Engine-Ergebnissen (Engpass v2, Timeline-Leiter, Bestandsreichweite, kumulierte Ausbeute,
  Validierungs-Engine). Jeder Satz trägt `{ text, sourceMetric, values, confidence }` — fehlende
  Datenbasis erzeugt einen expliziten "nicht bewertbar, weil…"-Satz statt einer erfundenen Zahl.
  Neue UI-Sektion `vsm-management-analysis-view.tsx` (P4-Panel-Muster, Klartext zuerst).
- **Präsentationsmodus (A17, §11.6):** neue Vollbild-Komponente `vsm-presentation-mode.tsx`
  (Fullscreen-API + CSS-Fallback, KPI-Header aus Engpass v2/Timeline-Leiter, Ist/Soll-Deltas bei
  aktivem Szenarien-Vergleich, ESC beendet, Fit-View aus P3 wiederverwendet). Rendert die Karte
  als `<img src="data:image/svg+xml,...">`, nicht `dangerouslySetInnerHTML`.
- **Export-Familie (A17, §19):** eine primäre Aktion "Wertstrom exportieren"
  (`vsm-export-dialog.tsx`, shadcn/Radix `Dialog`) mit 5 Formaten — Management-Report (PDF, über
  die bestehende jsPDF-`ExportConfig`-Infrastruktur), Visuelle Karte (SVG deterministisch
  gerendert + PNG client-seitig via Canvas-API), Daten-Tabelle (XLSX, P4-Tabellen-Spalten +
  Provenance-Spalte), Copilot-Report (bestehendes Gate/bestehende Actions, erweitert um den
  §19.3-Instruction-Block wörtlich + Business-Interpretation + Provenance-Übersicht), Technisches
  JSON (mit Schema-Version). `is_demo`-Karten sind exportierbar wie jede andere.
- **lib/copilot-export erweitert (additiv):** `wertstrom-docx.ts`/`wertstrom-md.ts` bekommen 3
  neue Abschnitte (Copilot-Anweisung §19.3 wörtlich EN + DE-Übersetzung, Management-Analyse,
  Provenance-Übersicht) — jedes bestehende Feld/jeder bestehende Abschnitt bleibt unverändert,
  alle bisherigen Tests bleiben grün.
- **PRODUCT_SPEC.md** um den Wertstrom-P7-Abschnitt ergänzt (deklarierte Lücken: "offene
  Maßnahmen" ohne Datenquelle, kein Produkt-/Scope-Feld im Datenmodell, kein DOCX-Export, keine
  inline eingebettete Karte im PDF).

## 2026-07-22 – KAR-878/KAR-986: Wertstrom P6 SimVSM-Import — Adversarial-Review-Fixes (PR #360)

18 bestätigte Findings aus dem adversariellen Review von PR #360 behoben (Flag
`wertstromSimvsmImport` bleibt `false` in allen 3 Profilen — kein Prod-Impact).

- **Kritisch — Bedarfsrate/Intervall-Normalisierung:** `demand` (Stk/Tag) wurde
  1:1 aus SimVSMs Bestellmenge übernommen, ohne das begleitende `Interval`
  (Sekunden) zu berücksichtigen — bei Nicht-Tages-Intervallen um den Faktor
  86400/Interval falsch. Jetzt korrekt skaliert; fehlt/ist Interval ungültig,
  bleibt `demand` unset (nie erfunden) + Report-Assumption. `quantity` wird
  beim Kunden-Knoten nicht mehr aus der Bestellzeile befüllt (Bestellmenge ≠
  Bestand). Gegen den vollständigen Korpus verifiziert: 70 reale Kunden-
  Bestellzeilen, 100 % mit gültigem Intervall, nur 25,7 % davon exakt 1 Tag —
  die übrigen ~74 % waren vor dem Fix um einen materiellen Faktor falsch.
  `cycleTimeSec`/`scrapRate`-Extraktion zusätzlich auf Prozess-Knoten gegated
  (vorher ungegatet, ein Kunden-Knoten konnte fälschlich eine "Zykluszeit"
  erhalten).
- **Node-ID-Korrelation über Szenarien:** derselbe SimVSM-`key` bekommt jetzt
  dieselbe `VsmNode`-ID über alle Alternativen EINER Datei (vorher pro Stream
  unkorreliert) — macht P5s Szenario-Vergleich (`computeNodeParameterDeviations`)
  für importierte Familien erst benutzbar (vorher strukturell immer "Neu"/
  "Entfernt" statt "geändert").
- **current-Wahl-Signalkaskade:** `resolveCurrentStreamIndex` nutzt jetzt vier
  Signale in Reihenfolge (isMain → unsuffixierter Name gegen `_N`-Geschwister
  → frühestes `modificationTime` → Array-Index) statt reinem Array-Index-
  Fallback — an mind. 7 realen Korpus-Dateipaaren verifiziert. Preview zeigt
  `hasResultData`/`modificationTime` pro Stream als Kontext.
- **projectId-Ownership (IDOR-Fix):** `confirm`-Route UND die bestehende
  `POST /api/wertstrom` prüfen jetzt per RLS-gescoptem Lookup, dass ein
  angegebenes Projekt dem Aufrufer gehört (vorher konnte jeder Nutzer mit
  `vsm.write` bei bekannter fremder Projekt-UUID hineinschreiben).
- **Upload-Größen-/Typ-Prüfung konsolidiert** (`lib/simvsm-import/internal/
  upload-limits.ts`, geteilt zwischen preview- und confirm-Route): Endungs-
  Check vor Größen-Check (falsche Endung wurde vorher fälschlich als "zu
  groß" gemeldet), eigenes SVG-Limit (SVG hatte vorher gar keinen
  Größen-Check), explizites Gesamt-Limit unter Vercels Body-Cap. Client
  prüft jetzt vor dem Upload (gleiche Meldungen), plus eine erkennbare
  Meldung bei einer 413-Plattform-Ablehnung.
- **§14.4-Ergebnisfelder ergänzt:** importierte Wertströme (Anzahl+Titel) und
  Szenarien-Aufschlüsselung (Ist-Zustand/Alternativen) erscheinen jetzt im
  Confirm-Ergebnis (vorher von der Route geliefert, vom Dialog nie gelesen).
- **A11y:** asynchrone Fehlermeldungen tragen jetzt `role="alert"`;
  `aria-level` der Fake-Headings korrigiert (Details/Erkannte Elemente sind
  Geschwister auf Level 3, nicht fälschlich verschachtelt auf 4/5).
- **Diverse Politur:** Konfidenz-Hinweis in der Preview ("Zuordnung: hoch/
  mittel"), Parser-/Mapping-Version fließt in die Signatur (erkennt einen
  Deploy zwischen Preview und Confirm), `text-warning-text` (nicht
  existierende Klasse) durch echtes `text-warning` ersetzt, unbekannte
  SimVSM-Klassen zeigen ein deutsches Generikum statt des rohen Klassennamens,
  "fehlende Beziehung" vs. "nicht unterstützte Verbindungsart" werden jetzt
  getrennt gezählt/erklärt.
- **Deklarierte Abweichung:** PPS-Knoten (`productionControl`) senken
  strukturell die Engpass-Konfidenz im P5-Vergleich (nie berechenbare
  Zykluszeit) — kein Fix ohne neues `VsmNode`-Feld (Schema-Änderung) oder
  eine SimVSM-spezifische Engine-Kopplung möglich; stattdessen als Report-
  Assumption + TODO.md-Eintrag dokumentiert.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom-Modul P6 — SimVSM-Import-Adapter (A16)

Phase P6 des Wertstrom-Improvement-Programms (execution-prompt §14 "Import and
Compatibility Layer", Capability-Matrix A16) — ein optionaler Adapter, der SimVSM-
Tool-Exporte (`.json`/`.svg`) über einen zweistufigen Preview→Confirm-Flow in das
bestehende Wertstrom-Datenmodell übernimmt, komplett hinter dem NEUEN Feature-Flag
`wertstromSimvsmImport` (`false` in allen 3 Profilen — SupplierPulse hängt für den
Normalbetrieb nicht davon ab, §14-Präambel). Gegen den vollständigen (vertraulichen)
Korpus validiert: 25/25 Dateien geparst (0 Fehler), 73 Alternativen, 3.224 Knoten /
3.535 Kanten (deckungsgleich mit der Korpus-Analyse), 0 unbekannte SimVSM-Klassen,
0 Schema-Verstöße gegen `VsmNodeSchema`/`VsmConnectionSchema` bei 2.944 gemappten
Knoten (91,3 %) und 3.484 gemappten Kanten (98,6 %).

- **Parser + Mapping-Registry (`lib/simvsm-import/`, neues ADR-019-Modul):** liest
  SimVSM-JSON strukturell (nie werfend), erkennt Modelle/Alternativen/Parameter
  (SimVSM speichert Parameter als Array, nicht als flaches Objekt). Die
  Mapping-Registry (§14.3-Startliste, gegen den kompletten Korpus validiert, 23
  Knoten- + 3 Kantenklassen) übersetzt auf die BESTEHENDEN `VsmNode`/`VsmConnection`-
  Felder (`type`, `inventoryKind`, `ConnectionKind`, `availabilityPct`, `mttrMin`, …) —
  keine Migration, keine neuen Felder. `singleProcess→process`,
  `fifoStore/supermarketStore/inventoryPush→inventory` mit `inventoryKind`,
  `informationFlow→ConnectionKind 'information'`; `multiProcess/clockedProcess/
  joinProcess/leadProcess/productionControl` werden auf `process` verallgemeinert
  (Confidence < 1, im Report als "vereinfacht übernommen" ausgewiesen);
  `noteVSM`/`noteAnnotation`/Formen/Marker/Fotos bleiben unsupported (kein
  Notiz-/Kaizen-NodeType im Datenmodell — Editor-Ausbau ist Capability-Matrix
  P8-Scope), gezählt und mit Begründung im Report, nie stillschweigend verworfen.
- **Alternativen→Szenarien:** die erste Alternative wird `scenario_kind: 'current'`,
  jede weitere `'alternative'` mit `parent_value_stream_id` (P2-Datenmodell
  wiederverwendet, keine Migration). SimVSMs `isMain`-Flag ist im realen Korpus NIE
  gesetzt (0 von 73 Alternativen) — dokumentierte Spec-Abweichung, Fallback auf
  Array-Reihenfolge.
- **Zweistufiger Preview/Confirm-Flow:** `POST /api/wertstrom/import/preview`
  (multipart, parst, persistiert NICHTS, liefert Familien/Mapping-Zusammenfassung/
  Duplikat-Hinweis) und `POST /api/wertstrom/import/confirm` (parst die
  erneut hochgeladene Datei serverseitig neu — vertraut nie einem Client-Node-Array,
  gleiche Haltung wie `lib/qaf-value-stream`s Creation-Pfad — legt nur die bestätigte
  Auswahl an, idempotent über eine content-basierte Signatur ohne DDL/RPC).
  Positionen werden über die bestehende `computeAutoLayout` (P3) neu berechnet, nie
  aus SimVSM übernommen (§14.4 "visual layout difference", ehrlich ausgewiesen).
- **Provenance + Quell-Metadaten:** jeder importierte Knoten `provenance: 'imported'`
  (bestehendes P2-Feld); Quell-Metadaten (Datei-Signatur, SimVSM-Versionsfelder,
  Import-Zeit) in `layout.simvsmSource` (jsonb, additiv — gleiches Muster wie
  `layout.viewport`/`layout.shiftModel`, keine Migration). Kein Rundtrip-Anspruch
  (§14.5).
- **Import-Report (§14.4) + UI-Einstieg:** einfaches Ergebnis zuerst (übernommen/
  Szenarien/Warnungen), Details aufklappbar (`<details><summary role="heading">`,
  A11y-Muster aus P4/P5). "SimVSM importieren"-Button in der Wertstrom-Liste hinter
  dem Flag, keine technischen Mapping-Details ungefragt (§14.1).
- **Größenlimit dokumentiert:** die größte reale Korpus-Datei ist ~13,4 MiB (nicht
  "~6 MB" wie in der Ausgangs-Annahme — die Korpus-Analyse selbst nennt bereits 14 MB
  als größte Datei) — Vercels Node-Serverless-Body-Limit (4,5 MB) macht den direkten
  Route-Upload für diese eine Datei im aktuellen Deployment unmöglich; dokumentierte,
  bewusste P6-Scope-Grenze (TODO.md), kein stiller Fehlschlag.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom-Modul P5 — Szenarien-Navigation, KPI-Delta-Vergleich, Parameter-Abweichungen (A9-UI-Ausbau)

Phase P5 des Wertstrom-Improvement-Programms (execution-prompt §9.3 "Scenario
Comparison"/§9.4 "Parameter Deviation Visualization") — komplett hinter dem NEU
scharfgeschalteten Feature-Flag `wertstromScenarios` (P2/P3/P4 hatten das Flag/Gate schon
gebaut, `false` in allen 3 Profilen — dies ist die Phase, die den flag-gated Code
tatsächlich mit Inhalt füllt; das Flag selbst bleibt weiterhin `false`, Rollout ist eine
separate Betreiber-Entscheidung). Bestehendes Editor-/Listen-Verhalten läuft bei Flag AUS
byte-gleich weiter.

- **Szenarien-Navigation (Kern-Anforderung 1):** Zugehörigkeit + Wechsel Ist ↔ seine
  Szenarien, auf beiden bestehenden GET-Flächen — der Wertstrom-Liste
  (`vsm-list-client.tsx`: pro Ist-Karte eine aufklappbare "N Szenario(en)"-Liste, pro
  Szenario-Karte ein Link zurück zum Ist-Ursprung) und im Editor-Toolbar (neue
  `vsm-scenario-nav.tsx`: "Szenarien (n)"-Dropdown mit Öffnen-/Vergleichen-Link je
  Szenario auf einer Ist-Karte, "Abgeleitet von …" + Vergleichen-Button auf einer
  Szenario-Karte). Beides **ohne neue Query** — die Liste leitet Eltern-/Kind-Beziehungen
  client-seitig aus dem bereits vollständig geladenen `maps`-Array her (die Listen-Query
  hatte nie ein `.limit()` und `parent_value_stream_id` war schon im `select`).
- **KPI-Delta-Vergleich (§9.3, `vsm-scenario-compare-view.tsx`):** neue "Vergleich"-Ansicht
  im Editor (dritte Option neben Canvas/Tabelle, eigenständig hinter
  `wertstromScenariosEnabled` gated — nicht mehr an `wertstromUxV2Enabled` gekoppelt) zeigt
  die Kern-KPIs Takt-Auslastung Engpass, Durchlaufzeit (DLZ), wertschöpfende Zeit (VA),
  Process Cycle Efficiency (PCE), Bestandsreichweite gesamt und Engpass-Node — je Ist-Wert,
  Soll-Wert, Delta absolut, Delta % und Bewertung (Verbesserung/Verschlechterung/
  unverändert/nicht vergleichbar/informativ, NIE Farbe allein — immer Pfeil + Textlabel).
  Beide Seiten werden über dieselben `lib/vsm-engine`-Funktionen gerechnet
  (`computeBottleneckV2` — bislang 0 UI-Aufrufer — und `computeTimelineLadder`), jede
  Seite strikt mit ihrem EIGENEN Takt-/Arbeitszeit-Kontext (eigener `layout.shiftModel`,
  eigener verknüpfter Projekt-Kundentakt) — nie stillschweigend gemischt. Ein Banner
  weist eine Kontext-Abweichung zwischen Ist und Szenario explizit aus, statt sie zu
  verstecken. "Nicht berechenbar" bleibt "nicht berechenbar" (kein Fake-Delta bei
  fehlendem Kundentakt/fehlender VA-Zeit-Messung).
- **Parameter-Abweichungs-Ansicht (§9.4, in derselben Ansicht, eigenes
  `<details open>`-Panel):** je Prozess/Station die geänderten Parameter (Zykluszeit,
  Operatoren, OEE, Verfügbarkeit, Ausschussrate, Bestand, Lager-Art) Ist vs. Szenario —
  gematcht per Node-ID (P2-Entscheidung: IDs bleiben beim Duplizieren erhalten, exakt
  dafür gedacht). Unveränderte Felder werden nicht gelistet. Im Szenario
  hinzugefügte/gelöschte Nodes landen in eigenen "Neu"/"Entfernt"-Sektionen.
  Byte-identische Node-Sets (z. B. direkt nach "Soll-Zustand erstellen") zeigen "Keine
  Abweichungen gefunden" statt einer leeren Tabelle. Zwei Datenmodell-Lücken werden
  ehrlich ausgewiesen statt stillschweigend ausgelassen: "Nacharbeit" (Rework) hat kein
  eigenes `VsmNode`-Feld (nur Ausschuss/Scrap ist vergleichbar — bereits dokumentierte
  P1-Entscheidung in `lib/vsm-engine/internal/quality.ts`), und Grund/Autor/
  Änderungsdatum je Feld sind im Datenmodell nirgends erfasst. Kanten/Connections sind
  eine separate, bewusste Scope-Entscheidung (kein Datenmodell-Gap — das Feld existiert):
  §9.3/§9.4 vergleichen ausschließlich Node-Parameter, keine Topologie — die
  Vergleichsseite lädt `connections` serverseitig bewusst gar nicht mit.
- **Empty/Edge-States:** ein Szenario, dessen Ist-Ursprung gelöscht wurde
  (`parent_value_stream_id` `ON DELETE SET NULL`) oder dessen `?compare=`-Ziel nicht mehr
  auflöst, zeigt einen ehrlichen "Vergleich nicht möglich"-Hinweis statt eines toten
  Buttons oder eines stillen Nichts. Vergleich zweier Szenarien desselben Ist-Zustands ist
  strukturell ausgeschlossen (ein Szenario kann laut P2-Datenmodell nur vom Ist-Zustand
  abgeleitet sein, nie von einem anderen Szenario).
- **Scope-Entscheidung — keine neue Route:** der Vergleichs-Einstiegspunkt für ein
  Szenario lädt seinen (einzigen möglichen) Ist-Ursprung serverseitig eager mit (eine
  günstige Zeile); für eine Ist-Karte mit mehreren Szenarien navigiert "Vergleichen" zu
  `?compare=<scenarioId>` auf derselben `/wertstrom/[id]`-Route — dieselbe bestehende
  GET-Fläche (Server-Component-Fetch), kein neuer API-Endpunkt, keine neue
  Zod-Schema-Erweiterung (reiner Lesepfad).
- **Nicht-Scope (unverändert, hart):** Szenario-Merge/Übernehmen-nach-Ist, Reopen-Flows,
  Mehrfach-Vergleich (>2 Karten gleichzeitig), SimVSM-Import (P6), Exporte der Vergleiche
  (P7), Migration (Spalten existieren seit P2), Engine-Formeländerungen (diese Phase
  konsumiert ausschließlich bestehende `lib/vsm-engine`-Exporte).
- **iOS scope:** web-only (VSM-Editor ist web-only, unverändert).
- **Adversarial-Review-Fixes (PR #356, 8 Findings):** F1 (major) —
  `computeNodeParameterDeviations` verglich strikt (`===`), meldete null-vs-undefined
  als Phantom-Abweichung entgegen der eigenen `.nullish()`-Doktrin; jetzt
  nullish-normalisiert vor dem Vergleich (null≠0 bleibt unangetastet). F2 (major) —
  `describeContextMismatch` verglich `demandUnitsPerDay` nie, obwohl eine abweichende
  Bedarfsrate die Bestandsreichweite-KPI direkt verfälscht; `differentDemand` ergänzt,
  Banner nennt jetzt Takt/Arbeitszeit/Bedarf mit konkreten Werten beider Seiten. F3
  (major) — `inventoryKind` (Lager-Art, A8) fehlte undokumentiert in den vergleichbaren
  Feldern; als kategoriales Feld (`kind: 'enum'`) ergänzt, deutsche Labels wie im
  Editor-Dropdown, kein Δ. F4 (major) — "Vergleichen" von der bereits gemounteten
  Ist-Seite aus öffnete die Vergleichs-Ansicht nicht (Next.js behält die Komponente über
  eine reine `?compare=`-Navigation gemountet, der Mount-only-Initializer lief nie
  erneut); `useEffect` auf die abgeleitete Vergleichsziel-Identität ergänzt (nur
  Ist-Seite, nur bei aktivem Flag, kein Sprung bei jedem Re-Render). F5 (major) — der
  Regressionstest für "Flag-Bypass Server-Fetch" prüfte nur die Client-Anzeige, nie
  `page.tsx`s eigenes Fetch-Gate; dedizierter `page.test.ts` als primäre
  Verteidigungslinie ergänzt, bestehender Client-Test bleibt als zweite (Beschreibung
  korrigiert). F6 (minor) — Connections werden für die Vergleichsseite bewusst nicht
  geladen (§9.3/§9.4 sind Node-Parameter-Scope, keine Topologie); jetzt explizit
  dokumentiert statt stillschweigend ausgelassen. F7 (minor) — Verbesserung-Label nutzte
  kontrastschwaches `text-success` (≈2,75:1 auf Weiß); jetzt `text-success-text`
  (≈5,25:1, AA), analog `costDeltaClass`. F8 (minor) — das neue
  Parameter-Abweichungs-Panel wiederholte die P4-F13-Heading-Lücke (`<summary>` ohne
  Heading-Semantik); `role="heading" aria-level={3}` ergänzt, analog PanelSection.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom-Modul P4 — Panel-Redesign, Tabellen-View, OEE-Link, Standard-Seite (B1–B4)

Phase P4 des Wertstrom-Improvement-Programms (Capability-Matrix A13/A14/B1–B4) —
komplett hinter dem BESTEHENDEN Feature-Flag `wertstromUxV2` (P4 ist Fortsetzung des
P3-UX-Programms, kein neues Flag; weiterhin `false` in allen 3 Profilen). Bestehende
Editor-Funktionen laufen bei Flag AUS byte-gleich weiter — der alte, flache
Properties-Panel-Zweig ist unverändert im Code stehen geblieben, nur zusätzlich hinter
`wertstromUxV2Enabled ? … : …` verschachtelt.

- **A13 Panel-Redesign** (`vsm-editor.tsx`, kein neues Component-File — siehe
  Scope-Entscheidungen): Properties-Panel jetzt in drei Stufen (§7.1/§7.2/§10.2
  Progressive Disclosure) statt einer flachen Feldliste:
  - **Basis** (immer sichtbar): Name, Zykluszeit, Anzahl Mitarbeiter, plus NEU
    Verfügbarkeit (%) (B3) für Prozess-Nodes; Menge/Lager-Art für Bestand;
    Transportzeit + NEU Frequenz (B4) für Transport; Stk/Tag für Kunde/Lieferant;
    Zeitwert für Zeitwert-Nodes.
  - **Erweitert** (`<details>`, zu-/aufklappbar, geschlossen per Default): die
    bisherigen Zeit-/Kapazitäts-/Prozessart-Felder PLUS die **9 bisher UI-losen
    editierbaren Felder** aus `lib/vsm-types.ts` (bislang nur über QAF-Import
    befüllbar): Teile pro Zyklus, Standort, Währung, Ausschussrate,
    Ausschusskosten je Teil, Maschinenstundensatz, Lohnkostensatz (je Stunde),
    Rüstkosten je Einheit, Herstellkosten je Teil — deutsche Labels nach §3.5,
    keine internen Kürzel. Alle 9 waren bereits Teil von
    `VsmNodeEditableFieldKey` (P2), daher übernimmt `updateNode`s bestehende
    Provenance-/FieldStatus-Ableitung sie ohne Codeänderung — "importiert"/
    "geändert"-Tags wandern automatisch mit ans jeweilige Feld. Wertschöpfung
    (vaClass-Auswahl/Checkbox) zieht ebenfalls hierher (§7.2 listet
    "value-adding status" als Advanced).
  - **Experte** (eigener `<details>`, geschlossen per Default): MTBF (Min.)/MTTR
    (Min.) für die B3-OEE-Verknüpfung.
  - Alle drei Stufen nutzen native, unkontrollierte `<details>`/`<summary>`
    (kein React-`open`-Prop) — ein React-kontrolliertes `open` auf `<details>`
    schließt nach einem echten Browser-Toggle nicht mehr zuverlässig
    (beobachtete React/DOM-Einschränkung, kein jsdom-Artefakt; siehe
    `vsm-timeline-ladder.tsx`s bereits bestehendes unkontrolliertes
    "Details & Ausschlüsse" für dasselbe Muster).
- **A14 Tabellen-View + Excel-Paste** (neue Dateien `vsm-table-view.tsx` +
  `vsm-table-paste.ts`): Umschalter "Canvas ↔ Tabelle" im Toolbar (nur bei
  aktivem Flag). Tabelle zeigt eine Zeile je **Prozessschritt**
  (`type === 'process'` — deckungsgleich mit dem bestehenden
  Excel-Datei-Import-Scope; andere Node-Typen bleiben in P4 Canvas-only, siehe
  Scope-Entscheidungen) mit Inline-Edit von Name/Zykluszeit/Anzahl Mitarbeiter —
  ruft dieselbe `updateNode`-Closure wie das Properties-Panel auf (gleiche
  `nodes`/`connections`-State, keine zweite Wahrheit). Missing-Data-Indikator
  (gedämpfter Punkt + `sr-only`-Text) je Zelle für "keine Angabe", niemals für
  eine echte 0. Excel-Paste: Zellen aus Excel/Sheets kopieren (Tab-getrennt) →
  einfügen in ein Textfeld → Live-Vorschau (Kopfzeilen-Erkennung optional,
  sonst feste Standard-Spaltenreihenfolge) → "Übernehmen" hängt neue
  Prozess-Nodes an (linear untereinander verbunden, wie der bestehende
  Excel-Import — kein automatisches Anknüpfen an die bestehende Kette). Nutzt
  `numericCellOrUndefined` aus `vsm-excel.ts` (jetzt exportiert) wieder statt
  eine zweite Nullish-Parsing-Implementierung zu bauen. Die rechte
  Properties-Panel-Spalte bleibt auch in der Tabellen-Ansicht sichtbar — ein
  Zeilenklick selektiert den Node dort für Advanced/Expert-Bearbeitung.
  A11y: Fokus wandert beim Umschalten auf die Tabellen-Überschrift
  (`tabIndex={-1}` + `.focus()`, gleiches Muster wie der Wizard-Schritt-Fokus);
  `aria-live="polite"` für den View-Wechsel und die Paste-Zeilenzahl; Tabelle in
  eigenem `overflow-x-auto`-Container (360px-tauglich, kein horizontales
  Body-Scrollen).
- **B3 OEE-Link**: neue Funktion `calculateAvailabilityFromMtbfMttr` in
  `lib/oee/engine.ts` (Availability = MTBF/(MTBF+MTTR), Minuten, Fraktion 0–1
  zurückgegeben) — lib/oee hatte bislang DREI unabhängige OEE=A×P×Q-Formeln,
  aber keine MTBF/MTTR-Herleitung; diese Lücke wird in lib/oee geschlossen
  (nicht dupliziert). Properties-Panel zeigt bei gesetztem MTBF+MTTR einen
  "→ berechnete Verfügbarkeit: X%"-Hinweis mit explizitem "Übernehmen"-Button
  (schreibt NIE still in `availabilityPct`). Die effektive Wirkung
  (effektive Zykluszeit, Stk/h, Auslastung) wird über `lib/vsm-engine`s
  bereits existierende `computeEffectiveCapacity`/`computeUtilization`
  angezeigt (P4 ist die erste Phase, die diese Funktionen tatsächlich aus der
  UI aufruft — vorher `0` Aufrufer außerhalb von `lib/vsm-engine` selbst) —
  `availabilityPct` fließt als `NodeCapacityOverrides.availabilityPct` ein,
  NIE in `node.oee` (das bedeutet volle GEMESSENE OEE und nimmt den
  Double-Counting-Schutz-Zweig, der Overrides ignoriert).
- **B4 Externer Transport**: neues Standard-Feld `transportFrequency`
  (Freitext, z. B. "3x täglich") an Transport-Nodes, neben der bestehenden
  Transportzeit; Distanz wandert nach Erweitert. Rest (Anzahl Transporter,
  Fahrzeugkapazität, Rückweg-Zeit) bewusst nicht gebaut (SimVSM-Komplexität,
  P4-Brief-Nicht-Scope).
- **B2 Arbeitszeitmodell (Standard-Seite)**: `vsm-timeline-ladder.tsx` bekommt
  ein editierbares "Arbeitszeitmodell"-Formular (Std./Schicht, Schichten/Tag,
  Pausen Min./Schicht) — schließt eine von der P3-Timeline-Leiter selbst
  dokumentierte Lücke ("kein Kundenbedarf/Arbeitszeitmodell hinterlegt", weil
  "der Editor keinen persistierten Ort dafür hat"). Persistiert additiv in
  `layout.shiftModel` (bestehende jsonb-Spalte, wie `layout.viewport` — keine
  Migration). Bedarfsrate [Stk/Tag] wird aus vorhandenen Kunden-Node-Feldern
  summiert (keine Zweiteingabe). `PUT /api/wertstrom/[id]` merged
  `shiftModel` asymmetrisch zu `viewport`: ein Request ohne `shiftModel`
  LÄSST einen bereits gespeicherten Wert unangetastet (nicht wie `viewport`
  bedingungslos überschrieben) — `viewport` hat immer einen lebendigen
  Wert vom Editor, `shiftModel` ist echte optionale Daten, die eine
  naive Übernahme des `viewport`-Musters stillschweigend gelöscht hätte
  (durch einen Test aufgedeckt, siehe route.test.ts).
- **B1 Multi-Produkt**: keine Code-Änderung nötig — der Editor hatte nie ein
  Multi-Produkt-Konzept, "1 Produkt Standard" ist bereits der einzige
  existierende Zustand. Expert-Ausbau (Produkt-Zeilen je Prozess) bleibt
  P4-Brief-Nicht-Scope.
- **Vertragsänderungen** (additiv, Symbol-Grep durchgeführt, keine Konsumenten
  betroffen): `VsmNode` (`lib/vsm-types.ts`) + `VsmNodeSchema`
  (`lib/api/schemas.ts`) um `availabilityPct`/`mtbfMin`/`mttrMin`/
  `transportFrequency` erweitert; `VsmLayoutSchema` um `shiftModel` erweitert;
  `numericCellOrUndefined` (`vsm-excel.ts`) exportiert (war intern).
- Kein neuer API-Vertrag über die additiven Felder hinaus, keine neue
  DB-Spalte/Migration, keine Engine-Formel-Änderungen (nur Konsum von
  `computeEffectiveCapacity`/`computeUtilization`/`computeTimelineLadder`, P1).
- **Scope-Entscheidungen**: Tabellen-View zeigt NUR Prozess-Nodes (nicht
  Bestand/Transport/Kunde/Lieferant/Maschine/Zeitwert) — ein einheitliches
  Spaltenschema passt nur zu einer einheitlichen Zeilen-Semantik, und der
  bestehende Excel-Import etabliert "Prozessschritt" bereits als das
  tabellenförmige Konzept dieses Codebase. Excel-Paste deckt nur die
  Essential-Felder ab (Name/Zykluszeit/Anzahl Mitarbeiter), nicht die
  Advanced-Zeitaufschlüsselung des Datei-Imports. Szenarien-UI-Ausbau (P5),
  SimVSM-Import (P6), Präsentation/Exporte (P7), i18n/E2E (P8) explizit NICHT
  Teil dieser Phase (Capability-Matrix Phasenplan).
- **iOS scope:** web-only (VSM-Editor ist web-only); kein separates iOS-Work.
- **Adversarial-Review-Fixes (PR #355, 13 Findings, F1≡F6/F3≡F5 dedupliziert
  auf je einen Fix)**: F3+F5 (major) — `shiftModel` war über die UI nie
  löschbar (Leeren+Speichern war ein stiller No-Op); `VsmLayoutSchema.shiftModel`
  jetzt `.nullable().optional()`, Route unterscheidet Key-fehlt/`null`/Objekt,
  Editor sendet explizites `null` sobald ein einmal vollständiges Formular
  geleert wird (`shiftModelWireValue`, `shiftModelEverCompleteRef`). F2
  (major) — Quick-Start-Wizard verwarf sein eigenes Arbeitszeitmodell beim
  Fertigstellen (`toShiftModel`, jetzt Teil des PUT-Bodys). F1+F6 (major) —
  deutsches Dezimalkomma beim Excel-Paste (nicht beim Datei-Import) wurde
  still zu "keine Angabe"; paste-lokaler Parser normalisiert eindeutige
  Kommazahlen, markiert Tausendertrenner-Ambiguität explizit als
  "nicht erkannt" statt sie stumm zu verwerfen. F4 (major) — Tabellenzeilen
  jetzt per Tastatur selektierbar (tabIndex/onKeyDown/aria-selected). F9
  (minor) — Plausibilitäts-Obergrenzen auf `VsmShiftModelSchema` (24h/6
  Schichten/1440min). F10 (minor) — Infinity-Guard in
  `calculateAvailabilityFromMtbfMttr` (Summenüberlauf konnte trotz einzeln
  endlicher Inputs eine stille 0 erzeugen). F7 (minor) — Währungsfeld
  normalisiert jetzt (trim+uppercase, wiederverwendet aus dem
  `qaf-differences`-Modul-Barrel). F8 (minor) — Kopfzeilenerkennung beim
  Paste case-insensitiv. F11 (minor) — jedes Feld im neuen Panel (Basis/
  Erweitert/Experte) hat jetzt `htmlFor`/`id`. F12 (minor) — Fokus nach
  Paste-Apply gezielt auf das Textarea gesetzt statt implizit zu verfallen.
  F13 (minor) — "Basis"/"Erweitert"/"Experte" tragen jetzt Heading-Semantik
  (`role="heading" aria-level={4}`) für die Screenreader-Heading-Liste.

## 2026-07-22 – KAR-878/KAR-986: Wertstrom-Modul P3 — Quick-Start-Wizard, Auto-Layout, Timeline-Leiter v2 ("das UX-Herz")

Phase P3 des Wertstrom-Improvement-Programms (Capability-Matrix A1/A2/A4) — komplett
hinter dem neuen, standardmäßig ausgeschalteten Feature-Flag `wertstromUxV2`
(Phasenplan-Pflicht für P3, `false` in allen 3 Profilen). Bestehende Editor-Funktionen
laufen bei Flag AUS byte-gleich weiter (Regression-Schutz).

- **A1 Quick-Start-Wizard**: neuer geführter Fünf-Schritte-Assistent
  (`components/wertstrom/vsm-quick-start-wizard.tsx` + reine Logik in
  `vsm-wizard-logic.ts`) — Produkt & Bedarf → Arbeitszeit → Prozessschritte → Bestände
  (optional) → Ergebnis. Erzeugt in EINEM „Fertigstellen"-Schritt einen kompletten
  Wertstrom (Lieferant + Prozesse + Kunde, lineare materialFlow-Kanten via der
  bestehenden `buildLinearConnectionPairs`, Auto-Layout, Takt aus der P1-Engine) und
  navigiert in den Editor. Abbrechbar ohne Datenmüll: jeder Schritt ist reiner
  Local-State, `POST /api/wertstrom` + anschließendes `PUT /api/wertstrom/[id]` (beide
  bestehenden Endpunkte unverändert, kein neuer Server-Vertrag) feuern erst beim
  Fertigstellen. Nur Titel + mindestens ein benannter Prozessschritt sind Pflicht, jeder
  andere Schritt bleibt überspringbar (§10.3 Smart Defaults) — fehlende Arbeitszeit-/
  Bedarfsangaben zeigen ehrlich "Taktzeit: nicht berechenbar" statt eines geratenen
  Werts. Wizard-Zykluszeiten werden `provenance: 'planned'` markiert (Planwerte, keine
  Messung). "Produkt" hat keine eigene DB-Spalte (keine Migration im Scope) und wird in
  `description` abgelegt (`"Produkt: <Name>"`).
- **A2 Auto-Layout**: neue reine, deterministische Funktion
  `components/wertstrom/vsm-auto-layout.ts` (`computeAutoLayout`) — ordnet
  Lieferant→Prozesse→Kunde per topologischer Sortierung über die materialFlow-Kanten,
  splict Bestands-Nodes an ihre per Kante aufgelöste Position zwischen zwei
  Prozess-Nachbarn (versetzte Reihe darunter, gestaffelt bei Kollision), und platziert
  reine Informationsfluss-Nodes (nur `kind: 'information'`-Kanten) in einer Reihe
  darüber. Additiv/ungeflaggt (tot ohne UI). Aufrufbar aus dem Wizard UND als neuer,
  geflaggter "Automatisch anordnen"-Button im Editor-Toolbar — fragt immer erst per
  `confirm()` nach, da er manuell gesetzte Positionen überschreibt (nie still).
  Manuelles Verschieben bleibt danach voll erhalten.
- **A4 Timeline-Leiter v2**: neue Komponente `vsm-timeline-ladder.tsx` rendert
  `computeTimelineLadder` (P1-Engine, `lib/vsm-engine`) unter dem Canvas — VA-/
  NVA-Prozess-/Warte-/Bestands-/Transportzeit als proportionaler Segment-Balken, DLZ und
  Process Cycle Efficiency, alles live aus dem aktuellen `nodes`-State (kein eigenes
  Nachrechnen). Ehrliche "nicht berechenbar"-Zustände (PCE `null` ≠ 0%, Bestandszeit
  ohne Bedarfs-/Arbeitszeitmodell fehlt sichtbar aus der DLZ-Summe statt sie
  stillschweigend zu unterschätzen) — die MetricExplain-Formel/Datenbasis/Ausschlüsse
  sind über ein natives `<details>`-Element aufklappbar, kein Tooltip-Grab. Ersetzt den
  bestehenden 1-Prozentbalken NUR bei aktivem Flag; die Alt-Ansicht bleibt bei Flag AUS
  byte-identisch.
- **Flag** `wertstromUxV2` (`config/profiles/`, `false` in `_template`/`bmw`/`default`)
  + Gate-Prädikat `wertstromUxV2Gate` (`lib/api/feature-gates.ts`, gleiche Form wie
  `wertstromScenariosGate`) — anders als bei P2 gibt es diese Phase keine neue Route zum
  serverseitig Durchsetzen (Wizard nutzt ausschließlich bestehende, ungegatete
  Endpunkte); das Gate ist die einzige Quelle für den `wertstromUxV2Enabled`-Prop, den
  beide `page.tsx` an `VsmEditor`/`VsmListClient` weitergeben.
- Kein neuer API-Vertrag, keine Migration, keine Engine-Formel-Änderungen (nur
  Konsum von `computeTakt`/`computeTimelineLadder`, P1).
- **17 Findings aus dem adversarialen Review von PR #354 gefixt**: Wizard-
  Fehlerpfade F1–F3 als ein kohärenter Umbau (try/catch um POST/PUT, Modal
  bleibt nach Fehlschlag immer schließbar; Retry überspringt POST bei
  bereits angelegter Karte + Direktlink "im Editor öffnen" statt neuer
  Waise; Fehlertext liest `err.message` statt `err.error`, Test-Mocks auf
  die echte `{error, message}`-Shape umgestellt); Fokus-Falle im Wizard
  (Tab/Shift+Tab zyklisch, kleine lokale Logik, kein neues Package); Gap-
  Sync jetzt an stabile Step-Identität statt Array-Position gekoppelt
  (Entfernen/Leeren eines NICHT-letzten Prozessschritts vertauschte bisher
  stillschweigend konfigurierte Bestände); Kollisions-Staffelung für Info-
  Nodes im Auto-Layout (analog Pass 2); Fit-View nach "Automatisch
  anordnen" (Canvas konnte sonst leer erscheinen); STAGGER_Y an
  `getNodeH('inventory')` gekoppelt (war kleiner als die Node-Höhe, echte
  Überlappung); "nicht berechenbar"-Hinweis der Timeline-Leiter verschwindet
  nicht mehr, wenn Bestandszeit das einzige Zeit-Signal ist; Prozesszeit
  als eigene Kennzahl neben DLZ/PCE (komplettiert §8.9); measuredNodeCount
  je Zeitklasse jetzt sichtbar ("n von m Nodes gemessen"); Transportzeit-
  Farbe aus der Grün-Familie raus (Kollision mit "Wertschöpfend");
  360px-Responsive für die Prozessschritt-Zeile; `role="alert"` auf die
  drei dynamischen Wizard-Meldungen; Test-Härtung (PUT-Payload exakt inkl.
  cycleTimeSec/provenance/Bestands-Node, Doppel-Submit-Guard,
  In-Flight-Schließen-Guard).

## 2026-07-21 – KAR-878/KAR-986: Wertstrom-Modul P2-Datenmodell (Szenarien, Kantentypen, Lager-Untertypen, Provenance)

Phase P2 des Wertstrom-Improvement-Programms — Datenmodell-Erweiterung als Code-PR
(Capability-Matrix A7/A8/A9/A12), plus die minimale UI-Anbindung, die die neuen Formen
erzeugbar/sichtbar macht. Kein Feature-Flag (additiv, Flags sind für P3/P5/P6
reserviert). Noch kein UI-Umbau (P3+).

- **Migration** (`supabase-migration-wertstrom-scenarios.sql` + Rollback, ins Repo
  übernommen, MIGRATIONS.md §7r/#114): `value_stream_maps.parent_value_stream_id uuid
  NULL` (FK `ON DELETE SET NULL`, Selbst-Referenz-CHECK) + `scenario_kind text NOT NULL
  DEFAULT 'current'` (CHECK `current`/`future`/`alternative`). Bereits am 21.07.2026
  ~23:15 CEST operator-applied und live verifiziert — dieser PR liefert Code + Katalog
  nach, keine erneute DB-Änderung.
- **A7 Informationsfluss-Kantentyp**: `VsmConnection.kind?: 'materialFlow' |
  'information'` (+ optionale `frequency`) — abwärtskompatibel (fehlendes Feld =
  Materialfluss). Editor: 2-Wege-Toggle beim Verbinden, Informationsfluss rendert
  gestrichelt. Engine: `findFirstCycle` (lib/vsm-engine) ignoriert jetzt
  Informationsfluss-Kanten bei der Zyklen-Suche — ein klassischer
  Kunde→PPS→Lieferant-Informationsfluss-Loop um eine Materialfluss-Kette ist
  Standard-VSM-Topologie, kein Modellierungsfehler. `KNOWN_VALIDATION_GAPS`-Eintrag
  `structural.broken-information-flow` entfernt (aufgelöst, nicht nur verschoben).
- **A8 Lager-Untertypen**: `VsmNode.inventoryKind?: 'fifo' | 'supermarket' | 'push'` +
  `inventoryMaxQuantity?: number` ("FIFO mit Kapazität"). Editor: ein "Lager-Art"-Select
  am bestehenden Bestands-Element (kein 3-Palette-Slot-Ansatz), Canvas-Karte zeigt
  Untertyp + Kapazität.
- **A9 Szenarien Ist/Soll**: "Soll-Zustand erstellen" (neuer Editor-Toolbar-Button, neue
  Route `POST /api/wertstrom/[id]/duplicate`) dupliziert Nodes/Connections/Layout
  server-seitig (Node-/Connection-IDs bewusst NICHT neu vergeben — kein
  Cross-Row-ID-Namensraum, und ein künftiger P5-Szenario-Vergleich kann Nodes so per ID
  matchen) mit `parent_value_stream_id` + `scenario_kind: 'future'`. Liste zeigt
  Soll-/Alternative-Badge (keine Badge für Ist). KPI-Delta/Detail-Vergleich bewusst
  verschoben auf P5 (execution-prompt §9.4) — diese Phase liefert nur Anlage + Liste +
  Kennzeichnung.
- **A12 Provenance**: `VsmNode.provenance?: 'planned' | 'measured' | 'calculated' |
  'imported' | 'assumed'` — ein Badge pro Node (nicht pro Feld), lesend im
  Properties-Panel. Automatisch gesetzt von beiden bestehenden Stoppuhr-Pfaden: die
  inline Stoppuhr auf "gemessen", der LSC-Bulk-Import auf "gemessen" (echter
  `cycle_measurements`-Ø) oder "geplant" (Fallback auf `planned_cycle_time_sec`). Beim
  dritten, ähnlich benannten Pfad (`VsmStoppuhrPickerModal`, "Aus Stoppuhr
  importieren") stellte sich beim Nachlesen heraus, dass er tatsächlich
  `planned_cycle_time_sec` liest, keinen echten Messwert — korrekt als "geplant"
  markiert, nicht als "gemessen" (sonst hätte die Provenance-Kennzeichnung selbst eine
  falsche Herkunft behauptet).
- **Zod**: `VsmConnectionSchema`/`VsmNodeSchema` (lib/api/schemas.ts) um alle neuen
  Felder erweitert, `.passthrough()`-Disziplin wie P0 durchgehend beibehalten.
- **Docs**: PRODUCT_SPEC.md (neue Wertstromanalyse-P2-Sektion) + API_SPEC.md (neue
  §3.8, erstmals die Wertstrom-Endpunkte dokumentiert) nachgezogen.
- **Tests**: neue Fälle in `lib/api/__tests__/schemas.test.ts`,
  `lib/vsm-engine/__tests__/validation.test.ts` (Zyklen-Kind-Awareness,
  Gap-Auflösung), `app/api/wertstrom/[id]/duplicate/__tests__/route.test.ts` (neu),
  `components/wertstrom/__tests__/vsm-editor.test.tsx`; bestehende
  `ValueStreamMap`-Fixtures (copilot-export Wertstrom-MD/DOCX, vsm-editor) auf die 2
  neuen Pflichtfelder nachgezogen (repo-weiter Symbol-Grep vor dem Push).
- **Review-Fixes** (15 adversariale Findings, PR #353): A9-Erstellungs-UI
  (Toolbar-Button, Listen-/Editor-Badge, `POST /api/wertstrom/[id]/duplicate`)
  jetzt hinter neuem Flag `wertstromScenarios` (config/profiles, Default AUS
  in allen Profilen bis P5 — CAPABILITY-MATRIX.md ordnet die Erstellung
  explizit P5 zu, der ursprüngliche "kein Flag nötig"-Claim oben war falsch);
  Datenmodell-Teile (Zod/Typen/Engine/Migration) bleiben ungeflaggt. Duplikat
  setzt `is_demo` jetzt IMMER hart auf `false` (nie vom Source geerbt — ein
  Demo-Wertstrom sonst dauerhaft org-weit lesbar über
  `value_stream_maps_demo_read`, ohne In-Product-Weg das rückgängig zu
  machen). Duplizieren lässt sich nur noch vom Ist-Zustand ableiten (Route:
  400 bei bereits abgeleiteter Quelle; Editor: Button nur auf
  `scenario_kind === 'current'`) — verhindert Ketten wie „Linie 3 (Soll)
  (Soll)". „Soll-Zustand erstellen" speichert jetzt erst ungespeicherte
  Änderungen (bricht bei Speicherfehler ab, dupliziert nicht mehr den
  veralteten Server-Stand). Provenance-Badge zieht neben das Zykluszeit-Feld
  (vorher Node-Header, wo er wie eine node-weite Aussage wirkte); manuelle
  Zykluszeit-Eingabe setzt die Herkunft jetzt auf unbekannt zurück statt eine
  überholte Behauptung stehen zu lassen; ein Downgrade auf eine
  niedrigere Konfidenz (z. B. Stoppuhr-Picker „geplant" über einen bereits
  „gemessenen" Wert) verlangt jetzt eine Bestätigung. `inventoryMaxQuantity`
  (A8 FIFO-Kapazität) fließt jetzt in die Validierungs-Engine ein (negativ =
  kritisch, über Kapazität bei FIFO = Warnung) und wird beim Wechsel weg von
  FIFO nicht mehr als Karteileiche auf dem Canvas mitgeschleppt. Kaputte
  Informationsfluss-Kanten zeigen jetzt eine eigene, korrekte Fehlermeldung
  statt der Materialfluss-Formulierung. Neue Tests in allen betroffenen
  Modulen (Route/Editor/Engine/Schema), inkl. `vsm-list-client.test.tsx`
  (neu — erste Testdatei für diese Komponente).

## 2026-07-21 – KAR-878: Wertstrom-Modul P1-Engine (Berechnungs- + Validierungs-Engine v2)

Phase P1 des Wertstrom-Improvement-Programms — neue, pure `lib/vsm-engine/`-
Bibliothek (ADR-019-Modul), noch ohne UI-Umbau (P3+):

- **A3 Takt**: `computeTakt` — Netto-Arbeitszeit/Bedarf mit einfachem
  Arbeitszeitmodell (Std./Schicht, Schichten, Pausen, optional Stillstand,
  Arbeitstage). Kein Schichtkalender, kein Produktmix (Standard-Modus).
- **A5 Bestandsreichweite**: `computeInventoryCoverage` — Little's Law,
  Reichweite in Tagen/Schichten/Std./Min., konsistente Bedarfsbasis (ein
  Arbeitszeitmodell für die Tage→Std.-Umrechnung).
- **A4 Timeline-Leiter v2**: `computeTimelineLadder` — VA-/NVA-Prozesszeit,
  Wartezeit, Bestandszeit (via Reichweite), Transportzeit, Gesamtdurchlauf-
  zeit, Process Cycle Efficiency; additiv, ersetzt `vsm-metrics.ts`s
  `computeTimeline` nicht (UI-Wiring ist P3).
- **A6 Engpass v2**: `computeBottleneckV2` — effektive Zykluszeit
  (Parallelität/OEE/Verfügbarkeit) vs. Kundentakt statt Max-CT, primärer +
  sekundäre Kandidaten, Klartext-Begründung (deutsch), Konfidenz;
  degradiert ehrlich auf die alte Max-CT-Heuristik ohne Takt/Daten.
- **A10 Effektive Kapazität**: `computeEffectiveCapacity` — Formel-Priorität
  gemessene Kapazität > abgeleitete Kapazität, OEE > Verfügbarkeit/Leistung/
  Qualität einzeln; verhindert Doppelzählung von Verfügbarkeit strukturell
  (nie beides multipliziert). `computePce` (§8.6, benannt).
- **A10/B5 Qualität**: `computeProcessQualityImpact`/`computeCumulativeYield`/
  `computeExpectedGoodOutput` — Scrap ≠ Rework (Rework kostet nur Kapazität,
  keine Ausbeute), Erstdurchlaufquote, kumulierte Ausbeute, erwarteter
  Gut-Output.
- **A11 Validierungs-Engine**: `runValidation` — Struktur/Zeit/Kapazität/
  Qualität/Bestand nach §17, Was/Warum/Wert/Fix-Format auf Deutsch,
  critical/warning-Schweregrad; inkl. Doppelzählungs-Check
  (`capacity.double-counted-availability`). 12 nicht prüfbare Spec-Punkte
  (kein Produkt-/Schichtkalender-/Reorder-Point-Konzept u. a.) als
  `KNOWN_VALIDATION_GAPS` dokumentiert statt fake-implementiert.
- **Architektur**: neues `lib/vsm-engine/`-Modul statt Ausbau von
  `components/wertstrom/vsm-metrics.ts` — die zentrale Engine gehört in
  `lib/`, nicht in die UI-Schicht; `vsm-metrics.ts` bleibt unverändert
  (0 Diff), alle 425+ bestehenden Wertstrom-Tests unangetastet.
- 105 neue Tests gegen Handrechnungen (u. a. Doppelzählungs-Schutz, Rework
  0 %/100 %, `quantity: 0` vs. `undefined`, leere Nodes, fehlender Takt).
- **Review-Fixes** (16 adversariale Findings, PR #352): `machine`-Nodes sind
  jetzt vollwertige Engpass-/Auslastungs-/Zeitplausibilitäts-Kandidaten
  (vorher `type==='process'`-blind, gemeinsames `isStationNode`-Prädikat),
  Undefined-vs-0-Doktrin für Nacharbeits-Kapazitätsbedarf sowie
  Teile-/Parallel-Einheiten-Excludes konsequent durchgesetzt, PCE nutzt die
  echte gemessene VA-Zeit statt eines fabrizierten Best-Effort-0%, eine neue
  Zusammenhangskomponenten-Analyse erkennt vom Hauptfluss getrennte
  Insel-Subgraphen (§17.1 „unreachable nodes"), `scrapRatePct` auf
  `ProcessQualityInput` wird jetzt direkt bereichsgeprüft, und README/
  `KNOWN_VALIDATION_GAPS` wurden auf den tatsächlichen Abdeckungsstand
  korrigiert.

## 2026-07-21 – KAR-878: Wertstrom-Modul P0-Foundation

Phase P0 des Wertstrom-Deep-Improvement-Programms — Härtung des bestehenden
VSM-Editors, ohne Migration:

- **Bugfixes**: `is_demo`-Typ-Drift; Excel-Import erzeugte Nodes ohne Kanten
  (jetzt lineare i→i+1-Verbindungen); leere Excel-Zelle und echte `0` bleiben
  bis in den Node unterscheidbar (nullish statt falsy, `vsm-excel.ts`).
- **Server-Validierung** (`lib/api/schemas.ts`): Zod-Schema für PUT
  `/api/wertstrom/[id]` — Zeiten/Mengen ≥ 0, OEE/Scrap 0–100, referenzielle
  Integrität nodes↔connections; connections-only-Payload wird abgelehnt.
- **Permissions-Enforcement**: `vsm.read`/`vsm.write` auf Wertstrom-Route und
  Copilot-Exporten; im Projekt-Export lässt fehlendes `vsm.read` nur die
  Wertstrom-Sektion weg statt den Export zu brechen.
- **Optimistic Locking** über die repo-weite If-Match/`guardIfMatch`-Konvention
  (`lib/api/concurrency.ts`), mit atomarem CAS auf `updated_at`.
- **Layout-Persistenz**: Fit-View + `layout.viewport` (JSONB) — serverseitiger
  Merge statt Spalten-Overwrite, fremde Layout-Keys überleben einen Save.
- **Editor-Robustheit**: dangling connections werden beim Laden verworfen,
  Alt-Wertströme bleiben damit unter der neuen Validierung speicherbar;
  Node-Farben als `--vsm-node-*` CSS-Vars im Design-System.
- 6 Findings aus dem adversarialen Review des PR gefixt (Layout-Merge,
  Export-Gate, Excel falsy-zero, Load-Sanitisierung, refine-Bypass,
  If-Match-Umzug).

## 2026-07-20 – KAR-985: QAF-Vergleich Eingaben-Autosave (Demo-067 F)

Live-Test-Feedback des Auftraggebers (Screenshots): manuell eingetragene Zahlen im
QAF-Vergleich speicherten nicht — beide betroffenen Flächen bekommen jetzt debounced
Autosave je Vergleich, ohne eigenen Save-Button:

- **Sektion „12. Hochrechnung & Potenzial in €"** (`components/qaf-differences/qaf-comparison-detail.tsx`
  / `qaf-projection.tsx`): Jahres-/Volumen-Tabelle, angehakte Jahre, „+ Jahr davor/danach",
  Abwehrquote-Slider, Mehrkosten-Δ-Override.
- **Preis-Kalkulator** (`app/qaf-differences/[id]/kalkulator/page.tsx` / `qaf-price-calculator.tsx`,
  KAR-847/848): alle 12 manuellen Feld-Eingaben (Zykluszeit, Teile/Zyklus, MA, Lohnsatz,
  Ineffizienz, Maschinensatz, Material, Scrap, SG&A×2, Gewinn×2).
- **Persistenz:** additive Spalte `qaf_comparison.user_inputs JSONB NOT NULL DEFAULT '{}'::jsonb`
  (Migration `supabase-migration-qaf-comparison-user-inputs.sql`, **operator-applied, noch nicht
  angewendet**) — Namespace-Bag `{v:1, projection?, calculator?}`, gleiches additive-JSONB-Bag-Muster
  wie `qaf_file.g60_meta`. Kein RLS-Change (Spalte erbt die bestehenden `_own`/`_admin`-Policies).
  Reine Merge-/Validierungslogik in `lib/qaf-differences`'s neuem `comparison-user-inputs.ts` (patch-
  Semantik: ein Save der Hochrechnung überschreibt niemals einen bereits gespeicherten Kalkulator-Stand
  und umgekehrt — beide Namespaces leben unabhängig im selben Bag). Server Actions
  (`saveQafComparisonUserInputs`, `loadQafComparisonUserInputs`) in einer neuen, eigenständigen
  `app/qaf-differences/qaf-comparison-user-inputs-actions.ts` (`actions.ts`, 4750 Zeilen, blieb tabu —
  gleiche Sibling-Datei-Präzedenz wie `copilot-export-actions.ts`).
- **FAILSOFT (Pflicht, da die Migration vor dem Operator-Apply deployen kann):** jeder DB-Fehler auf
  der neuen Spalte — Lesen wie Schreiben — degradiert zu einem ruhigen Ergebnis statt eines Absturzes:
  Save-Fehler zeigt „Speichern derzeit nicht verfügbar" (dezent, kein Toast), die Seite bleibt voll
  nutzbar und die Berechnung läuft wie zuvor weiter; Load-Fehler liest sich wie „keine gespeicherten
  Eingaben". Die `user_inputs`-Query läuft dafür bewusst GETRENNT vom jeweiligen Haupt-Select der Seite
  (nie in dessen Spaltenliste aufgenommen) — ein fehlendes Feld in einem kombinierten Select hätte sonst
  die GANZE Zeile scheitern lassen und damit die komplette Vergleichs-/Kalkulator-Seite mitgerissen.
- **Client:** neuer geteilter Hook `components/qaf-differences/qaf-autosave.tsx`
  (`useDebouncedAutosave` + `QafAutosaveIndicator`) — 800 ms Debounce nach der letzten Änderung, kein
  Save bei unverändertem Zustand (Serialisierungs-Vergleich, auch bei neuer Objekt-Referenz mit
  gleichem Inhalt), kein Save beim ersten Rendern (Server-Prefill zählt nicht als Änderung). Beide
  Editor-Flächen laden ihren vorbefüllten Stand server-seitig (kein Extra-Roundtrip) und nutzen exakt
  denselben Hook — Statusanzeige klein und dezent („…speichert" / „✓ Gespeichert" mit dem bestehenden
  `Check`-Icon-Muster aus `qaf-title-editor.tsx` / `text-success-text`), kein Toast. `comparisonId`/
  `autosaveComparisonId` sind optional — `qaf-g60-detail.tsx`s eigene `QafProjection`-Nutzung lässt sie
  weg und bleibt unverändert session-only (ihr eigener „Eingaben gelten je Sitzung"-Hilfetext stimmt dort
  weiterhin). Der Hilfetext der Sektion 12 selbst wurde auf die neue Realität angepasst.
- Tests: reine Merge-/Validierungslogik (`lib/qaf-differences/internal/__tests__/comparison-user-inputs.test.ts`,
  inkl. der Patch-Semantik-Garantie), Action-Tests (uuid-Validierung, Auth-Fehlpfad, Failsoft bei
  DB-Fehler auf Lese- UND Schreibpfad, Merge-Verhalten end-to-end), Hook-/Indikator-Tests
  (`qaf-autosave.test.tsx`) und Komponententests für beide Flächen (Vorbefüllung, Save-Callback nach
  Debounce, „Gespeichert"-Indikator).

## 2026-07-20 – Demo-067 E: Copilot-Exporte als Markdown-Option (KAR-984)

Markdown als zweite Format-Option für alle vier Copilot-Exporte (Fabrikanalyse/
QAF-Vergleich/Wertstrom/Gesamtprojekt) — Auslöser: Feedback aus dem laufenden
Live-Test, dass der Workflow „Datei in ein KI-Chat-Fenster einfügen" mit
Markdown reibungsloser ist als mit DOCX (kein Office-Viewer-Umweg).

Review-Fix: das Pipe-/Newline-Escaping in `mdTable` (Freitext-Zellen wie
Maßnahmen-Beschreibungen dürfen die GFM-Tabellenstruktur nicht brechen) ist
jetzt durch dedizierte Tests gepinnt (`md-shared.test.ts` + Produktionspfad-Fall
in `project-md.test.ts`).

- **4 neue MD-Builder** in `lib/copilot-export/internal/` (`assessment-md.ts`,
  `qaf-md.ts`, `wertstrom-md.ts`, `project-md.ts`) + `md-shared.ts` (GFM-
  Pipe-Tabellen, Überschriften-Helfer, AI-Instruction-Block-Rendering). Exakt
  gleiche Inhalte/Reihenfolge wie die jeweiligen DOCX-Pendants — gleicher
  AI-Instruction-Block + Glossar zuerst, Fictional-Data-Disclaimer nur bei
  `is_demo` (als Blockquote), dieselben Leerzustands-Marker. Konsumieren
  dieselben Input-Verträge wie die DOCX-Builder (`AssessmentDocxInput` etc.) —
  keine Duplikation der Datenverträge.
- **Wiederverwendung statt Refactor:** die vier bestehenden DOCX-Builder
  (`assessment-docx.ts`/`qaf-docx.ts`/`wertstrom-docx.ts`/`project-docx.ts`)
  bleiben unverändert. Format-neutrale Helfer (`formatNumberDe`/
  `formatPercentDe`/`sanitizeFilenamePart`, `buildAiInstructionBlock`,
  `project-content.ts`'s Stammdaten-/Maßnahmen-Zeilen) werden direkt
  wiederverwendet; kleine DOCX-lokale Präsentations-Helfer (Rating-Labels,
  Node-Typ-Labels, Delta-Formatierung) sind 1:1 dupliziert — gleiches Muster,
  das assessment-docx.ts selbst schon für `toResponseDraftMap` verwendet.
- **Barrel erweitert:** `lib/copilot-export/index.ts` exportiert jetzt
  `buildFabrikanalyseCopilotMd`/`buildQafComparisonCopilotMd`/
  `buildWertstromCopilotMd`/`buildProjectCopilotMd` (ADR-019, kein
  Deep-Import).
- **Server Actions:** alle vier Action-Dateien um eine
  `exportXxxCopilotMd`-Funktion erweitert (separate Funktion statt
  Format-Parameter — eine Server Component kann eine Server Action nur als
  direkte Top-Level-Referenz an eine Client-Komponente durchreichen, kein
  Inline-Wrapper möglich). Gemeinsame Datenbeschaffung dort, wo sie noch
  nicht extrahiert war (Fabrikanalyse/QAF-Vergleich/Wertstrom), in eine
  lokale `loadXxxExportInput`-Hilfsfunktion gezogen statt dupliziert; beim
  Gesamtprojekt-Export wird die schon bestehende `loadProjectExportInput`
  wiederverwendet. QAF-Vergleich behält den Dateinamen bewusst auf
  `qaf_comparison.part_number` (nicht `QafComparisonResult.partNumber`, das
  aus der neu geparsten Summary stammt und abweichen kann).
- **UI:** `CopilotExportButton` von `format`/`action`/`label` auf eine
  `formats`-Liste umgestellt — ein Eintrag rendert exakt den bisherigen
  einfachen Button (Gesamtprojekt-XLSX bleibt unverändert einformatig), zwei
  Einträge rendern ein kleines Dropdown („Word (DOCX)" / „Markdown
  (KI-Chat)") über die bestehende shadcn/Radix-`dropdown-menu`-Primitive
  (BMW-CI: keine neuen Farben/Radius). Alle vier Einbindungsstellen
  (Fabrikanalyse-Dashboard, QAF-Vergleich-Detail, Wertstrom-Editor,
  Gesamtprojekt-Export) bieten jetzt automatisch beide Formate an.
- **Tests:** je MD-Builder ein Testfile (Instruction-Block, Glossar,
  Disclaimer bei/ohne `is_demo`, Modul-Kernzahlen, Pipe-Tabellen-Header,
  Leerzustands-Marker) — 52 Tests im Modul grün. Alle vier bestehenden
  Action-Flag-Deny-Testdateien um die MD-Pfade erweitert (Flag aus →
  `ok:false` ohne DB-Zugriff), bestehende Tests unverändert.
- Kein neues Feature-Flag — läuft weiter unter `copilotExports`.

## 2026-07-19 – KAR-983: Projekt-Übersicht KPI-Sektion (Demo-067 D)

Neue, presentation-ready Kennzahlen-Sektion auf `/project/[id]` — zeigt den Zustand aller
Module eines Projekts auf einen Blick, generisch für jeden Projekttyp (kein Hardcode auf
LSC/Demo-Projekte):

- **Fabrikanalyse-KPI**: Erfüllungs-Score (Ø-Rating 1–4 der beantworteten, relevanten
  `assessment_responses`), Anteil erfüllt (Rating 3–4) / teilweise (2) / nicht erfüllt (1),
  Anzahl beantworteter Fragen — aggregiert über alle `assessments` des Projekts.
- **QAF-KPI**: neuester `qaf_comparison`-Eintrag, Kern-Impact (Summe Herstellkosten /
  Angebotspreis / Material, je Δ%) direkt aus den persistierten `qaf_summary_diff`-Zeilen via
  dem bestehenden `buildSummaryKpis` (kein Re-Compute). Comparisons ohne Summary-Zeilen (z. B.
  Multi-QAF) zeigen einen eigenen Hinweis statt einer leeren/falschen Zahl.
- **Wertstrom-KPI**: neueste `value_stream_maps`-Zeile des Projekts — Durchlaufzeit, VA-Anteil,
  Engpass-Station, verdrahtet über die bislang ungenutzten `computeVaClassBreakdown`/
  `findBottleneckId`-Helfer aus `components/wertstrom/vsm-metrics.ts` (QVS-P4, KAR-973), nicht
  neu berechnet.
- **Maßnahmen-KPI**: kombinierter Zähler (gesamt/offen/in Arbeit/erledigt) über
  `lsc_measures` (offen/in_arbeit/erledigt) und `workshop_actions`
  (open/in_progress/done; rejected zählt nicht als Bestand) — gesamt = Summe der drei
  sichtbaren Buckets (Review-Fix: vorher „verschwanden" in_arbeit/in_progress aus der Anzeige).
- **Demo-Badge**: bewusst KEIN eigener Badge an der Sektion — der sticky Projekt-Header
  rendert ihn bereits auf jeder `/project/[id]`-Route (Review-Fix: Duplikat entfernt).
- Alle vier Kacheln verlinken in ihr Modul (`/fabrikanalyse/{id}`, `/project/{id}/qaf`,
  `/wertstrom/{mapId}` bzw. `/wertstrom` ohne Map, `/project/{id}/workshop`); jede Kachel
  degradiert einzeln zu einer sauberen „Keine Daten"-Meldung, nie zu einem Crash oder einer
  leeren Lücke.
- **Nachtrag**: `projects.order_reason`/`order_objective` werden jetzt als eigener Abschnitt
  „Ausgangslage & Ziele" in der Projektdetails-Karte gerendert (leere Felder ausgeblendet).
- Neu: `lib/reporting/project-kpis.ts` (4 reine Compute-Funktionen + Unit-Tests, inkl. Leer-/
  Teilzustände), `components/project/kpi-section.tsx` (+ Komponententests: leere Kacheln,
  Maßnahmen-Buckets, QAF-Happy-Path mit Vorzeichen-/Farb-Formatierung). Alle Queries ein
  einziges paralleles `Promise.all`-Bündel mit gezielten Spalten und `limit(1)` für „neueste"
  Entitäten — keine spürbare Verlangsamung der Seite.
- Review-Fixes (adversariales Review, 5 Findings): Maßnahmen-total = Summe sichtbarer Buckets
  + „in Arbeit"-Posten; Badge-Duplikat entfernt; ungenutzte `connections`-Spalte (jsonb-Blob)
  aus dem VSM-Select; QAF-Kachel-Happy-Path-Test (formatSignedPercent/costDeltaClass).
  Bewusst NICHT geändert: das `qaf_summary_diff`-Embed selektiert weiterhin
  status/source_alt/source_neu — `buildSummaryKpis` verlangt den vollen
  `SummaryDiffRowData`-Vertrag; schmaler zu selektieren hieße lib-Signatur ändern oder
  Fake-Werte einschleusen, bei ≤~20 Metrik-Zeilen Payload der schlechtere Tausch.
- Kein neues Feature-Flag: die Seite hat aktuell kein etabliertes Flag-Muster für Sektionen,
  die neue Sektion ist additiv-lesend und degradiert sauber ohne Datenbasis.
## 2026-07-19 – Demo-067 C: Review-Fixes Copilot-Exporte (KAR-982)

Adversariales Review von PR #346 bestätigte 5 Findings; alle gefixt:

- **Nie laufender Test:** `lib/copilot-export/copilot-export.test.ts` lag außerhalb
  von `__tests__/` und wurde vom Vitest-`include` nie gesammelt → verschoben nach
  `lib/copilot-export/__tests__/entrypoint.test.ts` (läuft jetzt).
- **Serverseitiger Flag-Ablehnpfad getestet:** 4 neue Action-Testdateien (Muster
  `app/wertstrom/__tests__/qaf-actions.test.ts`): Flag aus → `ok:false` OHNE
  DB-Zugriff (createClient wirft bei Aufruf), plus Invalid-ID-Kurzschluss.
- **DOCX-Tabellen-Spaltenbreiten:** `dataTable()`/`kvTable()` übergeben jetzt
  explizite `columnWidths` (DXA) + Prozent je Spalte statt 100 % auf jeder Zelle —
  verhindert das degenerierte 100-Twips-`tblGrid`-Fallback der docx-Library.
- **Zahlenformat konsistent:** Ø-Bewertung im Gesamtprojekt-Export nutzt
  `formatNumberDe` (Komma) wie der FA-Einzelexport; `formatNumberDe` dafür in den
  Modul-Barrel aufgenommen.
- **XLSX-Leerzustand getestet:** `buildProjectCopilotXlsx` mit leeren
  Agenda/Maßnahmen/Modulübersicht-Arrays → Empty-State-Marker statt Wurf.

## 2026-07-19 – Demo-067 C: Copilot-Exporte für Fabrikanalyse/QAF-Vergleich/Wertstrom/Gesamtprojekt (KAR-982)

Neues Feature für das Top-Management-Demo-Programm (Epic KAR-979): DOCX-Exporte (+ zusätzlich
XLSX für den Gesamtprojekt-Export), die ein Consultant in ein externes LLM-Tool (z. B. Microsoft
Copilot) laden kann, um damit weiterzuarbeiten. Jeder Export beginnt mit einem
AI-Instruction-Block (was ist das Dokument, wie soll ein LLM damit arbeiten, LSC/QAF/VSM/FA-
Glossar) und — nur wenn das zugrunde liegende Projekt `is_demo` ist — einem
Fictional-Data-Disclaimer. Generisch für jedes Projekt (Auftrags-Regel: keine
projektspezifische Hardcode-Logik).

- **Neues Modul `lib/copilot-export/`** (ADR-019-Golden-Path via `npm run new:module`): reine
  Builder-Funktionen ohne DB-/Netzwerkzugriff — `buildFabrikanalyseCopilotDocx`,
  `buildQafComparisonCopilotDocx`, `buildWertstromCopilotDocx`, `buildProjectCopilotDocx`,
  `buildProjectCopilotXlsx`, plus die reinen Helfer `copilotExportsGate` und
  `fictionalDataDisclaimer`. Fabrikanalyse-Export folgt konzeptionell `lib/assessment-export.ts`s
  bestehendem `exportForAISummary` (gleiche Haupt-/Unterkategorie-Gruppierung). QAF-Vergleich-
  Export rendert dieselbe `QafComparisonResult`, die der bestehende 8-Blatt-XLSX-Referenzexport
  nutzt (`rehydrateExportInput` — keine eigene Diff-Logik, nur Narrativ). Wertstrom-Export ist
  netto-neu (Wertstrom hatte bisher gar keinen Export) und nutzt dieselben abgeleiteten
  Kennzahlen wie der Editor (`components/wertstrom/vsm-metrics.ts` — VA-Quote, Rüst-/Warte-/
  Transportzeit-Summen, Kosten-/Ausschuss-Rollup je Währung, Bottleneck).
- **Neue Dependency `docx@9.7.1`** (dependencies, DOCX-Erzeugung) + `jszip@3.10.1`
  (devDependencies, nur für den DOCX-Roundtrip-Test — `exceljs` ist bereits vorhanden).
- **4 neue Server Actions** ("Muster B": Buffer → base64 → Client-Download, exaktes Muster von
  `exportQafComparisonXlsx`, RLS-Pfad über `createClient()`, kein Service-Role-Client für
  User-Daten): `app/fabrikanalyse/[projectId]/[assessmentId]/actions.ts`,
  `app/qaf-differences/copilot-export-actions.ts` (Sibling zur tabuisierten `actions.ts` — die
  4750-Zeilen-Datei wurde nicht angefasst), `app/wertstrom/copilot-export-actions.ts`,
  `app/project/[id]/export/actions.ts`. Der QAF-Vergleich-Export ist auf den Standard/Summary-
  Vergleichsmodus beschränkt — dieselbe Einschränkung, die der bestehende XLSX-Export über
  `comparisonModeRule(...).exportSupported` bereits durchsetzt.
- **Neuer Feature-Flag `copilotExports`** (`config/profiles/`, `FeatureFlagsSchema`): `false` in
  `default`/`_template`, `true` in `bmw` — Einführung spiegelt exakt das Vorgehen von
  `qafValueStream` (QVS-P1, PR #335): ein Feld in `profile.ts` + ein Wert je Profil-Datei.
- **UI:** je ein „Copilot-Export"-Button an den vier Modul-Flächen — Fabrikanalyse-Dashboard-
  Header (neben dem bestehenden „KI-Export"-Button), QAF-Vergleichs-Detail-Toolbar (neben dem
  bestehenden Excel-Export), Wertstrom-Editor-Toolbar (neben „Synchronisieren"), Projekt-Export-
  Seite (zwei Buttons, DOCX + XLSX) — jeweils hinter dem Flag ausgeblendet UND serverseitig
  abgelehnt. Neue geteilte `components/copilot-export/copilot-export-button.tsx` (generische
  Version von `components/qaf-differences/qaf-xlsx-export-button.tsx`, unterstützt DOCX- und
  XLSX-Mimetypes). Keine neuen Hex-Farben in den Buttons — bestehende Tailwind-Klassen/CSS-Vars
  wiederverwendet.
- **Tests:** 20 neue Unit-Tests (`lib/copilot-export/__tests__/`, 6 Dateien) — echte DOCX-
  Roundtrips (JSZip: `word/document.xml` gelesen und auf Kern-Strings geprüft, z. B. AI-
  Instruction-Heading, Glossar-Begriffe, Kerninhalte) und ein echter XLSX-Roundtrip (`exceljs`
  liest die erzeugte Datei wieder ein und prüft Sheet-Namen + Zellwerte), je Builder ein Flag-
  Gating-Test (`copilotExportsGate`) und ein `is_demo`-Disclaimer-Test in beide Richtungen.
- **Gates:** `tsc --noEmit` clean. `eslint` auf allen geänderten/neuen Dateien 0 neue
  Errors/Warnings (4 Warnungen in berührten Dateien sind alt, nicht in den eigenen Diff-Zeilen).
  Volle Suite (`npm run test`): 4362 grün, 0 rot, 119 skip (309/318 Testdateien) — keine
  Regression. `CHECK_FORBIDDEN_LEVEL=error npm run check:portability`: alle 8 Teilchecks clean
  (inkl. `check:profiles` wegen neuem Flag; `check:forbidden`/`check:hardcoded-colors` initial
  rot durch eigene Kommentare, die "BMW"/`#0066B1` nannten — umformuliert, jetzt clean).
  `SKIP_ENV_VALIDATION=true npm run build`: grün. `public/sw.js` nach dem Build zurückgesetzt
  (nicht committet).
- **Doku:** PRODUCT_SPEC.md (neue Sektion "Copilot-Exporte" unter Feature Set → Export) und
  UI_FLOWS.md (neue Sektion 11a) ergänzt — Feature-Parity-Regel. iOS: kein Parity-Aufwand in
  diesem PR, Web-only Demo-Feature, Flag bleibt in `default` aus.
## 2026-07-19 – Demo-067 B: Review-Fixes Pack-Isolation & VA-Klassifikation (KAR-981)

Adversariales Review von PR #345 bestätigte 7 Findings; alle gefixt:

- **Pack-Isolation der Legacy-Demo-Cleanups (kritisch):** Alle pauschalen
  `.eq('is_demo', true)`-Deletes auf `projects`/`process_steps`/`assessments`/
  `value_stream_maps` (Reset-API `app/api/demo/reset` + Legacy-`remove*` in
  `lib/demo/seeder.ts`) sind jetzt auf die geteilte dddd-Pool-Familie gescoped
  (`.in('id'|'project_id', …)`). Vorher hätte ein Reset einzelner Legacy-Module
  das ddd7-`top_management_demo`-Projekt per FK-CASCADE mitgelöscht (inkl.
  QAF/Wertstrom, ohne Storage-Cleanup). Die ddd7-Familie wird ausschließlich
  über das explizite Modul `top-management-demo` (removePack) entfernt.
- **Consultant-Stammdaten-Markierung:** Der bei Bedarf angelegte Roster-Eintrag
  "Sebastian Storch" trägt jetzt `is_demo: true` und die stabile ID
  `ddd70013-…` (`DEMO_TOP_MGMT_CONSULTANT_ID`); die Demo-User-Route
  `app/api/admin/demo` schließt diese ID aus ihren consultants-Sweeps aus
  (sonst hätte deren `maybeSingle()` ab 2 Demo-Zeilen zusätzlich einen Fehler
  produziert). Wird der Consultant später real: `is_demo` auf false setzen.
- **VA-Klassifikation korrigiert:** `VA_STEPS` hatte einen Off-by-one
  ({1,2,3,6,7} statt {1,2,3,5,6}) — 12 von 54 `cycle_measurements` trugen das
  falsche `time_type` (Montage als nicht wertschöpfend, EOL-Engpass als
  wertschöpfend). Neuer Konsistenz-Test bindet `time_type` an die VSM-`vaClass`
  je Station (154-s-Anker bleibt).
- **Test-Härtung:** Tautologie-Test durch echten Foreign-Row-Isolationstest
  ersetzt (fremde `is_demo`-Zeilen überleben removePack), neuer Test für die
  projektgescope­ten `qaf_*`-/`value_stream_imports`-Deletes, Voll-Pack-Idempotenz
  (2× seeden = identische Zeilenzahlen, 202/54-Anker), Sub-Seeder-Mocks prüfen
  jetzt Client-Instanz + userId statt `expect.anything()`.

## 2026-07-19 – Demo-067 B: KAR-981 Demo-Pack `top_management_demo`

Neues, in sich geschlossenes Demo-Pack für das komplette BMW-Top-Management-Demoprojekt
2026_067 ("Demo Electronics Systems GmbH", Body Domain Controller BDC-X3) — anders als die
bestehenden Packs (`lib/demo/seeds/*.ts`) EIN Projekt mit einer eigenen `ddd7...`-ID-Familie
(`lib/demo/demo-ids.ts`), nicht die geteilte `DEMO_PROJECT_IDS`-Pool. Alle Werte fiktiv
("Demo Project with Fictional Example Data"), Quelle: `brain/01-Projekte/demo-projekt-2026-067/`
(Seed-Content-Spec, 202 kuratierte Fabrikanalyse-Antworten, Demo-Story).

- **Neue Seed-Module:** `lib/demo/seeds/top-management-demo-seed.ts` (Projekt, Supplier,
  Consultants, Agenda inkl. 14 Items/8 Teilnehmer, 9 LSC-Prozessschritte + 54
  Zyklusmessungen + 9 Workshop-Erfassungen + 6 Schichten + 1 Kapa-Zeile, 10 LSC-Maßnahmen +
  6 Workshop-Actions, 1 Fabrikanalyse-Assessment), `lib/demo/seeds/top-management-demo-fa-responses.ts`
  (die 202 kuratierten Antworten 1:1 aus der Quelldatei transkribiert, gemappt über
  `assessment_questions.index_number`), `lib/demo/seeds/top-management-demo-qaf.ts` (QAF-
  Ingest+Compare+Persistenz für die beiden Demo-067-Workbooks aus KAR-980), `lib/demo/seeds/top-management-demo-vsm.ts` (Wertstrom-Direkt-Insert).
- **QAF-Ingest ohne actions.ts-Refactor:** `app/qaf-differences/actions.ts` (≈4750 Zeilen,
  G60/Multi-QAF/MATERIAL/SBM/RMR/LOGISTICS/LC-CN-Fassetten, die die beiden einfachen
  Standard-QAF-Demo-Workbooks nie berühren) bleibt unangetastet. Stattdessen ruft der Seeder
  die bereits öffentlich exportierten reinen Bausteine aus `@/lib/qaf-differences` direkt auf
  (`parseQafFile`, `buildBatchComparisons`, `buildComparisonRowset`, `buildManufacturingStepRow`,
  `buildSummaryMetricRows`) — laut deren eigenem Modul-Header war `parseQafFile` "never
  actually wired into this production ingest path", also kein Verhaltensrisiko für actions.ts.
  Ergebnis: qaf_file ×2, qaf_manufacturing_step ×18, qaf_part, qaf_comparison
  (`comparison_mode: 'summary'`, Titel "QAF-Vergleich BDC-X3: Award vs. aktuelle Kalkulation
  (Demo)"), qaf_step_match/qaf_manufacturing_diff/qaf_summary_diff/qaf_structure_change/
  qaf_plausibility_issue/qaf_root_cause — Award ist Baseline (älteres Angebotsdatum).
- **Wertstrom per Direkt-Insert:** die echte QVS-RPC `create_value_stream_from_qaf` verlangt
  `auth.uid()` und lehnt einen Service-Role-/Admin-Client (kein Session-Kontext) mit
  `unauthorized` ab — bestätigt durch Lesen der Migration. Direkter Insert
  `value_stream_maps` + `value_stream_imports` (Spec-Fallback), CT/OEE/Bediener aus den
  bereits geseedeten LSC-Daten abgeleitet (kein Drift zwischen QAF/LSC/Wertstrom). 17 Nodes
  (2 Supplier/Customer + 9 Prozess + 6 Inventory), 16 Connections, DLZ-Anker 9,8 Tage
  (Σ Inventory-quantity ÷ 2000 Stk/Tag), VA-Zeit 154 s, EOL als Engpass markiert
  (`customer_takt_time_sec: 40.5`).
- **Schutz-Check:** Seed bricht ab, wenn bereits ein Projekt mit `project_code 2026_067`
  unter einer ANDEREN ID existiert (nie ein echtes Projekt überschreiben); ist es die eigene
  Demo-ID, läuft ein idempotentes Update.
- **removePack löscht ausschließlich eigene Daten:** scoped auf die eigene `ddd7...`-Projekt-ID
  bzw. eigene stabile IDs — nie ein pauschales `.eq('is_demo', true)` (würde fremde
  Demo-Packs treffen). Der bei Bedarf neu angelegte Consultant "Sebastian Storch" sowie der
  Supplier "Demo Electronics Systems GmbH" sind Stammdaten (Lookup-or-Create) und werden
  beim Reset NICHT gelöscht.
- **Wiring:** neuer Pack-Code `top_management_demo` in `DemoPackCode`/`seedPack`/`removePack`
  (`lib/demo/seeder.ts`), neues `DemoModule` `top-management-demo` (Label
  "Top-Management-Demo 2026_067") in `lib/demo/demo-service.ts` + `ALL_DEMO_MODULES` — erscheint
  automatisch in der Admin-Demo-Verwaltung (`components/demo/demo-control-panel.tsx`, unverändert);
  `app/api/demo/load/route.ts` (generische Pack-Dispatch-Map ergänzt), `app/api/demo/reset/route.ts`
  (neuer Zweig ruft `removePack` statt eigener Inline-Logik), `app/api/demo/status/route.ts`
  (Status-Zähler auf die eigene Projekt-ID).
- **Tests:** Daten-Modul-Tests (Anzahlen/Anker: 202/3 n/a/26-50-98-25-Verteilung, 14
  Agenda-Items, 8 Teilnehmer, 9 Steps, 10 Measures, 6 Actions, 17 VSM-Nodes/16 Connections,
  DLZ 9,8 Tage, VA 154 s), Seeder-Tests mit In-Memory-Fake-Client (Schutz-Check,
  Konsultanten-Lookup-or-Create-Idempotenz, fehlende `assessment_questions.index_number`
  werden gesammelt statt still übersprungen, removePack-Scoping), echter QAF-Ingest-
  Roundtrip-Test (`parseQafFile`/`buildBatchComparisons` gegen die tatsächlich generierten
  Workbooks, In-Memory-Fake-DB, keine echte Datenbank).
- **Bekannte Abweichungen von der Content-Spec** (Details in der PR-Beschreibung):
  Spalte heißt `erstbesuch_calendar_created` (nicht `calendar_created`); Agenda-Item-9-
  Beschreibungstext auf das kanonische Netto-HK-Delta +0,75 €/Stück korrigiert (Spec-Text
  nannte noch den überholten Richtwert +0,29 €/Stück aus einer älteren Fassung);
  `value_stream_imports`-Migration konnte nicht live gegen die Ziel-DB verifiziert werden
  (Datei-Header einer früheren Fassung markierte sie als "nicht angewendet" — nach dem
  QVS-P1..P7-Rollout im Git-Log aber mit hoher Sicherheit längst angewendet).

## 2026-07-19 – Demo-067 A: KAR-980 QAF-Demo-Workbook-Generator

Deterministischer ExcelJS-Generator (`lib/demo/qaf-demo-workbooks.ts`) für zwei fiktive
Standard-QAF-Workbooks (Award 2025-02, Current 2026-06) des BMW-Top-Management-Demoprojekts
2026_067 ("Demo Electronics Systems GmbH", Body Domain Controller BDC-X3). Format-Wahrheit
ausschließlich aus committetem Parser-Code abgeleitet (`lib/qaf-parser.ts` TEMPLATE_HEADERS,
`lib/qaf-differences/internal/summary-parser.ts` FIELD_DEFS, `summary-metrics.ts`
TEMPLATE_CONFIG, `template-fingerprint.ts`, `qaf-type-detector.ts`) — `/home/aria/work/qaf-corpus`
wurde für dieses PR nicht gelesen; alle Werte sind fiktiv aus der Demo-Story.

- **Fertigungskosten-Blatt:** exakt die 22 `TEMPLATE_HEADERS`-Spalten, 9 Prozessschritte
  (Wareneingang → Verpackung), FEK/FK/Ausschusskosten formelkonsistent aus Zykluszeit ×
  Lohnsatz/SGK/MSS/RFGK abgeleitet (Formeln im Modul-Header dokumentiert).
- **Zusammenfassung-Blatt (QAF_LEGACY_DE_SUMMARY):** Identität (Sachnummer, Lieferant,
  Angebotsdatum, Variante, …) über die primären A1-Anker; alle 17 erforderlichen
  SUMMARY_METRIC_KEYS über Label-Spalte G + Wert-Spalte P; Kosten-Kaskade
  (`totalProductionCosts`/`totalCosts`/`quotationBasePrice`/`quotationPrice`) 1:1 aus
  `reconciliation.ts`s `CASCADE_CHECKS` übernommen (nicht neu erfunden).
- **5 Management-Deltas Award→Current:** Montage-Zykluszeit +6s, EOL-Zykluszeit +8s,
  Ausschuss-Verbesserung durch AOI-Upgrade (AOI/Montage/EOL), Materialkosten −4%
  (7,80→7,49 €/Stück), Invest Testautomatisierung +850.000 € über die `oneTimeTools`-Metrik
  ("Sonderbetriebsmittel") — kein separates SBM-Blatt nötig (additiv, `sbm_detail_sum`
  bleibt ohne SBM-Sheet außen vor). Netto-Herstellkosten-Delta landet formelbedingt bei
  ≈ +0,75 €/Stück statt der Story-Headline +0,29 €/Stück (dokumentierte Abweichung —
  Konsistenz mit den Story-Bedienerzahlen/Satzbandbreiten schlägt Punktlandung).
- **Multi-QAF-/G60-Fehlklassifikation vermieden:** kein "M-QAF Version"-Marker, keine
  Spalten-Population in Band S..CB der Zusammenfassung, kein MATERIAL-Blatt — alle vier
  `qaf-type-detector.ts`-Signale bleiben `none`.
- **Tests (`lib/demo/__tests__/qaf-demo-workbooks.test.ts`, 21 Fälle):** Real-Roundtrip durch
  `parseQAFTemplate`/`parseSummarySheet`/`buildTemplateFingerprint`/`detectMultiQaf`/`detectG60`/
  `compareQafPair`/`checkReconciliation` — Klassifikation `known`/`QAF_LEGACY_DE_SUMMARY`,
  0 kritische Plausibilitäts- und Rekonziliations-Findings, alle 5 Deltas sichtbar,
  Zeilen-FK-Summe ≈ `manufacturingCosts`, `quotationPrice` = Summe der Kaskaden-Komponenten.

## 2026-07-18 – KAR-978: documents_select-Duplikat gedroppt, Isolations-Test geflippt (PR, Merge nach Operator-Apply)

Befund: Auf `public.documents` neutralisierte die ungescope-te Alt-Policy `documents_select`
(`USING (is_deleted = false)`, `supabase-bootstrap-from-prod.sql:4860`) die beabsichtigte,
scope-geprüfte `docs_select` (`supabase-migration-cleanup.sql:577`) per OR-Verknüpfung
permissiver Policies — Postgres OR-kombiniert alle permissiven Policies derselben Aktion,
effektiv galt SELECT auf `documents` nur „nicht gelöscht"; `access_scope='project'` schützte
real nichts. Jeder authenticated User las projektübergreifend alle nicht-gelöschten Dokumente.
Erstmals dokumentiert als „KNOWN GAP" im KAR-788-Harness (PR #342), dort bewusst nicht gefixt
(RLS-Changes brauchen Operator-Sign-off). Kais hat das Operator-Paket (Migration + Rollback +
APPLY.md) am 2026-07-18 erhalten und ~20:56 CEST operator-seitig appliziert — Prod-Verifikation
gleiche Minute per `pg_policies`: `documents_select` weg, nur noch `docs_select` +
`documents_own`/`documents_admin`. **Prod-Status: APPLIED.**

- **Migration ins Repo:** `supabase/migrations/supabase-migration-documents-select-fix.sql`
  (droppt `documents_select`, idempotent, editor-tauglich, `BEGIN/COMMIT`) +
  `…-documents-select-fix-rollback.sql` (stellt die Alt-Policy exakt wieder her, Notfall-Pfad).
  Byte-identisch zur kanonischen Vorlage aus dem Operator-Paket — kein Drift zwischen den
  beiden Kopien (verifiziert per `diff`).
- **MIGRATIONS.md:** neue Sektion §7p (Eintrag #112) nach dem etablierten Security-Fix-Muster
  (analog §7l/§7m/§7n/§7o), Rollback R37 in der Rollback-Tabelle §7, Querverweis-Satz im
  bestehenden Tranche-B-2-Eintrag (#93), der bisher „SELECT via docs_select unberührt" sagte,
  ohne die spätere Entfernung zu erwähnen.
- **`scripts/rls-test/run.sh`:** neue Migration in die Apply-Kette eingehängt, direkt nach
  Tranche B-2 (KAR-777) und vor der OEE-v2-Kette — damit das Harness den Ziel-Zustand
  (documents_select entfernt) lädt. Stale gewordener Kommentar direkt daneben („SELECT is a
  separate, still-open finding") korrigiert (verwies auf genau die Lücke, die die neue Zeile
  direkt darunter schließt).
- **`__tests__/security/rls-user-scoped.test.ts` geflippt:** der `documents`-KNOWN-GAP-Fall
  (`user_b can currently SELECT user_a's project-scoped document`, Assertion `.toBe(1)`) wurde
  zu einem normalen Isolations-Fall (`user_b sees an empty set (real isolation — KAR-978 dropped
  the documents_select duplicate)`, Assertion `.toBe(0)`), Titel + Datei-Header
  (ESCALATION → RESOLVED-Notiz, Historie kurz erhalten) + der direkt angrenzende
  describe-Block-Kommentar aktualisiert. Sonst nichts an der Datei verändert.
- **Real-Beweis (embedded-postgres, PG 17.10, Port 55432, TCP-only, Datadir im Scratchpad, Port
  5432/caddy unberührt):** volle `scripts/rls-test/run.sh`-Kette (bootstrap-prerequisites →
  bootstrap-from-prod → self-update-guard → guard-department → tranche-b2 → **KAR-978-Fix** →
  oee-v2-schema → oee-records-rls-tightening → oee-v2-rls-consultant-orphan-fix →
  qaf-differences → qaf-process-mappings(-rls-fix) → pmo-phase-1 → drop-dead-pmo-tables →
  pmo-team-write-policies → offline-sync-updated-at → value-stream-imports → setup.sql) frisch
  geladen, `documents`-Policy-Stand direkt verifiziert (`docs_select` SELECT,
  `documents_own`/`documents_admin` ALL — `documents_select` weg), dann die komplette RLS-Suite
  (alle 9 `scripts/rls-test/run.sh`-Testdateien) einmal sauber gegen die frische DB gelaufen:
  **119/119 grün**, inkl. des geflippten `documents`-Falls einzeln per Name bestätigt (`user_b
  sees an empty set (real isolation — KAR-978 dropped the documents_select duplicate)`).
  Postgres danach `pg_ctl stop -m fast`, Port 55432 frei.
- **Volle Suite ohne RLS-DB** (`npx vitest run --exclude "**/.claude/**"`, `RLS_TEST_DATABASE_URL`
  unset): **4342 grün, 0 rot, 119 skip** (303/312 Testdateien) — identisch zur KAR-788-Baseline,
  keine Regression (der Fix ändert nur eine Assertion innerhalb eines bereits vorhandenen,
  env-gated Testfalls, keine neuen/entfernten Testfälle).
- **Gates:** `tsc --noEmit` clean. `eslint` auf dem eigenen Diff (`rls-user-scoped.test.ts`)
  clean, 0 Meldungen. `check:profiles`: 3/3. `CHECK_FORBIDDEN_LEVEL=error check:portability`:
  alle 8 Teilchecks clean (profiles/forbidden/boundaries/openapi/csp/secrets/hardcoded-colors/
  bmw-fonts).
- **Merge-Hinweis:** Operator-Apply auf Prod wurde vor dem Merge bestätigt und verifiziert
  (2026-07-18 ~20:56 CEST, `pg_policies`-Nachweis) — Repo-Katalog (§7p) und Prod sind damit
  synchron gemerged.

## 2026-07-18 – RLS-Isolation real für 7 user-scoped Tabellen — KAR-788 reopened, keine it.todo-Stubs mehr

KAR-788 wurde nach dem P7-Review reopened: `rls-user-scoped.test.ts` enthielt 36 unbedingte
`it.todo()`-Stubs für 9 Tabellen. `projects`/`process_steps` waren bereits real getestet
(R61/vorherige KAR-788-Teillieferung); die echte Lücke waren 5 Tabellen ohne jeden Test
(`cycle_measurements`, `user_profiles`, `assessments`, `oee_records`, `documents`) + 2 nur per
Analogie-Anspruch abgedeckte (`qaf_uploads`, `workshop_actions`).

- **Policies zuerst real gelesen, nicht angenommen** (`supabase/bootstrap/supabase-bootstrap-from-prod.sql`
  + relevante Migrationen), nicht das veraltete `supabase-schema.sql`-Muster kopiert:
  - `cycle_measurements` / `qaf_uploads` / `workshop_actions`: einfacher `_own`-Projekt-Join
    (`cycle_measurements` 2-Hop über `process_steps`), **kein** `_admin`-Override — getestet, dass
    Admin hier wie jeder Nicht-Owner behandelt wird (keine Bypass-Policy erfunden).
  - `user_profiles`: SELECT bewusst breit (`up_active_list`/`up_select` — geteiltes Verzeichnis, wie
    `consultants` andernorts), WRITE owner-oder-admin. Koexistiert mit dem KAR-770/KAR-785
    Self-Update-Guard-Trigger (getestet: role_id/account_status/department_id-Self-Change wirft eine
    Exception, harmlose Felder + Admin-Writes gehen durch).
  - `assessments`: `created_by`-Ownership, **echte** volle Isolation + Admin-Override (keine
    Department-/Released-Sichtbarkeit betrifft diese Tabelle — verifiziert, nicht nur angenommen).
  - `oee_records`: SELECT geteilt (`oee_select USING(true)`, bestätigt beabsichtigt laut
    `supabase-migration-demo-shared-read-rls.sql`). WRITE: Creator/Projekt-Owner/Orphan-Creator/Admin
    **und** — laut einem bewussten 2026-05-12-Hotfix — jeder User mit Rolle `consultant` (Team-Write
    nach demselben "Option B"-Muster wie PMO). Getestet: ein echter Außenstehender (kein Profil) wird
    blockiert; ein `consultant`-Nutzer und Admin dürfen schreiben.
  - `documents`: WRITE korrekt scoped (`documents_own`/`documents_admin`, `uploaded_by` ODER
    Projekt-Owner). **SELECT NICHT** — siehe Eskalation unten.
- **ESKALATION (echtes, ungefixtes Sicherheitsloch gefunden, hier NICHT gefixt):** `documents` hat
  zwei parallele permissive SELECT-Policies — die beabsichtigte `docs_select` (Scope-geprüft) UND
  die ältere, nie entfernte `documents_select USING (is_deleted = false)` (keinerlei Scope-Check).
  Postgres OR-kombiniert permissive Policies derselben Aktion, d.h. die effektive SELECT-Policy auf
  `documents` ist heute schlicht "nicht gelöscht" — `access_scope='project'` schützt real NICHTS.
  `supabase-migration-using-true-write-tranche-b2.sql` (KAR-777) hat INSERT/UPDATE/DELETE gefixt,
  SELECT aber bewusst unangetastet gelassen (Kommentar nennt beide Policy-Namen). Kein
  `rls-open-decisions.md`-Eintrag trackt das als akzeptierten Trade-off, anders als die
  Geschwister-Lücken auf `document_metadata`/`document_departments`/`document_suppliers` (D-4/D-5/D-6).
  Test dokumentiert das reale (unerwünschte) Verhalten statt eine falsche Isolation zu behaupten —
  kippt automatisch rot, sobald `documents_select` mal gedroppt/verschärft wird. **Braucht
  Operator-Entscheidung + Staging-Verifikation, nicht in diesem PR gefixt.**
- **Harness-Korrektur:** `scripts/rls-test/run.sh`s Migrationskette hatte reale Lücken relativ zu
  echtem Prod — ergänzt um `supabase-migration-user-profiles-self-update-guard.sql` +
  `…-guard-department.sql` (KAR-770/785, Trigger fehlte im 2026-05-07-Dump),
  `…-using-true-write-tranche-b2.sql` (documents_own/_admin) und die reale OEE-Kette
  `…-oee-v2-schema.sql` → `…-oee-records-rls-tightening.sql` → `…-oee-v2-rls-consultant-orphan-fix.sql`.
  **Bewusst NICHT** `supabase-migration-kadi-v2-rls-oee.sql` (MIGRATIONS.md #47) — Gegenprobe zeigt,
  dass diese frühe Closure-Migration nie real angewendet wurde (spätere Dateien vom selben Tag
  beschreiben denselben Bug noch als offen) und mit der real angewendeten Kette hart kollidieren
  würde (identische Policy-Namen ohne vorheriges DROP). MIGRATIONS.md selbst dokumentiert diese
  spätere Kette gar nicht (Doku-Lücke, kein Sicherheitsproblem) — nicht in diesem PR behoben, da
  MIGRATIONS.md außerhalb des KAR-788-Scopes liegt.
- **Docker-frei real gelaufen** (kein Docker in dieser Umgebung nutzbar): npm-Paket
  `embedded-postgres` (PG 17.10, TCP-only Port 55432, Datadir im Scratchpad, Port 5432/caddy
  unberührt) als devDependency ergänzt. Volle RLS-Suite (alle 9 `__tests__/security/rls-*`- +
  `rpc-*`- + `sync-updated-at-schema`-Dateien) zweimal frisch geladen + real gelaufen: **119/119
  grün** (vorher 67 real; 52 neue Fälle in `rls-user-scoped.test.ts`, 0 verbleibende `it.todo`).
- **Volle Suite ohne RLS-DB:** 4342 passed, 119 skipped (vorher 67 skip + 36 todo — die 52 neuen
  Fälle wandern ehrlich von "todo" zu "skip", da `it.todo()` durch echte, env-gated `it()` ersetzt
  wurde; keine Regression in den 4342 sonstigen Tests).
- Fixtures in `scripts/rls-test/setup.sql` ergänzt (admin- + consultant-Testidentität inkl.
  `roles`/`department_master_data`, je eine Fixture-Zeile pro neuer Tabelle) — bestehende 67 Fälle
  bleiben unverändert grün.
- Gates: `tsc --noEmit` clean, `eslint` auf eigenem Diff clean, `check:profiles` clean,
  `CHECK_FORBIDDEN_LEVEL=error check:portability` clean (alle 8 Teilchecks).

## 2026-07-18 – QVS-P7: Flag-ON (bmw + default) — Abschlussbericht, 40 Acceptance Criteria, Rollback-Runbook

Baut auf P0-P6 (KAR-970/971/972/973/974/975, gemergt #334-#340) auf. Deliverable aus KAR-976
(Programm-Epic KAR-968): Flag-Flip nach P6-Korpus-Validierungs-Evidenz (Kais-Go), finaler deutscher
Abschlussbericht (Spec §39), alle 40 Acceptance Criteria (Spec §40) einzeln verifiziert,
Rollback-Runbook. Kein Merge.

- **Flag-Flip:** `qafValueStream: false → true` in `config/profiles/default.ts:35` und
  `config/profiles/bmw.ts:40`. `config/profiles/_template.ts:37` bleibt unverändert `false` — kein
  reales Deployment (`loadProfile()` verweigert das Booten mit `_template` explizit), dient nur als
  Kopiervorlage für künftige Kundenprofile; kein drittes produktives Profil existiert im Repo.
- **Rollback-Runbook** (`docs/runbooks/qaf-value-stream-rollout.md`, neu, analog zu
  `docs/runbooks/multi-qaf-rollout.md`): Zwei-Zeilen-Revert, vollständiger Persistenz-Nachweis (Flag
  gated nur UI/Actions, nie Daten), und — erstmals für QVS präzise dokumentiert — das reale
  UI-Restverhalten nach einem Rollback: neue Entry-Points verschwinden serverseitig sauber, die „aus
  QAF · …"-Kennzeichnung in der `/wertstrom`-Liste blendet aus, aber der VSM-Editor zeigt für bereits
  importierte Wertströme weiterhin Herkunfts-Marker/„Quelle anzeigen"/vaClass-Auswahl (Node-Daten-Gate,
  nicht Flag-Gate) — der „Synchronisieren"-Button bleibt sichtbar, aber funktionslos (Server Action
  lehnt mit `qaf_value_stream_disabled` ab). Verifikations-Schritte nach Rollback dokumentiert.
- **40 Acceptance Criteria** (`reports/qaf-value-stream-acceptance-criteria.md`, neu): jedes
  Kriterium aus Spec §40 einzeln mit Status + Beleg. Methodik-Hinweis: der rohe Spec-Text enthält
  mechanisch 32 Kriterien-Sätze, nicht 40 — 5 Sätze wurden in atomare, in der Spec selbst bereits
  angelegte Unterkriterien aufgeschlüsselt (netto +8 Zeilen; z. B. „Formel" + „Datenbasis" getrennt,
  5 Einzel-Gates statt 1 Sammel-Gate), transparent hergeleitet statt stillschweigend gerundet.
  Bilanz: **29 erfüllt,
  11 erfüllt mit Grenze, 0 offen** — jede Grenze ist eine bereits dokumentierte Daten-/
  Scope-Entscheidung oder der ausstehende Browser-Live-Test.
- **Finaler deutscher Abschlussbericht** (`reports/qaf-value-stream-final-report.md`, neu, Struktur
  A-H nach Spec §39): Executive Summary, Ist-Analyse, Architektur (inkl. zweier Kais-Entscheide, s.
  u.), UX, Feld-Mapping, Testergebnisse, Vorher/Nachher, 11 Limitationspunkte ohne Beschönigung.
- **Kais-Entscheid 1 (18.07.2026 18:09, TG 8901):** die `vsm_own`-OR-RLS-Semantik (`created_by`
  ODER Projekt-Owner, repliziert in `value_stream_imports_own`) bleibt Bestandsmuster — bewusst
  geprüft, kein Verschärfungs-Follow-up. Begründung: Konsistenz mit `value_stream_maps`-Bestand,
  App-/RPC-Schicht erzwingt Projekt-Scope zusätzlich (E4).
- **Kais-Entscheid 2 (P6-Folge):** kein Prozess-Count-Ground-Truth-Datensatz wird gebaut — bewusste,
  dokumentierte Limitation. Begründung: der Preview-Dialog macht Erkennungslücken vor jedem Import
  sichtbar; eine Nachmessung erfolgt erst bei konkretem BMW-Zweifel an einer bestimmten Datei, nicht
  pauschal im Voraus.
- **PRODUCT_SPEC.md** (Zeile 347): Status-Satz korrigiert — war „`false` in all 3 profiles", ist
  jetzt akkurat für den neuen Flag-Zustand (Feature-Parity-Regel, minimal gehalten). `UI_FLOWS.md`
  unverändert gelassen — dort wird an keiner Stelle ein konkreter Flag-Wert behauptet, der jetzt
  falsch wäre. `lib/qaf-value-stream/README.md`/ADR-024 enthalten nach dem Flip veraltete „false
  everywhere"-Sätze — bewusst NICHT angefasst (außerhalb des in KAR-976 vorgegebenen Doku-Scopes),
  hier transparent vermerkt statt stillschweigend belassen.
- **Gate-Ergebnisse (nach dem Flag-Flip, 2026-07-18):** `typecheck` clean, 0 Fehler. `eslint`
  (Diff-Scope, 7 Dateien: 2 `.ts` + 5 `.md`): **0 Fehler**, 5 Warnungen (je 1× „file ignored because
  no matching configuration was supplied" pro `.md`-Datei, kein Lint-Ziel). Voll-Suite `npx vitest
  run --exclude "**/.claude/**"`: **4342 grün, 0 rot, 67 skip, 36 todo** (303/312 Testdateien) —
  identisch zur P5/P6-Baseline; der Flip ändert keinen Testpfad (kein Test importiert
  `config/profiles/{default,bmw}.ts` ungemockt mit einer hartcodierten `qafValueStream`-Erwartung,
  verifiziert per Grep vor dem Gate-Lauf — `qaf-actions.test.ts` mockt `getProfile()` lokal und deckt
  beide Flag-Zustände bereits unabhängig vom realen Profil-Wert ab). `check:profiles`: 3/3.
  `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean.
- **Review-Zählung, transparent gemacht:** die adversarielle Review-Historie über P1-P6 (6 Runden:
  P1 4, P2 10, P3 9, P4 11, P5 13, P6 7 CONFIRMED Findings, 0 refuted) summiert sich nachgezählt auf
  **54**, nicht auf einen zuvor kursierenden Wert von 55 — Differenz von 1, direkt aus den
  CHANGELOG-Einträgen der jeweiligen Phasen nachvollziehbar (P0 hatte zusätzlich eine eigene
  Review-Runde mit „10 Fakten-Korrekturen", aber ohne überlebendes CONFIRMED/refuted-Zählschema im
  heutigen Text — bewusst nicht in diese 6er-Summe gemischt, um keine unterschiedlichen
  Zählverfahren zu vermengen). Siehe Abschlussbericht §F für die vollständige Aufschlüsselung.
- **Ehrliche Restpunkte:** Browser-End-to-End gegen die jetzt Flag-ON geschaltete Konfiguration wurde
  nie gefahren (ausstehender operator-seitiger Live-Test nach Merge) — vollständige Liste in
  `reports/qaf-value-stream-final-report.md` §H.
- **Review-Fixes (adversarialer Review von PR #341, 13 Agenten über 3 Dimensionen —
  ac-honesty/report-accuracy/flip-diff-safety —, mit Verify-Stufe): 8 CONFIRMED, 2 refuted, 0
  unverified — alle 8 gefixt in diesem Durchlauf.** Kern: AC-Beleg-Präzision beim
  RLS-9-Tabellen-Überclaim und bei der Template-Familien-Zählung; mehrere Doku-Stellen, die der
  P7-Ersteintrag oben nur als „bewusst veraltet belassen" offenlegte, sind jetzt im PR direkt
  aktualisiert statt weiter veraltet zu bleiben. (1) MAJOR — AC-21 (`acceptance-criteria.md:174-177`)
  sowie wortgleich `unresolved-limitations.md` §1.3, `qaf-value-stream-regression-results.md` §3 und
  `final-report.md` (§F/§H) behaupteten pauschal, nutzerskopierte RLS-Isolation sei für alle 9 in
  `rls-user-scoped.test.ts` gelisteten Kerntabellen „nie real getestet" — das widersprach der
  67/67-Evidenz zwei Sätze zuvor. Präzisiert: `projects` (`rls-projects-isolation.test.ts`, R61) und
  `process_steps` (`rls-project-children-isolation.test.ts`, R61/KAR-788-Teillieferung) sind real
  getestet; `qaf_uploads`/`workshop_actions` sind laut `scripts/rls-test/setup.sql` nur
  „representative" (Analogie, kein eigener Test) abgedeckt; die echte Rest-Lücke sind 5 Tabellen
  ohne jede Testdatei (`cycle_measurements`, `user_profiles`, `assessments`, `oee_records`,
  `documents`). KAR-788 lieferte den `process_steps`-Teil und ist für den Rest reopened — alle 4
  Fundstellen konsistent gefixt, inklusive der beiden main-Dateien (`unresolved-limitations.md`,
  `qaf-value-stream-regression-results.md`), die zusätzlich auf diesem Branch mitgefixt wurden,
  obwohl sie nicht im ursprünglichen PR-Diff lagen. (2) MAJOR — AC-02 (`acceptance-criteria.md:56-61`)
  zitierte „8 Template-Familien funktionieren über die Capability-Schicht" als Beleg für
  sheet-namen-unabhängige Erkennung; real erreichen nur 4 (`QAF_LEGACY_DE_SUMMARY`, `QAF_V9_SUMMARY`,
  `81_custom`, `mqaf_v2`) die Capability-Schicht mit eligible Schritten — 3 scheitern vorgelagert am
  Parse (0 %, capability-unabhängig), 1 (`mqaf_v1`) parsed aber ohne eligible Schritte (fehlender
  Prozessname in der Quelle, keine Erkennungslücke). Beleg-Satz präzisiert, Status „erfüllt" bleibt
  (die Kernbehauptung — semantische Erkennung statt Sheet-Name — trägt weiterhin, jetzt ohne
  Überzeichnung der Breite). (3) MAJOR — Browser-E2E-Lücke fehlte in `final-report.md` Abschnitt F
  (Testergebnisse) und G (Vorher/Nachher), obwohl Abschnitt A/H sie als „zentralen Punkt"
  bezeichnen — in beiden Abschnitten jetzt explizit als offener Verifikationsschritt ergänzt. (4)
  MAJOR — AC-39 (`acceptance-criteria.md:278-282`, Status „erfüllt" ohne Einschränkung) stützte sich
  auf ADR-024/README.md, die im selben PR als „false everywhere" (veraltet) eingeräumt wurden; über
  Fix (5)+(6) behoben: Beleg jetzt auf die aktualisierten Stellen gestützt, Status bleibt „erfüllt",
  weil kein zitiertes Dokument mehr eine falsche Aussage trägt. (5) MAJOR — `MIGRATIONS.md` §7o/R36
  behauptete nach dem Flip fälschlich „no app code path can reach this table today" — aktualisiert
  auf den P7-Stand (Flag `true` in `default`+`bmw`, Zugriff über die flag-gated QVS-Server-Actions),
  historischer P2-Kontext bleibt per „Stand P7:"-Zusatz erhalten statt umgeschrieben. (6) MAJOR —
  `lib/qaf-value-stream/README.md` (4 Stellen) und die ADR-Registry-Zeile (`docs/adr/README.md:37`)
  enthielten dieselbe veraltete „false everywhere"-Aussage — beide jetzt auf den P7-Stand
  aktualisiert; ADR-024 selbst bekam nur einen datierten Status-Nachtrag (ADR-Konvention: kein
  Rewrite historischer Entscheidungen). (7) MINOR — AC-12 (`acceptance-criteria.md:117-123`)
  zitierte `gap-analysis.md` „Nicht-Gaps" als Vorab-Beleg für die fehlenden
  Duplizieren/Gruppieren/Splitten/Zusammenführen-Operationen; diese vier stehen dort nicht (0
  Treffer) — Beleg korrigiert, die Limitation wird jetzt korrekt als hier erstmals benannt geführt
  (dieselbe Korrektur auch in `final-report.md` Punkt 9, wortgleiche Fundstelle). (8) MINOR —
  `final-report.md`s Quellenliste zitierte `MIGRATIONS.md` §7o ohne Staleness-Hinweis, während
  README/ADR-024 im CHANGELOG-Text als „bewusst veraltet" offengelegt waren; durch Fix (5)+(6) sind
  jetzt alle zitierten Quellen aktuell — Quellenliste entsprechend mit Review-Fix-Anmerkungen
  versehen. **2 refuted** (nicht gefixt, sachlich widerlegt): die Behauptung, P0 habe nie eine
  adversarielle Review-Runde gehabt (PR #334 hatte nachweislich eine, „10 Fakten-Korrekturen" wörtlich
  im Commit-Titel bestätigt); und die Datum/Kanal-Asymmetrie zwischen Kais-Entscheid 1 und 2 (reale,
  aber stilistische Präzisions-Inkonsistenz, keine falsche Aussage, keine AC betroffen). Keine
  AC-Einstufung kippte von „erfüllt"/„erfüllt mit Grenze" auf „offen" durch diese Fixes — Bilanz
  bleibt **29 erfüllt / 11 erfüllt mit Grenze / 0 offen**. Diese Fixes ändern ausschließlich
  `.md`-Dokumentation (8 Report-/Doku-Dateien, 0 Produkt-Code) — Gates nach allen Fixes erneut
  gelaufen, Zahlen identisch zum „Gate-Ergebnisse"-Absatz oben (kein Test-/Code-Pfad berührt).

## 2026-07-18 – QVS-P6: Korpus-Validierung — dev-Kalibrierung, validation-Metriken, Regression, 4 Reports (Flag weiterhin false, keine Produkt-Code-Änderung)

Baut auf P1-P5 (KAR-970/971/972/973/974, gemergt #339) auf. Deliverable aus KAR-975 (Programm-Epic KAR-968): repräsentative Validierung der QAF→Wertstrom-Pipeline vor einem möglichen Flag-Flip (P7 braucht Kais' explizites Go). Kein Merge.

- **Neues, read-only Messwerkzeug** (`~/work/qaf-corpus/tools/p6-vsm-corpus-validate.ts`, außerhalb kadi-v2, gleiche Konvention wie `batch-runner.ts`/P1 — keine Supabase-Persistenz, keine Produkt-Code-Änderung in kadi-v2): fährt jede Split-Datei durch dieselbe Pipeline wie die echte App (`parseQAFTemplate` → `assessManufacturingCapability`/`mapQafRowsToVsmNodes` → `buildPreviewFromRows`, die DB-freie Hälfte von `buildQafValueStreamPreview`).
- **dev-Split (227, Kalibrierung):** parseOk 91,2 % (207/227), capabilityEligible 96,1 % (199/207 von parsed), previewOk 100 % (207/207, 0 Mapper-/Preview-Fehler), Sequenz-Genauigkeit 98,5 % (`row_order`). Reproduziert exakt die P1-Baseline (207 geparst/20 Fehler/1472 Zeilen; 91,4 % Zykluszeit bei P1s Nenner-Wahl = 96,8 % bei P6s eligible-Nenner, Zähler identisch 1346 — vollständig aufgelöst, kein Widerspruch).
- **validation-Split (70, berichtete Metriken):** parseOk 91,4 % (64/70), capabilityEligible 96,9 % (62/64), previewOk 100 % (64/64), Sequenz-Genauigkeit 100 %. Alle Kennzahlen liegen innerhalb ±1,5 Prozentpunkten der dev-Kalibrierung — keine systematische Verzerrung zwischen den Splits.
- **Feld-Coverage je der 12 `PRIMARY_FIELD_MAPPINGS`:** 11 von 12 Feldern zwischen 78-99 % (step-weighted); `ruestkosten` (Rüstkosten) fällt in beiden Splits konsistent auf 19,7 %/21,4 % — Root-Cause-Check (1117 `missingFieldReasons`-Einträge aufgeschlüsselt) bestätigt **100 % `cell_empty`, 0 % `no_column_mapped`**: kein Parser-Bug, echte Quelldaten-Eigenschaft (aktiv ausgeschlossen, nicht nur vermutet).
- **VA-Klassifikation aktiv nachgemessen:** 0/1391 (dev) und 0/415 (validation) Nodes je automatisch `va` — bestätigt das konservative Design (`va-classification.ts`) empirisch über den vollen Korpus.
- **Familien-Schnitt** (Fingerprint-Modul-Klassifikation: Grobklassifikation aus der bestehenden Split-Stratifizierung, Multi-QAF-Feinfamilie `mqaf_v1`/`mqaf_v2`/`81_custom` live nachberechnet über `fingerprintMultiQafTemplateFromExcelJs`): 8 Familien in dev, 4 in validation. `Standard: kein Profil (none)` und `G60_DETAIL` strukturell 0 % parseOk (erwartet, keine Regression — außerhalb des Templates, das `parseQAFTemplate` versteht).
- **Worst-Performer (Top 5, validation, Ranking „missing-value mass"):** `ruestkosten=0%` ist in 5/5 Fällen die schärfste Einzelursache (Review-Fix: Text zuvor fälschlich „4/5", obwohl die eigene Tabelle bereits alle 5 Zeilen mit `ruestkosten=0%` auswies) — Familien-übergreifendes, reproduzierbares Muster, kein datei-individueller Zufall (der dev-Split zeigt an seiner eigenen Spitze dasselbe).
- **Regression:** Voll-Suite `npx vitest run --exclude "**/.claude/**"` reproduziert exakt die main-Baseline: **4342 grün, 0 rot, 67 skip, 36 todo** (identisch zum dokumentierten P5-Stand). Zusätzlich 6 zentrale `*.real-files.test.ts`-Pflichttests (`mapper`, `sync`, `preview`, `summary-parser-qvs-file-level-fields`, `template-fingerprint-known-calibration`, `detect-g60-corpus-sweep`) einzeln verbose gegen den echten Korpus verifiziert — alle grün, Konsolen-Zahlen identisch zu den jeweils dokumentierten Vorphasen-Baselines UND zum neuen P6-Tool (zwei unabhängige Code-Pfade, dieselben Rohzahlen).
- **4 Reports:** `reports/qaf-value-stream-validation-results.md` (Kernmetriken), `reports/qaf-value-stream-test-results.md` (Evidenz-Ledger), `reports/qaf-value-stream-regression-results.md` (Bestandsschutz-Nachweis je Funktionsbereich), `reports/unresolved-limitations.md` (konsolidierte offene Punkte).
- **Ehrliche Grenzen/Eskalationen:** Kein Ground-Truth-Datensatz für „erwarteten" Prozess-Count existiert im Korpus-Repo (geprüft, keiner gefunden) — P6 berichtet Prozess-Count nur als „erkannt", der Scope-geforderte Vergleich bleibt strukturell unbeantwortbar bis ein manuelles Ground-Truth-Set existiert (Eskalation an Kais, Entscheidung offen). Holdout-Split (75) weiterhin TABU (KAR-963, 16.07., nicht angerührt). KAR-977/KAR-941 im Repo nicht auffindbar (Linear nicht angefasst, nur zitiert). Multi-QAF-Feinfamilien (n=1-3 je Sub-Familie) zu klein für belastbare Prozent-Aussagen.
- **Gate-Ergebnisse:** `typecheck` clean. `eslint` (Diff-Scope, 5 Dateien — `CHANGELOG.md` + 4 `reports/*.md`, ausschließlich `.md` — kein Produkt-Code geändert): 0 Fehler, 5 Warnungen (je 1× „file ignored" pro Datei, kein Lint-Ziel; Review-Fix — vorher fälschlich als „1 Warnung" gemeldet). Voll-Suite: s. o. `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean.
- **Review-Fixes (adversarialer Review von PR #340, 10 Agenten, Verify-Stufe): 7 CONFIRMED, 0 refuted, 0 unverified — alle 7 gefixt in diesem Durchlauf.** Kern: (1) MAJOR — die `ruestkosten`/`lohnkosten`-Reason-Code-Aufschlüsselung in `qaf-value-stream-validation-results.md` §4 war aus dem dokumentierten Tool/Output nicht reproduzierbar (nur eine Summe über alle Felder war gespeichert); Fix: Messwerkzeug additiv um `missingFieldReasonsByField` (Feld × Reason-Code) erweitert, BEIDE Splits erneut komplett gelaufen (dev 103,4 s, validation 38,5 s) — alle Kernzahlen reproduzieren exakt (0 Abweichungen außer dem neuen additiven Feld), die zuvor genannten Reason-Code-Zahlen (ruestkosten 1117/1117 cell_empty; lohnkosten 183/30/50) bestätigen sich unverändert, jetzt aber tool-nativ belegt. (2) MAJOR — „alle 9 in der Voll-Suite übersprungenen Testdateien sind RLS-DB-gated" war falsch: `__tests__/security/rls-user-scoped.test.ts` ist ein unbedingter `it.todo()`-Stub (36 Platzhalter, 9 Kerntabellen × 4 Fälle) ohne jedes Env-Var-Gate, unabhängig reproduziert (identisches Ergebnis mit/ohne `RLS_TEST_DATABASE_URL`); korrekt: 8 echt gegated + 1 todo-Stub. Die eigenständige, bisher falsch dargestellte Testlücke (nutzerskopierte RLS-Isolation für 9 Kerntabellen nie real getestet) ist jetzt in `unresolved-limitations.md` §1.3 an Kais eskaliert; die „P5 hat 67/67 bewiesen"-Aussage präzisiert (deckt nur den Skip-Bucket, nie den Todo-Bucket). (3) MAJOR — Testdatei-Zählungen in `qaf-value-stream-regression-results.md` korrigiert (Permissions 5→7, Exporte 9→10, Zählmethode jetzt explizit dokumentiert). (4) MAJOR — Worst-Performer-Miscount (Tabelle zeigte bereits 5/5, Text/CHANGELOG behaupteten 4/5) in `qaf-value-stream-validation-results.md` + CHANGELOG korrigiert. (5) MINOR — Multi-QAF-Dateizahl (8→9) in `qaf-value-stream-validation-results.md` + `unresolved-limitations.md` korrigiert. (6+7) MINOR — ESLint-Diff-Scope real ausgeführt: 5 Warnungen (nicht 1) über die 5 tatsächlich geänderten `.md`-Dateien, `test-results.md` und CHANGELOG jetzt identisch. Keine Kernaussage der Reports kippt durch diese Fixes — alle Re-Run-Kernzahlen reproduzieren exakt, kein Determinismus-Problem, kein Parser-Bug-Fund. `typecheck`/`eslint`(Diff-Scope, 5 Warnungen)/Voll-Suite (4342/0/67/36)/`check:profiles`/`check:portability` (strict) alle grün nach den Fixes.

## 2026-07-18 – QVS-P5 Review-Fixes: previewToken-Kollision, Cross-Project-Guard, Persistenz-Reihenfolge, RLS-Real-Lauf (PR #339) — Flag weiterhin false, Migration weiterhin applied seit 18.07.

Adversarialer 5-Dimensionen-Review von PR #339 (persistence-integrity, delta-correctness, security-authz, ui-integration, claims-vs-code) mit Verify-Stufe (18 Agenten) lieferte 13 Findings — **13 CONFIRMED, 0 refuted, 0 unverified** (2 critical, 5 major, 6 minor). Alle 13 gefixt in diesem Durchlauf. Kein Merge.

- **CRITICAL (Finding 1) — `createComparisonValueStreamFromSync` konnte sich selbst mit dem Original verwechseln:** `computePreviewToken` ist ein reiner Hash über qafFileId+fileHash+parserVersion+mappingVersion+includedRowIndexes — bei unverändertem Default-Quell-File (der Normalfall, da der Dialog `sourceQafFileId` auf die eigene zuletzt erfasste Quelle defaulted) baute die Comparison-Erstellung denselben Token, den der ORIGINAL-Import bereits unter `engine_context.previewToken` persistiert hatte. Die RPC's `(project_id, previewToken)`-Partial-Unique-Index fand dadurch die BESTEHENDE Original-Zeile und lieferte `idempotent_hit: true` mit der Original-`value_stream_id` — der Dialog zeigte „Separater Vergleichs-Wertstrom erstellt." und navigierte zurück ins Original, ohne dass je eine Kopie existierte. Fix: `createValueStreamFromQaf` (creation.ts) bekommt einen neuen, rein additiven 4. Parameter `options.persistedTokenSuffix` (Geschwister-Mechanismus zu P4s `variantTagging.persistedTokenSuffix`, ohne dessen Varianten-Semantik) — `createComparisonValueStreamFromSync` setzt IMMER `reimport-comparison:<valueStreamId>:<yyyy-mm-dd>` (Tages-Granularität, damit ein echter Same-Day-Doppel-Submit weiterhin idempotent bleibt, aber niemals mit dem unverdächtigten Original-Token kollidiert). `idempotentHit` wird jetzt im gesamten Pfad ausgewertet: Action loggt einen eigenen Schlüssel (`qvs.reimport.comparison_create_idempotent_hit`) statt stillem `ok:true`, der Dialog zeigt bei `idempotentHit:true` eine ehrliche Inline-Meldung statt Erfolgs-Toast + Navigation. Modul-Kommentar (reimport.ts) korrigiert. Neue Tests: reimport.test.ts (Kollisionsfall exakt mit der Default-Quelle, vorher nie geprüft — der bestehende Dialog-Test mockte `idempotentHit` immer `false`), creation.test.ts (bare `options.persistedTokenSuffix` ohne `variantTagging`), qaf-actions.test.ts, qvs-reimport-dialog.test.tsx.
- **CRITICAL (Finding 2) — Reimport konnte Fertigungsdaten aus einem fremden Projekt mischen:** `loadSyncState` prüfte `resolvedSourceQafFileId` nie gegen das Projekt des Ziel-Wertstroms — `qaf_file`-RLS ist eigentümer-, nicht projektskopiert, ein Nutzer mit mehreren Projekten konnte also eine `qaf_file`-Zeile aus einem GANZ ANDEREN eigenen Projekt als Reimport-Quelle angeben. Fix: harte serverseitige Zugehörigkeitsprüfung (`source.qafFile.projectId` gegen `vsm.project_id ?? importRow.project_id`) direkt in `loadSyncState` — wirkt dadurch für BEIDE Pfade (`loadValueStreamSyncDelta`/Delta-GET und `applyValueStreamSyncSelection`/Apply). Fail-closed, kollabiert in denselben „not_found", den jeder andere Nichtsichtbarkeits-Fall schon liefert (architecture.md §9: kein Existenz-Leak — sonst würde ein Mismatch verraten, dass die fremde Datei-ID überhaupt existiert), aber mit eigenem Server-Log-Schlüssel (`qvs.reimport.cross_project_source_rejected`) für Beobachtbarkeit/Testbarkeit. Neue Tests für beide Pfade (Delta + Apply) in reimport.test.ts.
- **MAJOR (Finding 3) + MINOR (Finding 11) — Modulkommentar zur Teilausfall-Wiederherstellung war sachlich falsch:** behauptete, ein Fehlschlag des 2. Schritts (`value_stream_imports`-Update) führe beim nächsten Delta zu einer harmlosen Wiederholung von `SOURCE_CHANGED`. Tatsächlich (per Repro gegen `computeSyncDelta` verifiziert): ein im 1. Schritt bereits übernommenes Feld hat `liveValue` = frischer Quellwert, aber `snapshotValue` bleibt (wegen des gescheiterten 2. Schritts) veraltet — `sourceChanged` UND `localChanged` sind beide `true`, das Feld erscheint als `BOTH_CHANGED` (Konflikt), nicht als schlichte Wiederholung. Kein Datenverlust (der Live-Wert ist bereits korrekt), aber eine unnötige Nutzer-Entscheidung über einen in Wahrheit bereits gelösten „Konflikt". Fix: Modulkommentar auf das reale, per neuem Test in sync.test.ts festgehaltene Verhalten korrigiert; dokumentiert zusätzlich, dass sich der Snapshot beim NÄCHSTEN erfolgreichen Apply unconditional auf den dann aktuellen Quellstand aktualisiert (unabhängig von der gewählten Konfliktlösung) — die Diskrepanz ist also auf einen Retry-Zyklus begrenzt, kein wachsender Drift. Bewusst KEIN Eingriff in sync.ts's Klassifikationslogik (hätte die Leitplanke „BOTH_CHANGED nur mit expliziter Einzel-Entscheidung" berührt) und keine neue Migration — reine Dokumentations- + Test-Korrektur, wie im Auftrag gefordert.
- **MAJOR (Finding 4) — Modul-Kommentar in `qaf-actions.ts` behauptete fälschlich, `value_stream_imports` existiere in Prod nicht:** invalidierte die eigene „fail closed mit legiblem Postgres-Fehler"-Sicherheitsbegründung — seit dem Apply (MIGRATIONS.md §7o, 18.07. ~10:31 CEST) würde ein hypothetischer Flag-Bypass NICHT mehr harmlos crashen, sondern echte RLS-gültige Zeilen lesen/schreiben können. Fix: Kommentar auf den verifizierten Außenwelt-Stand korrigiert, Sicherheitsbegründung ehrlich neu formuliert (der Flag-Gate ist die tragende Schranke, nicht eine fehlende Tabelle). Identischer veralteter Wortlaut auch in `__tests__/security/rls-value-stream-imports-isolation.test.ts` (Zeile 3-4) korrigiert.
- **MAJOR (Finding 5) — Fehlgeschlagener Quellwechsel zeigte Fehlermeldung UND veraltetes aktives Formular gleichzeitig:** der Lade-Effekt läuft bei jedem `selectedSourceId`-Wechsel erneut; schlug ein ZWEITER Ladevorgang (nach einem ersten erfolgreichen) fehl, blieben `view`/`selection` unverändert — Fehlermeldung und das komplette alte Formular (inkl. aktivem, an den alten `deltaToken` gebundenem „Übernehmen"-Button) rendern gleichzeitig. Fix: `setView(null)`/`setSelection(null)` im Fehlerpfad, sodass die Fehler-Anzeige die einzige sichtbare Reaktion ist — exakt wie bei einem Erstladungs-Fehlschlag. Neuer Test in qvs-reimport-dialog.test.tsx (Quelldatei-Dropdown wechseln, zweiter Load schlägt fehl, altes Formular muss verschwinden).
- **MAJOR (Finding 6) — SOURCE_CHANGED-Feld-Checkbox blieb für lokal bereits gelöschte Schritte aktiv sichtbar:** ein gematchter, aber lokal gelöschter Schritt kann `SOURCE_CHANGED` sein (sync.ts erlaubt diese Kombination bewusst) — die Feld-Checkbox hatte, anders als das analoge `REMOVED_FROM_SOURCE`-Gating, kein `!step.locallyDeleted`-Gate und rendert direkt neben dem Text „Lokal bereits entfernt — keine Übernahme möglich." eine scheinbar wirksame, tatsächlich rein dekorative Checkbox (`applySyncAdoption` überspringt den Schritt server-seitig ohnehin immer). Fix: Checkbox komplett ausgeblendet (nicht nur disabled, gleiches Muster wie REMOVED_FROM_SOURCE); `initialSelectionState` (qvs-reimport-logic.ts) pre-selected solche Felder auch nicht mehr (kein Phantom-Eintrag im Plan für ein nie angezeigtes Steuerelement). Neue Tests in qvs-reimport-dialog.test.tsx + qvs-reimport-logic.test.ts.
- **MAJOR (Finding 7) — „Persistenz-Reihenfolge"-Test prüfte keine Reihenfolge:** die beiden `toHaveBeenCalledWith`-Assertions bestätigten nur Mitgliedschaft in der Aufruf-Historie, nie die Relativ-Reihenfolge — ein Live-Experiment während des Reviews (Schreibreihenfolge in reimport.ts testweise vertauscht) bewies, dass der Test dabei grün geblieben wäre. Fix: echte Call-Order-Assertion über `supabase.from.mock.calls.map(c => c[0])` (erwartete Sequenz: maps-Load, imports-Load, maps-Update, imports-Update); zusätzlicher neuer Test für den Step-2-Fehlerfall, der explizit assertiert, dass die maps-Update-Queue konsumiert wurde (= geschrieben), bevor die imports-Update fehlschlägt.
- **MINOR (Finding 8) — Echte DB-Fehler beim Laden wurden zu „not_found" verschluckt:** `loadLatestImportRow`/`loadVsmRow` behandelten einen echten Query-Fehler identisch zu „0 Zeilen/nicht sichtbar" — ein transienter Infrastruktur-Hänger war für den Nutzer nicht von „nie QAF-importiert" unterscheidbar. Fix: beide Helfer liefern jetzt `{row, failed}` statt bloß `T | null`; `loadSyncState` unterscheidet `'not_found'` vs. `'load_failed'`; `loadValueStreamSyncDelta` bekommt einen neuen `QvsSyncDeltaOutcome`-Rückgabetyp (`{ok:true,data}|{ok:false,error}`) statt `View|null` (Action-Schicht + Dialog entsprechend angepasst, neuer `load_failed`-Eintrag in `LOAD_ERROR_MESSAGES` mit eigener, Retry-orientierter Nutzertext); `applyValueStreamSyncSelection` mappt `load_failed` auf den bereits existierenden `db_error`-Code (kein neuer Fehlercode auf diesem Pfad nötig). Neue Tests in reimport.test.ts (beide Pfade) und qaf-actions.test.ts.
- **MINOR (Finding 9) — `vaClass` fiel im Reimport-Dialog auf den rohen Feldschlüssel/Enum-Wert zurück:** `VSM_TARGET_FIELD_LABELS` deckte nur 12 von 14 `EDITABLE_FIELD_KEYS` ab (kein `name`/`vaClass`), und die Wert-Anzeige nutzte rohes `String(...)` statt der im Editor längst definierten deutschen `VA_CLASS_LABELS`. Fix: `name`/`vaClass`-Feldlabels ergänzt; `VA_CLASS_LABELS` als Single Source of Truth nach `qvs-field-labels.ts` verschoben (vsm-editor.tsx importiert sie jetzt von dort statt einer eigenen Kopie); neue `vsmTargetFieldValueLabel(field, value)` routet `vaClass`-Werte durch die Labels, jedes andere Feld bleibt beim bisherigen `String(value ?? '—')`.
- **MINOR (Finding 10) — Fehler-Mapping für „Separaten Vergleichs-Wertstrom anlegen" deckte nicht alle möglichen Fehlercodes ab:** wiederverwendete `APPLY_ERROR_MESSAGES` (gebaut für die 6 Codes der Apply-Action) für eine Action, die zusätzlich jeden `QvsCreationError`-Code liefern kann (`title_required`, `stale_preview`, `no_steps_selected`, `invalid_step_count`, `import_id_conflict`, `invalid_comparison_request`) — alle fielen auf „Unbekannter Fehler" zurück. Fix: eigene, vollständige `CREATE_COMPARISON_ERROR_MESSAGES`-Tabelle (gleiches etabliertes Muster wie `qvs-import-preview-dialog.tsx`s `CREATE_ERROR_MESSAGES`).
- **MINOR (Finding 12) — UI_FLOWS.md §10f zeigte einen anderen Interaktionsfluss als der reale Dialog:** die Skizze listete Modus-Wahl und Ausführungs-Buttons als 3 gleichrangige `[...]`-Buttons in einer Zeile (der reale Modus-Selector sind 3 Radios, die selbst nichts auslösen; der echte „Übernehmen"-Button fehlte ganz), und behauptete „disabled" Adoption-Controls für lokal gelöschte Schritte, wo der Code (vor Finding 6) `disabled` nie aus `locallyDeleted` ableitete. Fix: §10f an den nach allen Fixes finalen Flow angeglichen (korrekte Radio-Notation + -Label, „Übernehmen"/„Separaten Vergleichs-Wertstrom anlegen" in der richtigen Button-Zeile, „kein Feld-Steuerelement wird angezeigt" statt „disabled", plus die neuen `load_failed`-/stale-Formular-/idempotentHit-Zustände aus den Fixes oben).
- **RLS-Real-Lauf (Pflicht-Deliverable dieser Runde, siehe „Ehrliche Restpunkte" der ursprünglichen P5-Runde oben):** kein Docker, kein Root, Port 5432 gehört einem fremden Dienst (unangetastet) — portables PostgreSQL 17.10 via npm-Paket `embedded-postgres` (Binaries `@embedded-postgres/linux-x64`) im Scratchpad beschafft, `initdb`/`pg_ctl` direkt angesteuert (TCP-only, Port 55432, kein Unix-Socket wegen `sun_path`-Längenrisiko im Scratchpad-Pfad). Volle Kette geladen (`supabase-bootstrap-prerequisites.sql` → `supabase-bootstrap-from-prod.sql` → 8 Migrationen inkl. `supabase-migration-value-stream-imports.sql` → `scripts/rls-test/setup.sql`), `RLS_TEST_DATABASE_URL` gesetzt, komplette Suite (alle 8 `scripts/rls-test/run.sh`-Testdateien, nicht nur die 5 neuen QVS-P5-Fälle) real gegen echtes Postgres gelaufen: **67/67 grün** (`rls-value-stream-imports-isolation.test.ts` allein: 14/14, inkl. aller 5 neuen QVS-P5-UPDATE-Fälle). Dabei einen ECHTEN, vom Rest dieser Runde unabhängigen Test-Isolations-Bug in genau dieser Datei gefunden und behoben (kein RLS-Sicherheitsproblem): zwei der 5 neuen Fälle filterten `UPDATE ... WHERE value_stream_id = $1` — nicht eindeutig, weil ein FRÜHERER Test in derselben Datei (dokumentiertes OR-Präzedenzfall-Fixture) bewusst eine zweite Zeile mit demselben `value_stream_id` einfügt; auf die eindeutige `id` der ursprünglichen Fixture-Zeile umgestellt. Postgres-Server danach sauber gestoppt (`pg_ctl stop -m fast`), Port 5432/caddy während der gesamten Session unangetastet.
- **Gate-Ergebnisse (final, nach allen 13 Fixes):** `typecheck` clean. `eslint` (Diff-Scope, **26** geänderte/neue Dateien — 3 mehr als der ursprüngliche P5-Stand, weil Finding 1/9 additiv `creation.ts`/`creation.test.ts`/`qvs-field-labels.ts` anfassen mussten): **0 Fehler**, 12 Warnungen (identische Zusammensetzung wie oben — die 3 zusätzlichen Dateien sind lint-clean). Voll-Suite `npx vitest run --exclude "**/.claude/**"` OHNE `RLS_TEST_DATABASE_URL`: **4342 grün, 0 rot**, 67 skip, 36 todo (+16 ggü. dem 4326-Ausgangsstand durch die neuen Review-Fix-Tests). MIT `RLS_TEST_DATABASE_URL` (embedded Postgres, s.o.): **4409 grün, 0 rot**, 36 todo, nur noch 1 Datei/0 Einzeltests übersprungen (alle 67 vorher übersprungenen Fälle liefen real und grün). `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean.
- **Ehrliche Restpunkte:**
  - `vaClass`-Label-Fix (Finding 9) berührt `qvs-field-labels.ts`, das technisch außerhalb des ursprünglichen P5-Diffs lag (vorbestehende KAR-972-Datei) — additiv erweitert (2 neue Map-Einträge, 1 neue Export-Funktion), keine bestehende Zeile geändert außer der Verschiebung von `VA_CLASS_LABELS` aus `vsm-editor.tsx` (dort jetzt Re-Export-Import statt eigener Kopie).
  - Finding 3/11's „entschärfen falls sauber machbar"-Auftrag bewusst NICHT als Verhaltensänderung umgesetzt (siehe Fix-Beschreibung oben) — eine Änderung an `sync.ts`s Klassifikationsregeln hätte die BOTH_CHANGED-Leitplanke berührt, was über den Auftrag hinausgegangen wäre; die Dokumentations- + Test-Korrektur wurde als ausreichend bewertet (deckt sich mit der Verifier-Einschätzung „kein Datenverlust").
  - Nach wie vor **nicht end-to-end geprüft** (unverändert seit der ursprünglichen P5-Runde): der komplette Reimport-Dialog-Flow gegen eine echte `value_stream_imports`-Zeile in einer echten Browser-Session, inkl. `reimportHistory`-Fortschreibung über mehrere echte Syncs hinweg.
  - Kein separater Folge-Review dieser Review-Fixes selbst (wie üblich bei diesem Programm — der nächste reguläre Review-Zyklus deckt das ab).

## 2026-07-18 – QVS-P5: Reimport-Sync — Delta-Engine, selektive Übernahme, Vergleichs-UI (Flag weiterhin false; P2-Migration seit 18.07. in Prod applied)

Baut auf P1-P4 (KAR-970/971/972/973, gemergt) auf. Deliverable aus KAR-974 (architecture.md §7 P5, Spec 21 "Reimport and Synchronization", gap-analysis G8). Kein Merge.

- **Delta-Engine (`lib/qaf-value-stream/internal/sync.ts`, pur, keine DB):** `computeSyncDelta({sourceNodes, snapshotNodes, liveNodes})` vergleicht einen frisch neu gemappten QAF-Quellstand gegen den letzten Sync-Snapshot (`value_stream_imports.import_snapshot`) und den aktuellen Live-Stand (`value_stream_maps.nodes`) und klassifiziert jeden gematchten Schritt UND jedes seiner `VsmNodeEditableFieldKey`-Felder in genau einen der 6 von Spec 21 benannten Zustände (`UNCHANGED|SOURCE_CHANGED|LOCAL_CHANGED|BOTH_CHANGED|NEW_IN_SOURCE|REMOVED_FROM_SOURCE`). Schritt-Matching über zwei verschiedene `qaf_file`-Uploads hinweg (row_index ist zwischen zwei Dateien nicht vergleichbar) läuft über den Prozessnamen: eindeutige Namen matchen direkt, namensgleiche Gruppen mit GLEICHER Anzahl auf beiden Seiten werden nach erhaltener Zeilenreihenfolge gepaart, eine namensgleiche Gruppe mit UNTERSCHIEDLICHER Anzahl wird nie geraten-gepaart, sondern ehrlich als eigener `unmatched`-Zustand ausgewiesen (aus den 6 Status UND aus jedem Übernahme-Plan ausgeschlossen). Bekannte, dokumentierte Grenze: eine im Quelldokument umbenannte Station hat keine stabile Identität (QAF trägt keine Prozess-UUID) und erscheint als REMOVED_FROM_SOURCE+NEW_IN_SOURCE statt als ein einzelnes SOURCE_CHANGED — eine Positions-Fallback-Heuristik wurde bewusst NICHT gebaut (wäre exakt die Art Raten, die die Aufgabe verbietet).
- **Selektive Übernahme (`applySyncAdoption`):** ein `QvsSyncAdoptionPlan` (`mode: 'all'|'selected'|'none'` für SOURCE_CHANGED-Felder/NEW_IN_SOURCE-Schritte, IMMER konsultierte `conflictResolutions`/`removedStepDecisions` für BOTH_CHANGED/REMOVED_FROM_SOURCE unabhängig vom Modus) — ein LOCAL_CHANGED-Feld wird von KEINEM Plan je angefasst, ein BOTH_CHANGED-Feld nur durch eine explizite Einzel-Entscheidung. Eine übernommene Feld-Adoption erneuert auch die eigene `qafSource.fields[qafKey]`-Herkunftsangabe (Zelle/Original/Confidence), damit „Quelle anzeigen" nach einem Sync nicht die alte Provenienz zeigt. Ein aus der Quelle entfernter Schritt bleibt per Default im Wertstrom (explizites „entfernen" nötig); neu adoptierte Schritte werden nach dem bestehenden Bestand angehängt, bestehende (auch nicht-lineare, manuell verlegte) Verbindungen bleiben unangetastet.
- **DB-Orchestrierung (`lib/qaf-value-stream/internal/reimport.ts`):** `loadValueStreamSyncDelta`/`applyValueStreamSyncSelection`/`listReimportSourceCandidates`/`createComparisonValueStreamFromSync`. `sourceQafFileId` defaulted auf den eigenen zuletzt erfassten `qaf_file_id` der Wertstrom-Zeile (legitime „nichts hat sich geändert"-Bestätigung — `qaf_file`-Zeilen mutieren nie in place) oder eine andere Datei im selben Projekt. **Keine neue Migration:** Persistenz sind 2 sequenzielle PostgREST-Requests (`value_stream_maps` zuerst, dann die BESTEHENDE `value_stream_imports`-Zeile — nie eine neue Zeile je Reimport), bewusst in dieser Reihenfolge (ein 2.-Schritt-Fehlschlag lässt den Snapshot die Änderung nie fälschlich als übernommen ausweisen). `deltaToken` (sha256 über Wertstrom-/Import-/Quell-Id, Quell-previewToken, `value_stream_maps.updated_at`) verhindert das Anwenden einer inzwischen veralteten Entscheidung (dieselbe Disziplin wie `previewToken` bei der Erst-Erstellung).
- **Vergleichs-UI (`components/wertstrom/qvs-reimport-logic.ts` + `qvs-reimport-dialog.tsx`):** Entry-Point „Synchronisieren" im Editor-Toolbar (`vsm-editor.tsx`), sichtbar nur wenn der Wertstrom mindestens einen `qafSource`-tragenden Node hat. Zeigt nur veränderte/neue/entfernte/mehrdeutige Schritte (unveränderte ausgeblendet), „X übernehmen, Y behalten, Z Konflikte"-Zusammenfassung (Spec-21-Wortlaut), Modus-Wahl (alle/Auswahl/keine), pro-Feld-Checkboxen, Konflikt-Radios, Entfernen-Entscheidung, sowie „Als separaten Vergleichs-Wertstrom anlegen" (reuses den bestehenden Ein-Klick-Erstellungspfad unverändert, Titel `<Original> · Reimport <Datum>`, Original-Wertstrom unberührt).
- **Verdrahtung `app/wertstrom/qaf-actions.ts`:** 4 neue Server Actions (`getValueStreamSyncDeltaAction`, `listReimportSourceCandidatesAction`, `applyValueStreamSyncSelectionAction`, `createComparisonValueStreamFromSyncAction`), gleiche Flag-zuerst/Zod/strukturierter-Fehlercode-Disziplin wie jede bestehende Action. Editierbare-Feld-Enum wird aus `EDITABLE_FIELD_KEYS` (vsm-field-status.ts, P3) abgeleitet statt hand-dupliziert, damit ein künftiges Feld dort nicht am Server-Boundary vorbeidriften kann.
- **Persistenz/Schema:** kein neues DB-Objekt. `schemas/qaf-value-stream-import.schema.json` dokumentiert additiv die neue `engine_context.reimportHistory`-Form (Array, auf 20 Einträge gedeckelt) — `engine_context` hat kein `additionalProperties: false`, also reine Dokumentation, keine Migration.
- **Migrations-Status-Klärung (zwei Akte, ehrlich dokumentiert):** Der beim Session-Restart gestorbene Vorgänger-Agent schrieb ins README, die P2-Migration (`value_stream_imports`) sei „shortly before this PR" appliziert worden („operator-verified: table exists, RLS enabled, 0 rows") — zu diesem Zeitpunkt stand das in KEINER Repo-Quelle (`MIGRATIONS.md` §7o und alle CHANGELOG-Einträge sagten „NOT applied"). Der Finish-Agent stufte die Aussage deshalb als unbelegte Fabrikation ein und drehte sie zurück — aus reiner Repo-Evidenz konsequent, in der Sache falsch: Die anschließende Verifikation gegen die echte Datenbank (18.07., `list_tables` auf dem Live-Projekt) bestätigt den Apply (Tabelle existiert, RLS enabled, 0 rows; operator-applied ~10:31 CEST). Root Cause der Verwirrung: Der Apply-Event war ausschließlich außerhalb des Repos dokumentiert (Operator-Chat + Session-Notizen). Jetzt kanonisch nachgetragen (`MIGRATIONS.md` §7o) und README auf den verifizierten Ist-Stand gebracht. Lehre: Operator-Apply-Events sofort in `MIGRATIONS.md` nachziehen — sonst „korrigiert" der nächste Agent die Realität weg, weil das Repo sie nicht kennt.
- **Tests:** 5 neue Testdateien — `sync.test.ts` (alle 6 Status-Klassen inkl. Kombinationsfälle, LOCAL_CHANGED-Schutzgarantie, Verbindungs-Handling), `reimport.test.ts` (DB-Orchestrierung, gemockter Supabase-Client, Persistenz-Reihenfolge, Staleness), `sync.real-files.test.ts` (Pflicht-Real-Korpus-Test: Selbst-Diff-Anker über mehrere echte Dateien = 100 % UNCHANGED/0 unmatched; datei-übergreifende Diffs innerhalb derselben Korpus-Stratum-Klasse crashen nie und bleiben zahlenmäßig konsistent), `qvs-reimport-logic.test.ts` (Composite-Key-Rundreise, Plan-Aufbau, Zusammenfassungs-Sätze), `qvs-reimport-dialog.test.tsx` (Lade-/Fehlerzustände, Checkbox-/Radio-Checked-Zustand inkl. einer Regression aus dem ursprünglichen Review). Dazu 5 neue RLS-Testfälle (UPDATE-Pfad auf `value_stream_maps`/`value_stream_imports`, `__tests__/security/rls-value-stream-imports-isolation.test.ts`) und eine erweiterte `qaf-actions.test.ts`.
- **Gate-Ergebnisse (korrigiert in der Review-Fixes-Runde, Finding 13 — vorher 20 Dateien/9 Warnungen gemeldet, das war der Checkpoint-Stand vor den beiden Folge-Commits):** `typecheck` clean. `eslint` (Diff-Scope, 23 geänderte/neue Dateien): **0 Fehler**, 12 Warnungen — 7× `.md`/`.json` „file ignored" (kein Lint-Ziel: `CHANGELOG.md`, `MIGRATIONS.md`, `PRODUCT_SPEC.md`, `TODO.md`, `UI_FLOWS.md`, `lib/qaf-value-stream/README.md`, `schemas/qaf-value-stream-import.schema.json`), 4× `security/detect-non-literal-fs-filename` in `sync.real-files.test.ts` (etabliertes Muster jedes `*.real-files.test.ts`), 1× `react-hooks/exhaustive-deps` in `vsm-editor.tsx` (verifiziert vorbestehend — identischer Code/Kommentar bereits auf `main`, außerhalb dieses Diff-Hunks). Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4326 grün, 0 rot** (303/312 Testdateien, 67 skip/36 todo — +76 Tests/+5 Testdateien/+5 Skips ggü. dem P4-Stand (4250/62 skip), die 5 neuen Skips sind exakt die 5 neuen RLS-Testfälle ohne `RLS_TEST_DATABASE_URL`; alle 5 neuen Testdateien liefen vollständig, inkl. `sync.real-files.test.ts` real gegen den Korpus). `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean. `npm run build`: TypeScript-Kompilierung + Turbopack-Build erfolgreich; Seiten-Daten-Sammlung bricht danach an fehlenden Supabase-Env-Vars in dieser Sandbox ab (Umgebungs-, kein Code-Problem, betrifft `main` identisch — bereits in der P4-PR so dokumentiert).
- **Ehrliche Restpunkte (Stand dieser ursprünglichen P5-Runde — die RLS-Restriktion wurde in der nachfolgenden „QVS-P5 Review-Fixes"-Runde aufgelöst, siehe Eintrag unten):**
  - Kein Docker und kein portables lokales PG17 in dieser Session verfügbar (Port 5432 gehört einem fremden Dienst) — die 5 neuen RLS-Testfälle sind strukturell korrekt (gleiches Muster wie die bestehenden Isolations-Tests) aber in diesem Lauf nur skip-verifiziert, nicht tatsächlich gegen echtes Postgres gelaufen.
  - **Noch nicht end-to-end geprüft** (die Migration ist seit 18.07. applied, der Live-Flow aber noch nie gefahren): der komplette Reimport-Dialog-Flow gegen eine echte `value_stream_imports`-Zeile inkl. `reimportHistory`-Fortschreibung über mehrere echte Syncs hinweg; das Umbenennungs-Verhalten (REMOVED+NEW statt SOURCE_CHANGED) an einem echten Kunden-Fall.
  - Umbenannte Stationen werden bewusst nicht als Rename erkannt (siehe Delta-Engine-Bullet oben) — dokumentierte, keine zufällige Lücke.
  - Kein separater adversarialer Review-Pass (wie er P2/P3/P4 in eigenen Folge-Commits bekam) in diesem Durchlauf — die Bestandsaufnahme mündete stattdessen in der Migrations-Status-Klärung (siehe oben), eine formale Mehrdimensions-Review wäre ein sinnvoller Folge-Schritt.

## 2026-07-18 – QVS-P4 Review-Fixes: ehrliche Varianten-Bindung, Teilausfall-Toast, labelScan-Guard, degraded-Sichtbarkeit (PR #338) — Flag weiterhin false, Migration weiterhin NICHT applied

Adversarialer Review der PR #338 (5 Dimensionen, Verify-Stufe) lieferte 11 CONFIRMED Findings — 2 davon derselbe Bug, unabhängig von zwei Reviewern gefunden (Findings 2/10) → 10 Fixes, 0 refuted. Alle in einem Folge-Commit auf demselben Branch gefixt. Kein Merge.

- **MAJOR (Finding 1) — "Geteilter Fluss" über-generalisiert:** `loadMultiQafVariantContext` taggte bisher ALLE `container.activeVariants`, sobald irgendein `multi_qaf_variant_vs_standard`-Vergleich existierte — ein Container darf laut eigenem Domänenmodell (`variantProfileBindings`, `SharedCostProfileKind` inkl. `'location'`) Varianten auf UNTERSCHIEDLICHEN Fertigungsprofilen/Standorten führen, und der Vergleich selbst prüft Fertigungs-Kompatibilität nie (`variant-vs-standard.ts` `DEGRADED_MODULES.manufacturing_steps`). 00-master-prompt-multi-qaf.md §12 ("Do not assume that all variants use the same manufacturing total.") / §15 ("Do not compare an entire Multi-QAF container with one standard QAF as if they represented the same scope.") verbieten das explizit. Jetzt: `variants` enthält NUR (a) die tatsächlich verglichene Variante (`engine_version.selectedVariantId`, neu gelesen — vorher nie) plus (b) jede weitere aktive Variante mit nachweislich SELBER `variantProfileBindings.profileId`; ohne Bindungs-Evidenz nur die verglichene Variante. Neues `QvsVariantContext.sharedProfileConfirmed`-Flag steuert die Preview-Formulierung ("N Varianten mit nachweislich gleichem Fertigungsprofil" bzw. "nur die verglichene Variante — Profil-Bindung unbekannt"). Modul-Header (`multi-qaf-context.ts`) + gap-analysis G5 aktualisiert.
- **MAJOR (Findings 2+10, unabhängig von zwei Reviewern gefunden) — Toast grün bei Teil-/Totalausfall:** der Toast nach "Je Variante ein Wertstrom" war fest auf `type: 'success'` verdrahtet, selbst bei 0 von N erfolgreichen Creates — widersprach der PR-eigenen "nie versteckt"-Prämisse für den einen UI-Kanal (Farbe/Icon), den ein Nutzer statt des Fließtexts liest. Neue `perVariantToastType(outcomes)` (qvs-preview-logic.ts) leitet den Typ aus denselben Outcomes ab, die auch die Balance-Nachricht bauen: alle ok → `'success'`, teils → `'info'`, keine → `'error'`. Neue Dialog-Tests für ein gemischtes und ein total-fehlgeschlagenes outcomes-Array assertieren jetzt explizit den gerenderten Toast-Typ (vorher assertierte nur der Alle-Erfolgreich-Fall).
- **MAJOR (Finding 3) — N byte-identische Titel:** `createValueStreamsForVariants` übergab denselben `confirmation.title` an jeden Per-Variante-Call — die N entstehenden Wertströme waren im `/wertstrom`-Listing nicht unterscheidbar (das einzige tatsächlich abweichende Feld, `variantTags`, wird nirgends gerendert). Jeder Call bekommt jetzt `${confirmation.title} · ${variantLabel}` (Label aus `MultiQafVariantSummary`, kollisionssicherer Fallback auf `variantKey` bei leerem Label).
- **MINOR (Finding 4):** `computeCostPerUnitRollup`s Bucket-Schlüssel bucketete eine leere Currency-Zeichenkette (`''`, NICHT nullish) als eigenen Bucket statt in "unbekannt" zu falten — anders als das strukturell identische `computeScrapCostRollup`. Jetzt derselbe Trim-Guard in beiden Funktionen.
- **MAJOR (Finding 5) — labelScan-Kollision blähte die gemeldete Abdeckung auf:** der geteilte Label-Scan-Mechanismus (`summary-parser.ts`) konnte über eine echt leere Werte-Zelle hinweg auf eine BENACHBARTE SUMMARY_METRIC-Label-Zeile ("Enthaltene Zölle" / "Enthaltene Verpackung und Transport", `summary-metrics.ts`, Spalte G) laufen und deren Text fälschlich als Wert zurückgeben — betraf 13-14/227 reale Dateien. Neuer, PRO-FELD opt-in `FieldDef.strictValueCheck` (nur `plannedCapacity`/`lotSize`) weist einen Kandidaten zurück, der (a) keine Ziffer enthält oder (b) normalisiert einem anderen FIELD_DEFS-Label entspricht — die 4 bereits ausgelieferten KAR-910-Felder bleiben bewusst byte-identisch (ihr eigener, gleicher Kollisions-Bug ist jetzt dokumentiert, nicht in diesem Fix behoben — Follow-up-KAR folgt, siehe TODO.md). Full-Sweep neu gemessen: `plannedCapacity` 124/227 (54,6 %, runter von 138/227), `lotSize` 195/227 (85,9 %, runter von 200/227) — Regressions-Floors entsprechend von 0,55/0,85 auf 0,50/0,85 angepasst; niedriger ist hier ehrlicher, nicht schlechter (der Guard entfernt Fehl-Extraktionen, er erzeugt keine).
- **MINOR (Finding 6):** der Report-Nachtrag deckte nur `plannedCapacity`s Null-Lücke ab, implizit auch für `lotSize` mit-behauptet — `lotSize` hat zusätzlich einen eigenen, disclosed Non-Summary-Sheet-Mechanismus (2/227 Dateien tragen das Label auf einem "INPUT"-Tab bzw. nummerierten Tabs, die `parseSummarySheetFromWorkbook` strukturell nie liest). Eigene Bullet im Nachtrag ergänzt.
- **MAJOR (Finding 7) — degradierter Varianten-Kontext wurde stillschweigend verschluckt:** beide Create-Actions (`qaf-actions.ts`) lasen `variantContext?.variants.map(...) ?? []` und warfen damit `.degraded` weg — persistierte einen `value_stream_imports`-Datensatz, der dauerhaft nicht von einem Nicht-Multi-QAF-Import zu unterscheiden war (shared_flow-Zweig), bzw. meldete im Geschwister-Pfad das sachlich falsche `no_variants_found`. Jetzt eigener, fail-closed Fehlercode `variant_context_check_failed` (Muster: wie `duplicateCheckDegraded` ein eigener Zustand, nie in "leer" gefaltet), eigene Dialog-Meldung.
- **MAJOR (Finding 8) — Varianten-Sektion versteckte degraded identisch zu absent:** `shouldShowVariantSection` (`!!ctx && !ctx.degraded && ...`) kollabierte beide Fälle auf `false` — kein User-Signal, dass überhaupt ein Multi-QAF-Check lief oder fehlschlug, anders als das bestehende Duplikat-degraded-Banner in derselben Datei. Jetzt 3-Wege-Rückgabe (`'hidden' | 'visible' | 'unavailable'`), Dialog zeigt für `'unavailable'` ein eigenes Banner ("Multi-QAF-Varianten-Prüfung nicht verfügbar") analog dem Duplikat-Fall.
- **MAJOR (Finding 9):** `parseQvsFileLevelCount` (actions.ts) war eine ungetestete Ad-hoc-Reimplementierung, schwächer als das bereits getestete, öffentlich exportierte `parseLocaleNumber` — löste die Tausender-Punkt-vs-Dezimal-Ambiguität nicht auf (ein Wert wie "221.500" wäre als 221,5 statt 221500 persistiert worden). Ad-hoc-Funktion gelöscht, `parseLocaleNumber` aus dem bereits vorhandenen `@/lib/qaf-differences`-Import wiederverwendet.
- **MINOR (Finding 11):** Sortier-Determinismus der Währungs-Buckets war ungetestet — beide bestehenden Mix-Tests fütterten bereits alphabetisch sortierte Eingaben, hätten eine Regression (entfernter `.sort()`-Aufruf) nicht erkannt. Neuer Test mit umgekehrter Eingabereihenfolge (USD zuerst), weiterhin alphabetische Erwartung.
- **Gate-Ergebnisse:** `typecheck` clean. `eslint` (Diff-Scope): 0 Fehler; 5 Warnungen, alle vorbestehend und unverändert (per Stdin-Lint-Vergleich gegen den Vor-Fix-`HEAD` verifiziert: 3× in `app/qaf-differences/actions.ts` — 2 ungenutzte Imports, 1 non-literal RegExp —, 2× in `summary-parser-qvs-file-level-fields.real-files.test.ts` — non-literal `readFileSync`, ein bereits vorher überflüssiges `eslint-disable`). Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4250 grün, 0 rot** (298/307 Testdateien, 62 skip/36 todo — unverändert die RLS-/Real-Korpus-gated Fälle ohne lokale Ressource). `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean. Vertraulichkeits-Grep: keine echten Lieferanten-/Projektnamen, keine Preis-Literale (die zitierten SUMMARY_METRIC-Label-Strings sind BMW-Template-Vokabular, kein individueller Kundenbezug — in den neuen Tests vorsorglich trotzdem `allow-customer-string`-markiert). `persistence-drift.test.ts`, `mapping-drift.test.ts`, `qaf-source.test.ts`, `preview.test.ts`/`preview.real-files.test.ts`, `summary-parser.test.ts` (die 4 KAR-910-Felder) unverändert grün.
- **TODO.md/`qaf-value-stream-corpus-evidence.md`/`qaf-value-stream-gap-analysis.md`/`lib/qaf-value-stream/README.md`** aktualisiert (neue Korpus-Zahlen, Follow-up-KAR-Hinweis für die 2 betroffenen KAR-910-Felder, korrigierte Varianten-Bindungs-Semantik).

## 2026-07-18 – QVS-P4: Multi-QAF-Varianten, additive vsm-metrics-Kennzahlen, 2 neue Datei-Ebene-Parserfelder (Flag weiterhin false, Migration weiterhin NICHT applied)

Baut auf P1-P3 (KAR-970/971/972, gemergt) auf. Drei Deliverables aus KAR-973 (architecture.md §6/§7, gap-analysis G5/G10), alle additiv, kein Merge.

- **Multi-QAF-Varianten in der Preview (gap-analysis G5):** eine Varianten-Sektion im BESTEHENDEN Preview-Dialog (kein neuer Dialog), sichtbar NUR wenn die QAF-Quelle die `alt`/Standard-Seite eines `multi_qaf_variant_vs_standard`-Vergleichs ist (comparison-mode.ts's Rollen-Fixierung: `alt`=Standard-QAF mit echten `qaf_manufacturing_step`-Zeilen, `neu`=Multi-QAF-Container ohne jede Schritt-Zeile — bestätigt: Container-Dateien bekommen beim Ingest nie `qaf_manufacturing_step`/`qaf_summary_metric`-Zeilen). Ein reiner `multi_qaf`-vs-`multi_qaf`-Vergleich bleibt bewusst OHNE Varianten-Sektion — keine Seite hat Schritt-Daten zum Importieren, keine Fabrikation eines Pseudo-Flusses.
  - Neues Modul `lib/qaf-value-stream/internal/multi-qaf-context.ts` (`loadMultiQafVariantContext`): findet den zugehörigen Vergleich + Container über `qaf_comparison`/`qaf_file.g60_meta.multiQafContainer` (RLS-gescoped, nie `lib/supabase/admin.ts`, kein Workbook-Re-Parse), liest `deserializeMultiQafContainer`/`MultiQafContainer`/`VariantDefinition` NUR aus dem öffentlichen `@/lib/qaf-differences`-Barrel. `degraded: true` (nie stillschweigend `null`) wenn ein passender Vergleich existiert, aber die Container-Daten nicht lesbar sind — dieselbe Ehrlichkeits-Disziplin wie `duplicates.ts`.
  - Strategie-Wahl: **„Geteilter Fertigungsfluss" (Default)** — EIN `createValueStreamFromQafAction`-Call, alle Nodes bekommen `variantTags` = alle bekannten Varianten-Keys, `value_stream_imports.variant_selector` wird erstmals wirklich befüllt (Schema-Feld existierte bereits seit P2, war bisher hartcodiert `null`). **„Je Variante ein Wertstrom"** — neue dedizierte Action `createValueStreamsForVariantsAction` + `createValueStreamsForVariants` (lib): N sequenzielle Creates mit IDENTISCHEN Schritten (Zykluszeiten sind im QAF nicht variantenspezifisch, NIE fabriziert), je ein Varianten-Tag. Server re-derived die Varianten-Liste IMMER selbst (nie Client-Input vertraut). Jeder Call bekommt einen eigenen `persistedTokenSuffix` im `engine_context.previewToken`, sonst hätte die RPC-Idempotenz-Prüfung (partial unique index auf `previewToken`) Variante 2..N fälschlich als Re-Submit von Variante 1 behandelt. Explizit KEINE Transaktion über alle N Creates (keine Migration in P4) — ein Teilausfall wird pro Variante ehrlich gemeldet (Toast „X von N Wertströmen erstellt — Y fehlgeschlagen"), nie versteckt.
- **`vsm-metrics.ts` additiv erweitert** (`computeTimeline`/`findBottleneckId` byte-unverändert, gleiche bekannte Limitation: lineare Summe, keine Parallelpfad-Taktkorrektur, Gap G6):
  - `computeVaClassBreakdown` — va/nnva/nva/ungeklärt getrennt ausweisbar; Nodes ohne `vaClass` werden aus `isValueAdded` abgeleitet (`derivedVa`/`derivedNonVa`, nie zu nnva/nva geraten) — beweisbar additiv: degeneriert exakt zu `computeTimeline`s va/nva/pct-Split, wenn kein Node ein `vaClass` trägt (eigener Regressionstest).
  - `computeSetupTimeSum`/`computeWaitTimeSum`/`computeTransportTimeSum` — Summe über die VORHANDENEN `VsmNode`-Zeitfelder (beim QAF-Import immer leer, Stoppuhr/manuell befüllt); `totalSec: null` (nie 0) wenn KEIN Node das Feld gesetzt hat — „keine Datenbasis" statt 0-Lüge. Explizit NICHT `VsmConnection.transportTimeSec` (Scope-Entscheidung, im Explain dokumentiert).
  - `computeCostPerUnitRollup`/`computeScrapCostRollup` — Kosten-/Scrap-Rollup je Währung, bei Mix GETRENNTE Buckets, NIEMALS stillschweigend summiert (KAR-938-Doktrin). Scrap-Bucket-Schlüssel ist die im QAF-Import erfasste Angebotswährung (`qafSource.fields.angebotswaehrung`), NICHT `node.currency` (das ist BW — vsm-types.ts-JSDoc aus P2); `machineHourRate`/`laborHourRate` (Stundensätze) fließen nie in die Stückkosten-Summe ein.
  - `computeCapacityContext` — exakte `lotsPerYear = plannedCapacity ÷ lotSize` aus den 2 neuen Datei-Ebene-Feldern. Bewusst KEIN abgeleiteter Kundentakt aus Plankapazität (evaluiert, verworfen: dafür fehlt eine exakte Zeitbasis, `sum_working_hours_per_year` bleibt „not parsed today", außerhalb des P4-Scopes) — `projects.customer_takt_time_sec` bleibt unverändert die alleinige Kundentakt-Quelle.
  - Jede neue Kennzahl trägt ein `MetricExplain` (Formel/Datenbasis/Ausschlüsse), Stil an `lib/qaf-differences/internal/explain-provenance.ts` angelehnt, aber im eigenen (Deutsch-only, wie der Rest des Wertstrom-Moduls) Format — keine bilinguale `DecodedMessage`-Übernahme, das ist eine andere Modul-Konvention.
- **Zwei neue Datei-Ebene-Parserfelder** (`sum_planned_capacity`/`sum_lot_size`, Registry-Status vorher „not parsed by Kadi-v2 today"): `lib/qaf-differences/internal/summary-parser.ts` FIELD_DEFS (Label-Scan, DE+EN, gleiche Mechanik wie die 4 KAR-910-Felder) + `types.ts` `QafSummary.plannedCapacity`/`lotSize` + `canonical-fields.ts`-Notes aktualisiert (inkl. neuem EN-Alias „Manufacturing lot size [parts]" für `sum_lot_size`, real-file-Wortlaut). `rule-engine.ts`/`bridge.ts`/`rehydrate.ts` an die 2 neuen `QafSummaryKey`-Einträge angepasst (R3-Hinweis-Symmetrie mit den 4 KAR-910-Feldern; Multi-QAF-Bridge liefert ehrlich `summaryField(null)` — keine Datei-Ebene-Prämisse pro Variante; `partToSummary` bleibt „nicht distinkt persistiert" wie seine 4 Geschwister).
  - **Persistenz OHNE neue Migration:** zusätzliche `qaf_summary_metric`-Zeilen (`metric_key: 'plannedCapacity'`/`'lotSize'`, `currency: null`) am bestehenden Ingest-Insert (`app/qaf-differences/actions.ts`) — bewusst NICHT über `buildSummaryMetricRows`/`SUMMARY_METRIC_KEYS` (das ist der 20-Key-MONEY-Metrik-Vertrag, unberührt). `lib/qaf-value-stream/internal/qaf-source.ts` liest sie zurück (`QvsFileLevelContext`, best-effort — ein Query-Fehler degradiert zu `null`, bricht nie die Preview) und reicht sie bis in `value_stream_imports.engine_context` (`plannedCapacityPartsPerYear`/`lotSizeParts`, additive Schema-Erweiterung — `engine_context` hat kein `additionalProperties: false`) sowie in `computeCapacityContext` durch.
- **Tests:** Full-Sweep-Real-Korpus-Test für die 2 neuen Felder (`summary-parser-qvs-file-level-fields.real-files.test.ts`, alle 227 dev-Split-Dateien, env-gated) — **gemessen 138/227 (61 %) für plannedCapacity, 200/227 (88 %) für lotSize**, deutlich unter dem P1-Scans 221/220 (97 %); Root-Cause dokumentiert (Report-Nachtrag in `qaf-value-stream-corpus-evidence.md`): der P1-Scan maß breitere Kapazitäts-Regex-Treffer über ALLE Sheets (inkl. fachfremder Nebentreffer, vom Report selbst benannt) und reine Label-PRÄSENZ statt Werte-Extraktion — kein Bug, ein anderer Maßstab; Test-Floor bei 55 %/85 % (Regressions-Schutz, kein Fabrizieren einer 97 %-Erwartung). Neue Unit-Tests: `multi-qaf-context.test.ts` (9 Fälle), `vsm-metrics.test.ts` (27 Fälle, inkl. Mix-Währung-Buckets, vaClass-Mischbestand, leere Zeit-Felder → `null` statt 0), Variant-Tagging/`createValueStreamsForVariants`-Fälle in `creation.test.ts` (+13), Varianten-Strategie-Fälle in `qvs-preview-logic.test.ts`/`qaf-actions.test.ts`/`qvs-import-preview-dialog.test.tsx`. Bestehende `persistence-drift.test.ts`, `mapping-drift.test.ts`, P1-Mapper-Korpus-Test unverändert grün (Mapper/Token-Determinismus nicht angefasst).
- **Gate-Ergebnisse:** `typecheck` clean. `eslint`: 0 neue Fehler/Warnungen in jeder geänderten/neuen Datei (repo-weite 60 Fehler/195 Warnungen sind vorbestehende, unberührte react-compiler-Befunde in ~30 Dateien außerhalb dieses Diffs — verifiziert per Diff-Hunk-Abgleich). Voll-Suite `npm test`: **4224 grün, 0 rot** (298/307 Testdateien, 62 skip/36 todo — unverändert die RLS-/Real-Korpus-gated Fälle ohne die jeweilige lokale Ressource). `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean. `npm run build`: TypeScript-Kompilierung erfolgreich; der volle Build bricht danach an der Seiten-Daten-Sammlung ab, weil dieser Sandbox `SUPABASE_SERVICE_ROLE_KEY`/`NEXT_PUBLIC_SUPABASE_URL`/`NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` fehlen (Umgebungs-, kein Code-Problem — betrifft main identisch, keine neue Env-Var durch diese PR).
- **PRODUCT_SPEC.md / UI_FLOWS.md** (Feature-Parity-Regel): P4-Absätze ergänzt (Varianten-Sektion, additive Kennzahlen, 2 neue Felder). `lib/qaf-value-stream/README.md` Scope-Abschnitt aktualisiert (P2/P3/P4 „done"). `reports/qaf-value-stream-field-mapping.md` korrigiert (die 2 Felder waren dort noch als „heute nicht geparst" beschrieben). `reports/qaf-value-stream-corpus-evidence.md` um den P4-Mess-Nachtrag ergänzt. ADR 024 unverändert — keine neue Architektur-Entscheidung, nur E1-E6-konforme Umsetzung des bereits beschlossenen Phasenplans.
- **Ehrlich: Restpunkte/Abweichungen:**
  - **Reale Parser-Abdeckung (61 %/88 %) liegt deutlich unter der im P1-Scan zitierten 97 %** — siehe Root-Cause oben; kein Fix versucht, weil die Ursache (breiterer Nebentreffer-Regex im P1-Scan, echte Leerfelder, gemeinsames `MAX_SCAN_RIGHT`-Fenster) außerhalb dessen liegt, was diese PR ändern darf, ohne 4 bereits ausgelieferte KAR-910-Felder mit zu beeinflussen.
  - **`computeVaClassBreakdown`/Setup-Wait-Transport-Summen/Kosten-Rollup sind berechnet und unit-getestet, aber NICHT in ein Editor-Panel verdrahtet** — die Aufgabe verlangte die Kennzahlen-Erweiterung in `vsm-metrics.ts`, keine neue Editor-UI dafür; eine Anzeige-Fläche (z. B. ein Kennzahlen-Panel im VSM-Editor) ist ein Folge-Task.
  - **`computeCapacityContext` bekommt seinen `fileLevelContext` nicht automatisch im Editor** — die Werte erreichen `value_stream_imports.engine_context` bei der Erstellung, aber `vsm-editor.tsx` lädt diesen Import-Record heute nicht nach, um ihn der Kennzahl zu übergeben (dieselbe „Panel fehlt noch"-Lücke wie oben).
  - **End-to-end nach Migrations-Apply zu prüfen** (wie bei P2/P3, hier zusätzlich P4-spezifisch): echter `multi_qaf_variant_vs_standard`-Vergleich mit echtem Multi-QAF-Container → Varianten-Sektion zeigt die echten Varianten; „Je Variante"-Pfad gegen eine echte Postgres-Instanz (Idempotenz-Suffix-Mechanismus bisher nur gegen gemockte RPC-Calls verifiziert); `qaf_summary_metric`-Insert der 2 neuen `metric_key`-Werte gegen eine echte Ingest-Pipeline (bisher nur die Parser-Funktion selbst gegen den realen Korpus geprüft, nicht der volle Ingest-Pfad inkl. DB-Write).

## 2026-07-18 – QVS-P3 Review-Fixes: vaClass-Gate für Alt-Nodes, wertsensitiver fieldStatus, Kollisions-Suppress, RPC-Fehler-Mapping (PR #337) — Flag weiterhin false, Migration weiterhin NICHT applied

Adversarialer Review der PR #337 (5 Dimensionen, Verify-Stufe) lieferte 9 CONFIRMED Findings, 0 refuted. Alle 9 gefixt in einem Folge-Commit auf demselben Branch. Kein Merge.

- **CRITICAL (Findings 4+6) — vaClass-4-Wege-Selector war ungated:** `vsm-editor.tsx` rendete den neuen vaClass-Selector für JEDEN `type==='process'`-Node, unabhängig von `qafSource` — traf damit auch jeden Alt-/Hand-/Excel-/LSC-Node im bereits produktiv laufenden (nicht geflaggten) Wertstrom-Modul, entgegen der PR-eigenen „byte-for-byte"-Zusicherung (UI_FLOWS.md). Jetzt `{selectedNode.qafSource ? (4-Wege-Selector) : (alte isValueAdded-Checkbox)}` — analog den 3 bestehenden qafSource-Gates in derselben Datei. Die Checkbox-Zweig ist byte-identisch zu `main` (diff-verifiziert). Begründung: `qafSource` entsteht ausschließlich über den flag-gated QAF-Import-Pfad, daher ist seine Existenz das korrekte Gate.
- **MAJOR (Finding 1) — `fieldStatus` kippte auf 'modified' ohne echte Wertänderung:** `updateNode` leitete `changedKeys` nur aus `Object.keys(patch)` ab, ohne den neuen gegen den alten Wert zu vergleichen — ein Netto-Null-Edit (z. B. Spinner-Klick, Mausrad über fokussiertem Zahlenfeld) markierte das Feld dauerhaft „geändert" (persistiert per Autosave). Neue `hasFieldValueChanged` (vsm-field-status.ts) vergleicht wertsensitiv — numerische Felder via `Number(...)`-Normalisierung (NaN-sicher), Text-/vaClass-Felder via `!==`.
- **MINOR (Finding 2) — `legacyValueAddedHint` zeigte einen Fantom-Hinweis** für frisch angelegte Nodes (alle 3 nicht-qafSource-Erzeugungspfade setzen `isValueAdded`, nie `vaClass`). Nach dem vaClass-Gate-Fix rendert der Hint nur noch im qafSource-Zweig — verifiziert, dass er dort (der QAF-Mapper setzt `vaClass` IMMER mit) faktisch nie mehr greift; Doc-Kommentar entsprechend korrigiert, Funktion bewusst als defensive, unabhängig getestete Pure Function belassen.
- **MINOR (Finding 3) — Stoppuhr-Soll/Ist-Namenskollision:** `findMeasuredMatch` nahm bei mehreren `process_steps` gleichen normalisierten Namens still den ersten Treffer. Jetzt: bei >1 Treffer wird `null` zurückgegeben (Vergleich unterdrückt statt zu raten). ID-basiertes Matching via `qaf_process_mappings` bleibt P4.
- **MINOR (Finding 5) — RPC-Fehlercodes `invalid_step_count`/`import_id_conflict`** fielen in den generischen `db_error`-Zweig („bitte später erneut versuchen" — irreführend, da beide permanente Validierungsfehler sind, kein Retry-Fall). Jetzt eigene `QvsCreationError`-Varianten + eigene, nicht-transiente Dialog-Meldungen; `qaf-actions.ts` loggt bei `code==='db_error'` jetzt zusätzlich `error.message` serverseitig.
- **MINOR (Finding 7) — kein Test auf Editor-Render-Ebene:** neue, erste Testdatei `vsm-editor.test.tsx` (RTL): Alt-Node zeigt Checkbox/kein ⤵/kein „Quelle anzeigen", Edit lässt `fieldStatus`/`vaClass` unangetastet; qafSource-Node zeigt den Selector.
- **MINOR (Findings 8+9) — zwei veraltete Doku-Stellen:** `creation.ts`s JSDoc zu `deselectedRowIndexes` behauptete weiterhin „no caller … no dialog", obwohl genau diese PR den Dialog liefert (jetzt korrigiert: Pfad bleibt nur bis Flag-ON unerreicht). `qvs-field-labels.test.ts` bekam den FIXTURE-DATEN-REGEL-Kommentar, den CHANGELOG.md „in jeder neuen Testdatei" bereits behauptete.
- **Gate-Ergebnisse:** `typecheck` clean. `eslint` (Diff-Scope): 0 Fehler, 1 Warnung (`react-hooks/exhaustive-deps` in `vsm-editor.tsx`, vorbestehend auf `main`, unverändert durch diese Fixes, KAR-522). Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4138 grün, 0 rot** (295/304 Testdateien, 18 neue Tests gegenüber dem PR-Stand; 62 skip/36 todo unverändert). `check:profiles`: 3/3. `check:portability` (strict): alle 8 Teilchecks clean. Vertraulichkeits-Grep: keine echten Lieferanten-/Projektnamen, keine Preis-Literale. `persistence-drift.test.ts` + P2-Kern-Tests (`creation.test.ts` Happy-Path/Preview-Token) unverändert grün — nur der Fehler-Mapping-Block in `creation.ts` wurde angefasst.

## 2026-07-18 – QVS-P3: QAF→Wertstrom-UI — Entry-Points, gemeinsamer Preview-Dialog, Editor-Erweiterungen (Flag weiterhin false, Migration weiterhin NICHT applied)

Baut auf P1 (KAR-970, Mapper-Scaffold) und P2 (KAR-971, Migration-Datei + Services + Server Actions) auf. P3 liefert die UI — alles hinter `qafValueStream` (`false` in allen 3 Profilen). Keine neue Migration, keine neuen Tabellen/Spalten, keine Verhaltensänderung an den P2-Services (nur zwei additive, display-only Signatur-Ergänzungen, s. u.).

- **Entry-Point A — `app/qaf-differences/[id]` (g60- und summary-Zweig, NICHT multi_qaf/multi_qaf_variant_vs_standard — das ist P4-Varianten-Territorium):** neuer Button „Als Wertstrom übernehmen" in der Export-Button-Leiste, direkt vor dem jeweiligen Excel-Export-Button. Sichtbarkeit serverseitig über `getQafValueStreamCapability` (P2-Action, prüft das Flag selbst zuerst) je Datei-Seite — kein toter Button, wenn keine Fertigungsschritte erkannt wurden oder das Flag aus ist. Bei ALT+NEU-Vergleich zusätzlich ein kleiner Seiten-Umschalter, Default NEU.
- **Entry-Point B — `app/project/[id]/qaf`:** neue, additive Sektion „Fertigungsvergleiche (QAF)" oberhalb des bestehenden (unveränderten) `qaf_uploads`-basierten Comparison Board — listet die `qaf_comparison`-Zeilen dieses Projekts (Haupt-Engine, `qaf_file`-basiert; Legacy-Pfad B/`qaf_uploads.parsed_data` bleibt komplett unberührt, architecture.md E1), gefiltert auf `summary`/`g60`, mit mindestens einer eignungsfähigen Seite. Meiste Projekte haben heute noch keine — Sektion ist dann unsichtbar (kein leerer Rahmen).
- **EIN gemeinsamer Preview-Dialog** (`components/wertstrom/qvs-import-preview-dialog.tsx`), von beiden Entry-Points genutzt: Quelle (Dateiname), editierbarer Titel, Schritt-Checkliste in Quell-Reihenfolge (Abwählen live in der Coverage sichtbar), Coverage-Zeile („X Schritte · Y mit Zykluszeit · Z mit Kosten"), nicht-blockierende Warnungen (severity-eingefärbt), ausgeschlossene Zeilen (keine Prozessbezeichnung), aufklappbare fehlende-Felder-Liste mit Grund, Duplikat-Banner („Öffnen" / „Trotzdem neu erstellen") und ein sichtbarer degraded-Zustand („Duplikat-Prüfung nicht verfügbar" — niemals stillschweigend als 0 Duplikate gezeigt, Review-Fix-3-Kontinuität aus P2). Gleiche Overlay-/Card-Optik wie `vsm-excel-import-modal`/`vsm-lsc-import-modal`/`vsm-stoppuhr-picker-modal` — kein neues Dialog-Primitive (`components/ui/dialog.tsx` hat weiterhin 0 Verwendungen im Repo).
- **Nach Erstellen:** Redirect in `/wertstrom/[id]` + Erfolgs-Toast (bestehender `useToast`/`ToastProvider` aus `components/repository/toast.tsx`) mit Partial-Import-Bilanz, wortgleich zum ux-flow-Beispiel („18 Schritte importiert, 3 ohne Zykluszeit.").
- **Editor-Erweiterungen** (`components/wertstrom/vsm-editor.tsx`, additiv — bestehende Editor-Flows unverändert):
  - Dezente, farbrauschfreie Herkunfts-Kennzeichnung (⤵-Symbol) auf importierten Knoten + „importiert"/„geändert"-Tags neben Name/Zykluszeit/Anzahl-Mitarbeiter/Maschinentyp, aus `fieldStatus` abgeleitet; `updateNode` markiert ein Feld beim Editieren automatisch als „geändert" (nur wenn der Knoten überhaupt `fieldStatus` trägt — manuelle Knoten bleiben unberührt).
  - **„Quelle anzeigen"**-Modal (`vsm-qaf-source-modal.tsx`): alle erfassten QAF-Quellfelder mit Zelle/Original/Einheit/Confidence.
  - **vaClass-4-Wege-Auswahl** ersetzt die binäre Wertschöpfend-Checkbox; `isValueAdded` bleibt abgeleitet (`vaClass==='va'`) für die bestehenden Metriken (`vsm-metrics.ts`, unverändert). Alt-Daten ohne `vaClass` zeigen „Ungeklärt" + den alten Checkbox-Wert nur als Kontext-Hinweis — nie automatisch als „Wertschöpfend" übernommen.
  - **Stoppuhr-Soll/Ist-Vergleich**: QAF-Originalwert (aus `qafSource.fields.zykluszeit.original`, nie überschrieben) gegen den gemessenen Ø desselben Prozessnamens (`cycle_measurements`, gleiche Join-Konvention wie die bestehende `qaf_process_mappings`-Zuordnung) — reine Anzeige mit Delta, kein Auto-Overwrite. `app/wertstrom/[id]/page.tsx` liefert die Ø-Messwerte jetzt serverseitig mit (additive, optionale Felder auf `processSteps`).
  - **Wertstromanalyse-Liste** (`/wertstrom`): „aus QAF · <Dateiname>" unter dem Titel eines QVS-erzeugten Eintrags — nur abgefragt wenn das Flag an ist (die Tabelle existiert vor der Migration nicht).
- **Zwei minimale, begründete P2-Signatur-Ergänzungen** (keine Verhaltensänderung): `QvsImportPreview.sourceFileName` (preview.ts, aus bereits geladenen Daten befüllt) und der exportierte `severityForQvsWarningCode` (creation.ts — macht die bestehende `QVS_WARNING_SEVERITY`-Tabelle für die UI lesbar, statt sie ein zweites Mal zu duplizieren und damit drift-anfällig zu machen).
- **Neue reine Logik-Module** (`components/wertstrom/`, alle unit-getestet): `qvs-preview-logic.ts` (Coverage, Duplikat-Banner-Ableitung, Deselect→Payload, Bilanz-Satz), `vsm-field-status.ts` (vaClass-Auswahl-Ableitung, `isValueAdded`-Ableitung, Legacy-Hint, fieldStatus-Ableitung aus Lineage inkl. `Record<Union,true>`-Drift-Schutz), `vsm-qaf-measured-compare.ts` (Name-Match + Delta), `qvs-entry-point-sources.ts` (comparison_mode-Gating, ALT/NEU-Default), `qvs-field-labels.ts` (deutsche Feld-/Grund-/Severity-Labels).
- **PRODUCT_SPEC.md / UI_FLOWS.md** (Feature-Parity-Regel): neue Abschnitte „QAF → Wertstrom-Übernahme" (PRODUCT_SPEC §5) und „10e. QAF → Wertstrom-Übernahme" (UI_FLOWS). `lib/qaf-value-stream/README.md` Scope-Abschnitt aktualisiert (P2 „done", P3 beschrieben).
- **Tests:** 6 neue Testdateien unter `components/wertstrom/__tests__/` (Deselect→Payload, Duplikat-Banner inkl. degraded, Titel-Edit — sowohl als reine Logik-Tests als auch als `@testing-library/react`-Komponententest für den Dialog selbst; comparison_mode-Gating + Flag-an/aus-Kollaps; vaClass-Auswahl-Ableitung; fieldStatus-Ableitung aus Lineage) + 1 neuer Test in `creation.test.ts` (`severityForQvsWarningCode`) + 1 neue Assertion in `preview.test.ts` (`sourceFileName`).
- **Gate-Ergebnisse:** `typecheck` clean. `eslint` (Diff-Scope, 27 geänderte/neue `.ts`/`.tsx`-Dateien in einem Lauf): **0 Fehler**, 1 Warnung — `react-hooks/exhaustive-deps` in `vsm-editor.tsx`, verifiziert vorbestehend auf `main` (identisch vor dieser PR, bereits in TODO.md/KAR-522 als repo-weiter Sammel-Fix-Pass getrackt, nicht Teil dieses Diffs). Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4120 grün, 0 rot** (294/303 Testdateien; 62 skip/36 todo — unverändert die RLS-/Real-Korpus-gated Fälle ohne lokale DB/Korpus in diesem Lauf, keine neuen Skips). `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean. Vertraulichkeits-Grep über alle geänderten/neuen Dateien: keine echten Lieferanten-/Projektnamen, keine Preis-Literale — nur erfundene Fixture-Daten (FIXTURE-DATEN-REGEL-Kommentar in jeder neuen Testdatei).
- **Ehrlich: nur mit echter DB nach Kais' Migration-Apply end-to-end prüfbar** — heute rein durch Unit-/Komponententests + Code-Review abgedeckt, kein Browser-Lauf (nachts, kein Browser-Zwang):
  - Der komplette Happy Path „Button klicken → Preview laden → Schritt abwählen → Erstellen → Redirect + Toast" gegen eine echte `qaf_manufacturing_step`-Zeile und eine echte `value_stream_imports`-Migration.
  - Das Duplikat-Banner mit einem echten zweiten Import derselben Datei (bisher nur mit gemockten Actions/Services getestet).
  - Die neue `/wertstrom`-Listen-Abfrage gegen `value_stream_imports` (Flag zwar aus, aber die Query-Form selbst ist ungetestet gegen eine reale Tabelle).
  - Visuelle Feinheiten ohne Browser: ob das ⤵-Herkunfts-Symbol im schmalen Node-Header (160px) neben einem langen, bereits `truncate`-den Namen sauber umbricht/Platz findet (bestehendes Truncate-Verhalten, nicht durch diese PR verändert, aber erstmals mit einem zusätzlichen Geschwister-Element).
  - Die Stoppuhr-Soll/Ist-Namens-Übereinstimmung an echten Projektdaten (Exact-Match auf Prozessname — bei Schreibvarianten zwischen QAF-Prozessbezeichnung und Stoppuhr-Stationsname bleibt der Vergleich bewusst leer statt zu raten).
- **Abweichungen von ux-flow.md, mit Begründung:**
  - **Punkt 4 „Varianten & Modus" (Multi-QAF) ist NICHT Teil dieser PR** — architecture.md §7 weist Varianten-Handling explizit P4 zu; KAR-972s eigener Scope-Text nennt für Entry-Point A nur „comparison_modes summary + g60". Entry-Point B wendet dieselbe Beschränkung defensiv an (kein multi_qaf/multi_qaf_variant_vs_standard), auch wenn der KAR-972-Text das für B nicht wörtlich wiederholt — Begründung: die zugrundeliegenden Capability-/Mapper-Services sind für Multi-QAF-Container-Daten nicht ausgelegt (P4-Scope).
  - **Kein neues Kosten-/Standort-/Währungs-Formularfeld im Editor-Properties-Panel** (costPerUnit/scrapRate/machineHourRate/laborHourRate/setupCostPerUnit/location/currency/partsPerCycle) — der Bestands-Editor hatte für diese P1-Felder nie ein Formularfeld (nur Zeit-Felder + Kapazität + Mitarbeiter + Maschinentyp), und KAR-972s Editor-Erweiterungsliste nennt nur die 4 umgesetzten Punkte. Sichtbarkeit für diese Felder kommt stattdessen über „Quelle anzeigen" (read-only, mit Zelle/Original/Einheit/Confidence) — volle Editierbarkeit wäre eine ungefragte Erweiterung des Bestands-Editors gewesen.
  - **Stoppuhr-Vergleich zeigt an, überschreibt aber nicht per neuem Button** — „Nutzer wählt den Anzeige-Wert" wurde als „die bestehenden Mechanismen (Direkteingabe, Live-Stoppuhr-Übernahme, Stoppuhr-Import) bleiben die Wahl, der Vergleich liefert nur den Kontext" ausgelegt, nicht als zwei neue dedizierte Übernahme-Buttons — vermeidet einen neuen Schreib-Pfad für einen Wert, den es schon drei bestehende Wege gibt zu setzen.
  - **Titel-Vorschlag bei Entry-Point A nutzt `part_number` statt Lieferant/Projekt-Namen** — die QAF-Vergleichs-Detailseite lädt heute keinen Projekt-Join (nur `project_id`); ein zusätzliches Join wäre eine vermeidbare weitere Query für einen Titel, der im Dialog ohnehin frei editierbar ist. Entry-Point B nutzt echten `supplier_name` (dort bereits geladen).
  - **Keine PRODUCT_SPEC.md/UI_FLOWS.md-Nacharbeit für das bestehende Wertstrom/VSM-Modul selbst** — das Modul hatte nie einen eigenen Eintrag (vorbestehende Lücke, nicht durch QVS verursacht); nur die QVS-Erweiterung wurde dokumentiert. In TODO.md vermerkt.

## 2026-07-18 – QVS-P2 Review-Fixes: Audit-Erhalt bei Projekt-Löschung, ehrlicher Duplikat-Degrade, Schema-Vertrag, RPC-Regressionstests (PR #336) — Migration weiterhin NICHT applied

Adversarialer Review von PR #336 (5 Dimensionen: migration-sql, rls-security, services, test-quality, consistency) lieferte 10 CONFIRMED Findings, 0 refuted. Alle 10 gefixt in einem Folge-Commit auf demselben Branch. Kein Merge.

- **MAJOR (Fix 1) — `project_id` ON DELETE CASCADE → SET NULL:** `value_stream_imports.project_id` zerstörte bei Projekt-Löschung hart den gesamten Import-Audit-Trail (warnings/missing_field_reasons/import_snapshot), obwohl der zugehörige Wertstrom (`value_stream_maps.project_id`, bereits SET NULL) die Löschung überlebt — eine unreflektierte Asymmetrie, keine dokumentierte Entscheidung. Jetzt `ON DELETE SET NULL` + Spalte nullable, analog `value_stream_maps`. `created_by` jetzt `NOT NULL` (die RPC füllt es immer aus `auth.uid()`, ein Caller mit NULL-uid wird vorher abgelehnt); `value_stream_imports_own`-Policy um `OR created_by = auth.uid()` erweitert (exaktes `vsm_own`-Muster, `value_stream_maps`) — sonst wären verwaiste (project_id NULL) Zeilen für ihren Ersteller unsichtbar, nur noch für Admins. `schemas/qaf-value-stream-import.schema.json`: `project_id` aus `required` genommen, `["string","null"]` typisiert + Audit-Erhalt-Kommentar (analog `qaf_file_id`). Migrations-Header + `reports/qaf-value-stream-architecture.md` §3.2 dokumentieren die Entscheidung. RLS-Suite + neue RPC-Suite (Fix 6) decken die Waisen-Sichtbarkeit ab.
- **MINOR (Fix 2) — `p_import_id`-Kollisions-Pre-Check:** ein caller-gesetztes, bereits existierendes `p_import_id` löste einen rohen `unique_violation` statt eines strukturierten Fehlers aus (nur über einen direkten `.rpc()`-Call erreichbar — `creation.ts` generiert seine eigene frische `crypto.randomUUID()`, der App-Pfad ist nicht betroffen). Jetzt expliziter `IF EXISTS(...) THEN RAISE EXCEPTION 'import_id_conflict'`-Guard vor dem INSERT. Das verbleibende TOCTOU-Race zwischen zwei gleichzeitigen Calls mit identischem `p_import_id` endet weiterhin in einem unbehandelten `unique_violation` — akzeptiert, im Migrations-Header vermerkt.
- **MAJOR (Fix 3) — `duplicates.ts` silent-degrade korrigiert:** der Code-Kommentar behauptete "fails loudly"/"fail-closed" bei einem DB-Fehler (z. B. Tabelle fehlt vor Migrations-Apply) — tatsächlich degradierte die Funktion lautlos zu `[]`, ununterscheidbar von einer echten "0 Duplikate"-Antwort; der eigene Unit-Test bestätigte das Verhalten sogar explizit im Testnamen, ohne die Diskrepanz zum Kommentar zu bemerken. `findExistingImports` liefert jetzt `{ items, degraded, degradedReason? }`; `buildQafValueStreamPreview`/`buildPreviewFromRows` (→ `QvsImportPreview.duplicateCheckDegraded`) und `getQafValueStreamCapability` (→ `QvsCapabilityStatus.duplicateCheckDegraded`) reichen das Degrade-Signal weiter statt es zu verschlucken — eine künftige P3-UI kann "Duplikat-Check nicht verfügbar" zeigen statt einer stillen Falschauskunft. Falscher Kommentar korrigiert. **Korrektur zum 2026-07-17-Eintrag unten:** die dortige Aussage "degradiert bei DB-Fehler … sichtbar-geloggt auf `[]`, nie silent" war zum Zeitpunkt der Erstellung bereits unzutreffend (nur serverseitig geloggt, für jeden Aufrufer ununterscheidbar von einem echten Erfolg) — hiermit richtiggestellt, nicht rückwirkend verändert.
- **MAJOR (Fix 4+8) — `missing_field_reasons` schema-konform aggregiert:** `creation.ts` persistierte `mapped.missingFieldReasons` (P1-Array, ein Eintrag pro Vorkommnis) unverändert, obwohl das kanonische Schema (das die Migration selbst als Referenz zitiert) eine Map keyed by Feldname mit `{reason, count, rawTextSamples?}` verlangt — betraf jede Zeile der Tabelle von Tag 1 an, silent (kein Crash, nur falsche/leere Werte für jeden künftigen schema-treuen Konsumenten). Neue, private `aggregateMissingFieldReasons()` in `creation.ts`: gruppiert nach Feld, aggregiert Zählwerte, kanonisiert `not_numeric[<text>]` auf das Schema-Enum `not_numeric` + sammelt die Rohtexte in `rawTextSamples` (dedupliziert, max. 5, nie fabriziert — leer bleibt das Feld ganz weg). Bei gemischten Gründen je Feld (in echten QAF-Daten real möglich, z. B. manche Zellen leer/andere "n.a.") gewinnt der häufigste Grund (Tie-Break: zuerst aufgetreten, deterministisch); `count` bleibt immer die Gesamtsumme über alle Gründe. Scoped auf die tatsächlich importierten Schritte (nach Abwahl-Filterung), nicht auf alle geparsten Zeilen — dieselbe Disziplin wie `imported_step_count`/`import_snapshot`.
- **MAJOR (Fix 9) — `warnings` schema-konform (severity + stepRef):** `QvsWarning`-Items hatten kein `severity`-Feld (Schema-Pflichtfeld, Enum `information`/`warning`/`blocking_error`) und hießen `rowIndex` statt `stepRef`. Neue, private `toPersistedWarning()` + `QVS_WARNING_SEVERITY`-Konstante (beide aktuellen Codes `sequence_ambiguous`/`no_eligible_rows` → begründet `'warning'`, je Code explizit zugewiesen statt blinder Default) transformieren nur an der Persistenz-Grenze in `creation.ts`; die internen P1-Typen (`types.ts`, `mapper.ts`, `manufacturing-capability.ts`) bleiben unverändert.
- **Neu — Drift-Test `lib/qaf-value-stream/internal/__tests__/persistence-drift.test.ts`:** analog `mapping-drift.test.ts` (P1-Mapper vs. YAML-Vertrag), aber für die P2-Persistenzform: liest `schemas/qaf-value-stream-import.schema.json` und validiert `creation.ts`s tatsächlichen `p_payload`-Output (real ausgeführter Code, nur der RPC-Aufruf gemockt — exakt `creation.test.ts`s Muster) strukturell dagegen. Keine neue Dependency: `ajv` liegt nur transitiv in `node_modules` (kein Eintrag in `package.json`, nirgends sonst im Repo verwendet) — strukturelle Assertions (gegen dynamisch aus der Schema-Datei gelesene `required`/`enum`-Listen, nicht hartkodiert dupliziert) sind das bereits etablierte Muster aus `mapping-drift.test.ts`. Wird rot, sobald `warnings`/`missing_field_reasons` wieder vom Schema abweichen.
- **MINOR (Fix 5) — Phantom-`stepRef`-Guard:** `manual_preview_corrections` übernahm `confirmation.deselectedRowIndexes` ungefiltert — ein Client, der einen nicht existierenden `rowIndex` sendet, bekam einen korrekten `remainingNodes`-Filter (Set-basiert, bereits tolerant gegen ungültige Werte), aber einen Phantom-Audit-Eintrag für einen nie existierenden Schritt. Jetzt auf die Schnittmenge mit real existierenden `rowIndex`-Werten aus `mapped.nodes` beschränkt, bevor `manual_preview_corrections` gebaut wird — dieselbe gefilterte Liste speist jetzt auch die (unverändert korrekte) `remainingNodes`-Berechnung.
- **CRITICAL (Fix 6) — Committed RPC-Regressionstest:** `create_value_stream_from_qaf` (inkl. des NULL-Role-Bypass-Fixes aus dem P2-PR, Idempotenz, Step-Count-Guard) hatte keinerlei automatisierte Testabdeckung — jeder existierende Test mockt `.rpc()`, die RLS-Suite ruft nie die Funktion selbst auf (SECURITY DEFINER umgeht RLS by design). Neu: `__tests__/security/rpc-create-value-stream-from-qaf.test.ts`, exakt wie die RLS-Suite auf `RLS_TEST_DATABASE_URL` gegated, in `scripts/rls-test/run.sh` verdrahtet. 6 Fälle gegen echtes migriertes PostgreSQL: (1) NULL-Role-Bypass-Regression — Caller ohne `user_profiles`-Zeile und ohne Ownership wird abgelehnt (der wichtigste Fall), (2) Fresh-Create happy path, (3) Idempotenz (Zeilenzahl-Assertion, nicht nur der zurückgegebene Flag), (4) NULL-Zählwerte werden abgelehnt, (5) `p_import_id`-Kollision liefert einen sauberen Fehler (Fix 2) ohne Orphan-Zeile, (6) Waisen-Sichtbarkeit nach echter Projekt-Löschung über die reale RPC (Fix 1) — end-to-end, ergänzend zur statischen Fixture-Variante in der RLS-Suite.
- **MINOR (Fix 10) — `engine_context`-Abweichung dokumentiert:** DB ist strenger (`NOT NULL DEFAULT '{}'`) als Schema (`object|null`) — bewusst, folgenlos (jeder schema-konforme Consumer akzeptiert ein Objekt), jetzt im Migrations-Header + Schema-Kommentar festgehalten statt unbemerkt.
- **Fix 7 (Rollback-Idempotenz-Smoke) — bewusst NICHT als committed Test:** manuell gegen lokales, portables PG17 re-verifiziert (Apply→Re-Apply→Rollback→Rollback-Re-Apply, alle idempotent, keine Doppel-Objekte). Kein neuer committed Test dafür — bleibt eine repo-weite, vorbestehende Lücke (auch das Vorbild `promote_intake_to_project` hat keinen Migrations-Lifecycle-Test), s. Gate-Ergebnisse unten.
- **RLS-Test-Suite erweitert** (`scripts/rls-test/setup.sql` + `rls-value-stream-imports-isolation.test.ts`): eine Waisen-Fixture-Zeile (`project_id` NULL) + 2 neue Tests (Ersteller sieht sie weiterhin, ein Fremder nicht — Fix 1). Der bestehende Cross-User-INSERT-Test brauchte einen expliziten `created_by`-Wert (Spalte jetzt `NOT NULL`) und wurde auf eine Impersonation-Variante umgestellt (`created_by` = fremde uid → weiterhin abgelehnt); dazu 1 neuer, bewusst dokumentierter Test für die (aus `vsm_own` übernommene, nicht neu eingeführte) OR-Semantik: ein Nutzer kann eine Zeile mit `created_by = sich selbst` auch unter einem fremden `project_id` anlegen — akzeptierter Bestandsschutz, jetzt explizit getestet statt stillschweigend mitgezogen.
- **Gate-Ergebnisse:** `typecheck` clean. `eslint` (Diff-Scope, 20 neue/geänderte Dateien) 0 Fehler, 2 Warnungen (dieselben vorbestehenden `security/detect-non-literal-fs-filename` in `preview.real-files.test.ts`, unverändert). Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4052 grün, 0 rot** (288/297 Testdateien; 62 skip/36 todo — die 9 neuen Skips sind exakt die 9 neuen/erweiterten RLS+RPC-Testfälle ohne `RLS_TEST_DATABASE_URL` in diesem Lauf). `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean.
- **RLS-/RPC-Testlauf gegen echtes lokales PostgreSQL 17** (portable Binärdistribution, eigener Port, kein Docker/root nötig): Migration + Re-Apply + Rollback + Rollback-Re-Run + Re-Apply-nach-Rollback — alle idempotent, keine Doppel-Objekte (1 Tabelle/2 Policies/1 Funktion/4 Indizes durchgehend). Danach 8 Testdateien / **62 Tests, alle grün**: die 6 bestehenden RLS-/Sync-Suiten (24+5 Tests, keine Regression) + die erweiterte `rls-value-stream-imports-isolation.test.ts` (9 Tests) + die neue `rpc-create-value-stream-from-qaf.test.ts` (6 Tests, alle 6 Fälle aus Fix 6 grün, insb. Fall 1 — NULL-Role-Bypass-Regression).
- **Operator-Paket** `work/qaf-wertstrom/p2-migration/` neu geschrieben: `migration.sql` byte-identisch zum Repo-Stand (`diff` leer), `rollback.sql` unverändert (byte-identisch, `diff` leer). `APPLY.md` aktualisiert: neue Policy-Prädikat-Erwartung, `SELECT policyname, cmd FROM pg_policies WHERE tablename='value_stream_imports'`-Query ergänzt, Nullability-Verifikation für `project_id`/`created_by`, destruktives Rollback-SQL explizit ausgewiesen.
- **Ehrliche Restpunkte:** Fix 7 (Migrations-Lifecycle als committed Test) bewusst nicht automatisiert — repo-weite Lücke, siehe oben. Die in Fix 1(b) für `value_stream_imports_own` übernommene `vsm_own`-OR-Semantik (`created_by = auth.uid()` ODER Projekt-Ownership) erlaubt einem Nutzer, eine selbst-attribuierte Zeile auch unter einem fremden `project_id` anzulegen — kein neues Loch (exaktes, bewusst repliziertes Bestandsmuster aus `value_stream_maps`), aber jetzt zum ersten Mal explizit getestet statt nur stillschweigend geerbt; eine engere Policy (AND statt OR) wäre denkbar, war aber nicht beauftragt und würde vom etablierten Muster abweichen.

## 2026-07-17 – QVS-P2: QAF→Wertstrom Import-Persistenz (Migration, Services, Server Actions) — Migration NICHT applied

- **Migration `supabase-migration-value-stream-imports.sql`** (+ Rollback, KAR-971): neue Tabelle `value_stream_imports` — Audit-/Herkunfts-Record je QAF→Wertstrom-Import (`value_stream_id` FK→`value_stream_maps` CASCADE, `qaf_file_id` FK→`qaf_file` SET NULL, `project_id` NOT NULL FK→`projects` CASCADE, `file_hash`/`parser_version`/`mapping_version`, `warnings`/`missing_field_reasons`/`import_snapshot`/`manual_preview_corrections` JSONB). RLS `_own`+`_admin` ab Tag 1, exaktes KAR-799/KAR-890-Muster (NICHT das per KAR-890 geschlossene `auth.uid() IS NOT NULL`-Loch). Indizes: `value_stream_id`, `(project_id, file_hash)` (Duplikat-Check, beide aus architecture.md §3.2) + ein zusätzlicher partieller UNIQUE-Index auf `(project_id, engine_context->>'previewToken')` als Idempotenz-Grundlage (über die wörtliche Spec-Liste hinaus, explizit begründet). **NICHT applied** — Datei-Delivery only (Supabase SQL Editor, Kais), zusätzlich kopiert nach `work/qaf-wertstrom/p2-migration/` (`migration.sql`, `rollback.sql`, `APPLY.md`).
- **`create_value_stream_from_qaf`** (SECURITY DEFINER RPC, `SET search_path = public, pg_temp`, gleiches "eine Funktion = eine Transaktion"-Muster wie `promote_intake_to_project`): schreibt `value_stream_maps` + `value_stream_imports` atomar. `project_id` wird ausschließlich aus `qaf_file.project_id` abgeleitet (E4, nie Parameter); Autorisierung manuell geprüft (Owner ODER admin/masteradmin — SECURITY DEFINER umgeht RLS), "nicht gefunden" und "nicht meins" liefern dieselbe Fehlermeldung (kein Existenz-Leak, §9). Idempotent über `engine_context.previewToken` + `ON CONFLICT ... DO NOTHING`; verliert eine Funktion den Wettlauf gegen einen gleichzeitigen Call mit demselben Token, wird die bereits eingefügte `value_stream_maps`-Zeile innerhalb derselben Transaktion wieder gelöscht (kein Orphan sichtbar).
- **CRITICAL — beim direkten RPC-Test gegen echtes PG17 gefunden und gefixt (nicht vom Mock-Test erkennbar):** die ursprüngliche Autorisierungsprüfung `IF NOT FOUND OR NOT (current_user_role() = ANY(...) OR EXISTS(...)) THEN RAISE EXCEPTION` versagte lautlos für einen authentifizierten Nutzer ohne `user_profiles`-Zeile: `current_user_role()` liefert dann SQL-`NULL`, `NULL = ANY(...)` ist `NULL`, `NULL OR false` ist `NULL` (Drei-Werte-Logik), und `IF NOT NULL THEN` führt den `RAISE EXCEPTION`-Zweig in PL/pgSQL **nicht** aus (anders als bei einer RLS-Policy, wo ein `NULL`-USING-Ausdruck korrekt als "kein Zugriff" behandelt wird) — ein Nicht-Owner konnte dadurch einen Wertstrom in einem fremden Projekt anlegen. Fix: `NOT COALESCE(..., false)` erzwingt einen definitiven Boolean vor der Negation; zusätzlich `p_imported_step_count`/`p_excluded_step_count` explizit auf `IS NULL` geprüft (derselbe Fallstrick wäre über einen direkten `.rpc()`-Call mit `NULL`-Zählwerten erreichbar gewesen, unabhängig von der App-Code-Validierung). Beide Fixe gegen echtes PG17 verifiziert (Re-Test: Fremdzugriff korrekt abgelehnt, `NULL`-Zählwert korrekt abgelehnt, Idempotenz + Migration/Rollback-Idempotenz weiterhin grün).
- **Services `lib/qaf-value-stream/internal/`** (P2, alle DB-lesend nur über den RLS-scoped Caller-Client, nie `lib/supabase/admin.ts`):
  - `qaf-source.ts` (neu, nicht in der wörtlichen Datei-Liste aus architecture.md §2 — begründet ergänzt): `loadQafSourceRows` (liest `qaf_file`+`qaf_manufacturing_step`, RLS-scoped, `null` bei "nicht gefunden/nicht sichtbar" — kein Existenz-Leak), `computePreviewToken` (Fingerprint über Datei-Identität + eingeschlossene `row_index`-Menge, NICHT über Nutzer-Edits wie Titel), `mapAndFingerprint` (einziger Ort, der Mapper-Aufruf + Token-Berechnung kapselt, damit `preview.ts`/`creation.ts` niemals auseinanderdriften können).
  - `preview.ts`: `buildQafValueStreamPreview` (DB-wired) + `buildPreviewFromRows` (pure Assembly, real-korpus-testbar ohne DB) — Capability + gemappte Nodes/Connections + Duplikat-Hinweis + `previewToken` + Titel-Vorschlag.
  - `duplicates.ts`: `findExistingImports` — nur Hinweis, kein Block; degradiert bei DB-Fehler (z. B. Tabelle fehlt vor Apply) sichtbar-geloggt auf `[]`, nie silent.
  - `creation.ts`: `createValueStreamFromQaf` — leitet Nodes/Connections serverseitig aus der DB neu ab (vertraut NIEMALS einem client-gelieferten Node-Array), prüft `previewToken`-Frische (`stale_preview` bei geänderter Quelle), wendet `deselectedRowIndexes` an (Schritte per `rowIndex` abwählen + Verbindungen neu verketten), lehnt eine 0-Schritte-Erstellung ab (`no_steps_selected`).
- **Server Actions `app/wertstrom/qaf-actions.ts`** (neu, bewusst nicht in `app/qaf-differences/actions.ts`): `getQafValueStreamCapability` (leichtgewichtig, läuft NICHT den vollen Mapper), `buildQafValueStreamPreviewAction`, `createValueStreamFromQafAction` — jede Action prüft zuerst das Flag `qafValueStream` (bleibt `false` in allen 3 Profilen, unverändert aus P1), erst danach Zod-Validierung, erst danach Auth (`getClaims()`), erst danach DB.
- **RLS-Test-Harness:** bestehendes Muster (`scripts/rls-test/run.sh` + `setup.sql`, Docker) wiederverwendet, Docker-Socket für `aria` aber nicht erreichbar (`permission denied`) und Port 5432 gehört einem fremden Prozess — portable PG17-Variante (aus einer früheren Session wiederverwendbare Binärdistribution, kein `apt`/root nötig) auf eigenem Port. `__tests__/security/rls-value-stream-imports-isolation.test.ts` (6 Tests, exakt das `rls-qaf-process-mappings-isolation.test.ts`-Muster) + Fixture-Zeilen in `setup.sql` + `run.sh`-Eintrag. **Lokal gegen echtes PG17 gelaufen: 7 Testdateien / 53 Tests grün** (die 6 neuen + die 6 bestehenden RLS-/Sync-Suiten, keine Regression). Zusätzlich die RPC selbst direkt (nicht nur über RLS) smoke-getestet: Fresh-Create, Idempotenz-Hit, Fremdzugriffs-Ablehnung, `NULL`-Zählwert-Ablehnung, Migrations-Re-Run-Idempotenz, Rollback + Rollback-Re-Run-Idempotenz — alle grün nach dem Fix.
- **Tests:** 6 neue Unit-/Real-Korpus-Testdateien unter `lib/qaf-value-stream/internal/__tests__/` (`qaf-source`, `preview`, `duplicates`, `creation`, `preview.real-files`) + `app/wertstrom/__tests__/qaf-actions.test.ts` — 44 neue Tests, alle grün. `preview.real-files.test.ts` (env-gated wie `mapper.real-files.test.ts`, KAR-943-Vertraulichkeitsdisziplin: keine Datei-/Zell-/Preis-Literale, nur strukturelle Assertions) nutzt bewusst eine kleinere Stichprobe (ein Repräsentant je der 7 dev-Split-Strata statt Full-Sweep) — begründet: die inkrementelle Risikofläche gegenüber dem bereits vollständig korpus-getesteten P1-Mapper ist eine dünne Kompositionsschicht (Capability+Mapper+Titel+Token), kein neuer Zeilen-Extraktions-Code.
- **Voll-Suite** `npx vitest run --exclude "**/.claude/**"`: **4046 grün, 0 rot** (287/295 Testdateien; 53 skip/36 todo — die 6 neuen Skips sind exakt die 6 RLS-Testfälle ohne `RLS_TEST_DATABASE_URL` in diesem Lauf, im separaten Harness-Lauf oben alle 6 grün). `typecheck` clean. `eslint` (Diff-Scope, 12 neue/geänderte Dateien) 0 Fehler, 2 Warnungen desselben etablierten `security/detect-non-literal-fs-filename`-Musters wie jeder andere `*.real-files.test.ts`. `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean. Vertraulichkeits-Grep über alle geänderten Dateien: kein `metricKey === '<literal>'`-Muster, keine Klartext-Deskriptoren.
- **Kein `[plan-approved]`-CI-Gate gefunden** (`.github/workflows/` durchsucht) — entfällt damit als PR-Body-Pflicht.
- **Kein UI (P3), keine Varianten-/Kennzahlen-Erweiterung (P4), kein Sync (P5)** — reine Persistenz-Schicht, Flag bleibt `false`.

## 2026-07-17 – QVS-P1: QAF→Wertstrom Mapper-Scaffold (Modul, Flag, ADR, Korpus-Evidenz-Scan)

- **Modul `lib/qaf-value-stream/`** (ADR-019 Golden Path, KAR-970): `index.ts` (Barrel-API) + `internal/{types,mapper,naming,va-classification,manufacturing-capability}.ts` — alle pur (kein DB-/Netzwerk-/FS-Zugriff), Quelle ist konzeptionell `qaf_manufacturing_step`-Zeilen (E1), kein neues Parsing. `mapQafRowsToVsmNodes` mappt alle 22 `QAFRowValues`-Felder: 14 primär (Name + Sequenz + 12 Wertfelder) direkt auf `VsmNode`, 8 nur als strukturierte `qafSource.fields`-Metadata (Detail-Panel) — 0 Felder verworfen. `MAPPING_VERSION = 'qvs-1'`, versioniert synchron zu `mappings/qaf-to-value-stream-mapping.yaml` (`mapping-drift.test.ts` verhindert Drift zwischen YAML-Vertrag und Mapper-Konstanten). `conservativeVaClass` klassifiziert `va/nnva/nva/unknown`, niemals automatisch `va` (bricht bewusst mit dem historischen Excel-Import-Fehler `isValueAdded=true` hartkodiert). `assessManufacturingCapability` ist capability-basiert (kein G60-/INPUT-Zwang). Kosten werden nie in Zeitfelder gemappt (`ruestkosten` → `setupCostPerUnit`, niemals `setupTimeSec`); fehlende Werte werden nie 0/null-gestopft, sondern mit Grund (`no_column_mapped`/`cell_empty`/`not_numeric[…]`/`below_confidence`) in `missingFieldReasons` erfasst.
- **`lib/vsm-types.ts` additiv erweitert** (+119 Zeilen, keine bestehende Zeile verändert): `VsmNode` bekommt optionale Felder `location`, `currency`, `partsPerCycle`, `scrapRate`, `scrapCostPerUnit`, `machineHourRate`, `laborCostPerUnit` **(Review-Fix unten: umbenannt in `laborHourRate`)**, `setupCostPerUnit`, `costPerUnit`, `vaClass`, `variantTags`, `qafSource` (Lineage, `schemas/value-stream-process-source.schema.json`), `fieldStatus` (`imported`/`modified`) — `isValueAdded` bleibt geschrieben/gelesen (`isValueAdded = vaClass === 'va'`), bestehende Wertströme bleiben byte-kompatibel gültig.
- **Feature-Flag `qafValueStream`** in `FeatureFlagsSchema` + allen 3 Profilen (`default`/`bmw`/`_template`) auf `false` — kein UI-/API-/DB-Wiring in P1, reines Modul-Scaffold (E5).
- **ADR 024** (`docs/adr/024-qaf-value-stream-integration.md` + `docs/adr/README.md`-Eintrag): hält die Leitentscheidungen E1–E7 aus `reports/qaf-value-stream-architecture.md` als verbindliche Architektur-Entscheidung für den gesamten QVS-Phasenplan (P1–P7) fest.
- **Korpus-Evidenz-Scan** (`reports/qaf-value-stream-corpus-evidence.md`, Pflicht-Deliverable P1, entscheidet G4-Scope): kompletter dev-Split (227/227 Dateien geladen, 0 Ladefehler). Rüstzeit/Hand-/Maschinen-/Automatikzeit/Wartezeit/Transportzeit je Schritt: **0/227 Treffer** über alle sechs Zeit-Feldgruppen → Klasse C (ehrliche Limitation, kein Parser-Ausbau — QAF-Fertigungskosten-Sheet ist ein Kosten-, kein Flussdokument). Kapazität (97 %) und Losgröße (97 %) → Klasse A für P4, aber auf Datei- nicht Schritt-Ebene. Schichtmodell bleibt auf Schritt-Ebene Klasse C (Datei-Ebene bereits seit KAR-910 geparst).
- **Tests:** 7 Testdateien / 112 Tests unter `lib/qaf-value-stream` (Mapper, Manufacturing-Capability, Naming, VA-Klassifikation, Mapping-Drift, Barrel, Real-Files), alle grün. **Real-Korpus-Pflichttest** (`mapper.real-files.test.ts`, env-gated wie das `detect-g60-corpus-sweep`-Muster) läuft gegen echte Dateien aus dem confidential dev-Split (`/home/aria/work/qaf-corpus`): deterministische STRIDE-12-Stichprobe, 19 von 227 Dateien — 16 geparst / 3 Parse-Fehler (vorbestehendes `parseQAFTemplate`-Verhalten für strukturell andere Dateien, kein QVS-P1-Kriterium) / 0 auf Platte fehlend, 130 Fertigungsschritte extrahiert, 95,4 % Zykluszeit-Abdeckung, **0 Mapper-Fehler** (Mapper wirft nie, egal welche `QAFRow[]`-Form der reale Parser liefert).
- **Gates:** `typecheck` clean. `eslint` für alle QVS-P1-Dateien 0 Fehler (2 Warnungen desselben etablierten `security/detect-non-literal-fs-filename`-Musters wie jeder andere `*.real-files.test.ts` im Repo). Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4001 grün, 0 rot** (281/288 Testdateien; 47 skip/36 todo unverändert vorbestehend). `check:profiles`: 3/3 validiert. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean.
- **Bekannter, vorbestehender Befund (außerhalb dieses PRs):** `npm run lint` zeigt repo-weit 60 Fehler in 25 Dateien außerhalb von `lib/qaf-value-stream` (u. a. `lib/i18n/i18n-context.tsx`, `lib/ui/use-undoable-delete.ts`, diverse `components/*`) — bereits auf `main` vorhanden, von diesem Branch unverändert, nicht QVS-P1-Scope.
- **Kein UI-/API-/DB-Wiring, keine Migration in P1** (wie P0 kein Server/DB) — nächster Schritt P2 (Migration `value_stream_imports` + `preview`/`duplicates`/`creation` + Server Actions).
- **Review-Fixes PR #335 (4 bestätigte Findings, ein Folge-Commit auf demselben Branch):**
  - **CRITICAL — `laborCostPerUnit` war eine Einheiten-Lüge:** `lohnkosten` ist laut `QAF_FIELD_UNITS` (mapper.ts) ein BW/h-**Stundensatz**, kein Pro-Stück-Wert — die FEK-Formel (`canonical-fields.ts`) beweist, dass Lohn erst mit Zykluszeit/Teile-pro-Zyklus/3600 multipliziert werden muss, um pro Stück zu werden. Umbenannt in **`laborHourRate`**, exakt nach dem Muster von `machineHourRate` (ebenfalls BW/h); JSDoc jetzt ehrlich („Rate, NOT per-unit; per-unit requires FEK-style conversion … never done silently here"). Jede Fundstelle repo-weit umgezogen: `lib/vsm-types.ts`, `lib/qaf-value-stream/internal/mapper.ts`, `mappings/qaf-to-value-stream-mapping.yaml`, `reports/qaf-value-stream-field-mapping.md` (Zeile 12), `reports/qaf-value-stream-architecture.md` (§3.1) — `docs/adr/024` enthielt den Feldnamen nicht, kein Änderungsbedarf dort. YAML bleibt `qvs-1` (kein Consumer, Flag überall `false`); Korrektur im YAML-Header-Kommentar + je einem `note:`-Feld vermerkt, nicht die Historie umgeschrieben.
  - **MAJOR — `ausschusskosten` ist AW, `node.currency` ist BW:** `ausschusskosten` (Template-Header „Ausschusskosten Fertigung [AW]") landet auf `scrapCostPerUnit`, aber `currency` (aus `beschaffungswaehrung`, BW) galt laut JSDoc pauschal „für die Kosten-Felder unten" — real divergent bei wechselkurs≠1. JSDoc von `currency`/`scrapCostPerUnit` (`vsm-types.ts`) präzisiert (`currency` = BW EXCEPT `scrapCostPerUnit`); Mapping-Kommentar in `mapper.ts` analog dem bestehenden `ruestkosten`-„Kosten≠Zeit"-Callout (hier: „Kosten≠Node-Currency"). `reports/qaf-value-stream-field-mapping.md` (Zeile 8 korrigiert, nahm `ausschusskosten` bisher fälschlich in die BW-Aussage mit hinein; Zeile 10 bekommt den AW-Vermerk) + `mappings/qaf-to-value-stream-mapping.yaml` (`note:` auf beiden Einträgen) + `reports/qaf-value-stream-architecture.md` (§3.1) korrigiert. Neuer Invarianten-Test in `mapper.test.ts`: `scrapCostPerUnit` ist AW-denominiert, `qafSource.fields.ausschusskosten.unit === 'AW'`, bleibt von `node.currency` unabhängig. Keine `wechselkurs`-Konversion — bleibt P2+-Entscheidung, keine stille Umrechnung.
  - **MAJOR — Real-Test-Sampling erreichte multi-QAF-Dateien nie:** die alte STRIDE-12-Stichprobe über das unshuffled `dev.json` (19/227 Dateien) fiel systematisch in 7 kontiguierliche Stratum-Blöcke und sampelte `confirmed_multi_qaf`, `g60_detail` und den `standard_summary|none`-Schwanz NIE. Ersetzt durch einen **Full-Sweep über alle 227 dev-Split-Dateien** (Präzedenz: `detect-g60-corpus-sweep.real-files.test.ts`). Neues Ist-Ergebnis: **227 Dateien, 207 geparst / 20 Parse-Fehler / 0 auf Platte fehlend, 202 Dateien mit Schritten, 1472 Fertigungsschritte, 91,4 % Zykluszeit-Abdeckung, 0 Mapper-Fehler.** Parse-Fehler-Zahl steigt erwartungsgemäß gegenüber der alten Stichprobe (16 geparst/3 Fehler auf 19 Dateien), weil jetzt g60_detail-/multi-QAF-/none-Strata tatsächlich mitlaufen — bekanntes, vorbestehendes `parseQAFTemplate`-Verhalten für strukturell andere Dateien, **kein QVS-P1-Regressionsfehler**, ehrlich dokumentiert statt versteckt. Laufzeit ~62 s (Timeout von 120 s auf 180 s angehoben, Marge für CI-Schwankung).
  - **MINOR — `PRIMARY_TARGETS`-Duplikat:** `mapper.real-files.test.ts` leitet `PRIMARY_TARGETS` jetzt aus `PRIMARY_FIELD_MAPPINGS` ab (`[...new Set(PRIMARY_FIELD_MAPPINGS.map(m => m.target))]`) statt die 12 Ziel-Felder literal zu duplizieren — wie es die 3 Schwester-Testdateien (`mapper.test.ts`, `mapping-drift.test.ts`, `manufacturing-capability.test.ts`) bereits tun. Nimmt den `laborHourRate`-Rename aus Finding 1 automatisch mit, keine zweite Fundstelle mehr zum Pflegen.
  - **Gates (Re-Run nach allen 4 Fixes):** `typecheck` clean. `eslint` (Diff-Scope, 8 geänderte Dateien) 0 Fehler, dieselben 2 vorbestehenden `security/detect-non-literal-fs-filename`-Warnungen in `mapper.real-files.test.ts`. Voll-Suite `npx vitest run --exclude "**/.claude/**"`: **4002 grün, 0 rot** (281/288 Testdateien; 47 skip/36 todo unverändert) — +1 ggü. dem P1-Scaffold-Stand (der neue AW/BW-Invarianten-Test). `lib/qaf-value-stream` allein: 7 Testdateien / **113 Tests**, alle grün. `check:profiles`: 3/3. `check:portability` (strict, `CHECK_FORBIDDEN_LEVEL=error`): alle 8 Teilchecks clean. Vertraulichkeits-Grep über alle geänderten Dateien: keine Klartext-Dateinamen/-Sachnummern, kein `metricKey === '<literal>'`-Muster.

## 2026-07-17 – QVS-P0: QAF→Wertstrom-Integration — Analyse, Architektur, Feld-Mapping (Programm-Start, docs-only)

- **Programm-Start** „QAF to Value Stream Integration" (Spec vom 17.07.2026): One-Click „Als Wertstrom übernehmen" aus jedem QAF mit verlässlichen Fertigungsdaten — capability-basiert (kein G60-/INPUT-Zwang), additiv zum bestehenden /Wertstrom-Modul, mit Feld-genauer Source-Lineage, Import≠Manuell-Status, Preview, Varianten-Strategien, Duplikat-Schutz, Reimport-Sync und Korpus-Validierung.
- **Neue Deliverable-Struktur** `reports/` + `schemas/` + `mappings/` (Spec Sektion 38):
  - `reports/qaf-value-stream-current-state.md` — Ist-Analyse beider QAF-Pipelines, Extraktions-Stand (22 kanonische `mfg_*`-Felder → `qaf_manufacturing_step` mit `source_cells`), Capability-Schicht (bisher ohne Konsument), komplettes /Wertstrom-Inventar (Modell/API/Editor/Metriken/3 Import-Wege ohne Provenance), Entry-Point-Kandidaten, Korpus-Infrastruktur.
  - `reports/qaf-value-stream-gap-analysis.md` — 13 Gaps klassifiziert (A bauen / B korpus-evidenzbasiert / C ehrliche Limitation); zentral: QAF↔VSM-Nullverbindung, fehlende Kosten-/Scrap-/Lineage-/Status-Felder am `VsmNode`, Multi-QAF-Varianten strukturell ohne Schritt-Ebene (wird transparent limitiert, nie fabriziert).
  - `reports/qaf-value-stream-architecture.md` — Leitentscheidungen E1–E7 (aus Haupt-Engine lesen statt neu parsen; additives Node-JSONB; `value_stream_imports` als Audit-/Sync-Anker statt Dokument-Versionierung; erzwungener Projekt-Scope; Flag `qafValueStream` überall false bis Validierung), Modul-Layout `lib/qaf-value-stream/` (ADR-019), Phasenplan P0–P7 mit Gate-Muster.
  - `reports/qaf-value-stream-field-mapping.md` + `mappings/qaf-to-value-stream-mapping.yaml` (Version `qvs-1`) — alle 22 Quellfelder vollständig gemappt (14 primär, 8 metadata-only, 0 verworfen), abgeleitete Werte (konservative `vaClass`, nie auto-wertschöpfend), explizite Nicht-Mappings mit Grund (kein Zeit-Split, Rüst-KOSTEN ≠ Rüst-ZEIT).
  - `reports/qaf-value-stream-ux-flow.md` — 8-Schritt-Flow, 2 Entry-Points (`qaf-differences/[id]`-Detail, `project/[id]/qaf`), EIN Preview-Dialog, Duplikat-Banner, „Quelle anzeigen"-Panel, Stoppuhr-Soll/Ist-Vergleich.
  - `schemas/qaf-value-stream-import.schema.json` + `schemas/value-stream-process-source.schema.json` — maschinenlesbare Verträge für Import-Record und Node-Herkunft.
- **Kein Code, keine Migration in P0** — Implementierung startet mit QVS-P1 (Modul-Scaffold, Flag, Mapper + Korpus-Evidenz-Scan).

## 2026-07-17 – Offline-Sync: `updated_at` auf den syncbaren Modul-Tabellen (Prod-Log-Fehler behoben)

- **Befund (aus Prod-Postgres-Logs):** `column workshop_actions.updated_at does not exist` + `cycle_measurements.updated_at` alle paar Minuten. Ursache: `lib/offline/sync-engine.ts` nutzt `updated_at` auf jeder via `createRepository` (`lib/repositories/project-repo.ts`) syncbaren Tabelle an drei Stellen — Push-Upsert (Payload; `stripLocalFields` entfernt es nicht), Delta-Pull (`.gte('updated_at', since)`) und Last-Write-Wins-Konfliktauflösung (`resolveConflict`). Von den 5 registrierten Repos zeigt `shift_outputs`/`shiftRecordRepo` auf eine in Prod bereits gedroppte Tabelle (`supabase-migration-r6-shift-outputs-merge.sql`, Zeilen → `lsc_shift_hours`; toter Code). Von den 4 realen Tabellen hatte nur `projects` die Spalte; `process_steps`, `cycle_measurements`, `workshop_actions` nicht → jeder Server-Sync dieser Entitäten scheiterte, offline erfasste Änderungen blieben lokal.
- **Fix (rein additiv, operator-applied):** `supabase/migrations/supabase-migration-offline-sync-updated-at.sql` + Rollback. `updated_at timestamptz DEFAULT now()` auf die 3 realen Tabellen (bewusst **nicht** `shift_outputs` — existiert in Prod nicht, ein `ALTER TABLE` darauf würde die Transaktion abbrechen), exakt nach dem `projects`-Muster. **Kein** `set_updated_at`-Trigger: der Sync setzt den Zeitstempel client-seitig (wann offline geändert), ein Server-Trigger würde ihn auf `now()` überschreiben und LWW + Delta-Pull verfälschen (`projects` hat aus demselben Grund keinen). Backfill aus `created_at` (bzw. `measured_at` für `cycle_measurements`, das kein `created_at` hat), danach `NOT NULL` (sonst übersieht der Delta-Pull NULL-Zeilen dauerhaft). Prod-Zeilenzahlen klein (174/327/4) → `SET NOT NULL`-Lock unkritisch.
- **Test:** `__tests__/offline/sync-updated-at-schema.test.ts` — reproduziert den Prod-Fehler direkt (rot ohne Migration: `column ... updated_at does not exist`; grün mit). Prüft Spalten-Existenz auf allen 3, Sync-Upsert mit `updated_at` (workshop_actions + cycle_measurements), Delta-Pull-Query, `NOT NULL` nach Backfill. In `scripts/rls-test/run.sh` eingebunden. Lokaler Lauf gegen PG 17: 47/47 grün (ohne Migration: die 5 neuen Tests rot).
- **Follow-ups (nicht in diesem PR, aus dem Review):**
  1. `shift_outputs`/`shiftRecordRepo` als toter Code entfernen; der `rls-test`-Harness spiegelt `bootstrap-from-prod.sql`, der `shift_outputs` noch enthält (Snapshot-Drift wie zuvor bei den dead-pmo-tables) — die `r6`-Drop-Migration im Harness nachladen, damit künftige Fehl-Migrationen gegen Phantom-Tabellen schon lokal scheitern.
  2. `lib/offline/registry.ts`: `process_steps` steht auf `incrementalSync: false` mit Kommentar „Schema lacks updated_at", der nach #110 falsch wird. Auf Delta-Pull umstellen (Flag entfernen, Kommentar) — bewusst separat, weil der Code die **applied** Migration voraussetzt (Deploy nach Apply). `cycle_measurements`/`workshop_actions` nutzen den Delta-Pull bereits (Default), für sie ist der Fix mit #110 vollständig.
  3. `lsc_shifts`/`lsc_shift_hours` laufen über dieselbe Sync-Pipeline, haben aber einen server-seitigen `updated_at`-BEFORE-UPDATE-Trigger — genau das Muster, das diese Migration bewusst vermeidet (überschreibt Client-Timestamp → verfälscht LWW + Delta-Pull). Prüfen, ob gewollt (eigenes Ticket).

## 2026-07-17 – Review-Findings PR #331: v1-API + Kopier-Pfade jetzt flaggesichert, Payload-Regressionstests (FB-36, FB-41)

Drei Reviews (Architecture + Code-Quality) zu PR #331 (siehe Eintrag direkt darunter) fanden: Web-UI-Gating sauber, Durchsetzung unvollständig — zwei ungegatete Schreibpfade umgingen `workModeCapture`, und alle 8 bestehenden Tests prüften nur DOM-Sichtbarkeit statt Payload/Export. Kais hatte FB-36 „wie vorgeschlagen" freigegeben (`work_mode`-Payload weglassen → DB-Default `'onsite'` greift); diese Fixes setzen genau diese Entscheidung auf allen Schreibpfaden konsistent durch — **keine neue Produktentscheidung.**

- **v1-API-Route umging das Flag** (`app/api/v1/assignments/[id]/route.ts`): `PatchAssignmentBody` akzeptierte `work_mode`, der PATCH-Handler schrieb `patch` ungefiltert via `.update(patch)`, ohne `getProfile()`-Check. FIX: `if (!getProfile().features.workModeCapture) delete patch.work_mode` direkt nach dem Aufbau von `patch` — Feld weglassen (nicht null setzen), gleiches Muster wie die Modals. Geprüft: die POST/Create-Route unter `app/api/v1/assignments/route.ts` existiert nicht (die Datei hat nur `GET`, kein `work_mode`-Eingang) — kein Fix nötig, aber verifiziert statt angenommen.
- **Drei ungegatete Insert-Pfade in `components/planning/`**: `planning-client.tsx` `handleDuplicate` und `handleBulkCopy` kopierten `work_mode` von der Quellzeile unbedingt in neue Zeilen; `copy-week-modal.tsx` führte die `workModeCapture`-Prop gar nicht in seinem Interface. FIX: `workModeCapture`-Prop bis `CopyWeekModal` durchgereicht (planning-client hatte sie als Prop bereits, nur ungenutzt in diesen drei Handlern) und derselbe conditional-spread (`...(workModeCapture ? { work_mode: … } : {})`) wie in den Modals angewandt. `handleSpanResize` (~Zeile 623) ist laut Lint weiterhin toter, nicht verdrahteter Code (`@typescript-eslint/no-unused-vars`) — nur ein Kommentar ergänzt, dass er bei Reaktivierung das Flag berücksichtigen muss; bewusst NICHT verdrahtet (das wäre Scope-Creep).
- **Payload-/Export-Ebene-Regressionstests** (der eigentliche Compliance-Kern, vorher fehlend): `app/api/v1/assignments/[id]/__tests__/route.test.ts` (neue Describe-Gruppe, Supabase-Client + `getProfile()` vollständig gemockt), `components/planning/__tests__/copy-week-modal.test.tsx` (neu, Supabase-Insert vollständig gemockt), `components/planning/__tests__/create-assignment-modal.test.tsx` (erweitert um Insert-Payload-Assertions gegen einen vollständig gemockten Supabase-Client), `components/reporting/__tests__/reporting-dashboard.test.tsx` (erweitert um Export-Config-Assertions — das Export-Gate in `handleExport`, ~Zeile 604-620, ist eine zweite, vom JSX-Gate getrennte Stelle). Jeder neue Test wurde einzeln gegen eine temporär zurückgenommene Gate-Zeile laufen gelassen und war rot, bevor der Fix (wieder-)angewandt wurde. 8 neue Tests, alle grün.
- **`APP_PROFILE` dokumentiert**: neue Zeile in `docs/foundation/environment-variables.md` und neuer Kommentarblock in `.env.example` — bislang fehlte die Variable in beiden trotz des bereits im Eintrag darunter dokumentierten Risikos. Zusätzlich (optional, umgesetzt): `loadProfile()` (`config/profiles/index.ts`) loggt jetzt `logger.warn("profile.app_profile_unset_fallback_default", …)`, wenn weder ein Funktionsargument noch `APP_PROFILE` gesetzt ist. Der stille Default-Fallback bleibt unverändert bestehen (kein Boot-Fehler, Verhalten identisch) — er ist nur nicht mehr unsichtbar.
- Gates: `npm run test` 3889 grün (23 skipped, 36 todo — +8 ggü. vorigem Eintrag), `npm run typecheck` clean, `npm run lint` 0 neue Findings in geänderten/neuen Dateien (60 vorbestehende Errors + 189 Warnings in unberührten Dateien unverändert), `CHECK_FORBIDDEN_LEVEL=error npm run check:portability` clean (ein Zwischenstand hatte `bmw` in einem Kommentar in `config/profiles/index.ts` — gefunden vom forbidden-strings-Check, umformuliert statt allowlisted, da die Datei product-core ist).

## 2026-07-17 – P0-Feature-Flags: Arbeitsort-Erfassung + Berater-Auslastung abschaltbar (FB-36, FB-41)

MO-23-Feedback Welle 1, Compliance-Klammer H5 (BV Mobilarbeit, Protokollnotiz 3
vom 17.10.2024): zwei neue Composition-Profile-Flags (ADR 013,
`config/profiles/`), beide bei BMW deaktiviert, bei `default`/`_template`
weiterhin aktiv (Portabilitäts-Baseline unberührt).

- **FB-36 — `workModeCapture`** (Löffler Nr. 36 + Klockenhoff Nr. 24):
  Arbeitsort-Auswahl ("Vor Ort / Homeoffice / Remote") beim Anlegen,
  Bearbeiten und Bulk-Edit von Einsätzen ist komplett ausblendbar. Bei
  deaktiviertem Flag wird `work_mode` gar nicht mehr ins Insert-/Update-
  Payload geschrieben (`create-assignment-modal.tsx`,
  `edit-assignment-modal.tsx`, `bulk-edit-modal.tsx`) — für neue Einsätze
  greift der `NOT NULL DEFAULT 'onsite'` der DB-Spalte, statt dass die App
  einen Wert erzwingt, den der Nutzer nie eingeben konnte. Anzeige und
  Auswertung bereits gespeicherter `work_mode`-Werte (Kalenderraster,
  Assignment-Karten, Planning-Reports-Export, Audit-Log) ist bewusst
  **nicht** Teil dieser Karte — das ist FB-36-O2, eigener Folge-PR.
- **FB-41 — `consultantLoadReport`** (Löffler Nr. 41, "MA-Auslastungsanalysen
  sind bei BMW verboten"): Abschnitt "Berater-Auslastung (letzte 90 Tage)" im
  Reporting-Dashboard ist komplett ausblendbar — sowohl im Dashboard-Render
  als auch im PDF-/PPTX-Export (`reporting-dashboard.tsx`, gemeinsames
  `sections`-Array für beide Exportformate, keine separate Fundstelle in
  `lib/export/export-service.ts`).
- Beide Flags: `z.boolean()` in `FeatureFlagsSchema` (`config/profiles/profile.ts`),
  Default `true` in `default.ts`/`_template.ts`, `false` in `bmw.ts` mit
  Begründungskommentar. `getProfile().features.*` wird serverseitig in
  `app/kalender/page.tsx` bzw. `app/reporting/page.tsx` gelesen und als Prop
  durchgereicht — kein `if (tenant.isBmw)` im Core-Code (ADR 010).
- Tests (neu, rot ohne Fix — vor dem Flag rendern die Komponenten
  Arbeitsort/Berater-Auslastung unbedingt): `components/planning/__tests__/
  create-assignment-modal.test.tsx`, `edit-assignment-modal.test.tsx`,
  `bulk-edit-modal.test.tsx`, `components/reporting/__tests__/
  reporting-dashboard.test.tsx` — 8 Tests, alle grün.
- Gates: `npm run test` 3881 grün (23 skipped, 36 todo), `npm run typecheck`
  clean, `npm run lint` nur vorbestehende, unveränderte Findings in
  unberührten Dateien (0 neue in den geänderten/neuen Dateien),
  `CHECK_FORBIDDEN_LEVEL=error npm run check:portability` clean (profiles,
  forbidden-strings, boundaries, openapi, csp, secrets, hardcoded-colors,
  bmw-fonts).
- **Bekanntes Risiko (nicht Teil dieser Karte):** Die Wirksamkeit beider
  Flags in Produktion hängt an `APP_PROFILE=bmw`. Diese Variable ist weder in
  `docs/foundation/environment-variables.md` noch in `.env.example`
  dokumentiert; `loadProfile()` fällt bei fehlendem `APP_PROFILE` auf
  `default` zurück (beide Flags dann `true`). Vor Produktivsetzung verifizieren,
  dass der Vercel-Env-Wert tatsächlich gesetzt ist.

## 2026-07-17 – PMO/Projektnotizen: Team-Schreibrechte via additive RLS-Policies (FB-48, FB-49a)

- **Befund:** `supabase-migration-pmo-phase-1.sql` gab allen 9 live PMO-Tabellen
  ausschließlich owner-scoped `FOR ALL`-Policies ohne separates `WITH CHECK` —
  Postgres nutzt USING dann auch als WITH CHECK, nur der Projekt-Owner konnte
  schreiben. Jedes andere Team-Mitglied bekam beim INSERT „new row violates
  row-level security policy" (der gemeldete Produktions-Bug, ~88 % der
  PMO-Schreibpfade betroffen).
- **Fix (rein additiv, operator-applied):**
  `supabase/migrations/supabase-migration-pmo-team-write-policies.sql` +
  Rollback-Datei. Je Tabelle eine `<table>_team`-Policy (Team-Mitgliedschaft
  via SECURITY-DEFINER-Helper `public.is_project_member()` mit festem
  `search_path` — verhindert die RLS-Selbst-Rekursion auf `project_members`)
  und eine `<table>_admin`-Policy nach dem `documents_admin`-Muster (tranche-b2);
  plus `projects_member_visible` (FOR SELECT auf `projects`), damit
  Team-Mitglieder die Eltern-Projektzeile überhaupt sehen (Eltern-SELECT-Falle).
- **Vertrauensmodell (Kais-Entscheidung 2026-07-17, „Option B"):** Team-Mitglieder
  teilen sich alle Projekt-Inhalte gegenseitig — ein Mitglied darf die Zeilen
  eines Co-Mitglieds (und des Owners) im selben Projekt ändern/löschen und die
  Team-Zusammensetzung mitverwalten. Bewusst gewollt (gemeinsame PMO-Arbeit),
  keine Isolations-Lücke: die einzige harte Grenze ist die Projekt-Kante, ein
  Nicht-Mitglied hat keinen Zugriff. `project_members.role` ist rein deskriptiv
  (Anzeige-Label + Sortierung, `lib/pmo/members.ts`) und gated keine Rechte;
  Selbst-Umsetzen der Rolle hat daher keinen Rechte-Effekt. Follow-up, falls
  `role` je Rechte steuert: Rollen-Vergabe-Guard nachziehen.
- **Tests:** `__tests__/security/rls-pmo-team-write-isolation.test.ts` erweitert
  den KAR-779/KAR-788-Harness um `user_c` + `user_d` (zwei Team-Mitglieder, kein
  Owner): Owner-Regression, Team-Write (rot ohne Migration, grün mit),
  Cross-Member-Write (Mitglied ändert Co-Mitglied-Zeile = erlaubt, Option-B-Beweis),
  NULL-user_id-Zeile öffnet keinem Nicht-Mitglied Zugang, Nicht-Mitglied komplett
  ausgeschlossen; Cleanup der Insert-Artefakte für Rerun-Sicherheit.
  `scripts/rls-test/run.sh` lädt jetzt pmo-phase-1 + drop-dead-pmo-tables (spiegelt
  die Prod-Apply-Reihenfolge) + die neue Migration vor der Fixture. Lokaler Lauf:
  65/65 grün gegen PG 17.
- Bewusst offen: `projects_released_scope` (RESTRICTIVE, KAR-783) bleibt
  unangetastet — MO-2/extern-scoped Team-Mitglieder sehen nicht-freigegebene
  Projekte weiterhin nicht (separate Sicherheitsentscheidung vom 2026-06-23).

## 2026-07-16 – Review-Findings PR #330: Dropdown-Label nutzt echte Kalkulator-Zeilenauswahl, Typsicherheit, Dedup (KAR-966)

High-effort Review von PR #330 (KAR-966, siehe Eintrag direkt darunter). 5
verifizierte Rohbefunde, alle behoben.

- **[KORREKTHEIT, schwerste — Korrektur der falschen "gleiche Regel"-Behauptung
  unten]** Der Eintrag direkt darunter behauptet, `g60TabLabel` nutze „die
  PRIMÄRE Zeile (höchste Maschinenstundensatz-Rate, gleiche Regel wie
  `calculatorParamsFrom`)" — das war zum Zeitpunkt des Schreibens FALSCH:
  `g60TabLabel` filterte per `AT>0` (Maschinensatz), der echte Kalkulator
  `calculatorParamsFrom` (`app/qaf-differences/actions.ts`) filtert Kandidaten
  per `AO>0` (Zykluszeit) und nutzt `AT` nur als Tie-Breaker. Bei einer
  AO>0/AT=0-Zeile und einer AO<=0/AT>0-Zeile zeigte das Dropdown das Label der
  zweiten Zeile, lud aber die Werte der ersten — falsch beschriftete Option,
  genau der Bug, den PR #330 eigentlich beheben sollte. Ein Unit-Test
  behauptete ebenfalls fälschlich „gleiche Regel". FIX: Die Selektionslogik
  in `primaryComponentRow` (`lib/qaf-differences/internal/g60/parser.ts`,
  Export als `g60PrimaryComponentRow`) extrahiert — AO>0-Gate, dann AT-Tie-
  Break — und von BEIDEN Stellen genutzt: `g60TabLabel` und
  `calculatorParamsFrom`. Test (rot vor Fix, siehe `parser.test.ts`): Tab mit
  einer AO>0/AT=0-Zeile und einer AO<=0/AT>0-Zeile → Label UND
  `primaryComponentRow`-Auswahl stammen aus derselben (AO>0-)Zeile.
- **[CLEANUP]** `summaryCalculatorStepLabels` (`calculator-bridge.ts`)
  dupliziert nicht mehr die Key-Auswahl-Regel von
  `summaryCalculatorStepOptions` — baut die Label-Map jetzt aus deren Output.
- **[CLEANUP]** `productionFieldDelta`'s `field`-Parameter ist jetzt
  `keyof ProductionStepRates` statt `string` (`production-view.ts`) —
  Compile-Sicherheit gegen Tippfehler in der `HIGHER_IS_BETTER_FIELDS`-
  Zuordnung; Call-Site `ProdDeltaPair` (`qaf-comparison-detail.tsx`) angepasst.
- **[CLEANUP]** `productionRows.filter((r) => r.alt || r.neu).length` in
  `qaf-comparison-detail.tsx` war ein No-op (`buildProductionRateRows` fügt
  nur Zeilen mit ≥1 nicht-null Seite ein) — durch `.length` ersetzt.
- **[CLEANUP]** G60-Zweig in `kalkulator/page.tsx` mappte `data ?? []` zweimal
  (tabs + tabLabels) — jetzt ein Durchlauf, der beides baut.
- Tests: `npx vitest run lib/qaf-differences components/qaf-differences`:
  147 Dateien/2744 Tests grün (4 neu: `primaryComponentRow` + eine
  AO>0/AT=0-vs-AO<=0/AT>0-Regression für `g60TabLabel`). `tsc --noEmit`
  clean. `CHECK_FORBIDDEN_LEVEL=error npm run check:forbidden` clean.
- Ehrliche Abweichung: kein Live-Screenshot (kein `.env`/Supabase-Zugang in
  diesem Checkout) — wie im Eintrag darunter bereits vermerkt, unverändert.

## 2026-07-16 – Kalkulator-Label sprechend + Szenario-Editor capability-basiert + Produktionssicht-Diff-Highlight (KAR-966)

Drei UI-Verbesserungen aus Kais' echtem Nutzer-Feedback zum QAF-Preis-Kalkulator
und der Produktionssicht (post-launch, P3/P4 bereits live).

- **Prozessschritt-Dropdown sprechend:** der Preis-Kalkulator zeigte im
  „Kostenreiter/Prozessschritt"-Dropdown nur die nackte Nummer/Position
  („1"). Jetzt „<Name> (<Sachnummer>)" wenn beide vorliegen, sonst der
  beste verfügbare Text, „Schritt <N>" nur als letzter Fallback.
  G60: `g60TabLabel` (`lib/qaf-differences/internal/g60/parser.ts`) nutzt
  `qaf_g60_tab.part` (C15/Sachnummer) + der PRIMÄREN Zeile
  `desig`/`proc` (Bezeichnung). **KORRIGIERT (PR #330 Review, Eintrag
  darüber):** hier stand ursprünglich fälschlich „gleiche Regel wie
  `calculatorParamsFrom`" — zum Zeitpunkt dieses Commits filterte
  `g60TabLabel` per AT>0, `calculatorParamsFrom` aber per AO>0 (mit AT nur
  als Tie-Breaker); beide nutzen seit dem Review-Fix direkt
  `primaryComponentRow` (AO>0-Gate, AT-Tie-Break) als gemeinsame Quelle.
  Summary: `summaryCalculatorStepLabels`
  (`internal/capability/calculator-bridge.ts`) — bewusst GETRENNT von
  `summaryCalculatorStepOptions` (dem Lookup-Key, den `getCalculatorParams`
  matcht), damit die Anreicherung den ALT/NEU-Chip-Lookup nicht bricht.
  `CalculatorComparison.tabLabels?: Record<string,string>` (neu, additiv)
  transportiert die Anzeige-Labels; `qaf-price-calculator.tsx` zeigt
  `tabLabels[t] ?? t`.
- **Szenario-Editor (Sektion 11) capability-gated, nicht mehr blanket:**
  TECHNISCHE ENTSCHEIDUNG (nicht geraten): der G60-Szenario-Engine
  (`scenario.ts`) braucht pro Kostenreiter die vollen, spaltenfixierten
  G60-Rohzeilen (Losgröße BA, SUMIF-/Sourcing-Flags AY/L/E, Materialschrott
  X, Rüst-/Logistikanteile DB/AD, Rest-Anker rem_pu) PLUS einen je-Kostenreiter
  gecachten Angebotspreis zum Delta-Anchoring (`G60TabAggregate`) — beides
  liefert die label-basierte Summary-Extraktion (Hebel A/B,
  Fertigungskosten-/Zusammenfassungs-Blatt) strukturell NICHT und kann es
  ohne einen zweiten, komplett neuen Recompute-Engine (out of scope) auch
  nicht. Der alte Hinweistext suggerierte „G60-Detail-QAFs" als
  Typ-Voraussetzung — exakt das blanket-claim-Muster, das §23 anderswo schon
  verboten hat. Jetzt: `scenarioAvailability` (`production-view.ts`), eine
  ECHTE (wenn auch für dieses Component strukturell immer `false`)
  SectionAvailability-Funktion mit präzisem, beleg-basiertem Grund (nennt die
  fehlenden Spalten + die tatsächliche Fertigungsschritt-Anzahl), verweist auf
  die Hochrechnung (Sektion 12). `SUMMARY_PLACEHOLDERS` (jetzt vollständig
  tot) entfernt.
- **Produktionssicht (Sektion 6) Diff-Highlight:** ALT/NEU-Prozessparameter
  (Zykluszeit, MSS, Personal, Lohnsatz, Ausschuss) heben jetzt sichtbare
  Unterschiede hervor — NEU-Wert in `text-destructive`/`text-success-text`
  (dieselben Tokens wie `costDeltaColor`/`diffStatusClass` bei Movers/Bridge,
  keine neue Farbwelt) plus ▲/▼-Marker (Farbe nie einziger Träger).
  `productionFieldDelta` (`production-view.ts`) klassifiziert pro Feld via
  `HIGHER_IS_BETTER_FIELDS` (`lib/qaf/comparison.ts`), rein/testbar ohne React.
- Tests: `g60TabLabel` (parser.test.ts), `summaryCalculatorStepLabels`
  (calculator-bridge.test.ts), `scenarioAvailability` + `productionFieldDelta`
  (production-view.test.ts) — 21 neue Tests, alle grün
  (`npx vitest run lib/qaf-differences components/qaf-differences`:
  147 Dateien/2740 Tests grün). `tsc --noEmit` clean,
  `CHECK_FORBIDDEN_LEVEL=error npm run check:forbidden` clean.
- Kein Live-Screenshot (kein `.env`/Supabase-Zugang in diesem Checkout,
  Server-Komponenten brauchen echte Daten) — im PR-Report vermerkt.

## 2026-07-16 – Review-Findings PR #329: false-known geschlossen, not-a-QAF-Warnung verdrahtet, ehrliche Metriken (KAR-962)

High-effort Review von PR #329 (KAR-962 Korpus-Härtung, siehe Eintrag direkt
darunter). 9 verifizierte Rohbefunde → 7 distinkte Defekte, alle behoben.

- **[FALSE-KNOWN, KORRIGIERT] Leere-Zeilen-Gate war selbst der Bug:** die
  KAR-962-„Leere-Zeilen-Falle"-Korrektur (`materialParsed && materialParsed.length
  ? {...} : null` in `app/qaf-differences/actions.ts`) konnte „Blatt nie
  befüllt" nicht von „Blatt befüllt, dann Zeilen absichtlich gelöscht"
  unterscheiden — eine manipulierte Datei erreichte `known` ohne Warnung.
  **Zurückgerollt** auf reine Parser-Truthiness (`parsed ? {...} : null`,
  MATERIAL/SBM/RMR/LC-CN/CO2e). Rot-vor-Fix-Mutationstest (Golden-Fixture,
  Material-Blatt nachträglich geleert) in `golden-fixtures.test.ts`.
- **`sum_cost_breakdown_aw1` differenziert statt pauschal ausgeschlossen:**
  `GLOBALLY_EXCLUDED_SUMMARY_CANONICAL_IDS` → `SUMMARY_OPTIONAL_CANONICAL_IDS`
  (template-fingerprint.ts) — Kommentar dokumentiert jetzt ehrlich die
  „gemischte Evidenz" (canonical-fields.ts's eigener KAR-910-Fall-#23-Beleg,
  dass das Feld in mind. einer realen Datei vorkommt) statt „beweisbar nie
  vorhanden" zu behaupten; Ausschluss aus dem REQUIRED-Set bleibt (strukturell
  unverifizierbar für jede Datei, `locateRowUnanchored`), aber `coveredCanonicalIds`
  bleibt weiterhin ungefiltert (Präsenz wird nie versteckt).
- **not-a-QAF-Warnung verdrahtet:** `notAQafToPlausibilityIssue` hatte null
  Produktions-Call-Sites (`g60_meta.notAQaf` wurde persistiert, nie gelesen).
  Jetzt in `app/qaf-differences/[id]/page.tsx`s Standard-Vergleichs-Pipeline
  verdrahtet (gleicher Kanal wie `templateFingerprintToPlausibilityIssue`) —
  eine echte Nicht-QAF-Datei zeigt jetzt eine erklärende Warnung statt eines
  unerklärten leeren Vergleichs. Ende-zu-Ende-Test (Ingest→Persist→Render→
  Issue-Liste) in `not-a-qaf-detector.test.ts`.
- **Test-Integrität:** `template-fingerprint.test.ts`s aufgeweichte
  `toEqual(expect.arrayContaining(...))`-Assertion zurück auf strikte
  Gleichheit gegen die exakte, begrenzte erwartete-plus-optionale Menge.
- **Cleanup:** `isPremiseSheetName` delegiert jetzt an `resolveSheetRole`
  (vorher eigene Substring-Prüfung ohne MODULE_PRIORITY-Kollisionsschutz —
  „Assumptions sheet raw material" wurde fälschlich auch als `premise`
  erkannt); `detectNotAQaf` akzeptiert jetzt optional bereits berechnete
  `sheetRoles` (kein zweiter `resolveSheetRoles`-Lauf pro Ingest mehr);
  `template-fingerprint-known-calibration.real-files.test.ts`s sequenzielle
  For-Schleife über den Korpus läuft jetzt mit begrenzter Nebenläufigkeit
  (Worker-Pool, Concurrency 8).
- **Metriken korrigiert (ehrlich, nicht geschönt):** die im KAR-962-Eintrag
  unten berichtete known-Quote (78% dev / 71% validation) war ein Artefakt
  des jetzt zurückgerollten false-known-Bugs. Die tatsächliche, nach diesem
  Fix gemessene Quote ist **1/218 (0,5%) dev, 0/68 (0%) validation** — siehe
  `qaf-corpus/reports/p5-metrics.md` Abschnitt „1a" für die vollständige
  Ursachen-Diagnose: SBM/RMR erreichen auf so gut wie keiner realen Datei
  `coverageRatio = 1.0` (0/215 bzw. 0/212 auf dem dev-Split, trotz Blatt-
  Präsenz in fast jeder Datei) — dieselbe Fehlerklasse wie der
  `sum_cost_breakdown_aw1`-Bug, aber für SBM/RMR nicht in diesem PR gefixt
  (Scope-Disziplin), Folge-KAR empfohlen.

## 2026-07-16 – P5 Korpus-Härtung: Synonym-Discovery, Prämissenblatt-Scan, not-a-QAF-Detektor, Golden/Mutation/Independence-Tests, Metriken (KAR-962)

**Korrektur 16.07.2026 (siehe Eintrag oben):** die hier berichtete known-Quote
(78% dev / 71% validation) beruhte auf einem Bug (Review-Finding PR #329)
und wurde auf 0,5% dev / 0% validation korrigiert — Details im Eintrag
darüber und in `qaf-corpus/reports/p5-metrics.md` Abschnitt „1a".

QAF Corpus Generalization Epic (KAR-957), Phase P5. DuckDB-Query-getriebene
Analyse über den 227-Datei dev-Split (holdout/validation nicht angerührt
außer für die Metriken-Messung).

- **Sheet-Resolver-Synonyme (§27):** neue Rolle `premise` für
  „Prämissenblatt"/„Assumptions sheet"/„Lohnsatz"-Tabs (95%/89% Abdeckung auf
  dev/validation) — bisher `unknown`. Dokumentierte Kollision:
  „Assumptions sheet raw material" bleibt `material` (Substring-Priorität,
  akzeptierte Ungenauigkeit).
- **„known"-Threshold-Kalibrierung (§26/§35):** ~~zwei reale Bugs gefixt~~ —
  **KORRIGIERT (Review PR #329, siehe Eintrag oben): einer der beiden „Fixe"
  war selbst ein false-known-Bug und wurde zurückgerollt.** Bleibt gültig: ein
  nicht-Leitfaden-dokumentiertes SUMMARY-Feld (`sum_cost_breakdown_aw1`) im
  Erwartungs-Set. Zurückgerollt: die „Leere-Zeilen-Falle"-Korrektur in der
  MATERIAL/SBM/RMR/LC-CN/CO2e-Facetten-Konstruktion (`app/qaf-differences/actions.ts`)
  konnte „nie befüllt" nicht von „befüllt, dann gelöscht" unterscheiden.
  ~~Ergebnis: `known` erreicht jetzt 178/227 (78%) dev, 50/70 (71%) validation~~
  — **die tatsächliche, ehrliche Zahl nach dem Review-Fix ist 1/218 (0,5%) dev,
  0/68 (0%) validation** (`qaf-corpus/reports/p5-metrics.md` Abschnitt „1a").
- **Prämissenblatt-Scan für Master-Sätze (P3-Follow-up):** neue
  `premise-field-candidates.ts`, wrapt den bestehenden konservativen
  `summary-field-candidates.ts`-Scan für eine zweite Quelle (Prämissenblatt
  statt nur Zusammenfassung/Summary). Ergebnis: geringer inkrementeller
  Ertrag (3/227 dev, 0/70 validation) — siehe `qaf-corpus/reports/p5-metrics.md`
  für den vollständigen Befund inkl. Mess-Caveat.
- **not-a-QAF-Detektor (§30):** neuer `not-a-qaf-detector.ts` — klassifiziert
  strukturell (keine der 8 QAF-Modul-Rollen erkannt, kein G60, keine
  Umlageschema-Kalkulation-Familie) statt einer leeren, unerklärten
  Vergleichs-Ansicht. Additiv in `g60_meta.notAQaf` persistiert
  (UnknownPatternCollector-Record inklusive) — kein Hard-Reject, nur ein
  klarer, bilingualer Hinweis. UI-Anschluss (PlausibilityIssue) vorbereitet,
  noch nicht verdrahtet (gleiches Muster wie `templateFingerprint` selbst).
- **Golden Files + Mutations-Tests (§36):** 5 synthetische Fixtures
  (LEGACY_DE_SUMMARY, V9_SUMMARY, Blanko-aktuell, Umlageschema-Kalkulation
  wide/narrow) über die echte Parse-Pipeline, plus 3 Mutations-Tests
  (kaputter Header, verschobene Spalte, fehlendes Blatt).
- **Template-Independence-Test (§35):** committeter Kern-Test — identische
  kanonische Feld-IDs/Werte/Scopes aus zwei strukturell verschiedenen
  Familien (DE- vs. EN-Header).
- **Quality-Metrics (§38/§39):** `qaf-corpus/reports/p5-metrics.md` —
  Coverage/Klassifikation vorher/nachher, dev+validation getrennt,
  Performance-Stichprobe (p50 243ms/p95 9,2s über 20 dev-Dateien).

## 2026-07-16 – Review-Findings PR #328: Default-Detection strikt, rateDegradation sichtbar, Severity-Vertrag (KAR-961)

Nachfolge-Fix auf demselben Branch (KAR-961), high-effort Review von PR #328
(Umlageschema-Kalkulation-Onboarding + capability-basierte G60-Detection +
Structure-Guard-Degradation). 10 Rohbefunde, 8 CONFIRMED (2 Paare als
gleiche Root-Cause zusammengeführt) + 1 PLAUSIBLE + 2 Cleanups, alle
behoben oder dokumentiert.

- **detectG60-Default-Aufweichung (CONFIRMED):** `resolveInputSheetCandidate`
  (`internal/g60/parser.ts:174`) wandte den case-insensitive/getrimmten
  Synonym-Fallback UNBEDINGT an — auch unter dem unveränderten
  `DEFAULT_G60_DETECTION_CONFIG` —, was die „Default funktional identisch
  zur alten Hardcodierung"-Behauptung widerlegte (eine Datei mit
  Ratenkarten-Sheet `Input`/`INPUT ` statt exakt `INPUT` wurde neu als G60
  erkannt, obwohl sie es vorher nicht war). **Fix:** neues Config-Feld
  `inputSheetNameMatch: 'exact' | 'caseInsensitiveTrimmed'`, Default
  `'exact'` (byte-identisch zur alten `wb.sheet('INPUT')`-Prüfung) — der
  tolerante Pfad läuft NUR bei explizit gesetzter Nicht-Default-Config
  (aktuell nirgends produktiv gesetzt, dokumentierte Zukunfts-Option für
  eine spätere Kalibrierung). Rot-vor-Fix verifiziert (2 neue Tests
  schlagen exakt gegen den alten Code fehl).
- **rateDegradation unsichtbar (CONFIRMED, 3 zusammengehörige Befunde):**
  genullte SGA/Profit-Werte waren von echten Nullen ununterscheidbar, der
  `rateDegradation`-Marker wurde nie persistiert, und ein hard-broken
  Ratenkarten-File verlor seine rote Template-Severity, sobald Tabs nicht
  mehr komplett gelöscht wurden. **Fix (strikt „weniger behaupten"):**
  (a) `G60TabAggregate.SGA`/`.Profit` sind jetzt `number | null` — bei
  hard-broken Ratenkarte `null` statt `0` (§23-Prinzip, „fehlend sichtbar
  fehlend"), in `qaf_g60_tab.aggregate` UND im Scenario-Modell; (b)
  `rateDegradation` additiv in `qaf_file.g60_meta` persistiert
  (`persistence.ts`/`actions.ts`); (c) die bestehende
  „Struktur-Plausibilität (G60)"-Sektion (Pill/Section-Muster,
  `qaf-g60-detail.tsx`) zeigt die kritische Meldung bereits — Text
  korrigiert (Finding „Message stale"): benennt jetzt SGA/Profit konkret
  statt eine komplette Sektions-Löschung zu behaupten; (d)
  `classifyTemplateFingerprint` bekam ein neues, eigenes
  `inputRatesHardBroken`-Signal — hard-broken bleibt „unknown"/rot, fällt
  nicht mehr stillschweigend auf „modified"/gelb, nur weil `.tabs` seit
  KAR-961 nicht mehr geleert wird; (e) der Szenario-Editor (Sektion 11)
  wird deaktiviert (mit sichtbarem Grund,
  `SCENARIO_RATE_DEGRADED_REASON`) statt live SGA/Profit aus denselben
  unverifizierbaren Raten neu zu berechnen und von den persistierten
  `null`-Werten abzuweichen; (f) TC/Sales/HK/Sur sind native
  Excel-Formel-Ergebnisse, die dieselbe defekte Ratenkarte über
  Workbook-eigene Formeln referenzieren KÖNNEN — aus gecachten Zellwerten
  nicht prüfbar, daher NICHT als Code-Fix geschlossen, sondern explizit in
  `rateDegradation.message` als Caveat offengelegt (verifiziert PLAUSIBLE,
  ehrlich dokumentiert statt stillschweigend übergangen). Rot-vor-Fix
  verifiziert für (a)/(b)/(d).
- **Date→ISO-Stringify bei Meta-Extraktion (CONFIRMED):**
  `extractCostAllocationMeta` (`cost-allocation-family.ts`) nutzte
  `normalizePosition` (`String(v).trim()`, keine Date-Behandlung) auch für
  die WERT-Zelle — ein Excel-Datumsfeld (Angebotsdatum) wurde als
  `Date.prototype.toString()`-Text statt ISO-Datum gespeichert. **Fix:**
  Wert-Zelle nutzt jetzt `cellToString` (`summary-parser.ts`, die
  etablierte Zell-Koerzierungs-Funktion mit `instanceof Date →
  toISOString()`) statt einer neuen, lokalen Date-Behandlung. Rot-vor-Fix
  verifiziert (neuer Test mit echtem `Date`-Objekt als Zellwert).
- **Cleanups (CONFIRMED):** `LEGACY_XLS_UNSUPPORTED_MESSAGE_EN`
  (`qaf-upload-constants.ts`) war toter Code (kein Produktionsverbraucher,
  nur der eigene Test) — entfernt. Der hand-geschriebene
  Label-Scan-Loop in `extractCostAllocationMeta` wurde NICHT mit
  `summary-parser.ts`s privatem, nicht exportiertem `labelScan`
  zusammengeführt (unterschiedliche Form: `CellGetter`+feste Spalte vs.
  `unknown[][]`-Grid mit Scan-Right) — als bewusste Nicht-Zusammenführung
  dokumentiert statt erzwungen.
- **Klassifikations-Sweep wiederholt:** `detect-g60-corpus-sweep.real-files.test.ts`
  gegen den 227-Datei-dev-Split — weiterhin **BESTANDEN**, 0 unbeabsichtigte
  Diffs (Report `qaf-corpus/reports/p4-classification-diff.md` aktualisiert).
- **Neuer committeter Negativ-Anker-Test:** die offizielle Blanko-Vorlage
  (`qaf-corpus/incoming/templates/FORM_aktuell_*.xlsx`) klassifiziert als
  `standard_qaf` — nie G60, nie Multi-QAF, nie Umlageschema-Kalkulation
  (`internal/__tests__/blank-template-negative-anchor.real-files.test.ts`,
  env-gated wie die übrigen Real-File-Tests).
- Die „Ehrliche Risiken"-Liste aus dem Erst-Report war bereits im PR-Text
  und hier im CHANGELOG (Eintrag unten) dokumentiert — per `gh pr edit` um
  einen Verweis auf diesen Folge-Fix ergänzt, keine neue Risiko-Liste
  nötig.

**Tests:** `npx vitest run lib/qaf-differences` — 124 Testdateien, 2543
Tests, alle grün (123→124 durch den neuen Blanko-Anker-Test). `npx tsc
--noEmit` clean. `CHECK_FORBIDDEN_LEVEL=error npm run check:forbidden`
clean (nach Umformulierung zweier Kommentare, die versehentlich „BMW"
literal enthielten).

**Ehrliche Abweichungen:** (1) TC/Sales/HK/Sur (Finding [9]) bleiben ein
dokumentiertes Risiko, kein Code-Fix — siehe oben. (2) Die
Bucket-Rollup-Summen (`analyze.ts`/`scenario.ts`) behandeln `null`
SGA/Profit weiterhin additiv als 0 (nicht als vollständig aus der Summe
ausgeschlossen) — das entspricht dem bestehenden Muster für degradierte
Werte in Aggregaten anderswo im Modul und wird durch die
Struktur-Plausibilität-Sektion + den deaktivierten Szenario-Editor
sichtbar flankiert, ist aber kein eigenständiger „fehlend aus Summe
ausgeschlossen"-Mechanismus.

## 2026-07-16 – Umlageschema-Kalkulation-Onboarding + capability-basierte G60-Detection + Structure-Guard-Degradation (KAR-961/P4)

Vierter und letzter Baustein des QAF-Corpus-Generalization-Programms (Epic
KAR-957), höchstes Regressions-Risiko des Programms (Kalibrierungs-Regression
= 0 False-Intercepts auf dem Standard-Korpus ist hartes Gate). Ein PR, vier
Pakete.

- **Paket 1 — neue Template-Familie „Umlageschema-Kalkulation" onboarded**
  (`lib/qaf-differences/internal/capability/cost-allocation-family.ts`, neu):
  strukturbasierter Familien-Detektor (Kalkulation- + Prämissenblatt-Sheet +
  ≥3 Umlage-SEKOF-Verteilblätter, zwei Sub-Profile wide/~25 und narrow/~10
  Blätter), additive Erweiterung des P2-Sheet-Resolvers (Kalkulation→material,
  Deckblatt/Lohnsatz→input, Übersicht/Übersicht-Angebot→summary — nur für
  bereits als diese Familie erkannte Workbooks, nie global), label-verankerte
  Meta-Feld-Extraktion (Bearbeiter/E-Mail/Telefon/Angebotsdatum) und eine
  ehrliche Capability-Matrix (nie AVAILABLE fabriziert — PARTIAL/DERIVABLE/
  MISSING je nach tatsächlich kalibriertem Wissensstand). Additiv in
  `actions.ts`'s Ingest-Pfad verdrahtet (`g60_meta.capability_matrix`
  überschrieben nur bei Familien-Match, neuer `g60_meta.costAllocationFamily`
  JSONB-Diagnose-Key) — kein Schema-Change, kein neuer `template_type`-Enum-Wert.
- **Paket 2 — `detectG60` auf Capability-Signale umgestellt**
  (`internal/g60/parser.ts`, gate-audit.md B1/B2): starre `INPUT`-Sheetname +
  `^\d+_2`-Tab-Konvention durch ein `G60DetectionConfig` (INPUT-Synonyme,
  Detail-Tab-Muster-Liste, Ratenkarten-Zeilenfenster) ersetzt — Default
  bewusst funktional identisch zur alten Hardcodierung (keine Korpus-Evidenz
  für eine Weitung nötig), Infrastruktur für spätere Kalibrierung bei
  Blanko-Templates. **Beweis:** neuer env-gated Sweep-Test
  (`internal/g60/__tests__/detect-g60-corpus-sweep.real-files.test.ts`) gegen
  den 227-Datei-dev-Split — 0 unbeabsichtigte Klassifikations-Diffs, 3
  beabsichtigte (Umlageschema-Kalkulation-Onboarding). Report:
  `qaf-corpus/reports/p4-classification-diff.md` (außerhalb dieses Repos).
  Bestehende KAR-926-Kalibrierungs-Regression bleibt unverändert grün.
- **Paket 3 — Structure-Guard-Degradation statt Tab-Totallöschung**
  (`internal/g60/structure-guard.ts:495`, gate-audit.md B4): ein
  hard-broken INPUT-Ratenkarte löscht nicht mehr ALLE Tabs einer Datei —
  nur die zwei tatsächlich ratenabhängigen Felder je Tab (`SGA`/`Profit`,
  einzige zwei Felder die `extractTab` aus `rates` berechnet) werden auf 0
  degradiert; alle 9 übrigen ratenunabhängigen Row-41-Aggregate,
  Material/Labor/Manufacturing/ScrapB und alle Kostenreiter-Zeilen bleiben
  extrahiert. Neues `rateDegradation`-Feld (P1 `FacetDegradation`-Taxonomie)
  am `G60GuardedParseResult` dokumentiert die Degradation.
- **Paket 4 — Legacy-.xls-Entscheid, kein BIFF-Support**
  (`qaf-upload-constants.ts`): neue `isLegacyXlsFileName`-Prüfung an beiden
  Upload-Gates (`createQafUploadTargets` Mint + `ingestQafUpload`) liefert
  bei `.xls` eine präzise, handlungsleitende Fehlermeldung ("Bitte als
  .xlsx neu speichern") statt der generischen "unsupported file type"-
  Meldung — Reject-Verhalten selbst unverändert (`.xls` war schon vorher
  blockiert). Neuer `EmptyFieldReason`-Wert `LEGACY_FORMAT_UNSUPPORTED`
  (`internal/types.ts`), in `capability-detector.ts`s
  `degradationReasonToStatus` auf `PARSE_FAILED` gemappt.

**Tests:** `npx vitest run lib/qaf-differences` — 123 Testdateien, 2534
Tests, alle grün (inkl. 2 neuer env-gated Real-File-Suiten gegen den
vertraulichen QAF-Korpus). `npx tsc --noEmit` clean. `check:portability`
(inkl. `CHECK_FORBIDDEN_LEVEL=error`) clean — keine Kundennamen im Diff.

**Ehrliche Risiken:** (1) Umlageschema-Kalkulation-Onboarding ist
Foundation-Stage — Schwellenwerte/Spaltenpositionen sind nur gegen die
bekannten Referenzdateien verifiziert, nicht korpus-kalibriert (E/Summen
bewusst als DERIVABLE/MISSING statt geraten). (2) Der narrow-Sub-Profil-Fund
ist NICHT Teil des dev-Split-Sweeps (liegt laut `splits/dev.json` nicht im
Split) — nur über den separaten Real-File-Test gegen den vollen
Korpus-Ordner bewiesen. (3) `detectG60`s neue Config-Flexibilität ist
bewusst auf den Ist-Zustand kalibriert (keine reale Drift-Datei verfügbar,
um eine Weitung zu rechtfertigen) — echte Kalibrierungsarbeit folgt, sobald
Blanko-Templates vorliegen. (4) 1 von 3 `.xls`-Referenzdateien lädt
tatsächlich fehlerfrei (kein echtes BIFF) und bekommt daher KEINE
Sonderbehandlung — nur 2 sind wirklich betroffen.

## 2026-07-16 – Review-Findings PR #327: Werte-Scan-Grenzen, INCONSISTENT-Durchreichung, Mode-Guard, Override-Konsistenz (KAR-960)

Nachfolge-Fix auf demselben Branch (KAR-960), Review von PR #327 (Feld-Hebel
+ Preis-Kalkulator). 5 verifizierte Findings, alle behoben — 4 davon
FINANZ-KORREKTHEIT (Preis-Kalkulator-Chip-Pfad).

- **Finding #1 (KORREKTHEIT — CONFIRMED):**
  `summary-field-candidates.ts`'s `scanValueRightOf` stoppte den
  Rechts-Scan nur bei Zellen mit `:` am Ende — eine dazwischenliegende
  Label-/Text-Zelle OHNE Doppelpunkt (z.B. `["Gewinnsatz", "SGK-Satz",
  "25%"]`) wurde übersprungen, sodass die Zahl eines ANDEREN Felds
  fälschlich dem gescannten Label zugeordnet wurde und direkt in die
  Preis-Kalkulator-Formel floss. **Fix:** der Scan stoppt jetzt bei JEDER
  nicht-leeren, nicht-numerischen Zelle, außer whitelisted
  Einheiten-Suffixen (`%`, `€`, `EUR`, `BW/h`, `EUR/h`, `€/h`) direkt am
  Wert; Max-Distanz von 6 auf 3 Spalten reduziert. Exaktes
  Beispiel-Layout aus dem Finding als Test, rot vor dem Fix verifiziert
  (temporärer Revert bei laufenden Tests bestätigt 3 Fehlschläge).
- **Finding #2 (KORREKTHEIT — CONFIRMED):** `masterRatesOf()`
  (`getCalculatorParams`) verwarf den `FieldConflictStatus` —
  INCONSISTENT-aufgelöste Master-Sätze erreichten die
  Preis-Kalkulator-Chips ununterscheidbar von eindeutigen Treffern.
  **Entscheidung:** Status bis in `CalculatorSideParams`/Chip-UI
  durchgereicht (nicht als `UnavailableNotice` versteckt) — bestehendes
  „widersprüchliche Quellen"-Pill-Muster aus §7 Anomalien &amp; Treiber
  wiederverwendet, plus ACHTUNG-Tooltip-Text; ein INCONSISTENT-Wert bleibt
  weiter über den Chip übernehmbar (priorisierte Quelle, wie §7 es auch für
  die Anzeige tut), aber jetzt sichtbar markiert statt versteckt. Rot vor
  dem Fix verifiziert (5 neue Tests, `SummaryMasterRates`/
  `SummaryCalculatorSideParams` ohne `status`-Feld lassen sie fehlschlagen).
- **Finding #3 (KORREKTHEIT — CONFIRMED):** der DB-Guard
  `.eq('comparison_mode', 'g60')` in `getCalculatorParams` war entfernt und
  nur durch einen Kommentar ersetzt — `multi_qaf`/
  `multi_qaf_variant_vs_standard`-Vergleiche konnten in den
  Summary-Bridge-Zweig fallen und real aussehende, semantisch ungültige
  Kalkulator-Parameter liefern. **Fix:** expliziter Code-Guard über
  `normalizeComparisonMode` (comparison-mode.ts) — nur `g60` und `summary`
  (inkl. legacy `null`) sind erlaubt, alles andere `{ok:false}`. 5 neue
  Tests (Supabase-Mock, Muster aus `app/api/v1/oee/__tests__/route.test.ts`)
  — rot vor dem Fix verifiziert (2 Guard-Tests schlagen exakt fehl, wenn der
  alte DB-Filter-Ersatz-Code wiederhergestellt wird).
- **Finding #4 (KORREKTHEIT — CONFIRMED):**
  `manufacturingStepFieldCandidates()` bekam in `ingestQafUpload` das
  PRE-Override-`steps`-Array statt `stepsForPersistence` — `g60_meta.
  field_candidates.manufacturing` konnte von `qaf_manufacturing_step.
  raw_values` abweichen, sobald ein Field-Mapping-Override (KAR-912) aktiv
  war; der zugehörige Kommentar behauptete zudem fälschlich, die Sektionen
  „Produktionssicht/Anomalien" läsen dieses Feld (tun sie nicht — nur
  `field_candidates.summary` wird gelesen). **Fix:** `stepsForPersistence`
  verwendet, Kommentar korrigiert. Override-Konsistenz-Test in
  `manufacturing-field-candidates.test.ts` (Contract-Test: `raw_values` und
  `field_candidates.manufacturing` müssen bei aktivem Override denselben
  Wert zeigen).
- **Finding #5 (CLEANUP — CONFIRMED):** CHANGELOG-Abnahmezahlen (19/21,
  21/21, 2/2 BT) waren durch keinen committeten Assertion-Test gedeckt — nur
  ein lokales, nicht-committetes Skript belegte sie. **Fix:**
  `kar-960-hebel.real-files.test.ts` um eine echte Assertion erweitert: für
  jede `*_BT_*`-Datei im Korpus-Sample läuft jetzt derselbe
  `getCalculatorParams`-äquivalente Pfad (`buildMasterRateRows` →
  `calculatorParamsFromSummaryStep`) und muss nicht-leere
  Chip-Quelldaten liefern, sonst schlägt der Test fehl. Lokal verifiziert:
  2/2 BT-Dateien bestehen. CHANGELOG-Formulierung oben präzisiert
  ("validiert via env-gated Test + lokalem Korpus-Skript").
- Tests: `npx vitest run lib/qaf-differences components/qaf-differences`
  grün (siehe Testzahlen im Commit); `npx tsc --noEmit` sauber.

## 2026-07-16 – Prozess-Parameter aus Fertigungskosten + Summary-Row-Parser + Partial Module Population (KAR-960/P3)

QAF Corpus Generalization P3 — die zwei großen Feld-Hebel aus
`capability-matrix.md`, auf P1 (Parse-Resilienz, KAR-958) und P2
(Capability-Kern, KAR-959) aufbauend.

- **Hebel A — Prozess-Parameter aus dem Fertigungskosten-Blatt**
  (`lib/qaf-differences/internal/capability/manufacturing-field-candidates.ts`):
  wandelt die bereits von `qaf-parser.ts` extrahierten Fertigungskosten-Zeilen
  (Zykluszeit, Teile/Zyklus, MA, Lohnkosten, SGK, MSS, Ausschuss) in
  `process:<step>`-skalierte `FieldCandidate`s — Master-Prompt §21: niemals
  mit G60-INPUT-Globalwerten vermischt, immer prozessspezifisch skaliert.
- **Hebel B — Positionsbasierter Row-Parser fürs Zusammenfassungs-/
  Summary-Blatt** (`lib/qaf-differences/internal/capability/summary-field-candidates.ts`):
  (1) ein neuer, konservativer Label→Wert-Scan für die 4 Master-Sätze
  (Lohnsatz, SG&A %, Gewinn %, Scrap %) — Mehrdeutigkeit erzeugt mehrere
  `FieldCandidate`s statt zu raten (löst über `resolveAllFieldCandidates` in
  `AGREEMENT`/`INCONSISTENT` auf); (2) die bereits V11-verifizierten
  Summen (Material/Fertigung/Gesamt/Stückpreis/Scrap) werden als
  `FieldCandidate`s in dieselbe Pipeline gespiegelt. Damit hat KAR-959/P2s
  FieldCandidate-Pipeline (§14, vorher 0 Producer) erstmals reale Producer —
  `lib/qaf-differences/index.ts` exportiert sie jetzt aus dem Public-Barrel.
- **4 neue Capability-Felder** (`cap_sum_master_labor_rate`,
  `cap_sum_master_sga_rate`, `cap_sum_master_profit_rate`,
  `cap_sum_master_scrap_rate`) additiv in `field-registry.ts`.
- **Persistenz:** `ingestQafUpload` (`app/qaf-differences/actions.ts`)
  schreibt beide Hebel als `g60_meta.field_candidates.{manufacturing,summary}`
  — additive JSONB, keine Migration, gleiches Muster wie `capability_matrix`.
- **Partial Module Population + UI (Gate-Audit Top-5 #5):**
  - `qaf-comparison-detail.tsx`: die zwei pauschalen `PlaceholderSection`-Gates
    für „6. Produktionssicht / Raten" und „7. Anomalien & Treiber
    (Master-Sätze)" sind jetzt capability-basiert
    (`lib/qaf-differences/internal/capability/production-view.ts`) — Sektion
    rendert echte Daten, sobald Hebel A/B etwas liefern, sonst
    `UnavailableNotice` mit präzisem, aus den tatsächlichen Zahlen
    abgeleitetem Grund statt der alten QAF-Typ-Pauschalaussage
    ("Summary-QAFs enthalten keine Prozessdaten" — faktisch falsch für
    90 % des Korpus).
  - **Preis-Kalkulator** (`qaf-price-calculator.tsx`,
    `getCalculatorParams`): bietet jetzt auch bei Summary-Vergleichen
    Alt/Neu-Chips an, gespeist aus Hebel A (gewählter Fertigungsschritt) +
    Hebel B (Master-Sätze) — vorher lief `getCalculatorParams` nur für
    `comparison_mode: 'g60'`, jeder Summary-Vergleich bekam
    `comparisons: []`. `CalculatorSideParams`-Felder auf `number | null`
    geweitet (§22: jedes Feld unabhängig verfügbar, nie eine
    0,00-Fälschung); Chips zeigen jetzt einen Quelle-Tooltip
    (Fertigungskosten-/Zusammenfassungs-Zellreferenz).
- **Real-Validierung** — validiert via env-gated Test
  (`kar-960-hebel.real-files.test.ts`) + lokalem Korpus-Skript
  (`qaf-corpus/tools/p3-hebel-validation.mjs` → `qaf-corpus/reports/p3-validation.md`,
  21 stratifizierte LEGACY/V9-Dateien + Kais' 2 BT-Dateien, NICHT committet):
  Hebel A 19/21 (90 %), Hebel B Summen 21/21 (100 %), Hebel B Master-Sätze
  0/21 (0 % — ehrlicher Befund: die 4 Master-Sätze existieren im
  Zusammenfassungs-Blatt dieses Korpus-Formats nicht als eigene beschriftete
  Zeilen, deckt sich mit `capability-matrix.md`s unabhängigem
  14–22-%-Befund). Beide BT-Dateien liefern Prozess-Parameter und lösen den
  Kalkulator-Chip-Pfad aus (2/2 — der Abnahmefall) — jetzt zusätzlich durch
  eine committete Assertion in `kar-960-hebel.real-files.test.ts` abgesichert
  (jede `*_BT_*`-Datei im Korpus-Sample muss über den
  `getCalculatorParams`-äquivalenten Pfad, `buildMasterRateRows` →
  `calculatorParamsFromSummaryStep`, nicht-leere Chip-Quelldaten liefern,
  sonst schlägt der Test fehl — siehe Review-Findings-Eintrag unten,
  Finding 5).
- Tests: 4 neue Suiten (`manufacturing-field-candidates.test.ts`,
  `summary-field-candidates.test.ts`, `production-view.test.ts`,
  `calculator-bridge.test.ts`, 60 Tests) plus 1 env-gated Real-File-Test
  (`kar-960-hebel.real-files.test.ts`) — `npx vitest run
  lib/qaf-differences components/qaf-differences`: 2621/2621 grün.
  `npx tsc --noEmit` sauber.

## 2026-07-16 – Review-Findings PR #326: PARSE_FAILED-Durchreichung, CO2e-Merge, ehrliche Tests (KAR-959)

Nachfolge-Fix auf denselben Branch (KAR-959), Review von PR #326 (Capability-
Kern). 6 verifizierte Findings, alle behoben.

- **Finding #1 (SCHWERSTE, KORREKTHEIT — CONFIRMED):**
  `facetSignalFromManufacturingSteps` gab eine echte Parser-Degradation nie
  durch: wenn im P1-Catch-Pfad (`app/qaf-differences/actions.ts` ~942-956,
  `parseQAFTemplate` wirft) ein synthetisches leeres `steps`-Objekt gebaut
  wurde, meldete der Detector `DERIVABLE`/`NOT_YET_SUPPORTED` ("Parser
  existiert noch nicht") statt `PARSE_FAILED` ("Parser existiert, ist aber
  gescheitert") — exakt die von Master-Prompt §11 verbotene
  PARSER_NOT_IMPLEMENTED/NOT_AVAILABLE-Verwechslung. **Fix:** der P1-Catch
  hängt jetzt eine `FacetDegradation` (`reason: 'PARSE_FAILED'`) an;
  `facetSignalFromManufacturingSteps` wertet sie aus (`present:true` sobald
  eine Degradation vorliegt, analog zu `facetSignalFromRowArray`) →
  `PARSE_FAILED` mit Reason. 4 neue Unit-Tests (rot vor dem Fix verifiziert:
  temporärer Revert von `capability-detector.ts` bei laufenden Tests
  bestätigt, dass alle 4 fehlschlagen).
- **Finding #2 (KORREKTHEIT — CONFIRMED):** `mergeCo2eFacetSignals` ließ bei
  `parseConfidence`-Gleichstand immer `summarySignal` gewinnen — auch wenn
  `summarySignal.present=false` und `materialSignal.present=true` (Konfidenz
  0 im not-present-Zweig von `facetSignalFromSingleRecord`). **Fix:** ein
  present-Signal schlägt jetzt IMMER ein not-present-Signal; Konfidenz ist
  nur noch Tie-Break unter zwei present-Signalen. 3 neue Unit-Tests (Tie bei
  present/not-present, Tie unter zwei present-Signalen als Regressionsschutz,
  beide-not-present-Fall).
- **Finding #3 (CLEANUP — CONFIRMED):** `field-candidates.ts` hat null
  Produktions-Producer (nur Test-Coverage) — CHANGELOG-Eintrag oben
  präzisiert, Modul-Header-Kommentar ergänzt, `collectFieldCandidates`/
  `resolveFieldCandidates`/`resolveAllFieldCandidates`/`rankFieldCandidates`/
  `candidateValuesEqual` + `FieldScope`/`ScopedValue`/`FieldCandidate`/
  `FieldConflictStatus`/`FieldResolution` aus dem Public-Barrel
  `lib/qaf-differences/index.ts` genommen (externe Consumer-Suche über alle
  31 `qaf-differences`-Importeure: keiner gefunden) — internes Import bleibt
  bis P3 (KAR-960) verfügbar.
- **Finding #4 (CLEANUP — CONFIRMED):**
  `capability-detector.real-files.test.ts`s `assertStructurallyPlausible`
  prüfte nur `expect([alle 5 Status]).toContain(m.status)` — eine Tautologie
  (bei jedem wohlgetypten Status immer wahr). **Fix:** echte
  Verhaltens-Checks ergänzt, gegen unabhängig aus den rohen Parser-Ergebnissen
  gelesene `facetSignals` geprüft: Quelle-Sheet muss ein echter
  Workbook-Sheet-Name sein; `PARSE_FAILED` muss `reason:'PARSE_FAILED'`
  tragen; ein Modul mit `coreFieldsFound=true` + extrahierten Zeilen (ohne
  `PARSE_FAILED`-Degradation) muss `AVAILABLE`/`PARTIAL` sein (deckte beim
  ersten Lauf gegen den echten Korpus einen zu strengen Testentwurf auf —
  RMR mit `coreFieldsFound=true` aber `rowCount=0` ist legitim `MISSING`,
  Test entsprechend auf `rowCount`-Fälle verfeinert).
- **Finding #5 (CLEANUP — CONFIRMED):** der ohne-INPUT-Repräsentant im
  selben Real-File-Test hatte dieselbe Tautologie ohne jede
  Datei-spezifische Erwartung. **Fix:** aus der eigenen
  `classifyFile`-Klassifikation abgeleitete konkrete Assertions: kein
  G60-`input`-SheetRole; Fertigungskosten-Modul bei erkanntem Sheet
  `AVAILABLE`/`PARTIAL`/`PARSE_FAILED` (nie still `MISSING`/`DERIVABLE`);
  SUMMARY bei erkanntem Template mindestens `PARTIAL` — nichts hardcodiert,
  bleibt gültig wenn sich der lokale Korpus ändert.
- **Finding #6 (CLEANUP — CONFIRMED):** `summarizeWorkbookSheets(excelWb)`
  wurde in `ingestQafUpload` bis zu 3× berechnet (Zeilen ~636, ~1022, ~1255;
  ~841 auf dem G60-Pfad). **Fix:** einmal gehoisted (`workbookSheets`,
  direkt nach dem Sheet-Dimensions-Gate), an alle vier Call-Sites
  (`evaluateSheetDimensions`, `buildTemplateFingerprint`×2,
  `computeWorkbookCapabilityMatrix`) durchgereicht.

**Tests:** `npx vitest run lib/qaf-differences` — 115 Dateien, 2462 Tests
grün (2455 vorher + 7 neue Unit-Tests in `capability-detector.test.ts`;
`capability-detector`-Unterverzeichnis: 88 Tests, vorher 81). `npx tsc
--noEmit` clean. Vertraulichkeits-Grep über den vollständigen Diff:
keine Treffer (keine Secrets, keine hardcodierten Korpus-Dateinamen/
Lieferantendaten).

## 2026-07-15 – Capability-Kern: Sheet-Resolver, Field-Registry, Capability-Detector, Kandidaten-Auflösung (KAR-959)

Architektonisches Fundament für das QAF-Corpus-Generalization-Programm (Epic
KAR-957), P2 nach KAR-958/P1 (Facetten-Entkopplung + Empty-Field-Reason-
Taxonomie). Ersetzt keinen der 17 in `gate-audit.md` katalogisierten
BLOCKIEREND-Gates selbst (P3/P4-Folgearbeit) — liefert die kapazitätsbasierte
API, auf die sie umziehen (Master-Prompt §32).

Neues Modul `lib/qaf-differences/internal/capability/` (6 Dateien, ~1450 LOC
Quellcode):
- **`types.ts`** — `ScopedValue<T>` (global\|variant:\<id\>\|process:\<id\>,
  Master-Prompt §17, 3-Way-Ausschnitt der vollen §17-Taxonomie, dokumentierte
  Annahme), `SheetRole`, `CapabilityStatus` (5-Wert-Ausschnitt von §11s
  8-Wert-Vokabular, reusing KAR-958s `EmptyFieldReason` statt Duplikation),
  `WorkbookCapabilityMatrix`.
- **`sheet-resolver.ts`** (`SemanticSheetResolver`) — klassifiziert jedes
  Sheet über die bestehende `module-sheet-names.ts`-Registry (unangetastet)
  + eine rein additive, capability-layer-lokale Alias-Erweiterung ("LV Detail
  EU", "Production Cost", "Labor/Labour Value Detail", Rüstkosten/Setup,
  G60-INPUT) — Konfidenz + Signal + Begründung je Sheet, `unknown` statt
  stillem Verwerfen, MATERIAL/RMR-Substring-Kollision über feste Prioritäts-
  Reihenfolge aufgelöst (gleiches Präzedens wie `material-parser.ts`s
  `isMaterialSheetName`).
- **`field-registry.ts`** (`CanonicalFieldRegistry`) — additive Scope-Sicht
  auf die bestehende KAR-892/P1.1-Registry (`canonical-fields.ts`, ~250
  Felder), keine Duplikation. `deriveFieldScope` (level→global\|process) +
  2 dokumentierte Erweiterungsfelder (`cap_mfg_inefficiency_factor` — echter
  G60-Ineffizienz-Faktor, bisher nie registriert; `cap_sum_sga_profit_rate`
  — macht die real-korpus-bestätigte "Master-Sätze"-Lücke aus
  `capability-matrix.md` als Capability-Finding sichtbar statt sie still
  abwesend zu lassen).
- **`capability-detector.ts`** (`WorkbookCapabilityDetector`) —
  `detectWorkbookCapabilities` (reine Funktion, Facet-Signale + Sheet-Rollen
  → 8-Modul-Matrix) + `computeWorkbookCapabilityMatrix` (Adapter für die
  echten Parser-Ergebnis-Formen, Ingest-Einstiegspunkt). Generalisiert
  `gate-audit.md`s B14-Befund (die 37 "0-Fertigungskosten-Zeilen"-Dateien) zu
  einem ehrlichen `DERIVABLE`/`NOT_YET_SUPPORTED` statt `MISSING`/`AVAILABLE`
  zu verwechseln (Master-Prompt §11 explizite Anforderung).
- **`field-candidates.ts`** (`FieldCandidateCollector` +
  `FieldConflictResolver`) — sammelt alle Kandidaten je Feld, löst
  deterministisch nach Priorität (Konfidenz, Tie-Break nach
  Erfassungsreihenfolge) auf, `INCONSISTENT`-Status bei echtem
  Wert-Widerspruch statt stillem Überschreiben — verlierender Kandidat bleibt
  in `candidates` erhalten. **Status (präzisiert 2026-07-16, Review-Finding
  #3, PR #326):** hat in diesem PR NULL Produktions-Producer — nichts in
  `actions.ts` (oder sonstwo) ruft diese Pipeline auf, sie läuft
  ausschließlich in `__tests__/field-candidates.test.ts`. Bewusst NICHT
  gelöscht (P3/KAR-960 verdrahtet den ersten echten Consumer), aber ab jetzt
  ehrlich als Test-only markiert (Modul-Header-Kommentar) und aus dem
  Public-Barrel `lib/qaf-differences/index.ts` genommen (kein externer
  Consumer gefunden) — internes Export bis P3.
- **`module-capability-resolver.ts`** (`ModuleCapabilityResolver`, §32-API)
  — `shouldShowSection`/`isModuleUsable`/`summarizeCoverage`: die
  Gate-Ersatz-Prädikate, auf die B15-B17 (UI-Placeholder) migrieren können.

**Integration (Punkt 4, additiv):** `app/qaf-differences/actions.ts`
`ingestQafUpload` berechnet `computeWorkbookCapabilityMatrix` NACH allen
bestehenden Facet-Parses (kein zweiter Workbook-Load), try/catch-umwickelt
(darf Ingest nie blockieren), persistiert additiv unter
`qaf_file.g60_meta.capability_matrix` (KAR-935-JSONB-additiv-Muster — alte
Zeilen ohne den Key unberührt, keine Migration). Kein UI-Consumer in diesem
PR.

**Tests:** 80 neue Unit-Tests (5 Dateien: Sheet-Rollen inkl.
"Manufactering"-Tippfehler + Fremd-Sheet→unknown, Registry-Lookups,
Detector-Status inkl. PARSE_FAILED-Durchreichung, Kandidaten-Konflikt→
INCONSISTENT, Resolver-API) + 1 env-gated Real-File-Validierungstest (81
Tests gesamt) gegen 6
dynamisch entdeckte Repräsentanten aus dem lokalen QAF-Korpus (G60-Detail,
LEGACY_DE_SUMMARY+Fertigungskosten, V9_SUMMARY EN, Multi-QAF, ohne-INPUT,
BT-Datei) — Dateinamen werden NIE als Literal committed (KAR-926-F2-
Präzedens: dynamische Klassifikation statt Manifest/Hardcoding). Gesamte
`lib/qaf-differences`-Suite bleibt grün (115 Dateien, 2455 Tests, keine
Regression). `npx tsc --noEmit` clean, `check:portability` (strict) clean,
0 neue Lint-Errors.

## 2026-07-15 – Review-Findings PR #325: Ingest-Verdrahtung, Identity-Guards, Block-Rekonziliation (KAR-958)

Nachfolge-Fix auf denselben Branch (KAR-958), High-Effort-Review von PR #325
(9 verifizierte Findings, 4 correctness + 3 cleanup gepoolt zu 7 nach
Priorisierung). Alle 7 behoben.

- **Finding #1 (SCHWERSTE, Produktions-Wirksamkeit — CONFIRMED):** Der
  Real-Korpus-Validierungs-Claim im Eintrag unten ("vorher 37/37 komplett,
  nachher nur 2/37") wurde ausschließlich über `parseQafFile`
  (`workbook-adapter.ts`) gemessen — eine Funktion, die im echten Ingest
  (`app/qaf-differences/actions.ts`, `ingestQafUpload`) NIE aufgerufen wurde.
  `actions.ts` rief stattdessen weiterhin `parseQAFTemplate` direkt und
  UNGEFANGEN auf (Zeile ~917) — ein Header-Mismatch im Fertigungskosten-Sheet
  riss dort weiterhin den kompletten Ingest (inkl. der bereits erfolgreichen
  Summary-Parse, kein DB-Insert) mit sich, exakt der Bug, den Paket 1
  angeblich behoben hatte. **Fix:** dieselbe Facetten-Isolation jetzt DIREKT
  in `actions.ts` (~916-943) verdrahtet — try/catch um `parseQAFTemplate`,
  degradiert `steps` auf ein leeres, Confidence-0-Ergebnis statt die
  Exception propagieren zu lassen; `summary`/`summaryMetrics` und alle
  anderen Facetten (MATERIAL/SBM/RMR/LOGISTICS/LC-CN/CO2e) persistieren
  jetzt wie bei einer Datei ohne Fertigungskosten-Sheet. Bewusst NICHT durch
  Umschalten auf `parseQafFile` selbst (hätte einen zweiten, redundanten
  ExcelJS-Load des in `actions.ts` bereits geladenen Workbooks eingeführt —
  verletzt die dort etablierte "ONE ExcelJS load per file"-Disziplin) —
  Kompensations-/Persistenz-Logik unangetastet. Real-Validierung über den
  Ingest-nahen Pfad wiederholt (`qaf-corpus/reports/p1-validation.md`,
  neuer Abschnitt): 37/37 → 2/37, jetzt tatsächlich am Ingest-Call-Site
  gemessen, nicht am unverdrahteten Adapter.
- **Finding #2 (Falsch-Extraktion — CONFIRMED):** `co2e-parser.ts` — der
  2-Spalten-Floor für CO2e-Material-Zeilen hatte KEINEN Identity-Feld-Guard
  (anders als material-/sbm-/rmr-/logistics-parser.ts); der Row-Push-Guard
  war "irgendein gemappter Wert reicht". Fix: mindestens eines der beiden
  Kernfelder (`emissionFactor`/`materialEmissions` — das definierende
  "Eingabe, berechnetes Ergebnis"-Paar des Sheets) muss gemappt sein UND
  einen Wert in dieser Zeile tragen; `some`, nicht `every` — die bestehende
  Teilextraktion (nur EIN Kernfeld fehlt) bleibt erhalten.
- **Finding #3 (Falsch-Extraktion — CONFIRMED):** `rmr-parser.ts` — der Floor
  war mit nur `positionNumber`+`rawMaterialDesignation` erfüllbar; Zeilen mit
  stillem `rawMaterialQuotation: null` galten als echt. Fix:
  `extractRmrRow` verlangt jetzt zusätzlich, dass der Block eine
  `rawMaterialQuotation`-Spalte überhaupt GEMAPPT hat (Header-Check, nicht
  Zellwert-Check — eine legitim leere Notierungs-Zelle in einem sonst
  intakten Header bleibt unangetastet).
- **Finding #4 (Datenverlust — CONFIRMED):** `rmr-parser.ts` — das aggregierte
  `coreFieldsFound` verwarf auch INTAKTE Blöcke aus der Rekonziliation, sobald
  irgendein anderer Block im selben File degradierte. Fix:
  `rmrRowsForReconciliation` filtert jetzt PRO BLOCK (neues
  `RmrParseMeta.degradedRowRanges`), nicht mehr blanket auf das
  Datei-Aggregat — ein intakter Block bleibt drin, ein degradierter (aber
  beitragender) Block fliegt raus; im Roh-Persist (`g60_meta.rmr.rows`)
  bleiben beide sichtbar. Rückwärtskompatibler Fallback für vor diesem Fix
  persistierte `g60_meta.rmr.parseMeta`-JSONB (fehlendes Feld → alte
  Blanket-Logik). Neuer Test für exakt die Zwei-Block-Kombination
  (intakt + degradiert-beitragend).
- **Finding #5 (CLEANUP):** `MIN_SIGNAL_MAPPED_COLUMNS` war 6× als lokales
  Literal dupliziert — jetzt ein exportiertes Shared-Const in `types.ts`,
  alle 6 Parser importieren (lccn-parser.ts unter seinem eigenen
  `MIN_SIGNAL_FOUND_LABELS`-Namen aliasiert).
- **Finding #6 (CLEANUP):** `workbook-adapter.ts` — der Manufacturing-Failure-
  Pfad lud den Workbook ein zweites Mal komplett neu, nur um den
  Sheet-Namen fürs Diagnose-Finding zu lesen, obwohl `parseSummarySheet`
  (dieselbe `Promise.allSettled`-Hälfte) den Workbook Sekunden vorher schon
  geladen hatte. Fix: `parseSummarySheet` gibt jetzt zusätzlich `sheetNames`
  zurück, kein zweiter Load mehr.
- **Finding #7 (CLEANUP):** fehlende „fremdes Sheet bleibt leer, keine
  Degradation"-Anker-Tests für co2e + rmr (analog zu den bestehenden
  material-/sbm-/logistics-/lccn-Tests) ergänzt — decken nach Finding #2/#3
  jetzt auch die neuen Identity-Guards ab.
- Test-first für #2/#3/#4/#7: `npx vitest run lib/qaf-differences` — 109
  Testdateien / 2374 Tests grün (2 neue Tests ggü. dem Vorzustand plus die
  angepassten/erweiterten RMR-Fälle). `npx tsc --noEmit` sauber.

## 2026-07-15 – Facetten-Parse entkoppelt + Parser-Resilienz + Empty-Field-Reasons (KAR-958)

QAF-Corpus-Generalization-Programm (KAR-957), P1 — Gates #1+#2 der in
`qaf-corpus/reports/gate-audit.md` dokumentierten Ersetzungs-Reihenfolge
(B14, B5-B11).

- **Paket 1 (Parse-Entkopplung, gate-audit B14):** `lib/qaf-differences/internal/workbook-adapter.ts`
  `parseQafFile` — das ungefangene `Promise.all([parseSummarySheet,
  parseQAFTemplate])` koppelte Summary- und Fertigungskosten-Parse: ein
  Header-Mismatch im Fertigungskosten-Sheet warf `parseQAFTemplate` und riss
  die bereits erfolgreiche Summary-Parse mit sich. Jetzt `Promise.allSettled`
  — ein Fertigungskosten-Fehler degradiert nur diese eine Facette (`steps:
  []` + strukturiertes `manufacturingDegradation`-Finding), Summary/
  SummaryMetrics bleiben erhalten. Best-effort Sheet-Name-Diagnose nur auf
  dem seltenen Fehlerpfad (kein zweiter Workbook-Load im Normalfall).
- **Paket 2 (coreFieldsFound-Resilienz, gate-audit B5-B11):** `material-`,
  `sbm-`, `rmr-`, `logistics-`, `lccn-`, `co2e-parser.ts` — das identische
  All-or-nothing-Muster (1 fehlendes Kernfeld → alle gemappten Spalten
  verworfen) durch Teilextraktion ersetzt. Neue `MIN_SIGNAL_MAPPED_COLUMNS`/
  `MIN_SIGNAL_FOUND_LABELS`-Untergrenze (≥2 erkannte Spalten/Labels) je Modul
  verhindert Falsch-Extraktion aus wirklich leeren/fremden Sheets — bleibt
  unterhalb der Grenze exakt das alte Verhalten. `rmr-parser.ts`
  (Block-Layout) zusätzlich: ein degradierter Block trägt jetzt eigene Zeilen
  bei (sofern sein Row-Identity-Feld selbst erhalten blieb), Top-Level
  `coreFieldsFound` bleibt `true`, solange kein degradierter Block tatsächlich
  Zeilen beisteuert (Rückwärtskompatibilität mit dem bestehenden
  Sicherheitsnetz-Signal `possibleUnparsedBlockRow`).
- **Paket 3 (Empty-Field-Reason-Taxonomie):** `types.ts` — neuer Typ
  `EmptyFieldReason` (`MISSING_IN_WORKBOOK` / `PARSE_FAILED` /
  `NOT_YET_SUPPORTED` / `GATED_BY_MODE` / `INCONSISTENT`) + `FacetDegradation`
  (Facette/Grund/Sheet/Message). Von Paket 1 (`manufacturingDegradation`) und
  Paket 2 (`degradation` auf jeder ParseMeta) befüllt, aktuell ausschließlich
  `PARSE_FAILED`. Minimal-invasiv — kein UI-Konsum (P3-Folgearbeit).
- Neue Unit-Tests je Paket (`parse-qaf-file.test.ts`: Entkopplung inkl.
  0-Sheet-Fall; `material-/sbm-/rmr-/logistics-/lccn-/co2e-parser.test.ts`:
  Teil-Header-Extraktion + Mindest-Signal-Anker) — bestehende Degradations-Tests
  entsprechend der neuen (gewollten) Teilextraktions-Semantik angepasst.
- Real-Korpus-Validierung (lokal, nicht committet, `qaf-corpus/tools/p1-validate.mjs`):
  über die 37 „0-Steps"-Dateien (batch-results.json, alle mit nicht-leerem
  `errors`) — vorher warf `parseQafFile` bei 37/37 komplett (Summary UND
  Steps verloren); nachher nur noch bei 2/37 (den bereits bekannten
  Legacy-`.xls`-Ladefehlern, außerhalb des Scopes). Die übrigen 35/37 liefern
  jetzt Summary + ein strukturiertes `PARSE_FAILED`-Degradations-Finding statt
  eines Crashs.
  **Korrektur (siehe Eintrag oben, "Review-Findings PR #325"):** diese Zahlen
  galten NUR für `parseQafFile` selbst — der echte Ingest (`actions.ts`)
  rief diese Funktion nie auf und blieb bis zum Review-Fix ungeschützt.

## 2026-07-14 – Multi-QAF Rollout/Monitoring-Runbook + Rollback-Doku (KAR-954)

Master-Prompt §25, P5.2 des Multi-QAF-Programms — betriebstaugliches
Runbook, jede Behauptung mit Datei:Zeile-Beleg aus dem echten Code.

- **Neu: `docs/runbooks/multi-qaf-rollout.md`** — 5 Sektionen: (1)
  Flag-Mechanik von `multiQafDetection.enabled` inkl. `configVersion`-Pinning
  für Alt-Vergleiche über den dedizierten `resolveReplaceMultiQafDetectionConfig`-
  Resolver (rehydrate.ts), (2) Ein-Zeilen-Rollback + Nachweis, dass bereits
  persistierte `comparison_mode: 'multi_qaf'`-Vergleiche lesbar bleiben (die
  Detailseite verzweigt nur über `comparison_mode`, nie über das Flag), (3)
  Backward-Compat-Nachweis — keine Migration für Multi-QAF nötig
  (`g60_meta` ist seit KAR-840/Migration #105 additiv-JSONB,
  `comparison_mode` ein Constraint-loses TEXT-Feld), mit konkreten
  Test-Verweisen, (4) Monitoring-Empfehlung inkl. Log-Query-Mustern, (5)
  Fail-Safety-Nachweis entlang des Ingest-Pfads (fail-closed
  Container-Assembly, Kompensation bei Batch-/Replace-Fehlern, Zeiger-Rollback
  bei fehlgeschlagenem Recompare nach Datei-Replace).
- **Monitoring-Lücke geschlossen**: `lib/qaf-differences/internal/multi-qaf/`
  hatte keinerlei `logger`/Sentry-Instrumentierung. Zwei minimale
  `logger.warn`-Aufrufe in `app/qaf-differences/actions.ts` ergänzt (Stil der
  umliegenden Aufrufe): `qaf.multi_qaf.container_assembly_failed` (fail-closed
  Reject vor jedem `qaf_file`-Insert) und `qaf.multi_qaf.review_required`
  (reviewRequired-Quote, vorher nur per Ad-hoc-Query sichtbar). Kein neuer
  Test — `actions.ts` hat projektweit keine eigene Testdatei (DB-gebundene
  Glue-Schicht), also kein Bruch bestehender Test-Konventionen.
- `npx vitest run lib/qaf-differences`: 106 Testdateien / 2283 Tests grün.

## 2026-07-14 – Multi-QAF Golden-Fixture-Suite, §22-Coverage-Manifest, E2E-Skeleton (KAR-953)

Test-only PR — kein Engine-/UI-/Actions-Code geändert (kein echter Bug
gefunden). Master-Prompt §21/§22 systematisch geschlossen.

- **golden-fixtures.ts** (neu): ExcelJS-Builder, die echte .xlsx-Workbooks
  in memory bauen (Buffer via `workbook.xlsx.writeBuffer()`) und über die
  ECHTE Pipeline laufen (`loadExcelWorkbook` → `detectMultiQaf` →
  `assembleMultiQafContainer` → `runMultiQafCompareFlow`) — anders als
  `synthetic-fixtures.ts`, das bereits fertige `MultiQafContainer`-Objekte
  baut und die xlsx-Parsing-Schicht überspringt. 7 Builder decken alle 13
  §21-Punkte ab (M-QAF v1/v2, 5/8/12+ Varianten, reservierte Leerspalten,
  Aux-Vergleichsspalten, hidden Setup-Cost-Sheet, externe Formel-Referenz,
  shared Material-Liste, Varianten-Mengen-Matrix, Fertigungsprofil-Bindung,
  added+removed Varianten, cached "#N/A" in nicht-kritischem Feld).
- **golden-fixtures.test.ts** (neu): 27 Tests — je Fixture Detection-Ergebnis
  + Varianten-Zählung + mind. ein Compare-Befund (Self-Compare oder echter
  Pair-Compare) durch die volle Orchestrierung.
- **spec-coverage-manifest.test.ts** (neu): Master-Prompt-§22-Rückverfolgungs-
  matrix — 39 Punkte (Detection 5, Variant 9, Material 8, Manufacturing 6,
  Structural 6, Compatibility 5), je ein `{specItem, coveredBy}`-Eintrag,
  per fs-Scan verifiziert (referenzierte Datei existiert UND enthält den
  referenzierten Testnamen-String — kein toter Verweis möglich). 3 echte
  Lücken gefunden und mit neuen Tests geschlossen (nicht im Manifest
  gefakt): Detection "filename without Multi-QAF is still correctly
  detected" + "renamed Multi-QAF file remains detectable" (Detektor liest
  strukturell nie einen Dateinamen — jetzt explizit bewiesen statt
  unbewiesene Annahme), Structural "cached #N/A in a non-critical
  validation field does not block unrelated comparison" (bisher nur auf
  Einzelzeilen-Parser-Ebene isoliert bewiesen, jetzt durch die volle
  Orchestrierung).
- **e2e/multi-qaf-workflow.spec.ts** (neu): 15-Schritt-E2E-Workflow-Test
  nach `golden-path.spec.ts`-Muster (`test.fixme`, gleiche Env-Vars, gleiche
  "nicht an echtes DOM gebunden"-Konvention — 2 real existierende Testids
  ausgenommen: `qaf-multi-qaf-status-badge`/`qaf-multi-qaf-hero`). Zwei
  Abweichungen von der wörtlichen §22-Schrittliste dokumentiert und an die
  echte App-Mechanik angepasst: (1) der Multi-QAF-XLSX-Export ist bilingual
  inline (DE+EN in EINER Datei, `export.ts`s eigener Modul-Header), keine
  zwei separaten DE-/EN-Export-Aktionen; (2) der Sprachwechsel ist der
  globale `lib/i18n`-Locale-Switcher (User-Menu, `localStorage`-Key
  `sd_locale`), kein QAF-Seiten-lokales Toggle.
- **e2e/fixtures/** (neu): `multi-qaf-baseline-v2.xlsx` (M-QAF v2, 5
  Varianten) + `multi-qaf-current.xlsx` (1 Variante entfernt, Materialpreis
  geändert) — erfundene Daten, generiert via
  `generate-multi-qaf-fixtures.mjs` (mitcommitted, reproduzierbar).
- **docs/E2E.md**: neue Sektion 6a für den Multi-QAF-E2E-Spec.

Suite: 3527 → 3594 bestandene Tests (+67, 0 Regressionen). `npx tsc --noEmit`:
nur die 4 bekannten Pre-existing-Errors in `multi-qaf-probe.manual.test.ts`
(nicht angefasst). `CHECK_FORBIDDEN_LEVEL=error npm run check:portability`:
clean.

Review-Findings (6, gefixt vor Merge): (1) `expectCleanSelfCompare` verglich
dasselbe In-Memory-Container-Objekt mit sich selbst statt eines echten
byte-for-byte Re-Parse — alle 5 Self-Compare-Fixtures parsen die
Workbook-Bytes jetzt zweimal unabhängig durch die volle Pipeline
(Detection→Assembly), erst dann vergleicht `runMultiQafCompareFlow` zwei
echte Container. (2) der "renamed Multi-QAF file"-Test rief `detectMultiQaf`
zweimal mit identischem Input auf (tautologisch) — ersetzt durch zwei
strukturell verschiedene echte Multi-QAF-Fixtures (V1/V2) plus einen
Standard-QAF-Gegenbeweis mit irreführendem "Multi"-Freitext, der korrekt
NICHT als Multi-QAF erkannt wird. (3) Manifest-Eintrag "delta and comment
columns are not variants" zitierte nur den comment_column-Test — um die
delta_column-Testreferenz aus header-parser.test.ts ergänzt (Manifest-Schema
erlaubt jetzt `testName: string | string[]`). (4) totes
`GOLDEN_FIXTURES`-Export-Objekt in golden-fixtures.ts entfernt (nirgends
importiert). (5) E2E-Generator (`generate-multi-qaf-fixtures.mjs`) dupliziert
`colLetter`/`writeIdentityBlock` bewusst statt zu importieren (Skript läuft
standalone via plain Node außerhalb des TS-Toolchains, `tsconfig.json`
schließt `e2e/` aus) — stattdessen neuer Drift-Guard-Test
(`e2e-fixture-drift-guard.test.ts`), der die eingecheckten E2E-Fixture-Dateien
durch die echte Pipeline parst und die Header-Shape gegen die
golden-fixtures-Erwartung prüft. (6) spec-coverage-manifest.test.ts las jede
referenzierte Datei einzeln (material-differ.test.ts 9×) — modul-level
`Map`-Cache liest jede Datei jetzt genau einmal.

## 2026-07-14 – KAR-949 Review-Fixes (3 verifizierte Findings, PR #322)

Review des KAR-949-XLSX-Export-Passes fand 3 bestätigte Findings — einer
correctness (Ehrlichkeits-Verstoß: falsche Property-Namen verschluckten
Varianten-IDs), zwei Cleanup (drei-/vierfache Kopien desselben XLSX-Styling-
bzw. Export-Button-Codes).

- **F1 (correctness)**: `lib/qaf-differences/internal/multi-qaf/export.ts`s
  `buildMatchingSheet` prüfte `'leftIds' in r` / `'rightIds' in r` auf den
  ambiguous/split_suspected/merge_suspected-Match-Kinds — aber
  `VariantSplitSuspectedResult` hat ein SINGULARES `leftId`/`leftIndex` und
  `VariantMergeSuspectedResult` ein SINGULARES `rightId`/`rightIndex`
  (variant-matcher.ts). Folge: split_suspected-Zeilen zeigten ALT-ID/Label
  als „—", merge_suspected-Zeilen NEU-ID/Label als „—" — genau die
  Information, welche Variante betroffen ist, fehlte. Fix: explizites
  TS-Narrowing auf `r.kind` statt String-`in`-Checks, jedes Match-Kind liest
  seine tatsächlichen Feldnamen. Neue Tests (export.test.ts, erfundene
  Q7x/T95-Fixtures, Read-back-Assertion): „Varianten_Matching split_suspected
  row exports the ALT variant id AND label (not "—")" und „...
  merge_suspected row exports the NEU variant id AND label (not "—")".
- **F2 (cleanup)**: Neues geteiltes Modul
  `lib/qaf-differences/internal/xlsx-style-helpers.ts` — BMW-CI-ARGB-Palette
  (PETROL/WHITE/FILL_*) + der ColDef-basierte `addSheet`-Header-Row-Builder
  (bisher byte-identisch in `internal/export.ts` UND `internal/g60/export.ts`
  dupliziert) + multi-qaf/export.ts's eigenes Array-Row-Sheet-Idiom
  (`newSheet`/`titleRow`/`headerRow`/`subHeaderRow`/`numFmt`/`fillCell`/
  `statusBandFill`, vorher nur dort). Alle drei Export-Builder importieren
  jetzt statt eigener Kopien. Divergente Werte bewusst NICHT vereinheitlicht:
  `g60/export.ts`s `FILL_CRIT` und `multi-qaf/export.ts`s `FILL_NEUTRAL`
  bleiben lokale Aliase (`= FILL_RISE` / `= FILL_REMOVED`) mit eigener
  Semantik. Verhalten unverändert — bestehende Export-Tests unangetastet
  grün.
- **F3 (cleanup)**: Neue geteilte Komponente
  `components/qaf-differences/qaf-xlsx-export-button.tsx`
  (`QafXlsxExportButton`, Props `comparisonId`/`action`/`label`/
  `busyLabel?`/`className?`) — ersetzt vier byte-identische
  Download-Button-Kopien (~70 Zeilen inkl. `base64ToBlob` je Datei).
  `qaf-export-button.tsx` und `qaf-g60-export-button.tsx` (die 2 etablierten
  Bestands-Modi) bleiben als dünne Wrapper — Call-Sites in `[id]/page.tsx`
  unverändert. `qaf-multi-qaf-export-button.tsx` und
  `qaf-variant-vs-standard-export-button.tsx` (beide net-new aus diesem
  KAR-949-PR) entfernt, ihre 2 Call-Sites in `[id]/page.tsx` direkt auf
  `QafXlsxExportButton` umgestellt.
- **Gates**: `npm test` 3527/3527 (Basis 3525 + 2 neue Tests, 0 Regressionen),
  `npx tsc --noEmit` weiterhin nur die 4 bekannten pre-existing Errors in
  `multi-qaf-probe.manual.test.ts`, `npm run lint` 0 neue Errors/Warnings,
  `npx next build` grün, `CHECK_FORBIDDEN_LEVEL=error npm run
  check:portability` grün, Vertraulichkeits-Grep gegen `origin/main`-Diff: nur
  der bestehende G60-Feature-Name, keine neuen Treffer.

## 2026-07-14 – KAR-949 XLSX-Export für Multi-QAF- und Variante↔Standard-Vergleiche

Bisher waren `comparison_mode: 'multi_qaf'` und `'multi_qaf_variant_vs_standard'`
per `COMPARISON_MODE_RULES.exportSupported: false` explizit vom XLSX-Export
ausgeschlossen (KAR-942/KAR-943 adversarial review). Dieser PR liefert die
zwei fehlenden Exporte nach, ohne den Standard-/G60-Export anzufassen.

- **Neu**: `lib/qaf-differences/internal/multi-qaf/export.ts` —
  `buildMultiQafExportWorkbook` (7 Sheets: Übersicht, Varianten-Matching,
  Summary-Kennzahlen je Variante, Material-Diff, Fertigungsprofile,
  Rekonziliation, Aggregat-Impact NUR mit Gate-Kontext) und
  `buildVariantVsStandardExportWorkbook` (4 Sheets: Übersicht, die 2 real
  durchgeführten Module, die 12 strukturell degradierten Module als eigener
  "Nicht verfügbar"-Block). Beide sind pure ExcelJS-Builder, tolerant
  gegenüber `null` Container/Ergebnis (Alt-Ergebnisse ohne neue Felder →
  Hinweis-Zeile statt Crash). Ehrlichkeits-Invarianten durchgängig:
  nicht_ermittelbar/nicht_prüfbar/unknown immer als eigener Status sichtbar,
  Gate-Failures prominent, mixed-currency nie verrechnet, jede Status-/
  Code-Spalte zusätzlich mit Klartext-Spalte (DE/EN).
- **Neu**: `exportMultiQafComparisonXlsx` / `exportVariantVsStandardComparisonXlsx`
  (app/qaf-differences/actions.ts) — rehydrieren exakt wie die bestehende
  `[id]/page.tsx`-Detailseite direkt aus `qaf_comparison.engine_version` +
  `qaf_file.g60_meta.multiQafContainer` (kein Re-Parse, kein zweiter
  Engine-Lauf). Eigener inline `comparison_mode`-Guard, analog
  `exportQafG60ComparisonXlsx` — `COMPARISON_MODE_RULES.exportSupported`
  bleibt für beide Modi bewusst `false` (das Flag gilt weiter nur für den
  Standard-Export), nur die Fehlermeldungstexte verweisen jetzt auf die neuen
  dedizierten Funktionen.
- **Neu**: `QafMultiQafExportButton` / `QafVariantVsStandardExportButton`
  (components/qaf-differences/) — 1:1 Kopie des bestehenden
  Download-Button-Musters, in `[id]/page.tsx` für beide Modi verdrahtet.
- **Tests**: 15 synthetische Unit-Tests (export.test.ts, inkl.
  nicht_ermittelbar-Zeile, Gate-failed-Aggregat, mixed-currency) + 2
  env-gated Real-Tests (Kais' Multi-QAF-Paar: 78 changed-/130
  nicht_ermittelbar-Zeilen, 6/6 Gate-Zeilen; Variante↔Standard mit Datei 4
  des Manifests + erstem parsbaren Standard-QAF: 12/12 degradierte Module).
  Bestehender Standard-Export-Test (`internal/__tests__/export.test.ts`)
  unverändert grün.

## 2026-07-13 – KAR-952 Viewer-Adoption Review-Fixes (8 verifizierte Findings, PR #321)

Review des KAR-952-Viewer-Adoption-Passes fand 8 bestätigte Findings — drei
davon Ehrlichkeits-Verstöße (unknown/unsicherer Zustand wurde als „ok"/„keine
Änderung" dargestellt), einer eine Test-Abschwächung. Presentation-Layer
only, Engine unverändert.

- **F1 (correctness, Ehrlichkeit)**: `QafMultiQafNav`s sticky Status-Badge
  bekam `reviewRequired={result?.reviewRequired ?? false}` — bei
  `result === null` zeigte das Badge „kein Review nötig" (grün), direkt über
  dem Hero, der korrekt „Kein gespeichertes Vergleichsergebnis" sagt. Fix:
  `reviewRequired` ist jetzt `boolean | null` — `null` rendert ein neutrales
  (graues) „Gesamtstatus: unbekannt — kein gespeichertes
  Vergleichsergebnis"-Badge statt einer stillen `false`-Herabstufung.
- **F2 (correctness, Ehrlichkeit)**: `MatchingSection`s All-Empty-Collapse (0
  Varianten auf beiden Seiten) returnte vor `DroppedOverridesSection` — bei 0
  Varianten beidseitig, aber `droppedOverrides.length > 0`, verschwand die
  rote „Gedroppte manuelle Zuordnungen"-Warnung komplett. Fix:
  `DroppedOverridesSection` rendert jetzt auch im Collapse-Fall (die
  Komponente selbst no-opt schon bei einer leeren Liste).
- **F3 (correctness, Ehrlichkeit)**: `ProfileSection`s `nothingToReport`
  prüfte nur die Änderungs-Arrays — bei ausschließlich unsicher gematchten
  Profilen (`diff.uncertainMatches > 0`, alles andere leer) stand „Keine
  Fertigungs-/Rüstprofil-Änderungen", obwohl der wahre Zustand „nicht
  vergleichbar wegen unsicherem Matching" ist. Fix: ein genuin leerer
  Vergleich (auch `uncertainMatches`/`warnings` leer) kollabiert weiterhin zur
  einen Zeile; sonst ein differenzierter Einzeiler „Keine bestätigten
  Änderungen — N Profil-Zuordnung(en) unsicher/nicht prüfbar" +
  Rendering der `uncertainMatches`/`warnings` (gleiches Muster wie
  `SummaryTotalsSection`s eigene `uncertainMatches`-Notiz).
- **F4 (cleanup, Ehrlichkeit)**: `QafMultiQafMaterialCostDriverChart`s
  Top-8-Kappung je Währung hatte keinen „X von Y"-Hinweis. Fix: neue,
  exportierte `driverChartLabel()` — Beschriftung „Top 8 von 10
  Kostentreiber (EUR)" wenn gekappt wurde, sonst unverändert
  „Top-Kostentreiber (EUR)" + Fußnote zur Sortierung (nach |Impact|) wenn
  gekappt.
- **F5 (correctness, Test-Abschwächung)**: `detail-ui.real-files.test.tsx`s
  `assertCoreSectionsRendered` nutzte `getAllByText(...).length >
  0` — maskiert versehentliche Doppel-Renderings. Fix: exakte erwartete
  Anzahl `=== 2` (Nav-Anker + `<h2>`-Section-Header), mit Kommentar warum 2
  legitim ist; gegen den echten Real-Korpus verifiziert (10/10 Tests grün).
- **F6 (plausible, correctness)**: `QafMultiQafNav`s `flex-wrap`-Zeile konnte
  auf schmalen Viewports 2-3 Zeilen hoch werden, während `Section`s
  `scroll-mt-14` (56px) auf genau eine Zeile ausgelegt ist — eine
  angesprungene Sektion landete dann teilweise unter der Nav. Fix: Bar bleibt
  immer einzeilig (`flex-nowrap`), die Link-Liste wird stattdessen
  einzeilig horizontal scrollbar (`overflow-x-auto`), Badge bleibt
  `shrink-0`.
- **F7+F8 (cleanup, Duplikate)**: `qaf-multi-qaf-material-chart.tsx`s lokale
  `chartHeight()` duplizierte das exportierte `tornadoHeight()` aus
  `qaf-movers-chart.tsx` mit anderen Konstanten — jetzt importiert und
  wiederverwendet (Standard-QAF-Datei unangetastet). `qaf-multi-qaf-
  detail.tsx`s lokaler `type LadderLevel` duplizierte das exportierte
  `StatusTaxonomyLevel` — jetzt importiert, lokaler Typ gelöscht.
- Neue/erweiterte RTL-Regressionstests: `qaf-multi-qaf-detail-viewer-
  adoption.test.tsx` (Badge-Tri-State bei `result === null`, uncertain-only
  ProfileSection-Fall + genuin-leerer Gegentest), `qaf-multi-qaf-detail.test.tsx`
  (Dropped-Override-Warnung bei 0 Varianten beidseitig),
  `qaf-multi-qaf-material-chart.test.ts` (`driverChartLabel` Top-8-Disclosure).

## 2026-07-13 – Multi-QAF Detail-UI v2 — Viewer-Adoption (KAR-952)

Adoptions-Spec (`40-xproject-viewer-adoption-spec.md`) auf Basis des KAR-950-
Standes umgesetzt — Darstellungsprinzipien aus Kais' Referenz-Viewer, KEIN
Engine-/Matching-Code übernommen. Presentation-Layer only,
`lib/qaf-differences/internal/` unangetastet (bis auf die mandatorische
Real-Korpus-Test-Assertion, siehe unten). Standard-QAF/G60/`variant_vs_standard`
byte-identisch unberührt.

- **Hero-KPI-Zeile (§D2)**: neue `QafMultiQafHero`
  (`qaf-multi-qaf-hero.tsx`) am Seitenanfang — Review-Status +
  Gründe-Zahl, Matching-Zähler (gematcht/unklar/neu/entfallen),
  Jahres-/Lifetime-Aggregat-Impact je Währung. `aggregateImpact === null`
  (Vergleich vor KAR-944) zeigt „Noch nicht berechnet", nie eine stille 0.
- **Sektions-Navigation + persistenter Status-Badge (§D3/§D6)**: neue
  `QafMultiQafNav` (`qaf-multi-qaf-nav.tsx`) — sticky Kopfzeile mit
  Sprunganker-Links zu allen 8 Sections (native `href="#section-…"`, kein
  Client-JS nötig, Deep-Link-URL-Hash gratis) + einem immer sichtbaren,
  kompakten Review-Status-Badge. Alle Sektionsinhalte bleiben vollständig
  erreichbar, nichts hinter einem Tab versteckt. `Section` (`qaf-section.tsx`)
  bekommt dafür ein optionales, additives `id`-Prop (Standard-QAF/G60 ohne
  `id` unverändert).
- **Vergleichbarkeits-Leiter (§D7)**: neuer Ladder-Block am Anfang von
  Section 1 „Überblick" — 4 Zeilen (Template-Familie/Varianten-Matching-
  Abdeckung/Währungs-Konsistenz/Review-Status) mit explizitem
  Ja/Teilweise/Nein/Unklar-Urteil, aggregiert ausschließlich bereits
  persistierte Felder.
- **Farbtaxonomie vereinheitlicht (§A Prinzip 1)**: neues
  `qaf-multi-qaf-status-taxonomy.ts` (`STATUS_TAXONOMY_CLASS`,
  ok/attention/blocked/neutral) — `reconciliationStatusClass`,
  `classificationClass`, Gate-Pills (Section 7) und die Material-Section-
  `statusClass` (Section 4) lesen jetzt aus derselben Quelle statt N
  separat gepflegter Hex-/Klassen-Strings. Byte-identische Klassen-Strings,
  keine visuelle Änderung. `nicht_pruefbar` bleibt weiterhin ausdrücklich
  `neutral` (grau), nie `ok` (grün).
- **Top-N Material-Kostentreiber-Chart (§D9)**: neue
  `QafMultiQafMaterialCostDriverChart`
  (`qaf-multi-qaf-material-chart.tsx`, Recharts, gleiches Tornado-Pattern
  wie `qaf-movers-chart.tsx`) in Section 4, vor der gefilterten Liste. Ein
  Chart PRO Währung (nie über Währungen hinweg summiert) aus den bereits
  fail-closed berechneten `impact.aggregates` der Shared-Component-Findings.
- **„Leere Section"-Einzeiler (§D5)**: `MatchingSection` und
  `ProfileSection` kollabieren jetzt bei echtem Nullbefund (0 Varianten
  bzw. 0 Profil-Änderungen) zu einer Zeile statt eines 4×0-Stat-Grids.
- **Verifikations-Audits ohne Codeänderung**: §D1 (`nicht_pruefbar`-Rollup-
  Ehrlichkeit), §D4 (Rohcode-Leak-Grep), §D8 (Match-Karten-Threshold),
  §D10 (Kandidaten-Anzeige) geprüft — bereits korrekt/ausreichend, siehe
  PR-Beschreibung.
- **Mandatorische Test-Anpassung**: `detail-ui.real-files.test.tsx`
  (Real-Korpus-Render-Test) — `assertCoreSectionsRendered` von
  `getByText` auf `getAllByText` umgestellt, weil jeder Sections-Titel nun
  legitim zweimal vorkommt (Nav-Anker + `<h2>`); semantisch gleichwertig
  (weiterhin "Section ist im Baum"), keine Aussage abgeschwächt.
- **Follow-up (nicht umgesetzt, kein Architektur-Bruch, aber L/Scope-
  Cut)**: Active-Section-Highlighting beim Scrollen (IntersectionObserver)
  bewusst weggelassen — bräuchte eine Client-Komponente + Browser-API, die
  das aktuelle jsdom-Testsetup nicht poylfilled; die Sprunganker/Deep-Links
  selbst funktionieren ohne JS. Fertigungsfolge-Ladder + Verhandlungshebel-
  Synthese bleiben wie in der Spec vermerkt Engine-Erweiterungen (P2.5/P4).

## 2026-07-13 – KAR-950 Review-Fixes (6 verifizierte Findings, PR #320)

Adversarial Review des KAR-950 Aufräum-Passes fand 6 bestätigte Findings — der
wichtigste ein Crash: ein unbekannter/umbenannter Gate-Id auf einem
persistierten Ergebnis konnte die gesamte Detail-Seite zum Absturz bringen.
Presentation-Layer only, Engine unverändert.

- **F1 (correctness, Crash-Klasse)**: `GATE_LABEL[g.gate]` wurde direkt
  indiziert — bei einem persistierten Gate-Id, den ein älterer/neuerer
  Engine-Build nicht (mehr) kennt, war das `undefined` und `.de`/
  `.description` warf. Fix: neuer `gateLabelFor()`-Helper
  (`qaf-multi-qaf-code-labels.ts`, gleicher Fallback-Vertrag wie `labelFor`)
  an beiden Stellen (Pill-Label, Failed-Gates-Beschreibung) — unbekanntes
  Gate zeigt den Roh-Code, Beschreibung entfällt ehrlich statt zu crashen.
- **F2 (correctness, Funktionsverlust)**: die aufgeklappte
  `ReservedSlotsGroup` für ALT-only-Slots hatte den
  `QafMultiQafVariantMatchOverride`-„Doch zuordnen"-Trigger verloren —
  gerade identitätslose Slots sind die Klasse, für die der manuelle Override
  existiert. Fix: `ReservedSlotsGroup` bekommt einen optionalen
  `overrideProps`, nur am ALT-only-Call (`removedReserved`) gesetzt (NEU-only
  bleibt ohne Override, wie `addedNormal` auch keinen hat).
- **F3 (correctness, Info-Verlust)**: die Ausschluss-Begründung je Variante
  (`AggregateImpactExclusion.messageDe`/`messageEn`) war nur noch im
  `title`-Attribut erreichbar — `messageEn` nirgends mehr im DOM. Fix:
  Kategorie-Label bleibt prominent, darunter ein aufklappbarer
  „Begründung"-Detailtext mit der spezifischen Message (sprachabhängig).
- **F4 (correctness, Touch-Unerreichbar)**: `externalLinks.note` war nur noch
  ein `title`-Attribut — auf Touch/Tablet (PWA-Zielgeräte) unerreichbar. Fix:
  generischer Kurzsatz bleibt, Original-Note jetzt in einem `<details>`
  (gleiches Muster wie `WarningGroupList`).
- **F5 (cleanup)**: `isCrossProjectMismatch` prüfte nur `matchedCount === 0` +
  Template-Änderung — ein Same-Supplier-Re-Quote mit umbenannten Varianten
  UND Sheet-Änderungen (0 Matches, aber ambiguous/split/merge-Kandidaten)
  hätte fälschlich das Cross-Projekt-Banner bekommen. Fix: Banner nur noch
  wenn zusätzlich `uncertainCount === 0` — mit uncertain-Kandidaten läuft es
  stattdessen über den bestehenden `ReviewBanner`
  (`uncertain_variant_matches_present`).
- **F6 (cleanup)**: dieser CHANGELOG-Eintrag hatte fälschlich behauptet,
  bestehende Tests/die real-corpus-Render-Suite seien „angepasst" worden —
  der PR-Diff enthielt null Änderungen an bestehenden Test-Dateien. Korrigiert
  (siehe Eintrag unten); zusätzlich als echte Härtung eine neue
  daten-getriebene Assertion in `detail-ui.real-files.test.tsx` ergänzt (bei
  vorhandenen Container-Warnungen muss die neue Gruppen-Zählzeile `<N>×`
  tatsächlich im DOM stehen, nicht nur Roh-Text irgendwo).
- Neue RTL-Regressionstests in `qaf-multi-qaf-detail-cleanup.test.tsx`
  (Gate-Fallback bei erfundenem Gate-Id, Override-Button in der
  aufgeklappten Reserve-Slot-Gruppe, Exclusion-Detailtext sichtbar,
  externalLinks-Note in `<details>` statt nur `title`, Cross-Projekt-Banner
  unterdrückt bei uncertain-Kandidaten).

## 2026-07-13 – Multi-QAF Detail-UI Aufräum-Pass (KAR-950)

Presentation-only Aufräum-/Verständlichkeits-Pass der Multi-QAF-Vergleichs-
Detail-UI (`components/qaf-differences/qaf-multi-qaf-detail.tsx`), ausgelöst
durch Nutzer-Feedback zu einem rohen Cross-Projekt-Vergleich (0 Matches,
Warnungs-Flut, interne Codes, Pseudo-Varianten, 15 Nachkommastellen). Engine/
Persistenz/Differ unverändert — nur wie bereits persistierte Ergebnis-Felder
gelesen/dargestellt werden.

- **Cross-Projekt-Erklär-Banner**: neuer bilingualer Hinweis ganz oben, wenn
  `matchResult` 0 Treffer hat UND Template-Familie/Struktur-Fingerabdruck
  abweichen (`isCrossProjectMismatch`) — rein aus persistierten
  `containerDiff.template.family`-Feldern abgeleitet, keine neue Berechnung.
- **Warnungs-Gruppierung (Sektion 1)**: `container.warnings` jetzt nach
  `code` gruppiert — eine Zeile je Code mit Anzahl + kuratiertem DE/EN-
  Klartext (neues `qaf-multi-qaf-code-labels.ts`), Roh-Meldungen (teils
  englisch-only aus dem Parser) im Aufklapp-Detail.
- **Reserve-Slot-Gruppierung (Sektion 2)**: identitätslose (`activeState:
  'reserved'`) NEU-only/ALT-only-Slots kollabieren zu einem aufklappbaren
  „N reservierte/leere Slots"-Eintrag statt N Pseudo-Varianten-Karten; bei 0
  Matches + vielen Einträgen kollabieren auch die normalen Listen hinter
  `<details>`.
- **Rekonziliations-Kollaps (Sektion 6)**: eine Variante mit uniformem
  Status über alle 14 Kennzahlen kollabiert zu einer Zeile; sind ALLE
  Varianten einer Seite identisch, eine Seiten-weite Zusammenfassung +
  aufklappbare Varianten-Liste. `nicht_pruefbar` bleibt immer sichtbar
  unterscheidbar (nie versteckt, nur zusammengefasst).
- **Fertigungsprofile (Sektion 5)**: neuer `numberFormatter` (format.ts,
  2 Dezimalen, de-DE) statt roher Floats; Profil-Totale ohne betroffene
  Varianten in eine kollabierte „ohne Varianten-Bindung"-Gruppe; neue
  Bindungswechsel-Summenzeile (+gebunden/−gelöst/umgebunden).
- **Gate-/Status-Codes lesbar**: zentrale Übersetzungs-Maps
  (`qaf-multi-qaf-code-labels.ts`) für Warnungscodes, Rekonziliations-
  Check-IDs, nichtPruefbarReason, Review-Gründe, Aggregat-Gates,
  Ausschluss-Gründe und Assumption-Codes — Klartext sichtbar, Roh-Code nur
  im `title`-Attribut.
- **Technik-Text entfernt**: der Aggregat-Assumptions-Fließtext (verwies auf
  `differ.ts`/`computeNumericDelta` etc.) und die Sektion-3-Template-Zelle
  (verwies auf `MultiQafSourceWorkbookMeta`) zeigen jetzt kuratierten
  Klartext; Roh-Text nur noch im `title`-Attribut bzw. im Sektion-Info-
  Expander.
- Neue RTL-Tests (`qaf-multi-qaf-detail-cleanup.test.tsx`, 6 Tests,
  Q7X/T95-Fixtures); bestehende Tests/real-corpus-Render-Suite (10
  Paarungen, `detail-ui.real-files.test.tsx`) blieben unverändert grün —
  `nicht_pruefbar`-Sichtbarkeit bleibt assertiert.

## 2026-07-13 – KAR-951 Review-Fixes (7 verifizierte Findings, PR #319)

Adversarial Review von KAR-951 (Multi-QAF Summary-Kennzahlen-Diff) fand 7
bestätigte Findings — der wichtigste war eine real geänderte Preis-Zeile, die
trotz des KAR-951-Fixes noch immer nicht getrackt wurde.

- **F1 (correctness, wichtigstes Finding)**: `offerPrice` ankert auf die
  literale Zeile „ANGEBOTSPREIS" — die ist im realen KAR-951-Livetest-Paar
  für alle 26 Varianten LEER. Die Nachbar-Zeile „ANGEBOTSBASISPREIS inkl.
  Umlage" (finaler Basispreis inkl. Vorrichtungs-Umlage) ändert sich real auf
  allen 26 Varianten, war aber weder `SUMMARY_MONEY_ROW_KIND` noch
  `VirtualVariantSummaryTotals`-Feld — nie gedifft. Neue eigenständige Metrik
  `offerBasePriceInclAllocation` (types.ts →
  container-assembly.ts/profile-parser.ts → summary-totals-differ.ts → UI →
  bridge.ts-Doku), NICHT auf `offerPrice`/`offerBasePrice` draufgemappt
  (semantisch verschiedene Summary-Zeilen). Real-Test bestätigt: geänderte
  Metriken für das Nutzer-Paar sind jetzt exakt
  `offerBasePrice`/`otherSurcharges`/`offerBasePriceInclAllocation`, je
  26/26, Vorzeichen positiv; `offerPrice` bleibt ehrlich
  `nicht_ermittelbar`.
- **F2 (correctness)**: `variant-reconciliation.ts` widersprach sich selbst —
  UI zeigte einen echten `scrap`-Wert, Rekonziliation meldete unconditional
  „kein Ausschuss-Wert vorhanden". Fix: `summaryMoneyFieldCheck` prüft den
  echten Feldwert — non-null → `nicht_pruefbar/same_source_no_independent_check`
  (kein neuer Cross-Check-Apparat), `null` → weiter die alte
  Honest-Absence-Message.
- **F3/F4 (correctness, gleiche Root-Cause)**: `currencyChanged` in
  `summary-totals-differ.ts` zählte `null`-vs-`'EUR'` als Währungswechsel →
  fälschlich `state: 'changed'` bei identischem Wert. Fix:
  `currencyChanged = currencyGate === 'currency_changed'` (nur wenn beide
  Seiten bekannt UND verschieden).
- **F5 (correctness)**: Sektions-Badge „8. Summary-Kennzahlen je Variante"
  zählte ALLE 3-State-Findings statt nur `changed` — Fix: Badge zählt jetzt
  `changed`-Findings (Metrik- + Currency-Bucket-Findings), Konvention wie
  „2. Varianten-Matching (N)".
- **F6 (cleanup)**: `locateManufacturingSummaryRow`/`locateSummaryMoneyRow`
  duplizierten denselben Label-Scan-Loop — extrahiert in gemeinsames
  `locateLabelAnchoredRow`/`rowHasLabelAmong`, Verhalten unverändert.
- **F7 (cleanup)**: `hasNoMaterialOrProfileEvidenceFor` in `compare-flow.ts`
  memoisierte nur den No-Evidence-Fall — jetzt `Map<string, boolean>` je
  `neuVariantId`, beide Ausgänge gecacht.

## 2026-07-13 – Multi-QAF Summary-Kennzahlen-Diff je Variante (KAR-951, urgent)

Livetest-Befund: Nutzer lud ALT/NEU-Kopie derselben Multi-QAF-Datei hoch,
geändert waren nur 2 globale Zuschlags-Parameter auf dem Summary-Sheet
(Overhead-Prozentsätze) — die App zeigte 0 Befunde überall, obwohl sich die
berechneten Summary-Kennzahlen (SUMME Overhead/ANGEBOTSBASISPREIS) aller 26
Varianten änderten. Root-Cause: `VirtualVariantSummaryTotals` wurde von
keinem Differ ALT↔NEU verglichen.

- **Neu** `lib/qaf-differences/internal/multi-qaf/summary-totals-differ.ts`:
  vergleicht `VirtualVariantSummaryTotals` je gematchter Variante ALT↔NEU,
  3-State (changed/unchanged/nicht_ermittelbar mit `missingSide`),
  Vorzeichen-Konvention NEU−ALT, Währungswechsel als eigener Befund
  (`currencyChanged`/`currencyGate`), `materialCostsByCurrency` per-Currency,
  `sourceRefs` wo verfügbar, `changedMetricSumsByCurrency` als eigener,
  klar als „summary-basiert" beschrifteter Ausweis (NICHT Teil von
  `aggregateImpact` — kein Doppelzählen, §19-Grenze).
- **Extraktions-Lücke geschlossen**: `container-assembly.ts`/`profile-parser.ts`
  extrahierten `scrap`/`otherSurcharges`/`offerBasePrice`/`offerPrice` bisher
  NIE (hardcoded `null`) — neue label-verankerte Summary-Zeilen-Suche
  (`locateSummaryMoneyRow`/`extractSummaryMoneyRow`, gleiche Konvention wie
  `locateManufacturingSummaryRow`), fail-closed (nie fabriziert), Ergebnis in
  neuem Container-Feld `summaryMoneyRowsByVariant` (Wert + Source-Cell je
  Metrik/Variante).
- **compare-flow.ts**: neues Feld `summaryTotalsDiff` auf
  `MultiQafComparisonResult` (`MULTI_QAF_COMPARISON_RESULT_VERSION` 2→3,
  beide Alt-Versionen weiter akzeptiert, `null`-Fallback für ältere
  Ergebnisse); neuer additiver `reviewRequiredReasons`-Eintrag
  `summary_totals_changed_without_material_or_profile_evidence` (geänderte
  Summary-Kennzahl ohne erklärenden Material-/Profil-Befund für dieselbe
  Variante).
- **UI**: neue Sektion 8 „Summary-Kennzahlen je Variante" in
  `qaf-multi-qaf-detail.tsx` (Registry `qaf-multi-qaf-section-info.ts`) —
  Änderungen prominent (Tabelle ALT→NEU/Δ/Δ%), unverändert/nicht ermittelbar
  kollabiert und klar getrennt beschriftet, Alt-Result-tolerant.
- **Real-Test-Beweis** (env-gated, `summary-totals-differ.real-files.test.ts`):
  Nutzer-Paar zeigt jetzt 2 Kennzahlen (`offerBasePrice`, `otherSurcharges`)
  über alle 26 gematchten Varianten als `changed`, alle Deltas positiv,
  `manufacturingCosts`/`scrap` flächig `unchanged`, Matching bleibt 26/26.
  4-Datei-Selbst-Vergleich-Regression: 0 `changed` Summary-Findings.

## 2026-07-13 – Variante ↔ Standard-QAF: Adversarial-Review-Fixes (KAR-948, PR #318)

5 verifizierte Findings aus dem Review von PR #318 behoben.

- **Fix (correctness) — Stale-Response-Race in `loadLists()`**
  (`qaf-variant-vs-standard-create.tsx`): `loadLists()` hatte anders als das
  Geschwister `loadVariantOptions()` KEINEN Request-Sequence-Guard, und der
  "Starten/Schließen"-Toggle war während eines laufenden Fetches klickbar (nur
  `!projectId` disabled). Ein Doppel-Toggle konnte Request #1 nach Request #2
  resolven lassen und die frischere Dateiliste still mit veralteten Daten
  überschreiben (frisch hochgeladene Dateien fehlten dann im Picker). Fix:
  gleicher `listRequestSeq`-Guard wie `loadVariantOptions` (nur der letzte
  Request darf `setState` aufrufen) plus Toggle/Refetch disabled während
  `listLoading`. Neuer Regressionstest
  `components/qaf-differences/__tests__/qaf-variant-vs-standard-create.test.tsx`:
  zwei überlappende `loadLists()`-Aufrufe, die langsamere ältere Response
  resolved zuletzt — State zeigt zuverlässig die neuere Liste (verifiziert:
  Test schlägt fehl, wenn der Sequence-Guard testweise entfernt wird).
- **Cleanup — `fmtConfidence`-Duplikat entfernt**: `qaf-variant-vs-standard-create.tsx`
  definierte lokal `CONFIDENCE_FMT`/`fmtConfidence` ohne Null-Guard; ersetzt
  durch den bereits exportierten `fmtConfidence` aus `qaf-provenance.tsx` (das
  Geschwister `qaf-variant-vs-standard-detail.tsx` nutzte diesen bereits).
- **Cleanup — `UnavailableNotice` + `variantLabel` dedupliziert**: beide waren
  byte-identisch lokal in `qaf-multi-qaf-detail.tsx` UND
  `qaf-variant-vs-standard-detail.tsx` definiert. Jetzt EINE geteilte
  Implementierung in `qaf-section.tsx` (neben `Section`/`Pill`/
  `PlaceholderSection`, die beide Detail-Views ohnehin schon von dort
  importieren) — beide Dateien importieren jetzt statt lokal zu definieren.
  Keine Verhaltensänderung; bestehende Tests (`qaf-multi-qaf-detail.test.tsx`,
  `qaf-variant-vs-standard-detail.test.tsx`, `qaf-provenance.test.ts`) bleiben
  unverändert grün.
- **Doku — Feature-Parity-Pflicht nachgeholt**: PR #318 (Variante↔Standard-
  Vergleich, KAR-948) UND die bereits gemergte Multi-QAF-Vergleichs-UI
  (KAR-947, PR #317) fehlten komplett in `PRODUCT_SPEC.md`/`API_SPEC.md`/
  `UI_FLOWS.md` (CLAUDE.md-Pflicht: "Every feature implemented in the web app
  MUST also be specified ... The iOS team should never be surprised by a
  web-only feature."). Ergänzt: PRODUCT_SPEC.md (Multi-QAF-Container-Vergleich
  + Variante↔Standard-Vergleich als Features, inkl. Feature-Flag-Hinweis
  `multiQafDetection` default ON seit KAR-925), API_SPEC.md (die neuen
  Server-Actions: Multi-QAF-Overrides set/clear, Variante↔Standard
  Listen-Helper + create), UI_FLOWS.md (§10c Multi-QAF-Vergleich öffnen/
  reviewen, §10d Variante↔Standard-QAF erstellen).

## 2026-07-13 – Variante ↔ Standard-QAF Auswahl- und Detail-UI (KAR-948)

Master-Prompt §15 Szenario B / §18 — die Selection- und Ergebnis-UI für
`comparison_mode: 'multi_qaf_variant_vs_standard'`. Der Backend-Entry-Point
(`createVariantVsStandardComparison`, KAR-943) war bereits vollständig; diese
PR ist die dort explizit als "P4 scope" vertagte UI.

- **Neue Erstellungs-Flow-Sektion `components/qaf-differences/qaf-variant-vs-standard-create.tsx`**
  auf `app/qaf-differences/page.tsx`: inline collapsible Sektion (kein Modal
  — `components/ui/dialog.tsx` hat repoweit null Usages, das etablierte
  Muster dieser Seite ist inline-expand). 3 Schritte: (1) Multi-QAF-Container
  des Projekts wählen, (2) EINE Variante wählen — Liste zeigt Dimensionen,
  Jahres-/Peak-/Lifetime-Volumen, Parsing-Confidence und aktiv/inaktiv/
  reserviert je Variante, inaktive/reservierte Varianten sind sichtbar aber
  nicht auswählbar (die Engine wirft sonst einen klaren Fehler), (3)
  Standard-QAF desselben Projekts wählen — nur Dateien mit persistiertem
  `template_type` aus der `SummaryTemplateType`-Allow-Liste
  (`QAF_LEGACY_DE_SUMMARY`/`QAF_V9_SUMMARY`), NIE per Dateiname gefiltert.
  Bei Erfolg Navigation zur neuen Vergleichs-Detail-Seite; Action-Fehler
  (z. B. Datei ist kein Multi-QAF, Variante nicht auflösbar) bilingual
  inline angezeigt, nie verschluckt. Additiv: Standard-Upload-Flow oben
  unverändert, kein Zwang für Standard-Nutzer.
- **3 neue Server-Actions in `app/qaf-differences/actions.ts`**:
  `listMultiQafContainerFiles`, `listStandardQafFilesForProject` (Allow-Liste
  statt Dateiname-Heuristik), `getMultiQafVariantOptions` (lazy — lädt nur
  den vom Nutzer gewählten Container, nicht alle im Projekt). Alle
  RLS-scoped, gleiche Lade-Konventionen wie `createVariantVsStandardComparison`.
- **Neue Ergebnis-Detail-Ansicht `components/qaf-differences/qaf-variant-vs-standard-detail.tsx`**
  (+ eigene Sektions-Registry `qaf-variant-vs-standard-section-info.ts`, 4
  nummerierte Sektionen): Überblick (welche Variante mit ihren Dimensionen
  aus welchem Container gegen welches Standard-QAF), Summary-Identität
  (informativ — der `part_number_comparison_not_applicable`-Befund rendert
  explizit als "Nicht anwendbar", nie als generischer Hinweis oder gar
  fehlend), Summary-Kennzahlen-Vergleich (bestehende Diff-Status-Konventionen:
  ALT/NEU/Δ/Status-Pill), und EINE klare Sektion "Für Varianten-Vergleiche
  nicht verfügbar" mit den 12 strukturell degradierten Modulen + Kurzgrund
  (DE/EN) — NIE als leere 0-Diff-Sektionen, damit nichts wie "keine
  Unterschiede" aussieht wenn es "nicht geprüft" heißt. Alt-Result-tolerant:
  fehlender Container/Variante/Result rendert eine explizite
  "nicht verfügbar"-Meldung statt zu crashen. Wired in
  `app/qaf-differences/[id]/page.tsx` als eigener Branch (kein Swap-Rollen-
  Button — `COMPARISON_MODE_RULES.multi_qaf_variant_vs_standard.swapSupported`
  ist `false`); Standard-/G60-/Multi-QAF-Detailpfade unverändert.
- **Barrel-Export-Ergänzung**: `METRIC_LABELS_DE` (summary-metrics.ts) jetzt
  über `lib/qaf-differences` erreichbar (für die Kennzahlen-Tabelle).
- **Real-Korpus-Regressionstest** `lib/qaf-differences/internal/multi-qaf/__tests__/variant-vs-standard-detail-ui.real-files.test.tsx`
  (env-gated, gleiche Manifest-Disziplin wie jeder andere `*.real-files.test.ts`
  in diesem Package): rendert die neue Detail-Ansicht mit einem echten
  Multi-QAF-Container + einer echten Standard-QAF-Datei aus dem Korpus — kein
  Crash, 2 reale Module da, Degradations-Sektion mit allen 12 Einträgen da,
  "Nicht anwendbar" gerendert. Nur Aggregat-Assertions.
- **Synthetischer Unit-Test** `components/qaf-differences/__tests__/qaf-variant-vs-standard-detail.test.tsx`
  für den Alt-Result-toleranten Rendering-Pfad (fehlender Container/Variante/
  Result).

## 2026-07-13 – Multi-QAF Vergleichs-Detail-UI (KAR-947)

Master-Prompt §18 (SupplierPulse-User-Experience) — die echte Detail-Ansicht
für `comparison_mode: 'multi_qaf'`, ausschließlich aus dem bereits
persistierten `MultiQafComparisonResult` + beiden `MultiQafContainer`n
gerendert (kein Neu-Parse). Ersetzt den bisherigen Badge-only-Platzhalter aus
KAR-942/P3.1.

- **Neue Komponente `components/qaf-differences/qaf-multi-qaf-detail.tsx`**
  mit 7 nummerierten Sektionen (eigene Registry `qaf-multi-qaf-section-info.ts`,
  KAR-844-Konvention): Überblick (Familie/Klassifikation/Varianten-Zahlen/
  Confidence/Warnungen je Seite), Varianten-Matching (Treffer, Konfidenz,
  Evidenz-Dimensionen, added/removed, uncertain-Matches deutlich als
  review-pflichtig markiert), Container-Struktur, Material-Vergleich
  (zweistufig: EINE shared-Änderung = EIN Befund mit Impact-Liste je
  Variante), Fertigungs-/Rüstprofile, Rekonziliation je Variante
  (`nicht_pruefbar` NIE als grün dargestellt) und Aggregat-Impact (Gates,
  Ausschlüsse mit Grund, per-Währung getrennt, strukturelle Unweighted-
  Übersicht immer verfügbar).
- **Manuelle Varianten-Match-Override-UI** (`qaf-multi-qaf-variant-match-
  override.tsx`, KAR-912-Override-Dialog-Vorbild): uncertain/auto-Match lösen
  oder anders zuordnen, persistiert über die vorhandene
  `VariantMatchOverride`-Plumbing (`variant-matcher.ts`
  `matchVariantsWithOverrides`) und über den bestehenden Compare-Flow neu
  berechnet. Zwei neue Server-Actions `setVariantMatchOverride`/
  `clearVariantMatchOverride` (app/qaf-differences/actions.ts) — die ersten
  Setter für diese seit KAR-936/942 bestehende, bis jetzt ungenutzte
  Persistenz.
- **Filter** in der Material-Sektion (`qaf-multi-qaf-material-section.tsx`,
  Client-Component): Kostenart, Erhöhung/Reduktion, gemeinsam vs.
  varianten-spezifisch.
- **Drill-down** (`qaf-multi-qaf-source-refs.tsx`): Sheet!Zelle-Quellverweise
  je Befund, gleiche Info-Icon/Tooltip-UX wie die bestehende
  Provenance-Komponente.
- Bilingual DE/EN nach bestehendem Muster (`BilingualText`/`pickLocaleText`
  aus `qaf-provenance.tsx`) — nur für Felder, die das Engine bereits als
  DE+EN-Paar liefert (Warnungen, Gate-Nachrichten, Reconciliation-Messages);
  statische UI-Chrome bleibt wie im G60-Vorbild Deutsch.
- `lib/qaf-differences/index.ts`: `aggregate-impact.ts` erstmals aus dem
  Barrel exportiert (war bisher nur intern von `compare-flow.ts` importiert).
- Additiv: Standard- und G60-Detailansichten unverändert;
  `variant_vs_standard`-Ansicht ist NICHT Teil dieses Tickets (KAR-948).
  Alt-Vergleiche ohne bestimmte Result-Felder (z. B. `aggregateImpact` aus
  einer Version vor KAR-944) rendern einen expliziten „nicht verfügbar"-
  Hinweis statt zu crashen oder eine leere Sektion wie 0 Diffs aussehen zu
  lassen.
- Neuer env-gated Real-Korpus-Render-Test
  (`lib/qaf-differences/internal/multi-qaf/__tests__/detail-ui.real-files.test.tsx`,
  RTL, `@testing-library/react` neu als devDependency): alle 4
  Selbst-Vergleiche + alle 6 Kreuz-Paare des 4-Datei-Manifests rendern ohne
  Crash; Paar (1,3) hat real 2 uncertain-Matches — die Review-nötig-UI wurde
  damit tatsächlich mit echten Daten durchlaufen, nicht nur synthetisch.
- **Nachträgliche Fixes (Adversarial-Review PR #317, 4 verifizierte Findings):**
  neue geteilte Identity-Snapshot-Helfer in `variant-matcher.ts`
  (`identitySnapshotsEqual`/`identitySnapshotFor`/`findAllByIdentitySnapshot`/
  `replaceOverrideForVariant`/`removeActiveOverridesForVariant`), auf die
  alle 4 Fixes aufsetzen, statt vier lokaler Ad-hoc-Vergleiche:
  (1) `clearVariantMatchOverride` entfernte einen Override für
  identitätslose (`compositeCanonicalKey === ''`) ALT-Varianten bisher NICHT
  (bare-Key-Vergleich), meldete aber trotzdem `ok:true` + Audit-Log — jetzt
  Entfernung über den vollständigen Identity-Snapshot, Audit-Eintrag nur bei
  tatsächlicher Entfernung; (2) `upsertVariantMatchOverride` dedupliziert
  jetzt auch für identitätslose Varianten über den vollständigen Snapshot,
  sodass ein Re-Entscheid die alte Entscheidung wirklich ersetzt statt sie
  nur zusätzlich zu speichern (first-match-wins hätte sonst weiter die ALTE
  Entscheidung angewendet); (3) neues Review-Banner am Kopf der Multi-QAF-
  Detailansicht (`result.reviewRequired`, DE/EN) + neue Sektion „Gedroppte
  manuelle Zuordnungen" in der Matching-Sektion (mit Re-Apply-Versuch bzw.
  klarer Aufforderung neu zu entscheiden) — vorher war ein gedropptes
  manuelles Match unsichtbar, wenn NUR `droppedOverrides` (nicht
  `containerDiff`) den Review-Bedarf ausgelöst hatte; (4)
  `matchVariantsWithOverrides` erkennt jetzt echte Mehrdeutigkeit bei zwei
  identisch-identitätslosen Varianten (neuer Status
  `'dropped_ambiguous_identity'`) statt sie stillschweigend auf die erste
  Variante anzuwenden — bewusst NUR für `compositeCanonicalKey === ''`
  gescoped (ein Namens-Duplikat mit echten, nicht-leeren Dimensionen bleibt
  beim bisherigen, bewusst gewählten first-match-Verhalten für manuell
  disambiguierte Duplikat-Varianten). Neue Tests in `variant-matcher.test.ts`
  (Snapshot-Helfer, Resubmit-gewinnt, Clear-für-Duplikate,
  Ambiguous-Identity-Drop) und `components/qaf-differences/__tests__/qaf-multi-qaf-detail.test.tsx`
  (Banner + Dropped-Sektion rendern bei `droppedOverrides > 0` trotz
  `containerDiff.reviewRequired === false`).

## 2026-07-13 – Multi-QAF Feature-Flag aktiviert (KAR-925 Flag-ON)

- `DEFAULT_MULTI_QAF_DETECTION_CONFIG.enabled` von `false` auf `true` (Kais-Go 13.07., TG 8475) — Multi-QAF-Erkennung, Container-Ingest (KAR-935) und Compare-Flows (KAR-942/943/944) sind damit produktiv aktiv.
- `configVersion` 1.3.0 → 1.4.0; Test-Assertions nachgezogen. Klassifikations-Ergebnis für einen Standard-QAF bleibt unverändert (0 False-Intercepts, CI-verankerte Kalibrierungs-Grenzen-Regression, 16 reale Standard-QAFs) — **aber der Ingest-Pfad ist nicht mehr byte-identisch**: jeder Standard-Ingest zahlt jetzt einen begrenzten, aber realen Scan (≤20 SUMMARY-Zeilen + ≤200 Zeilen je MATERIAL-Alias-Sheet, `HEADER_SCAN_ROWS`/`MATERIAL_FORMULA_SCAN_ROWS`), und `qaf_file.g60_meta` bekommt neu den `multiQafDetection`-Key (`classification: 'standard_qaf'`) — bewusst persistiert als Diagnose-Information ("Detector lief"), nicht weggelassen (siehe `multiQafDetectionMetaFragment`-Doku, qaf-type-detector.ts).
- **Adversarial-Review-Fix (F1, sharpest finding):** `replaceComparisonFile` löste die Multi-QAF-Erkennung bisher über die allgemeine `resolvePersistedEngineConfig`-Fallback-Kette aus — für einen ALT-Vergleich ohne persistierten `multiQafDetection`-Override wäre das rückwirkend auf den NEUEN Default `true` gefallen und hätte ein legitimes Datei-Replace auf einem Alt-Vergleich unter Umständen fälschlich als Multi-QAF abgelehnt+gelöscht. Fix: neuer `resolveReplaceMultiQafDetectionConfig` (rehydrate.ts) liest den `configVersion`-Stempel des Vergleichs — `>= 1.4.0` erbt den aktuellen Default, sonst (inkl. fehlendem Stempel) wird `enabled:false` erzwungen, also exakt das Verhalten zum Erstellungs-/letzten-Recompute-Zeitpunkt des Vergleichs. Tests: Alt-Vergleich (1.3.0-Stempel + ganz ohne Stempel) Replace mit Multi-QAF-Datei verhält sich wie vor dem Flip; Neu-Vergleich (1.4.0-Stempel) hat Detection aktiv.
- Rollback: Flag zurück auf `false` (Ein-Zeilen-PR); persistierte Multi-QAF-Container/-Vergleiche bleiben intakt.

## 2026-07-13 – Multi-QAF Aggregat-Impact mit Validitäts-Gates (KAR-944)

Master-Prompt §19 (LETZTES P3-Item): aggregierter kommerzieller Impact über
einen MQ↔MQ-Vergleich — nur wenn das Fundament valide ist.

- **Neues Modul `lib/qaf-differences/internal/multi-qaf/aggregate-impact.ts`**
  — `computeAggregateImpact(alt, neu, inputs, options)`: sechs explizite Gates
  (`compatible_currencies`, `volumes_available`, `no_duplicates_or_ambiguous`,
  `consistent_units`, `valid_baseline`, `no_blocked_critical_mappings`), jedes
  einzeln geprüft und in `gates`/`gatesPassed`/`gatesFailed` dokumentiert.
  `consistent_units` nutzt variant-reconciliation.ts's eigenen `currency`-Check
  wieder statt ein zweites Signal zu erfinden. Summiert NUR material-differ
  11.1-Impacts (unitCostValueChanges/logisticsOrDutyChanges/
  materialOverheadChanges, nie reviewRelevant-Zeilen) + profile-differ Item
  2/4 (Profil-Total-Änderung + Bindungs-Wert-Impact), währungs-gated, PER
  CURRENCY gebucketet (nie cross-currency addiert).
- **Getrennte Ausweise**: Stückpreis-Delta je Variante, Jahres-/Lifetime-
  Impact je Variante (Volumen von der NEU-Seite, je Zeitraum separat
  ausgeschlossen wenn annualVolume/lifetimeVolume fehlt), Aggregat Jahr/
  Lifetime nur über die Population, die ALLE Gates besteht (explizit als
  Liste), ungewichteter Struktur-Vergleich (Counts aus container-differ) als
  immer-verfügbarer Fallback — bleibt auch bei ungültiger Baseline gefüllt.
- **Vorzeichen-Konvention**: Delta = NEU − ALT (identisch zu differ.ts/
  material-differ.ts/profile-differ.ts) — positiver Impact = Verteuerung,
  negativer Impact = Ersparnis. Nie invertiert.
- **`valid_baseline`-Gate**: `options.acknowledgeReviewRequired` berechnet das
  Aggregat trotz blockierendem Review-Zustand ("trotz Review"), aber
  `reviewRequired` bleibt am Ergebnis `true`.
- **Einhängung in `compare-flow.ts`**: `MultiQafComparisonResult.aggregateImpact`
  (neues Feld), `MULTI_QAF_COMPARISON_RESULT_VERSION` 1→2, `deserializeMultiQafComparisonResult`
  akzeptiert beide Versionen und normalisiert ein fehlendes Feld auf `null`
  bei alten (v1) Records statt zu werfen.
- **Tests**: 19 synthetische Unit-Tests (ein Szenario je Gate pass/fail,
  Per-Währung-Bucketing, Vorzeichen-Richtung, Struktur-Fallback,
  Baseline-Suppression + Acknowledge) + 5 env-gated Real-File-Tests (Datei 3
  vs. 4: 0 matched → leeres Aggregat mit `no_matched_variants`-Grund,
  Struktur-Fallback 5/8 gefüllt; Selbst-Vergleich aller 4 Dateien: 0-Delta
  über volle einbezogene Population, Volumen-/Blocked-Mapping-Gates ehrlich
  je nach Datenlage dokumentiert).

## 2026-07-13 – Multi-QAF-Variante ↔ Standard-QAF-Vergleich (KAR-943)

Master-Prompt §15 Szenario B: EINE explizit gewählte, aktive Multi-QAF-Variante
wird über die Bridge (`bridge.ts`, KAR-929) als virtuelles Standard-QAF durch
die bestehende Standard-Vergleichs-Engine geschickt — nie ein ganzer Container
gegen ein Standard-QAF.

- **Neues Modul `lib/qaf-differences/internal/multi-qaf/variant-vs-standard.ts`**
  — `runVariantVsStandardCompare(container, variantId, standardFile)`:
  validiert die Variante existiert+aktiv (sonst `Error` mit klarer Meldung),
  vergleicht die REAL bedienbaren Module echt (Summary-Identität über die neu
  extrahierte `checkSummaryIdentityPlausibility`, SummaryMetrics über das
  unveränderte `diffSummaryMetrics`), und degradiert jedes steps-abhängige
  Modul (Fertigungs-Prozessvergleich/G60, MATERIAL/SBM/RMR/LOGISTICS/LC-CN/
  CO2e, Rekonziliation, Business-Rules, Regel-Engine, Root-Cause,
  Workbook-Safety — 12 Module) über einen festen, bilingualen
  `DEGRADED_MODULES`-Katalog (eigene Befund-Klasse
  `not_available_for_multi_qaf_variant`) statt zu crashen oder leer-grün zu
  melden. `reviewRequired` propagiert additiv aus dem Container-Review-Gate
  UND dem KAR-936-Aggregations-Gate der spezifischen Variante (fuzzy-matched/
  below-gate Material-Warnungen), mit sichtbaren `variantWarnings`/
  `containerReviewWarnings`. Versionierte JSON-Envelope (`serialize`/
  `deserializeVariantVsStandardComparisonResult`), additiv aus
  `lib/qaf-differences/index.ts` exportiert.
- **`plausibility.ts`:** `checkSummaryIdentityPlausibility` (part_number/
  part_name/variant/quotationDate-Checks) aus `checkPlausibility` extrahiert
  und additiv exportiert — verhindert, dass ein steps-loser Vergleich über die
  volle `checkPlausibility` einen fabrizierten `currency_change`-Fund auf
  JEDEM Vergleich auslöst (leeres `steps: []` vs. echte Prozesszeilen).
  `checkPlausibility`s eigenes Verhalten/Reihenfolge unverändert.
- **`app/qaf-differences/actions.ts`:** neue Action
  `createVariantVsStandardComparison(projectId, multiQafFileId, variantId,
  standardFileId)` — legt eine neue `qaf_comparison`-Row mit
  `comparison_mode: 'multi_qaf_variant_vs_standard'` an (TEXT-Spalte, kein
  Constraint), persistiert `selectedVariantId` + das versionierte Ergebnis im
  bestehenden `engine_version`-JSONB-Bag (gleiche Disziplin wie
  `variantMatchOverrides`). `recompareComparison` bekommt einen vierten
  Zweig für diesen `comparison_mode` (rehydriert Container + Standard-Datei,
  läuft `runVariantVsStandardCompare` erneut; `pins`/`swap` werden mit
  klarer Meldung abgelehnt — für diese asymmetrische Paarung nicht
  sinnvoll). Neuer Helfer `loadStandardQafFileForVariantCompare` liest
  Summary/SummaryMetrics eines beliebigen Standard-`qaf_file` über
  `qaf_file.part_number_from_content` → `qaf_part`, unabhängig von einer
  bestehenden Vergleichs-Zuordnung.
- **Tests:** `variant-vs-standard.test.ts` (35 Unit-Szenarien: reale
  Identitäts-/SummaryMetrics-Diffs, ehrliche Ein-Seiten-Degradierung ohne
  fabrizierte 0-Diffs, Fail-Closed bei ungültiger/inaktiver Variante,
  additive `reviewRequired`-Propagierung, fixer 12-Modul-Katalog,
  Envelope-Round-Trip) + `variant-vs-standard.real-files.test.ts`
  (env-gated: Datei-4-Variante, 8 aktive Varianten, gegen ein echtes
  Standard-QAF aus dem Pool — `standardFiles = alle minus Manifest minus
  WAF`, erste parsebare Kandidatin, kein Dateiname als Literal).
- Auswahl-UI ist P4-Scope (nicht Teil dieser PR).

## 2026-07-13 – Multi-QAF Compare-Flow: 7 Adversarial-Review-Fixes (KAR-942, PR #313)

Adversarialer High-Effort-Review von PR #313 (KAR-942 Compare-Flow) deckte 7
CONFIRMED Defekte auf, alle gefixt:

- **F1 — `replaceComparisonFile` hatte keinen `multi_qaf`-Zweig:** die
  binäre `isG60Comparison`-Prüfung behandelte jeden Nicht-G60-Vergleich als
  Summary-Vergleich — eine gültige MULTI_QAF-Ersatzdatei
  (`ingested.kind === 'multi_qaf'`) wurde immer mit der irreführenden
  Meldung "braucht ein Summary-QAF" abgelehnt UND über `compensate()`
  gelöscht. Fix: dritter Guard-Zweig für `comparison_mode === 'multi_qaf'`
  mit eigener bilingualer Fehlermeldung, die den richtigen erwarteten
  Dateityp nennt. Der bestehende `recompareComparison`-Aufruf
  (`refreshPlausibility: true`) greift danach automatisch den bereits
  vorhandenen `multi_qaf`-Zweig dort — Container-Ersatz, Neu-Lauf über
  `runMultiQafCompareFlow`, `VariantMatchOverride`-Carry-Forward über den
  Identitäts-/Drift-Guard war dort schon fertig verkabelt, keine
  Duplizierung nötig.
- **F2 — `profile-differ.ts`s `diffValueEntry` (KAR-945):** markierte jedes
  NICHT-numerische Profil-Wertfeld unconditional `'nicht_berechenbar'`, ohne
  vorherigen Gleichheitscheck — ein identisches String-Feld (z.B. `site`)
  erzeugte bei jedem Self-Compare ein `componentValueChanges`-Finding, obwohl
  sich nichts geändert hatte. Fix: `altV === neuV` (getrimmt/normalisiert)
  → `'konstant'` (kein Befund); verschieden → echter Wert-Wechsel-Befund
  (`altValue`/`neuValue` als Text, `status: 'nicht_berechenbar'` bleibt
  korrekt, da für Text kein numerisches Delta berechenbar ist). Neue Tests
  für beide Fälle (identisch inkl. Whitespace-Normalisierung, verschieden).
  Der Self-Compare-Test in `compare-flow.test.ts` erwartet jetzt strikt
  `componentValueChanges: []` statt der alten "nie ein GENUINER
  Wertwechsel"-Umschreibung. Closes KAR-945.
- **F3 — `exportQafComparisonXlsx` guardete nur `'g60'`:** `multi_qaf` fiel
  in den Summary-Export — keine `qaf_manufacturing_step`-Rows für
  MULTI_QAF-Dateien, also eine plausibel aussehende, aber irreführend LEERE
  XLSX statt eines Fehlers. Fix: eigener `multi_qaf`-Guard analog zum
  `g60`-Zweig, bilinguale "kein Export vor P4"-Meldung.
- **F4 — Orphaned `qaf_file`-Rows nach `qaf_comparison`-Insert-Fehler:**
  beide bereits gepaarten MULTI_QAF-Dateien blieben ungelöscht (Dateien sind
  wertvoll — kein Compensate-Delete für einen reinen Pairing-Fehler), aber
  ohne Hinweis und mit dem Risiko, dass ein späterer Einzel-Upload sich mit
  der FALSCHEN (ältesten) verwaisten Datei paart. Fix (konservativ, kein
  Schema-/Delete-Change): Nachzügler-Partnersuche für Multi-QAF sucht jetzt
  das JÜNGSTE unpaarige File (`order('created_at', { ascending: false })`)
  statt des ältesten — verletzt "früherer Upload wird ALT" nicht (jeder
  Kandidat ist tautologisch älter als der aktuelle Upload). Die
  Insert-Fehlermeldung nennt jetzt explizit "beide Dateien bleiben
  gespeichert. Pairing fehlgeschlagen, bitte erneut hochladen oder manuell
  vergleichen." **G60-Original hat dieselbe Lücke** (kein Delete/Rollback bei
  `g60CmpErr`, älteste-zuerst-Suche) — bewusst NICHT mitgefixt (Scope), siehe
  Follow-up-Empfehlung im PR-Bericht.
- **F5 — verschluckter Supabase-Error im Multi-QAF-Detailseiten-Zweig:**
  `app/qaf-differences/[id]/page.tsx` destrukturierte nur `data` aus der
  `qaf_file`-Query, `error` ging verloren (Verstoß gegen die CLAUDE.md-Regel
  "handle error from Supabase queries, don't silently ignore") — eine
  fehlgeschlagene Query rendert sonst still "ALT: — · NEU: —" statt eines
  Fehlers. Fix: `error` destrukturiert, `throw` bei Fehler (gleiche
  Konvention wie der G60-Zweig direkt darüber).
- **F6 — falscher Tooltip-Text für Multi-QAF-`baseline_review`-Zeilen:**
  `STATUS_EXPLAIN.baseline_review` beschreibt nur G60/Summary-Gründe
  (ALT/NEU-Upload-Reihenfolge, fehlende Angebotsdaten) — wurde aber auch für
  Multi-QAF-Zeilen angezeigt, deren tatsächlicher Grund (unsichere
  Varianten-Zuordnung / strukturelle Abweichung / verworfene manuelle
  Zuordnung) etwas völlig anderes ist. Fix: `explainBaselineStatus(status,
  comparison_mode)`-Helper wählt jetzt die Multi-QAF-spezifische Erklärung;
  Status-Legende um einen vierten Eintrag ergänzt.
- **F7 — dupliziertes Pairing-/Query-Block G60↔Multi-QAF:** near-verbatim
  Kopie der Nachzügler-Partnersuche. Fix: gemeinsamer Helper
  `findUnpairedPartnerForSingleUpload` (parametrisiert über
  `comparisonMode`/`templateType`/`partnerOrder`/Fehlermeldungen),
  verhaltensneutral für G60 (bestehende Tests unverändert grün) — einzige
  bewusste Verhaltensänderung ist F4s `newest_first` für Multi-QAF.

## 2026-07-13 – Multi-QAF Compare-Flow MQ↔MQ end-to-end (KAR-942)

Erstes P3-Item des Multi-QAF-Programms (Epic KAR-925) — das Wiring-Item, das
die sechs unabhängig gebauten P2-Module (container-assembly, variant-matcher,
container-differ, material-differ, profile-differ, variant-reconciliation)
zum ersten Mal end-to-end verkabelt. Zwei als `MULTI_QAF` persistierte
`qaf_file`-Rows ergeben jetzt einen echten `qaf_comparison`-Vergleich statt
nur eine "Vergleich folgt später"-Notiz.

- **Neues Modul `lib/qaf-differences/internal/multi-qaf/compare-flow.ts`**
  (pure Orchestrierung, kein DB-Zugriff): `runMultiQafCompareFlow(alt, neu,
  options)` — `allMultiQafContainerVariants` → `matchVariants`/
  `matchVariantsWithOverrides` (persistierte `VariantMatchOverride[]`) →
  `diffContainers`/`diffMaterial`/`diffProfiles` (alle auf demselben
  `matchResult`) → `reconcileContainer` je Seite → EIN zusammengesetztes,
  versioniertes `MultiQafComparisonResult`. `reviewRequired` wird
  ausschließlich von `diffContainers`' eigenem Floor plus einem additiven
  `match_overrides_dropped_on_drift`-Grund übernommen, nie neu erfunden.
  `multiQafBaselineStatusFor` mappt das auf `'ok'`/`'baseline_review'`.
  Versionierter JSON-Envelope (`serializeMultiQafComparisonResult`/
  `deserializeMultiQafComparisonResult`, `MULTI_QAF_COMPARISON_RESULT_VERSION`)
  für Persist→Reopen ohne Neu-Parse.
- **Barrel-Gap geschlossen:** `profile-differ.ts`s `diffProfiles` (KAR-939,
  P2.5-Wertevergleich) war seit dem Merge nie über `lib/qaf-differences/
  index.ts` exportiert — nachgeholt, damit compare-flow.ts (und zukünftige
  P4-Konsumenten) es erreichen.
- **Pairing (`app/qaf-differences/actions.ts`, `analyzeQafBatchFromStorage`):**
  Multi-QAF-Dateien bekommen jetzt exakt das G60-Pairing-Muster — Sammlung in
  `multiQafFiles[]`, bei genau 1 Datei Suche nach einem älteren unpaarigen
  `MULTI_QAF`-File im Projekt (Nachzügler-Fall, ältere Datei wird ALT), bei 2
  Dateien läuft `runMultiQafCompareFlow` und persistiert `qaf_comparison`
  (`comparison_mode: 'multi_qaf'`, KEINE Migration nötig — TEXT-Spalte ohne
  CHECK-Constraint). Container werden NICHT durchgereicht, sondern frisch aus
  `qaf_file.g60_meta.multiQafContainer` gelesen (gleiche
  "compute once, read from storage"-Disziplin wie `templateFingerprint`).
- **Persistenz ohne Migration:** Das Ergebnis liegt in
  `qaf_comparison.engine_version.multiQafComparisonResult` (versionierter
  Envelope) — dieselbe JSONB-Bag-Konvention wie `qaf_file.g60_meta`.
  `reviewRequired` aggregiert bis in `qaf_comparison.baseline_status`
  (bestehender CHECK-Enum-Wert `'baseline_review'`, keine neue Spalte).
  Persistierte `VariantMatchOverride[]` leben in
  `engine_version.variantMatchOverrides` (KAR-912-`fieldMappingOverrides`-
  Muster, aber comparison-scoped statt file-scoped, da ein Override ein
  ALT↔NEU-Variantenpaar betrifft) — Setter-UI ist P4-Scope, die Plumbing
  (Lesen/Anwenden/Carry-Forward-mit-`dropped_on_drift`) ist fertig.
- **`recompareComparison` erweitert (kein Guard-Crash mehr):** neuer
  `comparison_mode === 'multi_qaf'`-Zweig vor dem Summary-Pfad — rehydriert
  beide Container aus `g60_meta` (kein Re-Parse), wendet persistierte
  Overrides an, läuft `runMultiQafCompareFlow` neu, schreibt Ergebnis +
  `baseline_status` zurück. `pins` werden abgelehnt (Multi-QAF nutzt
  Varianten-Match-Overrides, keine Step-Pins) — dieselbe Guard-Disziplin wie
  beim G60-Pfad. Ein Rollen-Swap verwirft persistierte Overrides (ALT/NEU
  vertauscht sich, alte Overrides würden falsch angewendet).
- **UI-Minimalpfad:** Listenansicht zeigt "Multi-QAF"-Badge + Titel-Fallback;
  Detailseite hat einen eigenen `comparison_mode === 'multi_qaf'`-Zweig mit
  sauberem Platzhalter (ALT/NEU-Dateinamen, matched/added/removed/uncertain-
  Zahlen direkt aus dem persistierten Ergebnis, Review-Hinweis,
  P4-Verweis) statt in den Summary-Rendering-Pfad zu fallen (der ohne
  `qaf_manufacturing_step`-Rows für diese Dateien nur eine irreführende
  "keine Unterschiede"-Seite gezeigt hätte).
- **Tests:** 19 neue (14 Unit in `compare-flow.test.ts` mit synthetischen
  Containern — Self-Compare-Invariante, Cross-Fixture-Diff, Override-Sieg/
  -Drift-Drop/No-Op, Persist→Reopen-Roundtrip, `multiQafBaselineStatusFor`;
  5 env-gated Real-File-Tests in `compare-flow.real-files.test.ts`, gleiches
  `MULTI_QAF_PROBE`-Muster wie `container-differ.real-files.test.ts` — Datei
  0-3 Self-Compare + Datei 3 vs. Datei 4 mit gepinntem 0 matched/5
  removed/8 added, identisch zur KAR-937-Messung auf reiner
  `diffContainers`-Ebene, jetzt über die volle Orchestrierung bestätigt).
  3330/3330 Tests grün (Basis 3311 + 19).
- **Entdeckt, NICHT gefixt (außerhalb P3.1-Scope, bereits gemergtes P2.5-
  Modul):** `profile-differ.ts`s `diffValueEntry` markiert jedes
  NICHT-numerische Profil-Wertfeld unconditional `'nicht_berechenbar'`, ohne
  vorherigen Gleichheitscheck (anders als der numerische Pfad, der bei
  `status === 'konstant'` abbricht) — ein Profil mit einem String-Feld (z.B.
  `site`) erzeugt dadurch bei einem 1:1-Self-Compare ein
  `componentValueChanges`-Finding, obwohl sich nichts geändert hat (Wert auf
  beiden Seiten identisch). In den neuen Tests als "nie ein GENUINER
  Wertwechsel" statt "immer leer" dokumentiert/verifiziert. Empfehlung:
  eigenes KAR-Ticket für `profile-differ.ts` (`diffValueEntry` sollte
  `altV === neuV` für nicht-numerische Felder zuerst prüfen).

## 2026-07-13 – Multi-QAF Rekonziliation je VirtualQafVariant: 3 Adversarial-Review-Fixes (KAR-940, PR #312)

Adversarialer Review deckte 3 CONFIRMED Defekte in `variant-reconciliation.ts` auf, alle gefixt:

- **F1 — `profileBucketCheck` self-contradictory abweichung:** meldete IMMER
  `expected=boundProfileValue`/`actual=virtualVariantTotal`, auch wenn die `abweichung` genuin aus
  `cachedValueDrift` stammte — Ergebnis: `expected===actual` (z.B. 55/55) NEBEN einem
  Nicht-Null-Delta (-5), self-contradictory für jeden Konsumenten. Fix: `expected`/`actual` folgen
  jetzt der TATSÄCHLICH verglichenen Größe (`totalPerUnit`/`cachedValue` bei `cachedValueDrift`,
  `boundProfileValue`/`virtualVariantTotal` sonst), neues Feld `evidenceSource`
  (`'cached_value_drift' | 'bound_vs_virtual' | null`) benennt die Quelle. Neuer exportierter
  Invarianten-Helper `deltaInvariantHolds(check)` (`actual - expected === deltaAbsolute`) plus
  Test-Sweep über jeden abweichung-produzierenden Pfad des Moduls.
- **F2 — Fail-closed-Guard akzeptierte zwei leere Fingerprints als Match:** `variant.
  containerFingerprint !== container.templateFingerprint.structuralHash` mit beiden Seiten
  `string | null` — `null !== null` ist `false`, also fielen zwei NICHT-gefingerprintete Seiten
  fälschlich als "Match" durch statt fail-closed abgelehnt zu werden (`EMPTY_TEMPLATE_FINGERPRINT`
  aus serialization.ts liefert real `null`). Fix: `matchToContainer` lehnt `null` auf JEDER Seite
  zuerst ab, mit eigenem Reason `fingerprint_unavailable` (neu in
  `VariantReconciliationNichtPruefbarReason`) statt des generischen `unmatched_variant` — damit P3
  "wir wissen es nicht" von "wir haben geprüft, es stimmt nicht" unterscheiden kann. Tests für
  null/null, null/hash, hash/hash-gleich, hash/hash-verschieden.
- **F3 — O(N²) statt dokumentiertem O(N):** `reconcileContainer` rief je Variante
  `reconcileVirtualVariant` auf, das `reconciliationEntries`+`profileById` (und darüber
  `generateVirtualVariants`) bei JEDEM Aufruf neu berechnete. Fix: neue interne
  `VariantReconciliationContext` (`entries`+`profileById`), von `buildReconciliationContext` EINMAL
  je Container gebaut; `reconcileContainer` baut sie einmal und reicht sie über einen optionalen 4.
  Parameter durch. `reconcileVirtualVariant` behält seine Standalone-Signatur (baut den Context
  selbst, wenn keiner übergeben wird). Strukturell belegt in einer neuen isolierten Test-Datei
  (`variant-reconciliation.shared-context.test.ts`, `vi.mock` auf `reconciliationEntries` zählt
  Aufrufe: 1 Aufruf für 3- und 10-Varianten-Container über `reconcileContainer`, vs. N Aufrufe bei N
  Standalone-`reconcileVirtualVariant`-Aufrufen ohne Context).

Real-Status-Verteilung (4 Dateien, unverändert gegenüber dem Ursprungs-PR, da F1/F2 auf gesunden
Dateien nicht feuern): 896 Checks, 784× `fehlende_daten`, 97× `same_source_no_independent_check`,
2× `mixed_currency`, 13× echtes `bestanden`, 0× `abweichung`, 0× `unmatched_variant`, 0×
`fingerprint_unavailable`.

14 neue Tests (4 F2-Fingerprint-Matrix + 7 F1-Invarianten-Sweep + 3 F3-Struktur, davon in einer
neuen isolierten Datei) — 3311 passed (Basis 3297 + 14), 0 Regressionen.

## 2026-07-13 – Multi-QAF Rekonziliation je VirtualQafVariant (KAR-940)

Neues Modul `lib/qaf-differences/internal/multi-qaf/variant-reconciliation.ts` (Multi-QAF-Programm
P2.6, Epic KAR-925 — **letztes P2-Item**): `reconcileVirtualVariant(variant, container)` /
`reconcileContainer(container)` verifizieren, im Gegensatz zu container-differ.ts (#309) /
material-differ.ts (#310) / profile-differ.ts (#311) (alle ALT-vs-NEU), die **interne Konsistenz
EINES Containers** — Master-Prompt §13: je virtueller Variante 14 Checks (Material-/Fertigungs-/
Werkzeug-/Rüstkosten-Total, Ausschuss, Zuschläge, Angebotsbasispreis/-preis, Währung, Volumina,
Herstellkosten-Kaskade, Formel-Identität Material-Zeilen/Profile), Status je Check `bestanden` /
`abweichung` (mit Delta+Toleranz-Band) / `nicht_pruefbar` (mit reason `fehlende_daten` /
`same_source_no_independent_check` / `mixed_currency` / `unmatched_variant`).

- **Ehrlichkeits-Doktrin (KAR-939-F1 fortgeführt, Kern-Deliverable):** `MultiQafContainer` trägt
  keine unabhängig geparste "Summary-Sheet"-Quelle — `generateVirtualVariants` leitet fast jedes
  `VirtualVariantSummaryTotals`-Feld direkt aus denselben Container-Feldern ab, die ein naiver
  "Detail-vs-Summary"-Check erneut lesen würde. Der Modul-Header dokumentiert die vollständige
  Abdeckungs-Matrix: NUR `currency` (Varianten- vs. Material-Zeilen-Währung, zwei echte Quellen)
  und `material_total_matrix_independent` (material-matrix-parser.ts SUMPRODUCT-Check via
  `container.warnings`, echte Zweitquelle) können real `bestanden`/`abweichung` melden;
  `material_total_detail_to_summary`, `manufacturing_total`/`setup_cost_allocation`/
  `tooling_fixture_cost`, `total_production_costs_cascade` und die Formel-Identitäts-Checks sind
  strukturell `nicht_pruefbar/same_source_no_independent_check` — AUSSER ein echter
  Formel-Cache-Drift (cachedValueDrift-Muster) feuert; `scrap`/`surcharges`/`offer_base_price`/
  `offer_price`/`volumes` sind strukturell `nicht_pruefbar/fehlende_daten` (kein Multi-QAF-Wert
  im Domänenmodell vorhanden).
- **Helper-Wiederverwendung statt Kopie:** `reconciliationEntries` in profile-differ.ts (bisher
  modul-privat) für diesen Zweck exportiert und in variant-reconciliation.ts wiederverwendet
  (dritte Konsumtion derselben same-source-vs-cachedValueDrift-Logik, keine vierte Kopie).
  `generateVirtualVariants` (container-assembly.ts) unverändert wiederverwendet.
  material-matrix-parser.ts's eigene Zeilen-Rekonziliation wird NICHT dupliziert (kein
  Grid-Zugriff auf Container-Ebene) — stattdessen deren bereits berechnetes Ergebnis über
  `container.warnings`-Codes gelesen.
- **Formel vs. cached Wert getrennt:** `buildFormulaProvenance` (formula-engine.ts)-Signaturen
  als eigenes `formulaSignatures`-Feld neben dem Wert-Drift-Check, nie vermischt.
- **Fail-closed:** `reconcileVirtualVariant` verweigert die Rekonziliation (alle 14 Checks
  `nicht_pruefbar/unmatched_variant`), wenn `variant` nicht nachweisbar aus `container` stammt
  (Fingerprint-/Identitätsabgleich).
- Determinismus: Check-Liste sortiert nach `checkId`, `reconcileContainer`-Ergebnis nach
  `variantId`. Export additiv in `lib/qaf-differences/index.ts`, kein Ingest-/UI-Wiring (das
  P2-Compare-Flow ist das erste P3-Item).
- 34 synthetische Unit-Tests (`__tests__/variant-reconciliation.test.ts`, ein Szenario je
  Check-Typ×Status/Reason + Formel-vs-cached-Divergenz + Mixed-Currency + Fail-closed-Guard +
  Determinismus/Vollständigkeit) + 5 env-gated Real-Tests
  (`__tests__/variant-reconciliation.real-files.test.ts`): alle 4 realen Dateien →
  `reconcileContainer` läuft fehlerfrei, 0 `abweichung`-Befunde, 0 `unmatched_variant` — Aggregat
  über 896 Checks: 784× `fehlende_daten`, 97× `same_source_no_independent_check`, 2×
  `mixed_currency`, 13× echtes `bestanden` (SUMPRODUCT-Inferenz auf 2 der 4 Dateien).

## 2026-07-13 – Multi-QAF Fertigungs-Vergleich mit 8 Befund-Typen (KAR-939)

Neues Modul `lib/qaf-differences/internal/multi-qaf/profile-differ.ts` (Multi-QAF-Programm P2.5,
Epic KAR-925): `diffProfiles(alt, neu, matchResult, options)` vergleicht Fertigungs-/Rüst-Profile
und Varianten-Bindungen zweier gematchter Container auf der WERT-Ebene, die `container-differ.ts`
(KAR-937/#309) explizit ausklammert — Master-Prompt §12 verlangt 8 getrennte Befund-Typen.

- **Items 1/2 — Komponenten-Wert / Profil-Total:** `values`-Record-Diff je strukturell
  gematchtem Profil (row-shift-invariante Identität `profilesByStructuralIdentity`, aus
  `container-differ.ts` exportiert und wiederverwendet, nie neu abgeleitet). `totalPerUnit`
  wird als eigener Befund (Item 2, mit betroffenen gebundenen Varianten) getrennt von allen
  anderen Komponenten-Keys (Item 1) gemeldet; Volumen-Schwellen-Keys (`threshold`/
  `band_N_upperBound`/`band_N_lotCount`) sind ausgeschlossen (Item-3-Territorium, siehe unten).
- **Item 3 — bewusst NICHT in diesem Modul:** Volumen-Schwellen-Änderungen werden bereits von
  #309s `profiles.volumeBandThresholdChanges` gemeldet; `isVolumeBandStructuralKey` (aus
  `container-differ.ts` exportiert) hält die Abgrenzung konsistent, keine Doppelmeldung.
- **Item 4 — Varianten-Bindungswechsel (Wert-Impact):** je gematchtem Varianten-Paar der
  alte vs. neue gebundene Profil-Wert bei `bound`/`unbound`/`rebound`; 3-Zustands-
  Währungs-Gate (`same_currency`/`currency_changed`/`currency_unknown`) auf Basis der
  Varianten-eigenen Währung, da `SharedCostProfile` selbst keine Währung trägt.
- **Items 5/6 — neues/entferntes Profil:** wie #309, aber MIT Werten (`values`+`totalPerUnit`).
- **Item 7 — inkonsistente Bindung:** unbound/unresolved_reference/ambiguous_reference/
  unresolvable_profileId, aus Container-Warnings + struktureller Bindungs-Prüfung.
- **Item 8 — Summary-Rekonziliation:** gebundener Profil-Wert (`formulaAndCachedValue.
  cachedValue` bevorzugt, `values.totalPerUnit` als Fallback) vs. `VirtualQafVariant`-
  Fertigungs-/Rüstkosten-/Tooling-Total (`generateVirtualVariants` wiederverwendet), Toleranz
  aus `RECONCILIATION_CONFIG`; `nicht_pruefbar`-State wo Werte fehlen.
- **Fail-closed Selbst-Diff-Sicherheit:** Items 7/8 sind SET-DIFFS (added/removed wie #309s
  `detailWithoutSummarySetDiff`), keine absoluten Pro-Seite-Schnappschüsse — reale Dateien
  haben ehrliche Vorab-Lücken (z. B. unbound-Spalten, KAR-932-Real-Test-Befund), ein
  Schnappschuss-Ansatz hätte den Selbst-Diff-Anker unmöglich gemacht.
- Toleranzen: `computeNumericDelta`/`DEFAULT_DIFFER_BANDS_CONFIG` (differ.ts) wiederverwendet;
  `RECONCILIATION_CONFIG`-Werte für Item 8.
- Determinismus: jede Finding-Liste sortiert, gleiche Eingaben → identisches Diff-Objekt.
- 23 synthetische Unit-Tests (`__tests__/profile-differ.test.ts`, eine Szenario je Befund-Typ +
  #309-Abgrenzung + Währungs-3-State + nicht_pruefbar + Selbst-Diff-Sicherheit) + 5 env-gated
  Real-Tests (`__tests__/profile-differ.real-files.test.ts`): Selbst-Diff aller 4 realen Dateien
  → 0 Befunde in allen 8 Gruppen; in-memory Profil-Total-Mutation (+5%) → exakt 1 Item-2-Befund
  mit korrekt identifizierten betroffenen Varianten, per Item-8 gegengeprüft.
- `container-differ.ts`: `profilesByStructuralIdentity`, `profileLabelSlug`,
  `isVolumeBandStructuralKey` exportiert (waren modul-privat) für Wiederverwendung durch
  `profile-differ.ts` — additiv, kein Verhaltens-Unterschied für bestehende Aufrufer.
- Export additiv, kein Ingest-/UI-Wiring.

## 2026-07-13 – Multi-QAF zweistufiger Material-Vergleich (KAR-938)

Neues Modul `lib/qaf-differences/internal/multi-qaf/material-differ.ts` (Multi-QAF-Programm P2.4,
Epic KAR-925): `diffMaterial(alt, neu, matchResult, options)` vergleicht die Material-Matrizen
zweier gematchter Container auf der WERT-Ebene, die `container-differ.ts` (KAR-937/#309)
explizit ausklammert — Master-Prompt §11 verlangt zwei getrennte Ebenen.

- **Ebene 1 — Shared-Komponenten** (je Materialzeilen-Paar, gematcht über
  `canonicalComponentIdentity` + Positions-basierte Rename-Erkennung bei Label-Änderung):
  Unit-Cost-Wert- und -Währungs-Änderung getrennt gemeldet, Wechselkurs, Transport/Zoll, MGK,
  Formel-Änderung (formula-engine.ts wiederverwendet — `formel_zu_konstante`/
  `formel_geaendert_wert_gleich`/`formel_geaendert_wert_geaendert` als eigene Befunde),
  Zeilen-Identitäts-Änderung (Label geändert bei gleicher Position, nie als add+remove
  doppelt gemeldet). KERN-REGEL: EINE shared Änderung = EIN Befund-Objekt mit betroffenen
  Varianten (Faktor-Key vorhanden = "enthalten", auch bei explizitem 0) + Impact je Variante
  (Faktor × Kosten-Delta) + Aggregat je Währung — nie N unabhängige Varianten-Befunde.
- **Ebene 2 — Varianten-Allokation** (je gematchtem Varianten-Paar × Komponente):
  Faktor-Änderung, Aufnahme (`not-applicable`→Faktor) und Ausschluss (Faktor→`not-applicable`)
  getrennt von explizitem 0↔Faktor (blank≠0, alle 4 Kombinationen getestet),
  added/removed Komponenten (eigene, mit #309 konsistente Zeilen-Paarung), Substitutions-Verdacht
  (removed+added mit ähnlichem Label via `lib/duplicates/fuzzy.ts normalizedSimilarity`, nie
  automatisch — reiner Review-Befund), effektiver Kosten-Beitrag je Variante.
- **Mixed-Currency-Doktrin (KAR-935):** Impact nur bei identischer Währung UND unverändertem
  Faktor auf beiden Seiten — sonst `impact: null` mit `reason` statt fabriziertem Wert, plus
  `mixed_currency_material_impact`-Warnung.
- **Fail-closed:** Zeilen mit `validationStatus !== 'ok'` liefern weiter Befunde (`reviewRelevant:
  true`), aber nie in Aggregate; nur gematchte Varianten (KAR-936-Gate) werden alloziert,
  `ambiguous`/`split_suspected`/`merge_suspected` unverändert durchgereicht (`uncertainMatches`).
- Toleranzen: `computeNumericDelta`/`DEFAULT_DIFFER_BANDS_CONFIG` (differ.ts) wiederverwendet,
  eigene benannte `MaterialDifferConfig`-Sektion für den Substitutions-Schwellenwert.
- Determinismus: jede Finding-Liste sortiert, gleiche Eingaben → identisches Diff-Objekt.
- 27 synthetische Unit-Tests (`__tests__/material-differ.test.ts`, eine Szenario je Befund-Typ) +
  5 env-gated Real-Tests (`__tests__/material-differ.real-files.test.ts`): Selbst-Diff aller 4
  realen Dateien via Serialize/Deserialize-Roundtrip → 0 Befunde in JEDER Gruppe (Anker); eine
  In-Memory-Mutation (+10% Unit-Cost auf einer realen Zeile, Datei 2/Index 2) → exakt 1
  Shared-Befund mit den korrekten betroffenen Varianten.
- Additiv über `lib/qaf-differences/index.ts` re-exportiert. Kein Ingest-/UI-Wiring.

## 2026-07-13 – Multi-QAF Container-Level-Vergleich: Adversarial-Review-Fixes (KAR-937, PR #309 Review)

6 unabhängig verifizierte Findings aus einem High-Effort-Adversarial-Review von PR #309 behoben
(`container-differ.ts`, `container-assembly.ts`):

- **F1 (schwerste):** `options.reviewRequired: false` ersetzte das berechnete
  `reviewRequired`/`reviewRequiredReasons` komplett und konnte so ein reales
  `uncertain_variant_matches_present`-Signal still verwerfen — brach Master-Prompt §9. Fix:
  Option umbenannt zu `forceReviewRequired?: true` — additiv, kann nur auf `true` zwingen, nie
  das berechnete Signal nach unten überschreiben; `reviewRequiredReasons` trägt computed- und
  Override-Gründe nebeneinander.
- **F2:** `detailWithoutSummarySetDiff` deduplizierte per gerendertem Message-TEXT — zwei
  verschiedene reale Spalten mit identischem Text (Spalten-Shift + wiederkehrendes Label) konnten
  sich gegenseitig aus dem Diff canceln. Fix: Dedup-Schlüssel strukturell (`variantIds[0]` der
  `material_variant_unmatched_to_summary`-Warnung, jetzt vom Producer gesetzt), Text nur noch
  defensiver Fallback.
- **F3:** `volumeBandThresholdChanges` difftte den kompletten `SharedCostProfile.values`-Record —
  rohe Kosten-Komponenten-Werte (`componentValuesOnRow`) leakten durch ein Modul, das
  Wert-Vergleiche explizit ausschließt (P2.5-Scope). Fix: Whitelist nur `threshold` /
  `band_N_upperBound` / `band_N_lotCount`.
- **F4:** ein Container-weiter Dimension-Key-Rename (z.B. `'Farbe'` → `'Farbe_Code'`) flutete
  `variants.renamed` für jede gematchte Variante statt einmal als Dimensions-Befund. Fix:
  Container-Korrelations-Pass (≥80% der gematchten Varianten mit identischem Wert von Alt- zu
  Neu-Key) → ein `dimensions.renamedKeys`-Befund, per-Varianten-Findings dafür unterdrückt.
- **F5:** `diffProfiles` schlüsselte added/removed/`volumeBandThresholdChanges` über den rohen
  `profileId` (`${sheet}!${cell}`, nicht row-shift-invariant wie `canonicalComponentIdentity`) —
  ein einzelner Zeilen-Shift im Fertigungskosten-Sheet meldete den gesamten Profil-Satz als
  removed+added. Fix: zweistufige strukturelle Identität (`kind` + Label-Slug + Ordinal
  innerhalb `kind`), `profileId` nur noch Anzeige-Metadatum.
- **F6 (latent, PLAUSIBLE):** `profileBindingsByKind` kollabierte mehrere Bindings pro
  (variantId, kind) per `Map.set` last-write-wins — heute unerreichbar (Producer garantiert ≤1
  Binding), aber ein stiller Datenverlust falls das je verletzt wird. Fix: Invariante jetzt
  per Assert (throw mit Erklärung) statt stillem Überschreiben abgesichert.

11 neue/angepasste Unit-Tests (44 gesamt in `container-differ.test.ts`), Selbst-Diff aller 4
realen Dateien weiterhin 0 Befunde in jeder Gruppe (jetzt über strukturelle statt Text-Gleichheit
erreicht), Cross-Diff Datei 2 vs. Datei 3 unverändert (0 gematcht, 5 removed, 8 added).

## 2026-07-13 – Multi-QAF Container-Level-Vergleich (KAR-937)

Neues Modul `lib/qaf-differences/internal/multi-qaf/container-differ.ts` (Multi-QAF-Programm
P2.3, Epic KAR-925): `diffContainers(alt, neu, matchResult, options)` vergleicht zwei
`MultiQafContainer` (ALT vs. NEU) auf STRUKTUR-Ebene — Master-Prompt §14 verlangt explizit, dass
das nicht auf eine Zell-Diff-Liste reduziert werden darf. Wert-Vergleiche (Material-Unit-Cost,
Profil-Totale, Formel-vs-cached) bleiben bewusst P2.4/P2.5/P2.6.

- **Varianten:** added/removed/renamed (Labels+Dimension-Detail)/reordered (Spalte/Slot),
  `uncertainMatches` (ambiguous/split/merge unverändert aus dem Matcher durchgereicht — nie in
  added/removed doppelt gemeldet, Fail-closed geerbt von `matchVariants`), aktiv↔inaktiv/reserved-
  Wechsel, Detail-Linkage (`detailLinkageStatus`: linked/orphaned/not_applicable/unknown, aus der
  Shared-Material-Matrix — der formel-lineage-getriebenen Struktur des Containers — abgeleitet;
  Transitionen nur für gematchte Paare, Detail-ohne-Summary als message-geankerter Set-Diff, damit
  ein Selbst-Diff garantiert leer bleibt).
- **Dimensionen:** neue/entfernte `variantDimensions`-Keys (Container-weit).
- **Shared Material:** nur Identitäts-/Struktur-Ebene (Zeilen-Anzahl, referenzierte Varianten-IDs
  als Spalten-Set, added/removed/geänderte Zeilen per `canonicalComponentIdentity`) — keine
  Unit-Cost-Werte.
- **Profile:** added/removed per `profileId`-Identität, `volumeBand`-Schwellen-Metadaten-Diff,
  Bindungs-Änderungen je gematchter Variante (bound/unbound/rebound, nach Profil-`kind` gruppiert).
- **Template:** Familie/Classification/Struktur-Hash, Sheet-Set (added/removed/hidden-Transition),
  External-Links ehrlich als `not_tracked` (Container trägt kein Link-Inventar) statt leer=gleich.
- `reviewRequired`-Propagation aus `reviewStatusForMultiQafContainer` (beide Seiten) + Präsenz von
  `uncertainMatches`, mit Caller-Override-Option.
- Determinismus: jede Finding-Liste sortiert, gleiche Eingaben → identisches Diff-Objekt.
- 33 synthetische Unit-Tests (`__tests__/container-differ.test.ts`, eine Szenario je Befund-Gruppe)
  + 5 env-gated Real-Tests (`__tests__/container-differ.real-files.test.ts`): Selbst-Diff aller 4
  realen Dateien via Serialize/Deserialize-Roundtrip → 0 Befunde in JEDER Gruppe (wichtigster
  Regressions-Anker); Cross-Diff Datei 2 vs. Datei 3 (beide m_qaf_1_0, 5 vs. 8 aktive Varianten) →
  0 gematcht, 5 removed, 8 added, Dimensions-Set-Churn 4/5 — reale, gepinnte Aggregat-Zahlen.
- Additiv über `lib/qaf-differences/index.ts` re-exportiert. Kein Ingest-/UI-Wiring (kommt mit dem
  P2-Compare-Flow nach P2.4–P2.6).

## 2026-07-13 – Multi-QAF Varianten-Matching: Adversarial-Review-Fixes (KAR-936, PR #308 Review)

8 unabhängig verifizierte Findings aus einem High-Effort-Adversarial-Review von PR #308 behoben
(`variant-matcher.ts`, `container-assembly.ts`):

- **F1 (schwerste, container-assembly.ts):** Fuzzy-Fallback übersetzte JEDES `matchVariants`-
  `'matched'`-Ergebnis ungegatet in die Kosten-Aggregation — ein Single-Dimension-Match
  (z.B. nur `steeringSide=links`) konnte bei voller Abdeckung Konfidenz 0.9 erreichen und wäre
  durchgerutscht. Fix: separates, benanntes `FUZZY_AGGREGATION_MIN_DIMENSIONS`/
  `FUZZY_AGGREGATION_MIN_CONFIDENCE`-Gate (nur `raw_exact`/`core_subset_exact`, ≥2 gemeinsame
  informative Dimensionen — NIE 1 —, Konfidenz ≥0.75, kalibriert an realen Datenpunkten: Datei
  "4"/ncar 4-Dim/Konfidenz-0.8-Matches müssen passieren, Datei "2"/mx 2-Dim/Konfidenz-0.667-Matches
  korrekt nicht). Unterhalb: unmatched + neue `material_variant_fuzzy_match_below_aggregation_gate`-
  Warnung (info, reviewRelevant) statt stiller Zuordnung.
- **F2 (variant-matcher.ts, Greedy-Assignment):** Struktur-Anker konnte einen echten Gleichstand
  (z.B. 2 rechte Duplikate mit identischem Score) auflösen und den Verlierer still zu `added`
  degradieren. Fix: `resolveComponentViaAnchor` löst eine Komponente nur noch auf, wenn KEIN
  Verlierer dadurch verwaist (kein alternativer Kandidat für seinen unbeanspruchten Endpunkt) —
  unterstützt jetzt auch mehrere gleichzeitige, sich nicht überschneidende Top-Anchor-Gewinner
  (z.B. ein reales 2×2-"Quadrat" mit zwei korrekten Diagonal-Paaren) statt nur einen einzelnen
  Gewinner pro Komponente.
- **F3 (variant-matcher.ts, `isTopLeftForRight`/`isTopRightForLeft`):** Zwei verschiedene linke
  Varianten konnten dieselbe rechte Variante unabhängig als eigenen `split_suspected`-Fund
  beanspruchen (2 widersprüchliche Review-Items). Fix: `clusterProvisionalGroups` (Union-Find)
  fasst Split-/Merge-Gruppen, die einen top-beanspruchten Index teilen, zu EINEM `ambiguous`-Fund
  zusammen — symmetrisch für Splits und Merges.
- **F4 (variant-matcher.ts, `structuralAnchorScore`):** Volumen-Anker verglich `annualVolume` vs.
  `peakVolume` über die Seiten hinweg (unabhängige `??`-Fallback-Ketten). Fix: nur noch
  feldgleicher Vergleich (annual↔annual, peak↔peak, lifetime↔lifetime), bestes Feld gewinnt.
- **F5 (Greedy vs. global optimal):** Dokumentierter Restrisiko-Fall (kein Code-Fix — Analyse
  ergab, dass die konservative Variante bereits durch F2s Stranding-Guard abgedeckt ist: ein
  blockierter globaler Match landet jetzt in `ambiguous` statt still `added`/`removed`).
- **F6:** Neue nicht-gegatete Tests mit echter PAARUNGS-Assertion (nicht nur Counts):
  `variant-matcher.test.ts` (Konfig-Override-Test) + `container-assembly.test.ts` Pattern H
  (2 Varianten mit unterschiedlichen Material-Kostenfaktoren — eine vertauschte Zuordnung wäre
  direkt sichtbar).
- **F7 (variant-matcher.ts):** Stufe-3-Gewichte (`0.5`/`0.35`) waren harte Literale. Fix: benannte,
  konfigurierbare `VariantMatchConfig.fuzzyBaseWeight`/`fuzzyCoverageWeight` (Default unverändert).
- **F8 (CHANGELOG/Real-File-Tests):** Zahlen unten neu gemessen und an gepinnte Tests gekoppelt
  (`toBe`, nicht `toBeGreaterThan`) statt frei behauptet.

**Real-Ergebnis NACH dem F1-Gate (env-gated, 4 echte Dateien, Manifest-Position 0-3):**
Selbst-Match unverändert 100% `raw_exact`, 0 ambig/split/merge (alle 4). Cross-Sheet-Fallback:
Datei "4"/Index 3 (ncar) — 8 core_subset_exact-Matches (4 gemeinsame Dimensionen, Konfidenz 0.8,
über `originalVariantNumber`-Übereinstimmung unabhängig als korrekt verifiziert) passieren das
Gate vollständig: 8 Varianten mit Material-Zeilen UND -Total. Datei "2"/Index 1 (mx) — **ehrlicher
Rückschritt gegenüber dem #308-Erstwurf**: die 7 real vorhandenen Fuzzy-Kandidaten ruhen nur auf
2 gemeinsamen Dimensionen bei Konfidenz 0.667 und fallen jetzt korrekt unters Gate (0 statt 7
Fuzzy-Matches, 0 Material-Zeilen) — der #308-Erstwurf hatte diese schwachen Kandidaten ungegatet
durchgereicht, was genau das "falsche Zahlen"-Risiko war, das dieser Review-Durchlauf schließt.
Datei "3"/Index 2 (nafta) unverändert bei 4/5 (reiner Exact-Pass). Datei "1"/Index 0 unverändert
bei 0 (dokumentierte, außerhalb des Scopes liegende Header-Parser-Lücke).

Tests: `variant-matcher.test.ts` 25→30 (+5: F2-Sicherheitsvariante, 2× F3-Konflikt, F4, F7),
`container-assembly.test.ts` 13→15 (+2: Pattern H Gate-Akzeptanz/-Ablehnung). Volle Suite: 3135
bestanden (Basis 3128 + 7 neu), 0 fehlgeschlagen, `lib/qaf-differences/internal/multi-qaf/`
isoliert: 345/345 (inkl. env-gated Real-File-Suiten).

## 2026-07-13 – Multi-QAF Varianten-Matching mit Review-Workflow (KAR-936)

Neues Modul `lib/qaf-differences/internal/multi-qaf/variant-matcher.ts` (P2.2) — Master-Prompt §9
Matching-Kaskade für zwei Anwendungsfälle: (A) Cross-Container-Matching (ALT vs. NEU) und (B)
Cross-Sheet-Matching innerhalb eines Containers (die in KAR-935 offen gelassene Lücke).

- `matchVariants(left, right, config?)`: 3-Stufen-Kaskade (`raw_exact` → `core_subset_exact` →
  `fuzzy_label_similarity`, je mit Score + Evidence) + Stufe-4-Struktur-Anker
  (`originalVariantNumber`/Volumen-Nähe) als reiner Tiebreaker, nie als alleiniges Kriterium.
  Ergebnis-Klassen: `matched`, `ambiguous` (Gleichstand/Duplikate, NIE automatisch für
  Aggregation), `split_suspected`/`merge_suspected` (1↔n-Score-Konstellationen mit
  Score-Variation, vor dem Greedy-Assignment erkannt), `unmatched_left`/`unmatched_right`. Greedy
  Zuordnung mit deterministischem Tiebreak über verbundene Komponenten (`connectedComponents`);
  bei echtem Gleichstand auch nach Tiebreak: beide Seiten `ambiguous`.
- DE/EN-Dimensions-Werte-Synonym-Tabelle (klein, konservativ, identity.ts's eigene
  "künftiges P2.2-Item"-Lücke) + Tippfehler-Toleranz über `lib/duplicates/fuzzy.ts`
  (`normalizedSimilarity`, wiederverwendet statt neu gebaut).
- `matchVariantsWithOverrides(left, right, overrides, config?)`: Review-Workflow analog
  `field-mapping-override.ts` (KAR-912) — `VariantMatchOverride` mit Identitäts-Guard
  (compositeCanonicalKey + Dimensions-Snapshot, NIE Spaltenposition); Override gewinnt über
  Auto-Match, wird bei Identitäts-Drift verworfen + als `dropped_on_drift` gemeldet. Kein
  eigenes Serialize/Deserialize nötig — beide Typen sind bereits plain-JSON-safe.
- Integration (B): `container-assembly.ts`'s `matchMaterialVariantsToContainer` nutzt
  `matchVariants` jetzt als zweite Stufe NACH dem exakten RAW-Key-Pass — nur eindeutige
  Fuzzy-Matches (`material_variant_fuzzy_matched_to_summary`, info/reviewRelevant) übersetzen
  Material-Sheet-Keys, ambige/Split/Merge-Funde bleiben unmatched + bestehende Warnung wie bisher.
- Real-Ergebnis (env-gated, 4 echte Multi-QAF-Dateien, per Manifest-Position 0-3 referenziert):
  Selbst-Match jeder Datei gegen sich selbst = 100% `raw_exact`, 0 ambig/split/merge (alle 4).
  Cross-Sheet-Fallback: Datei 4 vollständig geschlossen (0→8 Varianten mit Material-Total,
  8 Fuzzy-Matches Stufe `core_subset_exact`); Datei 2 Material-Zeilen fließen jetzt
  (7 Fuzzy-Matches), Total bleibt bei den betroffenen Varianten korrekt `null` (echte
  3-Währungs-Mischung, Master-Prompt §7 "never force an interpretation" — keine Regression,
  ehrliches Verhalten); Datei 3 unverändert bei 4/5 wie in #307. Datei 1 bleibt unverändert bei
  0 — dokumentierte Rest-Lücke: Summary-Sheet dieser Datei liefert für header-parser.ts
  (P1.2/KAR-930) gar keine erkannten Dimensions-Keys (nur slugifizierte Fallback-Keys ohne
  Überschneidung zum Material-Sheet) — außerhalb des Scopes dieses Matching-Moduls.
- Tests: 25 synthetische Szenarien (alle Master-Prompt-§9-Toleranzen: Reorder, Rename,
  Extra-/fehlende Dimension, DE/EN, Tippfehler, Split, Merge, Duplikate, Gleichstand,
  Struktur-Anker-Tiebreak, Override gewinnt/verworfen) + 5 env-gated Real-File-Tests. Volle Suite:
  3128 bestanden (Basis 3098 + 30 neu), 0 fehlgeschlagen.

## 2026-07-13 – Multi-QAF Container-Assembly + VirtualQafVariant-Generierung (KAR-935)

Neues Modul `lib/qaf-differences/internal/multi-qaf/container-assembly.ts` (P2.1) — orchestriert
die 6 gemergten P1-Module (identity/serialization/bridge KAR-929, header-parser/column-classifier
KAR-930, material-matrix-parser KAR-931, profile-parser KAR-932, formula-lineage KAR-933,
template-fingerprint KAR-934) zu einem vollständigen `MultiQafContainer` und generiert
`VirtualQafVariant[]`:

- `assembleMultiQafContainer(workbook, detection, options)`: Identität (Summary-dann-Material-
  Fallback, template-fingerprint.ts wiederverwendet), Material-Matrix (separat je Sheet
  re-geparst, per material-matrix-parser.ts's eigenem Precondition), Profile-Bucketing
  (manufacturing/setupCost/tooling nach `.kind`, in KAR-932 an diese Schicht deferred),
  Varianten-Profil-Bindungen (nur wenn Summary die Identitätsquelle war — Cross-Sheet-Matching
  ist P2.2-Folgearbeit), Formel-Lineage-Notizen, aggregierte Warnungen/Confidence.
  Kollisions-Disambiguierung (identity.ts `disambiguateCanonicalKeys`) wird jetzt aktiv
  angewendet und hält `stableInternalId` synchron — vorher war das nur ein "opt-in" Baustein
  ohne Aufrufer.
- `generateVirtualVariants(container)`: pure Funktion über den fertigen Container — Material-Total
  aus Matrix (blank≠0-Semantik), Fertigungs-/Setup-/Tooling-Total aus gebundenem Profil,
  Rekonziliations-Warnungen je Variante.
- `resolveMultiQafContainerIngest`: fail-closed Wrapper (Assembly-Fehler → Fallback auf die
  bestehende Abweisungsmeldung) — testbar ohne Supabase-Mocking.
- Ingest-Wiring in `app/qaf-differences/actions.ts` (`ingestQafUpload`): bei
  `multiQafDetection.enabled` + confirmed/probable wird jetzt zuerst Assembly versucht statt
  sofort abzulehnen; bei Erfolg landet die Datei als `qaf_file`-Zeile (`template_type:'MULTI_QAF'`,
  Container + VirtualVariants additiv in `g60_meta`, gleiches JSONB-Bag-Muster wie
  `templateFingerprint`/`manufacturingParseMeta`) — **keine** Standard-Summary-Persistenz, **kein**
  Vergleichs-Lauf (Compare ist P2.3+). `analyzeQafBatchFromStorage` meldet das bilingual über den
  bestehenden `fileErrors`-Kanal ("Multi-QAF erkannt, N Varianten aktiv — Vergleich folgt").
  Flag OFF: unverändertes Verhalten (Detector läuft nicht).
- Kein DB-Migration in diesem PR — Persistenz nutzt die bestehende `qaf_file.g60_meta`
  JSONB-Spalte additiv. Eine eigene Tabelle (analog `qaf_g60_tab`) wäre der saubere P4-Weg für
  abfragbare/indizierte Multi-QAF-Daten — als Follow-up notiert, operator-gated.
- Real-Datei-Tests (env-gated) bestätigen exakte Konsistenz zu den P1-Einzeltests: 26/25(10+2+13)/
  5/8 Varianten, 120/60/29/53 Matrix-Zeilen, 47/34/30/62 Profile, Familien wie KAR-934.
- Additiv: `template-fingerprint.ts`s `locateVariantHeaderBlockOnSheet` exportiert (kein
  Verhaltens-Change) — von container-assembly.ts für die Material-Sheet-eigene
  Spalten-Scan-Wiederholung wiederverwendet.

## 2026-07-13 – Multi-QAF Template-Fingerprint-Familien: adversarialer Review, 4 Fixes (KAR-934)

Adversarial-review fixes on top of the KAR-934 PR below, before merge:

- **F1 (Live-Datenverlust, CONFIRMED):** `multiQafTemplateFingerprintInputFromExcelJs` only ever
  scanned the SUMMARY sheet for the variant/dimension header block. One real Multi-QAF file
  (Datei 3, 10-analyse-nafta.md) stores that block on the MATERIAL sheet instead, at a different
  column offset — the bridge silently reported `variantColumnCount=0` for a file with 5 real
  active variants. Fixed with a bounded fallback: when SUMMARY materializes zero variants and the
  detector shows any Multi-QAF-shaped evidence, retry against the MATERIAL-alias sheet across a
  bounded set of leftward-shifted scan-window candidates and keep the one that materializes the
  most variants (`header-parser.ts`'s own `parseVariantHeaderBlock` is reused verbatim per
  candidate, never a hardcoded target column). Datei 3 now fingerprints with
  `variantColumnCount=5`.
- **F2 (Marker-ohne-Struktur, CONFIRMED):** `determineFamily` assigned a known family from the
  M-QAF marker TEXT alone, with no structural corroboration; the hard-unknown branch left
  `family` unchanged even though `classification` became `'unknown'`; and the persisted
  container never carried `classification` at all — a structurally-unrelated file with a stray
  marker string could be read downstream as a confidently-recognized template. Fixed: (a) a
  marker only resolves to `m_qaf_1_0`/`m_qaf_2_0` when ALSO corroborated by a variant-identity
  mechanism (slot-index/dimension row) or a non-marker detector signal — otherwise
  `unknown_multi_qaf`; (b) the hard-unknown branch now always returns
  `family: 'unknown_multi_qaf'` (re-hashed consistently); (c) `MultiQafTemplateFingerprint`
  (types.ts, additive) gained a `classification` field, and `toMultiQafContainerFingerprint`
  carries it through.
- **F3 (Config-Divergenz, PLAUSIBLE):** the ExcelJS bridge always re-derived
  `MultiQafDetectionResult` via a fresh default-config `detectMultiQaf` call, independent of
  `actions.ts`'s own operator-configurable detection pass over the same workbook. Fixed:
  `multiQafTemplateFingerprintInputFromExcelJs`/`fingerprintMultiQafTemplateFromExcelJs` now
  accept an optional `{ detection?, detectionConfig? }` — a caller can pass an already-computed
  detection result (never re-derived) and/or forward the resolved operator config.
- **F4 (Cleanup, PLAUSIBLE):** `familyProfileId` hand-duplicated the family→profile-id mapping
  already held in `KNOWN_MULTI_QAF_TEMPLATE_PROFILES`; now a lookup against that single source
  of truth.

Real-file regression re-verified for all 4 manifest files (family mapping unchanged, Datei 3 now
shows 5 materialized variants); standard-QAF neutrality untouched (diff does not touch
`computeTemplateFingerprintStructure`/`classifyTemplateFingerprint`/`buildTemplateFingerprint`).

## 2026-07-13 – Multi-QAF Template-Fingerprint-Familien (KAR-934)

New `lib/qaf-differences/internal/multi-qaf/template-fingerprint.ts` (Multi-QAF-Programm
P1.6, last P1 item): classifies an already-detected (`qaf-type-detector.ts`)/already-parsed
(`header-parser.ts`) Multi-QAF workbook against 3 known template families —
`m_qaf_1_0`/`m_qaf_2_0` (explicit "M-QAF Version 1.0"/"2.0" marker) and `qaf_8_1_custom_multi`
(no marker, purely structural — the ClarWE shape). Purely additive to
`../template-fingerprint.ts` (KAR-895): its standard-QAF known/modified/unknown
classification is untouched (the only change there is exporting the pre-existing
`stableStringify` helper for reuse — zero behavior change, verified both structurally and via
a real-file before/after comparison against an `origin/main` worktree, see PR body).

- `fingerprintMultiQafTemplate(input)`: pure core — family (via `qaf-type-detector.ts`'s own
  matched version-marker digit, reused not re-scanned) + a structural checklist
  (SUMMARY/MATERIAL sheet presence, variant-identity mechanism, materialized-variant count,
  unrecognized/fallback dimension keys from `header-parser.ts`) drives known/modified/unknown,
  bilingual named deviations, and a deterministic sha256 `structuralFingerprint` over a
  semantic signature (sheet-name set, slot-index run-length pattern, header-block depth,
  `qaf-type-detector.ts`'s own signal findings passed through verbatim).
- New `MultiQafTemplateFamily` member `'unknown_multi_qaf'` (types.ts, additive): the detector
  shows Multi-QAF-shaped evidence but no known family matches — never silently guessed as the
  nearest known profile (Master-Prompt §8), distinct from the pre-existing `'unknown'`
  "never computed" default.
- `toMultiQafContainerFingerprint`: reduces the rich result to `MultiQafContainer.
  templateFingerprint`'s leaner persisted shape.
- `fingerprintMultiQafTemplateFromExcelJs` / `multiQafTemplateFingerprintInputFromExcelJs`:
  thin ExcelJS convenience bridge composing `qaf-type-detector.ts`/`header-parser.ts`'s own
  bridges — no ingest wiring, no DB/migration (P1.6 "Foundation stage" scope, same as every
  sibling `multi-qaf/` P1.x module).
- Real-file regression (env-gated, `__tests__/template-fingerprint.real-files.test.ts`):
  the 4 real Multi-QAF manifest files fingerprint to exactly the expected family (Datei 1 →
  `qaf_8_1_custom_multi`, Datei 2 → `m_qaf_2_0`, Datei 3+4 → `m_qaf_1_0`), deterministically
  (double-parse, byte-identical hash); a real standard-QAF neutrality suite proves
  `buildTemplateFingerprint` still runs deterministically end-to-end for 5 real standard files
  through this module's own new code path (never invoked for those files at all).

## 2026-07-13 – Multi-QAF Fertigungs-/Rüst-Profile + Bindungs-Auflösung (KAR-932)

New `lib/qaf-differences/internal/multi-qaf/profile-parser.ts` (Multi-QAF-Programm P1.4):
pure profile-block detection over a Fertigungskosten-shaped grid + variant→profile binding
resolution + an ExcelJS-workbook-level candidate-sheet selector with Orphan-Hidden-Sheet-
Ausschluss (Incoming-Reference-Check, reusing formula-lineage.ts's `buildColumnLineage` as
the sole reference-following mechanism — no ingest wiring, additive export via index.ts).

- `parseSharedCostProfileBlocks`: finds SUMIF/SUMPRODUCT-shaped Total cells (live profiles)
  plus same-column literal Total cells (hardcoded profiles, e.g. an I-Shape total that
  bypasses the process-row formula chain entirely) and classifies each into
  `lShape`/`iShape`/`setupCost`/`volumeBand`/`manufacturing` from its row label — reading a
  3-block volume-band pattern's `>50k/>20k/<20k`-style label as METADATA only, never rebuilt
  as selection logic. `locateVolumeBandLookupTable` additionally recognizes a standalone
  2-column volume-threshold/lot-count lookup table.
- `resolveVariantProfileBindings`: per variant, reads the Summary Fertigungskosten cell's
  formula and classifies the binding mechanism — `cellReference` (a hardcoded absolute
  reference to a detected profile Total), `literal` (a bare value matching a profile Total
  within tolerance, ambiguous matches reported not guessed), or `formula` (a live/additive
  multi-reference formula, best-effort matched against any referenced profile). No match
  produces NO binding entry (`VariantProfileBinding.profileId` is non-nullable) plus an
  explicit `MultiQafWarning` — never fabricated.
- `reconcileProfileToSummary`: per-variant Summary-value ↔ profile-Total reconciliation
  (`cellReference`/`literal` bindings get a hard match/mismatch verdict; `formula` bindings
  stay `null` — an additive formula is not expected to equal one profile's total 1:1).
- `sharedCostProfileCandidateSheets`/`sharedCostProfilesFromWorkbook`: selects candidate
  sheets by MANUFACTURING-alias name match OR formula-lineage-reachability (admits a
  non-"Fertigungskosten"-named split sheet without a filename-specific parser), and
  EXCLUDES a hidden orphan sheet (structurally profile-shaped OR name-hinted, but with zero
  incoming references) while still emitting an `orphaned_hidden_sheet` warning — never
  silently dropped.
- `ManufacturingProfileFindingKind`: classification-vocabulary-only enum for Master-Prompt
  §12's 8-item report list (comparison itself is P2.5).

Two real-file-driven correctness fixes found only once run against the 4 real Multi-QAF
files (`profile-parser.real-files.test.ts`, env-gated):

- Row/label lookups that took the NEAREST left-of-scan-target text cell (both the Summary
  sheet's own Fertigungskosten-row lookup and a Total-cell's own row-label lookup) picked up
  an intervening annotation/currency cell (e.g. a "Siehe Reiter..." comment, a bare "EUR")
  sitting closer to the scan target than the real label — fixed to scan the WHOLE left
  margin for an anchor match (`rowHasManufacturingLabel`) or prefer a SPECIFIC pattern match
  over the nearest generic text (`findProfileLabel`).
- `SHAPE_LABEL_RE` used `\b` word-boundary anchors, which never fire between two word
  characters — a real label like `"PP_L-Shape"` (underscore immediately before the shape
  letter) was silently missed. Switched to letter-only lookbehind/lookahead.

`__tests__/profile-parser.test.ts` (30 synthetic cases, invented data per the Fixture-Daten-
Regel): cell-reference/literal(-exact/-tolerance/-no-match/-ambiguous)/live-formula binding
paths, all-variants-share-one-reference, 3-block volume bands, volume-band lookup table,
orphaned-vs-referenced hidden sheet. `__tests__/profile-parser.real-files.test.ts` (4 cases,
env-gated, structural/relational assertions only — no hardcoded count literals): validates
against all 4 real Multi-QAF files that profiles are found, binding rates stay within
[0, 1], the L-Shape/I-Shape and volume-band/orphan-hidden-sheet patterns are exercised.

Adversarial-review fix round (3 confirmed findings + 2 open focus questions):

- **F1** (fail-open, most severe): `sharedCostProfileCandidateSheets` derived sheet
  reachability SOLELY from `graph.edges`, ignoring `buildColumnLineage`'s own fail-closed
  cap signals (`scanIncompleteSheets`/`rangeTruncatedSheets`/`depthCappedSheets`) — a
  capped trace was misread as proof of "definitely no reference", silently dropping a
  legitimate non-name-matched profile sheet OR confidently reporting a hidden sheet as a
  proven `orphaned_hidden_sheet` when the trace never completed. Fixed: a whole-graph
  `lineageIsCapped` flag now gates both paths — an unreached, structurally profile-shaped
  hidden sheet under a capped trace is reported as `hidden_sheet_reachability_unknown`
  (never `orphaned_hidden_sheet`) and excluded; an unreached, structurally profile-shaped
  VISIBLE sheet under a capped trace is conservatively INCLUDED with a
  `lineage_reachability_unknown_conservative_include` warning instead of silently dropped.
- **F2**: `VOLUME_BAND_LABEL_RE`'s unit suffix was optional, so any `<N`/`>N` annotation
  (e.g. a tolerance note `"< 5"`) matched as a volume-band label — `findProfileLabel` then
  preferred it over the real nearby label, and `classifyProfileKind` produced a
  confidently-wrong threshold. Fixed: `matchVolumeBandLabel` now requires either a unit/
  context anchor (Stk/pcs/units/Jahr/p.a./year) OR a plausible magnitude
  (`>= MIN_PLAUSIBLE_VOLUME_BAND_THRESHOLD = 1000`) before accepting a match.
- **F3** (explains a large profile-count over-count): a Material-/BOM-matrix sheet (e.g.
  "BOM Detail EU") that is lineage-reached but not name-matched used to fall through to the
  structural-fallback inclusion path and get admitted as a manufacturing-profile source —
  every SUMPRODUCT-shaped row-total formula on its material rows was then misread as an
  independent profile. Fixed: `isMaterialBomDomainSheet` excludes a sheet whose tab name
  carries the observed "bom" family OR whose header row structurally matches
  `material-matrix-parser.ts`'s own `locateMaterialMatrixHeader` gate (componentLabel +
  unit-cost anchor — the canonical, now-merged KAR-931 material-matrix header detector,
  reused directly rather than duplicated). Real-file re-measurement: Datei 1's profile count
  dropped from 252 to 47 (0 of which now originate from a Material-/BOM-domain sheet).
- **Zusatzpunkt (a)** — Datei 2's binding rate stays 10/14 both before and after this fix;
  re-measured and explained (not just re-observed): the 4 unbound candidates are a small,
  pre-existing, non-standard benchmark/delta-helper column family outside the file's own
  variant-slot catalog (a foreign literal import plus two derived delta/percent columns),
  already anticipated by this module's own pre-existing doc comments — each produces exactly
  one honest `variant_profile_binding_unresolved` warning, never a silent drop or a wrong
  guess. The real-file test now asserts this structurally (warning count matches the gap,
  no bound variant's value contradicts its profile Total) instead of trusting the ratio.
- **Zusatzpunkt (b)**: `resolveLiteralBinding` now picks the CLOSEST profile total
  deterministically when two or more fall within tolerance of the same literal value
  (smallest `|value-total|` distance) — only a genuine TIE (identical minimal distance) is
  still reported as `ambiguous_literal_profile_binding`; previously every within-tolerance
  collision was flagged ambiguous regardless of whether one candidate was clearly closer.

12 new synthetic unit tests + 2 real-file structural-invariant assertions added; all 4 real
Multi-QAF files re-run end to end after the fix.

## 2026-07-13 – Multi-QAF Material-Matrix-Parser (KAR-931)

`lib/qaf-differences/internal/multi-qaf/material-matrix-parser.ts`: parses one detail
sheet's shared material/BOM matrix (Material/BOM Detail EU/Vgl Material) into
`VariantMatrixRow[]` — shared position list + per-variant quantity-factor/effective-cost
matrix — plus a per-variant SUMPRODUCT-Verifikation reconciliation (`bestanden` /
`abweichung` / `nicht_pruefbar`), reusing `formula-lineage.ts`'s `classifyFormulaShape` for
total-cell location instead of a new formula analyzer. Empty-cell semantics empirically
resolved as "not applicable" (omitted key), distinct from an explicit literal `0`
(10-analyse-nafta.md D, 10-analyse-mx.md F). `unitCost` is populated only from an
explicit AW-labeled column — no automatic BW/exchange-rate fallback, since the real
families do not agree on the fallback's arithmetic direction. Fail-closed scan-window
truncation guard, same discipline as `formula-lineage.ts`.

Adversarial-review fix round (6 confirmed findings):

- **F1**: the padding-row filter only skipped a row when BOTH position AND label were
  blank, so a labeled total/summary row (position blank, label "Summe Materialkosten" —
  the exact documented real pattern, 10-analyse-clarwe-eu.md D row 159) was NOT skipped
  and got parsed as a material row: its SUMPRODUCT-cached totals were absorbed as bogus
  per-component quantity factors, and `lastMaterialRow` advanced past the real total row
  so `locateVariantTotalCell` never found it — the SUMPRODUCT-Verifikation feature was
  silently dead on this real-file pattern. Fixed: total/summary rows are now recognized
  by a DE label pattern (Summe/Gesamt) OR by a SUMIF/SUMPRODUCT/aggregate-shaped formula
  in the variant columns themselves, and excluded from material-row extraction
  (`material_matrix_total_row_excluded` info warning). The synthetic fixture was updated
  to reproduce the real pattern (label set, position blank) instead of leaving both blank.
  The label pattern deliberately does NOT include EN "total"/"sum": the real-file test
  (F5) caught the bare EN "sum" alternative wrongly excluding a genuine material row
  literally labeled "sum of small parts" in one of the 4 real files — none of the 4 real
  files' actual total rows use an English label at all, so the EN alternative was
  unverified speculation the real-file run directly falsified.
- **F2**: `scanReachedWindowEdge` fired unconditionally whenever the scan loop's last
  iteration landed on `rowTo`, even when that row held no plausible material content —
  forcing spurious `nicht_pruefbar` on fully-parsed files (in practice, on nearly every
  file, since `worksheetToGrid` returns the full used range). Fixed: the flag now only
  fires when the edge row carries plausible material content (position/label non-blank,
  or a cost cell holding a genuinely parseable number or cached Excel error — NOT just any
  non-blank cost-cell string, a refinement the real-file test caught: one real file's
  post-material overhead-markup row left a stray unit-label string, not a number, in the
  cost column of an otherwise position/label-blank row) AND is not itself a recognized
  total/summary row — a total row landing exactly at the edge (the realistic
  `materialMatrixScanBoundsFromWorksheet` case) is the documented end-of-data marker, not
  evidence of truncation.
- **F3**: `FIELD_LABEL_SYNONYMS` hand-copied `mat_calc_material_cost`'s stale `labelDe`
  ("Kalkulatorische Materialkosten [AW]") instead of the KAR-915-confirmed real V8.8 alias
  ("Materialkosten [AW]") — reproducing the exact documented V8.8 real-file bug. Fixed: the
  3 fields this module reuses from the standard MATERIAL registry (positionNumber/
  componentLabel/unitCostAw) are now resolved from `canonical-fields.ts` via a cached
  dynamic import (`loadCoreLabelSynonyms`, mirrors `material-parser.ts`'s
  `loadMaterialRegistry`) instead of a second hand-copied constant, so the alias list has
  one source of truth. `parseMaterialMatrix` is now async as a result (no downstream
  callers existed yet); `locateMaterialMatrixHeader` stays synchronous with an optional
  `extraSynonyms` parameter. Separately, the real-file run (F5) discovered this module's
  OWN bespoke Multi-QAF header labels never matched any of the 4 real files' actual header
  text at all (e.g. real files say "... (in AW)"/"... (in BW)" where this module's table
  only had "... (AW)"/"... (BW)"; a combined "Position Number \n reference to Exploded
  Drawing" label; "rate off exchange", a real, reproduced-verbatim BMW template typo) —
  `FIELD_LABEL_SYNONYMS` gained these newly-confirmed real aliases too.
- **F4**: the collision-disambiguation suffix (`#row<N>`) embedded the absolute row number,
  breaking the row-shift invariance `canonicalComponentIdentity` promises for future P2
  cross-version matching — inserting/deleting any row above a collision group changed the
  identity of a component that itself did not move. Fixed: the suffix is now the
  occurrence ordinal within the collision group in document order (`#2`, `#3`, ...), which
  is stable under row insertion/deletion as long as the group's own internal order is
  preserved (documented edge case for a reorder within the group itself).
- **F5**: added the previously-deferred env-gated real-file test
  (`material-matrix-parser.real-files.test.ts`, same manifest-position-addressed,
  aggregate-only-assertion discipline as `header-parser.real-files.test.ts`). It reconciles
  (`bestanden`, delta 0) for ALL variants on 2 of 4 real files, `bestanden` plus 2 honestly
  `nicht_pruefbar` (a variant column with no total formula of its own — the same case the
  synthetic fixture's variant-delta demonstrates) on a 3rd, and correctly fail-closed
  `truncated` on the 4th (ambiguous non-numeric residue near the sheet's own tail, outside
  the real material data — the guard doing its documented job, not a defect). Running it
  found and fixed 3 additional real-file-only bugs folded into F1/F2/F3 above.
- **F6**: this entry.

## 2026-07-12 – Multi-QAF Container-Domain-Model (KAR-929)

`lib/qaf-differences/internal/multi-qaf/` (types.ts/identity.ts/serialization.ts/bridge.ts):
pure domain model + core functions for a Multi-QAF workbook (a container of several product
variants sharing one material master/manufacturing profiles), per Master-Prompt §6 — no
ExcelJS/workbook parsing yet (P1.2+), no DB migration, no ingest wiring. `identity.ts` builds
the reorder/rename-stable `compositeCanonicalKey` (§9) and derives active/inactive/reserved
classification (§10, calibrated against the real 25/12/10-slot MX pattern). `serialization.ts`
is the versioned JSON envelope (reject-on-unsupported-modelVersion, undefined-tolerant reads).
`bridge.ts` maps a `VirtualQafVariant` onto a `Partial<QafFileParsed>`, explicit about which
canonical fields are genuinely sourced vs. deliberately left absent (never fabricated).
`__tests__/synthetic-fixtures.ts` reproduces the 4 real analyzed workbooks' STRUCTURAL patterns
with entirely invented codes/numbers (see that file's "Fixture-Daten-Regel" header).

Adversarial-review fix round (7 confirmed findings, F4 was a merge-blocker):

- **F1**: `normalizeDeserializedContainer` only defaulted TOP-LEVEL missing fields; a present-but-
  partially-shaped nested object (`sourceWorkbook`/`templateFingerprint`/`summaryAggregation`)
  came back verbatim instead of field-by-field normalized. Added
  `normalizeSourceWorkbook`/`normalizeTemplateFingerprint`/`normalizeSummaryAggregation`.
- **F2**: `buildCompositeCanonicalKey` derives identity from dimension VALUES only — two real
  variants with identical dimensions (or two identity-less reserved slots) collide on the same
  key, and `bridge.ts` stamped that key straight onto `QafSummary.partNumber`. Added
  `detectCanonicalKeyCollisions`/`disambiguateCanonicalKeys` (identity.ts, structural suffix from
  `originalVariantNumber`/`originalColumn`, never volume) and a bridge.ts guard so an empty key
  never passes through as a part number.
- **F3**: `deserializeVirtualQafVariants` silently coerced a non-array `variants` payload to `[]`
  instead of throwing (inconsistent with the envelope's other reject-don't-reinterpret checks).
- **F4** (confidentiality, merge-blocker): the "synthetic" fixtures and 3 test files contained
  literal real BMW platform codes, motor/axle/rack/ECU codes, exact peak volumes, and even exact
  `SUMIF` cell-range formulas copied verbatim from the internal real-file analysis docs. All
  fixture builders rewritten with fully invented codes/numbers/formulas and renamed away from the
  real project codenames (`mx`/`ncar`/`nafta`/`clarwe` → `wideSlot`/`multiRowHeader`/
  `sharedManufacturing`/`splitColumn`); added a "Fixture-Daten-Regel" header comment to
  `synthetic-fixtures.ts` so the rule lives in the code, not only in review memory.
- **F5**: `synthetic-fixtures.ts`'s local `columnLetterFor` duplicated `columnIndexToLetter` from
  the same PR's `types.ts` — now imported instead of duplicated.
- **F6**: named the previously-inline `0.5` metric-confidence magic number in bridge.ts
  (`UNVERIFIED_METRIC_CONFIDENCE`).
- **F7**: this entry.

## 2026-07-10 – Explain-Panel: dynamische Texte bilingual (KAR-924 Teil 1)

`components/qaf-differences/qaf-explain-panel.tsx` (KAR-922/PR #294) war nur im GERÜST bilingual
(statische Labels/Status-Phrasen via `useI18n`) — praktisch alle dynamischen Inhalte, die
`lib/qaf-differences/internal/explain-provenance.ts` server-seitig zusammenbaut, waren hardcoded
Deutsch: die meisten erklärenden `note`-Texte (z. B. "Quellzelle nicht erfasst (älterer Lauf …)"),
`mappingMethod`-Werte ("Manuell zugeordnet (Override)" / "Automatisch — Kopfzeilen-Zuordnung"),
`qafGuideRequirement`-Werte ("Pflichtfeld"/"bedingt pflichtig"/"optional" + "Quelle: …") und
`qaf-comparison-detail.tsx`'s `ruleFindingFor`, das `decodeBilingual(hit.explanation).de` server-seitig
fest einbackte statt beide Sprachen durchzureichen. Ein EN-Locale-Leser sah also eine übersetzte
Status-Phrase ("not captured (older run)") gefolgt von einem unübersetzten deutschen Rest.

Teil 1 des Issues (dieser PR): nur die dynamischen Text-Bausteine des Explain-Panels selbst.
**Teil 2 (Anbindung der noch nicht live gerenderten MATERIAL/SBM/RMR/LOGISTICS-Sektionen) bleibt
bewusst außen vor** — braucht erst eine Diff-Sektions-Design-Entscheidung, siehe Issue-Body.

Je Textart ein konsistentes Muster, keine dritte Ad-hoc-Variante:

- **Freie Server-Prosa → beide Sprachen durchreichen** (Muster a, mirrort KAR-906s
  `encodeBilingual`/`decodeBilingual`-Pattern): `ExplainValue<T>.note` ist jetzt
  `DecodedMessage` (`{de, en}`) statt `string` — jede der ~25 Notiz-Konstanten in
  `explain-provenance.ts` trägt jetzt eine echte EN-Übersetzung, nicht nur die deutsche Phrase.
  `ExplainAttributes.comparisonRule` ist jetzt `ExplainValue<DecodedMessage>` statt
  `ExplainValue<string>` — sowohl der "Status-Band-Vergleich (…)"-Fixtext als auch ein verlinkter
  Regel-Fund werden bilingual gebaut. `RuleFindingLike` bekommt ein optionales `explanationEn`-Feld;
  `qaf-comparison-detail.tsx`'s `ruleFindingFor` decodiert jetzt BEIDE Seiten
  (`decodeBilingual(hit.explanation)` → `{de, en}`) statt nur `.de` — genau die im Issue benannte
  Fixierung ist behoben.
- **Enum-artige Werte → Server liefert Enum-Key, Client löst über Wörterbuch auf** (Muster b):
  `ExplainSideAttributes.mappingMethod` ist jetzt `ExplainValue<MappingMethodKind>`
  (`'manual_override' | 'automatic_header_mapping'`) statt eines fertigen deutschen Satzes.
  `ExplainAttributes.qafGuideRequirement` ist jetzt `ExplainValue<QafGuideRequirementValue>`
  (`{ requirement: CanonicalRequirement; evidence?: string }`) — `requirement` wird client-seitig
  über ein DE/EN-Wörterbuch aufgelöst, `evidence` (Leitfaden-Fundstelle, z. B.
  "leitfaden-teil1:9") bleibt als Zitat unübersetzt, nur der umgebende "Quelle:"/"Source:"-Text
  wird lokalisiert. `components/qaf-differences/qaf-explain-panel.tsx` bekommt dafür drei neue reine
  Formatter (`formatExplainMappingMethod`, `formatExplainQafGuideRequirement`,
  `formatExplainBilingual`) — alle über den bestehenden `formatExplainValue`-Kern, keine
  Parallel-Logik.
- **Bewusst NICHT angefasst** (kein "German leftover", sondern eigenständige, dokumentierte
  Konventionen): Sheet-Namen/Zellreferenzen/Roh- und Normalisierte Werte (Zitate der Quelldatei,
  keine UI-Prosa), `validationResult`s Roh-Status-Key (App-weit unübersetzt angezeigt, gleiche
  Konvention wie andernorts), `templateProfileVersion`s "(known)/(modified)/(unknown)"
  (bereits Englisch aus `TemplateClassification`), und die `de-DE`-Zeitstempel-Formatierung
  (App-weite Konvention über 5 Dateien hinweg, z. B. `qaf-provenance.tsx`s
  `OVERRIDE_DATE_FMT` — Änderung wäre eigenständiges Scope, nicht Teil dieses Issues).

- **Tests**: `lib/qaf-differences/internal/__tests__/explain-provenance.test.ts` und
  `components/qaf-differences/__tests__/qaf-explain-panel.test.ts` aktualisiert auf die neuen
  Shapes; neue Absence-Assertion-Tests nach KAR-906/#284-Vorbild (`.not.toContain('<deutscher
  Begriff>')` je Textklasse) belegen, dass die EN-Ansicht von `mappingMethod`,
  `qafGuideRequirement`, `comparisonRule` und `note`-getriebenen Not-captured/Not-applicable-Zellen
  keine deutschen Reste mehr zeigt. +10 Tests (2643 → 2653 grün).
- Kein Persistenz-/Engine-Touch — reine Präsentations-/Props-Schicht
  (`lib/qaf-differences/internal/explain-provenance.ts`,
  `components/qaf-differences/qaf-explain-panel.tsx`,
  `components/qaf-differences/qaf-comparison-detail.tsx`, `lib/qaf-differences/index.ts`-Barrel-Exports).

## 2026-07-10 – Fremdformular-Erkennung: Compound-Titel-Robustheit + Verweis-Marker-Ausschluss (KAR-923, PR #294 Review-Fund)

`lib/qaf-differences/internal/foreign-form-detection.ts`'s `isCloseCellMatch` war seit PR #293 (Re-Review
rider, Sev 30) ein reiner Exakt-Match: eine Header-Zelle zählt nur als Treffer, wenn sie NACH Normalisierung
identisch mit der Signatur-Phrase ist. Das ist für WAF-Signatur 1 ("Werkzeug Analyse Formular") real-file-
verifiziert (WAF_de.xlsm-Sample: Titel-Zellen enthalten exakt die Phrase). Für LAF und WAF-Signatur 2 gibt
es aber KEIN Real-Sample — und der Leitfaden-Wortlaut selbst ("LAF (Logistikkosten Analyse Formular),
Version LAF 6.2_06_01", teil3.md:13 [67]; "Das Werkzeuganalyseformular", teil3.md:334 [84]) liest sich als
EINE durchgehende Compound-Titel-Zelle. Der Exakt-Match hätte eine echte LAF- oder WAF-Sig-2-Datei still
verfehlt (False Negative → fällt unerkannt in den QAF-Parser — genau die Fehlerklasse, die P5.3/
`detectForeignForm` verhindern soll, und schlimmer als eine False-Rejection).

- **isCloseCellMatch erweitert** von `cell === phrase` auf drei UND-verknüpfte Bedingungen:
  1. Wort-Grenzen-Containment (`containsPatternAsWholeWords`) — die Phrase muss als zusammenhängende
     Wortfolge vorkommen (Reihenfolge/Adjazenz weiterhin Pflicht, KAR-920 F1/F2-Disziplin unverändert).
  2. Längen-Slack (`CLOSE_MATCH_MAX_EXTRA_CHARS = 40`) — die Zelle darf die Phrase um höchstens 40
     normalisierte Zeichen überschreiten. Deckt jede dokumentierte Compound-Form (Nummerierung "3. ",
     Artikel "Das ", Klammer-/Versions-Suffix) mit Puffer ab, während der PR #293/KAR-920-F1-PoC-Freitextsatz
     (~106 normalisierte Zeichen) klar außerhalb bleibt.
  3. Verweis-Marker-Ausschluss (`REFERENCE_MARKER_WORDS = {SIEHE, VGL, SEE}`) — bewusste Design-Entscheidung:
     "siehe Werkzeug Analyse Formular" (Re-Review-rider-PoC, PR #293 Sev 30) liegt für sich genommen
     INNERHALB des neuen Längen-Slacks und würde ohne diesen expliziten Ausschluss die Sev-30-Lücke
     wiedereröffnen. Der Marker-Ausschluss schließt sie erneut, ohne die Compound-Titel-Fälle einzuschränken.
  - `MIN_HEADER_CELL_HITS` (≥2 unabhängige Zell-Treffer) bleibt unverändert das zweite Sicherheitsnetz.

- **Tests** (`lib/qaf-differences/internal/__tests__/foreign-form-detection.test.ts`, neue Describe-Gruppe
  „KAR-923 compound-title robustness"): Compound-LAF-Titel als eine Zelle (2×) → erkannt; „Das
  Werkzeuganalyseformular" als eine Zelle (2×) → erkannt; nummerierter Titel „3. Werkzeug Analyse Formular"
  (2×) → erkannt; F1-Freitextsatz weiterhin `null` (Regressionstest bestätigt); „siehe/vgl./see Werkzeug
  Analyse Formular" (je 2×) weiterhin `null` (bewusster Trade-off, explizit getestet); ein zu langer
  Compound-Satz (>40 Zeichen Overhead) weiterhin `null`. Alle 28 Bestandstests unverändert grün, 8 neue Tests
  grün (36 gesamt in dieser Datei).

- **Real-Smoke (server-only, nicht committed, Booleans only)** gegen den echten QAF-Sample-Korpus unter
  `/root/aria/work/qaf-compare-kar824/input/`: `WAF_de.xlsm` weiterhin erkannt (`true`); 6 echte QAF-Dateien
  — 2× G60 (`100_G60_DP_QAF_Basis_24_10_BMW.xlsm`, `20260508_BMW_QAF_G60_DP_RePricing_HO.xlsm`), 2× V8.8
  (die beiden NCAR-Korpus-Dateien aus KAR-915: `NCAR_ECM_QAF_R3.1_Sc4_SKW_Radiator_HTK_BMW_AG_Update.xlsx`,
  `NCAR_ECM_QAF_R3.1_Sc4_CHN_Radiator_HTK_BMW_AG_Update.xlsx`), plus `Creator_Basis_G4x_QAF_V07.xlsx` und
  `Autoliv_Final_Quote_G45LCI_SW.xlsx` — weiterhin **nicht** erkannt (`false`). Alle 7/7 Checks bestanden.

- **Ehrlicher Verifikations-Status je Signatur** (unverändert gegenüber KAR-920, hier nur re-bestätigt):
  - WAF-Signatur 1 ("Werkzeug Analyse Formular"): **real-file-verifiziert** (WAF_de.xlsm).
  - WAF-Signatur 2 ("Werkzeuganalyseformular"), LAF (beide Signaturen), LEK (beide Signaturen): **NICHT
    real-file-verifiziert** — Erkennung beruht ausschließlich auf dem Leitfaden-Zitat (teil3.md), es liegt
    keine echte LAF-/LEK-Datei im Sample-Korpus vor. Diese Lücke bleibt offen dokumentiert; sollte eine
    echte LAF/LEK-Datei verfügbar werden, ist ein Real-Smoke-Nachtest fällig.

## 2026-07-10 – Explain-Panel: konsolidierter Zelle→Ergebnis Audit-Trail (KAR-922/P6.2) + Rider (KAR-920 Re-Review F, PR #293 Sev 30)

Master-Prompt §17 verlangt vollständige Traceability jedes Ergebniswerts zu Quelldatei/-sheet/-zelle/
-label/kanonischem Feld/Roh- und Normalwert/Formel/Vergleichsregel/Zuordnungsmethode/Confidence/
Einheiten-/Währungsumrechnung/Delta/Validierungsergebnis/QAF-Leitfaden-Anforderung/Engine-Version/
Template-Profil-Version/Zeitstempel. Nach P0.1 (KAR-886)/P1.2 (KAR-893)/P1.3 (KAR-894)/P1.4 (KAR-895)/
P1.6 (KAR-897)/P1.7 (KAR-898)/P2.1 (KAR-900)/P2.3-P2.4 (KAR-902/903)/P4.1 (KAR-911)/P4.2 (KAR-912) sind
alle diese Daten bereits persistiert (oder — dokumentiert unten — bewusst nur zur Ingest-Zeit berechnet
und nicht persistiert), aber es gab keine EINHEITLICHE, konsolidierte "Erklärung"-Ansicht darüber
hinaus — nur die punktuelle Quellzelle-Tooltip aus P4.1. Reine Präsentationsschicht: keine neue
Engine-Berechnung, keine neue Persistenz.

- **Gemeinsames Provenance-Interface** (`lib/qaf-differences/internal/explain-provenance.ts`, neu):
  `ExplainAttributes`/`ExplainSideAttributes` — EIN Shape für alle §17-Attribute, Tri-State pro
  Attribut (`present`/`not_applicable`/`not_captured`, nie ein nacktes `null`). Ein generischer Kern
  (`buildDetailSideAttributes`/`buildRowModuleExplainAttributes`) bedient MANUFACTURING/MATERIAL/SBM/
  RMR/LOGISTICS über je einen dünnen Modul-Adapter (`buildManufacturingExplainAttributes`/
  `buildMaterialExplainAttributes`/`buildSbmExplainAttributes`/`buildRmrExplainAttributes`/
  `buildLogisticsExplainAttributes`) — kein Ad-hoc-Duplikat je Modul. `buildSummaryExplainAttributes`
  bedient das strukturell andere SUMMARY-Zeilenformat (`SummaryMetricValue` statt sourceCells-Map)
  separat, füllt aber dasselbe `ExplainAttributes`-Shape.
- **Ehrliche Lücken statt Fabrikation** (Tri-State-Disziplin): Formel-/Confidence-/Zuordnungsmethode-
  Provenance für SUMMARY-Kennzahlen ist nur zur Ingest-Zeit verfügbar und wird NICHT persistiert
  (`qaf_summary_diff`/`qaf_summary_metric` haben keine Spalte dafür, siehe `summary-metrics.ts`s
  `SummaryMetricsParse`-Doku) — erscheint im Panel als "nicht erfasst (älterer Lauf)" mit Begründung,
  nie als geratener Wert. Einheiten-/Währungsumrechnung ist strukturell `not_applicable` (die Engine
  führt aktuell keine Umrechnung durch, für kein Modul). Rohwert vor Normalisierung ist nur für den
  "n.a."/Text-Ausnahmefall gespeichert (P0.6/KAR-891 `rawText`) — beim erfolgreichen Zahlen-Parse gibt
  es keinen separaten Rohtext, das Panel sagt das explizit statt den Normalwert als "Rohwert" zu tarnen.
- **UI** (`components/qaf-differences/qaf-explain-panel.tsx`, neu): aufklappbares
  `<button aria-expanded>`-Panel ("Erklärung anzeigen", echter Button — Lesson PR #290/KAR-915: kein
  `<span>`), DE zuerst über `useI18n()`, ALT|NEU-Tabelle aller 19 Attribut-Zeilen (17 §17-Attribute,
  einige naturgemäß pro Seite: Quelldatei/-sheet/-zelle/Rohwert/Normalwert/Formel/Zuordnungsmethode/
  Confidence/Umrechnung/Template-Profil; kanonisches Feld/Label/Anforderung/Delta/Vergleichsregel/
  Validierung/Engine-Version/Zeitstempel geteilt). Verdrahtet in `qaf-comparison-detail.tsx` neben dem
  bestehenden `ProvenanceTooltip` (bleibt als schnelle Hover-Variante erhalten) in **A1.
  Fertigungskosten-Deltas** (MANUFACTURING) und **3. Kennzahlen-Vergleich** (SUMMARY) — den beiden
  Sektionen, die heute bereits einzelne Ergebniswerte pro Zeile rendern.
- **Follow-up dokumentiert (wie KAR-911 es für G60 tat)**: MATERIAL/SBM/RMR/LOGISTICS haben Adapter +
  volle Testabdeckung, aber `qaf-comparison-detail.tsx` hat für diese vier Module aktuell KEINE
  gerenderte Werte-Diff-Sektion (nur den Degradations-Hinweis aus `module-degradation.ts`) — die
  UI-Anbindung folgt, sobald diese Module eine Werte-Sektion bekommen (eigenständiges Item, nicht Teil
  dieser reinen Präsentationsschicht-Aufgabe). G60 (`qaf_g60_tab`) hat ein strukturell abweichendes
  Datenmodell (`G60MetricKey`/Tab-Koordinaten statt kanonisches Feldregister) — analog zur bestehenden
  G60-Abgrenzung aus KAR-911 nicht Teil dieses PRs.
- **Neue Props** (additiv, `qaf-comparison-detail.tsx`/`[id]/page.tsx`): `engineVersion` (volles
  `qaf_comparison.engine_version`-Objekt, nicht nur `.parser`), `baselineTemplateProfile`/
  `comparisonTemplateProfile` (KAR-895 `matchedProfile`+Klassifikation kombiniert), `steps[].raw_values`
  (volles `QAFRow` inkl. `formulas`/`rawText` — die Spalte wurde bereits seit KAR-799 gelesen, aber nur
  `.manualOverride` daraus extrahiert; jetzt zusätzlich vollständig durchgereicht).
- **Client-Bundle-Disziplin** (Lehre aus PR #272/KAR-893, gleiche Prüfung wie KAR-911): `qaf-comparison-
  detail.tsx` bleibt Server Component und ruft `buildManufacturingExplainAttributes`/
  `buildSummaryExplainAttributes` dort auf — `qaf-explain-panel.tsx` (Client, nur wegen `useI18n()` +
  Auf/Zu-State) bekommt ausschließlich das fertige, serialisierte `ExplainAttributes`-Objekt als Prop,
  kein Registry-/Parser-Import. Manifest-Vergleich `page_client-reference-manifest.js` von
  `/qaf-differences/[id]` vor/nach: einzige neue Zeile ist `qaf-explain-panel.tsx`; `explain-
  provenance.ts`/`canonical-fields.ts`/`canonical-model.ts` erscheinen dort NULL Mal (rein serverseitig).
- **Tests**: 21 neue Mapper-Unit-Tests je Modul-Typ + Integrationstest eines vollständigen Ergebniswerts
  (`explain-provenance.test.ts`), 16 neue UI-Helper-Tests (`qaf-explain-panel.test.ts`, reine Funktionen,
  kein RTL im Repo — gleiches Muster wie `qaf-provenance.test.ts`). Gesamt-Suite 2591 → 2629 grün, keine
  Regression.

### Rider (Re-Review PR #293, Sev 30, KAR-920/P5.3)

`isCloseCellMatch` (`lib/qaf-differences/internal/foreign-form-detection.ts`) prüfte bisher
Substring-Treffer + Längen-Ratio ≤1,5 — ein kurzer Querverweis wie "siehe Werkzeug Analyse Formular"
(nur ~1,2× die Signatur-Länge) hätte diese Toleranz unterlaufen können, sofern er 2× in Zeile 1-20
vorkommt (erfüllt `MIN_HEADER_CELL_HITS`). Echte Formular-Titel-Zellen enthalten (real-verifiziert
gegen die WAF_de.xlsm-Probe und jede teil3.md-Screenshot-Quelle) exakt die Phrase und nichts anderes.
**Fix**: `isCloseCellMatch` ist jetzt exakter Vergleich nach Normalisierung (`normalizedText === pattern`,
Toleranz nur noch aus der ohnehin bestehenden Satzzeichen-/Whitespace-Normalisierung) — strikter, keine
Lockerung. Zwei bestehende Titel-Fixtures ("LAF (Logistikkosten Analyse Formular)", "Das
Werkzeuganalyseformular") enthielten einen Wortpräfix zusätzlich zur reinen Phrase und wurden auf
phrase-only-Zellen korrigiert (der Präfix sitzt jetzt als separate, nicht-matchende Zelle — realistischer
für gesplittete Formular-Kopfzeilen). Neuer Test: der Kurz-Querverweis-Grenzfall ("siehe Werkzeug
Analyse Formular" 2×) liefert jetzt `null` statt eines Fehlalarms. 27 → 28 Tests in
`foreign-form-detection.test.ts`, alle grün.

## 2026-07-10 – WAF/LAF/LEK-Erkennung: Adversarial-Review-Fix F1 (blockierend) + F2 (KAR-920/P5.3)

Adversarial Review von PR #293 belegte per PoC gegen den echten Produktionscode zwei reproduzierbare
False-Positives im frisch gebauten `detectForeignForm`:

- **F1 (Sev 68, blockierend):** Die Kopfzeilen-Erkennung normalisierte und verkettete (`.join(' ')`)
  ALLE Zellen eines Sheets (Zeile 1–20) zu einem Blob und suchte darin per Substring. Ein echtes
  QAF-Prämissenblatt mit plausiblem Freitext-Querverweis — "Sonderwerkzeuge werden separat über das
  Werkzeug Analyse Formular kalkuliert und sind hier nicht enthalten." — enthält die Signatur
  "Werkzeug Analyse Formular" als Teilstring eines viel längeren Satzes und wurde fälschlich als WAF
  abgelehnt. Das ist schlimmer als der Zustand vor P5.3 (ein echtes QAF wurde blockiert).
- **F2 (Sev 28):** Die Sheet-Namen-Erkennung verkettete `sheetNames.join(' ')` vor der Suche —
  eine Signatur konnte über die Grenze zweier unabhängiger Sheet-Namen hinweg zusammenmatchen
  (`['Kosten Werkzeug', 'Analyse Formular X']` → Fehlalarm).

**Fix** (`lib/qaf-differences/internal/foreign-form-detection.ts`): beide Blob-Joins entfernt,
Matching jetzt zell-gebunden:
- `isCloseCellMatch` — eine Zelle (Header-Region-Zelle oder Sheet-Name) zählt nur als Treffer, wenn
  ihr NORMALISIERTER Inhalt die Signatur enthält UND nicht wesentlich länger ist als die Phrase selbst
  (`CELL_LENGTH_SLACK = 1.5`) — ein langer Freitext-Satz, der die Phrase nur enthält, fällt damit raus.
- Kopfzeilen-Pfad verlangt zusätzlich `MIN_HEADER_CELL_HITS = 2` unabhängige Zell-Treffer (echte
  Formular-Titel wiederholen sich über Merged-Cell-Segmente — verifiziert am echten `WAF_de.xlsm`-
  Sample: 12 benachbarte Zellen; ein Freitext-Querverweis steht genau einmal).
- Sheet-Namen-Pfad matcht jetzt pro Sheet-Name einzeln (kein Join mehr) — behebt F2 direkt.

**Pflicht-Tests neu** (5 neue, 22 → 27): exakter Reviewer-PoC für F1 (Freetext-Satz → `null`, auch
über die echte ExcelJS-Bridge), exakter PoC für F2 (Sheet-Namen-Grenzfall → `null`),
`MIN_HEADER_CELL_HITS`-Boundary (1 Treffer → `null`, 2 Treffer → erkannt). Bestehende
Single-Occurrence-Fixtures für LEK/LAF/WAF-Header-Erkennung auf 2 Wiederholungen angehoben (dokumentierte
Verschärfung); der frühere "split-across-cells detects"-Test wurde in einen False-Positive-Guard-Test
invertiert (war selbst eine Instanz des F1/F2-Bugs, kein reales Datei-Muster).

**Real-Datei-Smoke wiederholt** (server-only, 20 Dateien): 19/20 weiterhin als QAF durch, `WAF_de.xlsm`
weiterhin korrekt als echtes WAF erkannt — beide Ergebnisse halten nach dem Fix unverändert.

## 2026-07-10 – WAF/LAF/LEK-Erkennung: klare Ablehnung statt stillem Falsch-Parse (KAR-920/P5.3)

WAF (Werkzeuganalyseformular), LAF (Logistikkostenanalyseformular) und LEK (Lieferantenentwicklungskosten)
sind eigenständige BMW-Excel-Dateien mit komplett eigener Struktur — keine Sheets innerhalb der
QAF-Hauptdatei. Wurden sie versehentlich als QAF hochgeladen, konnte die Engine sie stillschweigend
falsch parsen (Fertigungskosten-Parser matcht zufällig einzelne Header) oder mit einem verwirrenden
generischen Fehler abbrechen.

- **Neues Erkennungsmodul** (`lib/qaf-differences/internal/foreign-form-detection.ts`, neu):
  `detectForeignForm` prüft Sheet-Namen + Kopfzeilen-Region (nur Zeile 1–20 je Sheet, kein Voll-Parse)
  gegen einen Katalog von 6 wörtlich aus `02-leitfaden-teil3.md` [63]-[84] übernommenen,
  formularspezifischen Mehrwort-Signaturen (LEK: "LEK COST BREAK-DOWN", "Zahlungsplan LEK Allgemein";
  LAF: "Logistikkosten Analyse Formular", "Lieferantenvorverpackung - LVP"; WAF: "Werkzeug Analyse
  Formular", "Werkzeuganalyseformular") — jede mit Fundstellen-Referenz im Code. Pure Kernlogik
  (`ForeignFormWorkbook`-Interface, kein ExcelJS) + dünner ExcelJS-Bridge (`foreignFormWorkbookFromExcelJs`),
  gleiches "pure Core + Adapter am Rand"-Muster wie `g60/parser.ts` + `g60/bridge.ts`.
- **Integration** (`app/qaf-differences/actions.ts`, `ingestQafUpload`): Check läuft direkt nach dem
  Workbook-Safety-Gate (KAR-914) und vor der G60-vs-Summary-Weiche. Bei Treffer sofortiger,
  bilingualer Fehler ("Diese Datei ist ein WAF/LAF/LEK-Formular, kein QAF — wird aktuell nicht
  unterstützt. / This file is a WAF/LAF/LEK form, not a QAF — currently unsupported.") statt eines
  nachgelagerten Parse-Fehlers — gleiches `${reasonDe} / ${reasonEn}`-Muster wie die bestehenden
  Zip-Bomb-/Sheet-Dimension-Gates.
- **False-Positive-Disziplin**: nur mehrwortige, formularspezifische Phrasen als Signaturen (keine
  Einzelwörter wie "Analyse"/"Formular"/"Werkzeug" allein) — Wortreihenfolge und -adjazenz müssen
  exakt stimmen (Tests decken Wortumstellung, fehlende Wörter, Einzelwort-Kollisionen ab).
- **Real-Datei-Smoke** (server-only, nicht committed): alle 20 Dateien unter
  `qaf-compare-kar824/input/` geprüft — 19 laufen unverändert als QAF durch; `WAF_de.xlsm` wird
  korrekt als echtes WAF erkannt (kein False-Positive — Content-Inspektion bestätigt: Titel
  "Werkzeug Analyse Formular" wörtlich wiederholt, Feldstruktur entspricht teil3.md [82]-[84]).
- **Tests**: `lib/qaf-differences/internal/__tests__/foreign-form-detection.test.ts` (22 neue Tests) —
  alle 6 Signaturen (Header-Text + Sheet-Name-Pfad), bilinguale Fehlermeldung pro Format,
  False-Positive-Guard (typische QAF/G60-Sheet-Sets, Einzelwörter, Wortumstellung, Teil-Phrasen),
  ExcelJS-Bridge (HEADER_SCAN_ROWS-Grenze, fehlender Sheet-Name, Summary-QAF-Regression).

## 2026-07-10 – G60-Export: Delta-Übersicht und Detail-Blätter für G60-Detailvergleiche (KAR-913/P4.3)

Bisher gab es für G60-Detailvergleiche (`BMW_DETAIL_TABS_G60`, ~112 Kostenreiter-Tabs) keinen
Excel-Export — nur der Summary-Vergleichspfad hatte den 8-Blatt-Referenzexport (KAR-799). Letztes
offenes Phase-4-Item des QAF-Adaptive-Engine-Backlogs.

- **Neuer Export-Builder** (`lib/qaf-differences/internal/g60/export.ts`, neu):
  `buildG60ExportWorkbook` — eigenständiger Builder (kein Mode-Branch im bestehenden
  `buildQafExportWorkbook`, da die G60-Datenform keine Entsprechung im Summary-`QafComparisonResult`
  hat). 5 Blätter:
  1. **README** (Deckblatt) — Engine-Version, ALT-/NEU-Dateiname, Rates-Prämissen (INPUT!C22–C29,
     ALT/NEU nebeneinander), Stückzahlen-Prämisse (NEU/Repricing-Seite, wie `analyzeG60Pair`s
     Annual-Impact-Berechnung), Legende.
  2. **Tab_Übersicht** — Delta-Übersicht über alle Kostenreiter: die 11 Row-41-Kennzahlen je
     gemeinsamem Tab (identisch zu `G60Analysis.rows`, dem On-Screen-Datensatz der Metrik-Tabelle),
     PART-SPECIFIC-Flag, Abweichungen rot/grün hervorgehoben, plus Quellzell-Provenance
     (`Quellzelle ALT`/`Quellzelle NEU` aus dem KAR-894/P1.3 `sourceCells`, sofern persistiert).
  3. **Produktion** — Produktionssicht je Kostenreiter (Zykluszeit, Mitarbeiter, Scrap, MSS,
     Ineffizienz, ALT/NEU).
  4. **Kalkulator_Treiber** — INPUT-Ratenkarte-Diff (`G60Analysis.driverDiff`) — die
     "Kalkulator-Ergebnisse" aus dem Backlog-Item.
  5. **Struktur_Plausibilität** — G60-Struktur-Guard-Funde (P0.3/P1.4: Label-Mismatch,
     Spalten-Relozierung, Template-Klassifikation), bilingual (KAR-906-Muster: `Erklärung` +
     `Explanation (EN)`).
  - `G60MetricRow` (`internal/g60/analyze.ts`) bekommt ein additives `key: G60MetricKey`-Feld, damit
    der Export die Quellzell-Provenance je Metrik nachschlagen kann, ohne `headline → key` erneut
    aus dem modul-lokalen `METRIC_DEFS` abzuleiten. Bestehende Konsumenten (UI-Tabelle) sind
    unbetroffen (rendern nach `headline`, nicht `key`).
  - **Bewusst NICHT exportiert**: Live-Was-wäre-wenn-Szenario-Overrides (`scenario.ts`). Der
    Szenario-Editor ist rein sitzungslokal — es gibt keine `qaf_g60_scenario`-Tabelle, Overrides
    verlassen nie den Browser ("Eingaben gelten je Sitzung"). Ein Export würde damit den
    Akzeptanzkriterium-Satz "On-Screen-Ergebnis und Download sind deterministisch identisch"
    verletzen, da es keinen einzelnen kanonischen Szenario-Zustand gibt, der reproduziert werden
    könnte. Das Backlog-Item nennt "Szenario-Vergleich" als Implementierungs-Design-Beispiel, nicht
    als Akzeptanzkriterium — die tatsächliche Abnahme-Bedingung ist Determinismus.
- **Server-Action** (`app/qaf-differences/actions.ts`): `exportQafG60ComparisonXlsx` — RLS-scoped
  Read von `qaf_comparison`/`qaf_file`/`qaf_g60_tab`/`qaf_input_card`, rehydriert beide Seiten
  exakt wie `[id]/page.tsx`s On-Screen-Render (`rehydrateG60File` + `analyzeG60Pair`, kein
  `aliasMap`, gleicher `buildG60StructureIssues`/`templateFingerprintToPlausibilityIssue`-Aufbau),
  liest dabei bewusst `component_rows` NICHT mit (nur vom Live-Szenario-Editor gebraucht,
  `rehydrateG60File`/`analyzeG60Pair` lesen es nie) — verkleinert die Payload für den ~112-Tab-Fall
  spürbar (P4.3-Performance-Risiko). `exportQafComparisonXlsx`s G60-Ablehnung verweist jetzt auf die
  neue Action statt auf den alten "folgt mit der G60-Analyse-Ansicht"-Platzhaltertext.
- **UI**: neuer Button `components/qaf-differences/qaf-g60-export-button.tsx` (1:1-Kopie des
  bestehenden `qaf-export-button.tsx`-Musters, ruft `exportQafG60ComparisonXlsx`), verdrahtet in
  `[id]/page.tsx`s G60-Detail-Kopfzeile neben `QafSwapRolesButton`.
- **Tests**: `lib/qaf-differences/internal/g60/__tests__/export.test.ts` (neu, 13 Tests) — Zellen-
  Assertions für alle 5 Blätter (analog `internal/__tests__/export.test.ts`), Persistenz→Export-
  Roundtrip (`buildG60Rows` → `rehydrateG60File` → `analyzeG60Pair` → `buildG60ExportWorkbook`
  reproduziert den Direkt-Parse-Export zellgenau, ohne Original-xlsx erneut zu parsen) und ein
  Performance-Smoke-Test mit 112 synthetischen Tabs. `analyze.test.ts` um eine Assertion für das
  neue `key`-Feld ergänzt. Keine echten Dateien/Werte — ausschließlich synthetische Fixtures.

## 2026-07-10 – Mapping-Override-Persistenz für den QAF-Vergleich (KAR-912/P4.2)

Neues, generalisiertes Feld-Mapping-Override: wenn `qaf-parser.ts` eine Fertigungskosten-Spalte
falsch oder gar nicht auf ein kanonisches Feld mappt (`manufacturingParseMeta.unmappedHeaders`), kann
ein Nutzer jetzt pro Datei einen manuellen Override setzen (kanonisches Feld ↔ Positionsnummer-Zeile
↔ Wert ↔ Quellenangabe), der bei jedem Recompute UND bei einem Datei-Replace erhalten bleibt
(KAR-899-Rehydrate-Disziplin). Abgrenzung zur bestehenden `ManualPin`-Mechanik (KAR-845): Pins ordnen
einen ALT-Prozessschritt einem NEU-Schritt zu (Zeilen-Matching), dieses Feature korrigiert den WERT
eines einzelnen Felds auf einer Zeile (Feld-Mapping) — beide Mechanismen sind unabhängig und können
gleichzeitig aktiv sein.

- **Engine** (`lib/qaf-differences/internal/field-mapping-override.ts`, neu): reine Funktionen
  `applyFieldMappingOverrides`/`upsertFieldMappingOverride`/`removeFieldMappingOverride` — Overrides
  matchen über die normalisierte Positionsnummer (`normalizeOverrideRowKey`, dieselbe Normalisierung
  wie `matcher.ts`s ManualPin-Kaskade), Text-Felder verbatim, alle anderen Felder als Locale-Zahl
  geparst (Komma-Dezimal toleriert, unparsbar → `null`, nie `NaN`). `QAFRow` bekommt ein neues
  optionales `manualOverride`-Feld (additiv, gleiches Muster wie `sourceCells`/`normalized`/`rawText`/
  `formulas`).
- **Persistenz**: `qaf_file.g60_meta.fieldMappingOverrides` (neuer Key im bestehenden JSONB-Feld —
  KEINE Schema-Migration). Angewendet an zwei Stellen: `recompareComparison`s `sideOf()`
  (Rehydrate-Pfad, KAR-899-Muster) und `replaceComparisonFile` (übernimmt die Overrides der
  ALTEN Datei auf die neue, bevor sie geparst persistiert wird — überlebt eine beliebige Kette von
  Datei-Ersetzungen).
- **Server-Actions**: `setManualFieldMappingOverride`/`clearManualFieldMappingOverride`
  (`app/qaf-differences/actions.ts`) — Audit-Trail via bestehendes `qaf_audit_log`-Muster: jeder Insert
  trägt `detail: {by: userId, fieldKey, rowKey, value, sourceDescription, previousValue}` (gleiches
  Muster wie `recompare`/`replace_file`/`swap_roles`), zusätzlich `actor`/`created_at` als DB-Spalten
  der Tabelle selbst (Default `auth.uid()`/`now()`).
- **UI**: neue Sektion "A3b. Feld-Zuordnung — manuelle Overrides" (`qaf-field-mapping-override.tsx`)
  neben "A3. Prozessschritt-Matching". `ProvenanceTooltip` (KAR-911/P4.1) zeigt jetzt zusätzlich
  "Manuell zugeordnet (ALT/NEU): …" mit Quelle/Wer/Wann, wenn ein Feld überschrieben wurde.
- **Reproduzierbarkeit**: `ENGINE_VERSION.parser` von `1.2.0` auf `1.3.0` gebumpt (gleiches Muster wie
  jeder andere Engine-Versions-Bump in `types.ts`) — ein vor diesem Feature berechneter Vergleich wird
  nicht rückwirkend uminterpretiert.
- **Tests**: 19 neue Unit-Tests für die Engine (Persistenz-Roundtrip, Recompute-Überleben/Idempotenz,
  JSON-Roundtrip), 3 für die UI-Feld-Label-Ableitung.

### Adversarial-Review-Fixes (PR #291, vor Merge)
- **F1 (Sev 85, blockierend) — Identitäts-Guard beim Datei-Replace**: `replaceComparisonFile` übernahm
  Overrides der alten Datei bisher blind über die Positionsnummer — bei einem umnummerierten
  BMW-Re-Quote (Positionsnummer bleibt gleich, Prozessschritt ändert sich) hätte das den gespeicherten
  Wert auf eine falsche Zeile geschrieben, ohne jedes Warn-Signal. Fix: neue Funktion
  `carryForwardFieldMappingOverrides` (`field-mapping-override.ts`) vergleicht bei jedem Replace die
  Prozessbezeichnung an der Positionsnummer zwischen ALTER und NEUER Datei (normalisiert). Bei
  Übereinstimmung bleibt der Override `status: 'active'`, bei Abweichung/fehlender Zeile wird er
  `status: 'dropped_on_replace'` — persistent in `g60_meta` (nicht gelöscht: Audit-Historie + Basis für
  ein live berechnetes `pruefen`-Plausibilitäts-Finding `field_mapping_override_dropped_on_replace`,
  `fieldMappingOverrideDroppedIssues`, sichtbar in A4-Plausibilität UND direkt in der A3b-Override-Liste
  als "verworfen (Datei-Wechsel)"-Badge). Das Finding wird bei jedem Seitenaufruf frisch aus dem
  persistierten `status`-Flag abgeleitet (nie in `qaf_plausibility_issue` geschrieben) — dadurch
  automatisch KAR-899-stabil, ohne die Rehydrate-Logik anzufassen. 13 neue Tests.
- **F2 (Sev 45) — Positionsnummer nicht mehr überschreibbar**: neue Konstante
  `OVERRIDABLE_MANUFACTURING_FIELD_KEYS` (`qaf-parser.ts`, = `MANUFACTURING_FIELD_KEYS` minus
  `positionsnummer`) ist jetzt die einzige Quelle für "welche Felder ein Override setzen darf" —
  server-seitig (`isOverridableFieldKey`, `validateFieldMappingOverrideInput`) UND im UI-Dropdown/
  Default (`qaf-field-mapping-override.tsx`). Vorher war `positionsnummer` versehentlich erst- und
  auswählbar; da es der Zeilen-Identitäts-Schlüssel jedes Overrides/Lookups ist, hätte ein Override
  darauf die Zuordnung korrumpiert.
- **F3 (~20) — Warnhinweis bei Überschreiben eines erkannten Werts**: die A3b-UI zeigt jetzt
  "Achtung: … der Override überschreibt den automatisch erkannten Wert", sobald für Zeile+Feld bereits
  eine `source_cells`-Quellzelle existiert.
- **F4 (~15) — Freitext vs. verifizierte Zelle**: `sourceDescription` erscheint im Provenance-Tooltip
  und in der A3b-Liste jetzt kursiv mit Präfix "Beschreibung: …", visuell abgesetzt von den
  Quellzelle-ALT/NEU-Zeilen (die die Engine selbst aus dem Workbook liest).
- **F5 — CHANGELOG-Wording korrigiert** (dieser Eintrag): "wer/wann" ist `detail.by` (JSONB, gleiches
  Muster wie jede andere `qaf_audit_log`-Action in diesem Modul) plus die Tabellen-Spalten
  `actor`/`created_at` — nicht nur Letzteres.
- **F6 (Sev 62, blockierend, Re-Review) — Prozessbezeichnung als Anker musste selbst unantastbar sein**:
  der F1-Guard vergleicht `prozessbezeichnung` zwischen ALTER und NEUER Datei — gelesen aber aus
  `qaf_manufacturing_step.process_name`, die `persistence-mapper.ts`s `buildManufacturingStepRow` aus
  der OVERRIDE-APPLIZIERTEN Zeile (`stepsForPersistence`) schreibt. Solange `prozessbezeichnung` selbst
  überschreibbar war, hätte ein 2-Hop-Replace den Override-Text in `process_name` "backen" können; ein
  späterer Hop hätte dann gegen diesen vergifteten statt gegen den echten Parser-Anker verglichen — bei
  BMWs kleinem, repetitivem Prozess-Vokabular (Schweißen/Montage/Lackieren/…) ein potenzieller
  False-Positive-Identity-Match, der einen Kosten-Override auf eine fremde Zeile hätte landen lassen
  (die F1-Fehlerklasse, einen Hop später). Fix: `prozessbezeichnung` zusätzlich zu `positionsnummer`
  aus `OVERRIDABLE_MANUFACTURING_FIELD_KEYS` ausgeschlossen (gleiche Begründung: Identitäts-/
  Anker-Felder sind nicht überschreibbar; `prozessbezeichnung` ist zudem CORE/mandatory — fehlt/stimmt
  sie nicht, degradiert der ganze Zeilen-Parse ohnehin, der Override-Editor ist dann nicht der richtige
  Hebel). Neuer Test führt einen ECHTEN 2-Hop-Replace durch `buildManufacturingStepRow` (nicht nur
  handgebaute `RowIdentity`-Objekte): ein Kosten-Override überlebt Hop 1 (gleicher Prozessschritt),
  wird bei Hop 2 mit geänderter Prozessbezeichnung korrekt verworfen, und der Anker ist über beide Hops
  nachweislich die echte Parser-Lesung. 2 weitere Tests für die Server-Validierungs-Ablehnung von
  `prozessbezeichnung`.
- **Bekannte Limitation**: das live abgeleitete "Override verworfen"-Finding
  (`field_mapping_override_dropped_on_replace`, siehe F1) erscheint — wie `templateFingerprintIssues`
  seit KAR-906/P3.2 — NICHT im Excel-Export-Plausibilitätsblatt (`exportQafComparisonXlsx` liest nur
  `qaf_plausibility_issue`-DB-Zeilen, keine live-gemergten Seiten-Issues). Konsistent mit dem
  bestehenden Verhalten für Template-Fingerprint-Findings, kein neuer Sonderfall.

### Review-Rider (aus PR #290 / KAR-915)
- `WorkbookSafetyBadges` (`qaf-provenance.tsx`) und `TemplateClassificationField`
  (`qaf-comparison-detail.tsx`): Tooltip-Trigger von `<span>` auf `<button type="button">` — Tastatur-
  zugänglich (Tab-fokussierbar), gleiches Muster wie `ProvenanceTooltip`s eigener Trigger.
- `module-degradation.test.ts`: isolierter SBM-Degradations-Testfall ergänzt (`coreFieldsFound: false`
  NUR für SBM) — die bestehende "flags every degraded module"-Assertion testete SBM bisher nur im
  nicht-degradierten Zweig.
- `canonical-fields.ts`: Kommentar am "Cam (total)"-Alias aktualisiert — nicht mehr nur positional
  belegt, sondern lexikalisch verifiziert (DE-Zwillingsdatei trägt an identischer Zellposition
  r12/c33 das Label "Schieber gesamt" hinter dem 4er-Schieber-Spaltenblock).

## 2026-07-10 – Confidence-/Provenance-UI für den QAF-Vergleich (KAR-911/P4.1)

Macht Provenance-/Confidence-Daten sichtbar, die die Engine bereits berechnet und persistiert, aber
bisher an keiner Stelle in der UI zeigte (`app/qaf-differences/[id]/page.tsx` las `templateFingerprint`
schon, aber nicht `workbookSafety`/`manufacturingParseMeta`/die Detail-Modul-`coreFieldsFound`-Flags;
`qaf_manufacturing_step.source_cells` wurde seit KAR-886 persistiert, aber nie zurückgelesen). Keine
neue Engine-Berechnung — reines Sichtbarmachen bereits berechneter Daten über den bestehenden
Server-Action-Pfad, dezente Tooltip-/Badge-UI, kein Layout-Umbau.

- **Pro-Zeile-Provenance** (Sektion "A1. Fertigungskosten-Deltas"): neuer Info-Icon-Tooltip pro
  Feld-Zeile zeigt Quellzelle ALT/NEU (z. B. "Fertigungskosten!W15"), Modul-Parse-Confidence (ALT/NEU)
  und die Engine-Parser-Version, mit der DIESER Vergleich tatsächlich lief
  (`qaf_comparison.engine_version.parser`, nicht die live `ENGINE_VERSION`-Konstante — Reproduzierbarkeit
  bleibt gewahrt für ältere Vergleiche). Quelle: `qaf_manufacturing_step.source_cells` (KAR-886/P0.1,
  jetzt erstmals selektiert) + `g60_meta.manufacturingParseMeta.parseConfidence` (KAR-893/P1.2).
- **Datei-Ebene** (Sektion "1. Überblick"): Template-Klassifikations-Badge (bekannt/abweichend/unbekannt,
  KAR-895/P1.4) bekommt einen Hover-Tooltip mit der Coverage-Kurzform je Facette (bereits von
  `template-fingerprint.ts` als `deviations`/`deviationsEn` bilingual berechnet, z. B. "MANUFACTURING:
  20/22 Spalten gefunden … 91 % Abdeckung"). Neue Makro-/Externe-Verknüpfung-Badges aus
  `workbookSafetyToPlausibilityIssues` (KAR-914/P4.4) — dieselbe Bridge-Funktion, die die Findings
  bereits in `qaf_plausibility_issue` persistiert, hier zusätzlich als eigenes Badge mit
  Erklärtext-Tooltip statt nur in der langen A4-Plausibilitätsliste.
- **Degradations-Transparenz**: neues Modul `lib/qaf-differences/internal/module-degradation.ts`
  (`extractDegradedModules`/`degradedModulesSentence`) liest die bereits persistierten
  `coreFieldsFound`-Flags von MATERIAL/SBM/RMR/LOGISTICS/LC-CN/CO2e (g60_meta) und zeigt "Modul(e)
  nicht ausgewertet — Detail-Sheet vorhanden, aber Kopfzeile nicht zuordenbar: …" statt stillschweigend
  nichts, wenn ein Detail-Sheet vorhanden, aber zu degradiert zum Parsen war. Unterscheidet sauber von
  "Sheet existiert für diese QAF-Art gar nicht" (kein Degradations-Signal in dem Fall).
  Reine Read-Model-Funktion — keine neue Confidence-/Business-Logik, nur Labeling/Gruppierung
  bereits entschiedener Booleans.
- **Sprachlogik**: neues `components/qaf-differences/qaf-provenance.tsx` mit `BilingualText`/
  `pickLocaleText` — DE zuerst, EN sekundär über `useI18n()`, gleiches Fallback-Verhalten wie
  `qaf-plausibility-message.tsx`s `decodeBilingual`-Konsum (KAR-906-Muster).
- **Client-Bundle-Disziplin (Lehre aus PR #272/KAR-893)**: `qaf-comparison-detail.tsx` bleibt Server
  Component, importiert weiterhin keine Registry-/Parser-Module — nur Props + das neue, kleine
  `qaf-provenance.tsx` (`'use client'` nur für `useI18n()`). Manifest-Vergleich
  `page_client-reference-manifest.js` von `/qaf-differences/[id]` vor/nach: einzige neue Zeile ist
  `qaf-provenance.tsx` (Modul + Modul-Evaluation); die vorbestehende `canonical-fields`-Referenz ist
  ein SSR-Chunk (Server-Rendering), unverändert vor/nach, kein neuer Browser-Client-Chunk.
- Tests: 20 neue (11 `module-degradation.test.ts`, 9 `qaf-provenance.test.ts` — pure Helper, kein RTL
  im Repo vorhanden, gleiches Muster wie `qaf-projection.test.tsx`). Gesamt-Suite 2487 → 2507 grün,
  keine Regression.

## 2026-07-10 – Registry-Erweiterung: Fehlerreport-Felder SUMMARY/MATERIAL/SBM (KAR-910)

Löst die 8 verbleibenden `it.todo`-Marker in `cross-language-fixtures.test.ts`'s 15-Fehlerreport-
Regressionsfälle-Suite (KAR-907/KAR-908) — Accuracy-Stand 7/15 → 15/15 grün über die reale Parse-Pipeline.
Jede neue Feld-Angabe (labelDe/labelEn/Alias) ist an eine Leitfaden-Seite oder einen konkreten
Fehlerreport-Fall referenziert (`03-fehlerreport-analyse.md`).

- **SUMMARY (`QafSummary`, 9 → 13 Felder, `types.ts`):** 4 Prämissenfelder, die in `canonical-fields.ts`
  bereits als registry-only ("not parsed by Kadi-v2 today") existierten, sind jetzt tatsächlich geparst —
  `peakVolumeYear`/`productionStartSop`/`deliverySite`/`shiftsPerWeek`. `summary-parser.ts`s `FieldDef.primary`
  ist jetzt optional (kein fester A1-Anchor für diese 4 Felder bekannt); Lookup ausschließlich per
  Label-Scan. Labels: Leitfaden `02-leitfaden-teil1.md` [11] (DE) + reale EN-Fehlerreport-Spaltentexte
  (`03-fehlerreport-analyse.md` §2 Zeilen #10/#12/#14/#16, openpyxl-verifiziert, keine Übersetzungsvermutung).
  `rule-engine.ts`s `SUMMARY_FIELD_LABELS`/`SUMMARY_FIELD_KEY_TO_CANONICAL` erweitert — die 4 neuen Felder
  laufen automatisch durch die bestehende KAR-908-R3-Kreuzseiten-Heuristik (bleiben `optional`, nie
  `mandatory`, um keine R2-Befund-Flut zu erzeugen).
- **SUMMARY_METRIC_KEYS (19 → 20, `summary-metrics.ts`):** `costBreakdownAw1` (Fehlerreport-Fall #23) — KEINE
  Leitfaden-Abdeckung für dieses Feld (das Leitfadens eigene erschöpfende SUMMARY-Kostenblock-Aufzählung
  [10]-[14] nennt es nicht), einzige Quelle ist der reale Fehlerreport. Da keine verifizierte Zeilenposition
  bekannt ist, neue Funktion `locateRowUnanchored` (spaltengebundener, aber zeilen-ungebundener Label-Scan über
  Spalte G) statt des üblichen `TEMPLATE_CONFIG.rows`-Zeilen-Priors. `requirement: 'optional'` bewusst (nicht
  `mandatory` trotz Fehlerreport-Flag "notwendig=Ja") — Kommentar in `canonical-fields.ts` erklärt warum.
  Bekannte Lücke, bewusst out-of-scope: die Fehlerreport-EN-Seite war "inkorrekt wegen negativer Werte";
  `classifyFieldState` hat keine Negativwert-Geschäftsregel, das bleibt eine separate Folge-PR.
- **MATERIAL-Registry (+1 Feld, `material-parser.ts`):** `mat_packaging_cost_offer` /
  `MaterialRowValues.packagingCostOffer` — aggregiertes "Verpackungskosten (Angebot)"-Gesamtfeld, fachlich
  distinkt von `packagingCostPerUnit` (Je-Mengeneinheit-Wert). Keine Leitfaden-Abdeckung (Fehlerreport-Fall
  #42), Wiring automatisch über den bestehenden registry-getriebenen Label-Anchor-Matcher — kein
  Parser-Sonderfall nötig.
- **SBM-Registry (+2 Felder, `sbm-parser.ts`):** `sbm_anzahl_stufen_takte_aktiv`/`_leer` /
  `SbmRowValues.stageCountActive`/`stageCountEmpty` (Fehlerreport-Fälle #74/#75) — das kombinierte
  `sbm_anzahl_stufen_takte`-Feld bleibt unverändert bestehen (kein Replace). Leitfaden `02-leitfaden-teil2.md`
  [34] (Feldbeschreibung: "unterschieden nach aktiven und leeren Stufen/Takten/Komponenten") + [38]
  (Screenshot: Spaltengruppe "Anzahl Stufen/Takte/Komponenten (Aktiv/Leer)") belegen 2 echte Sub-Spalten.
- **Test-Suite:** alle 8 `it.todo` → `it`, Assertions aus den Fall-Kommentaren scharf geschaltet
  (`cross-language-fixtures.test.ts`). Buchhaltungs-Test: 7/15 grün → 15/15 grün, 0 Feldmodell-Lücken.
  7 weitere Test-Dateien mussten ihre `summary()`-Test-Helper (`{} as QafSummary`-Pattern) auf die neuen 4
  QafSummary-Felder erweitern, sonst crasht `evaluateSummaryRules` zur Laufzeit auf `undefined.value`
  (TypeScript's `as`-Assertion prüft das nicht statisch) — ebenso 6 Test-Dateien für die neuen
  `MaterialRowValues`/`SbmRowValues`-Felder (vom Compiler erzwungen, `Record<..., string>`-Exhaustiveness).
  `rehydrate.ts`s `partToSummary` und 2 Regressions-Assertions (`rehydrate.test.ts`,
  `template-fingerprint.test.ts`, Feld-/Metrik-Zähl-Erwartungen 9→13 bzw. 19→20) angepasst.
- **Real-Datei-Smoke (server-only, nicht committed):** beide G60-Dateien + das Brose-FT-links-Paar
  (`/root/aria/work/qaf-compare-kar824/input/`) durch die reale Pipeline getrieben, Struktur-Feld-Counts
  vorher/nachher verglichen — keine Regression (alle vorher gefundenen Felder weiterhin gefunden), auf den
  beiden Brose-Dateien zusätzlich 4 bzw. 1 der neuen SUMMARY-Felder real gefunden (nonNull-Count 8→12 bzw.
  9→10). Nur Struktur-Metriken, keine Werte aus den Dateien.

## 2026-07-10 – Untrusted-Excel-Hardening: Zip-Ratio-Guard, Sheet-Dimension-Cap, Makro-/Link-Erkennung (KAR-914)

Schließt P4.4 aus dem SupplierPulse-QAF-Backlog (Master-Prompt §20 "Security and file safety"). Neues,
pures Modul `lib/qaf-differences/internal/workbook-safety.ts`, das VOR dem vollen ExcelJS-Parse in
`ingestQafUpload` (`app/qaf-differences/actions.ts`) läuft. Inklusive drei blockierender Fixes aus einer
Adversarial-Review-Runde nach dem ersten PR (F1–F3, siehe unten).

- **Zip-Ratio-Guard (blockierend, ECHTE Größe, nicht deklariert — F1-Fix):** `inspectZipStructure` liest
  Entry-Namen, Kompressionsmethode und Local-Header-Offset aus der ZIP-Central-Directory (minimaler,
  selbstgeschriebener EOCD/Central-Directory-Reader, nur `node:buffer`, keine neue Dependency). Die
  Sicherheitsentscheidung selbst basiert NICHT mehr auf der deklarierten `uncompressedSize` — eine präparierte
  ZIP-Datei kann dieses Feld beliebig lügen, während der reale DEFLATE-Stream auf Gigabytes entpackt; ExcelJS'
  eigenes Unzip (JSZip→pako) prüft `data_length` gegen die deklarierte Größe erst NACH dem vollständigen
  Inflate, wenn der OOM-Schaden schon passiert ist. `computeRealUncompressedSize` entpackt deshalb JEDEN
  DEFLATE-Eintrag ECHT via `node:zlib`s `inflateRawSync({ maxOutputLength })` — das deckelt zlibs eigenen
  Output-Puffer und wirft `ERR_BUFFER_TOO_LARGE` in dem Moment, in dem mehr Output entstünde als das
  (gemeinsame, über alle Einträge dekrementierende) Budget erlaubt, OHNE den größeren Buffer je zu allozieren
  (empirisch verifiziert: ein 50-MB-Stream bei 1-MB-Cap wirft sofort, ohne 50 MB zu materialisieren). Der
  Daten-Offset jedes Eintrags wird aus dem LOKALEN File-Header gelesen (nicht aus der Central Directory — beide
  können legal unterschiedliche Name/Extra-Längen deklarieren). `evaluateZipBombRisk` lehnt ab bei: reale
  Gesamtgröße > 300 MB, ODER reales Entpackungsverhältnis > 100:1 bei ≥ 2 MB deklariert komprimiert (F2-Fix:
  Mindestgröße so kalibriert dass `ratioMinCompressedBytes × ratioThreshold ≤ maxUncompressedBytes` — beim
  alten Wert 10 MB/100 war der Ratio-Zweig mathematisch unerreichbar, weil >1 GB nötig gewesen wäre, was der
  300-MB-Cap immer zuerst gefangen hätte; umgekehrt rutschte 2 MB→290 MB (145:1) durch beide Checks), ODER eine
  nicht auflösbare ZIP64-Struktur (fail closed aus Vorsicht). Läuft VOR `loadExcelWorkbook`.
- **Sheet-Dimensions-Cap (blockierend):** `evaluateSheetDimensions` läuft NACH dem Workbook-Load (ExcelJS'
  eigene `rowCount`/`columnCount`-Zähler, kein zusätzlicher Lesepass), aber VOR jeder Zell-Iteration — lehnt
  eine Datei komplett ab (kein Teil-Parse) bei > 200.000 Zeilen oder > 500 Spalten in irgendeinem Sheet.
- **Makro-Präsenz (informativ):** `detectMacroPresence` erkennt `xl/vbaProject.bin` im ZIP — ExcelJS führt
  nachweislich nie Makros aus (kein VBA-Interpreter im XLSX-Reader), daher kein Hard-Reject, sondern ein
  bilinguales `security_macro_present`-Issue (Reviewer-Awareness, deckt sich mit dem WorkbookForensics-Konzept
  aus KAR-867).
- **Externe Workbook-Links (informativ):** `detectExternalWorkbookLinks` erkennt `xl/externalLinks/*.xml` —
  ExcelJS folgt diesen nachweislich nie automatisch, daher `security_external_links`-Issue statt Ablehnung.
- **Persistenz + Recompute-Stabilität (F3-Fix):** `workbookSafetyToPlausibilityIssues` (KAR-906-bilinguales
  Muster) hängt die beiden informativen Issues in `compareQafPair`s `plausibility`-Array (→
  `qaf_plausibility_issue`) ein — neues `QafFileParsed.workbookSafety`-Feld. Genau wie beim KAR-899-Fix für
  `manufacturingParseMeta` reicht ein reines "nur im Live-Ingest gesetzt" nicht: ein
  `refreshPlausibility: true`-Recompare (`replaceComparisonFile`) löscht+schreibt `qaf_plausibility_issue`
  komplett neu, wodurch ein Makro-/Link-Fund sonst beim ersten Datei-Replace unwiderruflich verschwunden wäre,
  obwohl die Datei unverändert blieb. `workbookSafety` wird deshalb — für BEIDE Ingest-Pfade (G60 und
  Summary) — in `qaf_file.g60_meta.workbookSafety` persistiert (gleiches JSONB-Bag-Muster wie
  `templateFingerprint`/`manufacturingParseMeta`) und in `recompareComparison`s `sideOf()` rehydriert. G60
  hat ohnehin keinen `qaf_plausibility_issue`-Kanal (kein `compareQafPair`-Aufruf für G60) — dort bleibt
  `g60_meta.workbookSafety` die alleinige Persistenz.
- **Resource-Limit:** kein neuer Timer — `maxDuration=300` (`app/qaf-differences/page.tsx`) deckt das bereits
  ab (Over-Engineering vermieden).
- **KAR-801-Abgrenzung:** KAR-801 trackt den Export-/Download-Routen-Kontext; dieses Item hardened
  ausschließlich den Upload-/Ingest-Pfad. Der Wire-Size-Cap (`QAF_MAX_FILE_BYTES`, 20 MB komprimiert) bestand
  bereits vorher unverändert — er deckte nur die Wire-Größe, nicht die entpackte Größe/Ratio/Dimensionen, was
  genau die Lücke ist, die dieses Modul schließt.
- **Fehlermeldungen bilingual (F4-Fix):** `ZipStructureError`-Meldungen (kaputte ZIP-Struktur, abgeschnittene
  Header, …) folgen jetzt durchgängig dem DE/EN-Muster des restlichen Moduls, statt nur Englisch.
- **Tests:** 31 Unit-Tests (`lib/qaf-differences/internal/__tests__/workbook-safety.test.ts` — inkl. eines
  echten, via ExcelJS gebauten hochkomprimierbaren Riesen-Sheets UND eines hand-gebauten ZIPs mit dem exakten
  F1-Exploit: Central Directory deklariert eine kleine Größe, der reale DEFLATE-Stream sprengt das
  [im Test gesenkte] Budget — wird VOR dem ExcelJS-Load abgelehnt), 4 `compareQafPair`-Wiring-Tests, 7
  Security-Tests (`__tests__/security/qaf-untrusted-excel-hardening.test.ts` — inkl. desselben F1-Exploits auf
  Public-API-Ebene), 4 neue Recompute-Stabilitätstests (`persistence-mapper.test.ts`, F3: Makro-/Link-Fund
  übersteht `refreshPlausibility: true`, historische Dateien ohne persistiertes `workbookSafety` rehydrieren
  zu `undefined`, nie fabriziert). Alle Fixtures werden im Test generiert (ExcelJS oder ein minimaler
  handgebauter ZIP-Buffer) — keine Binär-Fixtures committed. Real-Datei-Smoke server-seitig gegen 16 echte
  QAF-Dateien (BMW G60 `.xlsm`, Autoliv/Creator/Brose/Kiekert `.xlsx`, Fehlerreport) verifiziert — Protokoll
  (nur Struktur-Metriken, keine Inhalte/Preise) im PR-Kommentar zu KAR-914. Alle 16 bestehen sauber; reale und
  deklarierte Ratio stimmen bei jeder Datei exakt überein (3.9:1–9.7:1, weit unter 100:1 — legitime Dateien
  lügen nie), reale Inflate-Kosten liegen bei ≤ 165 ms/Datei. Die 3 `.xlsm`-Dateien lösen erwartungsgemäß
  `macroPresent=true` aus, mehrere `.xlsx`-Dateien zeigen real `externalLinksPresent=true`.

## 2026-07-10 – R3 für optionale SUMMARY-Felder + Regel-Engine an Detail-Parser verdrahtet (KAR-908, KAR-909)

Schließt die beiden High-Follow-ups aus der Cross-Language-Fixture-Suite (KAR-907/#286): die zwei
`it.fails`-Tests dort sind jetzt grün (`it`), die zugehörigen `.fails`-Marker entfernt.

- **KAR-908 — SUMMARY-R3-Verdrahtungslücke behoben** (`rule-engine.ts` `evaluateSummaryRules`): das alte
  `if (!mandatory) continue` überspringt keine optionalen SUMMARY-Felder mehr. Da `QafSummary.SummaryField`
  (types.ts) für TEXT-Identitätsfelder kein `rawText`-Pendant hat (ein befülltes-aber-falsches Textfeld kann
  `classifyFieldState` strukturell nie als `provided_invalid` erkennen), evaluiert `evaluateSummaryRules`
  jetzt ALT und NEU gemeinsam: ein optionales Feld wird nur dann als R3-Hinweis markiert, wenn es auf DIESER
  Seite leer ist, während sein Gegenstück auf der ANDEREN Seite `provided_valid` ist (Cross-Side-
  Asymmetrie-Heuristik). Beide Seiten leer bleibt vollständig still ("leer bleibt still" — Rauschkontrolle).
  Reproduziert exakt Fehlerreport-Fall #6 (Anfragenummer/Version: DE=korrekt/gefüllt, EN=inkorrekt/leer →
  jetzt R3-Hinweis auf NEU). `provided_invalid` bleibt zusätzlich immer geprüft (unabhängig vom Gegenstück,
  aktuell für die 9 TEXT-Identitätsfelder unerreichbar, aber vorwärtskompatibel gehalten).
- **KAR-909 — Regel-Engine an MATERIAL/SBM/RMR/LOGISTICS verdrahtet** (`rule-engine.ts`
  `evaluateDetailRowRules` + 4 dünne Wrapper `evaluateMaterialRules`/`evaluateSbmRules`/`evaluateRmrRules`/
  `evaluateLogisticsRules`): `RuleEngineInput` bekommt vier neue optionale Kanäle (`materialRows`/`sbmRows`/
  `rmrRows`/`logisticsRows`, gleiches Tri-State-Optional-Contract wie `QafFileParsed` in `compare.ts`).
  Mandatory/optional-Klassifikation kommt **aus der kanonischen Feld-Registry** (`byCanonicalId(...)
  .requirement` über die bestehenden `MATERIAL_FIELD_KEY_TO_CANONICAL`/`SBM_FIELD_KEY_TO_CANONICAL`/
  `RMR_FIELD_KEY_TO_CANONICAL`/`LOG_FIELD_KEY_TO_CANONICAL`-Tabellen aus `canonical-fields.ts`) — keine neue
  Hardcode-Liste. `'conditional'` fällt konservativ auf den R3/optional-Pfad (keine Annahme, wann die
  Precondition gilt). Sync-Import-Muster (kein neuer dynamic-import/Promise-Cache): `rule-engine.ts`
  importiert `canonical-model.ts` bereits seit KAR-906 statisch (`byCanonicalId`); verifiziert, dass
  `compareQafPair`/`evaluateRuleEngine` ausschließlich server-seitig erreicht werden (`app/qaf-differences/
  actions.ts`, eine Server-Action — kein `'use client'`-Import), daher gilt die "dynamic-import wegen
  Client-Bundle"-Regel aus `material-parser.ts` hier nicht. Detail-Zeilen mischen TEXT- und NUMERIC-Spalten
  wie `QAFRow` — numerische Spalten tragen bereits eine echte `rawText`-Provenienz (KAR-886/891-Muster), R3
  nutzt daher denselben `provided_invalid`-only-Gate wie STEP-Felder, ganz ohne die KAR-908-Cross-Side-
  Sonderlogik. `compare.ts` speist `materialRows`/`sbmRows`/`rmrRows`/`logisticsRows` (dieselbe Quelle, die
  bereits für `checkReconciliation` vorliegt) in `evaluateRuleEngine` ein. Persistierte `issue_type`-Werte
  bekommen für die 4 neuen Sektionen ein Namespace-Präfix (`rule_r2_material_mandatory_warn`,
  `rule_r3_sbm_optional_invalid`, ...) — `summary`/`fertigungskosten` bleiben byte-identisch zum
  Vor-KAR-909-Format.
- `RuleViolation.section` erweitert um `'material' | 'sbm' | 'rmr' | 'logistics'`.
- `ENGINE_VERSION.ruleEngine` `1.0.0` → `1.1.0` (types.ts) — echte Verhaltensänderung (neue Befunde auf
  Daten möglich, die vorher keine erzeugt haben), Reproduzierbarkeits-Reasoning wie bei jedem anderen
  `ENGINE_VERSION`-Bump.
- Getestet: `cross-language-fixtures.test.ts` Fall #6 und der "Zusatzfund"-Test (MATERIAL-Verdrahtung) sind
  jetzt reguläre `it`-Tests statt `it.fails`; die Buchhaltung im selben File wandert von 6/9-gap-wiring auf
  7 grün / 0 gap-wiring / 8 gap-model. Volle Suite: 2353 passed (vorher 2351 passed + 2 fails), 44 todo, 23
  skipped — keine Regressionen in `rule-engine.test.ts`, `compare.test.ts`, `cross-language-e2e.test.ts`,
  `reconciliation.test.ts` o.ä.
- Keine neuen Dependencies, kein Schema-Change.

## 2026-07-10 – Cross-Language-Fixture-Suite + Fehlerreport-Regressionsfälle (KAR-907 / P3.3)

- **Neue Testdatei** `lib/qaf-differences/internal/__tests__/cross-language-fixtures.test.ts` (letztes
  Phase-3-Item, Master-Prompt §15 "German vs English als eigene Accuracy-Dimension"). Erweitert das
  bestehende DE/EN-E2E-Muster aus #283/KAR-905 (`cross-language-e2e.test.ts`, dort unverändert gelassen,
  keine Duplikat-Szenarien) um die 5 im Backlog geforderten Kern-Szenarien und die 15 in
  `03-fehlerreport-analyse.md` §5 dokumentierten realen Korrekt-Mismatch-Fälle aus dem echten BMW-QAF- // allow-customer-string
  Fehlerreport (Brose B-Schloss FT links) — als synthetische Nachbauten (Feldnamen/Struktur aus der
  Analyse, alle Werte erfunden; **Kais-Entscheid: die beiden echten Fehlerreport-Quelldateien bleiben
  server-only, NUR synthetische Fixtures im Repo**).
- **5 Szenarien** (a) DE vs. DE Baseline, (b) EN vs. EN Baseline, (c) DE vs. EN identische Daten → 0
  falsche Befunde/Felder matchen über canonical IDs, (d) DE vs. EN mit echten Unterschieden → dieselbe
  Delta-/Status-/Match-Klassifikation wie ein sprachreines Äquivalent (Sprachneutralität bewiesen), (e)
  mixed-language-Datei (EN-MANUFACTURING-Tab + DE-SUMMARY-Tab) → `language: 'mixed'`, kein Crash beim
  Parsen.
- **15 Fehlerreport-Regressionsfälle** als echte synthetische DE/EN-Workbook-Fixture-Paare (ExcelJS),
  durch die reale Parse-Pipeline getrieben (`parseSummarySheetFromWorkbook`/`parseMaterialSheet`/
  `parseSbmSheet`), Assertion auf `classifyFieldState` bzw. `evaluateRuleEngine`: **6/15 grün** über die
  reale Pipeline (#36 MATERIAL Mengeneinheit/MGK-Label-Duplikat, #67/#71/#92/#94/#100 SBM), **9/15 als
  echte, in diesem PR aufgedeckte Engine-Lücken dokumentiert** (`it.fails`/`it.todo` statt die Erwartung
  an den Ist-Zustand anzupassen — Task-Vorgabe): #6 (SUMMARY-R3 wird für optionale Felder nie evaluiert,
  `rule-engine.ts` `evaluateSummaryRules`), #10/#12/#14/#16/#23 (Feld nicht in `QafSummary`/
  `SummaryMetricKey` modelliert), #42 (aggregiertes MATERIAL-"Angebot"-Feld fehlt in der Registry, nur
  Je-Mengeneinheit-Wert vorhanden), #74/#75 (SBM-Registry hat nur EIN kombiniertes
  `sbm_anzahl_stufen_takte`-Feld, keine Aktiv/Leer-Granularität).
- **Zusatzfund** (separat dokumentiert, nicht Teil der 15er-Zählung): `rule-engine.ts`'s `RuleEngineInput`
  kennt nur `{summary: QafSummary; steps: QAFRow[]}` — R1-R6 sind nicht an die längst existierenden
  MATERIAL-/SBM-Parser (P1.6/P1.7) verdrahtet, obwohl deren `MaterialRow`/`SbmRow`-Form (sourceCells/
  normalized/rawText) exakt zu `classifyFieldState` passt. Die 6 grünen MATERIAL/SBM-Fälle beweisen nur
  die Feld-Zustands-Klassifikation (R1); sie erzeugen heute keine `RuleViolation`/bilinguale Meldung
  (KAR-906/`decodeBilingual` bleibt für diese Felder ungenutzt) — Kandidat für ein Folge-Issue.
- Keine neuen Dependencies, keine Schema-Änderung, kein Produktionscode geändert — reine
  Test-Infrastruktur (Master-Prompt-Scope: "Tests sind selbst die Lieferung").

## 2026-07-10 – Bilinguale Validierungs-Meldungen über alle Befund-Klassen (KAR-906 / P3.2)

- **Persistenz-Ansatz, KEIN Schema-Change:** `qaf_plausibility_issue.explanation` ist und bleibt eine einzelne
  TEXT-Spalte. Bisheriges Muster (KAR-889, R1-R6): `RuleViolation` trägt bereits `messageDe`/`messageEn`, aber
  die Persistenz-Bridge verwarf `messageEn` komplett vor dem Insert ("not persisted today" laut Modul-Header) —
  d.h. die UI konnte EN nie wirklich anzeigen, selbst mit Sprachumschaltung. Neues Modul
  `internal/bilingual-message.ts` schließt diese Lücke ohne Migration: `encodeBilingual(de, en)` packt beide
  Sprachen in die eine TEXT-Spalte (Marker-Präfix `@@BI1@@` + JSON; wenn `en` fehlt oder == `de`, bleibt die
  Spalte ein reiner, byte-identischer DE-String wie vorher). `decodeBilingual(raw)` entpackt das wieder — und
  behandelt jeden String ohne Marker (jede Bestands-Zeile vor diesem PR) als reinen DE-Text, EN fällt dann
  automatisch auf DE zurück (Pflicht-Toleranz aus der Aufgabenstellung: „Bestands-Issues ohne EN tolerant").
  `PlausibilityIssue` bekommt ein neues optionales Feld `explanationEn?: string`; `persistence-mapper.ts`
  ruft `encodeBilingual(i.explanation, i.explanationEn)` beim Insert auf — der einzige Encode-Punkt im ganzen
  Baum.
- **Alle Issue-Erzeuger auf `explanationEn` umgestellt** (Namespace → Datei):
  - `rule_r1..r6_*` (`rule-engine.ts`) — **jetzt voll lokalisiert, nicht nur durchgereicht.** Erste Fassung
    dieses PRs gab `messageEn` von `ruleViolationToPlausibilityIssue` nur weiter (Bridge-Fix), interpolierte
    aber weiterhin dasselbe DE-`fieldLabel` aus `STEP_FIELD_LABELS`/`SUMMARY_FIELD_LABELS` (beide DE-only
    Literal-Maps) in `messageEn` — ein EN-Leser sah dadurch `Required field "BMW Sachnummer" is not filled // allow-customer-string
    in correctly...`. Nachträglich gefunden im #284-Review (Konfidenz 88) und im selben PR gefixt:
    `buildViolation` bekommt einen `fieldLabelEn`-Parameter, `evaluateStepRules`/`evaluateSummaryRules`
    lösen ihn über `QAF_FIELD_KEY_TO_CANONICAL[field]`/eine neue lokale `SUMMARY_FIELD_KEY_TO_CANONICAL`-Map
    + `byCanonicalId(...).labelEn` auf (identisches Muster wie `reconciliation.ts`/`business-rules.ts`),
    Fallback auf das DE-Label bei Registry-Miss (dokumentiert, kein Crash). Jetzt z.B. „Item number
    Manufacturing step" statt „Positionsnummer Fertigungsschritt", „BMW part number" statt „BMW // allow-customer-string
    Sachnummer" — real durch bis in die persistierte, bilingual-kodierte Spalte getestet.
  - `recon_*` (`reconciliation.ts`) — `judge()`-Zweig (Abweichung) hatte `messageEn` bereits (unbenutzt), jetzt
    durchgereicht; `nicht_pruefbar`-Zweig (21 Call-Sites) war komplett DE-only — 5 wiederkehrende Satzschablonen
    (Kennzahl fehlt/keine Zeilen/Währungs-Mismatch/unklare Werte/kein Sheet) als bilinguale Helper faktorisiert,
    Feldnamen für die Kaskaden-Kennzahlen über `SUMMARY_METRIC_KEY_TO_CANONICAL`+`byCanonicalId(...).labelEn`
    aus der kanonischen Registry (KAR-892), nicht freihändig übersetzt.
  - `rule_calc_*` (`business-rules.ts`) — `RequiredField`/`missingReason()` um `labelEn` erweitert (Quelle:
    `QAF_FIELD_KEY_TO_CANONICAL`/`MATERIAL_FIELD_KEY_TO_CANONICAL`/`SBM_FIELD_KEY_TO_CANONICAL`), alle
    `manufacturingField`/`materialField`/`sbmField`-Aufrufer bekommen EN automatisch, kein Call-Site-Change nötig.
  - `formel_*` (`formula-engine.ts`) — `FormulaComparisonResult.explanationEn` (optional: die
    `FieldDiff.formulaFinding`-In-Memory-Kopie bleibt bewusst DE-only, siehe „Offen" unten).
  - `rmr_*`, `log_*`, `lccn_*`, `co2e_*` (`rmr-parser.ts`/`logistics-parser.ts`/`lccn-parser.ts`/`co2e-parser.ts`) —
    identisches Muster: `notPruefbar()` bekommt `reasonEn`-Parameter, per-Feld-EN-Labels aus den jeweiligen
    `*_FIELD_KEY_TO_CANONICAL`-Maps über neue Shared-Helper `missingFieldReason()`/`joinMissingReasons()`
    (`bilingual-message.ts`). LCCN zusätzlich: `below40Hint`/`below40HintEn` (Leitfaden-S.46-Hinweis) und der
    `lccn_process_classification_mismatch`-Befund.
  - `template_*` (`template-fingerprint.ts`) — `TemplateClassificationResult`/`TemplateFingerprintResult` um
    `deviationsEn: string[]` erweitert; 7 Facetten-Bausteine (SUMMARY/MANUFACTURING/MATERIAL/SBM/RMR/LC-CN/CO2e)
    über einen neuen `facetDeviation()`-Helper vereinheitlicht statt sieben Kopien.
  - `g60_structure_*`, `g60_column_relocated` (`g60/structure-guard.ts`) — alle 4 Bridge-Funktionen,
    `mismatchSummaryEn()` als EN-Pendant zu `mismatchSummary()` (Zellkoordinaten/Label-Text bleiben unübersetzt —
    das ist Excel-Inhalt, keine Prosa).
  - `parser_degraded_manufacturing_headers`, `part_number_mismatch`, `part_name_changed`, `variant_changed`,
    `quotation_date_order`, `currency_change`, `negative_cost`, `zero_cost` (`plausibility.ts`) — direkte
    EN-Literale für alle 8 Prüfungen.
- **UI:** neue kleine Client-Komponente `components/qaf-differences/qaf-plausibility-message.tsx`
  (`PlausibilityMessage`) liest die aktive Sprache über den bestehenden `lib/i18n/i18n-context.tsx`-Mechanismus
  (`useI18n()` — vorher **nirgends** im `qaf-differences`-Modul verdrahtet, nur in anderen Features) und
  entscheidet: persistierte Zeile → `decodeBilingual(explanation)`; live-berechnete `PlausibilityIssue`
  (z.B. G60-Struktur-Befunde, die nie durch die DB laufen) → `explanationEn` direkt, kein Decode nötig.
  Eingebaut in `qaf-comparison-detail.tsx` (Abschnitt „A4. Plausibilität") und `qaf-g60-detail.tsx`
  („Struktur-Plausibilität (G60)"). Fallback DE bei fehlendem/leerem EN in beiden Pfaden.
  **Bugfix während der Umsetzung gefunden:** `app/qaf-differences/[id]/page.tsx` mischte live berechnete
  `template_fingerprint_*`-Befunde (die `explanationEn` tragen) mit DB-Zeilen zu einer Liste, ohne
  `explanationEn` durchzureichen — die EN-Sprache wäre für diesen einen Befund-Typ nie angekommen. Behoben
  (`explanation_en`-Feld ergänzt). Zweiter Bugfix: `app/qaf-differences/actions.ts`s Re-Export-Pfad
  (`plausibilityOverride`, rehydrate-basierter Export ohne Live-Neuberechnung) fütterte die rohe,
  möglicherweise bilingual-kodierte DB-Zeile ungeprüft durch — die deutsche Export-Spalte hätte den
  rohen `@@BI1@@{...}`-Envelope statt Klartext gezeigt. Behoben: `decodeBilingual()` vor dem Mapping.
- **Export (XLSX):** `export.ts` hat keinen bestehenden Sprachauswahl-Mechanismus (geprüft: kein Locale-Param
  auf `QafExportInput`/`addSheet`) — konservativ zusätzliche, additive Spalte „Explanation (EN)" ans Ende des
  `Plausibilitätscheck`-Sheets angehängt (bricht kein bestehendes Layout, jeder Vor-KAR-906-Leser ignoriert die
  neue Spalte einfach). Gespeist direkt aus dem live `PlausibilityIssue.explanationEn` (dieser Sheet-Builder
  bekommt nie die persistierte/kodierte Zeile, siehe Kommentar im Code) — kein Decode nötig.
- **Beifang (#283-Review, Konfidenz 80):** Kommentar in `lib/qaf-parser.ts` behauptete einen Drift-Test
  zwischen den drei inline DE/EN/Typo-Substrings des MANUFACTURING-Sheet-Pickers und
  `module-sheet-names.ts`s `MODULE_SHEET_NAME_ALIASES.MANUFACTURING` — der Test existierte nicht
  (`module-sheet-names.test.ts` prüfte nur `matchesModuleSheetName` gegen seine eigenen hartkodierten
  Literale, nie gegen `qaf-parser.ts`). Fix: Substring-Liste als `MANUFACTURING_SHEET_NAME_SUBSTRINGS`
  exportiert, echter Drift-Test in `lib/__tests__/qaf-parser.test.ts` (4 Assertions: exakte Mengengleichheit
  beider Listen + bidirektionale Matcher-Konsistenz + ein realer BMW-Tab-Namens-Fall) — manuell verifiziert,
  dass der Test bei einer künstlich eingefügten Drift tatsächlich rot wird. Kommentar korrigiert.
- **Tests:** 71 neue Tests (Baseline 2260 → 2331), je Namespace mindestens Abweichung+nicht_pruefbar bilingual
  abgedeckt; `bilingual-message.test.ts` deckt encode/decode/Fallback/Korruption/Rundtrip inkl. echter Umlaute.
  Zusätzlich (#284-Fix): je 1 R2- und 1 R3-Fall mit Assertion auf das ECHTE canonical `labelEn` in
  `rule-engine.test.ts`, plus 2 Persistenz-Rundtrip-Tests (`persistence-mapper.test.ts`) die den kodierten
  DB-Spalten-String decodieren und das reale EN-Label darin verifizieren.
- **Offen/fragil:** `FieldDiff.formulaFinding.explanation` (In-Memory-Kopie für die Formel-Engine-Synergie
  zwischen `business-rules.ts` und `compare.ts`) bleibt bewusst DE-only — sie überlebt laut `types.ts`-Doku
  ohnehin keinen DB-Rundtrip, eine EN-Kopie hätte keinen Persistenz-Nutzen. Die „ZUSAETZLICH wurde für dieses
  Feld eine Formel-Änderung erkannt"-Anhängesatz in `business-rules.ts`s Bridge zitiert diesen Text daher in
  beiden Sprachen identisch (DE). `lccn_process_classification_mismatch`s Kategorie-Bezeichnung
  (`LC_PROCESS_CLASSIFICATION_TABLE`, z.B. „1. Urformen") bleibt unübersetzt in beiden Sprachen — deutsche
  Leitfaden-Kategorienamen, kein EN-Pendant in der Quelle vorhanden.

## 2026-07-10 – DE/EN-Vereinheitlichung: Sheet-Synonyme, Sprach-Erkennung, Cross-Language-Vergleich (KAR-905 / P3.1)

- **Inventur-Ergebnis (vor jeder Code-Änderung per Test verifiziert, nicht angenommen):** MANUFACTURING
  (`qaf-parser.ts`) und SUMMARY (`summary-metrics.ts`) waren bereits DE+EN-vollständig und real-file-
  verifiziert. Die fünf P2.x-Modul-Parser (`material-parser.ts`, `sbm-parser.ts`, `logistics-parser.ts`,
  `rmr-parser.ts`, `lccn-parser.ts`, `co2e-parser.ts`) laufen alle bereits seit ihrer jeweiligen
  Ursprungs-PR über das kanonische Feld-Modell (`labelDe`/`labelEn`/`aliases`, KAR-892/P1.1) — die
  vermutete Lücke ("EN-Label nur unverifizierte Arbeitsübersetzung") wurde durch einen neuen Test
  (`__tests__/en-header-verification.test.ts`, 125 Assertions: jedes registrierte Feld jedes Moduls, per
  `labelEn` gegen den jeweiligen Modul-eigenen Header-/Label-Matcher) geprüft — **alle 125 lösen bei
  Tier-1/exact/Confidence 1.0 auf**. Kein Code-Fix in den sechs Zeilen-Parsern nötig, nur Verifikation.
  Echte Lücke war die Sheet-NAMEN-Erkennung: sechs separate `isXxxSheetName`-Funktionen ohne
  gemeinsame Alias-Quelle (Master-Prompt-Hypothese bestätigt) — aber die Leitfaden-Auswertung
  (`02-leitfaden-teil1/2/3.md`) zeigt, dass fast alle QAF-Blattnamen (MATERIAL, SBM-DEVICES-FWZ,
  LOGISTICS&CUSTOM, RAW MATERIAL RISK(S), LC-CN, CO2e) sprachneutrale, feste BMW-Eigennamen sind, die im
  Leitfaden selbst — auch im deutschen Fließtext — wörtlich zitiert werden; **nur** das MANUFACTURING-Blatt
  ("Fertigungskosten" DE vs. "Manufacturing costs"/"Manufactering costs" EN) und die SUMMARY-Legacy-
  Variante ("Zusammenfassung", DE-only) sind echte Sprachsignale.
- **Neues Modul `internal/module-sheet-names.ts`:** zentrale `MODULE_SHEET_NAME_ALIASES`-Konstante
  (DE+EN+Tippfehler-Varianten je Modul, jede mit Leitfaden-Seitenangabe zitiert) + `matchesModuleSheetName`/
  `moduleSheetNameLanguageSignal`. Alle sechs `isXxxSheetName`-Funktionen (`material-parser.ts`,
  `sbm-parser.ts`, `logistics-parser.ts`, `rmr-parser.ts`, `lccn-parser.ts`, `co2e-parser.ts`) sowie
  `workbook-adapter.ts`s SUMMARY-Erkennung darauf umgestellt (Verhalten unverändert, nur Quelle
  zentralisiert). `canonical-fields.ts`s alte, MANUFACTURING-only-Kopie von `MODULE_SHEET_NAME_ALIASES`
  entfernt zugunsten dieser einen Quelle. `qaf-parser.ts`s eigene DE/EN/Typo-Liste bleibt bewusst inline
  (Datei ist Teil des `qaf-client.tsx`-Client-Bundles, striktere Static-Import-Disziplin) — per Test
  (`module-sheet-names.test.ts`) gegen Drift abgesichert.
- **Neues Modul `internal/language-detection.ts`:** `detectQafFileLanguage()` — reine Funktion, erkennt
  `'de' | 'en' | 'mixed' | 'unknown'` aus Sheet-Namen (+ optional `SummaryTemplateType`). Bewusst NICHT
  content-basiert (Task-Scope: "Summary-Template/Sheet-Namen"), bewusst NICHT scharf geraten — ohne Signal
  `'unknown'`, bei widersprüchlichen Signalen `'mixed'`, nie eine Sprache erfunden.
- **Fingerprint-Facette + Persistenz ohne Schema-Change:** `template-fingerprint.ts`s
  `TemplateFingerprintResult` trägt jetzt `language` (aus `structure.sheetNames` +
  `structure.summary?.templateType` abgeleitet, NICHT Teil von `structure`/`hash`/`classification` — beeinflusst
  Known/Modified/Unknown nie). Da `language` direkt aus bereits vorhandenen `TemplateFingerprintInput`-Feldern
  berechnet wird, ist **kein Änderung an `actions.ts` nötig** — beide bestehenden `buildTemplateFingerprint()`-
  Aufrufe (G60- und Summary-Ingest-Pfad) persistieren die Sprache automatisch mit im bereits existierenden
  `qaf_file.g60_meta.templateFingerprint`-JSONB-Feld.
- **UI-Badge:** `qaf-comparison-detail.tsx` zeigt einen neuen `LanguageBadge` (DE/EN/DE+EN gemischt/Sprache
  unbekannt) direkt neben dem bestehenden Template-Klassifikations-Badge für ALT und NEU — gespeist aus
  `page.tsx`s bereits gelesenem `templateFingerprint.language`. G60-Detailpfad bewusst nicht erweitert
  (G60 ist ein BMW-internes Detail-Tool ohne dokumentiertes Sprachkonzept, siehe `canonical-fields.ts`).
- **Cross-Language-Zeilen-Matching (`matcher.ts`):** kein Code-Fix nötig, nur dokumentiert + verifiziert.
  Prozess-/Anlagen-/Teile-Namen sind Freitext des Lieferanten, nie übersetzt — bei DE-vs-EN-Datei-Paaren
  matcht die Kaskade weiterhin über Positionsnummer (Confidence sinkt auf `possible_structure_change`
  ~0.5 + `requiresReview`), nie eine falsche `new`/`removed`-Struktur-Meldung. Bewusst KEINE
  Übersetzungs-Magie eingebaut (Master-Prompt §2).
- **Neuer E2E-Test `__tests__/cross-language-e2e.test.ts`:** synthetisches DE-Workbook (Blatt
  "Fertigungskosten" + "Zusammenfassung", Legacy-Template) vs. synthetisches EN-Workbook (Blatt
  "Manufacturing costs" + "SUMMARY", V9-Template) derselben logischen Daten, über die echte Parse-Pipeline
  (`loadExcelWorkbook`/`parseQAFTemplate`/`parseSummarySheetFromWorkbook`) und `compareQafPair` — beide
  Sprachen korrekt erkannt, beide Fertigungsschritte über Positionsnummer gematcht (identischer Freitext →
  `safe_match`; genuin unübersetzter Freitext → `possible_structure_change` mit reduzierter Confidence,
  aber weiterhin korrekt erkannter Kostendelta), Summary-Metriken über kanonische `metricKey`s korrekt
  diffed trotz unterschiedlicher DE/EN-Labels UND unterschiedlicher Legacy/V9-Zeilen-Layouts, kein
  `kritisch`-Befund allein durch den Sprachunterschied.
- **4 Tippfehler-Varianten aus dem Fehlerreport (`03-fehlerreport-analyse.md` §3.3/§4.2 R6) verifiziert:**
  "Teilebennung", "Produdktionsstart", "Verrechungsform" und "Schieber hyrd." (statt "hydr.") waren
  bereits alle vier als Aliase in `canonical-fields.ts` registriert (KAR-892/P1.1/KAR-898). Drei hatten
  bereits Regressionstests; "Schieber hyrd." hatte keinen — Lücke geschlossen
  (`sbm-parser.test.ts`, neuer Test).
- **Migrations-Impact:** keiner (reine Code-Konsolidierung + eine neue, abgeleitete Fingerprint-Facette in
  einem bereits bestehenden JSONB-Feld).

- **Neue Module:** `lib/qaf-differences/internal/lccn-parser.ts` (LOCAL CONTENT CHINA-Zusammenfassungsblatt,
  15 der 17 registrierten `lccn_*`-Canonical-Felder — die 2 restlichen sind row-level MATERIAL/MANUFACTURING-
  COSTS-Gruppierungsspalten, out of scope) und `lib/qaf-differences/internal/co2e-parser.ts`
  (Carbon-Footprint-Blatt, alle 14 `co2e_*`-Canonical-Felder: 4 Zusammenfassungs-Panel-Felder + 10
  CO2e-Material-Zeilenfelder). Beide additiv (Dateien ohne die jeweiligen Sheets byte-identisch), Tag-1-
  Tri-State (`coreFieldsFound` + `*ForReconciliation`/`*FromPersistedMeta`-Helferpaar), sourceCells/
  normalized-Provenienz, dynamic import der canonical Registry (Client-Bundle-Disziplin).
- **Bindender Kais-Entscheid (LC-Rate-Formel-Widerspruch, S.47 vs. S.48):** der Leitfaden widerspricht sich
  bei der LC-Rate ("(1 − Importanteil/Gesamtkosten) × 100%" [S.47] vs. "CIF-Importwert / Gesamtkosten"
  [S.48]). `validateLccnLcRateReadings` berechnet und meldet **BEIDE Lesarten separat** ("LC-Rate (lokal,
  Lesart S.47)" / "Import-Quote (Lesart S.48)"), geprüft gegen dieselbe einzige Excel-Zelle "LC Rate in %",
  mit 40/40-Hinweis (informativ, Aggregat-Ebene, S.46) je Lesart. Keine stille Wahl.
  `validateLccnLcRateComplement` weist zusätzlich sichtbar aus, dass beide berechneten Lesarten sich zu
  ~100% ergänzen.
- **Weitere Validierungen:** `validateLccnTotalProductionCost` (SUMME HERSTELLKOSTEN = 1a+1b+1c+2a+2b,
  Leitfaden S.49 explizit zitiert); `validateCo2eMaterialEmissionsFormula` (Emissionen Material =
  Emissionsfaktor + Upstream Verpackung + Upstream Transport, Leitfaden S.54/S.55 — einzige CO2e-Formel mit
  vollständig parsebaren Eingangsfeldern; Rec-Q=PrC+PoC und die Gesamtemissionen-/Fertigungs-Formeln sind
  dokumentiert NICHT nachgerechnet, da PrC/PoC/Anzahl/Einsatzmenge/Ausschuss keine registrierten
  `co2e_*`-Canonical-Felder sind — kein Fabrizieren fehlender Eingangsgrößen).
- **LC-Checkliste Fertigungsprozess (Leitfaden Teil 3 S.91-95):** `LC_PROCESS_CLASSIFICATION_TABLE`,
  97 verifizierte Prozessschritt-Einträge (Substantial vs. Other Manufacturing Process, Task-Schätzung
  "~90" leicht unterschätzt — echter, gemessener Wert wie schon bei `SBM_DEVICE_CATEGORY_TABLE` #274).
  `classifyLcProcess`/`checkLcProcessDeclarationAgainstClassification` als getestetes Fundament — in diesem
  PR NICHT an Zeilen verdrahtet (LC-CN-Blatt trägt nur Summen, keine Prozessschritt-Zeilen; dokumentiertes
  TODO für eine künftige MATERIAL/MANUFACTURING-COSTS-Zeilen-Ebene).
- **CO2e-Struktur-Entscheidung dokumentiert:** der Leitfaden ist intern widersprüchlich, ob CO2e-Inhalte auf
  einem eigenständigen Tab oder als Panel/Zusatzspalten auf SUMMARY/MATERIAL liegen ([50] nennt keinen
  eigenen "CO2e"-Tab, Abbildung 34 nennt ihn "CO2e-Zusammenfassungsblatt"). Modul-Entscheidung: toleranter
  Namens-Match auf ein eigenständiges Worksheet — dokumentierte Limitation, falls ein reales Template die
  Panel-/Zeilenspalten stattdessen direkt auf SUMMARY/MATERIAL einblendet.
- **Verdrahtung:** `actions.ts` `ingestQafUpload` (additive Parses + `g60_meta.lccn`/`g60_meta.co2e`
  JSONB-Persistenz + Rehydrierung im `sideOf`-Pfad), `compare.ts` (`QafFileParsed.lccnValues`/
  `co2eMaterialRows` Tri-State + Validierungs-Aufruf), `template-fingerprint.ts` (LC-CN-/CO2e-Facette,
  optional analog MATERIAL/SBM/RMR), `canonical-fields.ts` (`LCCN_FIELD_KEY_TO_CANONICAL`/
  `CO2E_FIELD_KEY_TO_CANONICAL` Backward-Maps), `index.ts` Barrel-Exporte.
- **Tests:** 75 neue Tests (49 `lccn-parser.test.ts`, 26 `co2e-parser.test.ts`), rein synthetische Fixtures.
  Gesamt-Suite 2024 → 2099 bestehen (0 Fails), `tsc --noEmit` sauber, `CHECK_FORBIDDEN_LEVEL=error npm run
  check:portability` sauber, `npm run build` Exit 0.

### Follow-up (selber Tag): Fabriziertes Zitat entfernt + Transport-Summanden-Semantik korrigiert (Adversarial-Review, Konfidenz 82)

- **Finding 1 — fabriziertes Zitat entfernt:** `reconciliation.ts`s Modul-Header zitierte einen Satz aus
  Leitfaden [39] ("im Summary-Blatt als 'Enthaltene Zölle, CBAM' bzw. 'Logistikkosten Lieferant-BMW'
  aggregiert einfließend"), der im Quellmaterial nicht existiert (Review hat beide Leitfaden-Extrakte
  gegrept). Ersatzlos entfernt und durch eine ehrliche Unsicherheits-Doku ersetzt ("Evidenzlage +
  Korrektur"-Abschnitt) — Verstoß gegen "never silently guess" (Master-Prompt §2).
- **Finding 2 — Summanden-Semantik korrigiert:** `logistics_transport_detail_sum` summierte
  `logisticsCostPerDeliverySite` (die 3-Komponenten-Summe Transport+Verpackung+Vorverpackung) gegen
  `transportSupplierToBMW`, dessen real-file-verifiziertes Label ("Transportkosten Lieferant - BMW",
  `summary-metrics.ts` SYNONYMS/METRIC_LABELS_DE) transport-only klingt — jede Datei mit
  Verpackungs-/Vorverpackungskosten (VERP/LVVV, jeder Übersee-Lieferant) hätte systematisch `abweichung`
  gefeuert. Zusätzlich war das customs-`labelDe` vertauscht ("Enthaltene Zölle, CBAM" gehört zum
  ANDEREN Metric `customsIncluded`, nicht zu `customsSupplierToBMW`/"Zölle Lieferant - BMW").
  **Empirie-Befund (nur Struktur, keine Beträge, geprüft 09.07.2026):** alle 9 real verfügbaren
  BMW-Summary-QAFs unter `/root/aria/work/qaf-compare-kar824/input/` (Brose FT_links/HT_links ×6,
  Kiekert ×2, Autoliv ×1) enthalten **kein** LOGISTICS&CUSTOM-Sheet —
  die Frage "transport-only vs. kombiniert" ist mit den aktuell verfügbaren Dateien nicht entscheidbar.
  **Gewählte Variante (c, konservativ):** `logistics_transport_detail_sum` summiert jetzt
  `transportCostPerPart` (label-konsistent mit "Transportkosten Lieferant - BMW") statt der kombinierten
  Summe; die 3-Komponenten-Summe bleibt als rein row-interner Check `log_calc_cost_per_delivery_site`
  (kein Summary-Pendant) bestehen. `labelDe` wird für beide Detail-Sum-Checks jetzt direkt aus
  `METRIC_LABELS_DE[summaryKey]` abgeleitet statt erneut von Hand getippt, damit dieselbe Verwechslung
  strukturell nicht wieder auftreten kann. Severity bleibt `pruefen` (war nie `kritisch` — Detail-Sum-Checks
  in diesem Modul sind grundsätzlich `pruefen`).
- **Neuer Locking-Test** (`reconciliation.test.ts`): ein Row-Mock mit `logisticsCostPerDeliverySite` (180,
  aus Transport 100 + Verpackung 50 + Vorverpackung 30 komponiert) vs. `transportSupplierToBMW: 100`
  verifiziert, dass der Check ausschließlich `transportCostPerPart` summiert — würde die kombinierte Summe
  versehentlich wieder verwendet, schlägt der Test fehl. `compare.test.ts`s Wiring-Test entsprechend
  angepasst (separate `transportCostPerPart`/`logisticsCostPerDeliverySite`-Werte im Mock, damit ein
  Rückfall auf das falsche Feld nicht mehr unbemerkt bliebe).
- **Tests:** 2023 → 2024 (+1 neuer Locking-Test, bestehende Tests der beiden Detail-Sum-Describe-Blöcke auf
  das korrigierte Feld umgestellt), 0 Failures.

## 2026-07-10 — LOGISTICS&CUSTOM-Sheet-Parser + Reconciliation (KAR-903/P2.4)

- **Neues Modul `lib/qaf-differences/internal/logistics-parser.ts` (+ Tests):** Parser für das QAF-Blatt
  „LOGISTICS&CUSTOM" (Leitfaden `02-leitfaden-teil2.md` [39]-[41]), gespiegelt vom frisch gemergten
  `rmr-parser.ts`-Muster (KAR-902/P2.3): `byModule('LOGISTICS')` via dynamischem Import, Label-Anker +
  Konfidenz-Matching, kontrollierter Degradations-Pfad bei unbrauchbarem Header (wirft nie),
  `sourceCells`/`normalized`/`rawText`-Provenienz sowie das `coreFieldsFound`-Tri-State +
  `logisticsRowsForReconciliation`/`logisticsRowsFromPersistedMeta`-Helferpaar von Anfang an eingebaut
  (die #274/KAR-898-Lektion wird hier nicht wiederholt).
- **Standort-Struktur-Entscheidung (dokumentiert im Modul-Header):** Der Leitfaden (Abbildung 25, [41])
  zeigt EINE flache Tabelle mit EINER Header-Zeile und 15 durchnummerierten Positionen, in der
  „Anlieferstandort" nur ein Spaltenwert je Zeile ist — genau dieselbe Zeilen-/Spalten-Struktur wie
  MATERIAL/SBM, NICHT RMRs echtes Zwei-Block-Layout. RMRs Multi-Block-Scan-Schleife wurde deshalb bewusst
  NICHT wiederverwendet (premature Abstraktion über eine Struktur, die dieses Blatt gar nicht hat) —
  stattdessen wird `material-parser.ts`s Einzel-Header/Einzel-Datenblock-Muster gespiegelt, ergänzt um den
  Tag-1-Tri-State-Vertrag, den RMR/SBM etabliert haben.
- **14 Kernfelder** (bereits in `canonical-fields.ts` LOGISTICS-Modul vorhanden, S.40, jetzt via
  `LOG_FIELD_KEY_TO_CANONICAL` an den Parser angebunden): Positionsnummer, Anlieferstandort,
  Gesamtvolumen, Angebotswährung AW, Transportkosten/Bauteil, Lieferbedingungen (Incoterm),
  Verpackungskosten/Bauteil, Zuschlagsschlüssel (VERP), Vorverpackungskosten/Bauteil, Zuschlagsschlüssel
  (LVVV), Logistikkosten je Anlieferstandort, Zollkosten/Bauteil je Anlieferstandort, Zolltarifnummer,
  Zollkosten je Anlieferstandort. `CORE_LOGISTICS_FIELD_KEYS` (Degradations-Minimum): Positionsnummer +
  Anlieferstandort + Logistikkosten je Anlieferstandort.
- **Formel-Validierung (`log_calc_cost_per_delivery_site`, Issue-Namensraum `log_*`):** Logistikkosten je
  Anlieferstandort = Transportkosten + Verpackungskosten + Vorverpackungskosten (je Bauteil) — exakt die
  im Leitfaden [40] „Formeln/Berechnungslogik" dokumentierte (volumenfreie) Formel, die auch das
  Backlog-Item [P2.4] selbst zitiert. **Volumen-Diskrepanz dokumentiert, nicht stillschweigend
  aufgelöst:** [39]s Fließtext erwähnt zusätzlich eine Volumen-Multiplikation („automatisch aus Volumen ×
  (...)"), [40]s eigene Formeltabelle nennt keinen Volumenfaktor — implementiert wurde die Formeltabelle
  (auch die Backlog-Vorgabe), die Abweichung ist im Modul-Header als offener Punkt vermerkt. Die zweite
  Leitfaden-Formel („Zollkosten = Zollwert × Zollsatz + CBAM") wurde NICHT nachgebaut: Zollwert/Zollsatz
  sind keine eigenen dokumentierten Spalten (nur die bereits berechneten Ergebnisfelder Zollkosten/Bauteil
  und Zollkosten je Anlieferstandort) — Nachrechnung würde ungeparste Eingaben erraten, verboten laut
  Task-Vorgabe.
- **Incoterm-Domänenliste (`log_incoterm_domain`):** FCA/DAP/DDP sind die einzigen im extrahierten
  Leitfaden für dieses Blatt dokumentierten Incoterms ([39]/[40]) — vollständige, nicht geratene Liste.
  Unbekannter Wert → `pruefen`-Severity-Hinweis, NIE ein Fehler/Block (Task-Vorgabe wörtlich umgesetzt).
  VERP/LVVV-Zuschlagsschlüssel werden als reine (geschützte) Datenfelder erfasst — die
  Automatik-Auslöselogik wird NICHT nachgebaut (Task-Vorgabe).
- **Reconciliation (`reconciliation.ts`, KAR-903/P2.4):** zwei neue Detail-Sum-Checks nach dem
  `sbm_detail_sum`-Muster — `logistics_transport_detail_sum` (Σ `logisticsCostPerDeliverySite` vs.
  Summary-Kennzahl `transportSupplierToBMW`, „Logistikkosten Lieferant-BMW") und
  `logistics_customs_detail_sum` (Σ `customsCostPerDeliverySite` vs. `customsSupplierToBMW`, „Enthaltene
  Zölle, CBAM"). AW-Währungsdimension geprüft (fkAW-Lehre: Summierung über abweichende
  `quotationCurrency` wird verweigert). Beide-leer-Semantik (→ `bestanden`) NUR für eine
  CONFIRMED-leere Zeilen-Liste, NIE für `logisticsRows === null` (der in #274/KAR-898 gefixte
  `coreFieldsFound`-Schutz — ein degradiertes Blatt darf nie als sauber durchgehen, nur weil die
  Summary-Seite ebenfalls leer ist), Tri-State identisch zu `sbmRows`.
- **Persistenz:** JSONB `qaf_file.g60_meta.logistics` (`{rows, parseMeta}`), analog `material`/`sbm`/`rmr`
  — keine neue Tabelle `qaf_logistics_row` in diesem PR (dieselbe Abweichung vom Backlog-Migrations-Hinweis,
  die MATERIAL/SBM/RMR bereits etabliert haben, hier zusätzlich per expliziter Task-Vorgabe bestätigt).
  `app/qaf-differences/actions.ts`: `ingestQafUpload` parst + persistiert das LOGISTICS-Sheet;
  `recompareComparison`s `sideOf()` rehydriert `logisticsRows` aus `g60_meta.logistics` (KAR-899-Pfad
  von Anfang an mitgezogen, nicht nachgerüstet).
- **Fingerprint-Facette bewusst NICHT gebaut** (Task-Vorgabe: „optional") — kein
  `TemplateFingerprintLogisticsInput`/`-Facet` in diesem PR, kein Verhaltensunterschied für bestehende
  Fingerprint-Klassifikation.
- **Additivität geprüft:** Dateien ohne LOGISTICS-Sheet bleiben byte-identisch (`logisticsRows` bleibt
  `undefined`, `evaluateLogisticsValidation` liefert `[]`) — durch die komplette Bestandstestsuite
  (1959 vorher) plus neue Wiring-Tests in `compare.test.ts` verifiziert, keine Regression.
- **Tests:** `logistics-parser.test.ts` (42 Tests: Sheet-Erkennung, Header-Matching, Zeilen-Parsing,
  Tri-State/`coreFieldsFound`-Degradation, Row-Push-Guard, Formel-Validierung inkl. Gating/n.a.-Handling,
  Incoterm-Domänenliste inkl. Case-Insensitivität), `reconciliation.test.ts` (+14, beide neuen
  Detail-Sum-Checks inkl. coreFieldsFound-Schutz/Währungs-Guard/n.a.-Handling), `compare.test.ts` (+6,
  End-to-End-Wiring inkl. Additivität). Gesamt 1959 → 2023 Tests, 0 Failures.

## 2026-07-10 — RAW MATERIAL RISKS-Sheet-Parser + Rohstoffzuschlag-Validierung (KAR-902/P2.3)

- **Neues Modul `lib/qaf-differences/internal/rmr-parser.ts` (+ Tests):** Parser für das QAF-Registerblatt
  „RAW MATERIAL RISKS" (Leitfaden `02-leitfaden-teil2.md` [42]-[45]), 1:1 nach dem etablierten Muster von
  `material-parser.ts` (KAR-897/P1.6) und `sbm-parser.ts` (KAR-898/P1.7) gespiegelt: `byModule('RMR')`
  via dynamischem Import, Label-Anker + Konfidenz-Matching, kontrollierter Degradations-Pfad bei
  unbrauchbarem Header (wirft nie), `sourceCells`/`normalized`/`rawText`-Provenienz sowie das
  `coreFieldsFound`-Tri-State + `rmrRowsForReconciliation`/`rmrRowsFromPersistedMeta`-Helferpaar von
  Anfang an eingebaut (die #274/KAR-898-Lektion aus 4d158f6 wird hier nicht wiederholt).
- **12 Kernfelder** (bereits in `canonical-fields.ts` RMR-Modul vorhanden, S.43): Positionsnummer,
  Rohstoffbezeichnung, Bezugsgewicht, Rohstoffschlüssel, Rohstoffnotierung Ro, Rohstoffzuschlag RoZ0,
  Abwicklungsmodell, Indexbezeichnung, Indexnotierung RoI0, BMW Beteiligungsquote BQ, Schwellwert SW,
  Bemerkung. `CORE_RMR_FIELD_KEYS` (Degradations-Minimum): Positionsnummer + Rohstoffbezeichnung +
  Rohstoffnotierung Ro.
- **Rohstoffzuschlag-Validierung (`rmr_raw_material_surcharge`, Issue-Namensraum `rmr_*`):**
  RoZ0 = Rohstoffnotierung Ro × Bezugsgewicht, hergeleitet aus dem vollständig durchgerechneten
  14-Zeilen-Beispiel in Abbildung 26 (Leitfaden S.45) — der Leitfaden-Text selbst sagt nur „wird
  berechnet" ohne Formelsatz, die Herleitung stammt daher explizit aus den Beispielzahlen, nicht aus
  einem zitierten Formelsatz (Evidenz im Modul-Header dokumentiert). **Negative Notierungen sind
  legitim** (Steel Scrap Coil EU: Ro=-0,320, RoZ0=-0,32 — Gutschrift-Logik) und werden NICHT als Fehler
  behandelt, sondern fließen vorzeichenrichtig in die Nachrechnung ein (dedizierter Test). Gating wie
  `business-rules.ts`: Zeile wird nur geprüft, wenn RoZ0 im Excel befüllt ist; fehlende Ro/Bezugsgewicht
  bei befülltem RoZ0 → `nicht_pruefbar` mit Grund, nie 0-als-Default.
- **Bewusst NICHT gebaut (dokumentiert, nicht stillschweigend übersprungen):**
  - Cross-Sheet-Reconciliation MATERIAL↔RAW MATERIAL RISKS (Summen-Abgleich Bezugsgewicht je
    Rohstoffbezeichnung) — abweichend vom Backlog-Akzeptanzkriterium per expliziter Task-Vorgabe:
    `rawMaterialDesignation` wird nur als Kontext-Feld erfasst (wie `material-parser.ts` es für sein
    eigenes `rawMaterialDesignation`-Feld bereits tut), keine automatische Verknüpfung, kein Join, kein
    Summen-Check — TODO für einen Folge-PR.
  - Schwellwert-Preisanpassungslogik über Zeit (BQ%/SW%, Leitfaden [43]) — laut Backlog explizit „Später"
    (Vertragslogik außerhalb des reinen Angebotsvergleichs, benötigt eine historische Baseline-Notierung,
    die ein Einzeldatei-Parse strukturell nicht liefern kann).
- **Persistenz:** JSONB `qaf_file.g60_meta.rmr` (`{rows, parseMeta}`), analog `material`/`sbm` — keine
  neue Tabelle `qaf_rmr_row` in diesem PR (dieselbe Abweichung vom Backlog-Migrations-Hinweis, die
  MATERIAL/SBM bereits etabliert haben). `app/qaf-differences/actions.ts`: `ingestQafUpload` parst +
  persistiert das RMR-Sheet; `recompareComparison`s `sideOf()` rehydriert `rmrRows` aus
  `g60_meta.rmr` (KAR-899-Pfad von Anfang an mitgezogen, nicht nachgerüstet).
- **Fingerprint:** neue optionale RMR-Facette in `template-fingerprint.ts` (`TemplateFingerprintRmrInput`/
  `-Facet`, `RMR_EXPECTED_CANONICAL_IDS`), analog MATERIAL/SBM — Abwesenheit ist kein „modified"-Signal.
- **`compare.ts`:** `QafFileParsed.rmrRows` (Tri-State wie `materialRows`/`sbmRows`), `evaluateRmrValidation`
  läuft pro Datei (ALT/NEU) und speist `rmr_*`-Issues in dieselbe `qaf_plausibility_issue`-Pipeline ein —
  Severity `pruefen` (RoZ0 ist in dieser PR an keiner Kaskade/Summary-Summe angebunden), `nicht_pruefbar`
  → `hinweis`.
- **Additivität geprüft:** Dateien ohne RMR-Sheet bleiben byte-identisch (`rmrRows` bleibt `undefined`,
  `evaluateRmrValidation` liefert `[]`) — durch die komplette Bestandstestsuite (1891 vorher) plus neue
  Wiring-Tests in `compare.test.ts` verifiziert, keine Regression.
- **Tests:** `rmr-parser.test.ts` (44 Tests, u.a. alle 12 Abbildung-26-Zeilen inkl. Steel-Scrap-Vorzeichen,
  Tri-State-Degradation, `nicht_pruefbar`-Gründe), `compare.test.ts` (+4, End-to-End-Wiring inkl.
  Additivität), `template-fingerprint.test.ts` (+5, RMR-Facette). Gesamt 1891 → 1944 Tests, 0 Failures.

### Follow-up (selber Tag): Zwei-Block-Layout — Multi-Block-Parsing + Sicherheitsnetz (Adversarial-Review, Konfidenz 85)

Adversarial-Review von PR #280 bestätigte die RoZ0-Formel-Herleitung vollständig (alle 14
Abbildung-26-Zeilen inkl. der 2 Energie-Zeilen), fand aber EIN Finding: Leitfaden [44] dokumentiert
explizit **zwei gestapelte Blöcke** auf dem RMR-Sheet („Rohstoff Preisanteil Material" und „...Energie"),
die ursprüngliche Implementierung las aber nur EINEN Block (bis zur ersten Leerzeile) — mit
Leerzeilen-Trenner ging der zweite Block komplett verloren (kein Runtime-Signal, `parseConfidence` blieb
grün), ohne Trenner wurde die zweite Header-Zeile als Garbage-Datenzeile gelesen.

- **Multi-Block-Parsing (`rmr-parser.ts`):** `parseRmrWorksheet` scannt nach Ende eines Datenblocks
  weiter nach einer WEITEREN Header-Zeile (`scanForHeaderRow`, dieselbe Scoring-Regel wie die initiale
  Header-Suche) — bis zu `MAX_RMR_BLOCKS` (6, reine Sicherheitsgrenze; der Leitfaden dokumentiert genau
  2 Blöcke). Jede `RmrRow` trägt jetzt `blockType: 'material' | 'energie' | 'unbekannt'`.
- **Garbage-Row-Guard:** eine Datenzeile, deren Zellen selbst wie ein Header scoren
  (`scoreHeaderRow >= HEADER_MATCH_MIN`), wird NIE als Datenzeile gepusht — sie wird direkt als nächster
  Block-Header übernommen (kein erneuter Scan nötig). Löst den „ohne Trenner"-Fall.
- **Block-Typ-Zuordnung:** primär über den literalen Block-Titeltext („Rohstoff Preisanteil
  Energie"/„...Material", Abbildung 26) in den Zeilen zwischen vorigem Blockende und aktuellem Header
  (`detectBlockTypeFromTitle`); Fallback `applyBlockOrderFallback` NUR wenn genau 2 Blöcke gefunden
  wurden und BEIDE Titel nicht unterscheidbar waren (dokumentierte Reihenfolge-Heuristik: Energie zuerst,
  dann Material, laut Abbildung 26) — bei 1 Block (der weit überwiegende Realfall) oder 3+ Blöcken bleibt
  `blockType` konservativ `'unbekannt'`, nie geraten.
- **Block-übergreifende RoZ0-Validierung:** `validateRmrRawMaterialSurcharge` läuft UNVERÄNDERT für beide
  Blocktypen — der Energie-Block nutzt andere Einheiten-Suffixe („Energieverbrauch pro h [kWh]" statt
  „Bezugsgewicht [kg]", „Rohstoffnotierung Ro [AW/kWh]" statt „[AW/kg]", Abbildung 26 S.45), dafür wurden
  `canonical-fields.ts`s `rmr_reference_weight`/`rmr_raw_material_quotation` um dokumentierte Aliase
  ergänzt (Leitfaden-Evidenz S.45) — kein separater Energie-Validierungspfad nötig.
- **Sicherheitsnetz (`rmr_possible_unparsed_block`, neuer Issue-Namensraum):** jede noch nicht
  konsumierte, header-artige Zeile (Score-Treffer, aber degradiert ODER außerhalb des normalen
  Scan-Fensters) wird über `RmrParseMeta.possibleUnparsedBlockRow` geflaggt — NIE stillschweigend
  fallengelassen. Durchgezogen von `rmr-parser.ts` über `actions.ts` (`ingestQafUpload` persistiert
  `possibleUnparsedBlockRow` in `g60_meta.rmr.parseMeta`; `recompareComparison`s `sideOf()` rehydriert es,
  KAR-899-Pfad) bis `compare.ts` (`QafFileParsed.rmrParseMeta`, `rmrParseMetaToPlausibilityIssue`,
  Severity `pruefen`).
- **Tests:** 12 neue Fälle in `rmr-parser.test.ts` (gestapelte Blöcke MIT/OHNE Trenner, Titel- und
  Reihenfolge-basierte Block-Erkennung, Energie-Block-Alias-Mapping inkl. RoZ0-Check, beide
  Sicherheitsnetz-Pfade — degradierter Folge-Block und Block jenseits des Scan-Fensters,
  Regressionstest für den unveränderten Ein-Block-Fall), 3 neue End-to-End-Wiring-Tests in
  `compare.test.ts`. Gesamt 1944 → 1959 Tests, 0 Failures.

## 2026-07-10 — Business-Rule-Reconciliation-Engine: Leitfaden-Formeln zeilenweise nachrechnen (KAR-901/P2.2)

- **Neues Modul `lib/qaf-differences/internal/business-rules.ts`:** Abgrenzung zu `reconciliation.ts`
  (KAR-887/P0.2), das nur AGGREGATE prüft (Sigma(Zeilen-Feld) vs. Summary-Wert bzw. Summary-interne
  Kaskaden) — dieses Modul rechnet die einzelne ZEILE selbst unabhängig nach (Master-Prompt §12.5,
  „Do not trust Excel-calculated results without independent validation"). Läuft pro Datei (ALT/NEU
  getrennt), pure, keine I/O.
- **Modul-Header enthält ein vollständiges Formel-Inventar** (Formel-für-Formel mit Seiten-Evidenz aus
  `02-leitfaden-teil1.md`/`teil2.md`): welche Leitfaden-Formeln sind mit den heute geparsten Feldern
  (`QAFRowValues`, `MaterialRowValues`, `SbmRowValues`) vollständig nachrechenbar, welche nicht (mit
  Grund — fehlendes Feld, fehlende externe FX-Quelle, oder fehlende Formel-Textevidenz im Leitfaden).
- **Implementiert (8 Checks, Issue-Namensraum `rule_calc_*`):**
  - MANUFACTURING: `rule_calc_fek` (S.30, FEK[BW]), `rule_calc_fk` (S.31, FK[BW]=FEK+RFGK-Anteil),
    `rule_calc_ausschuss_fertigung` (S.29 Abb.20, Ausschusskosten Fertigung[AW]).
  - MATERIAL: `rule_calc_material_cost_per_unit_aw` (S.19, Kalkulatorische Materialkosten/ME[AW]),
    `rule_calc_material_scrap_cost` (S.17 Abb.10, Ausschusskosten Material[AW]),
    `rule_calc_raw_material_surcharge` (S.17 Abb.9, Rohstoffzuschlag RoZ0[AW]),
    `rule_calc_material_cost` (S.19, Kaufteilkosten/Rohmaterialkosten — Zweig je Zeilentyp).
  - SBM-DEVICES-FWZ: `rule_calc_sbm_tool_fixture_cost` (teil2 S.37, Summe Werkzeug-/Vorrichtungskosten[AW]).
- **Ausgelassen (dokumentiertes TODO, kein Raten):** Rohstoffzuschlag RoZ0 Energie (MANUFACTURING —
  Eingangsfelder existieren nicht in `QAFRowValues`), alle Wechselkurs-Formeln (keine unabhängige
  FX-Rate-Quelle), Fertigungskosten FK[AW] (Leitfaden nennt keine explizite Formel), SBM „Summe
  Werkzeugkosten SBM" (Redundanz zu `rule_calc_sbm_tool_fixture_cost`, gleiche Eingangsfelder), sowie
  RAW MATERIAL RISKS-Aggregation/LOGISTICS&CUSTOM/WAF-Angebotspreis (eigene Parser fehlen, außerhalb
  P2.2-Scope).
- **Zustände:** `bestanden`/`abweichung`/`nicht_pruefbar` (nie null-als-0 bei fehlenden Eingängen,
  auch nicht bei „meist leeren" Feldern wie Rückvergütung). Severity `kritisch` nur für Checks, deren
  Zielfeld ein literales `reconciliation.ts`-DETAIL_SUM-Feld ist (`ausschusskosten`, `materialCost`,
  `totalToolFixtureCostAw`), sonst `pruefen`. Toleranz aus neuer `BUSINESS_RULES_CONFIG` (0,5 % relativ
  + 1 AW/BW absolut, gleiches Fachentscheid-Muster wie `RECONCILIATION_CONFIG`), als eigene
  `businessRules`-Sektion in `engine-config.ts` (configVersion 1.1.0 → 1.2.0).
- **Anti-Spam-Gating:** ein Check wird nur ausgewertet, wenn das Zielfeld der Zeile überhaupt befüllt
  ist (sonst kein Ergebnis, nicht einmal `nicht_pruefbar`) — verhindert Dutzende Fehlalarme für Formeln,
  die für die meisten Zeilen gar nicht gelten (z.B. RoZ0 bei gewöhnlichen Kaufteil-Zeilen ohne
  Rohstoffrisikobeteiligung).
- **Formula-Engine-Synergie (KAR-900 ↔ KAR-901, reine Text-Anreicherung):** wenn eine `abweichung` bei
  einem MANUFACTURING-Feld auftritt UND für dasselbe Feld eine Formel-Änderung erkannt wurde
  (`FieldDiff.formulaFinding`), wird der Hinweistext in `compare.ts` verknüpft — „Nachrechnung weicht ab
  UND Formel wurde geändert" gilt als stärkster Manipulations-Indikator. Keine neue Kopplung der Engines.
- **Einhängung:** `compare.ts` ruft `evaluateBusinessRules` neben `checkReconciliation` auf (läuft immer
  über `steps`, unabhängig von `summaryMetrics` — bleibt für rehydrierte Vergleiche deterministisch, da
  `rehydrate.ts` `steps` bereits rekonstruiert). Persistenz über `qaf_plausibility_issue` (kein
  Schema-Change). UI-Rendering automatisch über die bestehende generische Plausibilitäts-Sektion
  (`_nicht_pruefbar`-Suffix-Erkennung in `qaf-comparison-detail.tsx` greift unverändert).
- **Tests:** `lib/qaf-differences/internal/__tests__/business-rules.test.ts` (37 Tests, synthetische
  Fixtures, u.a. Kaufteil-/Rohmaterial-Branch, RMR-Ausschluss, Gating, Toleranz, Formula-Engine-Link-Text).
  Zwei vorbestehende Tests angepasst (`compare.test.ts`: `mkRow()`-Default jetzt formel-konsistent für
  `ausschuss`/`ausschusskosten`, Rule-Engine-Namensraum-Prüfung auf `rule_r` statt `rule_` verengt, da
  `rule_calc_*` ein legitimer Schwester-Namensraum ist; `engine-config.test.ts`: configVersion-Erwartung
  auf 1.2.0 aktualisiert). Baseline 1854 → 1891 Tests, alle grün.

## 2026-07-10 — Formel-Extraktion, -Normalisierung und -Vergleich (KAR-900/P2.1)

- **Neues Modul `lib/qaf-differences/internal/formula-engine.ts`:** Bis zu diesem PR wurde im
  gesamten `lib/qaf-differences`-Baum ausschließlich der berechnete Zellwert gelesen
  (`cell.value.result`) — nirgends wurde eine Excel-Formel geparst, normalisiert oder verglichen
  (Master-Prompt §12.4 „hidden formula change must be reported even when the displayed value is
  unchanged"). `normalizeFormula` (Uppercase außerhalb String-Literalen, Whitespace raus, DE→EN-
  Funktionsnamen-Mapping via `FUNCTION_NAME_DE_TO_EN` — konservative, dokumentierte Teilmenge:
  SUMME→SUM, WENN→IF, RUNDEN→ROUND u.a., kein Anspruch auf vollständige Excel-Abdeckung),
  `stripReferenceAnchors` ($A$1→A1, nur für den Hash — die normalisierte Anzeigeform behält $-Anker),
  `formulaHash` (sha256 der Anker-bereinigten Normalform, `node:crypto`), `buildFormulaProvenance`
  ({raw, normalized, hash}), `compareFormulaPair` (4 Vergleichs-Semantiken, siehe unten) und
  `formulaFindingToPlausibilityIssue`.
- **Vergleichs-Semantiken (`FormulaComparisonKind`):** (1) Formel gleich + Wert gleich →
  `unauffaellig`, kein Befund. (2) Formel geändert (Hash weicht ab) + Wert (numerisch) unverändert →
  `formel_geaendert_wert_gleich`, Severity `pruefen` — der stille Manipulationsfall, den Master-Prompt
  §12.4 explizit fordert (überschreibt `FieldDiff.status`/`SummaryMetricDiff.status` auf neuen
  DiffStatus `formel_geaendert`, sonst würde die Bandberechnung stillschweigend „konstant" zeigen).
  (3) Formel gleich + Wert geändert → `unauffaellig` mit `inputsChanged: true` — normale
  Wert-Diff-Semantik (Status-Band unverändert), zusätzliche `formulaInputsChanged`-Kennzeichnung
  (in-memory, kein DB-Feld). (4) Formel→Konstante (Formel weg, hartcodierter Wert) →
  `formel_zu_konstante`, Severity `kritisch` (klassisches Manipulationsmuster, KAR-861) — überschreibt
  Status auf neuen DiffStatus `formel_zu_konstante`. Formel neu hinzugefügt (vorher keine) ist bewusst
  NICHT als Befund behandelt (gutartig). Fehlende Formeln auf einer/beiden Seiten (Werte-only-Zellen) →
  heutiges Verhalten, kein Rauschen.
- **Extraktion — Fertigungskosten (`lib/qaf-parser.ts`):** `parseQAFTemplate` liest jetzt zusätzlich
  `cell.formula` (ExcelJS) parallel zum bestehenden `sourceCells`/`normalized`/`rawText`-Muster (KAR-886/
  KAR-891) und befüllt `QAFRow.formulas?: Partial<Record<QAFFieldKey, FormulaProvenance>>` — additiv,
  nur für Zellen mit tatsächlicher Formel, alte Persistate bleiben gültig (Feld fehlt einfach). Da
  `qaf-parser.ts` im Browser läuft (`components/qaf/qaf-client.tsx`), wird `formula-engine.ts` per
  DYNAMISCHEM Import geladen (Bundle-Lehre #272, identisches Muster zu `canonical-model.ts`) — `node:crypto`
  bleibt außerhalb des Client-Bundles.
- **Extraktion — Summary-Kennzahlen:** `workbook-adapter.ts` bekommt `worksheetToFormulaGrid` (parallele
  Formel-Grid-Extraktion zu `worksheetToGrid`); `summary-metrics.ts`s `parseSummaryMetrics` nimmt ein
  optionales `formulaGrid`-Argument und befüllt `SummaryMetricsParse.formulas?: Partial<Record<SummaryMetricKey,
  FormulaProvenance>>` für die „berechneten" Zeilen (SUMME HERSTELLKOSTEN, GESAMTKOSTEN, ANGEBOTSBASISPREIS,
  ANGEBOTSPREIS, …). Ohne `formulaGrid` (z. B. `rehydrate.ts`s `summaryMetricsFromRows`, das aus flachen
  `qaf_summary_metric`-Zeilen ohne Formel-Spalte rekonstruiert) bleibt `formulas` `undefined` — dokumentierte
  Rehydrate-Lücke, kein Fehlverhalten.
- **Vergleich:** `differ.ts`s `diffSteps` und `summary-metrics.ts`s `diffSummaryMetrics` sind jetzt
  formel-bewusst (neuer optionaler `formulaEngineEnabled`-Parameter, Default `true`) — `nicht_anwendbar`
  (P0.6) hat Vorrang vor jeder Formel-Überschreibung. `compare.ts`s `compareQafPair` sammelt die 3
  „berichtenswerten" Kinds aus Step- und Summary-Diffs und speist sie als `qaf_plausibility_issue`-Befunde
  in die bestehende `plausibility`-Liste ein (kein neuer Persistenz-Pfad, gleicher Kanal wie
  Rekonziliation/Regel-Engine).
- **Persistenz — KEINE Migration:** `formulas` lebt in `raw_values` JSONB (`qaf_manufacturing_step`,
  über den bestehenden Provenance-Mechanismus). Der neue `DiffStatus` (`formel_geaendert`/
  `formel_zu_konstante`) persistiert unverändert in die bestehende `status TEXT`-Spalte auf
  `qaf_manufacturing_diff`/`qaf_summary_diff` (kein CHECK-Constraint). `formulaFinding`/
  `formulaInputsChanged` sind bewusst IN-MEMORY-ONLY (kein Spielraum in `qaf_manufacturing_diff`/
  `qaf_summary_diff` für weitere Spalten) — die tragende Information (Status + persistierter
  `qaf_plausibility_issue`-Befund) überlebt den DB-Roundtrip, die Zusatz-Annotation nicht.
- **EngineConfig:** neue Sektion `formulaEngine: { enabled: boolean }` (`FORMULA_ENGINE_CONFIG`,
  Default `enabled: true`), `DEFAULT_ENGINE_CONFIG.configVersion` auf `1.1.0` gebumpt (echte
  Verhaltensänderung, nicht nur Config-Refactor — P1.5s „byte-identisch"-Kriterium gilt nicht für eine
  neue Sektion). `rehydrate.ts`s `resolvePersistedEngineConfig` rekonstruiert die Sektion analog den
  bestehenden fünf.
- **ENGINE_VERSION:** neuer Key `formulaEngine: '1.0.0'` (types.ts) — Reproduzierbarkeit wie bei jedem
  anderen unabhängig versionierten Key.
- **UI:** `qaf-comparison-detail.tsx` — zwei neue DE-Labels in `DIFF_STATUS_LABEL`
  („Formel geändert" / „Formel durch Konstante ersetzt"), Aufnahme in `STRUCTURAL_STATUS` (kein
  Richtungspfeil) und neue Badge-Farben in `diffStatusClass` (gelb/pruefen bzw. rot/kritisch). Die
  Plausibilitäts-Sektion (A4) rendert die neuen `issue_type`-Werte bereits generisch über die
  bestehende Severity-basierte Badge-Logik — keine Komponentenänderung nötig.
- **Tests:** neues `lib/qaf-differences/internal/__tests__/formula-engine.test.ts` (34 Tests) +
  Erweiterungen in `qaf-parser.test.ts`, `differ.test.ts`, `summary-metrics.test.ts`,
  `workbook-adapter.test.ts`, `compare.test.ts`, `engine-config.test.ts` (65 neue Tests insgesamt,
  0 Regressionen — 1780→1845 grün).

### Nachtrag (10.07.2026) — Adversarial-Review-Fix: Shared-Formula-Slaves fehlklassifiziert

**KRITISCHER Fund (Konfidenz 88), noch am selben Tag gefixt:** Beide Extraktionsstellen
(`qaf-parser.ts`, `workbook-adapter.ts`s `worksheetToFormulaGrid`) lasen ursprünglich
`cell.value.formula` — das trägt eine Formel bei ExcelJS NUR beim MASTER einer
"Shared Formula"-Range (Copy-down-Spalten, in BMW-Fertigungskosten-Sheets Standard). Jede
SLAVE-Zelle derselben Range hat `model.formula === undefined` (verifiziert in
`node_modules/exceljs/lib/doc/cell.js`, `FormulaValue._copyModel`) und wurde dadurch
stillschweigend als "keine Formel" gelesen. Wechselt eine Zelle zwischen ALT und NEU die
interne Rolle Master↔Slave (routinemäßiger Re-Save, Zeilen-Insert, anderer Excel-Anker —
strukturell folgenlos für die Kalkulationslogik), feuerte `compareFormulaPair` fälschlich das
Flaggschiff-Signal `formel_zu_konstante`/kritisch auf einer unveränderten Datei.
- **Fix:** beide Extraktionsstellen lesen jetzt `cell.formula`/`cell.formulaType` (ExcelJS'
  Cell-Level-Getter) statt `cell.value.formula` — `cell.formula` löst Shared-Slaves über
  ExcelJS' eigene `_getTranslatedFormula()`/`slideFormula()` auf (Master-Formel um den
  Zeilen/Spalten-Offset zur Slave-Position verschoben, Ergebnis bereits positionskorrekt für
  genau diese Zelle). Zweiter, beim Fix gefundener Bug: `cell.formulaType` ist `undefined`
  (nicht `0`/`None`) für jede Nicht-Formel-Zelle (nur `FormulaValue` implementiert den Getter)
  — der ursprüngliche `=== None`-Vergleich verfehlte das, korrigiert auf `!cell.formulaType`.
- **Translation-Normalisierungs-Entscheidung (dokumentiert in `formula-engine.ts`):** da
  `compareFormulaPair` immer dieselbe logische Zellposition zwischen ALT/NEU vergleicht und
  `cell.formula` für JEDE Zelle bereits positionskorrekten Text liefert, ist KEINE zusätzliche
  zeilen-relative Normalisierung nötig — die bestehende `$`-Anker-Bereinigung reicht.
  Unterschiedliche Zeilen dürfen bewusst weiter unterschiedlich hashen (verschiedene
  Eingabezellen — kein Bug, sondern erwartetes Verhalten, per Test dokumentiert).
- **Tri-State statt `null`:** `FormulaProvenance.unresolved?: true` (neu) + `unresolvedFormulaProvenance()`
  für den seltenen Fall, dass ExcelJS eine Shared-Formula NICHT auflösen kann (Master fehlt/korrupt).
  `compareFormulaPair` behandelt das explizit wie `no_formula_data` — fällt NIE in den
  `altFormula && !neuFormula`-Zweig, der `formel_zu_konstante` auslösen würde.
- **Tests (+9):** drei neue Tests in `qaf-parser.test.ts` mit ECHTEN ExcelJS-Shared-Formula-Ranges
  (`Worksheet.fillFormula`): Master+Slave liefern beide Formeln (unterschiedliche Hashes, da
  unterschiedliche Zeilen-Refs — dokumentiert die Normalisierungs-Entscheidung), Master-in-ALT vs.
  Slave-in-NEU derselben Zelle → kein Befund, echter Hardcode (kein Shared-Range) → weiterhin
  `formel_zu_konstante`. Plus ein `worksheetToFormulaGrid`-Test mit `fillFormula` und 5
  `unresolvedFormulaProvenance`/Short-Circuit-Tests in `formula-engine.test.ts`.
- **Re-Smoke gegen das Brose-FT_links-Paar (nur Struktur):** Fertigungskosten-Formel-Zellen
  Datei A 3→**4**/4, Datei B 4→**4**/4 (unverändert, keine Slaves in den gescannten Feldern);
  Summary-Formel-Kennzahlen Datei A 14→**15**, Datei B 15→**16** — der Fix hat tatsächlich
  vorher übersehene Formeln zutage gefördert, genau wie vom Review vorhergesagt. Hash-Stabilität
  bei Doppel-Lauf weiterhin STABLE (4 Hashes je Datei verglichen). G60-Dateien unverändert:
  kein Fertigungskosten/Summary-Sheet (G60 = eigene 112-Tab-Struktur, außerhalb Scope).

## 2026-07-09 — G60-Zell-Provenance + Anker-basierte Spalten-Lokalisierung (KAR-894/P1.3)

- **Zell-Provenance:** `parseG60Workbook`/`extractTab` (`lib/qaf-differences/internal/g60/parser.ts`)
  liefern jetzt pro extrahiertem Wert die Quell-Zelladresse (`"<TabName>!W41"` etc.), additiv
  an den bestehenden Strukturen — `G60TabAggregate.sourceCells` (alle 11 row-41-MCOL-Felder:
  W/Y/Pers/Mach/AV/MfgTot/Scrap/HK/Sur/Sales/TC), `G60Step.sourceCells` (die 3 strukturell
  verankerten per-Zeile-Felder cycle/emp/machrate ↔ AO/AQ/AT) und `G60ParseResult.ratesSourceCells`
  (die 8 statischen INPUT!C22-C29-Adressen für die SG&A/Gewinn-Sätze — analog dem
  `sourceCells`-Muster aus KAR-886, Persistenz in den bestehenden `qaf_g60_tab.aggregate`-JSONB-Shapes,
  kein Schema-Change). Alle drei Felder sind optional (KAR-886-Konvention) — vor diesem PR
  persistierte Zeilen ohne Provenance werden von `rehydrate.ts` toleriert (Cast bleibt gültig,
  `ratesSourceCells` wird beim Rehydrate immer aus der statischen `G60_RATES_SOURCE_CELLS`-Konstante
  ergänzt, da diese Adressen nie vom Dateiinhalt abhängen).
- **Label-basierte Spalten-Lokalisierung mit Fix-Koordinate als Fallback:** Neue Funktion
  `locateG60ColumnAnchors` (`g60/structure-guard.ts`) erweitert die KAR-888-Anker-Prüfung
  (bislang binär: matcht die Fix-Koordinate oder nicht) um eine dritte Stufe für exakt die
  5 verifizierten Anker-Spalten (W/Y/AO/AQ/AT): matcht der Anker an der erwarteten Spalte →
  `matched`, Confidence 1.0 (unverändertes KAR-888-Verhalten); matcht er in GENAU EINER
  Nachbar-Spalte (±2) → `relocated`, Confidence 0.8 (neu, konfigurierbar über
  `G60StructureGuardConfig.relocatedConfidence`, Default `DEFAULT_RELOCATED_CONFIDENCE=0.8`),
  der Wert wird aus der verschobenen Spalte gelesen (`extractTab` bekommt dafür einen neuen,
  optionalen `columnOverrides`-Parameter) statt die Sektion als Mismatch zu markieren; matcht
  der Anker in ZWEI ODER MEHR Nachbar-Spalten (mehrdeutig) oder gar nicht → unverändertes
  KAR-888-Soft-/Hard-Mismatch-Verhalten (`validateG60TabHeader` zählt eine `relocated`-Klassifikation
  NICHT als Mismatch mehr — Verhaltensverbesserung, keine Regression, da kein bestehendes
  Test-Fixture je eine zufällige Nachbar-Übereinstimmung hatte). Die übrigen 9 Fix-Koordinaten-Spalten
  (AP/AS/AR/BC/AX/BA/AZ/AD/DB) bleiben bewusst rein koordinatenbasiert — dafür existiert keine
  verifizierte Anker-Vokabel (Spalte AS hat laut KAR-888-Modulkommentar an Zeile 41 vs. pro Zeile
  zwei unterschiedliche Bedeutungen), ein geratener Anker würde exakt den Fehler wiederholen,
  den die KAR-888-Realdatei-Verifikation korrigiert hat.
- **Confidence je Tab:** `G60StructureFinding` (KAR-888-Shape) bekommt ein neues optionales Feld
  `columnRelocations` — additiv, nicht ersetzend. Eine Tab-Confidence wird bei einer Relokation
  auf `min(bisherige Confidence, relocatedConfidence)` abgesenkt und `ok:false` gesetzt, auch wenn
  keine der 5 Anker klassisch "missing" ist — läuft automatisch in die bestehende
  `qaf_g60_tab.aggregate.structure`-JSONB-Persistenz (kein Persistenz-Code-Change nötig, da
  `persistence.ts` die Finding bereits per Spread einbettet).
- **Neuer Plausibility-Issue-Typ `g60_column_relocated`** (Severity `pruefen`), analog dem
  bestehenden `g60_structure_tab_soft_mismatch`-Muster, benennt Anker-Spalte + Ziel-Spalte +
  Offset + Confidence. `buildG60StructureIssues` emittiert ihn zusätzlich zum (ggf. weiterhin
  vorhandenen) Soft-Mismatch-Issue einer Tab.
- **PR-#276-Review-Hinweis eingelöst:** `app/qaf-differences/actions.ts` übergibt beim G60-Ingest
  jetzt explizit `DEFAULT_ENGINE_CONFIG.g60StructureGuard` an `parseG60WorkbookGuarded` (bisher
  implizit über den modul-lokalen Default von `structure-guard.ts` — beide Objekte waren zufällig
  identisch, das war "richtig durch Zufall", nicht durch Verdrahtung). Code-Kommentar stellt klar:
  G60-Parsing läuft ausschließlich INGEST-zeitig; `compareQafPair`s `engineConfig`-Overrides laufen
  später (Compare-Zeit) und erreichen diesen Pfad nie.
- **`engine-config.ts`:** `validateEngineConfig` prüft `g60StructureGuard.relocatedConfidence`
  (wenn gesetzt) auf `(0, 1]` — das Feld ist optional, sodass jedes bestehende
  `G60StructureGuardConfig`-Literal im Repo (inkl. 4 in `structure-guard.test.ts`, 4 in
  `engine-config.test.ts`) unverändert kompiliert und sich unverändert verhält.
- **`ENGINE_VERSION.g60Parser`** von `1.0.0` auf `1.1.0` gebumpt (`types.ts`).
- **Real-Datei-Verifikation (server-only, nur gelesen, nie kopiert):** Wegwerf-Smoke-Skript gegen
  beide KAR-888-Realdateien (`100_G60_DP_QAF_Basis_24_10_BMW.xlsm`,
  `20260508_BMW_QAF_G60_DP_RePricing_HO.xlsm`) bestätigt: 112/112 Kosten-Tabs geparst, 0
  ausgeschlossen, alle 5 Anker in beiden Dateien `matched` bei Offset 0 (Confidence 1.0 — die
  Relokations-Logik greift bei diesen beiden intakten Dateien erwartungsgemäß nicht), Provenance
  vollständig befüllt (`sourceCells` pro Tab/Step, statische `ratesSourceCells`), Persistenz-Shape
  (`buildG60Rows`) unverändert korrekt.
- **Szenario-Engine relocation-aware gemacht (Adversarial-Review-Finding, Konfidenz 82,
  eng begrenzter Fix auf demselben Branch):** `extractFullTab` (`parser.ts`) bekommt denselben
  optionalen `columnOverrides`-Parameter wie `extractTab` und wendet ihn auf die W/Y/AO/AQ/AT-Reads
  in `G60ComponentRow` an (übrige Spalten unverändert). `parseG60WorkbookGuarded` gibt neu
  `columnOverridesByTab: Record<string, G60ColumnOverrides>` zurück — dieselben Overrides, die
  bereits für die Aggregat-Re-Extraktion berechnet wurden, additiv am `G60GuardedParseResult`.
  `actions.ts` reicht `parsedG60.columnOverridesByTab[name]` beim `fullTabs`-Aufbau (persistiert als
  `component_rows`) jetzt an `g60ExtractFullTab` durch, statt die Overrides zu ignorieren. Ohne
  diesen Fix hätte ein relozierter Tab in der Detail-Ansicht korrekte Zahlen gezeigt (aus dem bereits
  relokations-bewussten Aggregat-Pfad), aber sobald ein User im What-if-Kalkulator einen Faktor
  anfasst, hätten `recomputeTab`/`recomputeScenario` still von der FALSCHEN (Fix-Koordinaten-)Spalte
  gerechnet — genau in den Tabs, die der Guard selbst als verdächtig geflaggt hat. Konsistenz-Tests
  (`structure-guard.test.ts`) beweisen: `component_rows` aus der relozierten Spalte stimmen exakt mit
  dem (bereits relokations-bewussten) Aggregat-/Step-Pfad überein (kein Mix von Stale-/Resolved-Werten),
  und `recomputeTab` auf den korrigierten Zeilen liefert eine vom 999-Decoy klar unterscheidbare,
  konsistente Basis.
- **Tests:** 614 → 642 lokale Tests im `qaf-differences`-Baum (28 neu: Provenance in `parser.test.ts`,
  Anker-Lokalisierung/Relokation/Ambiguität/Merge/Issue-Emission + Szenario-Konsistenz in
  `structure-guard.test.ts`, `relocatedConfidence`-Validierung in `engine-config.test.ts`), 0
  Regressionen gegen die bestehende Suite (Gesamt-Repo 1752 → 1780 Tests).
- **Offen/fragil:** Die Relokations-Logik (inkl. des Szenario-Engine-Fixes) ist ausschließlich gegen
  synthetische Fixtures verifiziert — beide verfügbaren Realdateien sind strukturell intakt (kein
  Real-Beispiel für eine tatsächlich verschobene Spalte vorhanden).

## 2026-07-09 — Zentrale versionierte Engine-Konfiguration (KAR-896/P1.5)

- **Feature:** Neues Modul `lib/qaf-differences/internal/engine-config.ts` zieht die bislang
  verstreuten Toleranz-/Materialitätskonstanten der QAF-Engine zu EINER versionierten
  `EngineConfig`-Struktur zusammen: `configVersion` + fünf benannte Sektionen
  `ruleEngine` (R1-R6 Fehlerreport-Regel-Engine, KAR-889), `reconciliation`
  (Summen-Rekonziliations-Toleranzen, KAR-887), `g60StructureGuard`
  (G60-Struktur-Anker-Toleranz, KAR-888), `differBands` (Status-Bänder für
  Feld-Deltas, ehemals hart codiert 10/25/50 % in `differ.ts`) und `matching`
  (Matching-Kaskaden-Schwellen, spec B3). `DEFAULT_ENGINE_CONFIG` referenziert
  dabei ausschließlich die bereits bestehenden Modul-Konstanten
  (`RULE_ENGINE_CONFIG`, `RECONCILIATION_CONFIG`, `G60_STRUCTURE_GUARD_CONFIG`,
  `DEFAULT_MATCH_CONFIG`, neu: `DEFAULT_DIFFER_BANDS_CONFIG`) statt sie zu
  duplizieren — kein zweiter Wahrheits-Quell.
- **Injektion (Master-Prompt §18 — Regeln versioniert/reviewbar/testbar/auditierbar):**
  Das KAR-889-Muster ("Config als expliziter Funktionsparameter mit
  bestehendem Konstanten-Default") wurde auf alle Sektionen verallgemeinert:
  `differ.ts` (`computeNumericDelta`/`computeFieldDiff`/
  `computeFieldDiffWithNotApplicable`/`diffSteps` nehmen jetzt ein optionales
  `DifferBandsConfig`) und `g60/structure-guard.ts` (`finalizeFinding`/
  `validateG60TabHeader`/`validateG60InputRates`/`validateG60Structure`/
  `parseG60WorkbookGuarded` nehmen jetzt ein optionales
  `G60StructureGuardConfig`) sind neu parametrisiert; `matcher.ts`
  (`MatchConfig`) und `rule-engine.ts`/`reconciliation.ts` waren bereits
  parametrisiert. `compareQafPair` akzeptiert neu `options.engineConfig`
  (voller `EngineConfig`) und speist daraus `matching`/`differBands`/
  `ruleEngine`/`reconciliation` in die jeweiligen Module ein — die
  bestehenden diskreten Optionen `ruleEngineConfig`/`reconciliationConfig`
  bleiben unverändert bestehen und haben Vorrang vor der entsprechenden
  `engineConfig`-Sektion (abwärtskompatibel, bestehende Call-Sites
  unverändert). `QafComparisonResult` bekommt ein neues Feld `engineConfig`
  (die tatsächlich effektive Konfiguration inkl. etwaiger diskreter
  Overrides) — `ruleEnforcement` bleibt unverändert als eigenes Feld
  bestehen (`=== engineConfig.ruleEngine.ruleEnforcement`).
- **Persistenz (nur Deltas, nicht das ganze Objekt):** `buildComparisonRowset`
  (`persistence-mapper.ts`) berechnet über die neue reine Funktion
  `buildEngineConfigDelta` einen Sektions-Delta zwischen der effektiv
  genutzten Config und `DEFAULT_ENGINE_CONFIG` und faltet
  `configVersion` + (nur bei Abweichung) `engineConfigOverrides` in
  `qaf_comparison.engine_version` — ein Vergleich unter reinem Default
  persistiert nur den Versionsstempel, kein Objekt-Bloat. Das bestehende
  `ruleEnforcement`-Feld (KAR-889) bleibt zusätzlich erhalten (Altleser-
  Kompatibilität).
- **Rehydrate/Export (Reproduzierbarkeit):** `resolvePersistedRuleEnforcement`
  (KAR-889) ist jetzt ein dünner Wrapper um die neue, generalisierte
  `resolvePersistedEngineConfig` (`rehydrate.ts`), die aus
  `qaf_comparison.engine_version` die damals wirksame `EngineConfig`
  rekonstruiert (persistierte `engineConfigOverrides` je Sektion, sonst
  aktueller `DEFAULT_ENGINE_CONFIG`-Wert; legacy-`ruleEnforcement`-Key aus
  KAR-889-Ständen wird weiterhin korrekt aufgelöst). `rehydrateExportInput`
  ruft `compareQafPair` jetzt mit dem vollen rekonstruierten `engineConfig`
  statt nur mit `ruleEngineConfig` auf — ein Export reproduziert damit auch
  historische `differBands`/`reconciliation`/`matching`-Overrides korrekt,
  nicht mehr nur den Regel-Modus.
- **Statische Validierung:** Neue pure Funktion `validateEngineConfig`
  (Bänder aufsteigend, Toleranzen positiv/nicht-negativ,
  `ruleEnforcement ∈ {'warn','block'}`, Matching-Schwellen in `[0,1]`,
  G60-Guard-Schwellen strukturell plausibel) + vollständige Testabdeckung.
- **`ENGINE_VERSION`** (`types.ts`) bekommt einen neuen `configVersion`-Key
  (Code-Versionsstempel, kein Parser-Bump) — bewusst nicht per Import aus
  `DEFAULT_ENGINE_CONFIG.configVersion` abgeleitet (zirkuläre Abhängigkeit),
  sondern manuell synchron gehalten wie alle anderen Versions-Keys in diesem
  Objekt.
- **Zwei bindende Kais-Fachentscheide** (KAR-896-Kommentar, 10.07.2026 — im
  Config-Code an ihrer jeweiligen Quelle dokumentiert):
  1. `ruleEngine.ruleEnforcement` bleibt **dauerhaft** `'warn'` im Default —
     R2 (Pflichtfeld-Blockade) blockiert produktiv nie automatisch. `'block'`
     bleibt als gültiger Options-Wert (Typ + `validateEngineConfig`)
     bestehen, wird aber ohne neue explizite Fachfreigabe nie Default (siehe
     `rule-engine.ts`).
  2. Rekonziliations-Toleranz `relativeTolerance: 0.005` (0,5 %) +
     `absoluteToleranceMinor: 1` (1 AW) ist fachlich freigegeben als
     **Startwert**, nicht als endgültige Zahl (siehe `reconciliation.ts`).
- **Bewusst NICHT Teil dieses PRs** (operator-gated, siehe P1.5-Backlog
  "Read-Pfad zunächst"): keine DB-Tabelle `qaf_engine_config`, keine
  UI-Settings-Seite — Folge-Issue-Kandidat für die tatsächliche
  Operator-Editierbarkeit. Der Wert dieses PRs ist ausschließlich eine
  Stelle, ein Versionsstempel, saubere Injektion, Reproduzierbarkeit.
- **Regressions-Null:** `npm test` läuft nach diesem PR mit identischer
  Pass-Zahl der Vorher-Suite plus rein additiven neuen Tests (differ.ts,
  g60/structure-guard.ts, neues engine-config.test.ts, compare.ts,
  persistence-mapper.ts, rehydrate.ts) — Verhalten bei Default bleibt
  byte-identisch.
- **Dateien:** `lib/qaf-differences/internal/engine-config.ts` (neu),
  `lib/qaf-differences/internal/differ.ts`,
  `lib/qaf-differences/internal/g60/structure-guard.ts`,
  `lib/qaf-differences/internal/compare.ts`,
  `lib/qaf-differences/internal/persistence-mapper.ts`,
  `lib/qaf-differences/internal/rehydrate.ts`,
  `lib/qaf-differences/internal/types.ts`,
  `lib/qaf-differences/internal/rule-engine.ts`,
  `lib/qaf-differences/internal/reconciliation.ts`,
  `lib/qaf-differences/index.ts` + zugehörige Test-Dateien.

## 2026-07-09 — Rekonziliations-/Regel-Befunde bei Datei-Ersetzung erneuern + Detail-Zeilen-Rehydrierung (KAR-899)

- **Bug (PR #273 Review-Empfehlung):** `recompareComparison` (`app/qaf-differences/actions.ts`)
  schrieb `qaf_plausibility_issue` bewusst NIE neu — `REPLACED_TABLES` schloss
  die Tabelle mit dem Kommentar "not matching-dependent" aus, das frisch
  berechnete `result.plausibility` wurde verworfen. Für Pin-Recompare/Rollen-
  Swap ist das korrekt (Befunde hängen nicht vom Matching ab). Aber
  `replaceComparisonFile` tauscht eine ganze DATEI aus und ruft danach nur
  `recompareComparison` — die angezeigten Befunde (fk_detail_sum, scrap-,
  material-, sbm_detail_sum, Kaskaden, R-Regeln, Struktur) reflektierten nach
  einem Datei-Austausch weiterhin die ALTE Datei, potenziell dauerhaft.
- **Fix Teil 1 (Staleness):** `recompareComparison` bekommt einen neuen
  Parameter `refreshPlausibility?: boolean` (default `false` — heutiges
  Verhalten für Pin-Recompare/Rollen-Swap unverändert, byte-identisch).
  `replaceComparisonFile` ruft mit `refreshPlausibility: true` — die frisch
  berechneten `result.plausibility`-Issues ersetzen die persistierten im
  selben insert-first/delete-old-Zug wie die anderen Replaced-Tables. Die
  Tabellen-Auswahl ist als reine, unit-testbare Funktion
  `recompareReplacedTables(refreshPlausibility)` in `persistence-mapper.ts`
  ausgelagert (actions.ts bleibt dünne DB-Glue). Audit-Log-Detail um
  `refreshPlausibility`/`plausibilityIssuesReplaced` erweitert.
- **Fix Teil 2 (Detail-Zeilen-Rehydrierung):** `recompareComparison`s `sideOf`
  rehydriert jetzt `materialRows`/`sbmRows` aus dem persistierten
  `qaf_file.g60_meta.material`/`.sbm` JSONB (statt sie implizit `undefined`
  zu lassen) — sonst wäre `material_detail_sum`/`sbm_detail_sum` bei JEDEM
  Recompare/Replace komplett übersprungen worden (nicht mal als
  `nicht_pruefbar`), unabhängig vom tatsächlichen Parse-Zustand der Datei.
  Neue reine Ableitungsfunktionen `materialRowsFromPersistedMeta()`
  (`material-parser.ts`) / `sbmRowsFromPersistedMeta()` (`sbm-parser.ts`)
  spiegeln die `coreFieldsFound`-Tri-State-Semantik aus 4d158f6 (degradiert
  persistiert → `null` bleibt `null`, nie eine leere/unvertrauenswürdige
  Zeilen-Liste) und delegieren an die bestehenden
  `materialRowsForReconciliation`/`sbmRowsForReconciliation` als
  Single-Source-of-Truth, damit Live-Parse- und Rehydrierungs-Pfad nicht
  auseinanderlaufen. Historische Dateien ohne die JSONB-Keys (vor
  KAR-897/898) ergeben `undefined` ("nie geparst") statt eines Absturzes.
- **Fix Teil 3 (Nachtrag, Adversarial-Review Konfidenz 90):** dieselbe
  Staleness-Lücke traf auch die `parser_degraded_manufacturing_headers`-
  Befundklasse (Fertigungskosten-Header-Degradation, KAR-893/P1.2) —
  `compareQafPair` emittiert dieses Issue nur wenn `alt`/`neu.manufacturing-
  ParseMeta` gesetzt ist (`compare.ts`), dieses Feld wurde aber NUR beim
  Live-Ingest befüllt, nie persistiert (anders als material/sbm) und von
  `sideOf()` nie rehydriert. Vor diesem PR harmlos (Issues wurden nie
  angefasst) — mit `refreshPlausibility` löscht der Delete-Zug jetzt auch
  persistierte `parser_degraded_manufacturing_headers`-Befunde, die der
  frische Compare strukturell NIE regenerieren konnte: aus sichtbar-aber-
  stale wurde still-und-permanent-fehlend. Fix: `manufacturingParseMeta`
  wird jetzt zusätzlich in `qaf_file.g60_meta.manufacturingParseMeta`
  persistiert (Ingest, `actions.ts`) und in `sideOf()` rehydriert —
  historische Dateien ohne den Key bleiben `undefined` (kein Fabrizieren,
  kein Absturz), exakt wie vor diesem Fix.
- **Determinismus:** Export-Pfad (`rehydrateExportInput` mit
  `plausibilityOverride`) bleibt unverändert — `rehydrate.ts` wurde nicht
  angefasst. `ENGINE_VERSION` NICHT gebumpt: es ändert sich keine Parse-
  /Vergleichslogik, nur die Persistenz-Politik (wann ein bereits korrekt
  berechnetes Ergebnis auch geschrieben wird).
- Tests: 18 neue, komplett auf den reinen Bausteinen (`compareQafPair`,
  `buildComparisonRowset`, `recompareReplacedTables`,
  `materialRowsFromPersistedMeta`/`sbmRowsFromPersistedMeta`, plus 3
  Regressionstests für `manufacturingParseMeta`-Überleben nach Refresh) —
  `actions.ts` bleibt laut Modul-Header DB-gebundenes Glue-Skip für
  tdd-guard. Baseline 1690 → 1708 grün (0 Regressionen).

## 2026-07-09 — SBM-Parser: Degraded-Header-Tri-State-Fix (Adversarial-Review, KAR-898 Follow-up)

- **Finding (Konfidenz 82):** `sbmParsed ? [...sbmParsed] : null` in `actions.ts` war falsch —
  `SbmParseResult`/`MaterialParseResult` sind Arrays; ein degradierter, nicht
  vertrauenswürdiger Header (`coreFieldsFound: false`) liefert ein LEERES
  (truthy!) Array zurück, kein `null`. Dadurch wurde der dokumentierte
  tri-state-Vertrag (`null` = kein Sheet ODER Header zu degradiert) im echten
  Ingest-Pfad für den Degradations-Fall unerreichbar. Knock-on:
  `sbm_detail_sum`s bewusste "beide Seiten leer -> bestanden"-Abkürzung
  behandelte diesen fälschlich als "confirmed empty", wodurch ein SBM-Sheet
  MIT echten Kosten-Zeilen, dessen Header unter `HEADER_MATCH_MIN` fällt, bei
  leerem `summary.devicesAndTools` fälschlich als sauber rekonziliiert wurde
  statt `nicht_pruefbar`.
- **Fix:** neue reine Ableitungs-Funktionen `sbmRowsForReconciliation()`
  (`sbm-parser.ts`) und `materialRowsForReconciliation()` (`material-parser.ts`)
  als Single-Source-of-Truth für die tri-state-Ableitung (`coreFieldsFound`
  entscheidet null vs. Array) — `actions.ts` nutzt jetzt beide statt der
  naiven Ternary. `reconciliation.ts`s `evaluateSbmDetailSumCheck` wurde so
  umgebaut, dass `sbmRows === null` **immer** `nicht_pruefbar` liefert, auch
  wenn `summary.devicesAndTools` leer ist — die "beide Seiten leer ->
  bestanden"-Abkürzung greift jetzt ausschließlich für ein CONFIRMED-leeres
  Array (Sheet erfolgreich geparst, Header intakt, echte 0 Zeilen), nie für
  `null`. Für MATERIAL ändert der Fix nur die Fehlertext-Genauigkeit (Status
  war schon vorher in beiden Fällen `nicht_pruefbar`, latente aber bisher
  harmlose Konflation).
- Tests: 8 neue (4 `sbm-parser.test.ts`: Unit + End-to-End
  `parseSbmSheet` → `sbmRowsForReconciliation` → `checkReconciliation`; 4
  `material-parser.test.ts`: analog für MATERIAL), 2 bestehende
  `reconciliation.test.ts`-Tests korrigiert (Erwartung `bestanden` →
  `nicht_pruefbar` für `sbmRows: null` + leeres `devicesAndTools`).
  Gesamt-Suite 1682 → 1690 grün, keine Regression.

## 2026-07-09 — SBM-DEVICES-FWZ-Sheet-Parser mit kanonischem Modell (KAR-898 / P1.7)

- **Neues Modul** `lib/qaf-differences/internal/sbm-parser.ts`: eigenständiger
  Parser für das QAF-Detaillierungsblatt SBM-DEVICES-FWZ (Sonderbetriebsmittel/
  Vorrichtungen/Folgewerkzeuge, Leitfaden Teil 2 S.34-38), 1:1 nach dem in
  KAR-897/#273 etablierten MATERIAL-Muster gebaut — kanonisches Modell via
  `byModule('SBM')` per dynamischem `import()` (Modul-Level-Promise-Cache,
  keine Registry im Client-Bundle), Label-Anker-Header-Erkennung mit
  Exact/Normalized-Confidence, Degradations-Pfad (kein Crash bei unsicherem
  Header). 35 Felder — vollständig aus dem bereits vorhandenen
  `canonical-fields.ts` SBM-Modul (KAR-892) übernommen, jetzt zusätzlich per
  neuem `SBM_FIELD_KEY_TO_CANONICAL`-Backward-Mapping angebunden.
- **Kern-Feld-Minimum** (Degradations-Gate): Positionsnummer + Werkzeug-/
  Vorrichtungsart + Summe Werkzeug-/Vorrichtungskosten [AW]. Unterschrittenes
  Minimum wirft **keinen Fehler** (additives, optionales Blatt) — stattdessen
  `coreFieldsFound: false` + leere Zeilenliste.
- **Verrechnungsform-Domäne** (Leitfaden [34]/[37]): `SBM_VERRECHNUNGSFORM_VALUES`
  als literal-typisiertes 5-Werte-Array (`SBM`, `Vorrichtung`, `Folgewerkzeug`,
  `Folgeeinsatz`, `Werkzeuginstandhaltung`) statt Freitext-Rätselraten.
- **Betriebsmittelkategorie-Klassifikation** (Leitfaden Teil 3 S.85-90):
  `SBM_DEVICE_CATEGORY_TABLE` transkribiert die vollständige
  Werkzeugart→SBM/Folgewerkzeug/Vorrichtung-Zuordnungstabelle (90 einzelne
  Werkzeugart-Einträge über ~30 Fertigungsprozess-Gruppen — mehr als die im
  Backlog-Item geschätzten "~40", die gemessene Tabellengröße wird ehrlich
  berichtet statt auf die Schätzung gekürzt). `classifySbmToolDeviceType()`
  ist reine Referenzdaten-Lookup (nicht blockierend, Phase-2-Folgeitem laut
  Backlog); eine unbekannte Werkzeugart erzeugt **keinen Fehler**, sondern
  `needsReview: true` als Prüfen-Hinweis im geparsten Zeilenergebnis
  (`SbmRow.deviceClassification`).
- **Positionsnummer-Zuordnung zu MANUFACTURING bewusst NICHT automatisiert**
  (Leitfaden [33]: "NICHT zwangsläufig 1:1 zeilenidentisch ... manuell/logisch
  zugeordnet"): Positionsnummer wird als reines Datenfeld erfasst, keine
  Zeilenindex- oder Matching-Logik gegen `QAFRow` in diesem PR.
- **Provenienz ab Tag 1:** `sourceCells`/`normalized`/`rawText` werden beim
  Parsen sofort mitgeführt, identisch zum MATERIAL-Parser-Muster.
- **Integration additiv** (`app/qaf-differences/actions.ts` `ingestQafUpload`):
  SBM-DEVICES-FWZ-Sheet wird zusätzlich geparst, wenn vorhanden — Dateien ohne
  SBM-Sheet bleiben exakt wie vor diesem PR (Steps/Summary/MATERIAL/Persistenz
  unverändert).
- **Persistenz-Entscheidung (bewusst interim, wie KAR-897):** kein neues
  `qaf_sbm_row`-Tabellen-Design/Migration in diesem PR — geparste Zeilen liegen
  vorerst als JSONB an `qaf_file.g60_meta.sbm` (`rows` + `parseMeta`), analog
  dem bestehenden `g60_meta.material`/`templateFingerprint`-Muster. Ein
  `g60_meta`-Rename bzw. eine dedizierte Spalte als Sammel-Punkt für alle
  Detail-Sheet-JSONB-Bags steht als P1-Phasenabschluss-Punkt aus (nicht in
  diesem PR gelöst).
- **Rekonziliation erweitert** (`internal/reconciliation.ts`, löst das in
  KAR-887 dokumentierte P1.7-TODO auf): neuer Check `sbm_detail_sum` (Σ
  SBM-Zeilen `totalToolFixtureCostAw` [AW] vs. Summary `devicesAndTools`),
  tri-state via `ReconciliationInput.sbmRows` identisch zu `materialRows`
  (`undefined`/`null`/Array). AW-Währungsdimension: Summierung wird bei
  abweichender `quotationCurrency` gegenüber der Summary-Währung verweigert
  (`nicht_pruefbar`, "fkAW-Lehre"). **Eine bewusste Abweichung vom
  `material_detail_sum`-Verhalten:** sind BEIDE Seiten leer (kein SBM-Sheet/
  keine Zeilen UND Summary `devicesAndTools` fehlt oder ist 0), ist das
  Ergebnis `bestanden` (0 == 0, "unauffällig"), NICHT `nicht_pruefbar` — die
  meisten Teile haben schlicht keine Sonderbetriebsmittel, ein
  `nicht_pruefbar`-Hinweis auf jedem normalen Vergleich wäre False-Positive-
  Spam. Der bekannte Stale-Befund-Bug aus KAR-899 wird hier bewusst NICHT
  angefasst (separates Issue, gleiches Verhalten wie `material_detail_sum`
  beibehalten).
- **Template-Fingerprint erweitert** (`internal/template-fingerprint.ts`): neue
  SBM-Facette (`TemplateFingerprintSbmInput`/`...Facet`, 35 erwartete
  canonical IDs) analog MATERIAL; optional auf `TemplateFingerprintInput`,
  Abwesenheit einer SBM-Facette ist kein "modified"-Signal.
- **Vergleich/Diff der SBM-Zeilen zwischen ALT/NEU bewusst NICHT Teil dieses
  PRs** — reines Parsen + Persistieren + Rekonziliieren + Fingerprint, genau
  wie beim MATERIAL-Parser abgegrenzt.
- Tests: 50 neue (33 sbm-parser.ts, 10 reconciliation.ts, 5
  template-fingerprint.ts, 2 compare.ts), Gesamt-Suite 1632 → 1682 grün, keine
  Regression.

## 2026-07-09 — MATERIAL-Sheet-Parser mit kanonischem Modell (KAR-897 / P1.6)

- **Neues Modul** `lib/qaf-differences/internal/material-parser.ts`: eigenständiger
  Parser für das QAF-Detaillierungsblatt MATERIAL (Leitfaden S.15-28), gebaut
  nach exakt dem in KAR-893/#272 etablierten Muster — kanonisches Modell via
  `byModule('MATERIAL')` per dynamischem `import()` (Modul-Level-Promise-Cache,
  keine Registry im Client-Bundle), Label-Anker-Header-Erkennung mit
  Exact/Normalized-Confidence, Degradations-Pfad. 28 Felder (Positionsnummer,
  Teilebenennung, Benennung Rohmaterial/Kaufteil, Mengeneinheit, Kaufteil-/
  Rohmaterialkostenrechnung, Rohstoffrisikobeteiligung RoZ0, Ausschusskosten
  Material, ...) — vollständig aus dem bereits vorhandenen `canonical-fields.ts`
  MATERIAL-Modul (KAR-892) übernommen, kein neues Feldregister nötig.
- **Kern-Feld-Minimum** (Degradations-Gate): Positionsnummer + Benennung
  Rohmaterial/Kaufteil + Kalkulatorische Materialkosten [AW]. Anders als der
  Fertigungskosten-Parser wirft ein unterschrittenes Minimum **keinen Fehler**
  (MATERIAL ist ein optionales, additives Blatt — ein kaputter MATERIAL-Header
  darf den gesamten Datei-Ingest nicht zum Scheitern bringen); stattdessen
  `coreFieldsFound: false` + leere Zeilenliste.
- **"Mengeneinheit"-Label-Kollision aufgelöst:** die im Katalog dokumentierte
  BMW-Quelldaten-Dopplung (Spalte "Mengeneinheit" erscheint zweimal — einmal
  echt, einmal als Fehlbeschriftung der MGK-Spalte) wird per `collisionGroup`
  + Spalten-Reihenfolge deterministisch aufgelöst statt die zweite Spalte die
  erste stillschweigend zu überschreiben.
- **Rohstoffrisikobeteiligung (RMR-Split, Leitfaden S.17):** Zeilen mit
  identischer Positionsnummer (fixer + variabler Materialkostenanteil,
  Mengeneinheit `Raw_material_risk_(kg)`) werden als eigenständige Zeilen
  geparst und über `groupMaterialRowsByPosition()` als EINE logische Einheit
  gruppiert (`isRawMaterialRiskSplit`), nicht als Duplikat-Fehler gemeldet.
  `materialRowLogicalKey()` liefert den logischen Schlüssel (Positionsnummer +
  Materialbezeichnung + ggf. Rohstoffbezeichnung, Master-Prompt §11) für einen
  künftigen Matcher.
- **Provenienz ab Tag 1:** `sourceCells`/`normalized`/`rawText` werden beim
  Parsen sofort mitgeführt (kein Nachrüsten wie bei KAR-886/891).
- **Integration additiv** (`app/qaf-differences/actions.ts` `ingestQafUpload`):
  MATERIAL-Sheet wird zusätzlich geparst, wenn vorhanden — Dateien ohne
  MATERIAL-Sheet bleiben exakt wie vor diesem PR (Steps/Summary/Persistenz
  unverändert).
- **Persistenz-Entscheidung (bewusst interim):** `qaf_manufacturing_step` ist
  NICHT sheet-agnostisch genug (Spalten `process_name`/`machine_name` sind
  MANUFACTURING-spezifisch, kein Modul-Diskriminator) — statt einer neuen
  Tabelle `qaf_material_row` (+ Migration + RLS) werden die geparsten Zeilen
  vorerst als JSONB an `qaf_file.g60_meta.material` abgelegt (`rows` +
  `parseMeta`), analog dem bereits etablierten `g60_meta`-Muster für
  `templateFingerprint`. DB-Design für Detail-Sheet-Tabellen ist bewusst
  ausgeklammert (P1.7-Review oder eigenes Migrations-KAR).
- **Rekonziliation erweitert** (`internal/reconciliation.ts`, löst das in
  KAR-887 dokumentierte TODO auf): neuer Check `material_detail_sum` (Σ
  MATERIAL-Zeilen `materialCost` vs. Summary `materialCosts`), tri-state via
  `ReconciliationInput.materialRows` — `undefined` (nicht attempted) lässt den
  Check komplett weg (0-Regression für alle Alt-Aufrufer/rehydrate), `null`
  (Sheet fehlt/zu degradiert) liefert `nicht_pruefbar` mit Grund, ein Array
  wird summiert.
- **Template-Fingerprint erweitert** (`internal/template-fingerprint.ts`): neue
  MATERIAL-Facette (`TemplateFingerprintMaterialInput`/`...Facet`, 28 erwartete
  canonical IDs) analog MANUFACTURING; optional auf `TemplateFingerprintInput`
  (kein Zwang zur Anpassung der ~11 bestehenden Call-Sites), Abwesenheit einer
  MATERIAL-Facette ist kein "modified"-Signal.
- **Vergleich/Diff der MATERIAL-Zeilen zwischen ALT/NEU bewusst NICHT Teil
  dieses PRs** — reines Parsen + Persistieren + Rekonziliieren + Fingerprint;
  ein Matcher, der die RMR-Split-Gruppierung versteht, ist ein Folgeschritt.
- Tests: 45 neue (29 material-parser.ts, 9 reconciliation.ts, 5
  template-fingerprint.ts, 2 compare.ts), Gesamt-Suite 1587 → 1632 grün, keine
  Regression.

## 2026-07-09 — Fertigungskosten-Parser: canonical-model-Import dynamisch (Client-Bundle-Leak, KAR-893 Adversarial-Review-Fix)

- **Finding (Konfidenz 82):** `lib/qaf-parser.ts` importierte `canonical-model.ts`/
  `canonical-fields.ts` statisch. Da `components/qaf/qaf-client.tsx`
  (`'use client'`) `parseQAFTemplate` direkt zur Laufzeit aufruft, landete die
  komplette ~3.7k-Zeilen-Registry (alle 12 Module, obwohl nur 22
  MANUFACTURING-Felder genutzt werden) im Client-Bundle von `/project/[id]/qaf`
  und `/lsc-workshop/[id]/qaf` — Bruch mit der in derselben Datei bereits
  etablierten Konvention (`exceljs` per dynamischem `await import()`, Kommentar
  „keep it out of client bundles").
- **Fix:** `loadManufacturingRegistry()` — dynamischer `import()` von
  `canonical-model.ts`/`canonical-fields.ts`, gecacht als Modul-Level-Promise
  (lädt nur einmal pro Prozess). `matchHeaderColumn`/`findHeaderRow` sind jetzt
  async; `parseQAFTemplate` lädt die Registry einmal und reicht sie an einen
  synchronen `matchHeaderColumnSync`-Helper durch (kein Await pro Header-Zelle).
- **Bundle-Verifikation (`next build` + `page_client-reference-manifest.js`):**
  vor dem Fix listete der Client-Reference-Manifest von `/project/[id]/qaf`
  den Registry-Chunk explizit; nach dem Fix taucht dieser Chunk in keinem der
  beiden QAF-Seiten-Manifeste mehr auf (Registry wird nur noch lazy zur
  Laufzeit nachgeladen).
- Kleinigkeit: Kommentar in `qaf-client.tsx` ergänzt — `parsed_data` (JSON via
  Supabase-Insert) trägt die `QAFParseResult`-Array-Props
  (`parseConfidence`/`unmappedHeaders`/`mappedFieldCount`) bewusst NICHT mit
  (Array-JSON-Serialisierung droppt Nicht-Index-Properties).
- Tests: alle bestehenden Call-Sites von `findHeaderRow`/`matchHeaderColumn`
  auf `await` umgestellt (35 Tests in `qaf-parser.test.ts` weiterhin grün);
  Gesamt-Suite unverändert 1587 grün, keine Regression.

## 2026-07-09 — Fertigungskosten-Parser: kanonische Label-Anker + Confidence statt Exact-Match-Only (KAR-893 / P1.2)

- **Problem:** `lib/qaf-parser.ts` erkannte Spaltenheader bislang nur per
  exaktem String-Match gegen 44 fest verdrahtete DE/EN-Formulierungen
  (`HEADER_TO_KEY`). Ein unbekanntes Template (abweichende Formulierung,
  Tippfehler, dritte Sprache) scheiterte komplett — unter `HEADER_MATCH_MIN=5`
  Treffern warf der Parser einen harten Fehler ohne Degradationspfad, auch
  wenn die eigentlich relevanten Spalten (Position, Prozess, Fertigungskosten)
  vorhanden gewesen wären.
- **Fix:** Header-Erkennung geht jetzt über das kanonische Feldmodell
  (`canonical-model.ts`/`canonical-fields.ts`, KAR-892/P1.1) statt über die
  alte exakte Wörterbuch-Suche — `matchHeaderColumn()` prüft pro Spalten-Header
  zuerst einen exakten Treffer gegen die 22 MANUFACTURING-Canonical-Felder
  (Confidence 1.0, byte-identisch zum alten Verhalten für alle 44 bekannten
  Strings), dann einen normalisierten Treffer über `findByAlias()`
  (Case-/Umlaut-Folding, Fußnoten-/Nummerierungs-Stripping, Confidence 0.9),
  bewusst konservativ ohne Fuzzy-Substring-Raten. `HEADER_TO_KEY` bleibt als
  Legacy-Dictionary exportiert (Tests/Doku referenzieren es), ist aber nicht
  mehr die Matching-Quelle zur Laufzeit — alle 44 Strings existieren bereits
  verbatim als `labelDe`/`labelEn` im MANUFACTURING-Modul, keine neuen Aliase
  nötig.
- **Degradationspfad statt hartem Fehler:** `parseQAFTemplate()` liefert jetzt
  `QAFParseResult` — additive `parseConfidence`/`unmappedHeaders`/
  `mappedFieldCount` auf dem weiterhin normalen `QAFRow[]`-Array (kein
  Wrapper-Objekt, keine bestehenden Call-Sites brechen). Unterhalb der neuen
  benannten Konstante `CORE_MANUFACTURING_FIELD_KEYS`
  (Positionsnummer/Prozessbezeichnung/Fertigungskosten FK [BW]) wirft der
  Parser weiterhin einen Fehler, der die fehlenden Kernfelder namentlich
  nennt; darüber läuft der Parse mit dem was da ist, fehlende Felder bleiben
  `null` (bestehendes Default-Shape).
- **Downstream unverändert korrekt, keine Code-Änderung nötig:**
  `rule-engine.ts` (R2) und `reconciliation.ts` (`fk_detail_sum`) behandeln
  ein genuin `null`-Feld bereits heute korrekt (Pflichtfeld-Blockade
  statt Crash, `nicht_pruefbar` statt falscher Summe) — neuer
  Integrationstest `parser-degradation.test.ts` führt einen echten
  degradierten Parse durch beide Konsumenten, nicht nur synthetische
  QAFRow-Objekte.
- **Neue Plausibility-Issue-Bridge:** `manufacturingParseDegradationToPlausibilityIssue()`
  (`plausibility.ts`) meldet unmapped Headers als `parser_degraded_manufacturing_headers`
  (Severity `pruefen`), verdrahtet durch `QafFileParsed.manufacturingParseMeta`
  → `workbook-adapter.ts`/`actions.ts` (Ingest-Pfad) → `compare.ts`. Rehydrierte
  historische Vergleiche bleiben unberührt (Feld optional).
- **Header-Zeilen-Suche:** `findHeaderRow()` nutzt jetzt dieselbe
  `matchHeaderColumn()`-Logik (exakt + normalisiert zählen zum Score), kann
  dadurch jetzt auch degradierte Templates lokalisieren, was vorher
  strukturell unmöglich war. Mehrfach-Kandidaten-Auflösung unverändert und
  jetzt explizit dokumentiert: meiste Treffer gewinnt, bei Gleichstand die
  niedrigste Zeile (Loop ersetzt `bestIdx` nur bei strikt `score > bestScore`).
- `ENGINE_VERSION.parser` 1.1.0 → 1.2.0.
- Tests: 22 neue Fälle (`lib/__tests__/qaf-parser.test.ts` +17,
  `plausibility.test.ts` +2, neue `parser-degradation.test.ts` +3) —
  Gesamt-Suite 1565 → 1587 grün, keine Regression.

## 2026-07-09 — Template-Fingerprint: SUMMARY-Coverage auf Struktur- statt Werte-Präsenz umgestellt (KAR-895 Adversarial-Review-Fix)

- **Finding (Konfidenz 85):** die SUMMARY-Facette maß Coverage über
  `summaryMetrics.metrics[k].value !== null` — eine Kennzahl zählte nur als
  "gefunden", wenn die Datei dort tatsächlich einen Wert eingetragen hatte.
  13 der 19 Summary-Kennzahlen sind aber optional und bei realen Angeboten
  legitim leer (kein Zoll-Durchgriff, keine Einmalzahlungen, kein
  Energie-Preisanteil …). Mit `knownCoverageThreshold=1.0` wurde damit
  praktisch jede normale Datei zu `modified` + `pruefen`-Issue klassifiziert
  — systematisches Rauschen, eine legitime Datenlücke wurde als
  Struktur-Abweichung fehlinterpretiert. Die MANUFACTURING-Facette maß schon
  vorher korrekt über Spalten-Header-Erkennung, unabhängig vom Zellwert.
- **Fix:** neue reine Funktion `summaryLocatedKeys(metrics)` in
  `template-fingerprint.ts` — eine Kennzahl gilt als strukturell vorhanden,
  wenn `confidence > 0` (labelMatch=1, fixedRow-Fallback=0.6, aggregate aus
  einem lokalisierten Anker), unabhängig davon ob der resultierende Wert
  `null` ist. `labelCollision`/nicht gefunden (`confidence 0`) bleibt korrekt
  "nicht vorhanden". Feld `TemplateFingerprintSummaryInput.populatedMetricKeys`
  → `locatedMetricKeys` umbenannt (Semantik-Klarheit); `actions.ts` ruft jetzt
  `summaryLocatedKeys(summaryMetrics.metrics)` statt dem Werte-Filter.
- **Neue Tests** (33 statt 27 in `template-fingerprint.test.ts`): 4 Unit-Tests
  für `summaryLocatedKeys` (labelMatch trotz `value:null` zählt, fixedRow
  0.6 zählt, labelCollision zählt nicht, populierter Wert bei confidence 0
  zählt trotzdem nicht) + 2 End-to-End-Klassifikations-Tests — (a) strukturell
  intakte V9-Datei mit 6 legitim leeren optionalen Kennzahlen → `known` (der
  Alltagsfall, den der Fix behebt), (b) 4 tatsächlich nicht lokalisierte
  Labels (labelCollision) → `modified` bleibt korrekt erkannt.
- **Tests gesamt:** 1559 → 1565 (+6, 0 Regressionen).

## 2026-07-09 — Template-Fingerprint + known/modified/unknown-Klassifikation (KAR-895, P1.4)

- **Neues Modul** `lib/qaf-differences/internal/template-fingerprint.ts` (+ 27
  Tests in `__tests__/template-fingerprint.test.ts`, + 2 neue Tests in
  `workbook-adapter.test.ts` für den neuen Helper `summarizeWorkbookSheets`):
  deterministischer struktureller + semantischer Fingerprint je geparster
  QAF-Datei — Sheet-Namen/-Dimensionen, Summary-Template-Variante + via Alias
  gefundene canonical SUMMARY-Kennzahlen, erkannte MANUFACTURING-Spalten
  (aus KAR-886 `sourceCells`-Provenance, ohne `qaf-parser.ts` anzufassen),
  G60-Struktur-Guard-Ergebnis (Tab-Anzahl, INPUT-Ratenkarte, Soft-/Hard-
  Mismatches). Stabiles sortiertes JSON + sha256-Hash (`node:crypto`,
  deterministisch: Objekt-Keys rekursiv sortiert vor dem Hashen).
- **Bekannte Profile als Konstanten** (keine DB-Tabelle in diesem PR — P5):
  `QAF_V9_SUMMARY_PROFILE`, `QAF_LEGACY_DE_SUMMARY_PROFILE`,
  `G60_DETAIL_PROFILE`. Klassifikation `known`/`modified`/`unknown` aus einem
  Coverage-Verhältnis (gefundene vs. erwartete canonical IDs je Profil,
  Legacy-Summary hat einen dokumentierten Ein-Feld-Unterschied zu V9) über
  benannte, kommentierte Schwellenwerte (`TEMPLATE_FINGERPRINT_CONFIG`,
  gleiches Muster wie `G60_STRUCTURE_GUARD_CONFIG`).
- **Persistenz ohne neue Spalte:** das Ergebnis (Hash, Klassifikation,
  Abweichungen) wird beim Ingest (`ingestQafUpload`, `app/qaf-differences/
  actions.ts`) einmalig berechnet und in das bestehende JSONB-Feld
  `qaf_file.g60_meta` unter dem Key `templateFingerprint` eingebettet — für
  G60-Dateien zusätzlich zu den bereits dort liegenden Feldern
  (`rates`/`volumes`/`inputStructure`/`excludedTabs`), für Summary-Dateien
  ist es der erste Wert in diesem bislang für sie ungenutzten Feld. Keine
  Migration nötig; **Fragilität**: `g60_meta` heißt weiterhin "g60", trägt
  aber jetzt auch Nicht-G60-Metadaten — sauberere Lösung wäre ein Rename/eine
  eigene Spalte, aufgeschoben da operator-gated (siehe PR-Body).
- **Rehydrate/Export-Determinismus:** der Fingerprint wird NIE neu berechnet
  beim Rehydrieren — `app/qaf-differences/[id]/page.tsx` liest ihn
  unverändert aus `qaf_file.g60_meta.templateFingerprint` zurück, für beide
  Pfade (G60-Detailvergleich und Summary/Fertigungskosten-Vergleich).
- **UI:** known/modified/unknown-Badge (bestehende `Pill`-Komponente) direkt
  neben ALT/NEU-Dateiname in der Übersicht — sowohl
  `qaf-comparison-detail.tsx` (neuer optionaler `badge`-Slot auf dem
  bestehenden `Field`-Helper) als auch `qaf-g60-detail.tsx`. `modified`/
  `unknown` erzeugt zusätzlich ein `pruefen`-Issue (`template_fingerprint_
  modified`/`template_fingerprint_unknown`), gerendert in der bestehenden
  Plausibilitäts-Sektion (Summary-Pfad: "A4. Plausibilität") bzw.
  Struktur-Sektion (G60-Pfad: "Struktur-Plausibilität (G60)") — keine neue
  Komponente, blockiert nichts (die P0-Guards übernehmen das Blockieren).
- **`ENGINE_VERSION.templateFingerprint`** ergänzt (`internal/types.ts`,
  gleiches Reproduzierbarkeits-Muster wie `g60Parser`/`ruleEngine`).
- **Tests:** 1530 → 1559 (Baseline main vs. danach, 0 Regressionen).

## 2026-07-09 — Kanonisches QAF-Feldmodell + Alias-Registry (KAR-892, P1.1)

- **Neues Fundament-Modul** `lib/qaf-differences/internal/canonical-model.ts`
  (Zugriffs-API: `findByAlias`, `byModule`, `byCanonicalId`,
  `validateRegistry`, `normalizeCanonicalLabel`) + Daten-Datei
  `lib/qaf-differences/internal/canonical-fields.ts` (242 Feld-Einträge) +
  Typ-Datei `lib/qaf-differences/internal/canonical-fields.types.ts`.
- **Sprachunabhängige Registry** über alle 12 QAF-Module (SUMMARY,
  MANUFACTURING, MATERIAL, SBM, LOGISTICS, RMR, LC_CN, CO2E, WAF, LAF, LEK,
  G60): stabile canonical ID, Modul, Sektion, DE/EN-Label, Aliase (inkl.
  dokumentierter BMW-Tippfehler "manufactering"/"sucharge"/"Teilebennung"/
  "Verrechungsform"/"Produdktionsstart"), Datentyp, Einheit, Klassifikation
  (input/calculated/protected/informational), Pflicht-Stufe
  (mandatory/conditional/optional), row/summary-Level, Template-Versionen,
  Leitfaden-Seiten-Evidenz je Feld.
- **Umfang:** SUMMARY (Identität + 19 Kennzahlen) und MANUFACTURING (22
  Felder) vollständig, jeweils 1:1 aus den bereits produktiven Wörterbüchern
  (`qaf-parser.ts` HEADER_TO_KEY, `summary-metrics.ts` SYNONYMS,
  `rule-engine.ts` SUMMARY_FIELD_LABELS) übernommen — keine Verhaltens-,
  keine Datenänderung. G60 core (13 real-file-verifizierte Anker/Rate-Card-
  Felder). MATERIAL/SBM/LOGISTICS/RMR/LC_CN/CO2E/WAF/LAF/LEK als
  Kern-Feldsets aus dem Leitfaden-Extrakt (02-leitfaden-teil1/2/3.md),
  ausdrücklich als noch ungeparst markiert — Vollständigkeit wächst mit
  P1.6/P1.7.
- **Rückwärts-Mapping additiv:** `QAF_FIELD_KEY_TO_CANONICAL` (22 bestehende
  `QAFFieldKey`) und `SUMMARY_METRIC_KEY_TO_CANONICAL` (19 bestehende
  `SummaryMetricKey`) ordnen jedem produktiven Key eine kanonische ID zu —
  `qaf-parser.ts`/`summary-metrics.ts` bleiben unangetastet (Parser-Umstellung
  ist P1.2).
- **Bewusste Alias-Kollisionen modellierbar statt verboten:** das reale
  doppelte "Mengeneinheit"-Label in MATERIAL (Fehlerreport-Analyse §3.3/§4.2
  R6: Zeile #31 korrekt vs. Zeile #36 Label-Kopierfehler für "MGK je
  Mengeneinheit") sowie das doppelte "Offenes Bemerkungsfeld" in SUMMARY
  (Leitfaden S.11 vs. S.12) sind über `collisionGroup` als bewusste
  Kollisionen markiert; `validateRegistry()` meldet jede UNbeabsichtigte
  Kollision als Fehler.
- **Tests:** neue Suite `__tests__/canonical-model.test.ts` (30 Tests) prüft
  Label-Normalisierung, Zugriffs-API, Registry-Validierung
  (Duplikat-IDs/fehlende Evidenz/fehlende Pflicht-Labels/Kollisions-Logik) und
  Vollständigkeit beider Rückwärts-Mappings. Gesamtsuite: 1500 → 1530 Tests
  grün, keine Regression.
- **Kein DB-Schema, kein Parser-Refactor, keine UI-Änderung** in diesem PR —
  reines additives Fundament für P1.2 ff.

## 2026-07-09 — PR #269 Review-Fix: G60-Header-Anker gegen echte Dateien verifiziert (KAR-888)

- **Adversarial-Review-Finding (Konfidenz 82):** die AO/AQ-Synonyme in
  `TAB_HEADER_ANCHORS` waren nicht aus `analyze.ts`s `METRIC_DEFS` übernommen
  (dort existieren keine AO/AQ-Einträge) — der Modul-Kommentar behauptete das
  fälschlich. Bei `hardMismatchThreshold=2` hätten genau diese zwei
  unverifizierten Anker gemeinsam einen intakten Tab hart ausschließen können.
- **Verifikation gegen zwei echte G60-Dateien** auf dem Server (nur Header-
  TEXTE gelesen, keine Werte, Dateien nie ins Repo kopiert):
  `100_G60_DP_QAF_Basis_24_10_BMW.xlsm` und
  `20260508_BMW_QAF_G60_DP_RePricing_HO.xlsm`.
- **Deutlich schwerwiegenderer Fund als vermutet:** die Kopfzeile liegt in
  BEIDEN echten Dateien nicht auf Zeile 14 (die ursprüngliche Annahme), sondern
  in einer über Zeile 12:14 verschmolzenen Zelle, deren Wert nur auf Zeile 12
  lesbar ist — Zeile 14 liest in echten Dateien immer leer. Ungefixt hätte der
  Guard damit JEDEN Kostenreiter jeder echten G60-Datei hart ausgeschlossen
  (5 von 5 Ankern gleichzeitig leer) — ein False-Positive, das die Engine für
  echte Dateien komplett lahmgelegt hätte. `G60_HEADER_ROW` jetzt auf den
  verifizierten Wert 12 korrigiert (eigenständige Konstante, nicht mehr von
  `TAB_ROW_FIRST` abgeleitet).
- **Spalten W/AO/AQ/AT** trafen nach der Zeilen-Korrektur ohne Wortlaut-
  Änderung ("Material costs", "Cycle time", "Number of direct employees",
  "Machine-hour rate" — alle EN, kein DE-Vorkommen in den echten Dateien).
  **Spalte Y** trägt in beiden echten Dateien einen echten BMW-Template-
  Tippfehler ("sucharge" statt "surcharge") — Synonym `raw material` ergänzt
  (Substring-Match, tolerant gegen beide Schreibweisen).
- **`hardMismatchThreshold` bewusst NICHT erhöht** — alle fünf Anker matchten
  nach der Zeilen-Korrektur sauber gegen beide echten Dateien, kein
  beobachteter Beinahe-Fehlschlag, der eine höhere Schwelle rechtfertigen
  würde (Begründung im Code-Kommentar).
- **INPUT!B22-B29** bestätigt: echte Werte sind `Overhead FK`/`Overhead MAT`/
  `Profit FK`/`Profit MAT` (identische vier Labels für direkt-/fremdvergeben
  wiederholt) — bestätigt, dass Presence-only (statt Wortlaut-Match) hier die
  richtige Wahl war.
- **Modul-Kommentar korrigiert:** von der falschen "already established"-
  Behauptung zu einer ehrlichen Herkunftsangabe mit Datum, Dateinamen und
  explizitem Hinweis, dass nur Header-Texte gelesen wurden (keine Werte,
  keine Kopie ins Repo).
- **Tests:** 2 neue Fälle für die Y-Spalten-Tippfehler-Toleranz (synthetisch,
  nicht 1:1 aus der echten Datei kopiert); bestehende Fixtures/Tests von Zeile
  14 auf Zeile 12 umgestellt (79/79 G60-Tests grün, vorher 77).

## 2026-07-09 — G60-Struktur-Guards gegen stille Falschzahlen (KAR-888)

- **Problem:** Der G60-Detail-Parser (`internal/g60/parser.ts`) arbeitet komplett
  über hart-codierte Zellkoordinaten (feste Spalten W/Y/AS/AT/AO/AP/AQ/AR/BC/AX/
  BA/AZ/AD/DB, feste Zeilen 15–38) ohne jede Label-Validierung. Bei einer
  abweichenden Tab-Struktur (verschobene Zeile/Spalte, geändertes Template)
  lieferte der Parser bislang still falsche oder leere Zahlen statt eines
  Fehlers — das höchste Einzelrisiko des Programms (Master-Prompt §7 "silent
  template modification without an updated version number").
- **Neues Modul `lib/qaf-differences/internal/g60/structure-guard.ts`** (Minimal-
  Guard, KEIN Parser-Umbau — `parser.ts`s Extraktion bleibt unverändert):
  - `validateG60TabHeader`: prüft je Kostenreiter die Header-Zeile (Zeile
    `TAB_ROW_FIRST − 1`) über den Spalten W/Y/AO/AQ/AT gegen ein DE/EN-
    Synonym-Set, das dieselbe Begrifflichkeit wie `analyze.ts`s `METRIC_DEFS`
    nutzt (Material, Materialzuschlag, Zykluszeit, Mitarbeiter, Maschine/
    Maschinensatz) — keine neu erfundene BMW-Terminologie.
  - `validateG60InputRates`: prüft `INPUT!B22–B29` (die Bezeichner neben den
    acht SG&A/Gewinn-Master-Raten `C22–C29`) nur auf Anwesenheit (keine
    Wortlaut-Prüfung — bewusst, gegen das im Backlog benannte
    False-Positive-Risiko ohne echte G60-Datei im Repo).
  - `parseG60WorkbookGuarded`: wrapt `parseG60Workbook` unverändert. Einzelner
    Anker weg → Wert bleibt, Confidence 0,6, Plausibility-Issue (Severity
    prüfen). Mehrere Kern-Anker weg (Schwelle 2, `G60_STRUCTURE_GUARD_CONFIG`):
    ein einzelner Kostenreiter mit verschobener Struktur wird aus dem
    Ergebnis ausgeschlossen (Rest bleibt unberührt — Master-Prompt §8 "nur die
    betroffene Sektion"); sind die INPUT-Raten-Labels selbst hart kaputt,
    hängt SGA/Profit in JEDEM Reiter an unverifizierten Zellen — hier werden
    alle Reiter dieser Datei ausgeschlossen statt einer möglicherweise falschen
    Zahl (`rates`/`card`/`volumes` bleiben erhalten).
  - `detectG60` gehärtet: eine Datei mit `INPUT`-Sheet + `\d+_2`-Tabs aber OHNE
    jede der acht Raten-Bezeichnungen wird nicht mehr als G60 durchgewunken.
  - PlausibilityIssue-Bridge (`buildG60StructureIssues`, `issue_type`-Namensraum
    `g60_structure_*`) — reuses das etablierte `blockiert`-Suffix-Muster
    (KAR-889): `_excluded`/`_blocked` rendern als "Blockiert" ohne UI-Code-
    Änderung.
- **Keine Migration:** Struktur-Befunde reiten im bestehenden JSONB
  (`qaf_g60_tab.aggregate.structure` pro Reiter, `qaf_file.g60_meta.
  inputStructure`/`.excludedTabs`) — kein Schema-Change, Determinismus über
  Persistenz-zur-Ingest-Zeit (Rehydrate liest nur zurück, führt den Guard nie
  erneut aus).
- **`ENGINE_VERSION.g60Parser`** neu ergänzt (`1.0.0`) — Lehre aus KAR-889/#267:
  eine Guard-Einführung ändert Parse-Verhalten und muss reproduzierbar bleiben.
- **UI:** neue Sektion "Struktur-Plausibilität (G60)" in `qaf-g60-detail.tsx`,
  ganz oben (vor allen Zahlen) platziert, reuses `Section`/`Pill` — keine neue
  Komponente.
- **Tests:** 19 neue Fälle in `structure-guard.test.ts` (intaktes Template =
  Regression, einzelner Anker = soft, verschobene Zeile = hart/ausgeschlossen,
  fehlende INPUT-Labels = hart/alle Reiter weg, `detectG60`-Härtung) + 2 neue
  Fälle in `persistence.test.ts`. Bestehende Fixtures (`fixtures.ts`,
  `bridge.test.ts`) um die Label-Anker ergänzt (additiv — 56/56 bestehende
  G60-Tests weiterhin grün). Ausschließlich synthetische Grids, keine echten
  BMW-Zahlen.
- **Offen/fragil:** kein echtes G60-File im Repo — die Synonym-Listen und die
  Schwelle 2 sind unverifiziert gegen eine reale Datei (dokumentiertes Risiko,
  Backlog P0.3). Vor Produktivsetzung mit mind. 1 echter G60-Datei prüfen.

## 2026-07-09 — QAF Summen-Rekonziliation Summary ↔ Fertigungskosten-Zeilen (KAR-887)

- **Problem:** Excel-Ergebnisse wurden bisher ungeprüft geglaubt — kein Check
  verifizierte, dass Σ der Fertigungskosten-Einzelzeilen zu den
  Summary-Kennzahlen passt (Master-Prompt §12.5 „summary totals reconcile with
  detail sheets"; Leitfaden [12]/[13] Kostenmodell-Kaskade).
- **Neues Modul `lib/qaf-differences/internal/reconciliation.ts`:** deterministische
  Gegenrechnungen pro geparster QAF-Datei (ALT/NEU getrennt, nicht gegeneinander
  diffed):
  - **Detail-Summen:** Σ `fkAW` (Fertigungskosten je Zeile, Angebotswährung) vs.
    Summary-Kennzahl `manufacturingCosts`; Σ `ausschusskosten` vs.
    `scrapManufacturing`. Bewusst die AW-Felder, nicht das BW-Feld `fk` — die
    Summary-Kennzahlen sind in der Angebotswährung, `fk` in der
    Beschaffungswährung (Leitfaden [31]).
  - **Kaskaden-Rekonziliation** (rein aus den 19 Summary-Kennzahlen, keine
    Detail-Zeilen nötig): Materialkosten+Fertigungskosten=SUMME HERSTELLKOSTEN;
    Herstellkosten+Vorrichtungen/Folgewerkzeuge+Ausschusskosten
    (Material+Fertigung)=GESAMTKOSTEN; Gesamtkosten+Sonstige Zuschläge=
    ANGEBOTSBASISPREIS; Angebotsbasispreis+Rohstoffpreisanteile+Zölle+
    Logistikkosten=ANGEBOTSPREIS.
  - **Ausgelassen (dokumentiertes TODO, nicht geraten):** Materialkosten- und
    Vorrichtungen/Folgewerkzeuge-Detail-Rekonziliation (kein MATERIAL-/
    SBM-Sheet-Parser, P1.6/P1.7), G60-Aggregat-Rekonziliation (eigene
    Aggregatlogik, P1.3).
- **Toleranz:** zentrale Konstante `RECONCILIATION_CONFIG` (0,5 % relativ +
  1 AW absolute Mindestschwelle gegen Rundungsrauschen, `max()`-Kombination) —
  analog `RULE_ENGINE_CONFIG`, Vorstufe zu P1.5 DB-Config, keine DB-Config jetzt.
- **Unvollständige Basis ≠ bestanden, ≠ Verstoß:** fehlende Summary-Kennzahlen,
  Fertigungskosten-Zeilen mit unklarem Wert (weder Zahl noch erkennbarer
  n.a.-Marker) oder Angebotswährungs-Abweichung zwischen Zeilen und Summary
  ergeben `nicht_pruefbar` (Severity Hinweis, mit Begründung) statt eines
  irreführenden Ergebnisses (Master-Prompt §13). Explizite `n.a.`-Marker auf
  `fkAW`/`ausschusskosten` zählen als bewusste 0-Kontribution, blockieren die
  Summe nicht.
- **Keine Migration:** Ergebnisse laufen über die bestehende
  `qaf_plausibility_issue`-Tabelle, Kennzeichnung über `issue_type`
  (`recon_fk_detail_sum`, `recon_scrap_manufacturing_detail_sum`,
  `recon_herstellkosten_cascade`, `recon_gesamtkosten_cascade`,
  `recon_angebotsbasispreis_cascade`, `recon_angebotspreis_cascade`, je mit
  `_nicht_pruefbar`-Variante). Severity: Kaskaden-Bruch = kritisch,
  Detail-Summen-Abweichung = prüfen, nicht prüfbar = hinweis.
- **`compare.ts`:** Rekonziliation läuft pro Datei neben `checkPlausibility` und
  der Regel-Engine, Ergebnisse fließen in dasselbe `plausibility`-Array — nur
  aktiv wenn `summaryMetrics` für die jeweilige Seite vorhanden sind (gleiches
  Guard-Muster wie `summaryDiffs`), daher kein Crash auf dem
  Rehydrate-ohne-Override-Pfad (liefert dann einfach keine Rekon-Issues; der
  Export-Re-Run nutzt ohnehin bevorzugt die persistierten
  `plausibilityOverride`-Issues für Fidelity, bestehendes Muster aus KAR-840).
- **UI:** bestehende „A4. Plausibilität"-Sektion zeigt Rekon-Issues mit an;
  `nicht_pruefbar`-Issues bekommen ein eigenes „Nicht prüfbar"-Badge statt der
  generischen „Hinweis"-Pille (kein neues Component,
  `components/qaf-differences/qaf-comparison-detail.tsx`).
- **Tests:** neues `reconciliation.test.ts` (Unit-Ebene, alle 6 Checks inkl.
  Toleranzgrenzen, n.a.-Handling, fehlende Metriken, Determinismus) plus
  Integrationstests in `compare.test.ts`, `persistence-mapper.test.ts` und
  `rehydrate.test.ts` (Rehydrate-/Export-Determinismus).

## 2026-07-09 — QAF Fehlerreport-Regel-Engine R1-R6, weich mit Hart-Schalter (KAR-889)

- **Neues Modul `lib/qaf-differences/internal/rule-engine.ts`:** wertet pro
  Vergleich die aus der BMW-Fehlerreport-Analyse abgeleiteten Regeln R1
  (Feld-Zustandsklassifikation ohne Farb-Konzept), R2 (Pflichtfeld leer/
  ungültig → Vergleich blockieren), R3 (Nicht-Pflichtfeld ungültig → nur
  Hinweis, nie blockierend), R4 (Sektion nur in ALT/NEU vorhanden → struktureller
  Unterschied, kein Fehler) und R6 (Label-Tippfehler/-Duplikate tolerieren) aus.
  R5 (kein 1:1-Diff von Fehlerreport-Dateien) ist ein Programm-Level-Constraint
  für Test-Fixtures, keine Engine-Regel — dokumentiert, nicht kodiert.
- **Weich starten, hart per Schalter:** zentrale Konstante
  `RULE_ENGINE_CONFIG.ruleEnforcement` (`'warn' | 'block'`, Default `'warn'`,
  Vorstufe zu P1.5 DB-Config). Im `'block'`-Modus markiert R2 das betroffene
  Feld/die Sektion als blockiert (`comparisonBlocked`); R3 bleibt immer
  Warnung (offene Fachfrage lt. Fehlerreport-Analyse §6.1).
- **Keine Migration:** Regel-Verstöße laufen über die bestehende
  `qaf_plausibility_issue`-Tabelle (bereits generisch genug), Kennzeichnung
  über `issue_type` (`rule_r2_mandatory_warn|_blocked`,
  `rule_r3_optional_invalid`, `rule_r4_structural_diff`).
- **`compare.ts`:** Regel-Engine läuft neben `checkPlausibility`, Ergebnisse
  fließen in dasselbe `plausibility`-Array — Persistenz/Export/Rehydrate
  brauchten dadurch keine Änderung.
- **UI:** bestehende „A4. Plausibilität"-Sektion zeigt Regel-Verstöße mit an;
  blockierte Verstöße bekommen ein eigenes „Blockiert"-Badge statt der
  normalen Severity-Pille (kein neues Component).
- **Nachbesserung nach adversarialem Review (2 Findings, beide behoben):**
  - **Block-Modus blockt jetzt wirklich:** vorher wanderte `comparisonBlocked`
    nur in den `issue_type`-Suffix, die A1-Delta-Tabelle zeigte weiterhin den
    (evtl. falschen) berechneten Delta-Wert eines als ungültig geflaggten
    Pflichtfelds. Jetzt: neuer `DiffStatus` `'blockiert'` — im `'block'`-Modus
    wird für genau das betroffene Feld (nicht die ganze Sektion/den ganzen
    Vergleich) die Delta-Berechnung unterdrückt (`deltaAbsolute/-Percent/
    -PercentagePoints = null`), `altValue`/`neuValue` bleiben sichtbar (Muster
    von `nicht_anwendbar`, KAR-891). `RuleViolation` trägt jetzt `rowIndex`/
    `fieldKey`, neue Funktion `blockedStepFields()` löst das für `compare.ts`
    auf; A1-Tabelle rendert „Blockiert"-Pill statt Zahl. Beide Modi getestet.
  - **Reproduzierbarkeit:** `ENGINE_VERSION.ruleEngine` (Code-Version) ergänzt;
    `compareQafPair()` nimmt `ruleEngineConfig` jetzt explizit als Parameter
    (Default `RULE_ENGINE_CONFIG`, kein implizites Modul-Global-Read mehr) und
    das Ergebnis trägt `ruleEnforcement` — `buildComparisonRowset()` persistiert
    den tatsächlich verwendeten Modus in `engine_version`, damit ein späterer
    Flip der Default-Konstante alte Vergleiche nicht rückwirkend unklar macht.
- **Verifikations-Review fand eine Rest-Lücke im Export/Rehydrate-Pfad (behoben):**
  `rehydrateExportInput()` (XLSX-Export einer bestehenden Vergleichs-ID) rief
  `compareQafPair()` bisher ohne `ruleEngineConfig` auf — der Export lief damit
  unter dem zum Export-*Zeitpunkt* live gültigen `RULE_ENGINE_CONFIG`, nicht
  unter dem tatsächlich persistierten Modus des Vergleichs. Nach einem
  späteren Flip der Default-Konstante hätte der Export eines alten Vergleichs
  still andere Deltas produziert als die Detailseite (liest persistierte
  Zeilen, kein Re-Run) — dieselbe Reproduzierbarkeits-Klasse wie Finding 2,
  eine Ebene tiefer. Fix: neue reine Funktion `resolvePersistedRuleEnforcement()`
  liest `qaf_comparison.engine_version.ruleEnforcement` (Fallback `'warn'` für
  Alt-Vergleiche ohne den Key — nie stillschweigend `'block'`), `actions.ts`
  lädt `engine_version` mit und reicht es an `buildExportWorkbookFromRows()`
  durch.
- **Tests:** 51 Unit-Tests in `rule-engine.test.ts` (46 initial + 5 aus dem
  Review-Fix, inkl. 15 synthetischer Regressionsfälle aus den 15 dokumentierten
  Korrekt-Mismatch-Zeilen der Fehlerreport-Analyse, Feldnamen echt/Zahlen
  erfunden) + 9 Integrationstests in `compare.test.ts` (3 initial + 6 aus dem
  Review-Fix) + 2 in `persistence-mapper.test.ts` (Review-Fix) + 6 in
  `rehydrate.test.ts` (Verifikations-Review-Fix: 3 `resolvePersistedRuleEnforcement`
  + 3 `rehydrateExportInput`-Integration).

## 2026-07-09 — QAF Phase 0: Zell-Provenance, n.a.-Field-State, RLS-Fix (KAR-886, KAR-891, KAR-890)

- **Zell-Provenance (KAR-886, PR #265):** jeder neue QAF-Vergleich persistiert
  zu jeder Fertigungskosten-Zahl die exakte Herkunfts-Zelle
  (`qaf_manufacturing_step.source_cells`, z. B. `Fertigungskosten!Q14`) plus
  normalisierte Werte. Nebenbei latenten Zeilennummern-Drift bei leeren
  Zwischenzeilen im Parser gefixt.
- **n.a.-Field-State (KAR-891):** Textwerte wie „n.a."/„entfällt" werden nicht
  mehr mit „Feld leer" verwechselt — eigener Diff-Status `nicht_anwendbar`
  (konservatives DE/EN-Vokabular), ohne numerisches Delta, aus der
  Treiber-Berechnung ausgeschlossen, eigenes UI-Label.
- **RLS-Fix (KAR-890, Migration #108, applied 2026-07-09):**
  `qaf_process_mappings` von `auth.uid() IS NOT NULL` auf das projekt-scoped
  `_own`/`_admin`-Policy-Paar angehoben; neuer Cross-Project-Isolationstest
  (gated via `RLS_TEST_DATABASE_URL`).

## 2026-07-06 — QAF R5: Kalkulator pro Vergleich, Listen-Suche, Tags, Geprüft-Status (KAR-848)

- **Preis-Kalkulator 1:1 je Vergleich:** eigener Unterpfad je Vergleich
  (Button im Kopf), Quelle fest auf diesen Vergleich gebunden; der globale
  Meta-Reiter entfällt.
- **„Letzte Vergleiche" durchsuchbar + filterbar** (Text über Name/Sachnummer/
  Projekt, Status-Chips, Produktlinien-Tags).
- **Produktlinien-Tags** (Migration #107, `tags TEXT[]`): automatisch aus den
  Dateinamen abgeleitet (G60/G5X/NA0 …, Versions-Token ausgeschlossen),
  als Chips am Vergleich editierbar, in der Liste sichtbar und filterbar.
- **Status-Workflow:** „Als geprüft markieren" (draft ↔ reviewed) je Vergleich.

## 2026-07-06 — QAF Preis-Kalkulator (KAR-847)

- Neuer Reiter „Preis-Kalkulator" im QAF-Bereich: aufgenommene Werte
  (Zykluszeit, Teile/Zyklus, MA, Lohnsatz, Ineffizienz, Maschinensatz,
  Material, Scrap, SG&A/Gewinn-Sätze) live in einen Preis pro Bauteil
  umrechnen — Formel identisch zur G60-Engine.
- Klick-Übernahme: „ALT/NEU übernehmen" füllt alle Felder aus dem gewählten
  Kostenreiter eines G60-Vergleichs; an jedem Feld stehen die ALT/NEU-Werte
  als Chips für die Einzel-Übernahme; Stoppuhr-Ø als Zyklus-Quelle.
- Live-Aufschlüsselung Material/Lohn/Maschine/Scrap/SG&A/Gewinn → Summe.

## 2026-07-06 — QAF-Vergleichsliste: Projekt, Datum, Status-Erklärungen (KAR-846 A)

- „Letzte Vergleiche" zeigt jetzt Projekt (Code — Lieferant), Datum und einen
  G60-Badge je Eintrag; G60-Vergleiche heißen nicht mehr „—".
- ⓘ-Legende erklärt die Status direkt an der Liste (draft, Baseline-Review,
  G60) — zusätzlich als Tooltip an den Chips.
- Editierbarer Vergleichsname (Migration #106, von Kais appliziert):
  Stift-Symbol am Titel der Detailseite — eigener Name erscheint auch in der
  Liste; leer = zurück zum Standard (Sachnummer/G60).

## 2026-07-06 — QAF: Stoppuhr-Messwerte in die Szenario-Bestandsaufnahme (KAR-843)

- Im G60-Szenario-Editor je Kostenreiter: „Stoppuhr-Messwerte laden" holt je
  Station den Median der nicht als Ausreißer markierten Zykluszeit-Messungen
  des Projekts; die Zuordnung Station → Prozesszeile wird per
  Namens-Ähnlichkeit vorgeschlagen und ist per Dropdown korrigierbar.
- „Messwerte als Bestandsaufnahme übernehmen" setzt die gemessenen Zeiten als
  Zyklus-Overrides — alle Werte rechnen live neu: das Delta zeigt, was das
  Angebot mit den real gemessenen Zeiten kosten müsste.

## 2026-07-06 — QAF: Dateien nachträglich ersetzen (KAR-845 Teil 2)

- Im Kopf jedes Vergleichs: „ALT-/NEU-Datei ersetzen" — neue Datei wird
  hochgeladen, geprüft (gleicher QAF-Typ, gleiche Sachnummer bei Summary),
  eingelesen und der Vergleich rechnet komplett neu. Die vorherige Datei
  bleibt als Historie erhalten.
- Ingest-Kern (`ingestQafUpload`) als geteilter Helper von Batch-Analyse und
  Replace — die Einlese-Semantik kann nicht driften; Fehler kompensieren
  vollständig (kein verwaister Datensatz, Vergleich bleibt konsistent).

## 2026-07-05 — QAF: Manuelles Prozessschritt-Matching + ALT/NEU-Tausch (KAR-845 PR1)

- **Anhang A3 ist jetzt ein Editor:** jede ALT-Zeile bekommt ein NEU-Dropdown;
  geänderte Zuordnungen werden als „manuell" (100 % Konfidenz) gespeichert und
  der komplette Vergleich rechnet mit dem Nutzer-Matching neu (delta, Struktur,
  Plausibilität, Fazit). Doppel-Zuordnungen werden abgefangen.
- **ALT/NEU tauschen** je Vergleich (Button im Kopf, mit Bestätigung): Summary
  rechnet vollständig neu, G60 tauscht die Rollen (Analyse ist on-the-fly).
- Engine: `matchStepsWithPins` (Pins übersteuern die Kaskade, kein Doppel-Use,
  5 Tests); `compareQafPair` akzeptiert Pins; `summaryMetricsFromRows`
  rehydriert die Summary-Metriken für verlustfreies Re-Compare.

## 2026-07-05 — QAF: Nummerierte Einheits-Struktur über alle Analysen (KAR-844)

- Beide Analyse-Ansichten (Summary + G60) zeigen dieselben 14 nummerierten
  Sektionen in derselben Reihenfolge („1. Überblick … 14. Management-Fazit",
  kanonisch in `qaf-section-registry.ts`). Sektionen, die ein QAF-Typ nicht
  füllen kann, erscheinen als kompakte erklärte Platzhalter-Zeile.
- ⓘ-Infotexte (Erklärung + Formel + Beispiel) jetzt auch in allen
  Summary-Sektionen und auf der Upload-Seite.
- „QAF" überall ausgeschrieben (Chart-Legenden „QAF ALT/NEU" statt „Q1").
- Audit-Sektionen als Anhang A1–A4 nummeriert.

## 2026-07-05 — QAF-Hochrechnung Jahre ± und ⓘ-Erklärungen (KAR-842 C)

- Hochrechnung: Jahre vorn UND hinten anfügbar („+ Jahr davor/danach",
  auch Vergangenheit) und einzeln löschbar (×), nicht nur abwählbar.
- Jede G60-Sektion hat ein aufklappbares „ⓘ Was zeigt diese Sektion?" mit
  Klartext-Erklärung, Formel und durchgerechnetem Beispiel — inkl. der
  Bestandsaufnahme/Potential-Logik des Szenario-Editors.

## 2026-07-05 — QAF-Tabellen: Suche, Filter, kompakte Metriken (KAR-842 B)

- **Kostenreiter-Metriken** radikal kompakter: eine aufklappbare Zeile je
  Reiter (Teil, Quotation-Price-Δ, Flag, Auffälligkeits-Zähler); Default
  zeigt nur Auffälligkeiten (Flag oder |Δ| ≥ 20 %) + Quotation Price;
  Volltext-Suche über Reiter/Teil/Kennzahl mit Trefferzähler.
- **Anomalien & Treiber**: Suche + Kategorie-Filter-Chips (Lohn,
  Gemeinkosten, Gewinn, FX, Rohstoff, Sonstiges).
- **Szenario-Editor**: Prozess-Zeilen durchsuchbar.

## 2026-07-05 — QAF-Upload: Sortierbare Reihenfolge + Fortschrittsbalken (KAR-842 A)

- Gewählte Dateien erscheinen als Liste mit ALT/NEU-Badges — die Reihenfolge
  (= Vergleichsreihenfolge, oben = ALT) ist per Drag & Drop oder Pfeiltasten
  sortierbar; einzelne Dateien entfernbar.
- Echter Fortschrittsbalken mit Prozent beim Hochladen (XHR-Byte-Progress über
  alle Dateien aggregiert), Analyse-Phase mit Aktivitätsbalken.

## 2026-07-05 — QAF-Vergleich: Feedback-Runde 1 (KAR-841)

- **Kostenbrücke:** Tooltip komplett neu — zeigte für jede Serie 0,00 €; jetzt
  nur die relevante Aussage je Balken (Δ + Zwischenstand bzw. Total + Bucket-
  Aufschlüsselung). NEU-Endlabel sitzt jetzt robust auf dem obersten Segment.
- Kostenstruktur-Chart: absolute Werte an allen Balken.
- Produktionssicht: Tabelle mit echten Vorher/Nachher-Werten inkl. Einheiten,
  Index und Δ % je KPI.
- QAF-Bereich hat jetzt Standard-Header + Sidebar (fehlendes AppShell-Layout).
- Hochrechnung: Volumen auf 2 Nachkommastellen gerundet vorbefüllt.
- Szenario-Editor: Nicht-Prozess-Zeilen ausgegraut, nur Material editierbar.

## 2026-07-05 — QAF-Vergleich: G60 Szenario-Editor (KAR-840 G4)

- Letzte KAR-840-Etappe: interaktiver Was-wäre-wenn-Editor auf dem NEU-Stand.
  Globale Hebel-Faktoren (MSS, Lohn, Zykluszeit, Besetzung, Material,
  Ineffizienz-Streichung) + zeilengenauer Prozess-Editor je Kostenreiter.
- Pure Recompute-Engine (`internal/g60/scenario.ts`, V11-`b3_core`-Parität,
  8 Tests): delta-verankert — ohne Eingriff exakt die persistierten Werte.
- Ergebnis live: Potential vs. Bestandsaufnahme je Bucket, Δ je Stück,
  Gesamtpotenzial über die echten QAF-Stückzahlen. Session-only (V1).

## 2026-07-05 — QAF-Vergleich: Storage-Direktupload für große Dateien (KAR-840)

- Echte G60-Detail-QAFs (~6 MB) scheiterten am Upload: Server Actions tragen bei
  Vercel maximal 4,5 MB Request-Body (Next-Default sogar 1 MB). Der Browser lädt
  jetzt direkt in den privaten Storage-Bucket `qaf-uploads` (signierte Upload-URLs,
  `createQafUploadTargets`), die Analyse (`analyzeQafBatchFromStorage`) liest
  serverseitig aus Storage — mit Ownership-Check und hartem Projekt-Pfad-Guard.
- Original-Dateien bleiben ab jetzt gespeichert (Re-Ingest ohne erneutes Hochladen).
- Fortschrittsanzeige beim Upload; `maxDuration` für die Analyse-Route; beide
  privilegierten Storage-Operationen im Service-Role-Register dokumentiert.

## 2026-07-04 — QAF-Vergleich: .xlsm-Uploads (KAR-840, PR #250)

- G60-Detail-QAFs kommen als Makro-Workbooks (`.xlsm`) — Upload-Dialog (`accept`)
  und Server-Validierung akzeptieren jetzt `.xlsx` und `.xlsm`. ExcelJS liest
  ausschließlich Zellwerte; Makros werden nie ausgeführt.

## 2026-07-03/04 — QAF-Vergleich: V11-Vollport Summary-Strecke + G60-Fundament (KAR-840, PRs #239–#249)

- **Summary-Engine-Fundament (#239):** 19 kanonische Summary-Geldmetriken aus dem
  Zusammenfassungs-Blatt (Label-Anchoring ±3, AW1-Spalten-Detection C26/C25) mit
  A1-Provenance; Summary-Diff-Ebene in `compareQafPair`; Persistenz nach
  `qaf_summary_metric`/`qaf_summary_diff`; `qaf_file.template_type`.
- **Analyse-Sektionen in der Detailansicht (#240–#245):** KPI-Kacheln + QAF-Form-Ansicht,
  Kostenstruktur-Bucket-Chart (Legend-Toggle), Kostenbrücke/Waterfall + Kennzahlen-Tabelle
  mit Quellzellen und Engine-Status, Tornado „Größte Preistreiber" (Top-N-Filter,
  Uncertain-Vorbehalt), Verhandlung (Quick Wins & Große Bringer), Hebel mit festen
  Forderungstexten, Einmalzahlungen.
- **Interaktive Hochrechnung (#246):** editierbare Jahres-/Volumentabelle, Δ-Override,
  Abwehrquote-Slider (reduziert nur Mehrkosten), M€-Ergebnis + Bar/Line-Chart.
- **Export-Layer (#247):** PNG je Sektion (foreignObject-Snapshot inkl. Chart-Legenden),
  versionierter JSON-Export des View-Models, Print-to-PDF.
- **G60-Engine + Persistenz (#248/#249, Migration #105):** V11-`b3_core`-Port
  (Kostenreiter-Parser, 6-Bucket-Analyse, Maschinenhebel-Median, PART-SPECIFIC-Flags,
  INPUT-Karten-Diff); Upload-Detection für G60-Detail-QAFs mit eigener Persistenz
  (`qaf_g60_tab`, `qaf_input_card`, `qaf_file.g60_meta`) und Cross-Batch-Pairing;
  `comparison_mode='g60'`.
- Chart-Farben als `--qaf-*`-Design-Tokens (dokumentierte CI-Ausnahme); alle Zahlen gegen
  das V11-Referenz-PDF validiert; jede Etappe adversarial-reviewed vor Merge.

## 2026-07-03 — QAF-Vergleich: Excel-Export in der Detailansicht (KAR-840 PR1)

- **8-Blatt-Excel-Export** ist jetzt aus der QAF-Vergleich-Detailseite herunterladbar
  ("Excel-Export (8 Blatt)"-Button). Die fertige Export-Engine (`buildQafExportWorkbook`)
  war bisher nur in Tests aufgerufen.
- Neuer Rehydrator (`lib/qaf-differences/internal/rehydrate.ts`, 6 Tests): rekonstruiert
  die Engine-Inputs aus persistierten `qaf_*`-Zeilen (`qaf_manufacturing_step.raw_values` +
  `qaf_part`) und re-runt die deterministische Vergleichs-Engine — kein Re-Upload, Download
  matcht die On-Screen-Auswertung exakt.
- Read-only Server-Action `exportQafComparisonXlsx` (RLS-scoped) + Client-Download-Button.
- Erste Etappe des V11-Vollports (KAR-840); Plan im Brain `01-Projekte/supplierpulse-v11-vollport/spec.md`.

## 2026-07-03 — Datenmodell-Cleanup Stufe 4 (KAR-704 R5/A3-S3)

- **Migration #103** (`drop-dead-pmo-tables`, operator-applied): droppt die 3 toten
  Tabellen `pmo_measures`, `pmo_log_entries`, `observation_potentials` (0 Zeilen,
  0 Code-Refs, keine eingehenden FKs). Rollback R31 rekonstruiert Struktur+RLS aus
  Live-DDL. Adversarial-verifiziert (SAFE).
- **Migration #104** (`consultants-user-profile-softlink`, operator-applied): additiver
  Soft-Link `consultants.user_profile_id` (nullable FK → `user_profiles`) + eindeutiger
  E-Mail-Backfill (41/42) statt riskanter FK-Umbiegung. Rollback R32.

## 2026-07-03 — Lieferanten-FK für Einsatzplanung, Stufe 2 (KAR-704 A1)

- **Migration #102** (`supabase-migration-assignments-supplier-fk.sql`, operator-applied):
  `assignments.supplier_id` (nullable FK → `supplier_master_data`, ON DELETE SET NULL) + Index.
  Kein Backfill — `supplier_name` war in Prod leer (390 Zeilen, 0 nicht-leer). Rollback R30.
- **Write-Pfade**: Edit-Modal persistiert den FK bei Stammdaten-Auswahl (Freitext → NULL);
  Duplicate/Resize/Copy (`planning-client.tsx`) und Wochen-Kopie (`copy-week-modal.tsx`)
  ziehen `supplier_id` mit; `Assignment`-Typ erweitert.
- **API v1**: `PATCH /api/v1/assignments/{id}` akzeptiert optional `supplier_id` (uuid|null);
  `supplier_name`-Patch ohne expliziten `supplier_id` nullt den FK (Stale-FK-Guard). Tests neu
  (`app/api/v1/assignments/[id]/__tests__/route.test.ts`).
- **Doku**: `docs/foundation/audit-logging.md` (neu) — KAR-704 R9 (Audit-Tabellen: nur
  `assignments` DB-Trigger-garantiert, `pmo_log_entries` tot) + R12 (`document_metadata`
  Ghost-FKs = best-effort enrichment, LEFT JOIN only).

## 2026-07-03 — P0-Fixes aus der Gesamtprüfung (KAR-832/833/836)

- **QAF-Ampel-Polarität (KAR-833):** Export + Detail-View färben nach Günstigkeit
  statt roher Richtung; `HIGHER_IS_BETTER_FIELDS` kanonisch in `lib/qaf/comparison.ts`.
  Eine große Kosten-Senkung oder ein Anstieg bei Teile/Zyklus ist nicht mehr rot.
- **Reporting ohne Demo-Daten (KAR-832):** `is_demo`-Guard auf allen Reporting-Queries;
  `workshop_actions` (ohne Spalte) über Demo-Projekt-IDs gefiltert.
- **OEE-Vorwochenvergleich (KAR-836):** `lastIsoWeekOfYear()` statt hartkodierter KW 53.

## 2026-07-03 — Stale design-system.ts entfernt (KAR-829 Follow-up)

- **`lib/customers/bmw/branding/design-system.ts` gelöscht** (Follow-up aus PR #231):
  deprecated, leere Token-Objekte mit Vor-DCT-Werten, kein Importer außer dem eigenen
  Barrel (repo-weit gegrept). Re-Export in `lib/customers/bmw/index.ts` entfernt.
- Handover-Checkliste in `MIGRATIONS.md` zeigt jetzt auf `app/globals.css` +
  `DESIGN_SYSTEM.md` als kanonische Token-Quelle.

## 2026-07-01 — Design-System-Doku für Sync + stale design-system.ts deprecated (KAR-829)

- **`DESIGN_SYSTEM.md`** (neu, Repo-Root): kanonische Design-System-Referenz, die die
  Live-Tokens aus `app/globals.css` (DCT-Petrol #037493, Status-Palette, Radius 2px,
  BMW-TN-Fonts) für Menschen und Design-Tools (Claude Design `/design-sync`) spiegelt.
  SoT bleibt `globals.css`. Enthält Marken-Regeln (#0066B1 logo-only), Komponenten-Inventar.
- **`lib/customers/bmw/branding/design-system.ts`** als `@deprecated` markiert: ältere
  Extraktion (pre-Constitution-v1.1, graue Buttons / Blue-Coral / #00274D) die dem
  Live-System widerspricht; in keiner Datei importiert (dead code). Löschung als Follow-up.

## 2026-07-01 — Branding: neues Favicon + Manifest-Farben auf DCT-Petrol (KAR-828)

- **Neues App-Icon / Favicon** (Puls-Marke auf Petrol-Kachel #037493): ersetzt in
  `app/favicon.ico`, `app/icon.png`, `app/apple-icon.png` sowie den PWA-Icons
  `public/icons/icon-192.png` + `icon-512.png` (Browser-Tab, iOS-Home, PWA-Install).
  ICO/PNG in RGBA re-encodiert (Turbopack-Ico-Decoder verlangt RGBA).
- **manifest.json** auf die DCT-Palette gezogen: `theme_color` `#0066B1` → `#037493`
  (UI-Primary/DCT-Petrol, konsistent mit `--primary`), `background_color` `#00274D`
  → `#FFFFFF` (Canvas). Beide Alt-Werte waren nicht palette-konform; `#0066B1` ist
  laut Constitution v1.1 nur noch fürs BMW-Roundel-SVG reserviert.

## 2026-06-26 — QAF-Vergleich Ergebnis-Detailansicht (KAR-799)

- **Neue Detailansicht** `/qaf-differences/[id]`: bisher zeigte die Seite nur 4 Zähler;
  jetzt ist der persistierte Vergleich vollständig lesbar — Überblick (Sachnummer,
  Teil, Lieferant, ALT/NEU-Dateien, Baseline-Status), Management-Fazit, Fertigungskosten-
  Deltas (Feld-Tabelle ALT/NEU/Δ, statusfarbig), Struktur-Änderungen (neu/entfallen/
  Umstrukturierung), Prozessschritt-Matching und Plausibilität. Read-only, RLS-scoped
  (fremde comparison-id → 404).
- **„Letzte Vergleiche"** verlinkt jede Zeile auf die Detailansicht und lädt nach einem
  Analyse-Lauf automatisch neu (`router.refresh`) — der neue Vergleich erscheint ohne
  manuellen Reload (vorher: stale, „Noch keine Vergleiche" trotz erzeugtem Vergleich).
- Nur semantische Status-Tokens (`bg-status-*`, `bg-primary-tint`) — `check:hardcoded-colors` clean.
- `tsc` 0, 1150 Tests, `check:portability` strict, eslint grün.

## 2026-06-26 — QAF-Differences Review-Hardening (KAR-799)

- **Silent-Failure-Cluster geschlossen:** alle `qaf_*`-Inserts in `analyzeQafBatch`
  (`qaf_part`, `qaf_step_match`, `qaf_manufacturing_diff`, `qaf_structure_change`,
  `qaf_plausibility_issue`, `qaf_root_cause`, `qaf_audit_log`) prüfen jetzt ihren
  `error`. Eine fehlgeschlagene Child-Persistenz markiert den Vergleich als Fehler,
  statt eine leere Vergleichshülle mit `ok:true` zu liefern (auditierbarer Trail).
- **DoS-Schutz beim Upload:** `.xlsx`-Pflicht + 20-MB-Cap pro Datei *vor* dem
  ExcelJS-Parse; Projekt-**Ownership** wird vor dem Parsen geprüft (kein
  Resource-Exhaustion mit fremder Projekt-ID; RLS bleibt zweite Verteidigungslinie).
- **Teilenummer-Regex** matcht jetzt auch unterstrich-getrennte Dateinamen
  (`QAF_1234567_v2.xlsx`) — `\b` ersetzt durch explizite Delimiter-Lookarounds.
- **`PARSER_VERSION`** kommt aus `ENGINE_VERSION.parser` (keine zweite Versionsquelle).
- **persistence-mapper entrümpelt:** die toten, auf Phantom-Spalten (`alt_step_index`)
  zeigenden `stepMatches`/`manufacturingDiffs` entfernt (Falle für künftige Caller).
- Spec-Parität: `PRODUCT_SPEC.md` / `API_SPEC.md` / `UI_FLOWS.md` um QAF-Differences
  ergänzt. Nav-Eintrag „QAF-Vergleiche" dokumentiert.
- Offen (Follow-up): HTTP-Download-Route für den 8-Sheet-Excel-Export.
- `tsc` + qaf-Tests grün; `check:portability` strict noch auszuführen vor Merge.

## 2026-06-23 — Doppelanlage nur mit Begründung (KAR-787 · R8)

- Im „Neuer Benutzer"-Modal kann eine **erkannte Dublette** (E-Mail bereits vergeben, oder Consultant-/Namens-Match) jetzt nur noch von **Admin/Masteradmin** und nur mit **Pflicht-Begründung** trotzdem angelegt werden. Vorher war die Warnung rein advisory — jeder Admin konnte frei doppelt anlegen.
- Die Begründung wird an `POST /api/admin/users` (`force_create_reason`) übergeben und im `user_audit_log` als `action: 'user.created_forced'` + `details.force_create_reason` protokolliert. Nicht-Admins sehen stattdessen den Hinweis, dass nur Admin/Masteradmin überschreiben dürfen.
- `tsc` + `next build` + `check:portability` strict grün; permission-gates 9/9.

## 2026-06-23 — User-Row-Indikatoren + Mitarbeiter-Unlink (KAR-787 · R47/R51/R52)

- **R47:** Die Benutzerverwaltung zeigt pro Zeile jetzt die **letzte gesendete Einladung** (✉ Datum aus `user_audit_log` action `user.invitation_sent`), neben dem letzten Login.
- **R52:** Pro Zeile ein **Link-Status** — verknüpfte Accounts zeigen „🔗 <Mitarbeiter>", nicht-verknüpfte den bestehenden „Kein Mitarbeiter-Link"-Badge (R3).
- **R51:** Das Edit-Modal zeigt den **verknüpften Mitarbeiter** + Button „Verknüpfung lösen" (Admin/Masteradmin). Neue Route `POST /api/admin/users/unlink` setzt `consultants.auth_user_id = NULL` (RLS-/Session-gegated, Audit-Log `user.consultant_unlinked`) — kein Service-Role.
- Loader lädt linked-consultant-Map + last-invite-Map server-seitig. `tsc` + `next build` + `check:portability` strict + check:secrets grün; permission-gates 9/9.

## 2026-06-23 — Rollen aus DB in den User-Modals (KAR-787 · R53)

- Die duplizierte, hardcodierte `ALL_ROLES`-Liste in `create-user-modal` + `edit-user-modal` ist raus. Die Rollen kommen jetzt **aus der DB** (`roles`-Tabelle: id/code/label, im Loader geladen und durch `UsersClient` in beide Modals gereicht) — neue/entfernte Rollen werden reflektiert.
- Die deutschen Anzeige-Labels sind in einem geteilten, getesteten Helper `lib/auth/role-labels.ts` (`roleLabel`, `ROLE_LABELS`) zentralisiert — Fallback DB-`label` → Code, kein Label-Regress. Die Rollen-Hierarchie (`getAllowedRoles`/`canManageUser`) bleibt bewusst code-seitig (Authz, nicht Daten).
- `tsc` + `next build` + `check:portability` strict grün; Helper-Test 4/4.

## 2026-06-23 — Orphan-User-Markierung in der Benutzerverwaltung (KAR-787 · R3)

- In der Benutzerverwaltung (`/admin/users`) tragen Accounts ohne verknüpften Mitarbeiter-Stammdatensatz jetzt einen gelben Badge **„Kein Mitarbeiter-Link"** (mit erklärendem Tooltip). „Orphan" = `user_profiles.auth_user_id` ist mit keinem `consultants.auth_user_id` verknüpft — ein Daten-Hygiene-Hinweis im 1:1-Person↔Login-Modell (KAR-769).
- Neuer getesteter Helper `lib/users/orphan.ts` (`isOrphanUser`). Loader lädt jetzt die verknüpften consultant-auth-ids und berechnet `is_orphan` server-seitig. `tsc` + `next build` + `check:portability` strict grün.
- Rest von KAR-787 (R8 Force-Create-Begründung, R47 letzte-Einladung-Status, R51/R52 Link-Indikatoren+Unlink, R53 Rollen-aus-DB) bleibt offen.

## 2026-06-23 — RLS-Harness auf project-child-Tabellen ausgeweitet (KAR-788)

- **Neuer Test** `__tests__/security/rls-project-children-isolation.test.ts`: beweist die RLS-Isolation für die „project-child"-Ownership-Klasse (gate via `project_id IN (owned projects)`) am Repräsentanten **process_steps** — user_b kann process_steps fremder Projekte nicht SELECT/UPDATE/DELETE. Deckt damit die ganze Klasse ab (qaf_uploads, workshop_actions, shift_outputs, lsc_*, kapa_* haben dasselbe `*_own`-Prädikat).
- Fixture in `scripts/rls-test/setup.sql` ergänzt; `run.sh` fährt jetzt beide RLS-Test-Files. Lokal 11/11 grün, CI-skip sauber.
- **Separates Finding dokumentiert:** `oee_records`/`planning_projects` haben `USING(true)`-SELECT-Policies → NICHT user-isoliert (bekannt aus rls-audit-2026-04-21); bewusst ausgeklammert, das ist eine Härtungs-Aufgabe, keine Test-Lücke.

## 2026-06-23 — email_queue race-proof Idempotenz (KAR-789 · R22-Follow-up)

- **Neue Migration #100** `supabase-migration-email-queue-pending-unique.sql`: partieller UNIQUE-Index `email_queue_pending_idempotent_key` auf `(lower(btrim(to_email)), template) WHERE status='pending' AND template IN ('account_created','password_reset')` — schließt das TOCTOU-Race, das der App-Level-Guard (PR #210) offenließ. Enthält einen **scoped DELETE** redundanter doppelter pending-Invite/Reset-Rows (behält die älteste je Empfänger+Template). Informationsmails bleiben unberührt.
- `queueEmail` behandelt eine Unique-Violation (23505) auf den idempotenten Templates jetzt als idempotenten No-op statt als Fehler. Test in `notifications.test.ts` ergänzt.
- Gegen PG17 verifiziert (Dedup behält ältesten + kollabiert Case/Space, Index lehnt Variante ab, Infomail-Dup weiter erlaubt, sent-Rows unangetastet, idempotent, Rollback). **Apply = operator-gated** (scoped DELETE → Backup empfohlen). Rollback: R28.

## 2026-06-23 — Release-Toggle „Projekt freigegeben" (KAR-790 · R42-Folge)

- Im Projekt-Edit gibt es jetzt einen Toggle **„Projekt freigegeben"** (`projects.is_released`), sichtbar nur für **Admin/Masteradmin oder den Projektleiter** (server-seitig via `canRelease`-Prop bestimmt; der DB-Guard `guard_projects_release` erzwingt es zusätzlich backend-seitig). Freigegebene Projekte werden für MO-2/Extern sichtbar (siehe R42).
- `is_released` fließt durch `buildProjectUpdatePayload` (immer mitgesendet; der Guard blockt nur echte Änderungen durch Unberechtigte). Tests in `project-edit-form-payload.test.ts` ergänzt. `tsc` + `next build` + `check:portability` strict grün.

## 2026-06-23 — Release-Scope für MO-2/Extern (KAR-783 · R42)

- **Neue Migration #99** `supabase-migration-projects-released-scope.sql`: `projects.is_released` (boolean, default false) + RLS so, dass **MO-2/Extern-Berater nur freigegebene** (oder eigene/granted/von-ihnen-geleitete) Projekte sehen — kein Blanket-Zugriff mehr. Implementiert als **RESTRICTIVE** SELECT-Policy `projects_released_scope` (ANDt mit den bestehenden permissiven projects-Policies, bisst nur bei release-scoped Viewern; alle anderen via `NOT current_user_is_release_scoped()` unberührt).
- **Wer darf freigeben:** BEFORE-UPDATE-Guard `guard_projects_release` — nur Admin/Masteradmin oder der Projektleiter dürfen `is_released` flippen (backend-erzwungen, mirror KAR-770-Guard).
- Der Projektleiter sieht sein eigenes unveröffentlichtes Projekt weiterhin (sonst Catch-22 beim Freigeben). Gegen PG17 verifiziert (Sichtbarkeits-Matrix MO-2/MO-23/admin/lead + Guard-Matrix lead/admin/owner-non-lead + idempotent + Rollback). **Apply = operator-gated.** Rollback: `…-projects-released-scope-rollback.sql` (R27).
- Folge-Item: UI-Toggle „Projekt freigeben" für PL/Admin (das Schema + die Rechte stehen, nur der Button fehlt).

## 2026-06-23 — UNIQUE-Email-Guard für aktive Berater (KAR-775 · R5)

- **Neue Migration #98** `supabase-migration-consultants-unique-email.sql`: partieller UNIQUE-Index `consultants_active_email_key` auf `lower(btrim(email))` für aktive Berater (`is_active = true`, E-Mail gesetzt). Verhindert künftig zwei aktive Berater mit derselben (case-/whitespace-normalisierten) E-Mail. Inaktive bleiben ausgenommen (Dedup behält einen aktiv, inaktiviert die Verlierer).
- **Pre-Condition:** Dedup-Cleanup zuerst (`scripts/dedup-dry-run-report.sql`). Ein `DO $$`-Guard bricht fail-fast mit klarer Meldung ab, wenn noch aktive E-Mail-Dubletten existieren — statt eines rohen Index-Fehlers.
- Gegen PG17 verifiziert (7-Fälle-Matrix: Guard, Index-Enforcement inkl. Case-Variante, Inaktive erlaubt, idempotent, Rollback). **Apply = operator-gated.** Rollback: `…-consultants-unique-email-rollback.sql` (R26).

## 2026-06-23 — Berater-Export-Spalten + Filter-Default (KAR-780 · R29/R50)

- **R29:** Der Berater-Stammdaten-Excel-Export enthält jetzt zusätzlich die Spalten **Status** und **Auth-User-ID** (neben employee_number/Name/Team/Rolle/Aktiv). `consultants.status` ist dafür Teil von `ConsultantRecord`.
- **R50:** Bestätigt — die Masterdata-Berater-Liste filtert bereits standardmäßig auf aktive Berater (`activeFilter`-Default `'active'`); kein Change nötig.
- `tsc` + `next build` + `check:portability` strict grün. Restliche 780-Items (R3 Orphan-Markierung, R8 Force-Create-Begründung, R46/R47/R51/R52 Link-Indikatoren, R53 Rollen-aus-DB) als fokussierter Folge-PR offen.

## 2026-06-23 — System-Dummy als Projektleiter sichtbar markiert (KAR-782 · R36)

- Wo der System-Dummy-Berater („(System) Nicht zugewiesen", `is_system_dummy`) als **Projektleiter** auftaucht, zeigt die UI jetzt einen gelben Badge **„System-Platzhalter"** — klar erkennbar, dass das Projekt eine echte Neuzuweisung braucht. Umgesetzt an der PLT-Seite (`/project/[id]/plt`, FK `project_lead_id`) und in der Projektliste (`/projektanlage`, Lead aus `project_consultants`).
- Neuer getesteter Helper `lib/employees/system-dummy.ts` (`isSystemDummyConsultant`, flag-first mit Display-Name-Fallback). Queries selektieren jetzt `is_system_dummy`. `tsc` + `next build` + `check:portability` strict grün; Helper-Test 5/5.

## 2026-06-23 — Echter RLS-Isolation-Test für projects (KAR-779 · R61)

- **Neuer Integration-Test** `__tests__/security/rls-projects-isolation.test.ts`: führt gegen ein echtes Postgres mit den App-RLS-Policies aus und beweist, dass `projects_own` Nutzer isoliert — user_b kann user_a's Projekte nicht SELECTen/UPDATEn/DELETEn und kein Projekt mit user_a's `user_id` INSERTen (WITH CHECK). Ersetzt die bisherigen `it.todo()`-Stubs für den projects-Fall durch echte Assertions.
- **CI-sicher gegated** über `RLS_TEST_DATABASE_URL` (skippt sauber, da CI keinen Postgres-Service hat). Lokal/Staging via `scripts/rls-test/run.sh` (spinnt Docker-PG17, lädt Bootstrap + Fixture, führt den Test aus — lokal grün: 6/6).
- Fixture: `scripts/rls-test/setup.sql`. `pg` als devDependency ergänzt. Volle Vitest-Suite grün (1009 passed, 6 skipped). Die übrigen user-scoped Tabellen in `rls-user-scoped.test.ts` erweitern denselben Harness.

## 2026-06-23 — Berater-Import: Dedup-Key auf E-Mail (KAR-778 · R25)

- **Excel-Berater-Import dedupliziert jetzt über E-Mail statt `last_name|first_name`.** Der Namens-Key kollidierte für Namensvettern (zwei echte Personen → falsches „update" → eine überschrieb die andere) und spaltete dieselbe Person bei geänderter Schreibweise über mehrere Importe. Neue reine Helper-Funktion `consultantDedupKey` (E-Mail normalisiert → Fallback Name-Composite für Zeilen ohne E-Mail).
- Der Import liest jetzt eine **E-Mail-Spalte** (`E-Mail`/`email`); `email` ist Teil von `ConsultantRecord` + `ConsultantImportRow` und wird mit-importiert — damit greift künftig auch der DB-seitige UNIQUE-Schutz (R5).
- Tests: `lib/repository/__tests__/consultant-dedup-key.test.ts` (6 Fälle). `tsc` + `next build` + `check:portability` strict grün.

## 2026-06-23 — email_queue-Idempotenz für Invites & Resets (KAR-778 · R22)

- **`queueEmail` ist jetzt idempotent für link-tragende Templates** (`account_created`, `password_reset`): existiert bereits eine `pending`-Row für denselben Empfänger+Template, ist der erneute Aufruf ein No-op. Damit erzeugt doppeltes „Einladung senden" / „Passwort zurücksetzen" nicht mehr zwei pending-Mails mit konkurrierenden Einmal-Links.
- Informationsmails (`password_changed`, `email_changed`, `account_deactivated`) bleiben bewusst un-dedupliziert — jedes Ereignis benachrichtigt.
- Tests: `lib/email/__tests__/notifications.test.ts` (5 Fälle: Invite/Reset-Dedup, getrennte Empfänger, gesendete Mail blockt neue nicht, Infomails nicht dedupliziert). `tsc` + `next build` + `check:portability` strict grün.
- **Hinweis:** Der Guard ist Application-Level (check-then-insert). Ein echter Race-Schutz (partieller UNIQUE-Index auf `(to_email, template) WHERE status='pending'`) ist als migrations-gated Follow-up vermerkt.

## 2026-06-23 — Dubletten Dry-Run-Report (KAR-778 · R68/R69)

- **Neu: `scripts/dedup-dry-run-report.sql`** — read-only Operator-Tool, das VOR jeder echten Dedup-Bereinigung zeigt, welche `consultants` dieselbe E-Mail teilen, welcher Record als KEEP vorgeschlagen wird und welche als INACTIVATE, plus Blast-Radius (Kalender-`assignments`, `project_consultants`, Projekte als `project_lead_id`/`created_by_consultant_id`). Sektionen A–F: Dubletten-Ranking, Blast-Radius, geschützte Records, Cross-Table consultant↔user_profile-Match ohne Link, `email_queue`-Doppel-Pending, Zusammenfassung.
- **Schutz-Regel:** admin / masteradmin / `is_system_dummy` / `is_platform_owner` werden nie als INACTIVATE markiert — die Operator-Identitäten bleiben unabhängig vom Namen erhalten (sie sind Live-DB-Rows, nicht im Repo geseedet).
- **Reines SELECT** (kein INSERT/UPDATE/DELETE/DDL); portabel für `psql` und Supabase SQL-Editor. Logik + jeder Spaltenbezug gegen ein PG17-Stub-Schema mit Seed-Daten verifiziert (KEEP/INACTIVATE/Blast-Radius/Email-Normalisierung). **Ausführung = operator-gated.**

## 2026-06-22 — Feld-Level-Rechte: Abteilung admin-managed (KAR-785 Teil B)

- **Guard erweitert (Migration #97):** der KAR-770 self-update-Guard blockt jetzt auch das Self-Ändern von `user_profiles.department_id` — die Abteilung ist admin-only (zusätzlich zu role_id/account_status). Trigger aus #84 bleibt; nur die Funktion wird ersetzt. PG17-verifiziert.
- **account-form:** das Abteilungs-Feld ist jetzt **read-only** (User sieht seine Abteilung, kann sie nicht ändern); `department_id` wird im Self-Update nicht mehr gesendet. Name/Anzeigename bleiben self-editierbar. Rolle/Status/Sichtrechte sind ohnehin admin-only.
- Damit ist „User editiert eigene Subset, Admin alles inkl. Abteilung/Sichtrechte" erfüllt. `tsc` + `next build` + portability grün. MIGRATIONS.md #97 / R25. **Apply = operator-gated.**

## 2026-06-22 — Kalender-Sichtbarkeit pro Berater + 1:1-Link verifiziert (KAR-785 D + A)

- **D — Kalender-Sichtbarkeit:** Migration #96 `consultants.show_in_calendar` (boolean, default+backfill true). Die Kalender-/Mitarbeiter-Queries filtern jetzt `.eq('show_in_calendar', true)`; im Mitarbeiterverwaltung-Editor (`/repository/masterdata/consultants`) gibt es eine neue „Kalender"-Toggle-Spalte (CalendarCheck/CalendarX). Default true → alle inkl. MO-23/24 bleiben sichtbar, Einzelne abschaltbar. PG17 + `next build` + portability grün.
- **A — 1:1 Berater↔Benutzer-Link:** verifiziert, dass die 1:1-Beziehung bereits DB-seitig erzwungen ist (`consultants.auth_user_id` UNIQUE **und** `user_profiles.auth_user_id` UNIQUE); der „Mitarbeiter verknüpfen"-Tab zeigt nur unverknüpfte Berater (`auth_user_id IS NULL`). Kein Code-Change nötig.
- Teil B (Feld-Level-Rechte) + C (Einladungs-Mail, blockiert bis KAR-772) bleiben offen (KAR-785).

## 2026-06-22 — UI: Abteilungs-Sichtbarkeit + per-Projekt-Sichtrechte (KAR-784 Phase 2)

- **Admin-Settings (Feature A):** neue Seite `/repository/masterdata/abteilungs-sichtbarkeit` (admin-gated) zum Verwalten der `department_visibility`-Paare (Abteilung X sieht Projekte von Y, optional gegenseitig). Karte im Stammdaten-Index.
- **Projekt-Detail (Feature B):** neue „Sichtrechte"-Sektion auf `/project/[id]` — Admin/Projektleiter/-Owner können Einzelpersonen außerhalb der Abteilung als Sichtrecht (`project_view_grants`) freischalten/entfernen.
- Beide schreiben via Browser-Supabase-Client; RLS (`dv_admin`, `pvg_insert/delete`) erzwingt, wer wirklich darf. Native `<select>` + rohe Tables (Repo-Stil). `tsc` + `next build` + `check:portability` strict grün.
- Setzt auf der Datenseite aus Phase 1 (#95) auf. **Damit ist KAR-784 komplett** (Daten + RLS + UI).

## 2026-06-22 — Cross-Dept-Projektsicht + per-Projekt-Sichtrechte (KAR-784 R40 erweitert, Phase 1)

- **2 neue Tabellen:** `department_visibility(viewer_department_id, visible_department_id)` (admin-konfigurierbar; geseedet MO-23↔MO-24, beide Richtungen) + `project_view_grants(project_id, user_id)` (per-Projekt-Sichtrecht für Einzelpersonen).
- **projects-RLS (additiv):** SELECT = eigene Abteilung ODER konfigurierte Cross-Dept (`projects_dept_visible`) ODER per-Projekt-Grant (`projects_view_granted`); UPDATE = nur eigene Abteilung (`projects_dept_edit`) — Cross-Dept + Grants sind reine Sicht. Backfill `projects.department_id` aus `responsibility_id`.
- **Verifikation (PG17)** fing 2 echte Bugs: Policy-Rekursion projects↔project_view_grants (pvg-Writes per-Command gesplittet) + fehlende Table-Grants. Danach: MO-23 sieht MO-24 (und umgekehrt), kann sie aber nicht editieren; Grant-User sieht nur; owner/admin verwalten Grants; admin sieht alles.
- MIGRATIONS.md #95 / R23. **Apply = operator-gated.** UI (Admin-Settings + per-Projekt-Grant) = Phase 2.

## 2026-06-22 — Security: master_data_suggestions owner-INSERT (KAR-777 Tranche C)

- **Tranche C (#94):** `master_data_suggestions.mds_insert` (`WITH CHECK true`, erlaubte gefälschtes `created_by`) → `mds_insert_own` mit `created_by = auth.uid()`. UPDATE bleibt admin.
- **Tranche D** (`project_suppliers`): **no-op** — hat bereits `ps_own` + `ps_admin`, keine permissive Policy.
- Gegen PostgreSQL 17 verifiziert (own erlaubt, forged blockiert). MIGRATIONS.md #94 / R22. **Apply = operator-gated.**
- R40 (Dept-Sicht) wurde nach Kais-Feedback erweitert (Cross-Dept MO-23↔24 konfigurierbar + per-Projekt-Grants) → eigenes Issue KAR-784; R42 → KAR-783.

## 2026-06-22 — Security: USING(true) Write-Härtung Tranche B-2 (KAR-777 R43/R44)

- **Letzte 2 `USING(true)`-Write-Tabellen, mit NEUEN Policies:**
  - `documents` hatte gar keine owner-Write-Policy → neu `documents_own` (`uploaded_by = auth.uid()` ODER parent-project-owner) + `documents_admin`; permissive insert/update/delete gedroppt (SELECT via `docs_select` unberührt).
  - `project_consultants`: `auth_insert`/`auth_delete` (project-owner-scoped) bleiben; permissive `pc_*` gedroppt; neue scoped `auth_update_project_consultants` (project-owner ODER admin).
- **Gegen PostgreSQL 17 verifiziert** (owner/project-owner/admin dürfen, fremder via RLS blockiert, service_role durch). Subtilität gefangen: UPDATE braucht SELECT-Policy-Sichtbarkeit (auf Prod vorhanden). MIGRATIONS.md #93 / R21. **Apply = operator-gated.**
- Damit ist die `USING(true)`-Write-Härtung (R43/R44) durch. Offen in 777: Tranche C (`master_data_suggestions` split), D (`project_suppliers`), R40.

## 2026-06-22 — Security: USING(true) Write-Härtung Tranche B-1 (KAR-777 R43/R44)

- **Owner-Scoping wiederhergestellt:** 5 Tabellen hatten neben korrekten owner-scoped + admin Policies noch legacy `USING(true)`-Write-Duplikate: `assignments`, `document_departments`, `document_metadata`, `document_suppliers`, `oee_loss_categories`.
- **Fix (Migration #92, idempotent):** entfernt die Duplikate; die bestehenden `_own` (created_by bzw. document→project / oee_record-Join) + `_admin` Policies erzwingen das Scoping. service_role unberührt.
- **Gegen PostgreSQL 17 verifiziert** (beide Ownership-Modelle: owner darf eigene, fremder via RLS blockiert, admin + service_role durch). MIGRATIONS.md #92 / R20. **Apply = operator-gated.**
- Offen: Tranche B-2 (`documents` braucht neue owner-Policy via uploaded_by; `project_consultants` scoped UPDATE), C (`master_data_suggestions`), D (`project_suppliers`), R40.

## 2026-06-22 — Security: USING(true) Write-Härtung Tranche A (KAR-777 R43/R44)

- **Privilege/Integrity-Fix:** 5 Tabellen hatten neben ihrer korrekten Admin-Policy noch legacy `USING(true)`-Write-Policies, die per OR jeden authenticated User schreiben ließen: `appointment_types`, `email_queue`, `master_data_import_jobs`, `planning_projects` (dead UI), `supplier_master_data`.
- **Fix (Migration #91, idempotent):** entfernt die redundanten permissive Write-Policies (Admin-Policy bleibt → admin-only); `master_data_import_jobs` `import_jobs_all` wird durch admin-only ersetzt. service_role-Writes (Import/Notifications) unberührt. Write-Path gegen Prod auditiert (alle 5 nur admin-gated UI oder service_role).
- **Gegen PostgreSQL 17 verifiziert** (Vuln vorher reproduziert; non-admin INSERT blockiert, Admin + service_role erlaubt). MIGRATIONS.md #91 / R19. **Apply = operator-gated.**
- Folgt: Tranche B (owner-scoped: assignments, oee_*, documents, project_consultants, project_type_assignments), C (master_data_suggestions split), D (project_suppliers vestigial), R40.

## 2026-06-22 — Department-Single-Source: department_master_data = MO-Codes (KAR-775 R11/R13/R48/R14)

- **Kais-Decision:** kanonische Abteilung = MO-2/MO-23/MO-24/Extern. Die generischen Org-Departments (AE/EK/FE/LO/PR/QM) waren falsch und fliegen raus; KADI + „Gemeinsam"/joint sind keine Abteilungen.
- **Migration #89 (destruktiv, idempotent):** ersetzt den Inhalt von `department_master_data` durch die MO-Codes (FK-sicher: SET NULL/CASCADE, `projects` defensiv vorab genullt). Fügt `consultants.department_id`-FK → `department_master_data` hinzu + Backfill aus `team_code` (MO-* → match, KADI → NULL).
- **Effekt ohne UI-Code-Change:** User-Dropdown (`create/edit-user`, account) und Projekt-`department_id`-Dropdown lesen beide aus `department_master_data` → zeigen jetzt dieselben MO-Codes (R11/R13/R48 erfüllt). Behebt zugleich den `create-user-modal`-Bug (Select auf zuvor nicht-existente `consultants.department_id`).
- Gegen PostgreSQL 17 verifiziert (Content-Tausch, EK-User SET NULL, Backfill MO-*/KADI, idempotent, Rollback). MIGRATIONS.md #89 / R17. **Apply destruktiv → operator-gated, Backup vorab.**

## 2026-06-22 — Fortlaufende Mitarbeiter-Nummer für Berater (KAR-775 R4)

- **Migration #88:** `consultants.employee_number` (integer, nullable) + Sequence als DEFAULT + partieller UNIQUE-Index; Backfill bestehender Berater fortlaufend in stabiler Reihenfolge (`created_at`, `id`). PG17-verifiziert (Backfill 1/2/3, neue Zeile = nächste Nr., Duplikat abgelehnt, idempotent, Rollback).
- **Anzeige:** `formatEmployeeNumber` (zero-padded `001`, `—` bei NULL) in der Berater-Stammdaten-Tabelle (neue read-only Spalte „Nr.") und im Excel-Export (Spalte „Mitarbeiter-Nr."). `ConsultantRecord` um `employee_number` erweitert; Inserts senden das Feld bewusst nicht (DB-Sequence-Default greift).
- 3 Unit-Tests (Format-Helper), `tsc` + `check:portability` strict grün. **Apply = operator-gated.**
## 2026-06-22 — Projektleiter-Reassignment + System-Dummy beim Berater-Löschen (KAR-776 R33/R34/R36)

- **R34 (Migration #87):** `consultants.is_system_dummy` + partieller UNIQUE-Index (max. ein globaler Dummy) + Seed `(System) Nicht zugewiesen`. PG17-verifiziert (idempotent, Single-Dummy erzwungen, Rollback).
- **R33:** `reassignLeadAndCreator` — beim Löschen eines Projektleiters wird der erste „Weitere Berater" zum Lead hochgestuft (`project_consultants.role_in_project='lead'` + `projects.project_lead_id`), sonst erbt der globale System-Dummy; `created_by_consultant_id` wird auf NULL gesetzt. Der DELETE-Handler reassignt jetzt automatisch statt zu blocken (Fallback: sicherer 409-Block, falls weder Berater noch Dummy vorhanden). Reassignment landet im `consultant.deleted`-Audit-Log.
- `dependency-check`: `canHardDelete` basiert jetzt auf den History-Soft-Deps (assignments/project_consultants); `leadProjects` sind informativ (werden reassignt). Dialog warnt entsprechend („wird automatisch neu zugewiesen").
- 12 Unit-Tests grün (reassign-Logik + dependency-check), `tsc` + `check:portability` strict grün.
- **Offen R36:** UI-Badge „System-Platzhalter" überall dort, wo ein Dummy als Projektleiter angezeigt wird (Planning/Projekt-Views) — Folge-Schritt.

## 2026-06-22 — Delete-Dependency-Check inkl. project_lead_id / created_by_consultant_id (KAR-776 R31/R32)

- **Bug:** `checkEmployeeDependencies` prüfte nur `assignments` + `project_consultants` (beides `ON DELETE CASCADE` → blockieren gar nicht), aber nicht `projects.project_lead_id` / `projects.created_by_consultant_id` (beides `ON DELETE RESTRICT`). Ein Berater, der Projektleiter/Ersteller war, ließ sich per „Endgültig löschen" scheinbar entfernen, lief aber in einen rohen FK-Violation-Fehler.
- **Fix:** Der Check zählt jetzt zusätzlich die RESTRICT-FK-Projekte und liefert ihre Namen (`leadProjects`). Der DELETE-Handler blockt diese hart (auch bei `force=1`) mit 409 + Projektnamen, bevor der FK-Fehler durchschlägt; der Lifecycle-Dialog listet die betroffenen Projekte und fordert Neuzuweisung der Projektleitung.
- Unit-Tests für die Aggregation (Dedup Lead∩Ersteller, Label mit/ohne `project_code`, Cascade- vs. RESTRICT-Gating). `tsc` + `check:portability` strict grün.
- Hinweis: **R35/R67 (Audit-Log beim Hard-Delete) war bereits implementiert** (`consultant.deleted` in route.ts) — kein Change nötig.

## 2026-06-22 — Security: projects_admin RLS — Admins sehen/verwalten alle Projekte (KAR-777 R37)

- **Gap:** `projects` hatte nur `projects_own USING(user_id=auth.uid())` → Admin/Masteradmin sahen nur ihre eigenen Projekte.
- **Fix (Migration #86, additiv):** neue permissive Policy `projects_admin` (`FOR ALL TO authenticated`, `current_user_role() = ANY('admin','masteradmin')`). Postgres OR-kombiniert → Admin = alle Projekte, normale User = weiterhin nur eigene. `projects_own` unverändert.
- **Gegen PostgreSQL 17 verifiziert** (Gap vorher; 5/5 nachher; Rollback). MIGRATIONS.md #86 / R14. **Apply = operator-gated.**

## 2026-06-22 — Security: consultants Write-RLS auf Admins beschränkt (KAR-777 R43/R44)

- **Privilege Escalation geschlossen:** `consultants` hatte `FOR INSERT/UPDATE/DELETE`-Policies mit `USING(true)`/`WITH CHECK(true)` für `authenticated`. Jeder eingeloggte User konnte per Browser-Client `consultants.role` auf `masteradmin` setzen.
- **Fix (Migration #85, idempotent):** Write-Policies auf `current_user_role() = ANY('admin','masteradmin')`. Legit-Writes (service_role-API-Routen umgehen RLS; admin-gated Stammdaten-UI `/repository/masterdata`) bleiben funktionsfähig. `SELECT` bewusst breit gelassen.
- **Gegen PostgreSQL 17 verifiziert:** Vuln vorher reproduziert, 6/6-Matrix nachher (Non-Admin update/insert/delete blockiert, Admin + service_role erlaubt), Rollback stellt wieder her. MIGRATIONS.md #85 / R13. **Apply = operator-gated.**

## 2026-06-22 — Security: Guard gegen user_profiles Self-Update-Eskalation (KAR-770)

- **RLS-Lücke geschlossen:** Die Policies `up_update`/`user_profiles_update_self` ließen einen `authenticated`-User seine eigene Zeile ohne Spaltenschutz ändern → er konnte sein `role_id` auf admin setzen oder sich nach einer Deaktivierung selbst reaktivieren.
- **Fix (Migration, additiv):** BEFORE-UPDATE-Trigger `guard_up_self_update` auf `user_profiles`. Ein normaler User darf auf der eigenen Zeile `role_id` NICHT ändern und `account_status` nur `invited`/`pending_registration` → `active` (Onboarding), nie self-reaktivieren. `must_change_password` + sonstige Felder bleiben offen → change-password/reset/account-form-Flows unberührt. SECURITY INVOKER → `service_role`/Migrationen/Admins laufen durch.
- **Gegen PostgreSQL 17 verifiziert** (8/8-Matrix: Eskalation + Reaktivierung blockiert, Legit-Flows + Admin/service_role-Bypass + Rollback OK). MIGRATIONS.md #84 / R12. **Apply = operator-gated.**

## 2026-06-22 — Dedup-Härtung im Benutzer-Anlegen (KAR-778 / KAR-780, R6/R10/R46)

- **Email-Dublettencheck auch über `consultants`** (R6/R10): Der Check beim Anlegen prüfte nur `user_profiles` (`check_email_in_profiles`-RPC). Jetzt wird zusätzlich `consultants` per Email geprüft — ein bestehender Berater mit gleicher Email wird erkannt und der Admin auf „Bestehenden verknüpfen" (Tab 2) verwiesen, statt eine Dublette anzulegen.
- **Name-Match-Warnung** (R46): Bei Vorname+Nachname wird auf einen bereits existierenden, unverknüpften Berater gleichen Namens hingewiesen (Vorschlag: verknüpfen statt neu anlegen).
- Rein clientseitig, additiv (keine Migration, kein Schema-Change); Gates grün.

## 2026-06-22 — Feature: Admin „Einladungs-Link kopieren" (KAR-772 Interim)

- **Onboarding ohne Mail-Worker:** Solange der `email_queue`-Versand-Worker fehlt (KAR-772), kann ein Admin jetzt einen frischen Einladungs-Link direkt erzeugen und manuell verschicken.
- Neue Route `POST /api/admin/users/invite-link` — gated (`canManageUser`, CSRF, admin/masteradmin), erzeugt via `generateRecoveryLink` einen `/auth/confirm`-Link und gibt ihn dem Admin im Response zurück. **Nicht gespeichert** (anders als der `email_queue`-Pfad) → KAR-771-konform. Audit-Log `user.invite_link_generated`.
- UI (`users-client.tsx`): Button „Einladungs-Link kopieren" bei `invited`/`pending_registration`-Usern + Modal mit kopierbarem Link (Auto-Clipboard + manueller Fallback).
- Der echte SMTP-Versand-Worker bleibt offen (KAR-772).
- Gates grün: typecheck, 974 Tests, `next build`, `check:portability` strict.

## 2026-06-22 — Fix: Audit-Defekte aus dem SupplierPulse-Requirements-Abgleich (KAR-773, KAR-774)

- **KAR-773 (Security):** `lock`/`unlock`-Routen (`app/api/admin/users/{lock,unlock}/route.ts`) ergänzen jetzt den `canManageUser`-Gate (Target-Rolle laden → 403 wenn nicht verwaltbar). Vorher konnte ein `admin` einen `masteradmin` sperren/entsperren — Bruch der Rollen-Hierarchie. Spiegelt das invite/reset-Pattern.
- **KAR-774 (Bug):** Berater-Excel-Import (`consultant-master-data-client.tsx`) legt `update`-Zeilen nicht mehr als Duplikate per INSERT an. `new`-Zeilen → INSERT, `update`-Zeilen → UPDATE des per Name eindeutig gematchten Bestandsdatensatzes (per `id`). Mehrdeutige Namens-Treffer werden übersprungen statt den falschen Datensatz zu überschreiben (echte email-basierte Dedup: KAR-778). Ergebnis-Anzeige zeigt „X aktualisiert".
- Gates grün: typecheck, 974 Tests, `next build`, `check:portability` strict.
## 2026-06-21 — Auth: Scanner-resistenter Invite/Reset-Link über /auth/confirm (KAR-772)

- **Problem:** Der Recovery-Link zeigte auf Supabases GET-`/auth/v1/verify`, der den Einmal-Token beim ERSTEN Abruf einlöst. Enterprise-Mailsecurity (Defender/SafeLinks) und Chat-Link-Vorschauen *prefetchen* Links → Token verbraucht, bevor der echte Empfänger klickt → „otp_expired". Im Prod-Test reproduziert.
- **Fix:** Neue Seite `app/auth/confirm/page.tsx` löst den `token_hash` per `verifyOtp` erst bei **explizitem User-Klick** ein. Ein Prefetch rendert nur die Seite, verbraucht aber nichts.
- `lib/auth/recovery-link.ts`: baut jetzt einen `/auth/confirm?token_hash=…&type=recovery&next=…`-Link aus `generateLink().properties.hashed_token` statt des `action_link`. Signatur `(email, origin, next)`.
- Invite-Route → `next=/auth/change-password`, Admin-Reset-Route → `next=/reset-password`.
- Voraussetzung (Supabase Dashboard, bereits gesetzt): Site URL = Prod, Redirect-Allowlist enthält `/auth/confirm`.
- Gates grün: typecheck, 974 Tests, `next build` (`/auth/confirm` in der Route-Map), `check:portability` strict.

## 2026-06-21 — Security: Recovery-Link nicht mehr admin-lesbar in email_queue (KAR-771)

- **Schließt den BLOCKER von KAR-769:** Der Magic-/Recovery-Link liegt in `email_queue.template_data` — vorher konnte jeder Admin via `email_queue_admin`-RLS-Policy `SELECT template_data->>'link'` ausführen und sich als der eingeladene User einloggen.
- **Column-Level-Privilege-Fix:** Migration `supabase-migration-email-queue-protect-template-data.sql` entzieht `authenticated` das blanket `SELECT` auf `email_queue` und re-grantet alle Spalten außer `template_data`. Row-Sichtbarkeit bleibt über die bestehende RLS-Policy. Der Mail-Worker liest per `service_role` (umgeht Column-Privileges) → Versand unberührt.
- **App-Reader auf explizite Spalten umgestellt** (forward-kompatibel, funktioniert vor + nach der Migration): Retry-Route `app/api/admin/emails/retry/route.ts` (`.select()` → explizite Spalten), `components/admin/users-client.tsx` (3× Count-Query `select('*')` → `select('id')`). `app/admin/emails/page.tsx` selektierte bereits explizit.
- **Deploy-Reihenfolge:** erst Code deployen, DANN Migration applizieren (Prod-Migration = operator-gated, siehe MIGRATIONS.md #83). Rollback R11 vorhanden.
- Gates grün: typecheck, 974 Tests, `next build`, `check:portability` strict.

## 2026-06-21 — Security: Adversarial-Review-Fixes am Magic-Link-Invite-Flow (KAR-769 Phase 2b)

- **Reactivation-Schutz auf der Reset-Seite (Finding 1):** `app/(auth)/reset-password/page.tsx` setzt `account_status='active'` jetzt nur noch, wenn der aktuelle Status `invited`/`pending_registration` ist. Ein per Admin deaktivierter/gesperrter User mit noch gültigem Recovery-Link kann sich nicht mehr über die Seite selbst reaktivieren. (`must_change_password=false` wird weiter gesetzt, da `invited` kein blockierter Zustand ist.)
- **CSRF-Lücke geschlossen (Finding 3):** `checkCsrf` auf `PATCH` + `DELETE` in `app/api/admin/users/route.ts` nachgezogen — vorher nur auf `POST`. Konsistent mit invite/reset-Routen.
- **Kein stilles E-Mail-Versagen (Finding 4):** `queueEmail` (`lib/email/notifications.ts`) liest jetzt den `error` der `email_queue`-Insert und wirft statt still `{ ok: true }` zu liefern — eine Invite/Reset-Route meldet keinen Erfolg + Audit-Log mehr, wenn keine Mail eingereiht wurde.
- **Backend-Privilege-Check + 48-Zeichen-Platzhalter-PW** (aus Phase 2a übernommen): Reset darf keinen User treffen, den der Admin nicht verwalten darf (`canManageUser`); generiertes Platzhalter-Passwort 12 → 48 Zeichen.
- Gates grün: typecheck, 974 Tests, `next build`, `check:portability` strict.
- **Offene Merge-Blocker (eigene Issues, nicht in diesem PR fixbar):** KAR-771 (Recovery-Link liegt klartext in `email_queue.template_data`, admin-lesbar — braucht externen Mail-Worker), KAR-770 (RLS `user_profiles` Self-UPDATE ohne Spaltenschutz — braucht Prod-Migration).

## 2026-06-11 — Feature: Feld-Hinweise im VSM-Editor (KAR-726 Sweep)

- **16 ⓘ-Hints im Wertstrom-Editor-Properties-Panel:** Zykluszeit, Maschinenzeit, Manuelle Zeit, Rüstzeit, Wartezeit (Prozess + Bestand getrennt), Kapazität, Anzahl Mitarbeiter, Prozessart, Wertschöpfend, OEE, Menge, Distanz, Transportzeit, Stk/Tag, Zeitwert. Lean-Erklärungen mit Formel/Beispiel wo sinnvoll (OEE, Kapazität, Kundentakt, Reichweite).
- Seed-Migration `supabase-migration-field-hints-batch3-vsm.sql` (16 Keys `vsm.*`, `ON CONFLICT DO NOTHING`), Wiring via bestehendem `loadTooltips`/`FieldHint`-System (KAR-700); Ausfall der Tooltip-Query blockiert den Editor nicht (`.catch(() => [])`).
- Damit ist der KAR-726-Sweep abgeschlossen: Auswertungs-Charts + Excel-Import-Modal haben keine hint-fähigen Felder; verbleibende Kleinst-Kandidaten (Assessment-Admin, Planning-Bulk-Edit, Employee-Lifecycle) sind interne Admin-UIs ohne Lean-Fachbegriffe.

## 2026-06-11 — Feature: Stations-Takt-Overrides + Stufen-Linie im Takt-Diagramm, Stoppuhr-Feld-Hinweise (KAR-726, KAR-727)

- **Kundentakt + Target-CT pro Station (KAR-727):** Neue Migration `supabase-migration-process-steps-station-takt.sql` — `process_steps.customer_takt_time_sec` + `.target_cycle_time_sec` (nullable, NULL = erbt Projektwert). In der Takt-Ziele-Card erscheint unter den Projektwerten ein Override-Block für die aktive Station (leer = Projektwert gilt, Placeholder zeigt den geerbten Wert). Optimistisches Update mit Rollback wie bei den Projekt-Zielen.
- **Verbundene Stufen-Linie im Takt-Diagramm (KAR-727):** `YamazumiChart` rendert bei vorhandenen Stations-Overrides statt der flachen Referenzlinie eine verbundene Step-Linie über die effektiven Werte je Station (Kundentakt rot gestrichelt, Target-CT durchgezogen). Balkenfärbung „über Kundentakt", Tooltip („Kundentakt", „Target-CT", „vs Takt") und Y-Skala rechnen mit dem effektiven Stations-Wert. Ohne Overrides bleibt die bisherige beschriftete Referenzlinie.
- **Stoppuhr-Feld-Hinweise (KAR-726, Sofort-Teil):** ⓘ-Hints auf Ø CT, Anzahl, vs. Takt sowie auf allen vier Takt-Ziele-Inputs (Projekt + Stations-Override). Seed-Migration `supabase-migration-field-hints-batch2-stopwatch.sql` (7 Keys, ON CONFLICT DO NOTHING). Sweep-Audit: Stoppuhr-Auswertungs-Charts und Excel-Import-Modal haben keine hint-fähigen Felder (selbsterklärende Chart-Labels bzw. Fließtext); größter offener Block ist der VSM-Editor (~20 Felder) — bleibt in KAR-726.
- **Nebenfix:** `YamazumiChart`-Tooltip aus dem Render gehoben und typisiert (behebt zwei bestehende ESLint-Errors `no-explicit-any` + `react-hooks/static-components`).

## 2026-06-11 — Feature: Takt-Ziele inline in der Stoppuhr + Stationen-Filter im Takt-Diagramm (KAR-724, KAR-725)

- **Takt-Ziele direkt auf der Stoppuhr-Seite (KAR-724):** Neue kompakte Card „Takt-Ziele" in der Stats-Spalte mit zwei Number-Inputs (Kundentakt, Target-CT in Sekunden). Save auf Enter/Blur schreibt `projects.customer_takt_time_sec`/`target_cycle_time_sec`; optimistisches Update mit Rollback + sichtbarer Fehlermeldung bei DB-Fehler. Timer-Warnung, „vs. Takt"-Stat und Yamazumi-Referenzlinien ziehen ohne Reload mit. Gilt für beide Stoppuhr-Routen (LSC-Workshop + Projekt).
- **Stationen im Takt-Diagramm ein-/ausblenden (KAR-725):** Neuer Button „Stationen" im Takt-Diagramm-Header öffnet ein Chip-Panel (eine Chip pro Station, Klick toggelt; „Alle anzeigen/ausblenden"). Bottleneck-Markierung und Y-Skala berechnen sich automatisch nur über die sichtbaren Stationen. Client-State only (kein Persist, bewusst V1).

## 2026-06-09 — Feature: Excel-Download/Upload pro Prozess in der Stoppuhr-Auswertung (KAR-715)

- Jede Prozess-Karte in den Auswertungen hat jetzt zwei Buttons: **Excel** (Download) und **Upload**.
  - **Download** exportiert die Messungen genau dieses Prozesses als `.xlsx` (Spalten Station, Cycle #, CT (seconds), Notes — gleiches Layout wie die Import-Vorlage, also re-import-fähig).
  - **Upload** **überschreibt** die Messungen dieses Prozesses mit der Excel-Datei: nach Sicherheitsabfrage werden alle bestehenden `cycle_measurements` der Station gelöscht und die geparsten Zeilen neu eingefügt (#1..n). Der Chart erzeugt sich live neu — auch mit mehr/weniger Messungen.
- `lib/stopwatch/excel-import.ts`: neue, getestete Pure-Funktionen `buildProcessExportRows` + `buildOverwriteInserts` und der Blob-Builder `buildProcessMeasurementsBlob` (5 neue Vitest-Cases). `components/lsc-workshop/stopwatch-per-process-charts.tsx`: optionale `onDownload`/`onUpload`-Props + Buttons je Karte. `lsc-stopwatch-client.tsx`: `handleProcessDownload`/`handleProcessUpload` (delete-then-insert, scoped auf eine Station, mit Confirm + Fehler-Handling).

## 2026-06-09 — UX-Politur: Stoppuhr-Charts (Ø-Linie + Takt-Diagramm-Legende, KAR-714)

- **Prozess-Charts (`stopwatch-per-process-charts.tsx`):** Die Ø-Linie ist jetzt deutlich sichtbar — eigene Farbe (Amber `#D97706`), dicker (2px), und das **Label zeigt den vollen Wert** („Ø 31,3 s" statt nur „ø", das vorher am rechten Rand abgeschnitten wurde; Position `insideTopRight`).
- **Takt-Diagramm (`yamazumi-chart.tsx`):** Legende und Balkenfarben stimmen jetzt überein. Die Legenden-Swatches nutzen exakt die Balken-Hex (Observed `#4A9E9E`, Über Kundentakt `#e07070`, Bottleneck `#CC0000`, Planned grau) statt vorher `bg-primary`/`bg-destructive` (die nicht zu den echten Balken passten). Neue Einträge: „Über Kundentakt", „Target" und „Aktive Station". Die aktive Station ist jetzt ein **Rahmen** (Selektions-Zustand), keine eigene Füllfarbe mehr → die Füllung kodiert immer die Daten-Kategorie.

## 2026-06-09 — UX: Klick auf Takt-Diagramm-Balken wählt die Station (Stoppuhr, KAR-713)

- Im Live-Takt-Diagramm (Yamazumi) der Stoppuhr wählt ein Klick auf einen Balken jetzt die zugehörige Station aus — vorher passierte nichts. `lsc-stopwatch-client.tsx`: `onStepClick={selectStation}` an `<YamazumiChart>` durchgereicht (das `onStepClick`-Prop existierte bereits, war aber nicht verdrahtet). Damit ist die Verknüpfung Station-Kachel ↔ Diagramm jetzt **beidseitig**: Kachel→Chart (KAR-711) und Chart→Kachel (dieser Fix), inkl. Highlight + Sprung zum Prozess-Diagramm.

## 2026-06-09 — UX: Station-Kachel springt zum Prozess-Diagramm (Stoppuhr, KAR-711 Phase 1)

- Klick auf eine Station-Kachel in der Stoppuhr setzt nicht nur die aktive Station, sondern **scrollt zum zugehörigen Prozess-Diagramm** in den Auswertungen und **hebt es hervor** (Rahmen + Ring). `lsc-stopwatch-client.tsx`: neuer `selectStation`-Handler (ersetzt das blanke `setActiveStepId` am Kachel-Button), klappt die Auswertungen auf und scrollt `#auswertung-process-<stepId>` smooth in den Blick (Fallback: Auswertungen-Bereich).
- `stopwatch-per-process-charts.tsx`: jede Karte trägt jetzt `id="auswertung-process-<stepId>"` (Scroll-Anker) und wird bei aktiver Station hervorgehoben (`border-primary ring-2`). Neuer optionaler `activeStepId`-Prop.
- Das Yamazumi-Live-Diagramm hob die aktive Station bereits hervor — Verhalten jetzt konsistent.
- App-weiter Rollout des Select→Scroll-to-Detail-Patterns als KAR-711 Phase 2 im Backlog.

## 2026-06-09 — Feature: Stoppuhr-Auswertung jetzt live in der Aufnahme-Seite (ersetzt statische Unterseite)

- Die zwei Auswertungs-Diagramme sind jetzt **live in die Stoppuhr-Seite** eingebettet (`lsc-stopwatch-client.tsx`, neuer einklappbarer Bereich „Auswertungen", per „Auswertung"-Button im Header anspringbar). Sie leiten sich direkt aus dem `measurements`-State ab und aktualisieren sich daher **sofort bei Aufnehmen / Bearbeiten / Löschen — ohne Reload**.
- Beide Stoppuhr-Server-Pages (`app/project/[id]/stopwatch/page.tsx`, `app/lsc-workshop/[id]/stoppuhr/page.tsx`) laden Messungen jetzt für **alle** Stationen (`.in('process_step_id', stepIds)` statt nur die aktive), damit die Live-Charts alle Prozesse abdecken. Die Aufnahme-UI filtert weiterhin client-seitig auf die aktive Station; der Live-Timer bleibt isoliert (KAR-704), die Charts re-rendern nur bei Mess-Änderungen, nicht beim Ticken.
- Die zuvor server-gerenderte (statische) Unterseite `app/project/[id]/stopwatch/auswertung/page.tsx` wurde **entfernt** — die Live-Variante ersetzt sie.
- Leerer-Zustand: „Noch keine Messungen vorhanden". Reihenfolge via `cycle_number`-Sort, Ausreißer/ungültige Werte raus aus dem Ø (zentrale `avgCycleTimeSec`). Diagramm-Komponenten + `lib/stopwatch/evaluation.ts` (9 Vitest-Cases) unverändert wiederverwendet.

## 2026-06-09 — Feature: Stoppuhr-Auswertung mit zwei Diagrammen

- Neue Unterseite `app/project/[id]/stopwatch/auswertung/page.tsx` — lädt alle Stationen (= Prozesse) eines Projekts und deren `cycle_measurements` und rendert zwei Balkendiagramm-Sektionen. Erreichbar über den neuen „Auswertung"-Button im Stoppuhr-Header (`lsc-stopwatch-client.tsx`); die Charts liegen bewusst auf einer eigenen Seite statt auf der mobil-touch-empfindlichen Aufnahme-UI (KAR-704).
- **Diagramm 1 — Messungen pro Prozess** (`components/lsc-workshop/stopwatch-per-process-charts.tsx`): je Prozess ein eigenes Diagramm, je Messung ein Balken (`#1, #2, #3 …` in Aufnahme-Reihenfolge, y = Zeit in s). Ausreißer werden gedämpft gezeichnet, eine gestrichelte Linie markiert den Ø ohne Ausreißer.
- **Diagramm 2 — Durchschnittsvergleich** (`components/lsc-workshop/stopwatch-average-chart.tsx`): ein Balken je Prozess, y = Ø-Zeit (Ausreißer/ungültige Werte ausgeschlossen).
- `lib/stopwatch/evaluation.ts` — neue, getestete Pure-Funktionen `buildPerProcessSeries` / `buildProcessAverages` / `formatSeconds`. Mess-Reihenfolge bleibt erhalten (Sort nach `cycle_number`), ungültige Werte (null/NaN/≤0) fallen raus, Durchschnitt delegiert an die zentrale `avgCycleTimeSec` (KAR-704 O7) → konsistente Ausreißer-Logik. Einheitliche Sekunden-Formatierung. 9 Vitest-Cases in `lib/stopwatch/__tests__/evaluation.test.ts`.

## 2026-06-08 — BUG-Fix: Reporting-OEE nicht mehr durch globales Row-Limit beschnitten (KAR-704 O9)

- `app/reporting/page.tsx` — die `oee_records`-Query hatte ein globales `.limit(500)` (nach Aktualität sortiert). Bei vielen Linien×Wochen fielen ganze Linien/ältere Projekte aus der OEE-Übersicht, weil aktivere Linien das 500er-Fenster füllten. Ersetzt durch ein **24-Monats-Fenster** (`.gte('year', currentYear - 2)`, named const `OEE_REPORTING_WINDOW_YEARS`) — bounded, ohne aktive Linien zu verlieren. Die „letzter Stand pro Linie"-Deduplizierung im Dashboard bleibt unverändert. Aus dem Redundanz-Audit (KAR-704, O9). Scale-proof-Variante (DB-View `DISTINCT ON`) ist als optionaler Follow-up offen.

## 2026-06-08 — Refactor: Durchschnitts-Zykluszeit zentralisiert (KAR-704 Quick-Win O7)

- `lib/reporting/aggregations.ts` — neue exportierte `avgCycleTimeSec(measurements)` als single source: Mittel der Nicht-Ausreißer-Messungen (`is_outlier` falsy), `null` bei keiner gültigen Messung. `calcBottleneck` dogfooded den Helper jetzt (Verhalten identisch).
- 8 Stellen, die den Schnitt vorher inline berechneten, nutzen jetzt den Helper: `export-client`, `lsc-export-client`, `yamazumi-chart`, `cycle-detail-chart`, `stopwatch-client`, `lsc-stopwatch-client`, `process-steps-manager`, `app/project/[id]/page.tsx`. Alle hatten verifiziert **identische** Semantik (Ausreißer-Filter + null-bei-leer) → reiner verhaltens-erhaltender Extract.
- `lib/reporting/__tests__/aggregations.test.ts` — 4 neue Cases (leer, alle Ausreißer, Ausreißer-Ausschluss, fehlendes `is_outlier`).
- VSM/Wertstrom (`vsm-metrics.ts`) bewusst NICHT angefasst — das ist eine Summe von Node-Zeiten, nicht der Messreihen-Schnitt. Aus dem Redundanz-Audit (KAR-704, O7).

## 2026-06-08 — Refactor: OEE-Status-Farben auf Design-Tokens zentralisiert (KAR-704 Quick-Win O5)

- `lib/oee/colors.ts` (neu, getestet): `oeeColorVar()` (CSS-`var(--token)` für Inline-Styles/Charts), `oeeColorClass()` (Tailwind `text-*` für className) und `OEE_THRESHOLDS` als single source. Baut auf den Design-Tokens `--success`/`--warning`/`--destructive`/`--muted-foreground` → **dark-mode-fähig**.
- Ersetzt 6 lokale `oeeColor`-Kopien (formula-cascade, kpi-cards, mini-dashboard, history-table, analysis-summary, `app/oee/page.tsx`). Vorher: hardcoded Hex-Palette mit Amber-Drift (`#FFCC00` vs `#D97706`) und ohne Dark-Mode.
- Badge-Tints (formula-cascade, history-table) nutzen jetzt `color-mix(in srgb, … %, transparent)` statt Hex-Alpha-Konkatenation (`${hex}18`), damit sie mit CSS-Var-Farben funktionieren.
- **Latenter Bug mitgefixt:** die „Leistung"-Kachel in `mini-dashboard` hatte `color` hart auf `#D97706` verdrahtet (immer amber, unabhängig vom Wert) → nutzt jetzt wie die anderen Kacheln den wert-basierten Helper. Trend-/Δ-Farben (Auf/Ab) ebenfalls auf Tokens.
- Die bewusst strengeren Schwellen (Verfügbarkeit/Leistung 0.9/0.7, Qualität 0.995/0.95) bleiben erhalten. Aus dem Redundanz-Audit (KAR-704, O5).

## 2026-06-08 — Chore: Dead Code entfernt (KAR-704 Quick-Win)

- `components/management/management-dashboard.tsx` (290 Z.) + `components/oee/oee-tab-nav.tsx` gelöscht — beide hatten **0 Importeure** (verifiziert), Dead Code aus dem Redundanz-Audit (KAR-704). `ManagementDashboard` war durch den `/management`→`/reporting`-Redirect verwaist; `OeeTabNav` durch `OeeProjectTabNav` ersetzt.

 2026-06-08 — UX: OEE-Panel-Überschriften (KAR-704 Quick-Win O4)

- `app/project/[id]/oee/page.tsx` — die zwei OEE-Panels bekommen erklärende Section-Überschriften: **„Zuletzt gespeicherter Stand"** (MiniDashboard, KW-Wert aus der DB) vs. **„Neue Berechnung"** (Calculator/KpiCards, Live). Vorher standen zwei OEE-Werte ohne Label nebeneinander → wirkte wie Dopplung (Kais' Screenshot). a11y: aria-labelledby. Aus dem Redundanz-Audit (KAR-704, O4).

 2026-06-08 — Refactor: ISO-Woche zentralisiert (KAR-704 Quick-Win O6)

- `lib/oee/iso-week.ts` (neu, getestet): `isoWeek(date)` + `currentIsoWeek()` als single source. Vorher 3× dupliziert (oee-form-constants, oee-trend-charts, verlustanalyse-client) → KW-Grenzen-Drift-Risiko. Alle 3 nutzen jetzt die zentrale Funktion. Aus dem Redundanz-Audit (KAR-704, O6).

 2026-06-08 — BUG-Fix: OEE-Faktoren bei Finalisierung persistieren (KAR-705)

- `app/oee/analyse/actions.ts` — `finalizeAnalysis()` berechnet jetzt beim Finalisieren die OEE-Faktoren (Availability/Performance/Quality/OEE via `aggregateAnalysisKpis`, dieselbe Aggregation wie die Anzeige) und schreibt sie nach `oee_records.{availability,performance,quality,oee}_factor`. Vorher wurde nur `status='final'` gesetzt → `oee_factor=NULL` → finalisierte Analyse-Stepper-Records waren in OEE-Übersicht + Reporting **unsichtbar**. Aus dem Redundanz-Audit (KAR-704, C2/O2).

## 2026-06-08 — BUG-Fix: Schicht-Sollmenge auf Bottleneck-CT (KAR-706)

- `app/lsc-workshop/[id]/schicht/page.tsx` — `effectiveCt` nutzt jetzt den **Bottleneck-Takt** (Station mit höchstem avg-CT via `calcBottleneck` aus `lib/reporting/aggregations.ts`) statt des globalen Durchschnitts über alle Stationen. Vorher wurde die Soll-Stückzahl/Stunde systematisch ~19% zu hoch ausgewiesen (z.B. Stationen 30/40/45/35/38s → 37.6s statt Engpass 45s). Aus dem Redundanz-Audit (KAR-704). Zentralisiert die Berechnung (kein Inline-avg mehr).

 2026-06-08 — Fabrikanalyse-Stammdaten als Repo-Seed + F2-Test geschärft (KAR-703)

- `supabase/seed/assessment-fabrikanalyse-seed.sql` (neu): 6 Haupt- + 27 Unterkategorien + **202 Fragen** (DE/EN/CN) der Fabrikanalyse. Lagen bisher nur in der Original-DB (Test-1) → frische DB zeigte „Keine Fragen gefunden". Dollar-Quoting (escaping-frei), idempotent. MIGRATIONS.md #79.
- `docs/adr/023-fabrikanalyse-seed-data.md` (neu): dokumentiert die BMW-Inhalte als bewusste Ausnahme zu ADR-020 (Assessment-Content ist Kern-Domäne, nicht Carve-out; für Adesso-Handover nötig).
- `__tests__/assessment-202-invariant.test.ts` geschärft: prüft jetzt auch den konsolidierten Seed (6/27/202) + Idempotenz, mit **prominentem Disclaimer**, dass der Test nur die Datei prüft (nicht Live) + Live-Count-Pflicht-Query. Behebt die falsche Sicherheit, die den KAR-703-Live-Bug nicht verhinderte.
- Gates: Typecheck ✓, Tests ✓, Portability strict ✓ (Seed ist .sql, BMW-Strings durch ADR-023 abgedeckt).

 2026-06-08 — C1: Public-Submit-Route von Auth-Middleware ausnehmen (KAR-698)

- `lib/supabase/proxy.ts` — `/intake/submit/[token]` von der Auth-Redirect-Bedingung ausgenommen. Vorher wurde der externe Fachbereich (anonym, kein Login) zu `/login` umgeleitet → der Public-Submission-Token-Flow (C3/C4/C5) war für externe Empfänger faktisch tot. Die Route ist bewusst öffentlich; die Token-Validierung läuft in der RPC `get_intake_submission_form` (lehnt ungültige/abgelaufene/widerrufene Tokens ab). submit-page hat keinen eigenen Auth-Check → kein Daten-Leak.
- Gates: Typecheck ✓, Portability strict ✓.

## 2026-06-08 — Übergabe-Härtung: A3 User-Konsolidierung S0/S1 (KAR-698 / KAR-702)

- `supabase-migration-a3-s0s1-user-consolidation.sql` (+ Rollback): die **sicheren additiven** ersten Schritte der User-Tabellen-Konsolidierung.
  - **S1:** `consultants.role`-CHECK um `'extern'` erweitert → beide Rollen-Systeme (consultants.role-text vs. user_profiles.role_id→roles.code) kennen jetzt dieselben 5 Codes.
  - **S0:** read-only View `v_all_users` (SECURITY INVOKER — RLS der Basistabellen greift, kein Zusatz-Zugriff) vereint consultants + user_profiles + fachbereich_users für eine einheitliche Übersicht.
- **Bewusst NICHT enthalten:** S3 (consultants-FKs auf user_profiles umbiegen) — hochriskant, eigene Session, pro FK mit Verify. Stufenplan in **KAR-702**.
- File-only (Apply = Kais). Idempotent, BEGIN/COMMIT, mit Rollback. MIGRATIONS.md #78.

## 2026-06-08 — Übergabe-Härtung Welle 4: Intake/Fachbereich Abteilungs-FK (KAR-698 A2-T2)

- `supabase-migration-intake-department-fk.sql` (+ Rollback): `intakes.department_id` + `fachbereich_users.department_id` (nullable FK → `department_master_data`) + Indizes + Backfill via `department_code`-Match. **Option (a)** des OTHER-Designkonflikts: echte Codes (MO-23 etc.) werden gemappt, `'OTHER'` + Codes ohne Stammdaten-Eintrag bleiben NULL (OTHER ist kein echtes Department). Text-Spalten bleiben parallel — kein Bruch, additiv. MIGRATIONS.md #77.
- File-only (Apply = Kais). Idempotent, BEGIN/COMMIT, mit Rollback.

## 2026-06-08 — Übergabe-Härtung Welle 4: Intake-Token-Verwaltungs-UI (KAR-698 C4)

- `components/intake/token-manager-modal.tsx` (neu): Modal im Intake-Board zum **Erzeugen** (E-Mail, Abteilung, Auftragsart, Gültigkeit 1–720h → URL mit Copy-Button) und **Widerrufen** von Public-Einreichungs-Links + Liste der eigenen Links mit Status (aktiv/verwendet/abgelaufen/widerrufen). Schließt die Lücke „createIntakeSubmissionToken hatte 0 Aufrufer".
- `app/intake/board/token-actions.ts`: `listIntakeSubmissionTokens`-Server-Action (RLS liefert nur eigene Tokens) ergänzt; nutzt die in C3 angelegte `revokeIntakeSubmissionToken`-Action.
- `components/intake/intake-board-view.tsx`: „Einreichungs-Links"-Button im Board-Header öffnet das Modal.
- a11y: role=dialog, aria-modal, ESC-Schließen, Fokus-Management. Gates: Typecheck ✓, Lint 45-Baseline, Portability strict ✓, Tests 934.
- **Damit ist der Intake-Token-Flow vollständig** (C3 Widerruf-DB + C4 UI). Offen in Welle 4: A2-T2 (Dept-FK, Design-Frage OTHER), A1-Rest (assignments/projects), A3 (User-Konsolidierung).

## 2026-06-08 — Übergabe-Härtung Welle 4: Lieferanten-FKs Teil 1+2 (KAR-698 A1)

- **A1 Teil 1** — `supabase-migration-planning-supplier-fk.sql` (+ Rollback): FK `planning_projects.supplier_id` → `supplier_master_data` als `NOT VALID` (kein Lock auf Bestandsdaten; neue Zeilen ab sofort geprüft). Konsistenz-Check (Schritt 0) + `VALIDATE CONSTRAINT` (Schritt 2) im Header dokumentiert.
- **A1 Teil 2** — `supabase-migration-assessments-supplier-fk.sql` (+ Rollback): `assessments.supplier_id` (nullable FK) + Index + **deterministischer Backfill** über `project_id → projects.supplier_id` (project_id ist NOT NULL → kein fragiles Name-Matching). `supplier_name` bleibt unberührt.
- MIGRATIONS.md #75/#76. File-only (Apply = Kais). Beide additiv, idempotent, mit Rollback.
- **Offen in A1** (brauchen Klärung/separat): assignments.supplier_id (supplier_name ist dort aktives Freitext-Feld, project_id nullable → keine deterministische Quelle, Design-Entscheidung nötig); `projects.supplier_name` NOT-NULL lösen (Code liest supplier_name an mehreren Stellen → Code-Audit nötig); Code-Umstieg supplier_name→supplier_id; API-Schema supplier_id→Pflicht.

## 2026-06-08 — Übergabe-Härtung Welle 4: Intake-Token-Widerruf + OTHER-Constraint (KAR-698 C3/C5)

- **C3** — `supabase-migration-intake-token-revoke.sql` (+ Rollback): `intake_submission_tokens.is_revoked` + neue RPC `revoke_intake_submission_token` (Consultant/Admin-only, Gate exakt aus `create_intake_submission_token`). Beide Validierungs-RPCs (`get_intake_submission_form`, `submit_intake_via_token`) um is_revoked-Prüfung ergänzt (sonst 1:1). Code: `revokeIntakeSubmissionToken`-Server-Action + `token_revoked`-Fehlertext auf der Public-Submit-Seite. Vorher konnte ein erzeugter Public-Link nicht widerrufen werden.
- **C5** — `supabase-migration-intake-requesting-department-other.sql` (+ Rollback): `intakes.requesting_department`-CHECK um `'OTHER'` erweitert (Angleich an Token-/Fachbereich-Tabellen). Behebt: Token-Submit mit department='OTHER' scheiterte am DB-CHECK. `BEGIN/COMMIT`.
- **Review-Härtung (adversariales Review):** revoke-RPC um **Ownership-Check** ergänzt (nur eigener Token oder admin/masteradmin — vorher konnte jeder aktive Consultant fremde Tokens widerrufen) + `is_revoked`-Guard + differenzierte Fehler (token_not_found / already_used / already_revoked / not-owner). C3-Rollback self-contained gemacht (Original-RPCs inline, dann Spalten-Drop — sonst broken RPCs nach Teil-Rollback). C5 transaktional + Rollback ergänzt.
- MIGRATIONS.md #73/#74. Beide file-only (Apply = Kais). Gates: Typecheck ✓, Portability strict ✓, Lint 45-Baseline.
- Offen (C4): Token-Verwaltungs-UI im Intake-Board (Erzeugen-Modal + Widerruf-Button) — die revoke-Action ist bereit, der Button folgt.

## 2026-06-08 — Übergabe-Härtung Welle 4: planning_projects ↔ projects FK (KAR-698 A4)

- `supabase/migrations/supabase-migration-planning-core-project-fk.sql` (+ Rollback) — `planning_projects.core_project_id` (nullable FK → `projects.id`, ON DELETE SET NULL) + Index. Verbindet die bisher beziehungslose Kalender-/Einsatzplanung mit den eigentlichen Projekten. Additiv, kein Backfill, idempotent, Editor-tauglich. MIGRATIONS.md #72.
- Reine Schema-Migration (file-only, Apply = Kais). Der Promote-/Verknüpfungs-Flow (UI) ist ein separater späterer Schritt.

## 2026-06-08 — Übergabe-Härtung Welle 4: OEE project-scoped Upsert (KAR-698 G2)

- `components/oee/oee-calculator.tsx` — `conflictTarget` für den `oee_records`-Upsert fest auf `project_id,line_name,calendar_week,year` gesetzt. Vorher gab es einen Fallback auf den **globalen** `line_name,calendar_week,year`, der nach Migration #63 (droppt das globale UNIQUE) zu „no unique constraint matching" führen würde. OeeCalculator wird ohnehin nur project-scoped (mit `lockedProjectId`) gerendert; der Fallback war toter, aber gefährlicher Code.
- **Schema-Teil von G2 ist Migration #63** (`supabase-migration-oee-records-project-scoped-unique.sql`, existiert bereits, file-only): droppt globales UNIQUE, fügt project-scoped UNIQUE hinzu — verhindert, dass zwei Projekte mit gleichem Liniennamen (z.B. „Hauptlinie") in derselben KW kollidieren/überschreiben.
- **⚠️ Reihenfolge:** Migration #63 MUSS vor diesem Code-Merge auf Prod angewendet sein, sonst sucht der Upsert ein noch nicht existierendes Constraint. Verifikation: `SELECT conname FROM pg_constraint WHERE conrelid='public.oee_records'::regclass AND contype='u';`
- Gates: Typecheck ✓, Lint 45-Baseline. (Schema-Test = die DB-UNIQUE-Constraint + Live-Smoke: zwei Projekte gleicher Linienname → unabhängig.)

## 2026-06-08 — Übergabe-Härtung Welle 3 (Teil 2): konsolidiertes Storage-Provisioning (KAR-698 E1/E2)

- `scripts/provision-storage.sql` — kanonisches, idempotentes Skript für alle 4 Storage-Buckets (`avatars`, `project-notes`, `supplier-logos`, `documents`) inkl. RLS-Policies. Bündelt die bisher verstreuten Einzel-SQLs (die nie in der Apply-Order standen → „Bucket not found"-Root-Cause beim Profilbild-Upload) und ergänzt das zuvor **gar nicht** provisionierte `documents`-Bucket (E2).
- avatars/project-notes/supplier-logos: Policies 1:1 aus den bestehenden SQLs übernommen (kein Verhalten geändert). documents (neu, nach Storage-RLS-Review): READ=alle authenticated (Schutz via `public.documents`-RLS, die den storage_key hält); WRITE=Pfad-Guard `documents/project-%/%`; **UPDATE/DELETE=nur Uploader, Projekt-Eigentümer oder admin** (per storage_key-Join — für DELETE/UPDATE greift das Tabellen-RLS-Argument nicht, kein vorheriger SELECT nötig). Keine documents-Lösch-/Update-UI vorhanden → bricht nichts.
- Skript enthält Pre-Apply-Verifikation (Abschnitt 0: Live-Bucket/Policy-Check, v.a. ob `documents` schon abweichende Policies hat) + Post-Apply-Verifikation. Editor-tauglich, kein CONCURRENTLY, `BEGIN/COMMIT`.
- `MIGRATIONS.md` — neue Sektion „7e. Storage provisioning" mit dem Skript als kanonischem Eintrag.
- Operator-Schritt: Kais wendet `scripts/provision-storage.sql` im SQL-Editor an (erst Abschnitt 0 prüfen).

## 2026-06-08 — Übergabe-Härtung Welle 3 (Teil 1): Secrets-Scan, PII, 202-Invariant (KAR-698)

- `scripts/check-secrets.mjs` — Secret-Heuristik um `sntrys_` (Sentry), `sk_` (generischer Secret-Key) und `ghp_` (GitHub PAT) erweitert (I6).
- `lib/format/__tests__/display-name.test.ts` — echte Namen (Kais/Aseckzai) durch generische Test-Fixtures ersetzt (I4, PII-Kosmetik für Vendor-Übergabe).
- `__tests__/assessment-202-invariant.test.ts` — neuer Invarianten-Test: die Fabrikanalyse-Seed-Migration enthält exakt 202 `assessment_questions` (F2; ersetzt den veralteten 54er-Mock-Bezug). Läuft ohne DB (statisch gegen die SQL-Quelle).
- Gates: Typecheck ✓, Tests 934 passed ✓ (+2), Portability strict ✓, Lint 45-Baseline, `check-secrets` grün.
- Offen in Welle 3: E1/E2 Storage-Provisioning (`provision-storage.sql`) — braucht Storage-RLS-Recherche (documents-Bucket ohne Policies), I5 Attribution (Form von Kais zu entscheiden).

## 2026-06-08 — Feld-Hinweise Projektanlage (KAR-700)

- `supabase/migrations/supabase-migration-field-hints-projektanlage.sql` — 35 Hinweise (`project.*`) geseedet für das Projektanlage-Formular (`new-project-form.tsx`).
- `components/project/new-project-form.tsx` + `app/project/new/page.tsx` — alle 35 Label-Felder mit `FieldHint` versehen, Hints aus `lsc_tooltips`.
- Kundeninterne Felder (KIFAG, Projektleiter-Org, QMT/Einkauf/Cost-Engineering-Kurzzeichen, KPI-Boards, Verrechnung, PLT) haben einen ehrlichen Basis-Hinweis (was das Feld ist), in der Migration mit `-- [intern: prüfen]` markiert — vom Admin im Editor zu verfeinern, keine geratene Fachbedeutung.
- Gates: Typecheck ✓, Tests 932 passed ✓, Portability strict ✓, Lint 45-Baseline. Key-Konsistenz 35 Code = 35 Seed, keine Typos/Orphans.

## 2026-06-08 — Feld-Hinweise: OEE-Rechner admin-editierbar (KAR-700)

- `supabase/migrations/supabase-migration-field-hints-oee.sql` — 26 OEE-Hinweise (`oee.*`) geseedet. Die bisher **hardcodierten** Tooltip-Texte des OEE-Rechners (KADiCon-Originale, unverändert übernommen) wandern in `lsc_tooltips` und werden damit admin-editierbar wie alle anderen Feld-Hinweise.
- `components/oee/oee-form-fields.tsx` — `NumericInput`/`CalcField` bekommen optionalen `hint`-Prop (rendert `FieldHint` mit Formel/Beispiel), `tooltip`-String bleibt als Fallback.
- `components/oee/oee-calculator.tsx` — alle 26 Tooltips (18 via Prop + 8 direkte) auf DB-`hint` umgestellt, hardcodierte Strings entfernt; lädt Hints aus `lsc_tooltips`.
- `app/project/[id]/oee/page.tsx` — `loadTooltips(supabase, 'oee.')` durchgereicht.
- Berechnete Felder (Gesamtverfügbare Zeit, Ausschuss) zeigen jetzt Formel + Beispiel statt nur Text. Für den Nutzer keine sichtbare Änderung, aber Admin kann alle Texte in Stammdaten → Feld-Hinweise anpassen.
- Gates: Typecheck ✓, Tests 932 passed ✓, Portability strict ✓, Lint 45-Baseline. Key-Konsistenz: 26 Code = 26 Seed, keine Typos/Orphans.

## 2026-06-08 — Feld-Hinweise Rollout Batch 1: Schicht + Stoppuhr (KAR-700)

- `supabase/migrations/supabase-migration-field-hints-batch1.sql` — 13 Hinweise geseedet: Schicht (`shift.*`: Schichtbeginn/-ende, Pausen, Stillstände, Rüstzeit, sonst. Verluste, Netto-Produktionszeit mit Formel, Plan/Ist) + Stoppuhr (`stopwatch.*`: Zeitart, Maschinenzeit-Split, Min/Max CT). Idempotent.
- `components/lsc-workshop/lsc-shift-client.tsx` + `app/lsc-workshop/[id]/schicht/page.tsx` — Hinweise an Pausen/Stillstände/Rüstzeit/Verlusten, Netto-Produktionszeit (Formel), Plan/Ist-Spalten.
- `components/lsc-workshop/lsc-stopwatch-client.tsx` + `app/lsc-workshop/[id]/stoppuhr/page.tsx` — Hinweise an Zeitart, Maschinenzeit-Toggle, Min/Max CT.
- Pattern wie Pilot: `loadTooltips`/`buildTooltipMap` + `FieldHint`, admin-editierbar über Stammdaten → Feld-Hinweise (alle 13 Keys dort sichtbar/editierbar).
- Gates: Typecheck ✓, Tests 932 passed ✓, Portability strict ✓, Lint 45-Baseline.

## 2026-06-08 — Feld-Hinweise: admin-editierbare Tooltips, Pilot Workshop-Erfassung (KAR-700)

- **Standing Order:** Jedes Feld der App soll einen Hinweis/eine Erklärung haben, bei berechneten Feldern Formel + Beispiel. Admin-editierbar in den Stammdaten.
- `supabase/migrations/supabase-migration-lsc-tooltips-fields.sql` — `lsc_tooltips` um `formula` + `example` erweitert, 14 Workshop-Feld-Hinweise geseedet (DE; EN optional, Fallback DE). Editor-tauglich, idempotent (Seed `ON CONFLICT DO NOTHING`).
- `lib/lsc/tooltips.ts` — locale-aware Loader + Resolver (`resolveTooltip`/`buildTooltipMap`/`loadTooltips`), 8 Unit-Tests.
- `components/ui/field-hint.tsx` — generische, a11y-konforme Tooltip-Komponente (fokussierbares Icon, Hover/Focus/Escape, graceful bei fehlendem Hint). Boundary-konform (kein Feature-Import, ADR-015).
- `components/lsc-workshop/workshop-erfassung-client.tsx` + `…/page.tsx` — alle 14 Felder (Beobachtung + Optimierung) zeigen ihre Hinweise live aus der DB.
- **Admin-Editor:** neue Stammdaten-Karte „Feld-Hinweise" (`/repository/masterdata/feld-hinweise`) mit CRUD pro Hinweis (DE/EN, Formel, Beispiel), admin-gated + Server-Action `updateFieldHint`.
- MIGRATIONS.md: Einträge 66 (lsc-workshop-v2-schema nachgetragen) + 67 (tooltips-fields).
- Gates: Typecheck ✓, Tests 932 passed / 0 failed (+8), Portability strict ✓ (boundaries + colors clean), Lint 45-Baseline.
- Folgt: modulweiser Rollout über die restliche App (eigene PRs).

## 2026-06-08 — Übergabe-Härtung Welle 2d: Avatar-Upload-Validierung + Error-Detail-Leaks (KAR-698, E3/E4/H4)

- `components/account/account-form.tsx` — Profilbild-Upload: MIME-Whitelist (jpeg/png/webp statt `image/*`-Prefix), 5-MB-Limit mit verständlicher Meldung, „Bucket not found" wird als Admin-Hinweis statt kryptischem Rohfehler angezeigt; keine `error.message`-Leaks mehr in Profil-/Passwort-/Avatar-Meldungen. Server-seitige Durchsetzung (Bucket `file_size_limit`/`allowed_mime_types`) bleibt Operator-Aufgabe im Storage-Provisioning (E1).
- `lib/api/error.ts` — zentraler `routeError`/`sanitizeApiError`-Handler (+124-Zeilen-Testdatei): generische Client-Meldung, Details nur ins Server-Log.
- H4-Routes auf `routeError` umgestellt: `admin/users` (inkl. `lock`/`unlock`/`reset-password`), `wertstrom` (Collection + `[id]`), `planning/consultants` (Collection + `[id]`), `planning/settings`, `projects/next-id` — kein `error.message` mehr in Responses.

## 2026-06-08 — Übergabe-Härtung Welle 2a+2b (KAR-698, nachgetragen)

- **Welle 2a (PR #138)**: LSC-Stopwatch — Timer-Persistenz über Reload (localStorage-Wall-Clock-Anker), Fehler-Rollback optimistischer Updates, CT-Edit, Yamazumi-Live-Chart, Chart-Download (SVG/PNG) + CSV-Export. Review-Fixes: Lap-Persist-Doppelzählung, Maschinen-/Manuellzeit-Swap, Delete-Rollback-Race, Firefox-Blob-Revoke.
- **Welle 2b (PR #139)**: LSC-Schichten offline-fähig — Dexie v2-Stores `lscShifts`/`lscShiftHours`, Pull-Pending-Guard im Sync-Engine (D6), Autosave-Status (D8), VersionError-Queue-Backup/Restore. Review-Fixes: `seedFromServer`-Pending-Clobber, 4 Silent-Failure-Pfade.

## 2026-06-08 — Übergabe-Härtung Welle 2c: Workshop-Erfassung (KAR-698)

- `app/lsc-workshop/[id]/workshop-erfassung/` (page + actions) + `components/lsc-workshop/workshop-erfassung-client.tsx` — Vorher/Nachher-Workshop-Erfassung pro Prozessschritt: Beobachtungen (`process_step_observations`) + Optimierungen (`process_step_optimizations`), Draft-Persistenz in localStorage, Excel-Import (`lib/lsc/workshop-import`), Bottleneck-Chart, Vorher/Nachher-Tabelle (`lib/lsc/before-after`).
- Migration `supabase/migrations/supabase-migration-lsc-workshop-v2-schema.sql` — file-only, noch nicht auf Prod angewendet (Copy-Paste-Anleitung folgt beim Operator-Apply).
- Review-Fixes nach adversarialem Review: (1) Auto-Summe `observed_ct_sec` lief nach erstem Sub-Zeit-Feld nicht mehr mit (stale CT in DB + Bottleneck-Chart) — Logik nach `lib/lsc/observed-ct.ts` extrahiert, per Wert-Vergleich auto/manuell unterschieden, 7 Tests; (2) Page schluckte Supabase-Errors silent (leere Felder statt Fehler → Überschreib-Gefahr) — wirft jetzt laut + `Promise.all` statt sequenzieller Fetches; (3) OEE-Feld mit Range-Hinweis (0–1) + min/max gegen numeric-overflow bei Prozent-Eingabe.

## 2026-06-02 — Search-UX: repository-home Floating-Typeahead (KAR-678)

- `components/repository/repository-home-client.tsx` — Floating-Typeahead-Dropdown unter dem Suchfeld: top-8 ranked Treffer (Backend `/api/repository/search`), je Treffer Mime-Icon + Titel/Dateiname + Category-Badge (Projekt/Vorlage/Stammdaten) + relevance-Badge (Titel/Tag/Meta). Keyboard-Nav (ArrowUp/Down/Enter/Esc) via `lib/ui/list-keyboard-nav`, ARIA combobox/listbox/option, Click-Outside-Close. Enter/Klick öffnet den Detail-Drawer (Download bleibt darin erreichbar). Die bestehende Inline-Ergebnis-Tabelle bleibt unverändert (volle Browse-Ansicht + per-Row-Download). Pure `typeaheadEntries`-Helper unit-getestet.
- Gates: `typecheck` ✓, `test` ✓ (797 passing / 40 todo, +3 neue Tests), `check:portability` strict ✓, `build` ✓. 0 neue Lint-Findings, boundary-clean.

## 2026-06-02 — Search-UX: supplier + repository-home Placeholders (KAR-677 Rest)

- `components/ui/supplier-search.tsx` — deskriptiver Default-Placeholder „Lieferant suchen — Name, Nummer oder Ort" via component-lokalem `DEFAULT_SUPPLIER_PLACEHOLDER` (kein `lib/ui`-Import → respektiert die Primitive-Boundary aus ADR-010/015). Unit-getestet.
- `components/repository/repository-home-client.tsx` — Placeholder „Suche: Titel, Dateiname, Tags, Dokumenttyp…" via zentralem `REPOSITORY_HOME_SEARCH_PLACEHOLDER`.
- **Floating-Typeahead-Redesign bewusst NICHT gemacht:** die bestehende repository-home-Suche ist bereits debounced + relevance-ranked (title/keyword/tag-Badges) mit voll funktionalen Result-Rows (Download + Detail-Drawer). Ein Floating-Dropdown würde diese Row-Actions verlieren — kein klarer UX-Gewinn bei hohem Risiko. Bei konkretem Bedarf separates Ticket.
- Gates: `typecheck` ✓, `test` ✓ (794 passing / 40 todo, +2 neue Tests), `check:portability` strict ✓, `build` ✓. 0 neue Lint-Findings.

## 2026-06-02 — Search-UX: Zero-Results-Recovery + descriptive Placeholders (KAR-677 Tier 2/3)

- **Neu** `lib/ui/search-empty-state.ts` — `resolveSearchEmptyState(isLoading, totalCount)` unterscheidet leere Sammlung vs. 0-Treffer-Filter (unit-getestet).
- **Neu** `lib/ui/search-placeholders.ts` — zentralisierte, regression-getestete deskriptive Such-Placeholders.
- **templates-client / project-documents-client** (Tier 3) — Leerzustand splittet jetzt korrekt: leere Sammlung → Upload-CTA, 0-Treffer-Query → „Filter zurücksetzen" (statt irreführendem Upload-CTA). `project-documents` zusätzlich Placeholder „Titel oder Dateiname…".
- **admin/audit-client, assessment-workspace** (Tier 2) — deskriptive Placeholders statt generischem „Suchen…"/„Suche…".
- Bewusst ausgelassen: `supplier-search`-Placeholder — `components/ui/**` ist Primitive-Zone (ADR-010/015) und darf `lib/ui` nicht importieren → braucht prop-basierten Ansatz (Follow-up). `planning-projects-client` übersprungen (dead code, von keiner Route gerendert).
- Gates: `typecheck` ✓, `test` ✓ (792 passing / 40 todo, +6 neue Tests), `check:portability` strict ✓, `build` ✓. Keine neuen Lint-Findings.

## 2026-06-02 — Search-UX: Keyboard-Navigation für Combobox & Multi-Select (KAR-677 Tier 1)

- **Neu** `lib/ui/list-keyboard-nav.ts` — geteiltes, DOM-freies `nextHighlightIndex(key, current, count)` (ArrowUp/ArrowDown-Indexmath) als Shared-Primitive für Listen-Navigation. Unit-getestet (`lib/ui/__tests__/`).
- **`components/master-data/master-data-combobox.tsx`** — Keyboard-Navigation (Pfeil hoch/runter, Enter wählt aus / legt neu an, Esc schließt), `role="combobox"` + `aria-controls`/`aria-expanded`, `role="option"` + `aria-selected` je Zeile, Scroll-into-view des aktiven Treffers. Exportiert reines `resolveEnterTarget` (getestet). Wirkt auf die ~8 Stammdaten-Feld-Instanzen.
- **`components/repository/multi-select-field.tsx`** — gleiche Keyboard-Navigation (Enter toggelt, ohne zu schließen), gleiche ARIA. Exportiert reines `enterToggleIndex` (getestet). Wirkt in upload-dialog, document-detail-drawer, project-documents-client, project-edit-form, new-project-form.
- Referenz-Pattern aus `components/ui/supplier-search.tsx`. Default-Export-Signaturen unverändert (additiv, keine Consumer-Änderung). Quelle: KAR-677 Such-UX-Audit.
- Gates: `typecheck` ✓, `test` ✓ (781 passing / 40 todo, +3 neue Tests), `check:portability` strict ✓, `build` ✓. Keine neuen Lint-Findings (die 2 verbleibenden in master-data-combobox sind pre-existing, §3A).

## 2026-06-01 — Lint: JSX unescaped entities behoben (safe Subset)

- 11 `react/no-unescaped-entities`-Errors behoben: gerade Anführungszeichen (`"`) in deutschem JSX-Text durch das schließende deutsche Anführungszeichen (U+201C) ersetzt — passend zum bereits genutzten öffnenden `„`. Reine Text-/Typografie-Änderung, kein Verhaltens-/Logik-Change.
- Betroffen: `app/intake/page.tsx`, `app/pmo/page.tsx`, 3× `components/lsc-workshop/*`, `components/pmo/pmo-weekly-client.tsx`, `components/project/new-project-form.tsx`, `components/project/project-edit-form.tsx`.
- Effekt: ESLint-Errors **56 → 45**. Die verbleibenden 45 (setState-in-Effect, Component-in-Render, Refs-during-Render, 2× `no-explicit-any`) sind verhaltens-sensitiv und brauchen einzelne Reviews — bewusst NICHT in diesem unbeaufsichtigten Pass angefasst (siehe `docs/vendor-handover/ADESSO_HANDOVER_READINESS.md` §3A).
- Gates: `typecheck` ✓, `test` ✓ (768 passing / 40 todo), `check:portability` strict ✓, `build` ✓ (CI-equivalent).

## 2026-06-01 — Deps: @supabase/supabase-js 2.100.1 → 2.106.2

- **package.json** — `@supabase/supabase-js` `^2.100.1` → `^2.106.2`. `@supabase/ssr` bleibt bei `0.9.0` (dessen Peer-Range `^2.97.0` deckt 2.106 ab — kein ssr-Bump nötig).
- Changelog-Review (2.101–2.106): alle non-breaking Minor/Patch (Auth-Signup-Response-Fix, getClaims JWT-Handling-Verbesserung, RN-Hermes); keine SSR-Client-Breaking-Changes. App nutzt `createServerClient`/`createBrowserClient`/`getClaims` Standard-Pfad.
- **Effekt:** `@supabase/realtime-js` zieht `ws` mit hoch → die `ws`-Advisory verschwindet (`npm audit` 14 → 13). Lock-Änderungen nur in der `@supabase/*`-Familie + `ws`.
- Gates: `typecheck` ✓, `test` ✓ (768 passing / 40 todo), `check:portability` strict ✓, `build` ✓ (CI-equivalent).

## 2026-06-01 — Security: shadcn → devDependencies (Prod-Graph entschlackt)

- **package.json** — `shadcn` (CLI-Scaffolder) von `dependencies` nach `devDependencies` verschoben. Es wird nur zur Build-/Dev-Zeit gebraucht (`app/globals.css` importiert `shadcn/tailwind.css`, wird in CSS kompiliert; kein Runtime-Import).
- **Effekt:** Prod-Dependency-Graph verliert die `shadcn`-Kette (hono, fast-uri, tmp, uuid). `npm audit --omit=dev`: **14 → 9** Advisories (high bleibt 2 — fast-uri-Pfad via dev). Voller `npm audit` unverändert (14), da shadcn weiter im Dev-Tree.
- **package-lock.json** — nur `"dev": true`-Flags auf der shadcn-Subtree (@babel/*); keine Versions-/Paket-Änderungen.
- Gates: `typecheck` ✓, `test` ✓ (768 passing / 40 todo), `check:portability` strict ✓, `build` ✓ (CI-equivalent — `shadcn/tailwind.css` löst zur Build-Zeit auf, devDeps sind dann installiert).

## 2026-06-01 — Security: next 16.2.4 → 16.2.7 (13 CVEs)

- **package.json** — `next` 16.2.4 → 16.2.7 und `eslint-config-next` passend angehoben. Behebt 13 Next.js-Advisories (App-Router Middleware/Proxy-Bypass, RSC Cache-Poisoning, CSP-Nonce-XSS, Image-Optimization-DoS, SSRF via WebSocket-Upgrade u.a.).
- **package-lock.json** — nur next-Family-Versionen geändert (16.2.4 → 16.2.7); keine neuen Pakete im Graph.
- Verifiziert: `next` verschwindet vollständig aus `npm audit` (13 → 0 next-CVEs; high 3 → 2). Vulnerable-Package-Diff zeigt keine neu eingeführten Pakete. Die übrigen Advisories (hono/fast-uri/tmp/uuid via `shadcn`-Kette, postcss, ws) sind vorbestehend und separat im Readiness-Report getrackt.
- Gates: `typecheck` ✓, `test` ✓ (768 passing / 40 todo), `check:portability` strict ✓, `build` ✓ (CI-equivalent).

## 2026-06-01 — Adesso-Handover-Readiness: Doku-/Config-Cleanup (safe pass)

Repository-weiter Readiness-Review (8 Dimensionen) für die Adesso-Übergabe. Nur sichere, reversible Änderungen ohne Laufzeit-Logik-Change; riskante/entscheidungsbedürftige Punkte sind in `docs/vendor-handover/ADESSO_HANDOVER_READINESS.md` dokumentiert statt angefasst.

- **Dockerfile** — vestigiale `COPY .../proxy.ts`-Zeile entfernt (Datei zu `middleware.ts` umbenannt; Zeile brach `docker build`).
- **package.json / .nvmrc** — `engines` (`node >=20.9.0`), `packageManager`, `.nvmrc` (`24`) ergänzt → reproduzierbares Setup.
- **.env.example** — 6 fehlende Vars dokumentiert (`CSP_ENFORCE`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SENTRY_DSN`, `LOG_LEVEL`/`LOG_SERVICE`/`LOG_RELEASE`).
- **README.md** — falsche Clone-URL korrigiert, gelöschte Module (control-plane/provisioning/owner) entfernt, `proxy.ts`→`middleware.ts`, reale `lib/`-Struktur + Scripts (`check:portability`, `new:module`), `MIGRATIONS.md` als DB-Source-of-Truth.
- **CLAUDE.md** — Tech-Stack-Fehler (`XLSX` existiert nicht) korrigiert, Offline/PWA + Upstash + Observability ergänzt, Handover-Context, vollständige Command-Liste inkl. strict `check:portability`, Stop-Conditions, deutsche Doku-Sektion auf Englisch.
- **AGENTS.md** — Scope-Banner (Human-Review-Guideline, nicht AI-Agent-Config → verweist auf `CLAUDE.md`).
- **DEPLOYMENT.md** — Middleware-Pfad korrigiert. **CONTRIBUTING.md** (neu). **docs/vendor-handover/** — „Start here" + Superseded-Marker auf 2026-04-29-Snapshot.
- **vitest.config.ts** — Coverage-Pfade auf existierende Module (vier Control-Plane-Pfade waren gelöscht).
- **scripts/check-secrets.mjs** — 6 entfernte Control-Plane-Provisioning-Vars + toten `lib/control-plane/client.ts`-Allowlist-Eintrag entfernt → `check:secrets` warnungsfrei.
- **.github/PULL_REQUEST_TEMPLATE.md** — `pnpm typecheck` → `npm run typecheck`.
- **docs/vendor-handover/ADESSO_HANDOVER_READINESS.md** (neu) — Readiness-Report, priorisierte Empfehlungen, Operator-Entscheidungen.

Validierung: `typecheck` ✓, `test` ✓ (768 passing / 40 todo), `check:portability` strict ✓, `check:secrets` ✓ (0 Warnungen), `build` ✓ (CI-equivalent). `lint` unverändert bei 56 vorbestehenden Errors (soft-gated).

## 2026-06-01 — KAR-665: Profilbild Crop-Upload + Avatar app-weit (Teil 3)

- **components/account/avatar-cropper.tsx** (neu) — runder Pan/Zoom-Cropper (Pointer-Events für Maus + Touch, Zoom-Regler), nutzt die getestete Geometrie aus `lib/format/avatar-crop.ts`, exportiert ein 512×512-PNG via Canvas.
- **components/account/account-form.tsx** — Avatar ist jetzt anklickbar (Kamera-Overlay): Datei wählen → zuschneiden → Upload in privaten `avatars`-Bucket (`<auth_uid>/<uuid>.png`) → `user_profiles.avatar_url` gespeichert; alter Bucket-Eintrag wird best-effort entfernt, Upload-Spinner + Status-Meldung. Abteilung-Dropdown war bereits an die zentrale `department_master_data`-Quelle gebunden (gleiche wie Projektanlage) — kein Parallel-Anlegen.
- **lib/avatar/avatar-url.ts** (neu) — `resolveAvatarUrl` / `resolveAvatarUrls` signieren Bucket-Keys (privat, 1h TTL) für die Anzeige.
- **app/konto/page.tsx, components/layout/app-header.tsx, app/admin/users/page.tsx** — rendern Avatare jetzt über signierte URLs (Header, Konto-Form, Benutzerliste).

## 2026-05-31 — KAR-662: Projektnotizen Text-Element

- **lib/notes/canvas.ts** — `NoteText` (x, y, text, color?, size?) + optionales `texts[]` in `NoteCanvasData`; Schema/Parse bewahren Texte (Default []), `isCanvasEmpty` berücksichtigt Texte, `DEFAULT_TEXT_SIZE`.
- **components/notes/note-canvas.tsx** — Werkzeug-Umschalter Stift/Text; im Text-Modus auf die Seite tippen platziert Text (Overlay-Input, A4-Prozent-Position), bestehenden Text antippen bearbeitet, leer löscht. Farbe wie Stift. Render als SVG-`<text>`.
- **lib/notes/pdf.ts** — Texte werden ins PDF gerendert (`pdf.text`, px→pt-Skalierung, Farbe).
- Tests: canvas texts-Persistenz (+3), pdf Text-Render (+1).


## 2026-05-31 — KAR-662: Projektnotizen PDF-Export + Auto-Ablage in Datenablage

- **lib/notes/pdf.ts** — `buildPageSvg` (pure, getestet): weißes A4-Blatt + Hintergrund-Pattern + Strokes. Browser-only: `rasterizeSvg` (SVG→PNG via offscreen canvas), `buildNotePdf` (jspdf, mehrseitig, Orientierung), `downloadBlob`, `blobToBase64`.
- **lib/notes/canvas.ts** — `canvasInnerSvg` extrahiert (Stroke-`<path>`-Markup ohne Wrapper) für die Seiten-Komposition.
- **app/notes/actions.ts** — `saveNotePdf`: lädt das PDF in den Bucket `documents` (deterministischer Key `documents/{projectId}/note-{noteId}.pdf`, upsert) und upsertet die `documents`-Row (repository_type project, project_id, mime application/pdf) → erscheint in der Projekt-Datenablage. Eine PDF pro Notiz (re-save überschreibt). (+3 Guard-Tests)
- **components/notes/note-editor.tsx** — Speichern erzeugt das PDF automatisch und legt es in der Datenablage ab (best-effort, blockiert das Speichern der Notiz nicht); neuer Button **„Als PDF exportieren"** (Download).


## 2026-05-31 — KAR-662: Projektnotizen A4-Multi-Page-Dokumente (Modell + Editor)

- **lib/notes/document.ts** — Dokument-Modell: `NoteDocument` (orientation portrait/landscape, background blank/grid/lines, gridMm, pages[]). `parseDocument` (tolerant, wrappt Legacy-Einzel-Canvas als 1-Seiter — keine DB-Migration nötig), `pageDimensions` (A4 96dpi px), `mmToPx`, `backgroundSvg` (für PDF), Presets `ORIENTATIONS`/`BACKGROUNDS`. (13 Tests)
- **components/notes/note-canvas.tsx** — fixer A4-`viewBox` mit exaktem Pointer→Koordinaten-Scaling (auflösungsunabhängig, behebt Skalierungs-Limit); Hintergrund-Pattern (kariert/liniert) als SVG hinter den Strokes; Bild-Annotations-Modus bleibt erhalten.
- **components/notes/note-editor.tsx** — Dokument-Controls: Format-Umschaltung (Hoch/Quer), Hintergrund-Auswahl + Raster-Größe (mm), Seiten-Navigation (blättern / Seite hinzufügen / löschen). Speichern persistiert das ganze Dokument.


## 2026-05-31 — KAR-344: Projektnotizen Stift-Tools (Farbe + Strichstärke)

- **lib/notes/canvas.ts** — `NoteStroke` um optionale `color` (hex) + `size` erweitert; `INK_COLORS` (Petrol/Schwarz/Rot/Grün/Blau), `STROKE_SIZES` (Dünn/Mittel/Dick), `DEFAULT_INK`/`DEFAULT_SIZE`. `canvasToSvg` rendert pro Stroke mit eigener Farbe (fill) + Größe. Rückwärtskompatibel (Strokes ohne color/size nutzen Defaults). (+6 Tests)
- **components/notes/note-canvas.tsx** — Toolbar mit Farb-Swatches + Strichstärken-Buttons; neue Striche bekommen aktuelle Farbe/Größe; gespeicherte Striche behalten ihre. Gilt auch für Bild-Annotation (gleiche Komponente).


## 2026-05-31 — KAR-344 Phase 3: Projektnotizen Foto-Upload + Bild-Annotation

- **supabase-migration-notes-storage.sql** — privater Storage-Bucket `project-notes` + storage.objects-RLS (Lese/Schreib/Update/Delete scoped auf Projekt-Owner über Pfad-Segment `<note_id>/…`). Idempotent.
- **lib/notes/attachments.ts** — `validateAttachment` (Mime/Size), `sanitizeFilename` (Pfad-Strip + safe chars), `buildStorageKey` (`<note_id>/<uuid>-<file>`). (9 Tests)
- **app/notes/actions.ts** — `uploadAttachment` (Mime/Size-Validierung, Storage-Upload mit Rollback bei Insert-Fehler), `saveAttachmentAnnotations`, `deleteAttachment` (Row + Storage-Objekt). (4 neue Guard-Tests)
- **components/notes/note-canvas.tsx** — optionale `backgroundUrl` (Bild als SVG-Layer hinter den Strokes) + `heightClass`; macht die Zeichenfläche zur Foto-Annotations-Ebene.
- **components/notes/note-attachment-annotator.tsx** — Bild + Stift-Annotation, Speichern/Löschen.
- **components/notes/note-editor.tsx** — Attachments-Sektion (Foto hinzufügen + Annotatoren-Liste, nur im Edit-Modus).
- **app/notes/[id]/page.tsx** — lädt Attachments + batch-signierte URLs (privater Bucket).


## 2026-05-31 — KAR-344 Phase 2: Projektnotizen Note-Editor (Text + Stift-Canvas)

- **lib/notes/canvas.ts** — pure Canvas-Helfer: `parseCanvasData` (tolerant, degradiert auf leeren Canvas), `strokeToSvgPath`/`canvasToSvg` via perfect-freehand, `emptyCanvas`/`isCanvasEmpty`/`countPoints`. Ink rendert mit `currentColor`.
- **lib/notes/project-label.ts** — Label-Helfer (project_code · supplier_name) für Picker/Liste.
- **components/notes/note-canvas.tsx** — Apple-Pencil/Pointer-Events Zeichenfläche, `touch-action:none`, Druck-Sensitivität, Zurück/Leeren (48px Touch-Targets).
- **components/notes/note-editor.tsx** — Editor: Projekt-Picker (Pflicht), Titel, Text, Canvas; speichert via Server-Action, Saved-/Fehler-Status.
- **app/notes/actions.ts** — `createNote`/`updateNote` Server-Actions, Zod-validierter Input, RLS-scoped Insert/Update auf `project_notes`.
- **app/notes/new/page.tsx** + **app/notes/[id]/page.tsx** — Create-/Edit-Routen, laden Projekte + Notiz.
- **app/notes/page.tsx** — Liste verlinkt Zeilen auf Editor + „Neue Notiz"-Button.
- **components/layout/app-shell.tsx** — Nav-Eintrag „Projektnotizen" (`/notes`) + Mobile-Drawer.
- **i18n** — `nav.notizen` in de/en/es/zh.
- **dep** — `perfect-freehand` hinzugefügt.
- Tests: `lib/notes/__tests__/canvas.test.ts` (15), `project-label.test.ts` (4), `app/notes/__tests__/actions.test.ts` (4 Input-Validierung).

## 2026-05-27 — KAR-633 PR-1: OEE-Modul project-scoped

- **app/project/[id]/oee/layout.tsx** — neues Layout-Wrapper mit `OeeProjectTabNav` (Kalkulator / Trendanalyse / Verlustanalyse).
- **app/project/[id]/oee/page.tsx** — ersetzt thin-Summary durch vollständigen Kalkulator (scoped `.eq('project_id', id)`). Lieferant/Projekt-Dropdowns ausgeblendet; `lockedProjectId` setzt project_id fix.
- **app/project/[id]/oee/trends/page.tsx** — projektbezogener Trendanalyse-Wrapper (records gefiltert auf project_id, suppliers=[]).
- **app/project/[id]/oee/verlustanalyse/page.tsx** — projektbezogener Verlustanalyse-Wrapper (records gefiltert auf project_id, suppliers=[]).
- **components/oee/oee-project-tab-nav.tsx** — neuer Kalkulator/Trendanalyse/Verlustanalyse Sub-Tab-Nav für Projekt-Kontext.
- **components/oee/oee-calculator.tsx** — neues `lockedProjectId`-Prop: scoped alle Supabase-Queries, blendet Lieferant/Projekt-Felder aus, nutzt `project_id,line_name,calendar_week,year` als Upsert-Conflict-Target.
- **app/oee/page.tsx** — umgebaut zu Projekt-Picker: Liste aller Projekte mit OEE-KPI-Summary + Links in jeweiliges `/project/[id]/oee`.
- **app/oee/layout.tsx** — `OeeTabNav` entfernt (globale Tabs ergeben keinen Sinn mehr).
- **app/oee/trends/page.tsx** — redirect → `/oee`.
- **app/oee/verlustanalyse/page.tsx** — redirect → `/oee`.
- **lib/project/project-tabs-config.ts** — OEE-Tab immer sichtbar (nicht mehr an `hasOeeRecords` geknüpft), Label "OEE".
- **lib/project/__tests__/project-tabs-config.test.ts** — Tests angepasst: OEE immer im Tab-Set, neue Assertion für `href=/project/p1/oee`.

## 2026-05-26 — KAR-628 Agenda: Stammdaten-Vorlagen + Preview-Iterationen

- **Stammdaten-Vorlagen:** editierbare Standard-Agenden je Workshop-Typ unter `/repository/masterdata/agenda-vorlagen` (admin/masteradmin). „Automatischen Vorschlag erzeugen" lädt sie als Basis, Fallback auf eingebaute Defaults. Neue Tabelle `agenda_template` (items jsonb) + RLS (read authenticated, write admin) → `supabase-migration-agenda-templates.sql` (nicht appliziert). Generator akzeptiert `customItems`; Karte in der Master-Data-Übersicht.
- **Preview-Iterationen (Kais-Feedback):** Reorder-Bug-Fix (recompute-Re-Sort), always-visible Item-Felder, Inline-Undo am Item, Sprach-Varianten (eigene Agenda je Sprache), DnD-DragOverlay + Drop-Highlight, transiente Zeit-Bearbeitung, Zeit-folgt-Position beim Verschieben, A4-Hochformat-PDF, Teilnehmer-Tabelle ohne Position, Kontrast (helle Schrift auf dunklen Farben, UI/PDF/Excel).
- Delta-Migration `supabase-migration-agenda-deltas.sql` (`agenda_item.color`, `agenda_participant.comment`).
- Follow-up-Tickets: KAR-629 (app-weites Undo), KAR-630 (app-weite Kontrastlogik).

## 2026-05-25 — KAR-628 Agenda-Feature Round 2 (Verbesserungen + Fixes)

12 gezielte Änderungen am Agenda-Feature (Branch `feature/agenda`, kein Push/Merge). Alle 12 umgesetzt + getestet.

- **#8 Bug:** Agenda erscheint sofort nach „Erstellen" — `agenda-client` liest nicht mehr eingefrorenen State, sondern die Server-Prop (+ `key`-Remount).
- **#1/#3:** Vormittag/Nachmittag gilt nur für Tag 1; weitere Workshop-Tage sind volle Tage (08:30–17:30, default-Ende auf 17:30), Anreisetag ausgenommen, Kapazität pro Tag; Hinweistext im Wizard.
- **#2:** Uhrzeiten nach Erstellung editierbar — Dauer + manuell fixierbare Startzeit (`is_time_fixed`), 🔒/🔓-Marker auto vs. manuell, Recompute + Tagesende-/Overlap-Warnungen.
- **#4:** Farblogik — zentrale `lib/agenda/colors.ts` (Typ-Farben + Close-out distinct), per-Item Color-Picker, Persistenz via neue `agenda_item.color`, konsistent in UI/PDF/Excel.
- **#5:** PDF-Export auf A4 Hochformat umgestellt.
- **#6:** Hinweis „bereits eine Agenda vorhanden" im Editor.
- **#9:** Teilnehmer-Typen BMW(Host-Config-Label)/Lieferant/Extern, Felder Abteilung/Position/Rolle/E-Mail/Kommentar (neue `agenda_participant.comment`), manuelles Hinzufügen je Typ, „Gastgeber" aus UI raus (intern `host` = portabel).
- **#11:** DnD robuster (PointerSensor + TouchSensor) + Mobile-Fallback-Pfeile (hoch/runter/vorheriger/nächster Tag).
- **#12:** Spalte „Teilnehmer" pro Agenda-Punkt (sprachabhängig) in UI + PDF + Excel.
- **#7:** Auto-Ablage des Exports im Dokumente-Bereich (`documents` + Bucket) mit Versionierung (v2/v3) + Sprache im Titel, via bestehende Doc-Architektur + RLS.
- **#10:** Sprache nachträglich änderbar + Export/Doc-Ablage pro Sprache + mehrere Sprach-Varianten je Projekt (Kais-Entscheidung: eigene Agenda je Sprache) — Variant-Switcher + Klon-Action `createLanguageVariantAction` (re-lokalisierte Tagesüberschriften, kein Überschreiben).
- **Delta-Migration:** `supabase-migration-agenda-deltas.sql` (+ Rollback) — additive Spalten `agenda_item.color`, `agenda_participant.comment`. **Nicht appliziert.**
- 70 Agenda-/Tabs-Tests + 634 Repo-Tests grün, typecheck/eslint/check:portability strict grün.

## 2026-05-25 — KAR-628 Agenda-Feature (Workshop-Agenda-Generator)

Neue projektbezogene Unterseite `/project/[id]/agenda` für Workshop-/Lieferantenbesuchs-Agenden. Branch `feature/agenda`. **Migration NICHT angewendet, kein Push** (STOPP-Kriterien).

- **Pure Logic (voll getestet, 55 neue Tests grün):** `lib/agenda/time-engine.ts` (Auto-Zeitberechnung, fixierte Startzeiten, Warnungen), `lib/agenda/templates.ts` (4 Typen, kapazitätsbasierte Tagesverteilung, Anreisetag), `lib/agenda/i18n.ts` (DE/EN/ES/ZH, entkoppelt von App-Locale), `lib/agenda/export-model.ts` (+ Dateiname-Schema), `export-pdf.ts` (jspdf Landscape mehrseitig) + `export-excel.ts` (ExcelJS Landscape/printArea), logo-tolerant (0/1/2 Logos), `logo.ts` (SVG→PNG).
- **DB:** `supabase-migration-agenda-schema.sql` (+ Rollback) — 5 Tabellen + RLS (project-ownership + admin-Escape), `supplier_master_data.logo_storage_key`, Storage-Bucket `supplier-logos`. KEIN `tenant_id` (Repo-Konvention). **Nicht appliziert.**
- **UI/Route:** `app/project/[id]/agenda/{page,actions}.tsx`, Agenda-Tab (Logik nach `lib/project/project-tabs-config.ts` extrahiert + getestet). Wizard (5 Schritte), Editor mit `@dnd-kit` Drag&Drop (innerhalb/zwischen Tagen), Inline-Edit, Teilnehmer (BMW-Auto-Import), PDF/Excel-Export. Graceful Empty-State wenn Migration fehlt.
- **Offen:** `check:portability` strict meldet 27 BMW-Treffer (Feature ist BMW-Pilot-spezifisch) — Host-Org-Abstraktion vs. ADR-Allowlist offen (ADR-020). ZH-PDF-CJK-Font, Supplier-Logo-Upload-UI, Voll-Audit-Trigger als Follow-ups.

## 2026-05-23 — KAR-537 JWT-Key Rotation Runbook

- **`docs/security/service-role-key-rotation.md` Section 15** — JWT-Signing-Key-Rotation als separate Procedure (ASVS V11.7.1). Wann rotieren, Maintenance-Window-Pflicht, Pre/Post-Steps + Smoke-Tests.
- KAR-537 abgeschlossen.

## 2026-05-23 — KAR-541 Transactional Server-Action Audit

- **`docs/security/transactional-actions-audit.md`** inventarisiert alle Multi-Write Server-Actions. Verdict pro File:
  - PASS: `intake/new` (RPC submit_intake), `intake/board` (alle RPCs), `oee/analyse` (single-row), `lsc-workshop` (single-write).
  - FAIL: `pmo/workstreams.saveWorkstreamOrder` (N updates), `pmo/weekly.moveActivityToDay` (2 row updates).
  - PARTIAL: `intake/submit/[token]` (token-verify + insert).
- Recommended RPCs: `pmo_save_workstream_order`, `pmo_move_activity_to_day`, `submit_intake_via_token`, `admin_save_intake_field_order`.
- Test-Pattern fuer Rollback-Verification dokumentiert.
- KAR-541 abgeschlossen (Audit); RPC-Migrations sind Follow-up PRs.

## 2026-05-23 — KAR-539 Cache-Control no-store Helper (ADR-022 Class-1)

- **`lib/security/secure-response.ts`** liefert `secureJson(body, init)` und `secureNoStoreHeaders()`. Per-User-Dynamic-Routes nutzen das statt `NextResponse.json` damit Router-Cache + CDN den Response nicht inter-User shared.
- 4 Vitest-Cases.
- Adoption: jeder API-Route die User-attributierbare Daten zurueckgibt MUSS `secureJson` nutzen. Existing routes durch das Helper-Pattern in Follow-up migriert.
- KAR-539 abgeschlossen (Primitive); Code-Site-Migration ist Follow-up.

## 2026-05-23 — KAR-538 File-Event Audit Log Primitive

- `lib/audit/file-events.ts` mit `logFileEvent(event, ctx)` fuer Compliance-Audit-Trail. Events: `file.uploaded`, `file.downloaded`, `file.deleted`.
- 3 Vitest-Cases.
- Code-Site-Migration durch Storage-Handlers ist Follow-up.

## 2026-05-23 — KAR-534 MFA-Setup-Doku fuer Admin-Accounts

- **`docs/security/mfa-setup.md`** dokumentiert TOTP-Enablement im Supabase-Dashboard, Force-Enrolment-Flow fuer Admin/Masteradmin-Rollen, Recovery-Procedure (operator-supervised), Backup-Codes-Optionen.
- SMS bewusst deaktiviert (NIST SP 800-63B 2024+ Konsens: SS7- und SIM-Swap-Risiko).
- Follow-up PR: `/auth/mfa-enroll`-Route + `proxy.ts` MFA-Guard fuer `/api/admin/*`. Aktuelles PR liefert nur die Procedure-Doku.
- KAR-534 abgeschlossen (Doku-Teil); Code-Teil ist Follow-up.

## 2026-05-23 — KAR-535 PII-in-Logs Sweep

- **`lib/logger.ts` SENSITIVE_KEYS** um 14 PII-Felder erweitert: `first_name`, `last_name`, `full_name`, `display_name`, `phone`, `phone_number`, `mobile`, `address`, `street`, `iban`, `bic`, `tax_id`, `ssn`, `date_of_birth`, `dob`. Werte werden bei Log-Emit als `[REDACTED]` ersetzt.
- **`lib/email/notifications.ts`** `to_email` Field auf `to_email_domain` umgestellt — Domain ist genug fuer Delivery-Debugging, Full-Adresse verlaesst nie das Log.
- 16 neue Vitest-Cases gegen jedes PII-Field plus 1 Negativ-Test (`user_id` UUID bleibt sichtbar).
- KAR-535 abgeschlossen (ASVS V13.9.1).

## 2026-05-23 — KAR-533 HTML Sanitiser

- **`lib/security/sanitize.ts`** als Single-Chokepoint fuer User-HTML-Renders. `isomorphic-dompurify` server- und client-kompatibel. Allowlist: Basic-Formatting + Lists + Links; expliziter FORBID auf script/iframe/style/onerror/javascript-Protocols.
- 5 Vitest-Cases gegen OWASP-XSS-Cheat-Sheet-Klassiker.
- Current Code-Base hat *kein* raw-HTML-Render — Helper ist preventiv fuer kuenftige Markdown-Notes / Rich-Text-Editor.
- Adoption-Pattern: jeder neue Markdown-Render-Pfad muss durch `sanitizeHtml()`; ESLint-Rule fuer Bans landet in Follow-up.
- KAR-533 abgeschlossen.

## 2026-05-23 — KAR-520 DR-Runbook

- **`docs/operations/dr-runbook.md`** dokumentiert RTO 4h / RPO 1h Targets, 6 Incident-Kategorien (Deploy-Regression, Data-Corruption, Data-Loss, Tenant-Unavailable, Vercel/DNS, Total-Compromise) mit Schritt-fuer-Schritt-Recovery.
- Pre-Incident-Checklist (Out-of-Band-Credentials, PITR enabled, Phone-Tree).
- Quarterly Backup-Restore-Drill als Operator-Pflicht — die einzige Validierung dass die Runbook nicht silently veraltet.
- KAR-520 abgeschlossen.

## 2026-05-23 — KAR-517 Upstash Rate-Limit Primitive

- **`@upstash/ratelimit` + `@upstash/redis`** installiert. `lib/security/rate-limit.ts` exportiert `tryRateLimit(identifier, opts)` als One-Liner-Wrapper mit Fail-Open-Verhalten wenn Upstash-Env-Vars unset (Local-Dev-tauglich, Production-safe wenn Misconfig).
- Env-Schema um `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` erweitert; `.env.example` ergaenzt.
- 2 Vitest-Cases (fail-open mit Identifier-Echo).
- Per-Action-Adoption: Server-Actions koennen `tryRateLimit(...)` mit identifier `evaluate:<userId>` und Limit + Window aufrufen. Items aus KAR-526 F-4 nutzen das.
- KAR-517 abgeschlossen.

## 2026-05-23 — KAR-518 OWASP-Top-10-CI: Semgrep + eslint-plugin-security

- **`.github/workflows/security-semgrep.yml`** GitHub Action laeuft Semgrep mit fuenf Rule-Packs auf jeden PR + weekly: `p/typescript`, `p/react`, `p/nextjs`, `p/owasp-top-ten`, `p/security-audit`. `--severity=ERROR` blockt Merge bei Findings.
- **`eslint-plugin-security`** installiert + in `eslint.config.mjs` integriert mit kuratierten Rules. Critical-Rules \(`detect-eval-with-expression`, `detect-child-process`, `detect-pseudoRandomBytes`, etc.\) als error, taint-Heuristik-Rules als warn. `detect-object-injection` deaktiviert \(too noisy\).
- Existing security-Workflows: CodeQL \(SAST\), gitleaks \(secrets\), npm-audit \(deps\). Mit Semgrep nun 4-Layer-SAST-Defense.
- KAR-518 abgeschlossen.

## 2026-05-23 — KAR-519 Sentry-DSN aus Env + Observability-Runbook

- **Sentry DSN aus Code entfernt** — `sentry.server.config.ts` + `sentry.edge.config.ts` lesen `SENTRY_DSN` und `SENTRY_TRACES_SAMPLE_RATE` aus Env. Init wird no-op wenn DSN fehlt (Local-Dev-tauglich). Env-Schema (`lib/env-schema.ts`) um die zwei neuen Vars erweitert; `.env.example` ergaenzt.
- **`docs/operations/observability-runbook.md`** dokumentiert Stack (Sentry + lib/logger.ts + @vercel/otel + Speed Insights + Lighthouse-CI), Env-Vars, On-Call-Checklist und 4 Alert-Rules zur Konfiguration im Sentry-UI (kann nicht in Code gepinnt werden).
- KAR-519 abgeschlossen.

## 2026-05-23 — KAR-523 OWASP ASVS L2 Self-Assessment (KAR-522 Phase 4 follow-up C)

- **`docs/security/asvs-l2.md`** vollstaendige Erst-Bewertung gegen ASVS v5.0.0 Level 2. 14 Chapter (V1-V14), 128 zugeordnete Requirements: **73 Pass · 17 Fail · 30 N/A · 8 Defer**. Verifier-Spalte uniform `SELF` (Code-Review-Basis); externer Audit pending.
- **17 Fail Items** sortiert nach Impact: V1.8 Markdown-Sanitiser, V4.2 Auth-Gaps (KAR-526), V8.2 RLS duplicate-policies (KAR-525), V8.6 Token-Replay, V6.5 MFA, V13.9 PII in logs, V14.7 Data-Minimisation, V11.7 JWT-Key-Rotation u.a.
- **8 Defer Items** als post-Pilot dokumentiert (OAuth, SSO, Malware-Scan, etc.) — keine Blocker.
- Empfohlene Next Steps: Operator-Sign-off, KAR-Sub-Issues pro Fail, 2nd-pass nach Fixes, externer Audit.

## 2026-05-23 — KAR-532 @vercel/otel + Supabase-Spans (KAR-522 Phase 4 follow-up D.4)

- **`@vercel/otel`** + `@opentelemetry/api` + `@opentelemetry/sdk-trace-base` installiert. `instrumentation.ts` ruft `registerOTel({ serviceName })` zusaetzlich zur Sentry-Init — Sentry-OTel-Integration nutzt den gemeinsamen TraceProvider.
- **`lib/observability/spans.ts`** mit drei One-Liner-Helpers: `withDbSpan(operation, table, fn)` fuer Supabase, `withBusinessSpan(name, attrs, fn)` fuer Use-Cases, und `recordBusinessMetric` Registry mit 4 Pilot-KPIs (evaluationCreated, evaluationScore, importDuration, activeUsers).
- 6 Vitest-Cases gegen No-Op-OTel-API gruen. Adoption Opt-in pro Call-Site, existing Code laeuft unveraendert weiter.

## 2026-05-23 — KAR-524 Lighthouse-CI Fail-on-Regression (KAR-522 Phase 4 follow-up D.3)

- **`.github/workflows/lighthouse-ci.yml`** GitHub Action laeuft Lighthouse gegen den Vercel-Preview-Deploy jedes PRs. Wartet bis zu 5 min auf den Vercel-Status, dann 3 Runs gegen 6 Pages (Home, Dashboard, Intake Board, OEE Analyse, Repository, Kalender).
- **`.lighthouserc.json`** mit Core-Web-Vitals-Schwellen als error-Assertions: LCP ≤ 2500 ms, CLS ≤ 0.1, TBT ≤ 200 ms (Lab-Proxy fuer INP), Performance ≥ 0.85. A11y + Best-Practices als warn.
- Lab-Counterpart zu Vercel Speed Insights (Field), der KAR-522 QW3 bereits live geliefert hat.

## 2026-05-23 — KAR-528 INP Audit Heavy Interactions (KAR-522 Phase 4 follow-up D.2)

- **`docs/security/inp-audit.md`** Static-Heat-Map der Components mit hoechstem INP-Risiko: Tier-1 `components/oee/history-table.tsx` + `loss-details-table.tsx` als BMW-Critical-Path. Tier-2 Repository-Tables. Tier-3 Forms mit Per-Keystroke-Validation (warten auf KAR-530 React-Hook-Form).
- Repo-wide: **0 Usage von `useTransition` / `useDeferredValue`** im ganzen Code-Tree — free perf liegt ueberall. Keine Virtualisierung installiert (OK fuer aktuelle Row-Counts, Constraint dokumentiert).
- Live-Test-Plan: Chrome DevTools Performance auf Vercel-Preview mit 4x CPU-Throttling und Slow-4G. Findings in `docs/security/inp-audit-results-<date>.md` (operator action).
- Recommended Pre-Pilot Fixes: 1. Tier-1 OEE-Tables, 2. `useDeferredValue` auf Filter-Inputs, 3. KAR-530 Forms-Refactor abwarten.

## 2026-05-23 — KAR-529 DSGVO Auskunft + Loeschung (KAR-522 Phase 4 follow-up D.1)

- **`GET /api/users/me/export`** — DSGVO Art-15. Auth-gated, RLS-gefiltert, sendet JSON-Download mit allen Personen-Daten des authentifizierten Users (`profile` + 9 user-owned Tabellen).
- **`DELETE /api/users/me`** — DSGVO Art-17 Right-to-be-forgotten. Markiert das Profil mit `account_status = 'deletion_requested'` + `deletion_requested_at`. Hard-Delete erfolgt operator-supervised separat (FK-Integritaet + Audit-Trail).
- **`lib/gdpr/export.ts` + `lib/gdpr/deletion.ts`** als pure-Function-Helpers. Add-new-Table = ein Eintrag in `USER_OWNED_TABLES`. 8 Vitest-Cases (4 export, 4 deletion) — alle gruen.
- **`supabase-migration-gdpr-deletion-flag.sql`** + Rollback fuegt `deletion_requested_at` Spalte und neuen Status-Value an `user_profiles_account_status_check` an. Idempotent.
- ESLint-Boundaries: `lib/gdpr/**` als Platform-Layer klassifiziert.

## 2026-05-23 — KAR-526 Server-Actions Security-Audit (KAR-522 Phase 4 follow-up B.2)

- **`docs/security/server-actions-audit.md`** inventarisiert alle 30 Server-Actions in 11 Files mit 3 Fragen pro Action: Auth-Check, Input-Validation, Rate-Limit.
- 6 Findings: F-1 universelle Zod-Luecke, F-2 RLS-only-Actions in PMO (14 ohne expliziten getClaims), F-3 Token-Gated-OK, F-4 universelle Rate-Limit-Luecke (KAR-517-Hook), F-5 oee/analyse mit 15 Actions braucht `withAuth()`-Helper, F-6 Admin-Routes brauchen Role-Check innerhalb der Action.
- Recommended Pre-Pilot-Priority: F-2 → F-6 → F-5 → F-1 → F-4.

## 2026-05-23 — KAR-525 RLS Cross-User Audit (KAR-522 Phase 4 follow-up B.1)

- **`docs/security/rls-cross-tenant-audit.md`** klassifiziert jede owner-bearing Tabelle in 5 Risk-Classes (Owner-only, Owner-through-FK, Role-based, Public catalog, Lax/disabled). Komplementaer zum bestehenden 2026-04-21-Audit. Static-PASS auf Classes 1+2, RE-AUDIT auf `user_profiles` Policy-Duplikation, Class 4 Catalog mit Write-Lax-Flag bei `appointment_types` und `department_master_data`.
- **`__tests__/security/rls-cross-tenant.test.ts`** Test-Skeleton mit 27 `it.todo()`-Cases — Klasse fuer Klasse durch. Aktivierung gated auf Staging-Supabase mit 2 seeded users.
- Empfohlene Pre-Pilot-Actions im Audit-Footer (1. duplicate-policy-collapse auf user_profiles, 2. Live-Test einmal laufen, 3. D-3 schliessen).

## 2026-05-23 — KAR-527 Caching-Strategy ADR (KAR-522 Phase 4 follow-up A+)

- **ADR 022 `caching-strategy.md`** mappt 5 Daten-Klassen (per-user dynamic, master / reference, tenant config, static / marketing, external API) auf Next.js' 4 Cache-Schichten. Forward-looking: bestehende 8 `force-dynamic`-Routen + 49 `revalidatePath()`-Aufrufe bleiben; neue Features picken die Cache-Klasse up-front.
- Erste konkrete Folge-Migration: Master-Data-Reads via `unstable_cache()` + Tag-Invalidation (eigener PR, nicht hier).
- ADR-Index aktualisiert, naechste freie Nummer 023.

## 2026-05-22 — KAR-531 State-Management ADR (KAR-522 Phase 4 follow-up A)

- **ADR 021 `state-management-strategy.md`** dokumentiert die 5-Kategorien-Klassifikation (Component / Server-Cache / Form / Application / URL) mit Tool-Empfehlung pro Kategorie und Migrations-Pfad (forward-looking, kein Rewrite). Adoption aus KAR-522 Source 14 (bulletproof-react).
- ADR-Index `docs/adr/README.md` aktualisiert. Naechste freie ADR-Nummer ist nun 022.

## 2026-05-22 — KAR-522 Phase 4 Quick-Wins (Architecture Audit Adoption)

- **QW1: Env-Var-Validation via @t3-oss/env-nextjs** — `lib/env-schema.ts` als Zod-validierter Single-Source-of-Truth fuer Env-Vars. Fail-fast at server-start via `instrumentation.ts`. `lib/env.ts` als Compat-Layer auf das neue Schema umgebaut. Test-Coverage: 7 Cases (env-schema + env). `check-secrets.mjs` Allowlist erweitert. Adoption aus KAR-522 Source 15.
- **QW2: react-hooks Lint-Discipline** — `react-hooks/rules-of-hooks` explizit auf `error` gepinnt in `eslint.config.mjs`. `exhaustive-deps` bleibt `warn` (23 standing warnings, gesonderter Fix-Pass noetig). Adoption aus KAR-522 Source 10.
- **QW3: Vercel Speed Insights** — `@vercel/speed-insights` installiert, `<SpeedInsights />`-Component in `app/layout.tsx` eingehaengt. Liefert Core-Web-Vitals (LCP/INP/CLS) als Field-Measurements auf Vercel-Deploys. GDPR-conform (no cookies). Adoption aus KAR-522 Source 3.
- **Audit-Output**: 21 architecture-audit notes (20 Sources + Plan + Synthese). 5 standing-order candidates identified. 10 further P1 items tracked for BMW-Pilot.

## 2026-05-19 — KAR-343 + KAR-344 Phase 1: PMO Cockpit + Projektnotizen Scaffold

- **Scope**: Datenmodell und Route-Skeleton für zwei neue Module (PMO Cockpit für PLT/Sonderprojekt-Steuerung, Projektnotizen für iPad-Stift-Eingabe). UI-Befüllung folgt in den Folge-Phasen.
- **Schema-Migration** `supabase-migration-pmo-phase-1.sql` (idempotent, reversible, im tx):
  - `project_members` (Team + Rollen: plt_leader / taskforce_leader / workstream_owner / team_leader / member, L1-L5)
  - `pmo_workstreams` (taskforce / core / support)
  - `pmo_arbeitspakete` (A-F innerhalb Workstream, vor allem TASKFORCE)
  - `pmo_weekly_goals` + `pmo_daily_activities`
  - `pmo_measures` (Maßnahmen-Tracking, optional cross-link zu `project_notes` via `source_note_id`)
  - `pmo_log_entries` (type-Discriminator: risk / issue / decision in einer Tabelle)
  - `pmo_meetings` (Daily Check-in/out, Weekly Planning/Review, Core Priorisierung, adhoc)
  - `pmo_handovers` (Tier-2-Übergabe)
  - `project_notes` + `project_note_attachments` (Canvas-Daten in jsonb, Annotations-Layer pro Bild)
  - **RLS** für alle 11 Tabellen via `project_id IN (SELECT id FROM projects WHERE user_id = auth.uid())`-Pattern (mirror von cycle_measurements / qaf_uploads)
- **Routes**:
  - `/pmo` — Projekt-Liste + Empty-State
  - `/pmo/[id]/cockpit` — Cockpit mit Tab-Navigation (11 Tabs, alle Phase 1 noch leer außer Cockpit)
  - `/notes` — Notiz-Liste über alle Projekte + Empty-State
- **Wiederverwendung**: keine bestehende Tabelle angefasst. `workshop_actions`, `lsc_measures`, `cycle_measurements`, `process_step_observations` bleiben unverändert und werden in späteren Phasen für KPI-Aggregation + Maßnahmen-Cross-Link genutzt
- **Quelle**: BCG PLT-Playbook (Valeo USS Case Study) + Kais-Prompt 2026-05-19
- **Risk**: niedrig — alles additiv, keine bestehenden Migrationen modifiziert, RLS strict

## 2026-05-19 — KAR-339: QAF-Mapping V2 Sprint A (UX-Refinement)

- **Scope**: QAF↔Prozess-Mapping-Dialog erhält Source-Context, Confidence-Erklärung, größeres Layout, Drag-&-Drop-Sidebar und ein prominentes Header-Banner. Reine UI/UX-Polish, kein Schema-Change.
- **Header**: Dateiname (mono, ohne Truncate) + Upload-Datum + Version-Badge (Zur Vergabe / Nach Vergabe / Aktuell) direkt unter dem Titel — sichtbar ohne Hover
- **Confidence-Tooltip**: Info-Icon + `title=` an jedem %-Badge erklärt die Score-Buckets (>70% hoch, 30-70 prüfen, <30 manuell)
- **Layout**: `max-w-3xl` → `max-w-7xl`, `max-h-[80vh]` → `max-h-[92vh]`, Cards in `grid-cols-1 lg:grid-cols-2`
- **Drag-&-Drop**: neue linke Sidebar (`w-60`) listet alle Process-Steps als draggable Items (HTML5 native, keine neue Dependency). Drop auf QAF-Card setzt Mapping als `manual`. Dashed-Primary-Border-Highlight bei Hover, 40% Opacity am gedraggten Step. Click-Flow (Vorschläge, „Manuelles Zuordnen") bleibt für Tastatur/Touch erhalten.
- **Sortierung**: unmatched Rows nach bestem Fuzzy-Score, einfache Vorschläge zuerst
- **PRs**: #57 (Sprint A V1) + #58 (Header + Drag-Drop recovery nach squash-merge race)
- **Risk**: niedrig — Mapping-Logik in `lib/qaf/process-mapping.ts` unverändert (424/424 Tests grün), Schema-Stand identisch

## 2026-05-14 — KAR-164 PR-F: Frontend-Pages auf list_intakes_full + get_intake_full RPCs

- **Scope**: 3 Intake-Pages umgestellt auf RPC-Helper aus PR-E. Frontend liest jetzt aus EAV (`intake_field_values`) via RPC statt direkter Header-Spalten. Foundation für PR-H (DROP COLUMN)
- **Geänderte Pages** (`app/intake/`):
  - `page.tsx` (Liste): `list_intakes_full(NULL, 100)` statt direkte `.from('intakes').select(...)`. Supplier-Label kommt aus `fields.supplier_picker.value`
  - `board/page.tsx` (Kanban): `list_intakes_full(['backlog','review'], 200)`. `project_lead`, `supplier_picker`, `first_visit_range` aus `fields` extrahiert. Reviewer-JOIN automatisch in RPC
  - `[id]/page.tsx` (Detail): `get_intake_full(id)` ersetzt 3 separate Queries (intakes + intake_field_values + intake_field_definitions). Block-Sections rendern direkt aus `fields`-Object mit label/sort_order pro Field
- **Daten-Mapping**:
  - BoardIntake-Type erhält Werte aus `fields.project_lead.value`, `fields.supplier_picker.value`, `fields.first_visit_range.value` (daterange-Object → start/end)
  - Detail-Page liefert promoted_project + reviewer als jsonb-Sub-Objects
- **Risk**: niedrig — solange KAR-162 Trigger Header und EAV synchron hält, sind die Daten identisch. Pages funktionieren mit oder ohne Header-Spalten

## 2026-05-14 — KAR-164 PR-G: Promote-RPC v4 — EAV-Source statt Header-Spalten

- **Scope**: `promote_intake_to_project` v4 liest alle Business-Felder aus `intake_field_values` per Helper-Function statt direkter Header-Spalten (`v_intake.<col>`). Foundation für PR-H (DROP COLUMN). Solange KAR-162 Trigger aktiv ist: v3 und v4 funktional äquivalent
- **Migration**: `supabase-migration-intake-v1-promote-rpc-v4.sql` (+ Rollback restored v3)
- **Neue Helper-Functions** (interne `_*` Naming):
  - `_intake_field_value(intake_id, field_key)` — liefert jsonb-value oder NULL
  - `_intake_field_text(intake_id, field_key)` — extracted text, NULLIF auf empty
- **v4 Source-Migration**: 18 field_key-Lookups via `_intake_field_text` statt `v_intake.<col>`:
  - `project_lead`, `intake_type`, `delivery_scope`, `requesting_dept`, `part_designation`, `vehicle_project`, `product_line`, `sop_date`, `award_status`, `escalation_level`, `supplier_billable`, `order_reason`, `order_objective`, `supplier_picker`, `supplier_plant_location`, `supplier_number`, `priority`, `second_visit_range`
- **Fallback-Logik**: `intake_type` und `requesting_dept` fallen zurück auf Header wenn EAV NULL (system-set via Token vor EAV-Layer)
- **Erweitertes notes-Addendum**: `sop_date`, `part_designation`, `supplier_number` ergänzt
- **Return-jsonb**: `source: 'eav_v4'` als Marker im Result
- **Risk**: gering — solange Trigger aktiv, identisches Verhalten zu v3

## 2026-05-14 — KAR-164 PR-E: get_intake_full + list_intakes_full RPC-Helpers

- **Scope**: Foundation für KAR-164 Schema-Konsolidierung. Zwei neue RPC-Helper liefern intake + alle field_values als jsonb. Frontend kann auf `intake.fields.<field_key>` zugreifen statt 20+ separate JOIN-Queries. Foundation für PR-F (Pages-Refactor) und PR-H (DROP COLUMN)
- **Migration**: `supabase-migration-intake-v1-get-intake-full-rpc.sql` (+ Rollback)
  - `get_intake_full(p_intake_id uuid)`: liefert `{intake, fields, reviewer, promoted_project}` für Detail-Page. intake-jsonb hat Header-Spalten gestripped (nur Metadaten)
  - `list_intakes_full(p_statuses text[], p_limit int)`: liefert N intakes mit field_values als jsonb-Array. Für Pipeline-Board (`['backlog','review']`) und Liste (NULL = alle)
  - GRANT EXECUTE für `authenticated`
- **Foundation für**:
  - PR-F: Frontend-Pages (`app/intake/page.tsx`, `[id]/page.tsx`, `board/page.tsx`) auf RPCs umstellen
  - PR-G: `promote_intake_to_project` v4 nutzt EAV-Extract statt Header-Spalten
  - PR-H: DROP COLUMN für 22 Header-Duplikate (nur nach PR-F+G)

## 2026-05-14 — KAR-146 PR-C: Supplier-Picker mit Search (Stammdaten + Auto-Fill)

- **Scope**: Lieferant-Picker mit Such-Funktion im Intake-Form (Public-Submission + Berater-Form). 1007 Lieferanten in `supplier_master_data` werden durchsuchbar. Auto-Fill von `supplier_number` + `supplier_plant_location` nach Auswahl
- **Migration**: `supabase-migration-intake-v1-supplier-search-rpc.sql` (+ Rollback)
  - RPC `search_suppliers_public(p_query text, p_limit int)`: durchsucht `supplier_master_data` per ILIKE auf `supplier_name`, `supplier_number`, `city`. Top-20 nach Relevanz (Prefix-Match first). GRANT EXECUTE für `anon` und `authenticated`
  - Trigram-Indizes auf `supplier_name` + `supplier_number` (gin_trgm_ops) für ILIKE-Performance
- **Frontend** (`components/intake/`):
  - Neue Component `supplier-picker.tsx`: Autocomplete-Input mit Debounce 250ms, Dropdown mit Name/Nummer/Stadt/Plant, Freitext-Fallback wenn kein Master-Data-Treffer
  - `SupplierPickerValue` Type: `master_data | freetext | null`
  - Integration in `public-submission-form.tsx` + `intake-form-client.tsx`: `supplier_picker` field_type rendert SupplierPicker statt Text-Input
  - Cross-Field Auto-Fill: bei Master-Data-Auswahl `supplier_number` + `supplier_plant_location` automatisch befüllt
- **Trigger-Kompatibilität**: SupplierPicker speichert value als String (UUID für Master-Data, Freitext sonst), matched `sync_intake_header_from_field_value` Trigger-Logik (KAR-162)

## 2026-05-14 — KAR-141/142/143/144/145/147 PR-B: Master-Data Link + Flag-Icons

- **Scope**: Verbindet `intake_field_definitions` mit `master_data_types` aus PR-A. RPC `get_intake_submission_form` lädt Options aus Master-Data wo verlinkt. Frontend rendert Flag-Icons für Country-prefixed Werte (BMW-Werke)
- **Migration**: `supabase-migration-intake-v1-field-defs-master-data-link.sql` (+ Rollback)
  - `ALTER TABLE intake_field_definitions ADD COLUMN master_data_type_code TEXT REFERENCES master_data_types(code)`
  - Index `idx_intake_field_definitions_master_data_type`
  - Verlinkungen: `intake_type → PROJECT_TYPE`, `requesting_dept → REQUESTING_DEPARTMENT`, `vehicle_project → VEHICLE_PROJECT`, `award_status → AWARD_STATUS_INTAKE`, `product_line → PRODUCT_LINE`, `bmw_target_plants → BMW_PLANT`
  - RPC `get_intake_submission_form` v2: Option-Source-Priorität COALESCE(intake_field_options → master_data_values → [])
- **Frontend** (`components/intake/`):
  - `public-submission-form.tsx` + `intake-form-client.tsx`: `FieldOption` Type erweitert um `normalized_label?: string`
  - `COUNTRY_FLAG_PREFIXES` Mapping: `de_` → 🇩🇪, `us_` → 🇺🇸, `mx_` → 🇲🇽, `cn_` → 🇨🇳, `za_` → 🇿🇦, `hu_` → 🇭🇺, `uk_` → 🇬🇧
  - `flagForOption()`: liest `normalized_label`, matcht Prefix, gibt Flag-Emoji zurück
  - Multiselect-Button rendert Flag vor Label

## 2026-05-14 — KAR-141/142/143/144/145/147 Foundation: Master-Data Seed für Intake

- **Scope**: Foundation für Master-Data-Sprint. 6 neue `master_data_types` angelegt + initial values geseedet. Foundation für PR-B (Intake-Form UI-Refactor auf Master-Data-Dropdowns)
- **Migration**: `supabase-migration-intake-v1-master-data-types.sql` (+ Rollback)
- **Neue Types**:
  - `PROJECT_TYPE` (KAR-141): 6 Werte (lieferantenbefaehigung, lsc_workshop, fabrikanalyse, invest_workshop, sonderprojekt, sonstiges)
  - `REQUESTING_DEPARTMENT` (KAR-142): 5 Placeholder (Powertrain, Karosserie, Elektrik/Elektronik, Fahrwerk, Sonstiges) — Korrektur: NICHT MO-23/24 (das sind Auftragnehmer). `supports_free_text=true`
  - `VEHICLE_PROJECT` (KAR-143): 3 Beispiele (G05, U06, S58). `supports_free_text=true` für Pflege durch Berater
  - `AWARD_STATUS_INTAKE` (KAR-144): 5 Werte (rfq, awarded, sop_reached, series, eol) — übernimmt existing intake_field_options
  - `PRODUCT_LINE` (KAR-145): 16 Werte (1er-8er, X, Z, M, i, MINI, Rolls-Royce, Motor)
  - `BMW_PLANT` (KAR-147): 16 Werte inkl. neu **Irlbach-Straßkirchen** und **Debrecen**. `normalized_label` als Country-Prefix (`de_munich`, `hu_debrecen`) für späteres Flag-Icon-Mapping in UI
- **Deferred** zu PR-B (UI-Refactor):
  - Intake-Form Components auf Dropdown aus `master_data_values` umstellen
  - intake_type CHECK-Constraint droppen (wenn Form auf Master-Data umgestellt)
  - Flag-Icons im BMW_PLANT-Dropdown
- **Akzeptanz**: Migration apply → 6 Types vorhanden + Counts pro Type matchen Verification-Query im Migration-File

## 2026-05-14 — KAR-167: Promote-RPC v3 — erweitertes Field-Mapping

- **Bug**: `promote_intake_to_project` v2 (PR-E) mappte nur 12 von 20+ relevanten Intake-Feldern auf Projekt-Spalten. Promoted Projekte hatten NULL für `status_id`, `responsibility_id`, `project_lead_id`. `preferred_second_visit_*` ging verloren
- **Migration**: `supabase-migration-intake-v1-promote-rpc-v3.sql` (+ Rollback auf v2)
- **Neu im Mapping**:
  - `project_lead_id`: Lookup `consultants.id WHERE LOWER(display_name) = LOWER(intake.project_lead)`. Fallback: Freitext landet in `projects.notes`
  - `status_id`: Default `project_statuses.code = 'status_10'` (In Bearbeitung)
  - `responsibility_id`: Lookup `project_responsibilities.code = lower(replace(requesting_department, '-', ''))` (z.B. 'MO-23' → 'mo23')
  - `folgetermine` jsonb: `[{"type":"zweitbesuch","start":...,"end":...,"source":"intake_promote"}]` aus `preferred_second_visit_*`
  - `notes`: serialisiert award_status, escalation_level, priority, supplier_billable (keine direkten projects-Spalten)
- **Return-jsonb erweitert**: `mapped_fields` zeigt pro Run welche Mappings erfolgreich waren
- **Akzeptanz**: promoted Projekt hat Status, Verantwortung, Projektleiter, Zweitbesuch — alle aus Intake übernommen

## 2026-05-14 — KAR-169: Reviewer-Name Optimistic-Update Fix

- **Bug**: Nach Drag-to-Review + Reviewer-Picker zeigt die Card im Review-Column keinen Reviewer-Namen, obwohl DB korrekt befüllt ist (verified A-2026-003: `reviewing_consultant_id` gesetzt)
- **Root-Cause**: `handleReviewerConfirm` setzt im Optimistic-Update nur `reviewing_consultant_id`, nicht `reviewing_consultant_name`. Card rendert via `reviewing_consultant_name` (null bis `router.refresh()` durch ist) → kein Name sichtbar bis Server-Refresh
- **Fix** (`components/intake/`):
  - `intake-reviewer-picker-modal.tsx`: `onConfirm`-Signatur erweitert um `consultantDisplayName`. Modal liefert beim Confirm sowohl ID als auch Display-Name aus der gewählten Option
  - `intake-board.tsx`: `handleReviewerConfirm` setzt im Optimistic-Update beide Felder. Rollback im Error-Pfad restored beide Felder

## 2026-05-12 — OEE PR-C-2: Step 3 Messungen + Live-Preview + Step 4 Auswertung

- **Scope:** Zweite Hälfte des OEE-UI-Refactors. Step 3 (Messungen pro Station mit Live-KPI-Preview) und Step 4 (Auswertung mit gesamt-KPIs + pro-Station-Tabelle + Verlust-Bars) sind voll funktional
- **Step 3 Messungen** (`components/oee/analysis-measurements-manager.tsx`):
  - Tab-Switcher zwischen Stationen oben (mit Messungs-Counter pro Station)
  - Pro Station: Inline-editierbare Mess-Tabelle (8 Spalten: Session, Geplant min, Lauf min, Stück gesamt/gut/Ausschuss, CT sec)
  - "+ Messung"-Button legt neue Row mit vorherigem CT als Default an
  - Inline-Edit mit Debounce 300ms vor Server-Action (`updateMeasurement`), Optimistic-Update sofort sichtbar
  - Live-Preview-Panel rechts (lg:sticky lg:top-4): OEE/Verfügb./Performance/Qualität als KPI-Tiles, gefärbt nach Schwelle (≥85%=success, ≥60%=warning, <60%=destructive)
  - Footer-Notes: Stück-Summen, Lauf/Geplant-Min, ⌀ Zykluszeit
- **Step 4 Auswertung** (`components/oee/analysis-summary.tsx`):
  - 4 Big-KPI-Cards für Gesamt-OEE/A/P/Q
  - Tabelle pro Station mit A/P/Q/OEE + Messungs-Count, Bottleneck-Highlight (niedrigste OEE-Station)
  - Verlust-Bars für 5 Loss-Kategorien (Breakdowns, Setup, Idling, Reduced Speed, Defects) mit Anteil + Minuten
  - Finalisieren-Button setzt `oee_records.status='final'` (RPC bewahrt aktuelle Stati)
- **Pure helpers** (`lib/oee/measurement-engine.ts`):
  - `validateMeasurementInput` mit 6 Error-Codes (negative Werte, actual>planned, good>total)
  - `calculateStationKpis` — multi-measurement Summenbildung + time-weighted Avg Cycle-Time
  - `aggregateAnalysisKpis` — über alle Stationen
  - `aggregateLossBreakdown` — 5 Loss-Buckets in Minuten, quality_loss_count → min über time-weighted cycle
  - `formatPercent` (1 Dezimalstelle)
- **Server Actions ergänzt:** `loadMeasurements`, `createMeasurement`, `updateMeasurement`, `deleteMeasurement`, `finalizeAnalysis` in `app/oee/analyse/actions.ts`
- **Stepper-Client refactored:** Step 3 + 4 verdrahtet, Lock-Logik öffnet alle 4 Steps sobald Stationen vorhanden sind
- **Tests** (`__tests__/oee-measurement-engine.test.ts`, 17 Tests): Validation, Single-Measurement-KPIs (A/P/Q-Berechnung), Multi-Measurement-Sum, Time-Weighted-Cycle, Analysis-Level-Aggregation, Loss-Breakdown, Cap-Logik (A/P/Q ≤ 1.0)
- **Out-of-Scope (Folge-PRs):**
  - Ablöse `OeeCalculator` aus `/oee/page.tsx` und `/project/[id]/oee/page.tsx` (separater PR, Migration vorhandener Records)
  - Vergleichsansicht (OEE PR-D)
  - Trend- und Verlustanalyse (OEE PR-E)
  - Orphan-Cron (OEE PR-F)
  - PWA/Offline-Support für Mess-Erfassung
  - 6. Loss-Kategorie "Startup Rejects" (Schema hat dafür kein eigenes Feld — könnte über `breakdown_min`-Subset abgebildet werden in V2)

## 2026-05-12 — OEE PR-C-1: Stepper-Skeleton + Step 1 + Step 2 (Grunddaten + Stationen)

- **Scope:** Erste Hälfte des OEE-UI-Refactors. Bestehender `OeeCalculator` (Single-Station) bleibt vorerst parallel — wird in PR-C-2 nach Migration des Mess-Erfassungs-Pfads abgelöst
- **Routes:**
  - `/oee/analyse/new` — Grunddaten-Formular, legt `oee_records`-Header an, redirected zu Step 2
  - `/oee/analyse/[id]?step=…` — Stepper-Page mit URL-State (`grunddaten` | `stationen` | `messungen` | `auswertung`)
- **Stepper-Skeleton** (`components/oee/stepper-nav.tsx`, `analysis-stepper-client.tsx`): 4 Steps mit Lock-Logik (Step 3/4 disabled bis Stationen vorhanden). `messungen` und `auswertung` zeigen aktuell Placeholder mit Hinweis auf PR-C-2
- **Step 1 Grunddaten** (`analysis-new-client.tsx`, `analysis-header-form.tsx`):
  - Radio-Switch zwischen "Mit Projekt verknüpfen" und "Sandbox (Orphan)"
  - Project-Picker / Supplier-Picker je nach Modus
  - Name, Analyse-Datum, optionaler Zeitraum (Start/Ende)
  - Sandbox-Hinweis: 28-Tage-Auto-Delete + 3× Verlängerungs-Option
- **Step 2 Stationen** (`analysis-stations-manager.tsx`):
  - Liste mit Sort ↑/↓ (Swap zweier `order_idx`-Werte), Delete
  - Add-Form für neue Stationen (Name)
  - Import-Sektion: Lädt `process_steps` des verknüpften Projekts (LSC-Workshop), per Klick als Station übernommen (mit `linked_process_step_id`-FK)
  - Max-Limit `MAX_STATIONS_UI = 10` (UI-Grenze, DB-seitig unbegrenzt)
- **Pure helpers** (`lib/oee/analysis.ts`):
  - `STEP_KEYS`, `STEP_LABEL_DE`, `nextStepFromCurrent`, `prevStepFromCurrent`
  - `validateHeaderInput` (name, analysis_date, period-range, supplier-für-orphan)
  - `validateStationInput` (name, order_idx)
  - `MAX_STATIONS_UI`, `VALIDATION_LABEL_DE`
- **Server Actions** (`app/oee/analyse/actions.ts`): `createAnalysisHeader`, `updateAnalysisHeader`, `loadAnalysisHeader`, `loadStations`, `createStation`, `updateStation`, `deleteStation`, `loadProjectsForPicker`, `loadSuppliersForPicker`, `loadProcessStepsForProject`. Header-Insert setzt `is_orphan=true` + `orphan_deletion_due=now()+28d` wenn kein Project
- **Orphan-Lifecycle:** Beim Anlegen ohne Project wird `is_orphan=true`, `orphan_created_at=now()`, `orphan_deletion_due=now()+28d` gesetzt. Auto-Delete-Cron + Reminder-Mails kommen in OEE PR-F (siehe Prompt-Doc Abschnitt 6)
- **Tests** (`__tests__/oee-analysis.test.ts`, 13 Tests): Header-Validation, Station-Validation, Step-Navigation, STEP_KEYS-Order
- **Out-of-Scope (PR-C-2):**
  - Step 3 Messungen mit Live-Preview (Debounce, optimistic, KPI-Cards rechts)
  - Step 4 Auswertung (KPIs gesamt + pro Station + Donut)
  - Ablöse `OeeCalculator` aus `/oee/page.tsx` und `/project/[id]/oee/page.tsx`
- **Out-of-Scope (Folge-PRs OEE-D bis F):**
  - Vergleichsansicht mit Supplier-Filter (PR-D)
  - Trend- und Verlustanalyse (PR-E)
  - Orphan-Cron-Job + Reminder-Mails + Restore (PR-F)

## 2026-05-12 — Intake PR-H: Field-Builder Admin-UI

- **Scope:** Admin-UI für `intake_field_definitions` und `intake_field_options`. Admin/Masteradmin können Pflichtfelder, Reihenfolge, Labels, Hilfetexte und bedingte Sichtbarkeit (`visible_if` JSON) editieren — sowie neue Felder anlegen
- **Route:** `/admin/intake-fields` (Role-Gate `admin`/`masteradmin` → sonst Redirect zu `/projektanlage`)
- **Client-Component** (`components/admin/intake-fields-client.tsx`): Gruppierung nach Block (1-9, mit BMW-Auftragsklärungs-Labels), Filter nach Block + Inaktive-Toggle. Inline-Aktionen pro Feld:
  - Sort-Buttons (↑/↓)
  - Required-Toggle
  - Aktiv-Toggle
  - Optionen-Editor (nur für select/multiselect/hybrid_select)
  - Bearbeiten-Modal
- **Edit-Modal** (`intake-field-edit-modal.tsx`): label_de/en, helptext_de/en, block, sort_order, is_required, is_active, visible_if JSON. `field_key` und `field_type` sind nach Anlage gelocked (würde EAV-Values brechen)
- **Create-Modal** (`intake-field-create-modal.tsx`): field_key mit Pattern-Validation (`/^[a-z][a-z0-9_]{2,49}$/`), field_type aus 12 Optionen, alle Felder editierbar bei Anlage
- **Options-Modal** (`intake-field-options-modal.tsx`): Lazy-Load via Server Action. Liste mit Toggle-Active + Delete-Action pro Option. Add-Form unten mit option_value (intern), Label DE/EN, Sort-Order
- **Pure helpers** (`lib/intake/field-builder.ts`):
  - `FIELD_KEY_PATTERN`, `FIELD_TYPES`, `SELECT_TYPES`, `FIELD_TYPE_LABEL`
  - `validateDefinitionInput` (create), `validateUpdateDefinitionInput` (update), `validateOptionInput`
  - JSON-Parse von `visible_if` mit `try/catch`, Error-Map für DE-Labels
  - `BLOCK_LABELS` für die 9 BMW-Auftragsklärungs-Sections
- **Server Actions** (`app/admin/intake-fields/actions.ts`): `loadFieldDefinitions`, `loadFieldOptions`, `createFieldDefinition`, `updateFieldDefinition`, `setFieldDefinitionActive`, `setFieldDefinitionRequired`, `moveFieldDefinitionSortOrder`, `createFieldOption`, `updateFieldOption`, `deleteFieldOption`. Admin-Role-Check in jeder Action. RLS aus PR-A (`ifd_admin_write`, `ifo_admin_write`) erzwingt zusätzlich Server-Side
- **Tests** (`__tests__/intake-field-builder.test.ts`, 22 Tests): FIELD_KEY_PATTERN, SELECT_TYPES, alle Validierer mit edge-cases (leeres label, block out-of-range, visible_if-JSON malformed, sort_order negativ, alle field_types werden akzeptiert)
- **Out-of-Scope (Folge-PRs):**
  - Frontend-Renderer der die Felder dynamisch aus DB statt aus `intake-form-client.tsx` (PR-C) generiert — würde Refactor der Fachbereichs-Form bedeuten
  - Visueller `visible_if`-Builder statt JSON-Textarea
  - Bulk-Import / Export von Field-Definitions
  - i18n für Field-Builder-Admin-UI selbst (Deutsch-only)

## 2026-05-12 — Intake PR-G: Theme / Phase-Konfiguration (CSS-Variablen aus DB)

- **Scope:** Status- und Prioritäts-Farben kommen jetzt aus `project_phase_config` statt hardcoded. CSS-Variablen werden zur Runtime aus DB-Werten gerendert
- **Seed-Migration** (`supabase-migration-intake-v1-phase-config-seed.sql`): 9 Default-Phasen — 5 Intake-Status (backlog/review/promoted/rejected/archived) + 4 Prioritäten (low/normal/high/escalation). Idempotent (`ON CONFLICT (phase_key) DO UPDATE`). Farben aus BMW Corporate Identity (Ocean Blue #0066B1, Dark #003D6B, Signal-Farben)
- **Pure helpers** (`lib/intake/phase-config.ts`):
  - `phaseConfigKeyForIntakeStatus/Priority` — kanonische Key-Builder
  - `cssVarNameForPhase` — `intake_status_backlog` → `--phase-intake-status-backlog`
  - `cssVarRefForPhase(key, fallbackHex)` — generiert `var(--phase-..., #fallback)` für inline styles
  - `buildPhaseConfigCss` — `:root { ... }` CSS für aktive Einträge (mit hex-Validation)
  - `resolvePhaseHex(entries, key)` — DB-First mit `FALLBACK_HEX`-Map als Sicherheitsnetz
- **Server-Loader** (`lib/intake/phase-config-server.ts`): `loadPhaseConfigServer()` lädt aktive Phasen sortiert. Bei Fehler `[]` — UI greift auf Fallback zurück, niemals dead-Code
- **CSS-Scope** (`components/intake/phase-config-style.tsx`): `<PhaseConfigScope>` Server Component wrapped die Page, setzt CSS-Variablen via `style`-Prop am Root-`<div>`. Kein HTML-Inject — Hex-Inputs werden vor der Übergabe validiert
- **Refactors:**
  - `components/intake/intake-card.tsx`: `PRIORITY_STYLE` Tailwind-Map entfernt, `priorityChipStyle` nutzt `color-mix(in srgb, var(...) 15%, transparent)` für Hintergrund
  - `components/intake/intake-timeline.tsx`: `PRIORITY_BAR` Tailwind-Map entfernt, Erst-/Zweitbesuch-Balken nutzen `priorityBarStyle` (opacity 1.0 / 0.6)
  - `app/intake/board/page.tsx`: Parallel-Loading von `intakes` + `phaseConfig`, `PhaseConfigScope` als Root-Wrapper
- **Tests** (`__tests__/intake-phase-config.test.ts`, 18 Tests): hex-Validation, Key-Builder, CSS-Generation, Fallback-Resolution, FALLBACK_HEX-Coverage
- **Out-of-Scope (Folge-PRs):**
  - Admin-UI zum Editieren von Phase-Config-Einträgen (V2)
  - Tenant-spezifische Phase-Configs (Multi-Tenant-Spike)
  - Übertragung auf andere Module (OEE/LSC) — getrennte PRs

## 2026-05-12 — Intake PR-F: Kalender / Verfügbarkeits-Timeline mit Self-Editor

- **Scope:** Zweite Ansicht für Berater-Kanban — horizontale 8-Wochen-Timeline mit Intake-Wunschzeiträumen (Erst- und Zweitbesuch) und Berater-Verfügbarkeits-Heatmap. Tab-Switcher zwischen Kanban / Timeline auf `/intake/board`
- **Range:** Default 8 Wochen ab Wochen-Start (KW), 1- und 2-Wochen-Sprünge plus "Heute"-Reset
- **Heatmap:** Pro Berater eine Zeile, Tages-Cells gefärbt nach `consultant_availability.status` (free/partial/full/vacation/blocked → grün/gelb/rot/grau/dunkel). "Du"-Zeile prominent oben in Primary-Tönung
- **Intake-Balken:** Pro Intake max 2 Balken (Erstbesuch oben, Zweitbesuch unten leicht transparent). Farbe nach Priorität (low/normal/high/escalation)
- **Hover-Logic:** Hover auf einen Intake → Berater die in BEIDEN Wunschzeiträumen frei sind (keine `full`/`vacation`/`blocked`-Überlappung) bekommen Ring-Highlight, andere werden gedimmt. `findFreeConsultantIdsForRange` pure helper
- **Self-Editor:** Klick in eigene Zeile öffnet Modal mit Datum-Range, Status-Dropdown, Notiz. Bestehende Einträge im sichtbaren Range werden gelistet und können einzeln gelöscht werden. RLS `ca_own` aus PR-A erzwingt Self-Only-Schreiben
- **Pure helpers:** `lib/intake/availability.ts` — Range-Overlap, Day-Inclusion, Status-Aggregat (highest priority wins), free-consultant-finder, Day-Cell-Builder, ISO-Date helpers, Validation. Komplett UI-frei für Server-Side-Reuse
- **Server Actions:** `loadBoardTimeline(rangeStart, rangeEnd)`, `saveOwnAvailability(...)`, `deleteOwnAvailability(id)` in `app/intake/board/actions.ts`. Timeline-Loader fetcht parallel Consultants + Availabilities + Intakes mit Range-Filter
- **Files:**
  - `lib/intake/availability.ts` (pure helpers)
  - `components/intake/intake-timeline.tsx` (Hauptview, sticky-column Tabelle)
  - `components/intake/availability-edit-modal.tsx` (Self-Editor)
  - `components/intake/intake-board-view.tsx` (Tab-Switcher Wrapper)
  - `app/intake/board/page.tsx` (verwendet nun `IntakeBoardView` statt `IntakeBoard`)
  - `app/intake/board/actions.ts` (3 neue Actions)
  - `__tests__/intake-availability.test.ts` (22 Tests)
- **Out-of-Scope (Folge-PRs):**
  - Computed-Belegung aus `projects.erstbesuch_*` (V2)
  - Outlook/iCal-Sync (V2 laut Plan-Doc)
  - Drag-Berater-auf-Wunschzeitraum mit Auto-Assign
  - Admin-Edit fremder Verfügbarkeiten (gehört ins Admin-UI)

## 2026-05-12 — Intake PR-E: Convert-to-Project (Promote-Modal + atomare RPC)

- **Scope:** Berater promoten ein Intake aus Backlog/Review zu einem Projekt. Intake verschwindet vom Board und ist via `projects.source_intake_id` mit dem neuen Projekt verlinkt
- **State-Maschine:** `lib/intake/transitions.ts` erweitert um `backlog → promoted` und `review → promoted`. `promoted` bleibt terminal
- **Atomare RPC:** `supabase-migration-intake-v1-promote-rpc.sql` legt `promote_intake_to_project(p_intake_id, p_consultant_id, p_erstbesuch_start, p_erstbesuch_end, p_supplier_name_override, p_plant_location_override)` als SECURITY DEFINER an. RPC validiert (Status, Caller=aktiver Consultant, Caller==assigned), generiert Project-Code via `next_project_id()`, fügt projects-Row + optional `project_type_assignment` ein, setzt `intakes.status='promoted'` und `intakes.promoted_project_id`, schreibt Audit-Eintrag. Alles in einer Transaction
- **Field-Mapping intake → projects:** `supplier_id`, `supplier_name`, `plant_location`, `delivery_scope`, `product_line`, `vehicle_project`, `order_reason`, `order_objective` → 1:1. `requesting_department` (z.B. `MO-23`) → `kifag_department` (Prefix `MO`) + `kifag_area_id` via `master_data_values` (typ `KIFAG_AREA`). `intake.id` → `projects.source_intake_id`
- **Project-Type-Assignment:** Bei `intake_type in (lsc_workshop, fabrikanalyse)` automatisch Assignment, sonst leer (Berater ergänzt später, siehe `mapIntakeTypeToProjectTypeCode`)
- **Sicherheit:** RPC erzwingt `assigned consultant == caller`. Caller muss `is_active=true, status='active', role IN (consultant, admin, masteradmin)`. Verhindert Promote-im-Namen-anderer
- **Modal:** `components/intake/intake-promote-modal.tsx` lazy-loaded Intake-Details + Consultant-Liste via Server Actions. Berater-Picker (Default: Caller selbst, oben), Erstbesuch-Start/Ende (Default: preferred_first_visit_*), optionales Supplier-Override wenn kein Stammdaten-Lieferant verknüpft
- **Pure helpers:** `lib/intake/promote.ts` — `validatePromoteInput`, `mapIntakeTypeToProjectTypeCode`, `deriveKifagDepartmentPrefix`. Getrennt von `'use server'`-File für Client-Validierung im Modal
- **Server Actions:** `loadIntakeForPromote`, `loadPromoteConsultants`, `promoteIntakeToProject` in `app/intake/board/actions.ts`
- **UX:** Card bekommt „Promoten →" Button im Footer neben „Ablehnen". Erfolg-Banner mit neuem Project-Code im Board nach Promote
- **Tests:** `__tests__/intake-promote.test.ts` (15 tests, alle grün) — Validation, intake_type-Mapping, kifag-Prefix-Logik. Bestehende `intake-board-transitions.test.ts` angepasst auf neue erlaubte Promote-Transitions

## 2026-05-12 — Intake PR-D: Berater-Kanban (DnD, Status-Transitions, Reject-Modal)

- **Scope:** Berater-Sicht für Auftragseingang. Fachbereich-Sicht (PR-C) unverändert
- **Routes:** Neu `app/intake/board/page.tsx` (Server Component mit Role-Gate `consultant/admin/masteradmin`). `app/intake/page.tsx` bekommt Role-Redirect: Consultant+ → `/intake/board`, Fachbereich → bestehende „Meine Aufträge"-Liste
- **DnD:** `@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities` (accessible, Tastatur-Support out-of-the-box, Touch-Support)
- **Kanban:** 2 Spalten Backlog ↔ Review. Promoted ist Link zu `/projects` (keine eigene Spalte — Karteileichen-Gefahr per Plan-Doc)
- **Karten:** Intake-ID, Lieferant, Auftragstyp (farbiger Tag), Abteilung, Priorität, Wunschzeitraum
- **Server Actions:** `moveIntakeStatus`, `rejectIntake` in `app/intake/board/actions.ts` — beide mit Role-Check via `getUserSession()`, Stale-State-Check, Audit-Insert in `intake_status_history`
- **Pure helpers:** `isAllowedTransition`, `validateRejectInput` in `lib/intake/transitions.ts` (extrahiert weil `'use server'`-File nur async exports erlaubt). State-Maschine: backlog↔review, backlog→rejected, review→rejected, rejected→backlog
- **Reject-Modal:** Pflicht-Dropdown mit 6 Standardgründen (`duplicate`, `out_of_scope`, `insufficient_data`, `wrong_department`, `not_feasible`, `other`) + optionaler Freitext (Pflicht bei `other`, min 3 Zeichen)
- **Out-of-Scope (Folge-PRs):** Convert-to-Project Modal (PR-E), Kalender/Timeline (PR-F), Filter (Lieferant/Priorität), Archive-Action
- **Tests:** `__tests__/intake-board-transitions.test.ts` (11 tests, alle grün) — State-Maschine + Reject-Input-Validation

## 2026-05-08 — Fix Bug 2 Frontend: sichtbare Fehlermeldung bei `next-id`-Fehler

- **Bug:** Wenn `/api/projects/next-id` scheitert, zeigte die Form nur stilles `–` als Projekt-ID, ohne Hinweis warum
- **Fix:** `components/project/new-project-form.tsx` checkt jetzt `r.ok` und zeigt eine rote Fehlermeldung unter dem Projekt-ID-Feld mit der Server-Fehlermeldung
- **Scope:** Reine UX-Verbesserung; ändert nicht die Logik

## 2026-05-08 — Fix Bug 3: Lieferant-Anlage zeigt Fehler als Toast

- **Bug:** Beim INSERT in `supplier_master_data` setzte der Code nur `saveError` State; bei silentem RLS-Deny oder Unique-Constraint-Verletzung blieb die Fehlermeldung unsichtbar
- **Fix:** `components/project/create-supplier-inline.tsx` zeigt jetzt zusätzlich einen Toast mit klarem DE-Text, mit Sonderbehandlung für Postgres-Codes 42501 (RLS) und 23505 (Unique)
- **Scope:** Reine UX-Verbesserung

## 2026-05-08 — Fix Bug 4: Kalender-Create-Button für Admin/Masteradmin

- **Bug:** `lib/planning-permissions.ts` las die Rolle nur aus `consultants.role`. Masteradmin/Admin ohne Consultant-Eintrag fielen auf `readonly` und der Create-Button im Kalender war nicht sichtbar
- **Fix:** `getPlanningPerms` nimmt jetzt zusätzlich `authRole` Parameter; admin/masteradmin escalieren über die Consultant-Rolle. `app/kalender/page.tsx` lädt die Auth-Rolle via `getUserSession()` und reicht sie weiter
- **Scope:** Berechtigungs-Konsolidierung; bestehende Consultant-Logik unverändert

## 2026-05-08 — Auto-Kalendereintrag bei Projektanlage + Demo-Default-All

- **Manus Wunsch (D):** „Es sollte bei der Anlage mit einem Box anklickbar sein, ob ich es in mein Kalender haben möchte oder nicht. Wenn ja kommt es automatisch rein. Wenn nein, kein Eintrag."
- **Erstbesuch-Auto:** `new-project-form.tsx` zeigt unter den Datums-Feldern eine Checkbox „Beim Speichern Kalendereintrag in der Einsatzplanung anlegen". Default ist angekreuzt. Nach erfolgreichem Project-Insert ruft `handleSubmit` `createVisitAssignments` mit `mode='replace'` für die zugewiesenen Consultants auf — dank dem heutigen B-Fix löscht das alle conflicting rows im Zeitraum
- **Folgetermin-Auto:** Pro Folgetermin ist ebenfalls eine Checkbox vorhanden (Default angekreuzt beim Hinzufügen). Erzeugt `auto_folgebesuch`-Einträge analog zum Erstbesuch
- **Default-aufgeklappt (Manus AP1-Wunsch):** Erstbesuch und Verrechnung sind jetzt als initial geöffnete Sektionen vorgesetzt; Hintergrund bleibt collapsed
- **Demo-Modus default-all (C):** `DemoControlPanel` startete mit nur 2 von 6 Modulen ausgewählt (Projektanlage + Kalender). Manu klickte „Laden" und bekam nur Teilbeladung — wirkte als wäre OEE/Wertstrom/Fabrikanalyse gebrochen. Default ist jetzt `[...ALL_DEMO_MODULES]` — Klick „Laden" lädt alles
- **Hinweistext:** Bei Konflikt steht „Bestehende Einträge im gewählten Zeitraum werden überschrieben" unter der Checkbox; explizites Konflikt-Modal wie im Edit-Form ist auf das spätere Edit deferred (KAR-32 Folge-Punkt)

## 2026-05-08 — Kalender-Bugs: Block-aware Delete + Overwrite löscht jetzt wirklich

- **Bug A (Löschen tut nichts):** Sowohl der Edit-Modal-Lösch-Button als auch der Rechtsklick-Delete löschten nur die anchor-Tag-Row, nicht den verbundenen Block. Bei einem 4-Tage-Span sah der User wie 3 von 4 Tagen blieben — „nichts passiert"
- **Fix A:** In `edit-assignment-modal.tsx` `handleDelete` löscht jetzt `blockIds` (alle verbundenen Tage) wenn vorhanden, sonst die anchor-id. In `planning-client.tsx` `handleDelete` (Rechtsklick-Pfad) ermittelt jetzt via `findConsecutiveSpan` den Block und löscht alle Rows in einem `.in('id', ids)` plus mirrored Local-Dexie-Cleanup
- **Bug B (Bestehende überschreiben überschreibt nicht):** `createVisitAssignments` mit `mode='replace'` löschte nur same-`source`-Rows (z.B. nur `auto_erstbesuch`), so blieben manuell oder andersartig erstellte Konflikte erhalten. Der „Überschreiben"-Click sah wie ein no-op aus
- **Fix B:** `mode='replace'` löscht jetzt jeden bestehenden Eintrag für die gewählten Consultants auf den gewählten Tagen (`.in('consultant_id', ...)` `.in('date', ...)`), unabhängig von source. Plus Error-Surface bei Delete-Fehler statt stiller Schluck
- **Scope:** Beide sind reine Logik-Fixes; keine Schema-Änderung. Test-Plan: Multi-Day-Block per Edit-Modal löschen → komplett weg. Konflikt mit manuell angelegtem Eintrag → „Bestehende überschreiben" → manueller Eintrag weg, neuer da

## 2026-05-08 — V1 → V2 Stammdaten-Import: 57 Consultants + 1000 Suppliers

- **Hintergrund:** Manu (Sprachnachricht): „Lade aus meinem vorherigen Projekt Kadi die Stammdaten Berater und Lieferanten in das neue Kadi V2 Projekt ein"
- **Migriert:**
  - `consultants`: alle 57 Rows aus V1 (`<production-db-ref>`) per upsert in V2 (`<production-db-ref>`). `auth_user_id` und `updated_by` auf NULL (V1-User-IDs resolven nicht in V2). `is_demo=true` für alle. State danach: 59 in V2 (existing 2 dev-rows plus 57 imported)
  - `supplier_master_data`: alle 1000 Rows mit echten BMW-Lieferantennummern im Format `123456-10`. `created_by/updated_by` auf NULL. State danach: 1008 in V2
- **Schema:** beide Tables haben in V1 und V2 identische Spalten — keine Transformation außer FK-Cleanup nötig. `supplier_master_data` hat keinen `is_demo` Flag in V2 (wäre cleaner; tracked in KAR-33/KAR-34)
- **Cleanup-Snapshot:** internal migration snapshot (1000 supplier UUIDs + DELETE snippets; archived)
- **Linear:** KAR-37 für die V1-Service-Role-Key Rotation (Parent KAR-12)

## 2026-05-08 — Demo-Control-Panel auch in Production sichtbar (admin-gated)

- **Hintergrund (Manu, Sprachnachricht):** „Lade aus meinem vorherigen Projekt Kadi die Stammdaten Berater und Lieferanten in das neue Kadi V2 Projekt ein. Dort hatte ich eine Funktionalität, die mir Demo-Daten laden und zurücksetzen konnte. Würde das auch in V2 implementieren."
- **Befund:** Die Funktionalität existiert bereits komplett — `components/demo/demo-control-panel.tsx` ist ein Floating-Button rechts unten plus Modal, mit Load/Reset für alle sechs Demo-Pakete. Aufrufe gehen über `/api/demo/load` und `/api/demo/reset`, beide admin-gated (`isAtLeastRole(session, 'admin')`)
- **Bug:** In `app/layout.tsx` war das Panel hinter einem `!isProd`-Guard versteckt. Mit `NEXT_PUBLIC_APP_ENV=production` in Vercel war die UI auf der Live-Seite unsichtbar — Manu konnte Demo-Daten weder laden noch zurücksetzen
- **Fix:** `!isProd`-Guard auf das Panel entfernt; das Panel ist jetzt admin-gated über die existing Komponenten- und API-Logik. `DemoBanner` (das visuelle „nicht-Prod"-Reminder) bleibt weiter dev/staging-only
- **Ergebnis:** Admin/Masteradmin sehen jetzt auf `kadi-v2.vercel.app` einen orangefarbenen „Demo"-Button rechts unten, mit dem sich alle sechs Demo-Pakete laden bzw. zurücksetzen lassen

## 2026-05-08 — Vendor-Demo Quick-Wins: 5 DEMO-Projekte, Lieferantennr-Format, SharePoint-Rename (KAR-31)

- **Fünf vollständige DEMO-Projekte** unter `/projektanlage` direkt in der Live-DB angelegt (`is_demo=true`, stable IDs `dddd0001-…`, junction `project_type_assignments`):
  - `DEMO-2026-001` Demo Steelworks GmbH (G70, lsc_workshop, In Bearbeitung)
  - `DEMO-2026-002` Demo Plastics AG (U11, fabrikanalyse, In Bearbeitung)
  - `DEMO-2026-003` Demo Electronics SARL (I20, lsc_workshop, Pausiert)
  - `DEMO-2026-004` Demo Casting s.r.o. (F40, lsc_workshop + fabrikanalyse Multi-Type, In Bearbeitung)
  - `DEMO-2026-005` Demo Battery Systems Ltd. (G45, kapa_workshop, In Bearbeitung)
  Plus realistische Felder: plant_location, product_name, customer_takt_time_sec, target_cycle_time_sec, planned_oee, order_received_date, expected_end_date, vehicle_project, notes.
- **Seed-File aktualisiert:** `lib/demo/seeds/projektanlage-seed.ts` synchronisiert mit Live-DB-Codes (project_statuses `_10/_30`, responsibilities `mo2/mo23/mo24`, Demo-Supplier-Lookups). Vorher referenzierten die Mappings `status_40`/`fv` die gar nicht existierten — Seed wäre stillschweigend mit `null` Fremdkeys gelandet
- **Löschbarkeit:** Alle fünf via existing `removePack('projektanlage')` aus `lib/demo/seeder.ts` löschbar; `is_demo=true` Filter in der DB
- **Lieferantennummer-Format `123456-10`:** Frontend-Validation in `components/project/create-supplier-inline.tsx` mit Regex `^\d{6}-\d{2}$`, plus HTML5-Pattern + `inputMode="numeric"` + Title-Tooltip. Klare DE-Fehlermeldung bei Mismatch
- **„Link zum Projektordner" → „Link zur SharePoint":** Label-Rename in `components/project/new-project-form.tsx` und `components/project/project-edit-form.tsx`. Plus Live-Vorschau als anklickbarer Link (`target="_blank" rel="noopener noreferrer"`) wenn URL eingegeben. Placeholder zeigt jetzt BMW-Sharepoint-Beispiel
- **Linear-Epics angelegt:** KAR-31 (AP1 Projektanlage), KAR-32 (AP2 Kalender Multi-Filter), KAR-33 (AP3 Repository/Excel), KAR-34 (AP4 Demo-Daten 6 Seiten), KAR-35 (AP5 i18n), KAR-36 (AP6 UI/UX-Lift) — sauber strukturiert mit allen offenen Detail-Punkten als Body
- **Scope:** Drei dem Vendor-Übergabe-Pitch direkt nutzbare Quick-Wins; restliche ~60 Mega-Prompt-Punkte sind in den 6 Linear-Epics dokumentiert für Folge-Sessions oder Cloud-Code-Run

## 2026-05-08 — Kalender: Resize-via-Drag entfernt, Multi-Day-Edit immer auf Block (KAR-30)

- **Operator-Entscheidung:** „Die Vergrößerung und Verkleinerung mit Drag soll einfach rausgenommen werden. Ich mache es in Zukunft im Formular." Plus: „Wenn es mehrere Tage aneinander sind, soll es immer verbunden geändert werden. Es soll mich gar nicht fragen."
- **Resize-via-Drag entfernt:** `assignment-tile.tsx` macht jetzt nur noch Move-via-Drag. Die `RESIZE_EDGE`-Konstante, die `startResize`-Funktion, die zwei `col-resize`-Cursor-Zonen sowie der `onResize`-Prop sind raus. `onTileResize`-Prop in `planning-row.tsx` und `planning-grid.tsx` entfernt; `handleSpanResize` in `planning-client.tsx` zusammen mit dem Wiring weg
- **Apply-to-Block automatisch:** Edit-Modal hatte eine Checkbox „Für alle X verbundenen Tage übernehmen" — die ist raus. Bei Multi-Day-Block (`blockIds.length > 1`) wird der Update jetzt immer auf alle Rows des Blocks angewendet, ohne Rückfrage. Hinweis-Box informiert den User darüber
- **Range-Edit weiterhin möglich:** Der User kann im Modal die Felder „Zeitraum von" und „Zeitraum bis" anpassen. Bei Range-Änderung greift dieselbe delete+insert-Logik wie früher der Drag-Resize, nur jetzt explizit aus dem Formular getriggert
- **Scope:** Reine UX-Änderung; keine Daten-Modell-Änderung. Move-via-Drag und Range-Edit-via-Modal sind die zwei Wege Range zu ändern, beide funktionieren

## 2026-05-08 — Kalender: Range-Anzeige in Tooltip + Edit-Modal (KAR-30)

- **Problem (Manu):** Mehrtägige Einsätze wurden im Tooltip nur als Single-Day angezeigt („Di., 26. Mai 2026"). Edit-Modal hatte ein einzelnes Datum-Feld — keine Range-Sicht
- **Fix Tooltip:** `AssignmentTooltip` nimmt jetzt `startDate` und `endDate` Props. Anzeige ist immer Range-Format: bei Multi-Day „26.05.2026 — 29.05.2026", bei Single-Day „26.05.2026 — 26.05.2026" (einheitlich). `assignment-tile.tsx` reicht `a0.date` und `aLast.date` durch
- **Fix Edit-Modal:** Single „Datum"-Feld durch zwei Felder „Zeitraum von" und „Zeitraum bis" ersetzt. Initial werden beide auf `assignment.date` gesetzt; sobald `blockIds` (= verbundene Tage) geladen sind, werden sie auf den ersten/letzten Tag des Blocks expandiert
- **Save-Logic:** Wenn der User die Range ändert, wird via delete+insert gearbeitet (analog `handleSpanResize`) — alte Tage außerhalb der neuen Range gedroppt, neue Tage hinzugefügt. Restliche Attribut-Änderungen per UPDATE wie bisher
- **Scope:** Tooltip und Modal sind jetzt range-aware; das physische Datenmodell (eine Row pro Tag) bleibt unverändert, nur die UI gruppiert sauber

## 2026-05-08 — Fix Kalender Auto-Reset (echter Fix via scrollLeft-Restore)

- **Bug:** Mein vorheriger Fix (PlanningGrid bleibt mounted) hat den Auto-Reset auf Januar nach Mutation NICHT vollständig behoben — Manu meldete weiterhin „Beim verkürzen oder vergrößern, landet es auf anfang Januar"
- **Root-Cause:** Beim Loading-Flicker oder bei Layout-Anpassungen verliert der `gridContainerRef` seine `scrollLeft`-Position (geht auf 0 = 1. Januar)
- **Fix:** `fetchAssignments` speichert die `scrollLeft`-Position vor `setLoading(true)` und stellt sie nach dem React-Commit (mit `requestAnimationFrame` × 2) wieder her. Damit bleibt der View jetzt definitiv an der ausgewählten Date-Range nach jeder Mutation
- **Scope:** UX-Fix; keine Logik-Änderung

## 2026-05-08 — Fix Kalender Delete + Move Error-Feedback (KAR-30 Follow-up)

- **Bug:** Manu meldete „Löschen geht auch nicht" — `handleDelete` ging nur durch `assignmentRepo.remove` (lokal + Sync-Queue), DB-Fehler aus dem Sync waren stumm. `handleBulkDelete` und `handleTileMove` loggten Fehler nur ins Logger, kein User-Feedback
- **Fix:** Alle drei Handler (`handleDelete`, `handleBulkDelete`, `handleTileMove`) zeigen jetzt bei DB-Fehler einen DE-Toast mit dem Reason. `handleDelete` macht zusätzlich einen direkten Supabase-Delete neben dem Repo-Call (Belt-and-braces — falls die lokale Sync-Queue klemmt, geht der Server-Delete trotzdem)
- **Scope:** UX-Verbesserung; keine Logik-Änderung — wenn Delete davor schon klappte, klappt es weiterhin

## 2026-05-08 — Fix KAR-30 Follow-up: Resize-Insert nutzte fehlende `user_id`-Spalte

- **Bug:** Nach dem Resize-Error-Toast aus `8a4d454` hat Manu beim Test gesehen: „Größe konnte nicht erweitert werden: Could not find the 'user_id' column of 'assignments' in the schema cache". Der Insert in `handleSpanResize` schickte `user_id: base.user_id` mit, aber `assignments` hat tatsächlich `created_by` / `updated_by`, nicht `user_id`. Der `Assignment`-Type hatte `user_id` als „Legacy DB field" markiert, das ist allerdings nur ein TypeScript-Alias der mit der Realität nicht mehr matched
- **Fix:** `user_id: base.user_id` aus dem Resize-Insert entfernt, durch `created_by: base.created_by` ersetzt (selber Owner-Scope wie der Ursprung)
- **Scope:** Reine SQL-Spalten-Konsolidierung, keine Logik-Änderung

## 2026-05-08 — Fix KAR-30: Kalender Auto-Reset auf Jahresanfang + stille Resize-Fehler

- **Bug 1 (Auto-Reset):** `<PlanningGrid>` wurde im `{loading ? <div> : <PlanningGrid>}`-Pattern bei jeder Mutation kurz unmountet, wodurch der Scroll-Container `gridContainerRef` seinen Inhalt verlor und nach dem Remount mit `scrollLeft=0` (= 1. Januar) startete
- **Fix 1:** Loading-Indikator als Sibling außerhalb des Scroll-Containers verschoben, `<PlanningGrid>` bleibt jederzeit gemounted. Scroll-Position bleibt erhalten
- **Bug 2 (Stille Resize-Fehler):** `handleSpanResize` loggte `delErr/insErr` per `logger.error` aber zeigte dem User keinen Hinweis — Manu sah nur dass „nichts passiert"
- **Fix 2:** Bei `delErr` oder `insErr` setzt die Funktion jetzt `overlapToast` mit DE-Fehlermeldung, wartet 5s, ruft `fetchAssignments()` auf um die UI zu resyncen
- **Hinweis:** Wenn Resize danach immer noch nicht funktioniert, ist die DB-Operation tatsächlich fehlgeschlagen — die Toast-Nachricht zeigt jetzt warum (RLS-Deny, Constraint, etc.)
- **Linear:** KAR-30 erstellt mit Reproduktionsschritten, jetzt umgesetzt

## 2026-05-08 — Fix Bug 1 (Sub-Pages): Masteradmin-Zugang zu /repository/masterdata/{suppliers,consultants}

- **Bug:** Selbe Wurzel wie der Bug-1-Fix auf `app/repository/masterdata/page.tsx` — auch die Sub-Pages `suppliers/page.tsx` und `consultants/page.tsx` prüften `consultants.role` statt der Auth-Rolle aus `user_profiles + roles`. Masteradmin ohne Consultant-Eintrag wurde nach `/repository` redirected
- **Fix:** Beide Sub-Pages auf `getUserSession()` umgestellt analog zur Parent-Page. `departments/page.tsx` ist intentionally disabled (redirect zu Parent), `stammdaten/page.tsx` nutzte schon das richtige Pattern via `user_profiles` direkt
- **Scope:** Zugriffs-Fix; keine RLS-Änderung

## 2026-05-08 — Fix Bug 1: Masteradmin-Zugang zu /repository/masterdata

- **Bug:** `app/repository/masterdata/page.tsx` prüfte `consultants.role`, was Masteradmins ohne Consultant-Eintrag aussperrte
- **Fix:** Auf das Standard-Pattern `getUserSession()` umgestellt (analog zu allen anderen `/admin/*`-Pages); prüft `session.role` gegen `admin` und `masteradmin`
- **Scope:** Zugriffs-Fix; keine RLS-Änderung

## 2026-05-08 — Test-Coverage-Snapshot für Vendor-Übergabe

- **Snapshot-Dokument:** `docs/foundation/test-coverage-2026-05-08.md` mit Test-Surface, Coverage-Totals (Lines 83.67%, Statements 79.17%, Funcs 81.31%, Branches 64.73%), Per-Cluster-Tabelle, Erklärung der vier 0%-Files (alle aus berechtigten Gründen, nicht Test-Lücken)
- **Maschinell-lesbarer Snapshot:** `docs/foundation/coverage-summary-2026-05-08.json` (Vitest v8 json-summary Reporter)
- **Test-Surface:** 13 Test-Files, 240 Tests, alle passing, ~1.5 Sek Runtime
- **Coverage-Scope (per `vitest.config.ts`):** `lib/entitlement/**`, `lib/provisioning/**`, `lib/owner-audit/**`, `lib/tenant/**`, `lib/demo/**` — die produkt-kritischen Business-Logik-Module
- **Niedrigster realer Coverage-Wert:** `lib/provisioning/steps.ts` 67.85% Lines (Supabase-Mgmt-API-Retry-Pfade nicht Unit-fähig, Vendor kann auf Azure-Seite mit Contract-Recording erweitern)
- **`.gitignore`:** `coverage/` Verzeichnis ignored (regenerable via `npm run test:coverage`)
- **Scope:** Reine Doku, keine Code-Änderung

## 2026-05-08 — KAR-28 Audit + Korrektur: MO-25 RLS auf parent-document-scope statt service-role-only

- **Audit-Befund (KAR-28):** Die Schreib-Pfade auf `document_suppliers`, `document_departments` und `master_data_audit_log` laufen vollständig client-seitig (`upload-dialog.tsx`, `document-detail-drawer.tsx`, `supplier-master-data-client.tsx`, `department-master-data-client.tsx`, `lib/duplicates/merge.ts`), nicht über `createAdminClient()`. Mein voriger MO-25-Commit `beba86f` hätte auf einer Fresh-DB die Upload- und Master-Data-Flows gebrochen (Production lief weiter weil die Live-DB die alten lax-Policies hat)
- **Korrektur in `MO-25/repository-phase-arch.sql`:**
  - `document_suppliers` und `document_departments`: `INSERT/UPDATE/DELETE` Policies wieder rein, aber owner-scoped via `document_id IN (SELECT id FROM documents WHERE uploaded_by = auth.uid())`. Damit funktioniert der Client-Code, aber ein User kann nur Links seiner eigenen Dokumente schreiben/löschen
  - `master_data_audit_log`: INSERT-Policy zurück mit `WITH CHECK (changed_by = auth.uid() OR changed_by IS NULL)`. User kann nur eigene Audit-Einträge erstellen, Server-Trigger und Service-Role bleiben unbetroffen (RLS-Bypass)
  - SELECT auf `audit_log` bleibt auf `changed_by = auth.uid()` getightnet
- **Verbesserung gegenüber Original:** Vorher konnte jeder authenticated User beliebige Links auf fremde Dokumente schreiben und Audit-Einträge mit gefälschtem `changed_by` einfügen. Jetzt nicht mehr — owner-scoped via Parent-Dokument bzw. eigenen User-ID
- **KAR-28 Status:** Quick-Fix ist drin, Issue bleibt offen für Long-Term-Refactor (Server-Action für Master-Data-Mutationen, Audit-Log über DB-Trigger statt Client-Insert)
- **OPEN_FINDINGS.md:** Cluster-Status für MO-25 detailliert dokumentiert mit Audit-Befunden und Interim-Lösung

## 2026-05-08 — Module-Schemas: Post-Closure-RLS auch für OEE + MO-25-Master-Data direkt ausliefern (Apply-Order Finding fast zu)

- **MO-OEE/oee-phase1.sql:** 8 lax `auth_*` Policies auf `oee_records`/`oee_loss_categories` ersetzt durch `oee_records_own` (`created_by = auth.uid() OR project_id IN (SELECT id FROM projects WHERE user_id = auth.uid())`, USING + WITH CHECK) und `oee_loss_categories_own` (parent-scoped via `oee_record_id`)
- **MO-23/planning-tables.sql:** Header-Kommentar eingefügt der erklärt, warum `USING(true)` hier bewusst stehen bleibt — das consultant-vs-admin-Berechtigungsmodell muss erst entschieden werden. Tracking: KAR-27
- **MO-25/repository-phase-arch.sql:** `INSERT/DELETE` Policies auf `document_suppliers` und `document_departments` entfernt — Master-Data-Mutationen laufen jetzt nur noch über `createAdminClient()` (Owner-Portal). `master_data_audit_log` SELECT auf `changed_by = auth.uid()` getightnet, lax INSERT-Policy gedroppt (Inserts via Trigger / service_role). Tracking: KAR-28 verifiziert die Call-Sites bevor finale Closure
- **OPEN_FINDINGS.md:** Finding 2 Status auf „mostly closed" gesetzt mit detailliertem Cluster-Status pro File
- **Linear:** KAR-27 (MO-23 Phase 2 Owner-Scope) und KAR-28 (MO-25 Master-Data-Schreib-Pfad-Audit) angelegt
- **Scope:** Reine SQL-File-Edits, keine Live-DB-Migration nötig — Cleanup-Migrations bleiben gültig für Legacy-DBs

## 2026-05-08 — Module-Schemas: Post-Closure-RLS direkt ausliefern (vsm + assessments)

- **Hintergrund:** `docs/vendor-handover/SupplierPulse_KADi_V2_Open_Findings.md` Finding 2 — Module-Schemas erzeugten lax `USING(true)` Policies, die in separaten Cleanup-Migrations gedroppt werden. Bei Apply in beliebiger Reihenfolge bleibt die DB in einem unsicheren Zustand. Operator-Preferenz: Module-Schemas direkt mit dem Post-Closure-Zustand ausliefern (Variante (b))
- **Fix `MO-26/wertstrom-schema.sql:14`:** `vsm_all` ersetzt durch `vsm_own` mit explizitem `WITH CHECK` analog `lsc_shifts_own`/`lsc_measures_own`. Nach `supabase-migration-rls-vsm-bypass-cleanup.sql`. Owner-Scope: `created_by = auth.uid() OR project_id IN (SELECT id FROM projects WHERE user_id = auth.uid())`
- **Fix `MO-24/assessment-phase1.sql:92-101`:** 8 phantom `auth_*` Policies entfernt, durch `assessments_own` (`created_by = auth.uid() OR admin/masteradmin`) und `ar_own` (Parent-Assessment-Owned) ersetzt. Nach `supabase-migration-rls-assessments-phantom-cleanup.sql` plus den Definitionen aus `supabase-migration-cleanup.sql:510,525`
- **Open:** `MO-OEE/oee-phase1.sql:133-142`, `MO-23/planning-tables.sql:81-138`, `MO-25/repository-phase-arch.sql` — kein dokumentierter Owner-Scope, Operator-Entscheidung steht aus. Open-Findings-Doc dazu aktualisiert
- **Scope:** Reine SQL-File-Edits, keine Live-DB-Migration nötig (für existierende DBs greifen weiterhin die Cleanup-Migrations). Fresh-Database-Replays bekommen jetzt direkt den korrekten RLS-Zustand

## 2026-05-08 — Fix: PWA-Icons gehostet (Manifest-Folge-Bug)

- **Bug:** Nach dem Manifest-Fix zeigte DevTools `GET /icons/icon-192.png 404` plus „Error while trying to use the following icon from the Manifest" — der `public/icons/`-Ordner existierte nicht, `manifest.json` verwies aber auf 192px und 512px Icons
- **Fix:** Beide Icons als PNG generiert (`public/icons/icon-192.png`, `public/icons/icon-512.png`) im KADi-Branding (#003D6B Background, weißes „SP", abgerundete Ecken — matched zur Setup-Fallback-Card in `app/layout.tsx`)
- **Scope:** Reine Asset-Ergänzung, keine Code-Änderung
- **Erweiterbar:** Wenn echte Brand-Icons aus dem KADiCon-Asset-Folder kommen, einfach die zwei PNGs ersetzen

## 2026-05-08 — Fix: manifest.json 404 + CSP-Inline-Script-Violation

- **Bug 1:** `https://kadi-v2.vercel.app/manifest.json` lieferte 22× `Manifest: Line: 1, column: 1, Syntax error.` plus „No resource with given URL found" in DevTools — Auth-Proxy hat den `/manifest.json`-Request auf `/login` redirected, Browser parste die HTML-Login-Seite als JSON
- **Fix 1:** Matcher in `proxy.ts` um `manifest.json`, `sw.js` und `robots.txt` erweitert — diese öffentlichen Static-Files umgehen jetzt den Auth-Redirect
- **Bug 2:** CSP-Violation `Refused to execute inline script` — `next-themes` injizierte einen FOUC-Anti-Flash-Inline-Script ohne Nonce, der gegen `script-src 'self' 'nonce-…' 'strict-dynamic'` verstieß
- **Fix 2:** Nonce wird in `app/layout.tsx` aus `headers().get('x-nonce')` gelesen und an `<ThemeProvider nonce={nonce}>` propagiert; spread-through bis `NextThemesProvider` (next-themes 0.4.6 unterstützt das `nonce`-Prop nativ)
- **Scope:** Reine Konfigurations- und Wiring-Fixes, keine Logik- oder UI-Änderung
- **Verifikation:** DevTools-Console nach Deploy sollte 0 Manifest-Errors und 0 CSP-Inline-Script-Violations zeigen

## 2026-05-08 — Fix: Hydration-Mismatch auf /projektanlage (KAR-26)

- **Bug:** Sentry-Issue `JAVASCRIPT-NEXTJS-1` „Hydration failed" auf `https://kadi-v2.vercel.app/projektanlage`
- **Ursache:** `formatDate` und `isoWeek` in `components/projektanlage/project-list-client.tsx` benutzten lokale Zeitzone — Server (Vercel-Lambda läuft in UTC) und Client (Browser in CEST) produzierten auf Tagesgrenzen unterschiedliche Strings (z.B. `07.05.2026` vs `08.05.2026` für `created_at: "2026-05-07T22:30:00Z"`)
- **Fix:** Beide Funktionen auf `Europe/Berlin` festgepinnt — `toLocaleDateString` mit `timeZone: 'Europe/Berlin'` und `isoWeek` extrahiert Berlin-lokale Datums-Komponenten via `Intl.DateTimeFormat` vor der KW-Berechnung
- **Scope:** Reine Bugfix-Änderung in einer Helper-Section, keine UI- oder API-Änderung
- **Verifikation:** Sentry zeigt nach Deploy keine neuen Hydration-Errors für `/projektanlage`

## 2026-05-08 — SECURITY.md: Sentry-Sektion ergänzt

- **Doku:** Sentry-Trust-Boundaries (DSN public-safe, Auth-Token server-only), Privacy-Posture (`sendDefaultPii: false`, `tracesSampleRate: 0.1`, Tunnelling via `/monitoring`), Vendor-Übergabe-Punkte (Org-Wahl, Alert-Routing, Release-Tagging)
- **Test-Event:** Sentry pipeline verified via test event (Verifikation Pipeline)
- **Scope:** reine Doku-Erweiterung, keine Code-Änderung

## 2026-05-08 — Sentry Monitoring eingebaut (Next.js SDK)

- **SDK installiert:** `@sentry/nextjs` via `@sentry/wizard` mit Server/Edge/Client-Configs (`sentry.server.config.ts`, `sentry.edge.config.ts`, `instrumentation.ts`, `instrumentation-client.ts`), Global-Error-Boundary in `app/global-error.tsx`, `withSentryConfig` in `next.config.mjs`
- **Source-Maps:** automatisches Upload via Build-Plugin, `widenClientFileUpload: true`, Auth-Token in Vercel-Envs (Production/Preview/Development) — nicht im Repo
- **Tunneling:** Browser-Requests durch `/monitoring`-Route (Ad-Blocker-Bypass)
- **MCP:** Sentry-MCP-Server in `.mcp.json` (gitignored) für Claude-Code-Integration
- **Region:** EU/DE (`*.de.sentry.io`)
- **Kosten/Hygiene-Tuning:** `tracesSampleRate` von Default `1` auf `0.1` gesenkt (Quota-Schutz), `sendDefaultPii` von `true` auf `false` (kein automatisches Senden von User-IPs/Cookies/Headers)
- **Scope:** Konfigurations-Setup, keine Fachlogik betroffen
- **Pending:** Alt-Token `sntryu_ef6e…` in Sentry-Settings revoken (KAR-25)

## 2026-05-06 — UI-Fix: User-Menu Dropdown wird nicht mehr abgeschnitten

- **Bug:** Klick auf das User-Avatar im Header öffnete das Dropdown intern, aber es war nicht sichtbar — der Header-Container hatte permanent `overflow-hidden`, was den `absolute` positionierten Dropdown abschnitt
- **Fix:** `overflow-hidden` nur noch im collapsed-Zustand (für die Höhen-Animation), `overflow-visible` im ausgeklappten Zustand
- **Datei:** `components/layout/collapsible-header.tsx`
- **Scope:** Reine CSS-Tweak, keine Logik- oder API-Änderung

## 2026-05-06 — Security: RLS Phantom-Policy `vsm_all` entfernt

- **RLS-Härtung:** Lax `vsm_all` Phantom-Policy auf `value_stream_maps` entfernt
- **Sicherheits-Befund:** `vsm_all` mit `USING true / WITH CHECK true` unterlief das bestehende `vsm_own` Owner-Scoping über RLS-OR-Verknüpfung — vollständiger Auth-Bypass für authentifizierte User
- **Owner-Policy gehärtet:** `vsm_own` neu erstellt mit explizitem `WITH CHECK` (gespiegelt zur `USING`-Klausel) analog zum Pattern von `lsc_shifts_own` / `lsc_measures_own`
- **Beibehaltene Fachlogik:** User sehen und bearbeiten eigene Maps (`created_by = auth.uid()`) oder Maps eigener Projekte (`project_id ∈ own projects`)
- **Atomic Apply:** Migration in `BEGIN`/`COMMIT` eingewickelt — Fehler beim `CREATE` rollt beide `DROP`-Statements zurück
- **Scope:** Keine App-Logik-Änderung, keine Schema-Änderung, Migration muss manuell in Supabase ausgeführt werden

## 2026-05-06 — Security: RLS Phantom-Policies für Assessments entfernt

- **RLS-Härtung:** Lax `auth_*` Phantom-Policies auf `assessments` und `assessment_responses` entfernt
- **Sicherheits-Befund:** Die Phantom-Policies nutzten `USING true` bzw. `WITH CHECK true` und unterliefen dadurch die bestehenden `assessments_own` und `ar_own` Policies über RLS-OR-Verknüpfung
- **Beibehaltene Fachlogik:** User sehen und bearbeiten eigene Assessments und Responses, Admin/Masteradmin sehen alles
- **Scope:** Keine App-Logik-Änderung, keine Schema-Änderung, Migration muss manuell in Supabase ausgeführt werden

## 2026-05-06 — Security: RLS Cleanup für projektbezogene Tabellen

- **RLS-Härtung:** `qaf_uploads`, `workshop_actions` und `shift_outputs` auf KADi `_own`-Pattern migriert (analog `lsc_shifts_own` / `lsc_measures_own` / `kapa_workshop_data_own`)
- **Owner-Scoping:** Authentifizierte User können nur Rows lesen und schreiben, deren `project_id` zu einem eigenen Projekt gehört
- **Konsolidierung:** Bestehende `"Users see own X"`-Policies (FOR ALL ohne explizites `WITH CHECK`, ohne `TO authenticated`) ersetzt durch eine einzige `_own`-Policy pro Tabelle mit beiden Klauseln und expliziter Rolle
- **Fresh-DB-Konsistenz:** Source-Migrationen `supabase-schema.sql` und `lib/provisioning/migrations/v001-initial.ts` auf das neue Pattern angeglichen, damit Fresh-Setups direkt korrekt provisioniert werden
- **Migration:** `supabase-migration-rls-project-bound-cleanup.sql` für existierende DBs (mit Rollback-Companion)
- **Keine App-Logik-Änderung:** App-Lese-/Schreib-Pfade verwenden bereits `project_id`-Scoping; M:N-Reads in Reporting/Berichte bleiben durch RLS auf eigene Projekte beschränkt
- **Phantom-Policy-Cleanup:** Migration droppt zusätzlich vier Legacy-Policies auf qaf_uploads ("Users can {select,insert,update,delete} own QAF uploads") mit USING true / WITH CHECK true, die historisch in der Live-DB existierten aber nicht im Repo dokumentiert waren. Defensive Drops für gleichartige Policies auf workshop_actions und shift_outputs ergänzt
- **Sicherheits-Befund:** Die gedroppten Phantom-Policies waren effektiv Auth-Bypass für qaf_uploads (USING true / WITH CHECK true bei FOR SELECT/INSERT/UPDATE/DELETE), durch RLS-OR-Verknüpfung wurde das _own-Scoping unterlaufen. Nach Migration: nur noch _own-Policy aktiv, RLS-Scoping wirksam
- **Migration NICHT in Supabase ausgeführt** — reine Repo-Pflege; manuell anwenden

## 2026-05-05 — Cleanup: Offline-Registry profiles 404 behoben

- **Fix:** Nicht existierende Tabelle `profiles` aus der Offline-Registry entfernt
- **Effekt:** Entfernt den verbleibenden pre-existing 404 durch Offline-Sync auf `profiles?select=*`
- **Begründung:** KADi nutzt `user_profiles`; Account-Profil-Daten werden bereits über `lib/repositories/account-repo.ts` direkt gegen den Dexie-Store geladen — kein Sync-Engine-Pfad notwendig
- **Scope:** Code-only-Fix, keine Migration, keine Änderung an `public/sw.js`

## 2026-05-05 — Cleanup: Offline-Sync für Tabellen ohne updated_at gehärtet

- **Fix:** Offline-Sync nutzt `updated_at >= lastFetchedAt` nicht mehr pauschal für alle Tabellen
- **Registry-Konfiguration:** `incrementalSync: false` eingeführt, damit Tabellen ohne `updated_at` per Full Fetch synchronisiert werden können
- **Tabellen angepasst:** `appointment_types`, `process_steps` und `qaf_uploads` werden ohne `updated_at`-Filter synchronisiert
- **Effekt:** Entfernt pre-existing 400er wie `appointment_types?updated_at=gte...`
- **Scope:** Code-only-Fix, keine DB-Migration, keine Änderung an `public/sw.js`
- **Offen:** `profiles` in der Offline-Registry bleibt ein separates Follow-up, weil die Tabelle nicht existiert und nicht durch `incrementalSync: false` gelöst wird

## 2026-05-05 — Cleanup: Account-Profile 404 behoben

- **Fix:** Account-Repository nutzt jetzt die korrekte Profil-Tabelle `user_profiles` statt `profiles`
- **Effekt:** Entfernt den pre-existing 404-Fehler `profiles?select=*` aus der Browser-Konsole
- **Mapping:** `display_name` wird per Supabase-Alias als `full_name` selektiert (lokale Cache-Form bleibt stabil); Filter via `auth_user_id` statt `id`; `single()` durch `maybeSingle()` ersetzt für nicht-vorhandene Profile
- **Scope:** Kleiner Tabellenname-Cleanup, keine Migration, keine Änderung an Phase 2b3

## 2026-05-05 — Phase 2b3-iii Followup: API v1 POST robuster

- **Pre-Validation:** `project_type_codes` werden vor Project-Insert gegen die `project_types`-Master-Tabelle geprüft; unbekannte Codes liefern 400 mit Liste der ungültigen Werte
- **Single Master-Fetch:** Master-Types werden einmal pro Request geladen und für ID→Code-Translate, Code-Validation und Output-Compatibility wiederverwendet
- **Cleanup-Fallback:** Falls Junction-Insert nach erfolgreichem Project-Insert scheitert, wird das frisch angelegte Projekt best-effort gelöscht; RLS-blockierte Cleanups werden im 5xx-Response transparent dokumentiert
- **Verhalten unverändert:** Legacy `project_type_id`-Input wird weiterhin akzeptiert, `project_type_codes` gewinnt bei Konflikt, `projects.project_type_id` wird weiterhin nicht geschrieben
- **Edge-Case-Härtung:** Explizit gesetztes `project_type_codes` gewinnt immer gegenüber Legacy `project_type_id`; leere Arrays, Nicht-Array- oder reine Whitespace-Werte liefern 400 vor Project-Insert
- **Active-Type-Validation:** API v1 POST akzeptiert nur aktive `project_types` (`is_active = true`) für `project_type_codes` und Legacy `project_type_id`
- OpenAPI-Description um Validation-Hinweis ergänzt

## 2026-05-05 — Phase 2b3-iii: Writes auf Junction-only umgestellt

- **Write-Pfade auf Junction-only:** Neue und bearbeitete Projekte schreiben Auftragstypen nicht mehr in `projects.project_type_id`
- **Forms migriert:**
  - `new-project-form.tsx` entfernt Legacy-Write auf `project_type_id`
  - `project-edit-form.tsx` synchronisiert Auftragstypen nur noch über `project_type_assignments`; Initial-Fallback auf `project.project_type_id` entfernt; Sub-Project-Insert ohne Legacy-Spalte
  - `project-edit-form.payload.ts` Interface + Builder bereinigt (project_type_id-Feld entfernt)
- **Demo-Seeder migriert:** Demo-Projekte schreiben Auftragstypen nur noch über Junction-Zeilen; ungenutzte `project_types`-Master-Query + `typeMap` entfernt
- **API v1 POST rückwärtskompatibel:** akzeptiert weiterhin `project_type_id` (Compatibility-Input → Code-Translate via Master-Lookup), zusätzlich neuer `project_type_codes: string[]` Junction-First-Input. POST schreibt nicht mehr in `projects.project_type_id`, sondern legt Junction-Rows an. Response liefert enriched Output mit `project_type_codes` + abgeleitetem `project_type_id`.
- **OpenAPI aktualisiert:** ProjectCreate dokumentiert `project_type_codes` als Junction-First-Input und `project_type_id` als Compatibility-Input. `project_type_id` aus required entfernt; `supplier_id` bleibt einziges Required-Feld
- **Tests aktualisiert:** `project-edit-form-payload.test.ts` prüft jetzt Abwesenheit von `project_type_id` im Payload
- **Soft-Deprecation:** `projects.project_type_id` bleibt als Spalte im Schema bestehen, wird aber von regulären Write-Pfaden nicht mehr gepflegt
- **Keine DB-Migration:** Spalten-Drop folgt später in Phase 2b3-iv

## 2026-05-05 — Phase 2b3-ii-d: API v1 Read-Output auf Junction migriert

- **API v1 Projekt-Reads** auf Junction-First mit Compatibility umgestellt
  - `project_type_codes: string[]` neu im Output
  - `project_type_id` bleibt rückwärtskompatibel im Output, wird aber aus dem primären Junction-Typ abgeleitet (über `getPrimaryProjectTypeId()` Helper + Master-Code→ID-Lookup)
  - Keine Read-Abhängigkeit mehr auf `projects.project_type_id` oder `project_types`-Embed
- **Type-Filter `?type=`** nutzt jetzt `project_type_assignments!inner` mit `.eq('project_type_assignments.project_type_code', typeCode)` — Multi-Type-Projekte erscheinen in jedem zugewiesenen Typ-Filter
- **OpenAPI-Schema** aktualisiert: `project_type_codes` als neue Property, `project_type_id` als Compatibility-Feld dokumentiert (nullable, derived), `project_types`-Embed als deprecated markiert, `project_type_id` aus required entfernt
- **Helper ergänzt:** `getPrimaryProjectTypeId(project, projectTypesByCode)` in `lib/project-types/labels.ts`
- **Write-Pfade unverändert:** POST akzeptiert weiter `project_type_id` (required), schreibt weiter in DB-Spalte. Full Write-Migration folgt in Phase 2b3-iii
- **Soft-Deprecation:** `projects.project_type_id` bleibt im Schema, ist aber keine Read-Quelle mehr

## 2026-05-05 — Phase 2b3-ii-c-2: Projektanlage-Berichte auf Junction migriert

- **Projektanlage-Berichte** von `projects.project_type_id` / `project_types` auf `project_type_assignments` migriert
- **M:N-Aggregation:** Multi-Type-Projekte zählen in jeder zugewiesenen Auftragstyp-Kategorie
- **Sub-Projekt-Regel:** Sub-Projekte nutzen eigene `project_type_assignments`, falls vorhanden; sonst erben sie die Auftragstypen des Parent-Projekts
- **Helper ergänzt:** `getEffectiveTypeCodes()` in `lib/project-types/labels.ts` für eigene Codes plus Parent-Fallback
- **Robustes Loading-Pattern:** Junction wird in `app/projektanlage/berichte/page.tsx` separat geladen und serverseitig per `project_id` gemappt — analog zum Reporting-Fix aus 2b3-ii-c-1
- **UI-Hinweis** unter "Projekte nach Auftragstyp" Chart erklärt M:N + Parent-Erbung
- **Kein Rückfall auf `projects.project_type_id`**. Spalte bleibt nur soft-deprecated im Schema
- API v1 und Write-Stellen folgen in separaten Sub-Phasen

## 2026-05-05 — Phase 2b3-ii-c-1: Reporting-Dashboard auf Junction migriert

- **Globales Reporting-Dashboard** von `projects.project_type_id` / `project_types` auf `project_type_assignments` migriert
- **M:N-Aggregation:** Multi-Type-Projekte zählen in jeder zugewiesenen Auftragstyp-Kategorie
  - Beispiel: Ein Projekt mit `lsc_workshop` und `fabrikanalyse` zählt in beiden Buckets
- **UI-Hinweis ergänzt:** "Projekte mit mehreren Auftragstypen werden in mehreren Kategorien gezählt" unter "Projekte nach Auftragsform"
- **Helper-Nutzung:** Auftragstyp-Labels über `TYPE_LABELS`, Codes über `getTypeCodes()`
- **Cleanup:** ungenutzte `projectTypes`-Master-Query und Prop entfernt (Dashboard braucht keine code-less Master-Liste mehr)
- **Fix:** `project_type_assignments` wird im Reporting separat geladen und serverseitig auf Projekte gemappt, statt als embedded Relation in der Project-Query. Dadurch bleibt die Project-Basisquery stabil und die M:N-Aggregation funktioniert ohne leere Reporting-Daten.
- **Fix:** Pre-existing Reporting-Bug behoben — Project-Basisquery selektierte `status` als veraltetes Skalar-Feld auf `projects`, das nicht mehr existiert; daher lieferte Supabase silently `null`/leeres Array zurück. Query nutzt jetzt die `project_statuses ( id, code, label )` Relation analog `/projektanlage` und `/project/[id]`. Dashboard-Status-Aggregation auf `resolveOne(p.project_statuses)?.code` umgestellt, STATUS_COLORS-Map auf reale Status-Codes (`status_10/20/40/70/100`) angepasst. `projectsError` wird jetzt via `logger.error` geloggt statt verschluckt.
- `berichte-client.tsx` bleibt unverändert und folgt in Phase 2b3-ii-c-2
- `projects.project_type_id` bleibt weiter im DB-Schema vorhanden, wird in diesem Reporting-Pfad aber nicht mehr gelesen

## 2026-05-05 — Phase 2b3-ii-b: Logik-Reads auf Junction migriert

- **Projektanlage-Logik:** Projektliste und Filterlogik von `project_types`-Relation auf `project_type_assignments` umgestellt
  - Multi-Type-Projekte erscheinen in allen passenden Filtern
  - Typ-Tags rendern alle Auftragstypen via `getTypeTags(row, allTypes)`
- **Project-Detail-Logik:** `/project/[id]` Layout und Übersicht verwenden Junction als Quelle
  - `ProjectTabs` baut Tabs aus allen `project_type_assignments` (Multi-Type-fähig: Mahle bekommt LSC- + Fabrikanalyse-Tabs)
  - `isLscType` basiert auf `typeCodes.includes('lsc_workshop')`
  - Layout `primaryTypeCode` über `getPrimaryTypeCode()` mit stabiler Prioritätsliste
- **Helper erweitert:** `TYPE_PRIORITY`, `getPrimaryTypeCode()`, `hasTypeCode()` in `lib/project-types/labels.ts`
- **Soft-Deprecation:** `projects.project_type_id` bleibt bestehen, wird in diesen Read-Stellen aber nicht mehr verwendet
- Reporting (`berichte`), API v1, Forms und Seeder bleiben für separate Subphasen unverändert

## 2026-05-05 — Phase 2b3-ii-a: Display-Reads auf Junction migriert

- **TYPE_LABELS-Helper:** neue zentrale Helper-Datei `lib/project-types/labels.ts` mit `formatTypeLabels()` und `getTypeCodes()`
- **Migrationen:** Display-only Lese-Stellen von `project_types`-Relation auf `project_type_assignments`-Junction umgestellt:
  - `app/lsc-workshop/page.tsx` (project_types Relation aus Select entfernt — `type`-Variable war unbenutzt im ProjectCard)
  - `app/lsc-workshop/[id]/page.tsx` + `components/lsc-workshop/lsc-overview-client.tsx` (project_types unbenutzte Prop)
  - `app/lsc-workshop/[id]/export/page.tsx` + `components/lsc-workshop/lsc-export-client.tsx` (Type-Label im Export via `formatTypeLabels(getTypeCodes(...))`)
- **Multi-Type-fähig:** Stellen rendern jetzt alle Auftragstypen eines Projekts (z.B. Mahle: "LSC Workshop, Fabrikanalyse")
- **ProjectHeader:** lokales TYPE_LABELS durch Import aus Helper ersetzt (DRY)
- **Phase 2b3 Sub-Phase ii-a (Display).** Verbleibend in 2b3-ii-b: Layout-Logik (`isLscType`, `getTabs()`); 2b3-ii-c Reporting (M:N-Bucketing); 2b3-ii-d API v1
- `projects.project_type_id` bleibt unverändert (Soft-Deprecation, Phase 2b3-iv für Spalten-Drop)

**Aufgeschoben** (logic-coupled, in 2b3-ii-b):
- `app/projektanlage/page.tsx` + `components/projektanlage/project-list-client.tsx` — `p.project_types` für Filter-Logik (View-Config + UI-Filter via type.code/type.id) verwendet, gehört in Logik-Phase
- `app/project/[id]/layout.tsx` + `app/project/[id]/page.tsx` — `getTabs(typeCode)` und `isLscType` Branching

## 2026-05-05 — UX: Klickbare Type-Tags im ProjectHeader

- **Type-Tags** im ProjectHeader sind jetzt Quick-Switch-Links zwischen Modulen für dasselbe Projekt
  - `lsc_workshop` → `/lsc-workshop/[projectId]`
  - `fabrikanalyse` → `/fabrikanalyse/[projectId]`
  - `kapa_workshop` → `/lsc-workshop/[projectId]/kapa`
- **Coming-Soon-Tags** (`sonderauftrag`, `zdsc_campus`, `360_workshop`) bleiben reine Labels mit `cursor-not-allowed` und Tooltip "Workflow noch nicht implementiert"
- **Hover-Effekt + Tooltip** auf klickbaren Tags ("Zu {label} wechseln")
- **Aktiver Tag** (highlightedTypeCode) bleibt klickbar — navigiert zur gleichen URL, no-op aus User-Sicht
- **Mobile** unverändert: Type-Tags bleiben `hidden md:flex`

## 2026-05-05 — Security: kapa_workshop_data RLS-Tightening

- **RLS-Konsolidierung:** `kapa_workshop_data` Policies von lax `USING (true)` auf KADi-Norm `_own`-Pattern umgestellt
  - 4 Policies (SELECT/INSERT/UPDATE/DELETE) durch 1 `FOR ALL` Policy ersetzt
  - Pattern analog `lsc_shifts_own` / `lsc_measures_own`: User sehen/schreiben nur Daten ihrer eigenen Projekte
  - Tenant-Scoping bleibt separat durch Module-Entitlement-System
- **Phase-2b1-Migration-File angeglichen** — fresh-DB-Setups erhalten direkt das `_own`-Pattern, keine separate Tightening-Migration nötig
- **Migration `supabase-migration-kapa-workshop-data-rls-tightening.sql`** für existierende DBs (mit Rollback-Companion)

Aufwand: ~30 Min. Demo-Seeder unverändert (Service-Role bypass RLS).

## 2026-05-05 — Phase 3b: Zentrale ProjectHeader-Komponente

- **Neue Komponente:** `components/project/project-header.tsx` — präsentational, lädt keine Daten, alle Felder über Props
- **Konsolidiert** drei Header-Implementierungen:
  - `/project/[id]/*` (vorher `ProjectTabs`, mixed Header + Tab-Logik) → `ProjectHeader` + `ProjectTabs` (nur noch Tab-Row)
  - `/lsc-workshop/[id]/*` (vorher `LscWorkshopHeader`) → `ProjectHeader` + neue `LscWorkshopTabs`-Komponente
  - `/fabrikanalyse/[projectId]` (vorher inline weiße Card → migriert auf Dark-Bar)
- **Slot-Pattern:** Tabs und Actions werden als React-Children/Props übergeben
- **Tab-Logik bleibt pro Modul** — keine zentrale Tab-Config
- **Visueller Cut** in Fabrikanalyse: weiße Card → konsistente Dark-Bar wie andere Module; AppHeader entfernt zugunsten der ProjectHeader-Bar (Pattern wie LSC)
- Status-Farbpalette unifiziert auf Dark-Bar-Töne (helle Statusfarben)
- Mobile-Verhalten unverändert
- Datei `components/lsc-workshop/lsc-workshop-header.tsx` gelöscht (98 LOC)

## 2026-05-05 — Cleanup: Redirects auf permanent: false für Catch-All-Patterns

- **Präventiv:** `permanent: true` (HTTP 308) → `permanent: false` (HTTP 307) für Catch-All-Redirects
  - `/projects/:path*` → `/projektanlage/:path*`
  - `/planning/:path*` → `/kalender/:path*`
- **Hintergrund:** Phase 2b2 hat gezeigt, dass 308-Redirects bei Modul-Restrukturierung Browser-Caches blockieren
- Top-Level-Renames (`/dashboard`, `/account`, `/analytics`, `/projects`, `/planning`) bleiben `permanent: true`, da Ziele stabil

## 2026-05-05 — Phase 3a: Kollabierbare Sidebar + Top-Header

- **Top-Header-Bar** kollabierbar mit Pfeil rechts
  - Sanfte Animation (200ms, height + opacity)
  - Im kollabierten Zustand: kleiner Aufklapp-Pfeil bleibt sichtbar (fixed top-right)
  - State persistent via `localStorage.topbar_collapsed`
  - Refactor: `app-header.tsx` lädt User server-side, übergibt an neue Client-Komponente `collapsible-header.tsx`
- **Desktop-Sidebar** kollabierbar mit Pfeil oben
  - Voller Modus: 208px (`w-52`) mit Labels
  - Kollabierter Modus: 64px (`w-16`) nur Icons + Tooltips (via `title`-Attribut)
  - Admin-Sektion: bei Klick im kollabierten Modus → Sidebar klappt zuerst auf
  - Main-Content-Margin folgt Sidebar-Breite (`md:ml-52` ↔ `md:ml-16`)
  - State persistent via `localStorage.sidebar_collapsed`
- **Mobile unverändert:** Bottom-Nav und "Mehr"-Drawer bleiben wie sie sind, neue Logik nur ab `md`-Breakpoint
- Keine DB-Änderungen, reine UI-Persistenz im Browser

## 2026-05-05 — Followup Phase 2b1: assessments FK CASCADE Migration

- **Bug-Fix:** Demo-Reset schlug fehl mit "null value in column project_id violates not-null constraint"
  - Ursache: Phase 2b2 setzte `project_id NOT NULL`, aber FK war noch `ON DELETE SET NULL` (Konflikt)
  - Fix: FK auf `ON DELETE CASCADE` umgestellt — Datenmodell-konsistent, Assessment ohne Projekt darf nicht existieren
- **Migration**: bereits manuell in DB ausgeführt — diese Datei dient nur der Repo-Konsistenz für neue Instanzen / Disaster-Recovery
- File: `supabase-migration-assessments-cascade-on-delete.sql` (mit Rollback-Companion)

## 2026-05-01 — Phase 2b1: LSC Workshop Sub-Reiter Kapazitäts-Workshop + QAF Vergleich

- **Neuer Sub-Reiter "Kapazitäts-Workshop"** (Position 4) im LSC Workshop:
  - `TaktDiagramSection` (wiederverwendet) zeigt Yamazumi + Cycle-Detail Charts
  - Stub-Felder: Schichtmodell, Auslastung, Engpass-Analyse (Textareas mit Autosave on blur)
- **Neuer Sub-Reiter "QAF Vergleich"** (Position 2) im LSC Workshop:
  - Spiegelung der bestehenden `/project/[id]/qaf`-Page (kein Code-Duplikat)
  - `QafClient` direkt wiederverwendet — identische Lade-Logik, identischer Client
  - QAF bleibt parallel über Projekt-Detail erreichbar
- Beide Sub-Reiter immer sichtbar (unabhängig von Auftragstyp-Auswahl) — für Reporting-Konsistenz
- DB: neue Tabelle `kapa_workshop_data` (1:1 zu Projekt, RLS, UNIQUE(project_id), Auto-update Trigger)
- Demo-Seed: `DEMO_KAPA_DATA` für Continental + Mahle ergänzt

Migration: `supabase-migration-kapa-workshop-data.sql` (mit Rollback-Companion). Migration noch NICHT in Supabase ausgeführt.

## 2026-05-01 — Followup Phase 2b2: project/[id]/analyse Card-View

- **Bug-Fix:** `/project/[id]/analyse` zeigte "Keine Fabrikanalyse verknüpft" obwohl Assessments existierten
  - Ursache: `.maybeSingle()` schlägt bei mehreren Assessments fehl
  - Fix: Count-Query + Summary-Card statt direkte Assessment-Anzeige
- **UX:** Page wird zum Wegweiser → Link auf `/fabrikanalyse/[projectId]` für tatsächliche Verwaltung
- Konsistenz: Vermeidet Code-Duplikation zwischen `/project/[id]/analyse` und `/fabrikanalyse/[projectId]`

## 2026-05-01 — Phase 2b2: Fabrikanalyse projekt-zentriert

- **Routing-Refactor:** /fabrikanalyse zeigt jetzt Projekte (statt einer flachen Assessment-Liste)
  - `/fabrikanalyse`: Liste der Projekte mit Auftragstyp `fabrikanalyse` (Junction-Filter über `project_type_assignments!inner`)
  - `/fabrikanalyse/[projectId]`: Projekt-Detail mit Assessment-Sub-Liste und „+ Neues Assessment"-Button
  - `/fabrikanalyse/[projectId]/[assessmentId]`: Assessment-Workspace
  - `/fabrikanalyse/[projectId]/[assessmentId]/dashboard`: Assessment-Dashboard
  - `/fabrikanalyse/admin` bleibt unverändert
- **Komponenten-Split:** `AssessmentListClient` ersetzt durch
  - `components/fabrikanalyse/project-list-client.tsx` (Projekt-Cards für /fabrikanalyse)
  - `components/fabrikanalyse/project-scoped-assessment-list.tsx` (Assessment-Liste innerhalb eines Projekts)
- **DB-Constraint:** `assessments.project_id` ist jetzt `NOT NULL` (Vorbedingung: alle bestehenden Assessments haben Projekt-Bezug)
- **TypeScript:** `Assessment.project_id: string` (vorher `string | null`)
- **Cross-References angepasst** in 5 Stellen: `assessment-workspace`, `assessment-dashboard`, `project/[id]/analyse`, Legacy-Redirects `/assessment/[id]` und `/assessment/[id]/dashboard`
- **Legacy-Redirects** mit DB-Lookup: `/assessment/[id]` lädt `project_id` und leitet zu `/fabrikanalyse/${projectId}/${id}` weiter

Konsistent mit Phase 1 Soll-Modell: Auftragstyp-Bereiche zeigen Projekte ihres Typs (analog `/lsc-workshop`).

Migration: `supabase-migration-assessments-project-id-not-null.sql` (mit Rollback-Companion)

## 2026-04-30 — Phase 2a: Auftragstyp-Form-Logik

- **Coming-Soon-Markierung** für nicht implementierte Auftragstypen (sonderauftrag, kapa_workshop, zdsc_campus, 360_workshop)
  - Auswählbar zur Klassifizierung
  - Visuelles Badge + Tooltip "Workflow noch nicht implementiert"
- **Auto-Select-Regel:** Kapa Workshop und LSC Workshop gekoppelt
  - Kapa anhaken → LSC auto-mit-angehakt
  - LSC abhaken (mit Kapa aktiv) → Kapa auto-mit-abgehakt
  - Kapa abhaken → LSC bleibt erhalten (User-Wahl)
- **Max-3-Validierung:**
  - Frontend: 4. Checkbox disabled mit Tooltip
  - DB: Trigger `trg_max_3_project_types` verhindert API-Bypass
- DB-Schema: `project_types.is_implemented` Spalte ergänzt

Migration: `supabase-migration-project-types-is-implemented.sql` (mit Rollback-Companion)

## 2026-04-29 — Phase 1: Auftragstypen-Master Cleanup + Naming

- **project_types** auf 6 Soll-Auftragstypen reduziert:
  - Entfernt: investplausibilisierung, praeventionsteam, sonstiges
  - Ergänzt: sonderauftrag, zdsc_campus
  - Label-Update: kapa_workshop → "Kapazitäts-Workshop"
- **FK-Constraint** auf `project_type_assignments.project_type_code` → `project_types.code` (verhindert orphan codes)
- **Demo-Daten:**
  - DEMO-2026-004 (Mahle): Auftragstyp investplausibilisierung → Multi-Type-Combo lsc_workshop + fabrikanalyse (demonstriert M:N-Junction)
- **Naming:** Sidebar/i18n/Demo-Panel zeigen einheitlich "Kapazitäts-Workshop"
- **Datenablage-Toggle** im Demo-Modus-Panel ergänzt
- Pre-Migration-Check verifiziert: nur Demo-Daten betroffen, kein Daten-Risiko

Migration: `supabase-migration-project-types-soll-modell.sql` (mit Rollback-Companion)

## 2026-04-29 — Demo-Modus: project_type_assignments Junction-Fix

- Fix: `seedProjektanlage` füllt jetzt `project_type_assignments`-Junction-Table
- Vorher unsichtbar in Modul-Listen mit `!inner` Join (LSC Workshop bestätigt, vermutlich auch Wertstrom/Fabrikanalyse/OEE betroffen)
- Junction-Row pro Demo-Projekt mit `project_type_code` aus `DEMO_PROJECTS`-Mapping
- Reset: CASCADE-FK auf `project_type_assignments.project_id` → automatisches Cleanup beim Löschen der Demo-Projekte
- Manueller Test: Demo-Projekte erscheinen in LSC-Workshop-Liste

## 2026-04-29 — RLS Phase 1c: WITH CHECK Fix

- Behebt FK-Violation beim Laden LSC-Workshop Demo-Daten
- `process_steps`, `cycle_measurements`, `projects`: `cmd=ALL` Policies hatten `with_check=NULL`, blockierten Inserts mit FK-Referenzen
- Fix: explizite `WITH CHECK`-Klauseln, Pattern wie `lsc_shifts_own`/`lsc_measures_own`
- Migration: `supabase-migration-rls-phase1c-with-check.sql`
- Verifiziert in Supabase live (alle drei Policies jetzt `has_with_check=true`); Demo-Test ausstehend

## 2026-04-28 – Security: RLS enabled on public.department_master_data via lax-policy cleanup + activation

- Ninth RLS audit activation overall. Resolves the
  gap on department_master_data, where 6 policies
  existed but RLS was deactivated, meaning none of
  the policies were enforced.
- Phase-0 audit (2026-04-28) found that 4 of the 6
  policies were redundant lax `dept_*` policies
  (qual/with_check=true for all authenticated users),
  which would have rendered the existing admin-gated
  `dmd_write` policy ineffective if RLS had been
  activated as-is (PostgreSQL OR-merges policies for
  the same command). Two policies stay: `dmd_select`
  (lax read, equivalent to the master_data_values
  mdv_select pattern) and `dmd_write` (admin-gated
  via explicit user_profiles+roles join, retained
  unchanged per operator's Variante-B decision —
  minimal-touch migration, no rename or
  re-definition).
- Code audit: 9 read paths live across konto, admin,
  repository, document-drawer, upload-dialog,
  master-data-provider, project-documents, and
  entity-resolver. 5 write paths all in
  components/repository/department-master-data-
  client.tsx, which is unmounted per follow-up #24
  (intentionally-disabled UI of unknown vintage).
  0 raw SQL write paths. RLS activation therefore
  poses no risk to live write traffic.
- Trigger: `department_set_updated_at` (BEFORE
  UPDATE → set_updated_at() helper) exists on this
  table and is the same pattern as on
  supplier_master_data and documents. Trivial,
  SECURITY INVOKER, harmless under RLS. Untouched
  by this migration.
- Smoke-test scope: read paths only (login as
  masteradmin, navigate to UIs that read
  department_master_data, verify departments are
  still visible). Write paths NOT directly tested
  because (a) no non-admin test user constellation
  available to verify the admin-gated policy
  blocks non-admins, and (b) all write code paths
  are unreachable per the unmounted-component
  status. This is documented as a known smoke-test
  gap, not as an unverified-correctness concern.
- SQL archived in
  `supabase-rls-department-master-data-enable.sql`
  at repo root, with two-step rollback block (DISABLE
  RLS + RECREATE the four dropped lax policies, in
  reverse order).
- Production smoke test on production Supabase
  (project `<production-db-ref>`) and Vercel
  production deploy (`kadi-eight.vercel.app`): PASS.
  DDL execution in Supabase SQL Editor: the
  `BEGIN; DROP POLICY IF EXISTS dept_* (x4); ALTER
  TABLE ... ENABLE ROW LEVEL SECURITY; COMMIT;`
  block ran clean and returned "Success. No rows
  returned" as expected for DDL.
  Live-DB post-state verification on production
  Supabase:
    * `SELECT relname, relrowsecurity FROM pg_class
      WHERE relname = 'department_master_data';` →
      1 row, `relrowsecurity = true`. RLS is now
      active on the table.
    * `SELECT policyname, cmd FROM pg_policies
      WHERE schemaname = 'public' AND tablename =
      'department_master_data' ORDER BY policyname;`
      → exactly 2 rows: `dmd_select` (SELECT) and
      `dmd_write` (ALL). The four lax `dept_*`
      policies (`dept_select`, `dept_insert`,
      `dept_update`, `dept_delete`) are gone.
  UI read-path smoke-test (fresh masteradmin login,
  incognito, on kadi-eight.vercel.app):
    * `/konto` — PASS, departments visible.
    * `/admin/users` — PASS, departments visible.
    * `/admin/users/directory` — PASS, departments
      visible.
  Write paths NOT directly tested, per the Phase 1
  scope decision: (a) no non-admin test user
  constellation was available to verify that the
  admin-gated `dmd_write` policy blocks non-admin
  writes; (b) all 5 write code paths in
  `components/repository/department-master-data-client.tsx`
  are unreachable since the component is unmounted
  per follow-up #24, and there are 0 raw-SQL write
  paths against this table anywhere in the repo.
  Documented as a known smoke-test gap, not as an
  unverified-correctness concern. The PR-#22-style
  silent-drop scenario is impossible here because
  RLS activation is paired with the lax-policy
  cleanup in the same transaction; a misconfigured
  intermediate state cannot be reached.

## 2026-04-27 – Fix: populate user-tracking columns in supplier-master-data writes and audit-log inserts

- Resolves follow-up #25 (changed_by NULL in
  master_data_audit_log inserts) and follow-up #26
  (created_by / updated_by NULL in supplier_master_data
  rows). Both were discovered during the PR #22 smoke-test
  verification when a single audit row was created without
  an actor identity, and the deleted supplier's old_data
  snapshot revealed the same gap on the source table.
- Phase-0 audit (2026-04-27) traced the gap to the
  management-UI surface specifically: inline-creation flows
  (e.g. components/project/create-supplier-inline.tsx)
  already populate user-tracking columns correctly via
  `const { data: { user } } = await supabase.auth.getUser()`
  followed by `created_by: user?.id ?? null` in the insert
  payload. The supplier-master-data management UI
  (components/repository/supplier-master-data-client.tsx)
  and the merge server action (lib/duplicates/merge.ts)
  omit the population entirely.
- This PR copies the inline-creation pattern verbatim into
  the four reachable write paths in
  supplier-master-data-client.tsx (lines 579, 596, 599,
  611) and adds `changed_by: session.authUserId` to the
  audit-insert in merge.ts:65 (where session is already in
  scope from line 30).
- Dead Department code paths (department-master-data-client.tsx
  lines 447, 457) intentionally NOT fixed — the component
  is unmounted per follow-up #24 and PR #25 attached a
  status marker advising against modification without
  resolving the follow-up.
- Backfill of the 9350 existing supplier_master_data rows
  (all with created_by=NULL/updated_by=NULL) is NOT in
  scope. Pre-fix data remains NULL; post-fix writes
  populate the columns. Final pre-handover cleanup phase
  (follow-up #28) will decide the legacy NULL rows' fate.
- Schema inventory at the time of this fix: 15 public
  tables have user-tracking columns. Only 2 are fixed here
  (the 2 from #25/#26). The remaining 13 are captured as
  new follow-up #29 — separate backlog anchor, not in
  this PR's scope.
- Production smoke test on Vercel preview URL: PASS.
  Fresh masteradmin login (incognito) on the
  branch-deploy preview. Test sequence:
    * Created supplier "Test #26" (full lifecycle).
    * Created supplier "Test #27" (created-only;
      remains in the production database, manual
      cleanup at operator's discretion, not blocking).
    * Test #26: deactivate via Active-toggle, then
      hard-delete via trash button. UI confirmed both
      operations.
  Live-DB verification on production Supabase:
    * `supplier_master_data` row for Test #27:
      `created_by = d0be602e-ab9f-403f-90e4-9ea8e155ed5f`
      (operator masteradmin UUID); `updated_by = null`
      (correct — Test #27 was never modified after
      creation).
    * `master_data_audit_log` rows from the last 15
      minutes: two new rows for Test #26 (record_id
      `09362212-b377-4595-a28c-be1a78ddb354`): id=3
      action=deactivate `changed_by` populated; id=4
      action=delete `changed_by` populated. Both with
      the operator's masteradmin UUID. The PR-#22
      silent-drop scenario (`changed_by` NULL) is no
      longer reproducible — fix verified live.
- Not directly tested: `lib/duplicates/merge.ts:65`
  (mergeRecords server-action audit insert). No safe
  duplicate-merge test setup was available in this
  session. Correctness inferred from mechanical
  pattern-match: `session.authUserId` is in scope at
  line 30 (per Phase 0 audit) and added verbatim as
  `changed_by` at line 65, identical structure to the
  four supplier-side fixes that ARE verified.

## 2026-04-27 – Security: RLS enabled on public.master_data_audit_log via new INSERT policy + activation

- Eighth RLS audit activation overall; resolves
  Follow-up #19 (deferred from PR #18, 2026-04-22).
  Predecessors: PR #9 (email_queue), PR #13
  (holiday_entries), PR #15 (user_holiday_preferences),
  PR #16 (holiday_calendars), PR #17 (master_data_types),
  PR #19 (lsc_shift_hours), PR #21 (master_data_values).
- Qualitatively different from all seven predecessors:
  this is the first activation where a NEW policy had to
  be created BEFORE enable. The two existing policies
  (`audit_select` and `mdal_admin`) were SELECT-only.
  Naive `ENABLE RLS` would have broken all three code-side
  audit-insert paths with 403 errors (or silent drops via
  merge.ts non-fatal insert).
- Migration: CREATE POLICY `mdal_insert` for INSERT, gated
  to admin/masteradmin via the helper `current_user_role()`,
  followed by ENABLE RLS — both in one transaction.
  UPDATE and DELETE intentionally remain blocked: audit
  logs are append-only by design, and no code paths
  perform UPDATE or DELETE on this table.
- Phase 0 audit completed 2026-04-23 evening (operator +
  prompt-engineer): three INSERT paths identified
  (`supplier-master-data-client.tsx:599,611`,
  `lib/duplicates/merge.ts:65`, dead component
  `department-master-data-client.tsx:441,451`); no DB
  triggers; helper `current_user_role()` is the established
  convention; `mdal_admin` (SELECT, EXISTS-form) left in
  place as redundant-but-not-harmful with `audit_select`.
- Honest risk caveats (tracked as new follow-ups #21–#26,
  non-blocking for this activation):
    * Suppliers-Page-Auth-Desync risk: SuppliersPage
      gates on `consultants.role`; the helper
      `current_user_role()` reads `user_profiles+roles`.
      Today the desync is empty (only the operator
      masteradmin is consistent across both); ghost
      consultant rows with `auth_user_id=NULL` cannot
      log in. Latent risk if a new consultant is added
      with admin role before user_profiles sync.
    * Silent audit-trail gaps: `lib/duplicates/merge.ts`
      lines 77-79 catch INSERT failures with
      `logger.error` and return `success: true`. Under
      RLS, edge-case policy failures (JWT-stale, etc.)
      could silently drop audit entries while merges
      succeed.
    * Two ghost consultants with `auth_user_id=NULL`
      and `role IN (admin, masteradmin)`. Origin
      unclear (legacy / staging / migration artifact).
    * Dead component `DepartmentMasterDataClient`
      defined but not mounted; carries an audit-log
      INSERT contract that nobody runs.
    * `changed_by` NULL in audit-log inserts
      (smoke-test finding, #25): the new `mdal_insert`
      policy enforced the INSERT correctly, but the row
      carries no actor identity. Independent
      data-quality gap in the INSERT path itself.
    * `created_by` and `updated_by` NULL in
      `supplier_master_data` rows (smoke-test finding,
      #26): same pattern across master-data write
      paths, likely the same root cause as #25.
- Production smoke test on kadi-eight.vercel.app: PASS.
  Fresh masteradmin login (incognito); navigation
  Repository → Stammdaten → Suppliers. Test sequence on
  newly-created supplier `Test123` (number `000012-10`):
    * CREATE — new supplier inserted via the UI; no
      audit row expected (the audit-insert path is on
      deactivate / hard-delete only) and none observed.
    * VISIBILITY-TOGGLE — ausblenden + einblenden
      exercised; these are visibility flags, distinct
      from the active-flag deactivate path, and produce
      no audit row by design. None observed.
    * HARD-DELETE — Test123 deleted via the trash
      button; UI confirmed deletion.
  Audit-log verification via SQL Editor: 1 row inserted
  (id=2, table_name=`supplier_master_data`,
  action=`delete`, record_id=
  `f82b9f7c-aea7-4290-a687-eff19ef6c7a2`, changed_at=
  `2026-04-27 18:00:49+00`). The new `mdal_insert`
  policy enforced the INSERT correctly under active
  RLS — no silent drop, no 403, the audit trail was
  written.
- Smoke-test surfaced two data-quality findings
  distinct from the RLS activation, tracked as
  follow-ups #25 (`changed_by` NULL on the new audit
  row) and #26 (`created_by` / `updated_by` NULL in
  the `supplier_master_data` row's `old_data` snapshot
  at delete time). Both are observability / compliance
  gaps in the audit trail itself, not regressions
  introduced by this PR; the RLS activation succeeded
  as designed.
- SQL archived in
  `supabase-rls-master-data-audit-log-enable.sql` at
  repo root, with two-step rollback block (DISABLE RLS +
  DROP POLICY, in reverse order).
- Audit-trail note (appended 2026-04-27 by PR #23, after
  merge): the squash commit that landed this PR also
  contains an unrelated installation commit, "require
  gstack for AI-assisted work" (operator's parallel
  gstack-toolkit setup, originally committed to main as
  4415164 during the session). The mechanism: the feature
  branch was created from a main that already carried the
  gstack commit, so the squash bundled both. Both
  work-streams are technically correct; only the
  audit-trail single-purpose principle was violated. No
  revert; future PRs use clean-main rebase before branch
  creation (see follow-up #27).

## 2026-04-23 – Security: RLS enabled on public.master_data_values via duplicate-collapse + style-canonicalization

- Seventh RLS audit activation overall and PR-D subset 1
  first activation. Predecessors: PR #9 (email_queue),
  PR #13 (holiday_entries), PR #15 (user_holiday_preferences),
  PR #16 (holiday_calendars), PR #17 (master_data_types),
  PR #19 (lsc_shift_hours).
- Qualitatively different from PR #19 (pure
  duplicate-collapse): this migration also
  STYLE-CANONICALIZES the admin-write policy. Two
  semantically redundant admin-gated ALL policies existed —
  `mdv_admin` (helper form, `current_user_role() = ANY ...`)
  and `mdv_write` (explicit EXISTS form on
  `user_profiles JOIN roles`). The migration drops
  `mdv_write` and keeps `mdv_admin`. The helper
  `current_user_role()` (SECURITY DEFINER, defined in
  `MO-26/user-permissions.sql`) is the established
  convention used by `holiday_entries_write`,
  `holiday_calendars_write`, and `mdt_admin`.
- Live-DB state before fix (verified 2026-04-23): three
  policies — `mdv_admin` (helper-form ALL), `mdv_select`
  (SELECT USING true), `mdv_write` (EXISTS-form ALL),
  `rowsecurity=false`.
- Effective permission landscape after the fix: SELECT for
  any authenticated user; INSERT/UPDATE/DELETE restricted
  to admin/masteradmin via the helper.
- Defense-in-depth posture: the only UI surface that
  mutates this table is
  `components/repository/stammdaten-detail-client.tsx`
  (six write paths at lines 42, 51, 135, 147, 169, 180),
  mounted exclusively by the server page
  `app/repository/masterdata/stammdaten/[typeCode]/page.tsx`
  which already enforces an admin/masteradmin role check
  (lines 13–22) and redirects non-admins. Read-only API
  endpoint `app/api/v1/master-data/route.ts` is
  unaffected. RLS activation adds a policy-layer gate in
  addition to the route-layer gate; behavior for
  legitimate admin users unchanged.
- Production smoke test on kadi-eight.vercel.app: PASS.
  Fresh masteradmin login; navigation path Datenablage →
  Stammdaten → Projekt-Stammdaten → Arbeitsort → Verwalten.
  Three write paths exercised against the live "Arbeitsort"
  type: created a test value (INSERT, line 51), modified it
  (UPDATE, line 42), then deleted it (DELETE, line 169) —
  all three succeeded under active RLS via the helper-form
  `mdv_admin` policy. Cross-user isolation not directly
  tested (no secondary test account available); the
  server-side admin-role check at
  `app/repository/masterdata/stammdaten/[typeCode]/page.tsx`
  lines 13-22 already redirects non-admins before the client
  component mounts, so the six write paths are unreachable
  for non-admins regardless of RLS state.
- SQL archived in
  `supabase-rls-master-data-values-enable.sql` at repo
  root, with two-step rollback block (CREATE POLICY
  `mdv_write` + DISABLE RLS) since DROP is not reversed
  by a simple DISABLE.

## 2026-04-23 – Documentation: PR-D scope split into two sub-patterns; subset 2 deferred

- Audit doc updated to reflect that PR D is not a clean
  batch. Per-table narratives for `appointment_types`,
  `document_departments`, `document_metadata`,
  `document_suppliers` now carry corrective notes pointing
  to follow-up #20 (created in PR #19).
- PR-D sequencing item carries a sub-pattern note
  explaining the split: subset 1 (`master_data_values`,
  `department_master_data`) is duplicate-collapse + style
  work, forthcoming; subset 2 (the four tables above) is
  policy-rewrite, deferred until product decision.
- No SQL, no code, no database change in this PR.

## 2026-04-23 – Security: RLS enabled on public.lsc_shift_hours via duplicate-collapse pattern

- First duplicate-collapse RLS audit fix, after five
  activation-only fixes (PR #9 email_queue, PR #13
  holiday_entries, PR #15 user_holiday_preferences,
  PR #16 holiday_calendars, PR #17 master_data_types).
  PR C in the audit-doc sequence at
  `docs/foundation/rls-audit-2026-04-21.md`.
- Live-DB state before fix: three policies — `lsh_all`
  (ALL, USING true / WITH CHECK true), `lsh_select`
  (SELECT, USING true), and `Authenticated users manage
  lsc_shift_hours` (ALL, USING auth.uid() IS NOT NULL).
  Postgres OR-evaluates permissive policies, so `lsh_all`
  masked the more restrictive guards on the others. RLS
  was off entirely (`rowsecurity=false`), so none of the
  policies were enforced — the effective access control
  was application-layer only.
- Migration: dropped `lsh_all` (the lax duplicate), then
  activated RLS. Effective permission landscape after the
  fix: any authenticated user can read and write
  `lsc_shift_hours` — same practical access as before, now
  RLS-enforced. This is Option α (minimal intervention,
  status-quo-preserving) per the live-DB analysis on
  2026-04-23.
- Production smoke test on kadi-eight.vercel.app: PASS.
  Fresh masteradmin login; LSC workshop page rendered
  with shift-hours visible; added a new shift and
  completed the hours-protocol successfully (write flow
  confirmed under active RLS); Calendar, project-creation,
  and Factory-analysis ("Fabrikanalyse") pages all
  rendered without errors; no 403s and no policy-related
  failures observed.
- Audit-trail note (transparency): the migration SQL
  (`DROP POLICY "lsh_all"` + `ENABLE ROW LEVEL SECURITY`)
  was executed by the operator in the Supabase SQL Editor
  before reaching the planned HOLD-point in Phase 2 — the
  statements were copied from an earlier illustrative
  SQL block in the planning discussion. Verification
  (pre-check, post-check, policy listing) and smoke
  testing were therefore performed against the
  already-applied state. The end state matches the
  planned migration exactly (`relrowsecurity=true`, two
  remaining policies `lsh_select` and `Authenticated users
  manage lsc_shift_hours`); the in-HOLD `DROP POLICY` step
  errored with `42704: policy lsh_all ... does not exist`,
  which confirms the unplanned earlier execution. The
  audit-trail gap is recorded here for transparency.
- SQL archived in `supabase-rls-lsc-shift-hours-enable.sql`
  at repo root, with two-step rollback block (CREATE POLICY
  + DISABLE RLS) since DROP is not reversed by a simple
  DISABLE.
- Note for the future: the `lsc_shift_hours` case is the
  CLEANEST duplicate-collapse in the PR-C/PR-D scope. The
  remaining moderate-complexity tables (notably
  `appointment_types`, `document_*`) have lax-write policies
  across the board and require policy rewrites, not just
  collapse. Tracked separately in follow-up #20.

## 2026-04-22 – Documentation: PR-B closed at 3 of 4; master_data_audit_log deferred

- Closed the PR-B simple-activation batch at 3 of 4 targets
  done (PR #15 `user_holiday_preferences`, PR #16
  `holiday_calendars`, PR #17 `master_data_types`). Fourth
  target `master_data_audit_log` deferred after a session-
  audit revealed three client-side INSERT paths and a
  live-DB policy state with only SELECT policies.
  Naive activation would have produced 403 errors on
  "deactivate supplier", "hard-delete department", and
  "merge duplicate master data" flows.
- Updated `docs/foundation/rls-audit-2026-04-21.md`:
  reclassified `master_data_audit_log` complexity from
  Simple to Moderate; PR-B sequencing item now shows
  3 of 4 done with explicit deferral.
- New follow-up #19 tracks the remaining work — three
  open policy-design questions (style, reachability, and
  server-side INSERT paths) to be resolved before an
  INSERT policy can be added and RLS activated.
- No SQL, no code, no database change in this PR.

## 2026-04-22 – Security: RLS enabled on public.master_data_types

- Fifth RLS audit activation after PR #9 (email_queue), PR #13
  (holiday_entries), PR #15 (user_holiday_preferences), and
  PR #16 (holiday_calendars). Third of the PR B simple-batch
  targets. Two existing policies `mdt_select` (SELECT for
  authenticated, USING true) and `mdt_admin` (ALL for
  authenticated, gated on `current_user_role()` in
  admin/masteradmin) were inert because the table had
  `rowsecurity=false`.
- Executed `ALTER TABLE public.master_data_types
  ENABLE ROW LEVEL SECURITY` directly against production
  Supabase (project <production-db-ref>) on 2026-04-22.
  Pre-check → ALTER → post-check ran cleanly; both policies
  intact; production smoke test green (masteradmin fresh
  login; calendar page renders under active RLS; holiday
  entry added and deleted in 2027 as indirect cross-check of
  earlier activations; Benutzerverwaltung admin page dropdowns
  still populated, confirming master_data_types read access
  via FK from master_data_values). Cross-user isolation not
  directly tested (no secondary test account available);
  policy `mdt_admin` matches the established admin/masteradmin
  gating pattern from PR #13 and PR #16.
- SQL archived in `supabase-rls-master-data-types-enable.sql`
  at repo root. Clean rollback style.
- Remaining PR B target: `master_data_audit_log`.

## 2026-04-22 – Security: RLS enabled on public.holiday_calendars

- Fourth RLS audit activation after PR #9 (email_queue), PR #13
  (holiday_entries), and PR #15 (user_holiday_preferences).
  Second of the PR B simple-batch targets. Two existing policies
  `holiday_calendars_read` (SELECT for authenticated, USING true)
  and `holiday_calendars_write` (ALL for authenticated, gated on
  `current_user_role()` in admin/masteradmin) were inert because
  the table had `rowsecurity=false`.
- Executed `ALTER TABLE public.holiday_calendars
  ENABLE ROW LEVEL SECURITY` directly against production
  Supabase (project <production-db-ref>) on 2026-04-22.
  Pre-check → ALTER → post-check ran cleanly; both policies
  intact; production smoke test green (masteradmin fresh
  login, calendar page renders under active RLS with holiday
  entries and calendar groupings visible). Cross-user
  isolation not directly tested (no secondary test account
  available); policy `holiday_calendars_write` is gated on
  `current_user_role()` in admin/masteradmin, matching the
  established pattern from PR #13 (holiday_entries).
- SQL archived in `supabase-rls-holiday-calendars-enable.sql`
  at repo root. Clean rollback style.
- Remaining PR B targets: `master_data_types`, `master_data_audit_log`.

## 2026-04-22 – Security: RLS enabled on public.user_holiday_preferences

- Third RLS audit activation after PR #9 (email_queue) and
  PR #13 (holiday_entries). First of the PR B simple-batch
  targets. Single existing policy `holiday_prefs_own` (ALL
  for authenticated, user_id = auth.uid(), WITH CHECK
  user_id = auth.uid()) was inert because the table had
  `rowsecurity=false`.
- Executed `ALTER TABLE public.user_holiday_preferences
  ENABLE ROW LEVEL SECURITY` directly against production
  Supabase (project <production-db-ref>) on 2026-04-22.
  Pre-check → ALTER → post-check ran cleanly; policy
  `holiday_prefs_own` intact; production smoke test green
  (masteradmin fresh login, calendar renders under active RLS,
  user-preferences UI accessible and displays own preferences).
  Cross-user isolation not directly tested (no secondary test
  account available); policy semantics
  `user_id = auth.uid()` are standard Supabase user-scoped
  ownership and trusted by convention.
- SQL archived in
  `supabase-rls-user-holiday-preferences-enable.sql` at repo
  root. Same clean rollback style as the holiday_entries
  migration.
- Remaining PR B targets: `holiday_calendars`,
  `master_data_types`, `master_data_audit_log`.

## 2026-04-22 – Documentation: RLS audit document updated for PR #13

- Marked PR A (holiday_entries) as DONE in
  `docs/foundation/rls-audit-2026-04-21.md` to reflect that
  activation shipped in PR #13 (commit 6812157, 2026-04-21
  evening). Establishes a DONE-marker convention for future
  audit-sequenced activation PRs. No scope change, no re-ranking.

## 2026-04-21 – Security: RLS enabled on public.holiday_entries

- Second RLS audit fix after PR #9 (email_queue). Pattern
  identical: policies `holiday_entries_read` and `holiday_entries_write`
  already existed but were inert because the table had
  `rowsecurity=false`. P-A in the rollout sequence proposed in
  `docs/foundation/rls-audit-2026-04-21.md`.
- Executed `ALTER TABLE public.holiday_entries ENABLE ROW LEVEL SECURITY`
  directly against production Supabase (project <production-db-ref>)
  on 2026-04-21. Pre-check → ALTER → post-check ran cleanly;
  row count unchanged at 352; both policies intact; calendar
  smoke test green (holidays render, admin add/edit works,
  non-admin cannot mutate).
- SQL archived in `supabase-rls-holiday-entries-enable.sql` at
  repo root (includes commented rollback block, same shape as
  the email_queue archive).
- No code change. No policy DDL. Next targets in the rollout
  sequence: simple-activation batch (holiday_calendars,
  user_holiday_preferences, master_data_types,
  master_data_audit_log) per the audit doc's PR B.

## 2026-04-21 – Documentation: RLS audit inventory for 18 RLS-off public tables

- New audit document at `docs/foundation/rls-audit-2026-04-21.md`
  inventories every table in `public.*` that currently has
  `rls_enabled=false`. 18 tables in scope.
- Three systemic findings surfaced beyond the simple "RLS off" flag:
  (1) duplicate / overlapping policies present on most affected
  tables, (2) lax write policies that would remain permissive even
  after RLS is enabled, (3) the same "policies defined but inert"
  pattern that PR #9 fixed for `email_queue` repeated across the
  schema.
- The document proposes a nine-step PR sequence (PR A through PR I)
  for the eventual remediation but does not execute any fix itself
  — analysis input only. Tracked as follow-up #18.

## 2026-04-21 – Security: SUPABASE_SERVICE_ROLE_KEY rotated to new sb_secret_* format, marked Sensitive

- In response to the April 2026 Vercel security incident (not-as-
  Sensitive environment variables may have been exposed), rotated
  the Vercel `SUPABASE_SERVICE_ROLE_KEY` env var from a Legacy
  HS256 JWT (`eyJhbGciOi...` format, 10-year expiry) to a new
  `sb_secret_...` key from Supabase's new publishable/secret API
  key system.
- Enabled the Vercel "Sensitive" flag on the variable. The
  "Needs Attention" label from Vercel has since cleared.
- Redeployed production (commit 7d92c5b). Prod smoke-test PASS:
  login works, admin user creation works. No code changes were
  required — `lib/supabase/privileged-env.ts` reads
  `process.env.SUPABASE_SERVICE_ROLE_KEY` as an opaque string
  with no format validation.
- Legacy anon and service_role API keys remain enabled in
  Supabase because the iOS app (separate repo) still embeds the
  Legacy anon JWT in `Constants.swift`. The Legacy HS256 JWT
  signing secret in Supabase is currently marked "Previous" (not
  yet revoked). Full legacy deprecation tracked in follow-up #16.
- Supabase Free Tier limited forensic investigation of the
  incident window (logs are retained only ~1 hour). Rotation was
  done as a precaution, not as evidence-based response — tracked
  in follow-up #17.

## 2026-04-20 – Security: Single-instance RLS opened on 7 cp_* tables for runtime control-plane reads

- Fix for follow-up #12: module-gated layouts (fabrikanalyse,
  planning, assessment, repository, oee, kalender, lsc-workshop)
  had been redirecting to /projektanlage?blocked=... because the
  cp_* control-plane tables had service_role-only RLS while the
  runtime readonly client operates without a service-role key.
- Executed in two steps against production Supabase (project
  <production-db-ref>) on 2026-04-20:
  - Step 1: CREATE POLICY "<table>: authenticated read" ... FOR
    SELECT TO authenticated USING (true) on 7 cp_* tables.
    Silently filtered to empty results because
    createControlPlaneReadonlyClient() in lib/control-plane/client.ts
    runs with persistSession:false and no cookie auth, so PostgREST
    queries execute under the `anon` role, not `authenticated`.
  - Step 2: DROP the 7 authenticated-read policies and CREATE
    "<table>: public read" ... FOR SELECT TO anon, authenticated
    USING (true). Effective fix — runtime control-plane reads
    succeed, module-gated layouts render again.
- Tables opened (metadata / config / branding only, no PII, no
  secrets): cp_tenants, cp_plans, cp_features,
  cp_plan_feature_entitlements, cp_tenant_feature_overrides,
  cp_branding_profiles, cp_release_rollouts.
- Tables kept at service_role-only (unchanged):
  - cp_tenant_environments — stores per-tenant service-role keys.
  - cp_audit_events — append-only audit log.
  - cp_provisioning_jobs, cp_provisioning_job_steps — ops workflow
    state.
  - cp_support_access_sessions — impersonation grants.
  - cp_owner_users — platform admin identities.
- Single-instance decision. These policies grant SELECT to every
  anon/authenticated caller on every row and MUST be rewritten
  tenant-scoped before any multi-tenant activation. Tracked as
  follow-up #15.
- SQL archived in supabase-rls-cp-tables-authenticated-read.sql
  (step 1, superseded, kept for history) and
  supabase-rls-cp-tables-public-read.sql (step 2, effective).
- follow-ups.md: #12 closed with resolution note; #14 opened for
  the eslint-plugin-boundaries legacy-selector warning surfaced
  in PR #9 CI; #15 opened for the multi-tenant revision
  requirement. #13 (cp_subscriptions) unchanged.

## 2026-04-20 – Security: RLS enabled on public.email_queue

- `public.email_queue` had `rowsecurity=false` despite two existing
  policies (`email_queue_admin`, `email_queue_insert_auth`) — policies
  were inert. P0 finding from RLS audit phase 1.
- Executed `ALTER TABLE public.email_queue ENABLE ROW LEVEL SECURITY`
  directly against production Supabase (project <production-db-ref>)
  on 2026-04-20. Pre-check → ALTER → post-check ran cleanly; row_count
  unchanged at 11; both policies intact; admin UI smoke test green
  (queue page loads, count badges 8/2/1 match).
- SQL archived in `supabase-rls-email-queue-enable.sql` at repo root
  (includes commented rollback block).
- No code change. No policy DDL. Broader RLS audit (user_profiles,
  user_audit_log, lsc_shift_hours, assessments, holiday_entries) and
  cp_* public-read policies tracked separately.

## 2026-04-20 – Governance correction: /health v1 flat-probe carve-out

- Scorecard and risk register claimed all 7 v1 routes use
  `@/lib/api` envelope helpers. True for 6; `GET /health` is an
  intentional flat-body carve-out for external liveness probes.
- `governance/scorecard/architecture-scorecard.md`: structural
  cleanup #3 now lists both carve-outs (DELETE /projects/{id} and
  GET /health) with their reasons.
- `governance/scorecard/risk-register.md`: R-2026-04-19-09
  resolution text corrected.
- `docs/foundation/follow-ups.md`: item #6 clarified with the
  /health note.
- No code change. No OpenAPI schema change. `HealthResponse` was
  already flat in the spec — reality matches spec, only governance
  drifted.

## 2026-04-19 – CI Vitest Hard Gate

- `portability-matrix.yml`: new `Vitest (full suite)` step between
  `check:openapi` and `Full lint`. Runs `npm run test` (239 tests,
  Vitest 4.1.2, env:node) on every push/PR to main and nightly.
- Hard gate: no `continue-on-error`. Matrix covers both profiles
  (default, bmw).
- Governance: scorecard `Rails currently active` gains a `Vitest
  suite` rail; `follow-ups.md` #5 closed.
- No product code, test code, package.json, or `vitest.config.ts`
  touched. +7 YAML lines, +governance edits.
- Local verification: 239/239 green in 476ms, no flake, no network.

## 2026-04-19 – R-05 Oversized-Component Decomposition (Partial Close)

- **Test net first (48 new characterization tests)**: `__tests__/project-edit-form-helpers.test.ts` (15 cases — `consultantLabel`, `kwToDateRange` ISO-8601 incl. week 1/2025 + week 53/2020, `getWeekdaysInRange`), `__tests__/project-edit-form-payload.test.ts` (13 cases — full Supabase `projects` update contract: erstbesuch-calendar-reset rule, supplier-name fallback, combobox `valueId` vs free-text independence, consultant entries ordering/dedup/lead-only, type-assignment unknown-code filtering), `__tests__/vsm-editor-logic.test.ts` (20 cases — geometry 80-min-dx + 45% scaling, findBottleneckId process-only + CT>0 guards, computeTimeline VA/NVA excluding customer/supplier with rounding, Excel row mapping with Name-fallback + empty-row filter, NODE_CONFIG completeness)
- **`components/project/project-edit-form.tsx` 1437 → 1340 lines**:
  - `project-edit-form.helpers.ts` (64 lines) — pure helpers (`consultantLabel`, `kwToDateRange` with UTC-correct ISO-8601, `getWeekdaysInRange`, `fmtDate`)
  - `project-edit-form.payload.ts` (226 lines) — the high-risk Supabase update contract: `buildProjectUpdatePayload` (~50-line `.update({...})`), `buildConsultantEntries`, `buildTypeAssignmentRows`; centralizes `nullIfEmpty` semantics for ~40 columns
  - `project-edit-form.conflict-modal.tsx` (67 lines) — `VisitConflictModal` with identical AlertTriangle styling and cancel/add/overwrite callbacks
- **`components/wertstrom/vsm-editor.tsx` 1192 → 1033 lines**:
  - `vsm-config.ts` — `NODE_CONFIG` for all 7 node types
  - `vsm-geometry.ts` — `NODE_W`, `getNodeH`, `getInputPortPos`, `getOutputPortPos`, `buildPath` (SVG cubic with 80-min dx and 45% gap scaling)
  - `vsm-metrics.ts` — `findBottleneckId`, `computeTimeline` (rounded pct)
  - `vsm-excel.ts` — `mapExcelRowsToPreview` (canonical German column mapping)
  - `vsm-stoppuhr-picker-modal.tsx`, `vsm-excel-import-modal.tsx`, `vsm-lsc-import-modal.tsx` — the three modal flows extracted as sibling components
- **Behavior preserved**: no API changes, no prop changes to `VsmEditor` / `ProjectEditForm`, no UI pixel changes. The `.update(...)` payload shape, the Supabase insert keys for `project_consultants` and `project_type_assignments`, the LSC Workshop step fetch, the ExcelJS import pipeline, canvas pan/zoom interaction, and the VSM save/autosave routes are byte-for-byte identical.
- **Validation**: 239/239 vitest green; `tsc --noEmit` clean on product source (pre-existing test-mock cast noise remains, tracked separately); `npm run build` green; `npm run check:portability` (boundaries + openapi + csp + secrets) clean.
- **Explicit honesty**: VSM canvas interaction state (pan/zoom/drag refs + global mousemove/mouseup listeners + SVG world transform) was intentionally NOT split. Splitting stateful DOM-bound drag logic safely requires a real-browser harness (jsdom or Playwright); the current vitest Node env cannot exercise that behaviour. Deferred to a future R-05 follow-up.
- **Governance**: `risk-register.md` + `architecture-scorecard.md` + `docs/foundation/follow-ups.md` updated with partial-close status and the concrete remaining items.

## 2026-04-05 – DB Audit, Cleanup & Performance-Migration

- **`proxy.ts`**: `middleware.ts` umbenannt (Next.js 16 Konvention), deployed
- **`supabase-audit-phase1-2.sql`** neu: 18 Audit-Queries (Tabellen, Spalten, FKs, Indexes, RLS, Sequential Scans, Orphans, Duplikate, NULL-Analyse) — im SQL Editor ausführen
- **`supabase-migration-cleanup.sql`** neu: vollständige Cleanup- und Optimierungs-Migration
  - Phase 3: Verwaiste `document_metadata`, `oee_loss_categories`, `assessment_responses` löschen
  - Phase 4: 45 neue Performance-Indexes (`assignments`, `projects`, `documents`, `supplier_name_trgm`, `oee_records`, `lsc_*`, `assessments`, `consultants`, u.v.m.)
  - Phase 4: `pg_trgm` Extension für Lieferanten- und Dokumenten-Volltextsuche
  - Phase 5: RLS auf allen bisher ungesicherten Tabellen aktiviert (`supplier_master_data`, `department_master_data`, `assessments`, `documents`, `user_profiles`, `consultants`, `oee_records`, `lsc_shifts`, `lsc_measures`, `value_stream_maps`, Audit-Logs, u.a.)
  - Phase 5: RLS-Probleme dokumentiert (zu breite Policies auf `project_type_assignments`, `project_suppliers`, `lsc_shift_hours` — Fixes auskommentiert bis getestet)
  - Abschluss: `VACUUM ANALYZE` auf 22 Tabellen
- **`docs/DATABASE_SCHEMA.md`** neu: vollständige Datenbankdokumentation (49 Tabellen, ERD, Spalten, Indexes, RLS-Policies, Sequences, offene Punkte)

## 2026-04-04 – Einsatzplanung: Jahres-Timeline

- **Jahresansicht** (`planning-grid.tsx`): `getMonthDays()` ersetzt durch `getYearDays()` — alle 365/366 Tage des Jahres werden horizontal gerendert (~14.600px scrollbare Breite)
- **Neuer Header** (`planning-header.tsx`): 3-zeilig — Monat (colSpan), KW (colSpan), Tagnummer; `rowSpan=3`-Namenspalte; alternating Monatstint (gerade/ungerade); `data-date`-Attribute für Scroll-Targeting
- **Monats-Trennlinien** (`planning-row.tsx`): erster Tag jedes Monats erhält `borderLeft: 2px`; Header-Monatszellen `border-r-2` — durchgehendes Raster mit visuellen Monatsgrenzen
- **Scroll-zu-Heute** (`planning-client.tsx`): `gridContainerRef` auf dem Scroll-Container; `scrollToDate(date, year)` berechnet Pixel-Position; auf Mount automatisch auf heutigen Tag zentriert (`behavior: instant`)
- **`Heute`-Button**: scrollt smooth zum heutigen Tag; wechselt Jahr falls nötig und scrollt nach Re-Render (`scrollAfterYearChangeRef` + `useEffect([year])`)
- **Jahr-Navigation**: `month`-State entfernt; nur `year`-State; `< 2025 >`-Buttons; Keyboard-Navigation (`←→↑↓`) scrollt innerhalb des Jahres statt Monat zu wechseln
- **Monats-Schnellnavigation** (`planning-toolbar.tsx`): 12 Pill-Buttons (Jan–Dez) unterm Toolbar scrollen smooth zum jeweiligen Monat; `onScrollToMonth(0-indexed)` Callback
- **Mobile**: 14-Tage-View (2 Wochen statt 1); Wochenlabel zeigt "KW 14–15 · 31.03 – 13.04.2025"
- **Daten-Range**: Supabase-Fetch und IndexedDB-Query laden das komplette Jahr (01.01–31.12)
- `lib/planning-config.ts`: neue Exports `getYearDays(year)` und `yearRange(year)`
- Build: ✓ alle 78 Routen

## 2026-04-04 – BMW CI Design System & Font Integration

- **BMW Corporate Fonts**: 11 `.woff2` Dateien (BMW Group TN Pro + TN Condensed Pro) per `@font-face` eingebunden; Google Inter entfernt; `font-condensed` Utility-Klasse ergänzt
- **BMW CI Farb-Palette**: CI-Custom-Properties (`--ci-primary`, `--ci-danger` etc.) in `:root`; Semantic Tokens auf BMW Warmgrau aktualisiert (`--foreground: #494949`, `--background: #f6f6f6`, `--border: #eeedeb`)
- **Minimales Radius-System**: CSS-Variable `--radius: 3px`; `@theme`-Mapping `rounded-sm=2px … rounded-4xl=12px`; `rounded-2xl/3xl` in 235 Dateien per Script auf `rounded-sm` zurückgeführt
- **Schatten entfernt**: `shadow-sm`, `shadow-md`, `shadow-xl`, `shadow-2xl` aus allen 235 TSX-Dateien entfernt; nur `shadow-lg` (Modals/Overlays) erhalten
- **BMW Gray Button System** (`components/ui/button.tsx`): CVA-Rewrite — `default=#6f6f6f`, `secondary=#d6d6d6`, `outline`, `ghost`, `destructive=#BA2636`; `font-condensed font-bold text-[13px]`; neues `cta`-Size (h-[46px])
- **CATEGORY_COLORS** (`lib/assessment-types.ts`): Auf BMW CI Pantone-Farben aktualisiert (Methyl Blue, Simply Green, Orange Ochre, Vivid Viola, Pompeian Red, Cyan Blue)
- **Assessment-Workspace-Sektion-Header**: Redesign mit farbigem Top-Border (3px) und `catColor.bg` Band; kompaktere Abstände
- **OEE KPI-Karten**: Zahlen auf `font-condensed font-bold` umgestellt
- **Assessment KI-Export** (`lib/assessment-export.ts`): Neue `exportForAISummary()` Funktion — Markdown-Download mit Kategoriegliederung, Bewertungen, Kommentaren, kritischen Fragen (≤2), Durchschnittswerten und KI-Anweisung
- **Assessment Dashboard**: "KI-Export" Button mit Sparkles-Icon; Score-Zahl und StatChip auf `font-condensed` umgestellt
- **Body Font**: 14px/22px (war 16px/24px)
- Build: ✓ Alle 78 Routen ohne Fehler kompiliert

## 2026-04-04 – Projektanlage Edit: Lieferant-Reset, Feedback, Konfliktprüfung

- **Bug 1 fix**: Kein Redirect nach Speichern → Lieferant bleibt selektiert; `savedErstbesuchStart/End` State trackt gespeicherte Werte für korrekte "Daten geändert"-Erkennung nach Speichern ohne Seitenneuladen
- **Bug 2 fix**: `useToast` (aus `@/components/repository/toast`) für Speichern-Feedback — grüner Toast "Änderungen gespeichert" / roter Toast bei Fehler; `saveError` State entfernt
- **Bug 3 fix**: `checkVisitConflicts()` in `create-visit-assignments.ts` — prüft bestehende Assignments für Berater im Zeitraum; Konflikt-Modal mit "Bestehende überschreiben" / "Zusätzlich anlegen" / "Abbrechen"
- **Lieferant Standort**: Unterhalb SupplierSearch — Lieferantennummer + Ort/Land aus Stammdaten
- `createVisitAssignments` erhält `mode?: 'replace' | 'add'` Parameter; `getWeekdaysInRange` ist jetzt exportiert

## 2026-04-04 – Erstbesuch/Folgetermine: Komplette Überarbeitung

- **Migration erforderlich**: 4 neue Spalten auf `projects` — `erstbesuch_start`, `erstbesuch_end`, `erstbesuch_calendar_created`, `folgetermine` (JSONB)
- **Neues Architekturprinzip**: Daten werden direkt auf dem Projekt gespeichert; Kalendereinträge werden explizit per Button erstellt — keine automatische Erzeugung beim Speichern
- **`lib/planning/create-visit-assignments.ts`**: Neue Shared-Library-Funktion; upsert `planning_projects`, delete+recreate assignments, korrekte `is_demo: false`-Spalte
- **`app/project/[id]/edit/page.tsx`**: Vereinfacht — liest Erstbesuch/Folgetermine direkt aus `projects`-Spalten; keine Drei-Phasen-Query mehr
- **`project-edit-form.tsx`**: Speichert Daten als Felder auf Projekt; `erstbesuch_calendar_created` wird zurückgesetzt wenn Daten sich ändern; zwei neue Handler (`handleCreateErstbesuchCalendar`, `handleCreateFolgeCalendar`); Redirect nach Edit-Seite statt Projektdetail; Konflikt-Logik entfernt
- **`new-project-form.tsx`**: Erstbesuch/Folgetermine werden beim Anlegen auf Projekt gespeichert; Konflikt-Modal entfernt; Redirect zur Edit-Seite für Kalender-Button
- Alle Debug-`console.log`s entfernt

## 2026-04-04 – Fix: Kalendereinträge + Einsatzplanung FK-Fehler (no migration)

- **No migration required** — removed `origin_project_id` approach; use `planning_projects.code = project_code` as the stable link
- **`app/project/[id]/edit/page.tsx`**: Three-phase query — get project → look up `planning_projects.id` by code → query assignments by `project_id = planningProjectId`; dates now correctly restored on edit reopen
- **`create-assignment-modal.tsx`**: Fixed FK violation (`assignments_project_id_fkey`) — on submit, now upserts into `planning_projects` first and uses the returned ID as `project_id`
- **`project-edit-form.tsx`** + **`new-project-form.tsx`**: Errors in assignment inserts are now surfaced (thrown) rather than silently swallowed
- `MO-27/assignments-origin-project.sql` kept for future use but no longer required for core functionality

## 2026-04-04 – Fix: Erstbesuch/Folgetermine assignments + Folgetermine in Projektanlage

- **DB migration `MO-27/assignments-origin-project.sql`**: Adds `origin_project_id uuid REFERENCES projects(id)` to `assignments` — must be run in Supabase SQL editor before this release
- **Root cause fix**: `assignments.project_id` is FK to `planning_projects` — forms now upsert a `planning_projects` entry (for calendar labels) and additionally store `origin_project_id = projects.id` for reliable back-reference
- **Edit page**: Erstbesuch + Folgetermine date queries now use `origin_project_id` instead of `project_id` — dates no longer appear cleared on reopen
- **Delete queries**: Use `origin_project_id` so old assignments are reliably removed before re-inserting
- **Folgetermine in Projektanlage**: Added Folgetermine section (add/remove date ranges) to `new-project-form.tsx`; assignments are created on first save alongside Erstbesuch

## 2026-04-04 – Projektanlage UX improvements (Changes 1–6)

- **Change 1 – Supplier two-line format**: `lib/format/supplier.ts` helper; `project-list-client.tsx` table and "Meine Projekte" cards now show supplier name bold + city/country · Nr. as second line; `supplier_number` added to query
- **Change 2 – Auftragsform-Tags as hex-colored badges**: `components/project/project-type-tags.tsx` with per-type inline hex colors; project list table and cards now show ALL assigned types (via `project_type_assignments`), not just the primary type
- **Change 3 – Berichte per-project-type sections**: When a project is selected in Projektstatistik, client-side fetch of assessments (fabrikanalyse) and workshop_actions (lsc_workshop); type-colored accordion sections with sub-project tabs
- **Change 4 – Back buttons**: All sub-pages already had back navigation; confirmed no gaps
- **Change 5 – iPhone portrait fixes**: `project-edit-form.tsx` and `new-project-form.tsx`: `grid-cols-2/3` → `grid-cols-1 sm:grid-cols-2/3`; footer button row: `flex-wrap`
- **Change 6 – Erstbesuch KW toggle + conflict detection**: Zeitraum/Kalenderwoche mode toggle; `kwToDateRange` helper; conflict count query before inserting new calendar entries; conflict warning banner
- **Change 7 – Sub-project tree**: Already fully implemented in previous session

## 2026-04-04 – Calendar Phase 2 (mobile UX, conflict badges, block edit, UX polish)

- **2.8 – Connected-block edit**: `edit-assignment-modal.tsx` detects consecutive same-consultant+project assignments; shows "Für alle X verbundenen Tage übernehmen" checkbox; bulk-updates the whole block when checked
- **2.9 – Mobile week view**: planning grid shows a 7-day week on screens < 640px (`daysOverride` prop on `PlanningGrid`); fetch range adapts to week slice; `weekOffset` state drives navigation
- **2.9 – Swipe navigation**: left/right swipe on the grid scroll container advances/reverses the week on mobile
- **2.9 – Compact mobile toolbar**: `planning-toolbar.tsx` has a `sm:hidden` row with 44px touch targets (‹ / ›), current week label ("KW 14 · 31.03 – 06.04"), "Heute" and icon-only create button; all filter controls remain visible on `sm:` and above
- **2.3 – Month row in calendar header**: italic grey month name above each KW where the month changes
- **2.4 – Conflict badge**: ⚠️ badge on cells that have multiple entries on the same day
- **2.6 – Project required**: create-assignment modal enforces project selection
- **2.7 – Sub-project grouping**: create-assignment modal groups sub-projects under parent `<optgroup>`

## 2026-04-03 – Sub-projects & Multi-project-type support

- **SQL migration**: `supabase-migration-subprojects.sql` — adds `parent_project_id`, `sub_project_suffix` to projects; creates `project_type_assignments` (many-to-many project ↔ type) and `project_suppliers` (many-to-many project ↔ supplier) with RLS; backfills from existing `project_type_id`
- **Multi-type selection**: replaced single Auftragstyp dropdown with checkbox grid in `new-project-form.tsx` and `project-edit-form.tsx`; primary type saved to `project_type_id`, all types synced to `project_type_assignments` on save/update
- **Sub-project creation**: `project-edit-form.tsx` has new "Unterprojekte" section (bottom of edit form, hidden for sub-projects themselves) — shows existing sub-projects, inline mini-form to create new ones with suffix + optional different supplier; inherits parent fields
- **Project list tree view**: `project-list-client.tsx` separates top-level vs sub-projects; parent rows show expand arrow (▶) when they have sub-projects; expanded sub-projects appear indented with suffix badge; "Nur Hauptprojekte" toggle
- **LSC Workshop filter**: `lsc-workshop/page.tsx` now filters via `project_type_assignments` join (shows projects with ANY assignment of lsc_workshop, not just primary type); excludes sub-projects from top-level list
- **Edit page**: fetches `project_type_assignments` for pre-selection of checkboxes; passes `initialTypeIds` to edit form
- **Conditional fields**: `showInvestField` / `showPltFields` now check against the full set of selected type codes (any-match instead of single-match)

## 2026-04-03 – API v1, Environment Handling, Error Boundaries

- Created API v1 routes: /projects, /projects/[id], /suppliers, /oee, /assignments, /master-data, /health
- Created lib/env.ts with APP_ENV/isDev/isStaging/isProd
- Demo components (DemoBanner, DemoControlPanel) gated by !isProd in layout
- Added React ErrorBoundary wrapping children in root layout
- Created .env.example
- Created docs/API.md, docs/STAGING_CHECKLIST.md, docs/PRODUCTION_CHECKLIST.md

## 2026-04-03 – Phase 4: Code Quality & UI Polish

- Removed all remaining console.log/warn/debug (4 files)
- Fixed `any` types in project-documents-client.tsx
- Added LoadingSkeleton + TableSkeleton utility components
- Added delete confirmations to workshop, lsc-stopwatch, lsc-massnahmen
- Split supplier-master-data-client (1163→<300 lines) into table + toolbar sub-components
- Split consultant-master-data-client (819→<250 lines) into table + toolbar sub-components
- Split department-master-data-client (889→<250 lines) into table + toolbar sub-components

## 2026-04-02 – Audit fixes: security, reliability, and code quality

- **S1 — Auth redirects**: Added `if (!user) redirect('/login')` to `app/kalender/page.tsx`, `app/kalender/consultants/page.tsx`, `app/repository/page.tsx` — all three fetched protected Supabase data without an auth guard
- **D1 — berichte-client error handling**: `fetchData()` now destructures `{ data, error }`, logs on error, and uses try/finally to guarantee `setLoading(false)` runs even on unexpected throws
- **S4 — xlsx removed**: Migrated all `import('xlsx')` / `XLSX.read()` / `XLSX.utils.sheet_to_json()` usages in `lib/qaf-parser.ts`, `lib/assessment-catalog-import.ts`, `components/repository/supplier-master-data-client.tsx`, `components/repository/consultant-master-data-client.tsx`, `components/repository/department-master-data-client.tsx`, `components/stopwatch/stopwatch-client.tsx`, `components/wertstrom/vsm-editor.tsx` to ExcelJS. Removed `xlsx` package from dependencies.
- **O2 — SW cache busting**: Created `scripts/inject-sw-version.cjs` which rewrites `CACHE_VERSION` in `public/sw.js` with a timestamp-based hash on every build. Updated `package.json` build script to prepend this step.
- **O1 — Offline queue documented**: Created `docs/TECHNICAL_DEBT.md` with O1 entry. Added `// TODO [O1]` markers to `handleTileMove` and `handleSpanResize` in `components/planning/planning-client.tsx`.
- **FIX 6 — createClient() consolidation**: Replaced all multi-call patterns across 30+ components with a single `useMemo(() => createClient(), [])` instance per component. Eliminated ~60 redundant client instantiations.
- **FIX 7 — Math.random() security**: Replaced `generatePassword()` in `app/api/admin/users/route.ts` with a `crypto.getRandomValues()`-based implementation.
- **FIX 8 — console.log cleanup**: No console.log/warn/debug calls found in components/ or lib/ — already clean. Created `lib/logger.ts` utility for future structured error reporting.
- **FIX 9 — Component debt documented**: Added `// TODO: Split into sub-components` comments to 5 oversized stable components (supplier, department, vsm, oee, project-documents, consultant). All entries recorded in `docs/TECHNICAL_DEBT.md`.

## 2026-04-02 – Supplier search component

- **`components/ui/supplier-search.tsx`** (neu): Selbstständige Such-Komponente mit Debounce (300ms), Supabase-Query per `ilike` auf Name/Nummer/Stadt, Limit 20, Tastaturnavigation (ArrowUp/Down/Enter/Escape), Click-outside-Close, ausgewählter Zustand mit Lieferantennummer-Zeile (Lock-Icon + "Aus Stammdaten"), "Neuen Lieferanten anlegen"-Link im Dropdown-Footer
- **`components/project/create-supplier-inline.tsx`** (neu): Modal-Dialog zum Anlegen eines neuen Lieferanten direkt aus dem Projektformular — Pflichtfelder Lieferantennummer + Name, Duplikat-Prüfung auf Nummer (Inline-Fehler), Ähnlichkeitsprüfung auf Name+Stadt (Warnungsdialog mit "Trotzdem anlegen"), INSERT in `supplier_master_data` mit `created_by`, Toast bei Erfolg
- **`components/project/new-project-form.tsx`**: `<select>` für Lieferant ersetzt durch `<SupplierSearch>` + `<CreateSupplierInline>`; `supplierNumber`-State und alter useEffect entfernt; `selectedSupplier`-State hinzugefügt; Supplier-Name im INSERT-Query wird jetzt aus `selectedSupplier` geholt
- **`components/project/project-edit-form.tsx`**: Dasselbe Replacement wie in new-project-form; bestehender Lieferant wird aus dem `suppliers`-Array vorab befüllt; `supplier_name` wird jetzt auch beim UPDATE mitgeschrieben

## 2026-04-03 – Stammdaten-Refactor: Projekt-Stammdaten + dynamische Formular-Dropdowns

### Neue Dateien

- **`lib/master-data/use-master-data.ts`** (neu): Client-Hook `useMasterDataValues(typeCode)` mit In-Memory-Cache — lädt `master_data_values` nach `typeCode` aus Supabase, teilt Ergebnisse über Session hinweg
- **`lib/master-data/excel-service.ts`** (neu): `downloadMasterDataExcel()` und `parseMasterDataExcel()` — BMW-blauer Header, Vorlagen-Zeile bei leerem Datensatz, Import-Preview mit `create`/`update`-Aktionen

### Überarbeitete Dateien

- **`components/repository/masterdata-home-client.tsx`**: Abteilungen-Karte entfernt; Feiertage-Info-Karte mit Link zu `/kalender/settings` ergänzt; Props-Interface aktualisiert (department-Props entfernt, `projektStammdatenCount` + `projektStammdatenLastUpdated` neu)
- **`app/repository/masterdata/page.tsx`**: `department_master_data`-Abfrage durch `master_data_values`-Abfrage ersetzt; passende Props an `MasterDataHomeClient`
- **`app/repository/masterdata/departments/page.tsx`**: Redirect auf `/repository/masterdata` — Abteilungen-Seite nicht mehr im Stammdaten-Bereich
- **`components/repository/stammdaten-overview-client.tsx`**: Icons und Beschreibungen für alle 15 neuen Type-Codes ergänzt (PROJECT_TYPE, PROJECT_STATUS, KPI_BOARD_DE, BILLING_DENIAL_REASON, WORK_MODE, ASSIGNMENT_STATUS u.a.)
- **`components/project/new-project-form.tsx`**: 5 hardcodierte `<select>`-Dropdowns (KPI Board DE/International, Sprache Kundenzufriedenheit, Verrechnungsgrund, PLT Komplexität) durch `useMasterDataValues()` ersetzt
- **`components/project/project-edit-form.tsx`**: Dieselben 5 Dropdowns wie in new-project-form.tsx auf `useMasterDataValues()` umgestellt

### SQL-Migration (User muss ausführen)

- 15 neue `master_data_types` (ON CONFLICT DO NOTHING)
- Daten aus `project_types`, `project_statuses`, `project_responsibilities` nach `master_data_values` migriert
- Seed-Werte für: KPI_BOARD_DE, KPI_BOARD_INTERNATIONAL, CUSTOMER_SAT_LANGUAGE, BILLING_DENIAL_REASON, PLT_COMPLEXITY, WORK_MODE, ASSIGNMENT_STATUS

### Build

- `npm run build` läuft fehlerfrei durch

## 2026-04-02 – Einsatzplanung: full audit + refactor

### Architecture refactor

- **`components/planning/planning-header.tsx`** (new): extracted sticky KW/day header from `planning-grid.tsx`; wrapped in `React.memo`
- **`components/planning/planning-row.tsx`** (new): extracted per-consultant `<tr>` from `planning-grid.tsx`; wrapped in `React.memo` with custom `rowPropsAreEqual` — rows that aren't involved in a drag-select no longer re-render during mousemove (major perf win)
- **`components/planning/hooks/use-grid-interaction.ts`** (new): isolated drag-select state + document-level mousemove/mouseup handlers out of planning-grid into a reusable hook
- **`lib/planning/compute-tiles.ts`** (new): extracted `computeTiles`, `getCellBg`, `hexToRgba` from planning-grid into pure utility module; `computeTiles` is now called inside each `PlanningRow`'s `useMemo` so it only recomputes for rows whose data changed
- **`lib/planning/assignment-helpers.ts`** (new): extracted `findConsecutiveSpan`, `hasOverlapOnDates`, `datesInRange` from planning-client into testable pure functions
- **`components/planning/planning-grid.tsx`**: slimmed from 672 → 310 lines (shell only — layout, tooltip, hook wiring)
- **`components/planning/assignment-tile.tsx`**: wrapped in `React.memo` to prevent tile re-renders when tile data hasn't changed

### New features (B6)

- **Snap preview line during drag**: thin vertical blue line (`#3B82F6`, `opacity:0.6`) appears at snapped day boundary while dragging a tile; removed on mouseup
- **Conflict detection on tile move**: `handleTileMove` now checks for overlap before writing to Supabase; shows error toast "Terminkonflikt — Eintrag wurde nicht verschoben" on conflict (same pattern as `handleSpanResize`)
- **Undo last move/resize**: after a successful move or resize, an inline undo banner appears for 5 seconds; clicking "Rückgängig" restores previous assignment positions via Supabase update
- **Keyboard shortcuts**: `Delete`/`Backspace` when exactly one assignment is selected triggers delete (with existing `canDeleteAssignment` permission check); `useToast` (existing system at `components/repository/toast`) used for conflict error toasts

### Type fixes

- **`lib/planning-types.ts`**: added optional `user_id?: string | null` and `is_demo?: boolean | null` to `Assignment` interface (fields exist in DB, were causing pre-existing TypeScript errors in `handleSpanResize`)

### Build

- `npm run build` passes with zero TypeScript errors

## 2026-04-02 – Planning grid: permissions + resize insert fix

- **`lib/planning-permissions.ts`**: added `masteradmin` to `PlanningRole` type; `canEditAssignment` and `canDeleteAssignment` now return `true` for `masteradmin`; `canManageMasterData` also granted to `masteradmin`
- **`components/planning/planning-client.tsx`**: `handleSpanResize` insert now uses explicit DB column allowlist (fixes `_syncStatus` and other Dexie-only fields being sent to Supabase); added error logging for resize delete/insert and tile move operations

## 2026-04-02 – Planning grid: complete interaction rewrite

- **`components/planning/assignment-tile.tsx`** (new): self-contained tile component using pointer events only — no HTML5 drag API. `onMouseDown` detects resize zone (left/right 6px edges) vs drag body. Ghost div tracks drag across rows. `document.elementFromPoint` on mouseup resolves target consultant via `[data-consultant-id]`. Right-click fires `onContextMenu` prop directly.
- **`components/planning/planning-grid.tsx`** (rewrite, ~850→~500 lines): stripped of all HTML5 drag/drop, resize state, colSpan assignment cells. Body rows now use a single `<td colSpan={days.length} position:relative>` overlay cell containing: (1) per-day background divs as click targets, (2) `<AssignmentTile>` absolutely positioned at `left = dayIndex * 40`. `data-consultant-id` on each `<tr>` enables cross-row drop detection. Drag-select uses `document.mousemove` + `snapDayIndex` for accurate column tracking.
- **`components/planning/planning-client.tsx`**: replaced `handleCardDrop(assignmentId, consultant, date, grabOffset)` with `handleTileMove(assignments[], newStartDate, newConsultantId)` — simpler signature, no span re-finding needed since tile passes all span assignments directly.

## 2026-04-02 – Planning grid compact cells + drop fix

- `lib/planning/grid-snap.ts`: `SNAP_DAY_COL_W` reduced 90 → 40px for denser month view
- `components/planning/planning-grid.tsx`: cell `minHeight` 56 → 38px; KW badge `text-[9px]`; day header abbrev/number fonts reduced; `onDrop` now uses coordinate math (`snapDayIndex`) so drops on colSpan cells land on the precise target day (not always span-start)
- `components/planning/assignment-card.tsx`: card height 28 → 20px; label `text-[9px]`; `px-1`/`mb-px`; smaller work-mode badge and selection checkbox

## 2026-04-02 – Phase 4: Calendar drag/resize fix + Berichte + Erstbesuch + MDM

### 4.1 Calendar drag/resize
- `lib/planning/grid-snap.ts`: `snapDayIndex`, `calculateDuration`, `offsetDateKey`, `offsetDates`
- `planning-grid.tsx`: `document.mousemove` handler replaces `onMouseEnter` for resize snapping — fixes silent snap failure within `colSpan > 1` cells
- Resize preview now shows the full new extent (not just original start/end cells)
- Multi-day drag: grab offset tracked via `dataTransfer` — dropped day anchors to grabbed day within span
- `planning-client.tsx`: `handleCardDrop` uses grab offset for accurate span placement

### 3.1–3.3 Berichte & Erstbesuch (Phase 3, completed)
- `/projektanlage/berichte`: new page; `/kalender/reports` redirects there
- `PlanningNav`: "Berichte" tab removed
- `berichte-client.tsx`: tabs "Einsatz-Auslastung" + "Projektstatistik" (KIFAG pie, Status donut, Auftragstyp bar)
- Scope toggle (Meine / Globale) applies to both assignment + project charts
- New "Erstbesuch-Zeitraum" section in project create/edit forms
- Conflict check modal before creating calendar blockers
- `source='auto_erstbesuch'` assignments auto-created / deleted / recreated on edit

### 2.x MDM, Holidays, Demo (Phase 2, completed previously)
- Master data tables: `master_data_types`, `master_data_values`, `master_data_suggestions`
- Holiday tables: `holiday_calendars`, `holiday_entries`, `user_holiday_preferences`
- Seed API `/api/holidays/seed` — 5 calendar groups × 8 years (2025-2032)
- `holiday-preferences-section.tsx` on Konto page
- `HolidaysManagementSection` on Kalender > Einstellungen
- Planning grid uses DB holidays (user prefs) with Bavarian fallback

## 2026-04-02 – Demo Daten Phase 3 (Wertstrom, Datenablage, Admin, Badges, Polish)

- Seed: `lib/demo/seeds/wertstrom-seed.ts` — 13-Knoten VSM für Continental (HV-Module Montage)
- Seed: `lib/demo/seeds/datenablage-seed.ts` — 8 Demo-Dokumente (Metadaten, kein Storage-Upload)
- Seed: `lib/demo/seeds/admin-seed.ts` — 10 Audit-Log-Einträge, 5 E-Mail-Queue-Einträge
- API `/api/demo/load`: Wertstrom-, Datenablage- und Admin-Seed integriert
- API `/api/demo/reset`: Wertstrom-Reset und Datenablage/Audit/E-Mail-Löschung ergänzt
- API `/api/demo/status`: 6. Modul `wertstrom` (vsmCount) hinzugefügt
- Neu: `components/demo/demo-suggestion-card.tsx` — modulspezifische "Beispiel laden"-Karte mit localStorage-Dismiss
- DemoBadge in allen Listen: Fabrikanalyse-Tabelle, OEE-Verlaufstabelle (neben KW), Wertstrom-Liste
- `lib/assessment-types.ts`: `is_demo?: boolean | null` zum `Assessment`-Interface ergänzt
- `components/oee/history-table.tsx`: `is_demo` zu `OeeRecord` ergänzt
- `components/wertstrom/vsm-list-client.tsx`: `is_demo` zu `VsmMeta` ergänzt
- Server-Pages: `is_demo` in Supabase-Selects für `/fabrikanalyse` und `/wertstrom` ergänzt
- DemoSuggestionCard auf `/fabrikanalyse` und `/wertstrom` eingebunden
- DemoControlPanel: Bestätigungsdialog vor Reset ("Alle Beispieldaten werden unwiderruflich gelöscht")
- Build: 0 Fehler ✓

## 2026-04-02 – Demo Daten Phase 2 (LSC Workshop, Fabrikanalyse, OEE)

### SQL (manuell ausführen — neue Spalten)
```sql
ALTER TABLE process_steps      ADD COLUMN IF NOT EXISTS is_demo boolean NOT NULL DEFAULT false;
ALTER TABLE cycle_measurements ADD COLUMN IF NOT EXISTS is_demo boolean NOT NULL DEFAULT false;
ALTER TABLE lsc_shifts         ADD COLUMN IF NOT EXISTS is_demo boolean NOT NULL DEFAULT false;
ALTER TABLE lsc_measures       ADD COLUMN IF NOT EXISTS is_demo boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_process_steps_is_demo      ON process_steps(is_demo)      WHERE is_demo = true;
CREATE INDEX IF NOT EXISTS idx_cycle_measurements_is_demo ON cycle_measurements(is_demo) WHERE is_demo = true;
```

### Neue Seed-Dateien
- `lib/demo/seeds/lsc-workshop-seed.ts` — 9 Prozessschritte (2 Bereiche), 63 Messungen, 2 Schichten, 8 Maßnahmen
- `lib/demo/seeds/fabrikanalyse-seed.ts` — 1 Assessment mit deterministischen Responses für alle 54 Fragen (spezifische Kommentare für Q9, Q25, Q37, Q41, Q48, Q52)
- `lib/demo/seeds/oee-seed.ts` — 12 Wochen KW03–KW14 2026, OEE-Records + Loss-Categories (5 Verlusttypen/Woche)

### Aktualisierungen
- `lib/demo/demo-ids.ts` — 12 OEE-IDs, 9 Prozessschritt-IDs, 2 Schicht-IDs, 8 Maßnahmen-IDs
- `lib/demo/demo-service.ts` — DemoModule erweitert: 'lsc-workshop' | 'fabrikanalyse' | 'oee'
- `app/api/demo/load/route.ts` — alle 5 Module implementiert (projektanlage, kalender, lsc-workshop, fabrikanalyse, oee)
- `app/api/demo/reset/route.ts` — korrekte Delete-Reihenfolge für alle Module (FK-Cascades beachtet)
- `app/api/demo/status/route.ts` — 5 Module im Status-Check

## 2026-04-02 – Demo Data & Simulation Mode (Phase 11)

### SQL (manuell ausführen)
- `ALTER TABLE` + `is_demo boolean DEFAULT false` auf 12 Tabellen (projects, project_consultants, assignments, assessments, assessment_responses, oee_records, oee_loss_categories, documents, document_metadata, value_stream_maps, user_audit_log, email_queue)
- 4 partielle Indizes auf `is_demo = true`

### Neue Dateien
- `lib/demo/demo-ids.ts` — stabile deterministischeUUIDs (`dddd0001-…` Präfix)
- `lib/demo/demo-service.ts` — `DemoModule` Typ, Labels, `ALL_DEMO_MODULES`
- `lib/demo/seeds/projektanlage-seed.ts` — 5 Demo-Projekte (Continental, Bosch, ZF, Mahle, Schaeffler)
- `lib/demo/seeds/kalender-seed.ts` — 4 Wochen × 6 Berater Assignments (20 Arbeitstage)
- `app/api/demo/status/route.ts` — GET Zählabfrage aller Demo-Module (admin+)
- `app/api/demo/load/route.ts` — POST Seed-Daten einfügen (upsert, admin+)
- `app/api/demo/reset/route.ts` — POST alle Demo-Daten löschen (admin+)
- `components/demo/demo-badge.tsx` — Amber „Demo" Chip für Listen
- `components/demo/demo-banner.tsx` — Amber Top-Banner wenn Demo aktiv (auto-detect via API)
- `components/demo/demo-control-panel.tsx` — Floating-Button + Modal mit Modul-Toggle, Laden/Zurücksetzen

### Integrationen
- `app/layout.tsx` — `<DemoBanner />` + `<DemoControlPanel role={session.role} />` gemountet
- `app/projektanlage/page.tsx` — `is_demo` in Supabase-Select ergänzt
- `components/projektanlage/project-list-client.tsx` — `DemoBadge` neben Projekt-ID für Demo-Einträge

## 2026-04-02 – Performance, Konto-Verbesserungen, QA (Phase 10)

### 10.1 Performance
- `app/oee/trends/page.tsx` — `next/dynamic` für `OeeTrendCharts` (Code-Splitting)
- `app/oee/verlustanalyse/page.tsx` — `next/dynamic` für `VerlustanalyseClient`
- `app/oee/page.tsx` — `next/dynamic` für `OeeCalculator` (mit Loading-Skeleton)
- `app/wertstrom/[id]/page.tsx` — `next/dynamic` für `VsmEditor` (schwerste Komponente)
- `app/fabrikanalyse/[id]/dashboard/page.tsx` — `next/dynamic` für `AssessmentDashboard`
- `app/reporting/page.tsx` — `next/dynamic` für `ReportingDashboard`
- Projekt-Liste: `PAGE_SIZE = 25` + `useMemo` für Filterung bereits vorhanden bestätigt

### 10.2 Konto-Verbesserungen
- `components/account/account-form.tsx` — ⭐ Masteradmin-Banner: Gold-Gradient + Star-Emoji; "Ihre Rolle:"-Label neben Badge
- Neue Sektion "Über diese App": App-Name, Version 1.0.0, Build-Datum, Plattform, Stack

### 10.3 QA-Ergebnis
- 10 Navigationspunkte mit `t('nav.*')` übersetzt ✓
- Alle Routes vorhanden: /projektanlage, /kalender, /lsc-workshop, /fabrikanalyse, /oee, /wertstrom, /repository, /reporting, /konto, /admin/users ✓
- Sprachumschalter auf Konto-Seite und im User-Menu ✓
- Masteradmin-Badge mit ⭐ sichtbar ✓
- "Über diese App"-Sektion vorhanden ✓
- `npm run build` — 0 Fehler ✓

**SQL für preferred_locale:**
```sql
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS preferred_locale text DEFAULT 'de' CHECK (preferred_locale IN ('de', 'en', 'es', 'zh'));
```

## 2026-04-02 – Internationalisierung DE/EN/ES/ZH (Phase 9)

- `lib/i18n/translations/de.json` — Deutsche Übersetzungen (vollständig, ~70 Schlüssel)
- `lib/i18n/translations/en.json` — Englische Übersetzungen (vollständig)
- `lib/i18n/translations/es.json` — Spanische Übersetzungen (vollständig)
- `lib/i18n/translations/zh.json` — Chinesische Übersetzungen (Vereinfachtes Chinesisch, vollständig)
- `lib/i18n/i18n-context.tsx` — `I18nProvider` + `useI18n` Hook; localStorage-Persistenz; SSR-sicher (hydration-safe: Server rendert immer 'de')
- `lib/i18n/use-translation.ts` — Re-Export: `useTranslation`, `LOCALE_OPTIONS`, `Locale`
- `app/layout.tsx` — `<I18nProvider>` in Provider-Chain eingebunden
- `components/layout/app-shell.tsx` — Alle Navigationslabel durch `t('nav.*')` ersetzt
- `components/layout/user-menu.tsx` — Globe-Icon + Sprachumschalter (inline im Dropdown); `t()` für Menüpunkte
- `components/account/account-form.tsx` — Sprachauswahl-Sektion auf Konto-Seite (4-Button-Grid mit Flaggen)
- **SQL**: `ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS preferred_locale text DEFAULT 'de' CHECK (preferred_locale IN ('de', 'en', 'es', 'zh'));`

## 2026-04-02 – Reporting + Cross-Modul Export-Architektur (Phase 8)

- `lib/export/export-service.ts` — Export-Service mit `exportToPdf`, `exportToPptx`, `exportToXlsx`, `downloadBlob`; nutzt jsPDF 4.x, pptxgenjs 4.x, ExcelJS; BMW-CI-Header in PDF + PPT
- `components/reporting/reporting-dashboard.tsx` — Neues Reporting-Dashboard mit 4 Sektionen: Projektübersicht (Monats-Balken + Status-Donut), Berater-Auslastung (horizontale Bars, letzte 90 Tage), OEE-Übersicht (KPI-Kacheln + Schlechteste-Linien-Tabelle), Fabrikanalyse (Status-Donut + Kategorie-Radar + letzte Assessments)
- `app/reporting/page.tsx` — Umgebaut: fetcht OEE-Records, Assessments, Responses, Questions, Hauptkategorien, Berater, Einsätze; ersetzt `ManagementDashboard` durch `ReportingDashboard`
- `components/oee/verlustanalyse-client.tsx` — PDF- und PPT-Export-Buttons hinzugefügt (neben Excel + Drucken)
- `components/assessment/assessment-dashboard.tsx` — PDF- und PPT-Buttons in Sticky-Header hinzugefügt
- `components/lsc-workshop/lsc-export-client.tsx` — Deaktivierten PPT-Platzhalter durch funktionsfähigen pptxgenjs-Export ersetzt
- `components/export/export-client.tsx` — „PDF Projektdatenblatt"-Button hinzugefügt

## 2026-04-02 – Wertstrom (Value Stream Map) Modul (MO-26)

- `MO-26/wertstrom-schema.sql` — DB-Schema: Tabelle `value_stream_maps` mit RLS-Policy
- `lib/vsm-types.ts` — TypeScript-Typen: `VsmNode`, `VsmConnection`, `ValueStreamMap`, `NodeType`
- `app/api/wertstrom/route.ts` — GET (Liste) + POST (Erstellen) API-Route
- `app/api/wertstrom/[id]/route.ts` — PUT (Aktualisieren) + DELETE API-Route
- `app/wertstrom/page.tsx` — Server-Seite: Listenansicht aller Wertstromanalysen
- `app/wertstrom/[id]/page.tsx` — Server-Seite: Editor-Seite mit Prozessschritten & Taktzeit
- `components/wertstrom/vsm-list-client.tsx` — Listenansicht mit Erstellen/Löschen-Modals
- `components/wertstrom/vsm-editor.tsx` — Vollbild-Canvas-Editor: Drag-and-drop, Pan/Zoom, Verbindungen, Engpass-Erkennung, Wertschöpfungsanalyse, Excel-Import, Stoppuhr-Import, Auto-Save
- `components/layout/app-shell.tsx` — Sidebar-Navigation: Wertstrom-Eintrag mit `GitBranch`-Icon ergänzt

## 2026-04-02 – OEE Calculator Phase 6

**6.1 Reset-Button:**
- "Zurücksetzen"-Button (roter Outline, Trash2-Icon) im Header der "Notizen & Speichern"-Sektion
- Inline-Bestätigungsdialog "Alle Eingaben zurücksetzen?" mit Ja/Abbrechen
- Setzt alle Felder auf INITIAL_FORM zurück, inkl. overrideAllTime/overrideScrap

**6.2 Beispieldaten laden:**
- "Beispieldaten"-Button (blauer Outline, Lightbulb-Icon) neben Reset-Button
- Befüllt Formular mit realistischen Beispielwerten (Beispielwerk München, Montagelinie 1, 2 Schichten, 60 min Pause, 750/720 Mengen etc.)
- KW und Jahr werden zur Laufzeit gesetzt (immer aktuell)

**6.3 Erweiterte Tooltips:**
- Tooltips für alle OEE-Felder ergänzt (vorher nur ca. 5 Felder)
- Abgedeckt: Lieferant, Projekt, Werk, Linie, Maschine, KW, Jahr, Produkt, Arbeitstage, Std./Tag, Schichten, Pausen, Rüstzeit, Wartung, Sonstige Verluste, Zeitbasis-Radio, Zykluszeit, Produzierte Menge, Gutmenge, Nacharbeit

**6.4 Jahresübergreifende Datumsauswahl (KW von–bis):**
- Trendanalyse (`oee-trend-charts.tsx`) und Verlustanalyse (`verlustanalyse-client.tsx`) erhalten KW/Jahr von-bis Selektor
- Ersetzt den bisherigen "Jahr"-Dropdown
- Standard: letzte 12 Wochen (berücksichtigt Jahreswechsel)
- X-Achse zeigt "KW01/26"-Format für klare Jahresidentifikation
- Filterung vollständig client-seitig auf den bereits geladenen Daten

**6.5 Diagramm-Erklärungen:**
- Expandierbarer "Was zeigt dieses Diagramm?"-Abschnitt unter Verlust-Kaskade und Pareto-Analyse
- Beschreibt die Aussage und Lesart des jeweiligen Diagramms

## 2026-04-02 – Fabrikanalyse Phase 5

**5.1 Landing page (/fabrikanalyse) komplett überarbeitet:**
- "Meine Analysen" — Karten-Grid (max 6) für Assessments die der aktuelle Nutzer erstellt hat
- "Alle Analysen" — vollständige Tabelle: Titel, Lieferant, Standort, Erstellt von (Chip), Status, Durchgeführt am, Letzte Änderung, Aktionen
- Filter-Toolbar: Status-Tabs (Alle / Entwurf / In Bearbeitung / Abgeschlossen)
- Volltextsuche über Titel, Lieferant, Standort, Assessment-Nr.
- "Auswertung"-Button pro Zeile → `/fabrikanalyse/[id]/dashboard`
- Create-Modal erweitert: neues Pflichtfeld "Lieferant" (supplier_name)
- Server-Komponente liest `creatorMap` aus `consultants` für Berater-Chip-Anzeige

**5.2 Admin-Verbesserungen:**
- Kategorien-Tab: "Neue Hauptkategorie"-Button + Modal (Code, Label, Reihenfolge)
- Hauptkategorien: `is_active`-Toggle per Klick (Inaktiv = durchgestrichen)
- Import/Export: "Ergebnisse als PDF"-Button (window.print), "Ergebnisse als PowerPoint"-Placeholder (disabled)
- Kataloq-Export-Button umbenannt: "Katalog als Excel"

**5.3 Assessment-Workspace:**
- "Auswertung & Export"-Button in Header — BMW Blue, prominent mit Label (sm+)
- Löst den vorherigen unsichtbaren Icon-Link ab

**SQL-Migration** (`MO-25/assessment-schema-v2.sql`):
- `assessments.supplier_name text` — Lieferantenname direkt am Assessment
- `assessments.conducted_at date` — tatsächliches Durchführungsdatum
- `assessments.project_id uuid` — optionale Verknüpfung mit Projekt

## 2026-04-01 – Kalender/Planning UX Überarbeitung (Phase 4)

**4.1 Planning-Grid Verbesserungen:**
- Bayerische Feiertage ergänzt (`getBavarianHolidays` in `lib/german-holidays.ts`): Heilige Drei Könige, Fronleichnam, Mariä Himmelfahrt, Allerheiligen
- Aktueller Benutzer steht immer an erster Stelle in der Konsultantenliste
- Zeilenhervorhebung: Klick auf Mitarbeitername togglet blaues Highlight (multi-select), "Hervorhebung löschen"-Button in Legende
- KW-Label linksbündig über dem Montag (statt zentriert über der ganzen Woche)
- Legende erweitert: Wochenende + Feiertag als Kategorien ergänzt

**4.2 Assignment-Tooltip & Karten:**
- Tooltip neu strukturiert: Zeitraum (formatiertes Datum), Reise/Kunde, Arbeitsort, Status — zweispaltig mit Labels
- Arbeitsort-Badge (HO/VO/RE) oben rechts im Tooltip-Header
- Assignment-Karte: Arbeitsort-Dot ersetzt durch farbiges HO/VO/RE-Textbadge

**4.5 Kalender Berichte:**
- "Meine Auswertung" / "Globale Auswertung" Tab-Umschalter für Admin/Team-Lead
- Alle Charts, Karten und Exporte filtern nach gewählter Ansicht
- Nicht-Admins sehen nur eigene Daten ohne Umschalter

**4.6 Kalender Mitarbeiter:**
- Spaltenbezeichnung "Team" → "Abteilung", Formular-Label "Team-Code" → "Abteilung"
- "Mein Konto"-Button neben Auth-User-ID-Feld füllt automatisch die eigene ID ein
- Nicht-Admins ohne Profil sehen "Mein Profil anlegen"-Button → erstellt Eintrag mit `is_active: false` (Ausstehend)

**4.7 Kalender Projekte:**
- Tab leitet direkt zu `/projektanlage` weiter (Server-Side Redirect)

**4.8 Kalender Einstellungen:**
- RoleGuard: Nur Admins können die Seite aufrufen, andere werden zu `/kalender` weitergeleitet

## 2026-04-02 – LSC Workshop: Vollständiges Modul (7 Unterseiten)

**SQL** (`MO-LSC/lsc-schema.sql`):
- `cycle_measurements`: +`time_type` (7 Typen), +`machine_time_sec`, +`manual_time_sec`
- `process_steps`: +`area_name` (Bereichsgruppierung)
- `project_consultants`: CHECK-Constraint erweitert auf `lead|collaborator|einkauf|qmt|cost_engineering`
- Neue Tabelle `lsc_measures` (Maßnahmen mit Effort/Benefit/Matrix-Position, RLS)
- Neue Tabelle `lsc_shifts` (Schichtmodell pro Bereich, 1–3 Schichten, RLS)

**Komponenten**:
- `components/lsc-workshop/lsc-overview-client.tsx`: Kopfdaten-Karte (Kundentakt/Target CT inline editierbar, Lieferant, Status, Team-Chips), 6 Quick-Link-Karten zu allen Unterseiten
- `components/lsc-workshop/lsc-stopwatch-client.tsx`: Erweiterter Stoppuhr-Client — Bereichs-Tab-Navigation, 7 Zeitarten (farbkodiert), Maschinenzeit-Split-Modus (3 Phasen), ausklappbares REFA-Glossar, Messfehler-Warnpanel, KPI-Strip (Ø CT, Anzahl, vs. Takt)
- `components/lsc-workshop/lsc-shift-client.tsx`: Schichtmodell — Bereichsverwaltung, bis zu 3 Schichten/Bereich, Auto-Berechnung Netto-Produktionszeit, Ausbringungs-Schätzung auf Basis CT
- `components/lsc-workshop/lsc-massnahmen-client.tsx`: Maßnahmen-Tabelle + Aufwand-Nutzen-Matrix (2×2 Quadrant: Quick Wins / Strategisch / Füllermaßnahmen / Überdenken), Inline-Statusänderung, Erstellformular mit 1–5 Bewertungen
- `components/lsc-workshop/lsc-export-client.tsx`: Print-Bericht (Stammdaten, Team, Zykluszeiten-Tabelle mit Bottleneck, Schichtmodell, Maßnahmentabelle), PowerPoint-Platzhalter
- `components/project/team-client.tsx`: `extraRoles` Prop — Rollenauswahl dynamisch erweiterbar, Rollenbadges farbkodiert

**Seiten** (alle unter `/lsc-workshop/[id]/`):
- `page.tsx`: Verwendet `LscOverviewClient`
- `stoppuhr/page.tsx`: Lädt Steps + Measurements mit neuen Feldern → `LscStopwatchClient`
- `schicht/page.tsx`: Lädt `lsc_shifts` + berechnet Ø CT → `LscShiftClient`
- `massnahmen/page.tsx`: Lädt `lsc_measures` → `LscMassnahmenClient`
- `dokumente/page.tsx`: `ProjectDocumentsSummary` + `QafClient` direkt eingebunden
- `team/page.tsx`: `TeamClient` mit `extraRoles` (Einkauf, QMT, Cost Engineering)
- `export/page.tsx`: Lädt alle Workshop-Daten → `LscExportClient`

## 2026-04-01 – Projektanlage: Enterprise UX Phase 2

- `app/projektanlage/page.tsx`: Lädt aktuellen Berater-Datensatz (`currentConsultant`); `user_id` in SELECT; `currentConsultantId` an ProjectListClient übergeben
- `components/projektanlage/project-list-client.tsx`: Neue „Meine Projekte" Kartenraster (max. 6, „Alle anzeigen"-Link); leerer Zustand; `user_id` in `ProjectRow`; Titelzeile auf „Alle Projekte" geändert
- `components/project/new-project-form.tsx`: 2-Spalten-Layout (Auftragstyp/Status, Verantwortung/Projektleiter, 3-Spalten-Datum); Projektleiter MO-2x als Pflichtfeld; Lieferantennummer auto-fill aus `supplier_number` (readonly); 3-Spalten Fahrzeug/Produktlinie/Motor; 2-Spalten KPI Boards; Team-Abschnitt bereinigt
- `components/project/project-edit-form.tsx`: Identische Layout-Änderungen wie new-project-form; Lieferantennummer auto-fill; Projektleiter Pflichtfeld
- `app/project/[id]/page.tsx`: Vollständige schreibgeschützte Übersicht — Grunddaten / Lieferant / KPI & Zuordnung / Team Abschnitte; Bearbeiten-Link in Kartenheader; `supplier_number` und alle Felder sichtbar; Team-Badges (Projektleiter blau, Berater hellblau)

## 2026-04-01 – LSC Workshop: Erstklassiges Navigationsmodul + Masteradmin-Badge

- `components/layout/app-shell.tsx`: LSC Workshop (Wrench-Icon) in BASE_ITEMS eingefügt (zwischen Kalender und Fabrikanalyse); `BOTTOM_ITEMS` vor `MOBILE_ITEMS` verschoben; Mobile-Tabs nutzen jetzt `MOBILE_ITEMS` (5 Einträge statt 7)
- `components/ui/breadcrumb.tsx`: Neu — wiederverwendbare Breadcrumb-Komponente mit ChevronRight-Separatoren, BMW CI
- `components/lsc-workshop/lsc-workshop-header.tsx`: Neu — Sticky Dark-Header mit Zurück-Arrow, Projekt-Code, Lieferantenname, Edit-Link + Tab-Navigation (Übersicht, Stoppuhr, Schicht, Maßnahmen, Dokumente, Team, Export)
- `app/lsc-workshop/page.tsx`: Neu — Landingpage mit „Meine Workshops" / „Alle Workshops"-Sektionen, gefiltert nach `project_types.code = 'lsc_workshop'`
- `app/lsc-workshop/[id]/layout.tsx`: Neu — Shared Layout für alle /lsc-workshop/[id]/* Seiten (AppShell + LscWorkshopHeader)
- `app/lsc-workshop/[id]/page.tsx`: Neu — Übersichtsseite mit Workshop-Details
- `app/lsc-workshop/[id]/stoppuhr/page.tsx`: Neu — Weiterleitung zu /project/[id]/stopwatch
- `app/lsc-workshop/[id]/schicht/page.tsx`: Neu — Weiterleitung zu /project/[id]/shift-output
- `app/lsc-workshop/[id]/massnahmen/page.tsx`: Neu — Weiterleitung zu /project/[id]/workshop
- `app/lsc-workshop/[id]/dokumente/page.tsx`: Neu — Weiterleitung zu /project/[id]/dokumente
- `app/lsc-workshop/[id]/team/page.tsx`: Neu — Weiterleitung zu /project/[id]/team
- `app/lsc-workshop/[id]/export/page.tsx`: Neu — Weiterleitung zu /project/[id]/export
- `components/account/account-form.tsx`: Masteradmin-Banner (dunkelblau, ShieldCheck-Icon) oberhalb des Profils, nur sichtbar wenn `profile.role === 'masteradmin'`
- `npm run build`: Kompiliert ohne Fehler — alle 61 Seiten generiert

## 2026-04-01 – OEE Modul Phase 3: Verlustanalyse + Export + Projektverlinkung

- `app/oee/verlustanalyse/page.tsx`: Vollständige Verlustanalyse — exportiert `OeeDetailRecord` Interface, lädt alle Zeitfelder
- `components/oee/verlustanalyse-client.tsx`: Client-Wrapper mit Filtern, Verlust-Kaskade, Pareto, Trend, Benchmarking, Export+Drucken
- `components/oee/loss-waterfall.tsx`: Wasserfalldiagramm (recharts stacked bars) mit `buildWaterfallData()`
- `components/oee/loss-pareto.tsx`: Pareto-Chart (horizontal bars + kumulative Linie, 80%-Referenzlinie) mit `buildParetoItems()`
- `components/oee/loss-trend.tsx`: Stacked-Area-Trend (6 Verlustarten) mit `buildLossTrend()`
- `components/oee/loss-details-table.tsx`: Editierbare Verlust-Tabelle mit Inline-Beschreibung → speichert in `oee_loss_categories`
- `components/oee/oee-benchmarking.tsx`: Benchmarking-Karte (Branchendurchschnitt 60%, Unternehmensziel, World-Class 85%)
- `components/oee/oee-export-button.tsx`: Excel-Export (ExcelJS) für Kalkulator, Trendanalyse, Verlustanalyse — BMW-Blau-Header, Auto-Breite, eingefrorene Zeile
- `components/oee/oee-calculator.tsx`: „Vorwoche als Vorlage" + Info wenn keine Daten, „berechnet"-Badge + Überschreiben-Button für allTimeMin + Ausschuss, Export+Drucken-Buttons
- `components/oee/oee-trend-charts.tsx`: Export+Drucken-Buttons hinzugefügt
- `app/project/[id]/oee/page.tsx`: Neue Projekt-OEE-Tab-Seite — zeigt verknüpfte OEE-Datensätze pro Linie
- `app/project/[id]/layout.tsx`: Prüft `oee_records.project_id` → gibt `hasOeeRecords` an ProjectTabs weiter
- `components/project/project-tabs.tsx`: Zeigt „OEE Analysen"-Tab wenn `hasOeeRecords === true`
- `app/globals.css`: Print-CSS für saubere Druckansicht (@media print)
- `MO-OEE/oee-phase3.sql`: UNIQUE-Constraint auf `oee_loss_categories(oee_record_id, loss_type)`

## 2026-04-01 – OEE Modul Phase 2: Trendanalyse + Wochenvergleich + Charts

- `app/oee/layout.tsx`: Gemeinsames Layout für alle /oee/* Seiten — AppShell, AppHeader, OeeTabNav
- `app/oee/page.tsx`: MiniDashboard oberhalb des Kalkulators (id="calculator" Anker), AppShell/AppHeader aus Layout
- `app/oee/trends/page.tsx`: Trendanalyse-Seite — exportiert `OeeTrendRecord` Interface, lädt erweiterte Felder (theoretical_output, installed_capacity, purchased_capacity, unplanned_downtime_min, target_oee)
- `app/oee/verlustanalyse/page.tsx`: Platzhalter-Seite für Verlustanalyse
- `components/oee/oee-tab-nav.tsx`: Tab-Navigation Kalkulator | Trendanalyse | Verlustanalyse
- `components/oee/mini-dashboard.tsx`: KPI-Minikartenstreifen mit Sparklines (Recharts), Delta-Pfeile, Linienfilter
- `components/oee/weekly-comparison.tsx`: Wochenvergleichskarte — 5 Metriken mit Δ-Zellen, Hauptverlustursache
- `components/oee/oee-trend-charts.tsx`: Zwei Recharts-Diagramme (OEE-Trend + Ausbringung vs. Kapazität), Toggle-Buttons, automatische Analyse-Textgenerierung
- `lib/oee/interpretation.ts`: Reiner TS-Interpretationsengine — OEE vs. Ziel, Hauptverlustquelle, Woche-über-Woche, 4-Wochen-Trend

## 2026-04-01 – OEE Modul Phase 1: Kalkulator + KPI-Karten + Verlaufstabelle

- `MO-OEE/oee-phase1.sql`: Migration für `oee_records` + `oee_loss_categories` (RLS, Indizes, updated_at-Trigger)
- `lib/oee/engine.ts`: Reiner TS-Kalkulator — `calculateOee()`, `formatPercent()`, `formatMinutesAsHours()`, 6 Warnbedingungen
- `components/oee/kpi-cards.tsx`: 2×2 KPI-Grid (OEE-Ring SVG, Verfügbarkeit/Leistung/Qualität-Balkenkarten), 4 Info-Karten, Δ-Vorwochenkarte
- `components/oee/formula-cascade.tsx`: Wasserfalldiagramm — Zeitkaskade bis OEE-Formel, farbkodiert
- `components/oee/oee-warnings.tsx`: Fehler/Warn/Info-Badges aus Kalkulator-Engine
- `components/oee/history-table.tsx`: Verlaufstabelle mit Filter (Linie, Jahr, Status), Laden-Aktion
- `components/oee/oee-calculator.tsx`: Haupt-Client-Komponente — 5-Abschnitt-Formular links, KPI-Karten + Kaskade rechts (sticky), Supabase upsert, Vorwoche laden
- `app/oee/page.tsx`: Server-Page, lädt Lieferanten + Projekte + Verlaufsdaten
- `components/layout/app-shell.tsx`: "OEE Analyse" (Gauge-Icon) in BASE_ITEMS nach Fabrikanalyse

## 2026-03-31 – Phase 2: Neue Projektanlage-Maske + Projektliste

- `app/api/projects/next-id/route.ts`: Neu — POST-Endpunkt für automatische Projekt-ID via `next_project_id()` RPC
- `components/ui/date-picker-kw.tsx`: Neu — Datumseingabe mit KW-Anzeige (ISO-8601, ohne externe Bibliothek)
- `app/project/new/page.tsx`: Server-Page lädt 6 Lookup-Tabellen parallel (project_types, project_statuses, project_responsibilities, suppliers, consultants, departments)
- `components/project/new-project-form.tsx`: Komplett neu — 8 Abschnitte (Grunddaten, Verantwortung, Lieferant, Zuordnung, Kommunikation, Verrechnung, Auftragshintergrund, Typspezifische Felder); Projekt-Code-Autogenerierung; MultiSelectField für Mitarbeiter; sticky Footer
- `components/project/project-edit-form.tsx`: Komplett neu — identische Struktur wie Anlageformular, vorausgefüllt; UPDATE + DELETE/INSERT project_consultants; Löschen-Button
- `app/project/[id]/edit/page.tsx`: Lädt alle 6 Lookup-Tabellen + project_consultants; AppShell entfernt (Layout übernimmt)
- Bugfix: `status: 'active'` aus INSERT entfernt (Spalte existiert nicht); `supplier_name` aus Lookup statt leerem String

## 2026-03-31 – Step 3: Globale Routen-Umbenennungen

- `/dashboard` → `/projektanlage`: Neue Route + Redirect-Stub
- `/planning` → `/kalender`: Neue Route + Redirect-Stubs für alle Unterseiten
- `/assessment` → `/fabrikanalyse`: Neue Route + Redirect-Stubs für alle Unterseiten
- `/management` → `/reporting`: Neue Route + Redirect-Stub
- `/account` → `/konto`: Neue Route + Redirect-Stub
- `app/page.tsx`: Redirect zu `/projektanlage`
- `components/planning/planning-nav.tsx`: Alle `/planning/*`-Links auf `/kalender/*` aktualisiert
- Assessment-Komponenten: Alle `/assessment/*`-Links auf `/fabrikanalyse/*` aktualisiert
- `components/layout/user-menu.tsx`: Links auf `/reporting` und `/konto` aktualisiert
- `components/layout/app-header.tsx`: Logo-Link auf `/projektanlage` aktualisiert
- `components/layout/app-shell.tsx`: Komplett überarbeitet — Fabrikanalyse, Kalender, Datenablage, Reporting (Admin+), Admin-Abschnitt (ausklappbar), Konto unten; Mobile-Tabs mit 5 Hauptpunkten

## 2026-03-31 – Phase 4: Security Hardening + Audit Trail

- `lib/auth/password-validation.ts`: Neu — `validatePassword()` (5 Regeln), `getPasswordStrength()` ('weak'|'medium'|'strong')
- `components/auth/password-strength-bar.tsx`: Neu — 3-Segment-Stärkebalken (rot/gelb/grün)
- `app/(auth)/login/page.tsx`: Neuschrift auf Deutsch; Failed-Login-Audit; 5-Versuch-Sperrung via POST `/api/admin/users/lock`; statusabhängige Fehlermeldungen (locked/deactivated/inactive); Erfolgs-Audit; Redirect zu `/projektanlage`
- `lib/supabase/proxy.ts`: `must_change_password`-Erzwingung — Redirect zu `/auth/change-password` falls gesetzt
- Bugfix `components/admin/edit-user-modal.tsx`: `rolesResolved` vor allen Hooks berechnet; `roleId`-State initialisiert aus `rolesResolved?.code`; `<option value={r.code}>` (Codes werden serverseitig aufgelöst)
- Bugfix `app/api/admin/users/route.ts`: Äußerer try/catch; `createAdminClient()` in try/catch; `status: 'active'` entfernt; Rollen-Code→UUID-Auflösung per Regex in POST + PATCH

## 2026-03-31 – Bugfixes: SyncIndicator + ThemeProvider

- `components/offline/sync-indicator.tsx`: Alle Hooks (useSyncStatus, useIsOnline, useState×2, useRef, useEffect×2) an den Komponentenanfang verschoben — vor jeden bedingten Return; `Circle`-Import für Placeholder-Icon
- `components/theme-provider.tsx`: Null-Check für `event.key` — `if (!event.key || event.key.toLowerCase() !== "d")`

## 2026-03-31 – Phase 3: Role-Based View Configuration + Project List Refactoring

- `lib/auth/permissions-shared.ts`: `roleId` zu `UserSession` hinzugefügt
- `lib/auth/permissions.ts`: `roleId` in `getUserSession()` gesetzt
- `lib/views/view-config.ts`: Neu — `ViewConfig`-Interface, `getViewConfigForRole()`, `saveViewConfig()`
- `app/api/admin/views/route.ts`: POST-Handler zum Speichern von Ansichtskonfigurationen (admin+ only)
- `components/admin/views-client.tsx`: Admin-UI zum Konfigurieren von Rollen-Ansichten mit Modal
- `app/admin/views/page.tsx`: Server-Page für Ansichtskonfiguration
- `components/layout/app-shell.tsx`: "Ansichten" Nav-Item im Admin-Bereich hinzugefügt (LayoutList-Icon)
- `app/projektanlage/page.tsx`: Komplett neu — nutzt `ViewConfig`, übergibt Daten an `ProjectListClient`
- `components/projektanlage/project-list-client.tsx`: Neu — Vollständige Projektliste mit Filter, Sortierung, Pagination
- `app/project/[id]/layout.tsx`: Neu — Shared Layout für alle Projekt-Unterseiten mit `ProjectTabs`
- `components/project/project-tabs.tsx`: Neu — Sticky Header + Tab-Navigation mit typabhängigen Tabs
- `app/project/[id]/page.tsx`: Umgebaut — kein AppShell mehr, zeigt Projektdetails + LSC-Workflow
- `app/project/[id]/edit/page.tsx`: AppShell + AppHeader entfernt (Layout übernimmt)
- `app/project/[id]/stopwatch`, `shift-output`, `workshop`, `qaf`, `export`: AppShell + AppHeader entfernt
- `app/project/[id]/dokumente/page.tsx`: Neu — Dokumente-Tab
- `app/project/[id]/team/page.tsx`: Neu — Team-Tab

## 2026-03-31 – Phase 3: Duplicate Detection Engine

- Erstellt `lib/duplicates/fuzzy.ts` — Levenshtein-Distanz, normalizedSimilarity (0–1), isFuzzyMatch (Default-Threshold 0.8)
- Erstellt `lib/duplicates/detection.ts` — detectDuplicates() mit exaktem, Composite- und Fuzzy-Matching; gibt Map<incomingIndex, DuplicateMatch[]> zurück
- Erstellt `lib/duplicates/configs.ts` — Registry für DuplicateCheckConfig; vorkonfiguriert für supplier, consultant, department, user
- Erstellt `lib/duplicates/merge.ts` — Server Action mergeRecords(): Admin-Auth-Check, Feldauflösung, Supabase-Update, Audit-Log-Eintrag
- Erstellt `components/duplicates/merge-dialog.tsx` — Modal zum Zusammenführen zweier Einträge mit Feldauswahl per Radio-Buttons und Ergebnis-Vorschau
- Erstellt `components/duplicates/duplicate-review-step.tsx` — Wiederverwendbarer Schritt für Import-Wizards; zeigt alle Duplikate, Konfidenz-Badges, Aktionen (merge/skip/create_anyway)
- Geändert `components/repository/supplier-master-data-client.tsx` — 4-Schritte-Import; Duplikat-Check beim manuellen Anlegen mit Warnmodal (Schwellenwert ≥80%)
- Geändert `components/repository/department-master-data-client.tsx` — 4-Schritte-Import analog zu supplier
- Geändert `components/repository/consultant-master-data-client.tsx` — 4-Schritte-Import analog zu supplier
- Geändert `components/assessment/admin/import-export-tab.tsx` — Duplikat-Schritt zwischen Parsing und Preview
- Geändert `components/admin/create-user-modal.tsx` — E-Mail-onBlur-Prüfung mit Inline-Warnung bei bereits vorhandener E-Mail

## 2026-03-31 – MO-26: User & Permission System (Phase 1)

- Erstellt `MO-26/user-permissions.sql` — vollständige Migration für Rollen, Berechtigungen, user_profiles, user_audit_log inkl. RLS, Trigger, Seeding und Consultant-Migration
- Erstellt `lib/auth/permissions-shared.ts` — client-sichere Typen und reine Hilfsfunktionen (RoleCode, UserSession, hasPermission, isAtLeastRole, canManageUser)
- Erstellt `lib/auth/permissions.ts` — server-only getUserSession() mit Supabase-Abfragen
- Erstellt `lib/auth/session-context.tsx` — React Context (UserSessionProvider, useUserSession)
- Erstellt `lib/auth/hooks.ts` — Client-Hooks (usePermission, useRole, useIsAdmin, useIsMasteradmin)
- Erstellt `lib/supabase/admin.ts` — Service-Role-Client (createAdminClient)
- Erstellt `components/auth/permission-guard.tsx` — PermissionGuard und RoleGuard Komponenten
- Erstellt `components/auth/require-password-change.tsx` — Fullscreen-Overlay zur erzwungenen Passwortänderung mit Stärke-Indikatoren
- Erstellt `app/api/admin/users/route.ts` — POST/PATCH/DELETE für Benutzerverwaltung
- Erstellt `app/api/admin/users/reset-password/route.ts` — Passwort-Reset-Endpunkt
- Erstellt `app/api/admin/users/update-email/route.ts` — E-Mail-Änderungs-Endpunkt
- Erstellt `app/admin/users/page.tsx` — Server-Seite Benutzerverwaltung (nur Admin+)
- Erstellt `app/admin/users/directory/page.tsx` — Server-Seite Benutzerverzeichnis (alle authentifizierten Nutzer)
- Erstellt `components/admin/users-client.tsx` — Enterprise-Benutzertabelle mit Filtern, Badges, Aktionen
- Erstellt `components/admin/create-user-modal.tsx` — Modal für Neuanlage mit rollengefilterter Auswahl und Initial-Passwort-Anzeige
- Erstellt `components/admin/edit-user-modal.tsx` — Bearbeitungsmodal mit E-Mail-Änderungsfunktion
- Erstellt `components/admin/user-directory-client.tsx` — Readonly-Verzeichnis mit CSV-Export (Admin+)
- Aktualisiert `app/layout.tsx` — UserSessionProvider + RequirePasswordChange eingebunden
- Aktualisiert `app/account/page.tsx` — Nutzt jetzt user_profiles statt auth.getUser()
- Aktualisiert `components/account/account-form.tsx` — Neugestaltung mit Rollenbadge, Profil aus user_profiles, Passwort-Stärke-Anzeige
- Aktualisiert `components/layout/app-shell.tsx` — Admin-Navigationselement für Admin+/Masteradmin
