# Loop 3: der Katalog ist gebaut und nicht angeschlossen

Vorbefund vor dem Bau, gemessen gegen `main` = `d76672b` (06.08.2026).

## Warum es dieses Dokument gibt

Die Roadmap (`baseline-inputs/06_Autonomous_Loop_Roadmap_v1.0.md` §7) beschreibt
Loop 3 als Aufgabenliste, die mit „`differences-from-material.ts` implementieren"
beginnt. Die Messung sagt: **das ist die kleinere Hälfte.** Der tragende Teil ist
die Verdrahtung eines Katalogs, der fertig im Repository liegt und nirgends
aufgerufen wird.

Dieses Dokument hält die Messung fest, **bevor** gebaut wird — nach der Lehre aus
Loop 2, dass Auftragslisten altern und das Repository nicht. Bei Punkt D wurde
beinahe doppelt gebaut, weil die Liste als offen führte, was seit `#435` erledigt
war.

Jede Zahl hier ist mit ihrer Belegstelle versehen. Zeilenangaben altern; wer sie
prüft, sucht besser nach dem genannten Ausdruck als nach der Zeile.

## Was die Roadmap richtig sagt

`differences-from-material.ts` und `differences-from-manufacturing.ts`
**existieren nicht.** Vorhanden sind nur:

| Datei | Zeilen |
|---|---:|
| `lib/qaf-differences/internal/differences-from-findings.ts` | 97 |
| `lib/qaf-differences/internal/differences-from-tooling.ts` | 126 |

Hier ist die Liste also nicht veraltet. Der Kern von Loop 3 ist wirklich nicht
begonnen.

**Was die Roadmap dagegen nicht sagt:** „bestehende Tooling- und
Finding-Übersetzungen prüfen" ist keine Test-Lücke.
`differencesFromTooling` hat genau einen Export, und
`internal/__tests__/all-differences.test.ts` trägt bei
`describe('differencesFromTooling')` einen eigenen Block mit **7 `it()`-Fällen und
12 `expect()`-Aufrufen**, **ohne** `skipIf` — läuft also in der CI. Drei
Golden-Case-Suiten decken das Modul zusätzlich ab (env-gated). (Eine erste
Fassung sagte „sieben Zusicherungen", was zwischen Testfällen und Assertions nicht
unterschied; ein Review hat beides ausgezählt.)

Eine erste Fassung dieses Befunds behauptete „keine Testdatei, also der billige
erste Schritt". Das war eine Namenssuche, keine Abdeckungsmessung: eine Datei
`differences-from-tooling.test.ts` gibt es nicht, Abdeckung schon. Die
Roadmap-Aufgabe ist damit eine **fachliche** Prüfung der Übersetzungsregeln gegen
die Spezifikation — die aufwändigere Lesart, aber die richtige.

## Der eigentliche Befund: die Kette endet vor der echten UI

Exit-Kriterium 1 von Loop 3 lautet „jede UI-Aussage referenziert Difference IDs".
Heutiger Stand: **keine datenbankgespeiste UI-Aussage tut es.**

> **Präzisiert nach einem Review.** Eine erste Fassung schrieb „keine einzige tut
> es". Das ist widerlegbar, und ein Review hat es widerlegt: **`app/qaf-differences/referenz/page.tsx`
> zeigt Difference IDs.** Die Kette dort lautet `REFERENCE_CASES` →
> `buildReferenceView` (`reference-case.ts`) → `buildKpiTiles`/`evidenceFor` →
> `qaf-v2-reference-section.tsx`, und `:52` rendert sichtbar
> `Zellnachweis · {fall.evidenceForId}`. In `reference-case.ts:63-77` stehen
> nicht-leere Listen (`differenceIds: ['MAT-0001']` und weitere). Ein bestehender,
> grüner Test hält das fest („U-01: jede Kachel trägt ihre Quelle mit sich").
>
> Diese Seite ist aber ausdrücklich **kein echter Vergleich**. Sie trägt ein
> sichtbares Banner: „Beispieldaten, kein echter Vergleich. Alle Namen, Zahlen
> und Zellbezüge sind frei erfunden." Die Aussage bleibt also inhaltlich stehen,
> muss aber richtig zugeschnitten werden — und die Referenzseite ist dabei nicht
> ein Gegenbeispiel, sondern die **Vorlage** für die Verdrahtung (siehe unten).

| Schritt | Belegstelle |
|---|---|
| V2-Seite importiert den Produzenten | `app/qaf-differences/[id]/v2/page.tsx:19` — `import { buildOverviewRun } from '@/lib/qaf-differences/internal/overview-run'` |
| und ruft ihn auf | dieselbe Datei, `:70` — `const run = buildOverviewRun({…})` |
| Produzent setzt die IDs **fest leer** | `overview-run.ts:121` (KPI-Kacheln), `:180` (Wasserfall-Schritte) — beide `differenceIds: []` |
| Anzeige verarbeitet sie folgenlos | `chart-specs.ts:117` und `:192` — `[...(x.differenceIds ?? [])].sort()` auf einer leeren Liste |
| Katalog ist nicht angeschlossen | `assignDifferenceIds`, `buildCatalog`, `buildTraceabilityIndex`, `DifferenceCatalog` kommen außerhalb von `all-differences.ts` **ausschließlich in Testdateien** vor |
| Barrel kennt ihn nicht | `lib/qaf-differences/index.ts`: 182 Export-Anweisungen mit 1048 benannten Symbolen, **null** Verweise auf `all-differences` |

**Und diese Kette wird aus der Datenbank gespeist**, ist also der echte Pfad:
`page.tsx:42-57` liest `qaf_comparison`, `qaf_file` und `qaf_summary_metric` aus
Supabase und führt die Zeilen über `summaryMetricsFromRows` in
`buildOverviewRun`. Genau dort, wo echte Daten ankommen, sind die IDs leer.

> **Zur Zahl 182/1048, weil eine erste Fassung „203 Exporte" nannte.** Die 203
> kamen aus `grep -c 'export'` und zählten **20 Kommentarzeilen** mit — darunter
> Pfad-Zeichenketten wie `'./internal/export-envelope'`. Mit dem
> TypeScript-Parser gezählt: 182 `export`-Anweisungen, 1048 benannte Symbole,
> null Stern-Exporte. Ein Review hat es gefunden. Die tragende Aussage — **null**
> Verweise auf `all-differences` — ist per AST unabhängig bestätigt.

Nachgeprüft auch für `app/` und `components/`: die vier `differenceId`-Treffer
dort liegen alle in Testdateien.

**Damit ist eine der nicht verhandelbaren Regeln aus `CLAUDE.md` heute nicht
erfüllt:** „Jede quantitative Aussage benötigt Difference IDs und Source
References bis Datei, Blatt, Zelle, Formel und Wertzustand." Die Mechanik dafür
liegt fertig da — es fehlt die Verbindung von `overview-run.ts` zum Katalog.

### Warum das kein Signal erzeugt

Die Lücke meldet sich nicht:

- `differenceIds: []` ist syntaktisch und typkorrekt.
- Die Sortierung in `chart-specs.ts` arbeitet auf der leeren Liste fehlerfrei.
- Kein Test schlägt fehl, weil kein Test eine **nicht-leere** Liste verlangt.
- Die UI zeigt Zahlen; sie zeigt nur keine Herkunft, und das sieht man einem
  Diagramm nicht an.

**Wer Kriterium 1 abhaken will, muss nach einer nicht-leeren Liste in der
datenbankgespeisten UI suchen, nicht nach dem Feld.** Das Feld ist überall
vorhanden, und auf der Referenzseite ist es sogar gefüllt — mit erfundenen Werten.

**Korrektur an einer eigenen Behauptung:** Eine erste Fassung schrieb, die Lücke
stehe „in keinem Dokument". Das ist falsch. Die Köpfe von `reference-case.ts` und
`referenz/page.tsx` beschreiben sie ausdrücklich: „Die V2-Anzeigeebene ist gebaut,
aber an keine Datenquelle angeschlossen — in der App führt noch kein Weg von zwei
Dateien zu einem Lauf."

Was **neu** ist, ist etwas anderes und engeres: dieser Satz ist inzwischen
**überholt**. Es gibt einen datenbankgespeisten Weg (`[id]/v2/page.tsx`), und dort
sind die IDs fest leer. Die alte Notiz beschreibt „gar nicht angeschlossen", der
heutige Zustand ist „angeschlossen, aber ohne Herkunft" — und das ist der
gefährlichere Zustand, weil er wie Fertigstellung aussieht.

### Die Referenzseite ist die Vorlage, nicht das Gegenargument

Ihr eigener Kopf nennt den Grund, warum sie für Loop 3 wertvoll ist: „Hier stehen
Eingaben, keine Ergebnisse. Die Kacheln, die Brücke und die Zellnachweise
entstehen aus `buildKpiTiles`, `buildWaterfall` und `evidenceFor` — **denselben
Funktionen, die auch ein echter Lauf benutzt**."

Sie zeigt also bereits, wie die verdrahtete Ansicht aussieht und über welche
Funktion die Auflösung läuft (`buildKpiTiles` in `view-specs.ts` löst
`differenceIds` gegen die Sätze auf — **nicht** `chart-specs.ts`, das die IDs für
die Balkenbeschriftung gar nicht liest). Wer verdrahtet, baut den bereits
belegten Pfad nach, statt einen neuen zu erfinden.

**Nebenbefund mit eigener Konsequenz:** Die Seite formuliert ihre eigene
Abschaltbedingung — „verschwindet in dem Moment, in dem echte Läufe gerendert
werden können". Echte Läufe werden gerendert. Ob die Seite bleibt (als
Abnahmefläche für U-01…U-08) oder geht, ist beim Verdrahten zu entscheiden und
nicht stillschweigend offen zu lassen.

## Exit-Kriterium 4: heute kein Gleichstand, aber der Anzeigetext trägt schon

`assignDifferenceIds` (`all-differences.ts:80`) vergibt die Kennung als
**Positionszähler je Bereich** (`D-MAT-001`) nach einer Sortierung über vier
Schlüssel: `area` → `anchorRow` → `kind` → `descriptionDe`. Bei Gleichstand aller
vier entscheidet die Eingabereihenfolge (V8 sortiert stabil) — und der Kommentar
über der Funktion begründet die Bauart selbst damit, dass
Verarbeitungsreihenfolgen unzuverlässig sind.

**Gemessen am realen Golden-Paar** (`differencesFromTooling` → `buildCatalog`,
nur Zählungen erhoben):

| Schlüsselsatz | verschiedene Schlüssel | Kollisionsgruppen |
|---|---:|---:|
| `area\|anchorRow\|kind\|descriptionDe` (heute) | 16 von 16 | **0** |
| `area\|anchorRow\|kind` | 15 von 16 | **1** |
| `area\|anchorRow` | 15 von 16 | 1 |

Mit allen vier Schlüsseln gibt es heute keinen Gleichstand, die ID-Vergabe ist
also stabil.

> **Eine erste Fassung nannte die Lücke ein „Phantom". Das war zu bequem, und ein
> Review hat es aufgedeckt.** Die Tabelle zeigt: nimmt man `descriptionDe` weg,
> kollidieren **heute schon** zwei Sätze. Der vierte Schlüssel trägt also real,
> nicht bloß vorsorglich — und das heißt, die Eindeutigkeit der Kennungen hängt
> **bereits jetzt** an einem **Anzeigetext**. Genau die Kopplung, die unten als
> riskant beschrieben wird, ist keine Zukunftssorge, sondern Bestand.
>
> Die zwei kollidierenden Sätze unterscheiden sich in der Einheit
> (Stückumlage gegen Einmalzahlung derselben Position). Eine Umformulierung ihrer
> Beschreibung — etwa eine Vereinheitlichung der Bezeichner — würde heute zwei
> Kennungen vertauschen.

**Der Befund hat aber ein Haltbarkeitsdatum, und das ist genau Loop 3.** Die 16
Sätze stammen ausschließlich aus dem SBM-/Werkzeug-Pfad, dem einzigen
verdrahteten Produzenten. Material- und Fertigungspositionen tragen **mehrere
Felder je Zeile**; sobald die zwei fehlenden Module existieren, sind `area` und
`anchorRow` regelmäßig gleich, und dann tragen nur noch `kind` und
`descriptionDe`.

**Belegt, weil das sonst eine Vermutung bliebe:** `canonical-fields.ts` führt
**29** Einträge in `MATERIAL_FIELD_KEY_TO_CANONICAL` und **22** in
`QAF_FIELD_KEY_TO_CANONICAL` (dem Fertigungsblatt) — zum Vergleich 37 in
`SBM_FIELD_KEY_TO_CANONICAL`. Eine Material- oder Fertigungszeile kann also in
vielen Feldern gleichzeitig abweichen, und jede Abweichung wird ein eigener
Differenzsatz mit derselben `anchorRow`.

> Ein Review hatte diese Zahlen als „29 `mat_`- und 22 `mfg_`-Feld-IDs"
> bezeichnet. Nachgezählt: die **Zahlen stimmen**, die Präfixe `mat_`/`mfg_`
> existieren in der Datei **nicht** — es sind die Einträge der beiden
> Zuordnungstabellen. Das ist der Unterschied zwischen einer nachprüfbaren und
> einer nur plausibel klingenden Belegstelle.

Der Gleichstand ist damit keine Altlast zum Beheben, sondern eine
**Entwurfsanforderung an die zwei neuen Module.** Zwei Wege, vor dem Bauen zu
entscheiden:

1. `descriptionDe` je Satz eindeutig bilden. Dann bleibt die heutige Sortierung
   total. **Riskant:** die Beschreibung ist ein Anzeigetext; Eindeutigkeit an
   einen Anzeigetext zu hängen bricht, sobald jemand die Formulierung ändert.
2. Einen **fachlichen** fünften Schlüssel ergänzen, der keine Anzeige ist —
   naheliegend Blatt plus Zelle aus `cells[0]`. Der ist je Differenz eindeutig
   und ändert sich zwischen zwei Läufen über dieselben Dateien nicht.

**Empfehlung: Weg 2** — und nach der Messung oben ist das keine Vorsorge mehr,
sondern die Behebung einer bestehenden Kopplung.

Die tragende Probe ist nicht „Wert ändern", sondern **Eingabereihenfolge
permutieren** bei künstlich gleichgemachten ersten vier Schlüsseln. Der heutige
Permutationstest (`all-differences.test.ts`, „vergibt bei umgestellter Eingabe
dieselben Kennungen") prüft Sätze mit **unterschiedlicher `anchorRow`** — sie
teilen sogar `area` und `kind`, aber keine zwei haben dieselbe Zeile. Er belegt
damit, dass die Sortierung wirkt, nicht dass Gleichstände stabil bleiben. (Eine
erste Fassung schrieb „unterscheiden sich in allen vier Schlüsseln"; das war
ungenau, ein Review hat es korrigiert. Die Folgerung bleibt dieselbe.)

## Was bereits existiert und nicht neu gebaut werden darf

- `DifferenceArea = 'IDT' | 'SUM' | 'MAT' | 'MFG' | 'SBM'` — Material, Fertigung
  und SBM sind als Bereiche **vorhanden**. Exit-Kriterium 3 fragt nach
  Vollständigkeit, nicht nach Existenz.
- `DifferenceKind` ist ein geschlossenes Vokabular und enthält `merged` und
  `split` bereits. Die Aufgabe „Split und Merge im Katalog abbilden" ist im
  Vokabular erledigt; zu messen ist, ob sie auch **erzeugt** werden.
- `buildTraceabilityIndex()` löst die Zellen aus den Differenzsätzen auf, statt
  eine zweite Liste zu führen. Der Kopfkommentar begründet das mit genau der
  Regel, die in diesem Programm mehrfach verletzt wurde. **Nicht** durch eine
  parallele Liste ersetzen.
- `scope_removed` ist als getesteter Fehlmarker verankert:
  `internal/__tests__/material-mapping-fixture.test.ts` führt es in
  `NOT_YET_IMPLEMENTED`. Der Test weiß also schon, was fehlt — das ist der
  Ansatzpunkt, nicht ein neuer Test. Traceability-Matrix führt es als `DIF-005`,
  Priorität P0.

## Die Barrel-Umgehung: eine Regel gibt es schon, die Durchsetzung fehlt

Die V2-Seite importiert direkt aus `internal/` (`page.tsx:19`). Eine erste Fassung
dieses Dokuments nannte das „eine Entscheidung, die beim Verdrahten ansteht" und
begründete es damit, `check:boundaries` prüfe nur `boundaries/element-types`.
**Beides war zu klein gedacht.** Gemessen und nachgelesen:

| Befund | Beleg |
|---|---|
| Es ist nicht eine Seite, sondern **20 Produktionsdateien** in `app/` und `components/`, die aus `qaf-differences/internal/…` importieren | `grep -rl`, ohne Testdateien; darunter `referenz/page.tsx`, `supplier-benchmark-actions.ts`, `copilot-export-actions.ts` und sieben `components/qaf-differences/*` |
| Über den Barrel importieren 32 | dieselbe Erhebung |
| Die Regel **existiert bereits als ADR**: „Cross-module access goes through `index.ts` only — no deep imports." | `docs/adr/019-new-module-golden-path.md`, Regel 4 |
| Die Public-surface-Regel steht zusätzlich in der Rails-Doku, und `lib/qaf-differences` fehlt in deren Liste durchgesetzter Barrels | `docs/foundation/architecture-rails.md` |
| In `eslint.config.mjs` gibt es **keine Zone** für `qaf-differences`; `lib/**` fällt in einen generischen `module`-Typ | `eslint.config.mjs`, `boundaries/elements` |
| `governance/scorecard/bypass-log.md` enthält **keinen** `qaf`-Eintrag | Volltextsuche |

Die Rails-Doku verlangt: „a bypass without a sunset condition needs an ADR." Für
20 Dateien gibt es weder Log-Eintrag noch Ausnahme-ADR — nur eine ADR-Regel, die
sie verletzen, ohne dass ein Werkzeug es meldet.

**Der Unterschied zählt.** „Erlaubt, weil die Regel schmal ist" wäre harmlos.
„Verstößt gegen ADR-019 Regel 4, wird aber nicht durchgesetzt und nicht
protokolliert" ist eine unbemerkte Abweichung von beschlossener Politik.

Konsequenz für Loop 3, in dieser Reihenfolge:

1. **Nicht** nur den Katalog über den Barrel exportieren und den Rest lassen —
   das erzeugt zwei Vertragsflächen für dasselbe Modul.
2. Entweder eine Zone für `lib/qaf-differences` in `eslint.config.mjs` ergänzen
   und die 20 Stellen umstellen (sauber, aber ein eigener PR, **nicht** Teil von
   Loop 3), **oder** die Abweichung im Bypass-Log mit Sunset-Bedingung führen.
3. Erst danach entscheiden, wie der Katalog angeschlossen wird.

**Kein Loop-3-Blocker:** die Verdrahtung darf dem Bestandsmuster folgen, solange
die Abweichung benannt ist. Sie still zu vergrößern wäre die Sorte Schuld, die
dieses Programm mehrfach teuer bezahlt hat.

## Wie klein die Verdrahtung wirklich ist — und welcher Bereich sie braucht

Zwei Messungen, die die Aufgabe genauer schneiden als „es fehlt die Verbindung":

**1. Die auflösende Funktion wird bereits aufgerufen.** `overview-run.ts:231`
lautet `tiles: buildKpiTiles(kachelEingaben(diffs), [])`. Und
`buildKpiTiles(inputs, records)` (`view-specs.ts:92`) macht genau das Richtige:
sie baut `new Map(records.map(r => [r.differenceId, r]))`, löst je Kachel
`i.differenceIds` dagegen auf und flacht die `cells` der Treffer in
`sourceRef.cells` aus.

Es fehlen also **zwei Werte, nicht eine Verbindung**: die leere Katalogliste im
zweiten Argument und die leeren `differenceIds` je Kacheleingabe
(`overview-run.ts:121`). Der Kopf von `overview-run.ts` sagt selbst, das Modul sei
„das fehlende Bindeglied" — es hat alles verbunden außer der Herkunft.

**2. Der Bereich, den die UI zeigt, hat gar keinen Produzenten.** Das korrigiert
eine erste Fassung dieses Abschnitts, die „den vorhandenen SBM-Pfad bis in die UI
führen" empfahl. Gemessen:

| Produzent | erzeugt |
|---|---|
| `differences-from-tooling.ts` | ausschließlich `area: 'SBM'` |
| `differences-from-findings.ts` | keine `area`-Literale |
| `reference-case.ts` | `area: 'SUM'` — **die erfundenen Beispieldaten** |

`buildOverviewRun` speist die KPI-Kacheln und die Brücke aus
`diffSummaryMetrics`, also aus dem **SUM**-Bereich. Für SUM gibt es außerhalb der
Referenzdatei **keinen** Produzenten. Der vorhandene SBM-Produzent passt nicht in
diese Ansicht, weil `buildOverviewRun` das Summary-Blatt liest, nicht das
SBM-Blatt.

Meine erste Empfehlung nannte damit einen Weg, den die UI nicht hat. Das ist
derselbe Fehler wie „geprüft wurde, was ein Werkzeug leicht zeigt": ich hatte
einen Produzenten und eine UI gesehen und angenommen, sie gehörten zusammen.

## Der Kern von Schritt 1: die beiden Vokabulare passen nicht aufeinander

Beim Entwurf des SUM-Produzenten gemessen. Er muss `SummaryMetricDiff.status`
(`DiffStatus`) auf `DifferenceRecord.kind` (`DifferenceKind`) abbilden — und das
geht nicht auf:

| `DiffStatus` (14 Werte) | Ziel in `DifferenceKind` (9 Werte) |
|---|---|
| `neu` | `added` |
| `entfallen` | `removed` |
| `anstieg` · `senkung` · `auffaellig_10` · `auffaellig_25` · `kritisch_50` | `changed` |
| `konstant` | kein Satz nötig |
| **`formel_geaendert`** | **kein Ziel** |
| **`formel_zu_konstante`** | **kein Ziel** |
| **`nicht_berechenbar` · `nicht_anwendbar` · `blockiert`** | **kein Ziel** |

**Die beiden Formel-Fälle sind der Punkt, an dem es fachlich weh tut.**
`formel_geaendert` heißt laut eigenem Kommentar in `types.ts`: „formula hash
differs on both sides while the displayed value is unchanged — the
silent-manipulation risk Master-Prompt §12.4 explicitly calls out". Und
`formel_zu_konstante` ist „the classic manipulation pattern from the offline
tool". Beide werden **heute schon erzeugt** (`summary-metrics.ts:599` und
folgende).

Sie auf `changed` abzubilden würde genau die Unterscheidung löschen, für die sie
gebaut wurden — eine Formeländerung bei gleichem Wert wäre dann nicht mehr von
einer Wertänderung zu trennen. Sie wegzulassen verstößt gegen „Keine Zelle,
Formel, Tabelle, ausgeblendete Struktur oder unbekannte Information darf still
verschwinden."

Dasselbe gilt für das Trio ohne Rechenergebnis: `nicht_berechenbar`,
`nicht_anwendbar` und `blockiert` sind laut `CLAUDE.md` **getrennte Zustände**,
und `blockiert` bedeutet ausdrücklich, dass kein Delta gerechnet wurde, „instead
of showing a possibly-wrong number".

**Und die Roadmap weiß das längst.** Ihre Loop-3-Aufgabenliste enthält wörtlich
(Zeile 196): „**Formeländerungen getrennt von Wertänderungen führen**." Das ist
keine Erfindung dieses Dokuments, sondern der bereits vorgesehene Auftrag — nur
war bisher nicht benannt, *warum* er nötig ist: weil der Katalog die
Unterscheidung heute nicht ausdrücken kann.

**Anders als bei `ValueState` steht hier kein Test im Weg.** `DifferenceKind` ist
im Kopfkommentar als „bewusst geschlossen" begründet („Freitext an dieser Stelle
macht den Katalog unauswertbar"), aber **kein** Test nagelt die neun Werte fest
(gesucht: keine Fundstelle). Die Erweiterung ist damit eine Entwurfsentscheidung
im Rahmen eines vorgesehenen Auftrags, keine Spezifikationsabweichung.

**Damit rückt Schritt 1 an die zweite Stelle:** zuerst muss der Katalog
ausdrücken können, was der Summary-Vergleich schon produziert. Erst danach kann
ein Produzent ihn ehrlich füllen. Wer in der anderen Reihenfolge baut, muss beim
Abbilden lügen oder etwas weglassen — und beides ist hier ausdrücklich verboten.

### Und wie es zu modellieren ist: **nicht** als weitere `kind`-Werte

Der naheliegende Entwurf — `DifferenceKind` um `formula_changed` und
`formula_to_constant` erweitern — ist **falsch**, und der Summary-Vergleich zeigt
selbst warum.

`SummaryMetricDiff` trägt **zwei** Felder, nicht eines:

- `status` (`DiffStatus`) — die Wert- beziehungsweise Strukturänderung
- `formulaFinding` (`summary-metrics.ts:129`) — die Formeländerung, mit eigenen
  drei Werten: `formel_geaendert_wert_gleich`, `formel_geaendert_wert_geaendert`,
  `formel_zu_konstante`

Und die Zuweisung in `summary-metrics.ts:597` ff. ist der Beleg:

| Fall | `status` | `formulaFinding` |
|---|---|---|
| `formel_geaendert_wert_gleich` | wird auf `formel_geaendert` **überschrieben** | gesetzt |
| `formel_zu_konstante` | wird auf `formel_zu_konstante` **überschrieben** | gesetzt |
| **`formel_geaendert_wert_geaendert`** | **bleibt unberührt** (behält sein Wertband) | gesetzt |

Der dritte Fall entscheidet die Frage: **Formeländerung und Wertänderung treten
gleichzeitig auf.** Ein einziges Enum kann das nicht ausdrücken — man müsste sich
für eine der beiden Aussagen entscheiden und die andere verlieren. Genau deshalb
hat der Vergleich die Formelaussage von Anfang an **orthogonal** geführt, und die
Status-Überschreibung passiert nur dort, wo es kein Wertband zu berichten gibt.

**Für den Katalog heißt das:** `DifferenceRecord` braucht dieselbe
Orthogonalität — `kind` für die Wert- und Strukturänderung, **plus ein eigenes
Feld** für den Formelbefund. Dasselbe gilt für die Frage, *warum* kein Delta
vorliegt: `nicht_berechenbar`, `nicht_anwendbar` und `blockiert` sind Gründe für
`delta === null`, keine Änderungsarten.

Damit ist „den Katalog sprachfähig machen" **kein Enum-Anbau**, sondern eine
Erweiterung von `DifferenceRecord` um zwei orthogonale Angaben. Das ist der Grund,
warum es Schritt 1 ist und nicht ein Nebenschritt: es ändert den Kern-Typ, den
sieben Dateien importieren.

**Diese Fassung korrigiert eine frühere Formulierung dieses Dokuments**, die von
„fehlenden Zielen in `DifferenceKind`" sprach und damit den Enum-Anbau nahelegte.
Die Ziele fehlen — aber nicht im Enum, sondern im Satztyp.

### Wie teuer die Erweiterung ist, und warum sie allein nicht genügt

Gemessen, damit niemand die Kosten schätzen muss:

| Frage | Befund |
|---|---|
| Verbraucher von `DifferenceKind` | **zwei**: `all-differences.ts` (Definition) und `differences-from-tooling.ts` (Produzent) |
| Erschöpfende `Record<DifferenceKind, …>` | **keine** — das Typsystem erzwingt also nichts, es bricht aber auch nichts |
| Wo `DifferenceRecord.kind` sichtbar wird | **genau eine Stelle**: `table-specs.ts:163` (`matchType: n?.matchType ?? r.kind`) |
| Produktionsimporte von `table-specs` | **0** |

Zwei Folgerungen, beide wichtig:

1. **Die Erweiterung ist billig und risikoarm.** Zwei Dateien, keine
   Vollständigkeitsprüfung, die bricht. (Zur Warnung: die vielen
   `kind === '…'`-Treffer in `formula-engine.ts`, `multi-qaf/export.ts` und
   `data-quality.ts` gehören zu **anderen** `kind`-Feldern. Wer beim Erweitern
   danach greppt, findet Stellen, die nichts damit zu tun haben — geprüft über
   die Importliste, nicht über den Feldnamen.)
2. **Sie genügt allein nicht.** Die Roadmap-Aufgabe „Formeländerungen getrennt
   von Wertänderungen führen" hat **drei** Teile, nicht einen: das Vokabular muss
   es ausdrücken können, ein Produzent muss es setzen, **und eine Oberfläche muss
   es zeigen**. Der einzige Ort, der `kind` heute anfasst, ist selbst
   unverdrahtet. Wer nur das Vokabular erweitert, hat die Anforderung nicht
   erfüllt — nur die Voraussetzung dafür geschaffen.

## Zwei ID-Formate — eine Kollision, die heute nur schläft

`assignDifferenceIds` erzeugt `` `D-${area}-${nnn}` `` (`all-differences.ts:95`),
also `D-SUM-001`. `reference-case.ts` schreibt seine Kennungen von Hand als
`SUM-0001`, `MAT-0001`, `MFG-0002` — **ohne** `D-`-Präfix und mit vier Stellen.

Und es gibt einen **validierenden Parser**: `deep-links.ts:31` führt

```
const DIFFERENCE_PATTERN = /^D-(IDT|SUM|MAT|MFG|SBM)-\d{3,}$/
```

Die Kennungen der Referenzseite fallen dort durch. Heute merkt das niemand, weil
`deep-links.ts` **null Produktionsverbraucher** hat (geprüft über
`buildDeepLink`/`isViewSection`, nicht über den Pfad). Sobald Tieflinks verdrahtet
werden, kollidieren die Formate.

**Folge für die Einordnung der Referenzseite:** Sie ist die Vorlage für **Aufbau
und Auflösung** — nicht für das ID-Format. Wer sie nachbaut, muss das Format aus
`assignDifferenceIds` nehmen, nicht aus ihren Literalen.

## Die eigentliche Gestalt von Loop 3: fertige Teile, die nicht zusammenhängen

Gemessen, Produktionsimporte je Modul (ohne Tests, ohne Selbstimport):

| Modul | Produktionsimporte | Anmerkung |
|---|---:|---|
| `view-specs` | 5 | verdrahtet |
| `all-differences` | 7 | **nur für Typen** — die Bau-Funktionen ruft niemand |
| `chart-specs` | 4 | verdrahtet |
| `export-envelope` | 2 | verdrahtet |
| `ai-ready` | 1 | knapp verdrahtet |
| `negotiation-levers` | 1 | knapp verdrahtet |
| `deep-links` | **0** | vollständig unverdrahtet |
| `table-specs` | **0** | vollständig unverdrahtet |

Der Fall `all-differences` ist der lehrreiche: **sein Datenmodell ist die
gemeinsame Währung** — `DifferenceRecord` und `DifferenceCell` werden siebenmal
importiert — aber `assignDifferenceIds`, `buildCatalog`,
`buildTraceabilityIndex` und `validateTraceability` ruft **kein**
Produktionscode auf. Alle bauen auf dem Typ auf, niemand erzeugt eine Instanz.

**Das ist die Gestalt der Aufgabe.** Loop 3 ist nicht „Module schreiben", sondern
**fertige Teile verbinden und dabei die Widersprüche auflösen**, die beim
Getrenntbleiben entstanden sind: zwei ID-Formate, ein Herkunftsfeld ohne Quelle,
ein Meldewesen ohne Aufrufer, ein Tieflink-Parser, den die einzige ID-Quelle nicht
erfüllt.

## Wo Schritt 1 an eine geschlossene Grenze stößt

Bevor jemand den SUM-Produzenten baut: `DifferenceCell` verlangt **fünf** Felder —
`fileRole`, `sheet`, `cell`, `formula`, `valueState`. Die nicht verhandelbare Regel
verlangt dasselbe („bis Datei, Blatt, Zelle, Formel und Wertzustand"). Was
tatsächlich verfügbar ist:

| Feld | Mappen-Pfad (`parseSummarySheetFromWorkbook`) | Persistierter Pfad (`summaryMetricsFromRows`) |
|---|---|---|
| `cell` | ja (`SummaryMetricValue.cell`, A1) | ja (`source_cell`) |
| `sheet` | ja | ja (das Summary-Blatt) |
| `formula` | **teilweise** — `SummaryMetricsParse.formulas` ist optional und nur für Metriken gesetzt, deren Zelle wirklich eine Formel trug, und nur wenn ein `formulaGrid` übergeben wurde | **nein** — `qaf_summary_metric` hat keine `formula`-Spalte |
| `valueState` | ableitbar | **nein** — keine `value_state`-Spalte |

**Die V2-Seite nutzt den persistierten Pfad.** `summaryMetricsFromRows` schreibt
selbst in den Kommentar: „Quelle ist hier die Datenbank, nicht die Arbeitsmappe" —
und setzt `labelFile: null`, `labelVerified: null`, `confidence: 0.6`.

Dazu kommt eine **geschlossene Wertemenge**: `ValueState` kennt genau
`formula_and_cached`, `constant`, `empty`, `error`, `external_link` — es gibt
**keinen** Wert für „nicht gelesen". Ein Produzent auf dem persistierten Pfad
müsste also einen Wertzustand **erfinden**, etwa `constant` behaupten, wo er es
nicht weiß. Das wäre eine Falschaussage in einem Provenienzfeld und verstößt gegen
die Regel, dass diese Zustände getrennt bleiben.

Drei Wege, vor dem Bauen zu entscheiden:

1. **`qaf_summary_metric` um `formula` und `value_state` erweitern.** Ehrlich und
   vollständig, aber eine Migration — auf Produktion damit ein
   `deployment_only_blocker`. Bauen und testen geht trotzdem.
2. **Den Katalog nur auf dem Mappen-Pfad erzeugen** und in der V2-Seite
   ausdrücklich als unvollständig kennzeichnen. Vermeidet die Migration, erfüllt
   Kriterium 2 aber nur dort, wo Mappen vorliegen.
3. **`ValueState` um einen expliziten Zustand erweitern.** Ein benannter Zustand
   ist besser als ein erfundener.

**Es gibt einen vierten Weg, und er braucht keine Entscheidung.** Er stand nur
nicht auf der Liste, weil ich nach einem Zustand für die fehlende Angabe gesucht
habe statt nach dem Umgang mit der fehlenden Angabe.

`DifferenceRecord.cells` ist ein **Array**. Wo keine vollständige Herkunft bekannt
ist, wird eben **keine Zelle** erzeugt — dann muss niemand einen Wertzustand
erfinden. Und das Meldewesen dafür ist schon gebaut:
`validateTraceability()` (`all-differences.ts:159`) prüft genau das und liefert

```
{ kind: 'difference_without_cells', where: <differenceId>,
  detail: 'Differenz ohne Fundstelle — nicht nachschlagbar.' }
```

Damit ergibt sich für Schritt 1:

- **Exit-Kriterium 1** („jede UI-Aussage referenziert Difference IDs") wird
  **sofort erfüllbar** — die Kacheln bekommen ihre IDs, unabhängig von der
  Zellherkunft.
- **Exit-Kriterium 2** („jede Difference ID besitzt mindestens eine Source
  Reference") bleibt auf dem persistierten Pfad **offen — aber benannt und
  gezählt**, durch einen vorhandenen Mechanismus statt durch eine erfundene
  Angabe. Das ist der Unterschied zwischen einer bekannten Lücke und einer
  stillen Falschaussage.
- **Weg 1 (Migration) schließt Kriterium 2** und wird damit ein eigener,
  späterer Schritt statt eine Vorbedingung.

**Empfohlen ist also Weg 4 zuerst, Weg 1 danach.** Weg 3 fällt weg (siehe unten:
Spezifikationsabweichung), Weg 2 ebenfalls — es braucht keine
herkunftsabhängige Teilbedienung, wenn die Lücke sauber gemeldet wird.

Zu prüfen beim Umsetzen: `validateTraceability` hat heute **keinen**
Produktionsaufrufer (es liegt im selben unverdrahteten Modul). Die Meldungen
müssen also mitverdrahtet werden, sonst entsteht genau die stille Lücke, die der
Mechanismus verhindern soll.

### Weg 3 ist versucht und zurückgenommen: die fünf Zustände sind Spezifikation

Ich habe die Erweiterung gebaut, um ihre Kosten zu messen — und sie wieder
entfernt. Der Weg dahin gehört dokumentiert, weil er die Empfehlung umdreht.

**Die Kosten sind tatsächlich gering.** Nur drei Produktionsdateien berühren
`ValueState`: `cell-state.ts` (Definition), `all-differences.ts` (Verwendung im
Typ) und `qaf-v2-evidence-panel.tsx`. Der Compiler erzwingt die eine
UI-Anpassung, weil dort `Record<ValueState, string>` steht — genau ein Fehler,
genau an der erwarteten Stelle.

**Aber ein Test hat es verhindert, und zu Recht:**
`internal/__tests__/cell-state.test.ts` prüft „deckt genau die **fünf** Zustände
**der Spezifikation** ab" und vergleicht `VALUE_STATES` mit einer festen Liste.
Der Dateikopf von `cell-state.ts` nennt die Quelle: „Zellzustände (Spezifikation
Kap. 6.3)".

Damit ist Weg 3 **keine technische Entscheidung**, sondern eine Abweichung von
einer fachlichen Primärquelle. `CLAUDE.md` regelt beides eindeutig: Widersprüche
zu Primärquellen sind zu dokumentieren, nicht still zu ersetzen — und „kein Test
wird gelöscht oder abgeschwächt, nur um einen Gate grün zu machen". Diesen Test
anzupassen, um die eigene Erweiterung durchzulassen, wäre genau das gewesen.

**Weg 1 löst das Problem stattdessen auf.** Die fünf Zustände beschreiben eine
**gelesene** Zelle. Fehlt der Zustand, ist die richtige Antwort nicht ein sechster
Zustand für „nicht gelesen", sondern die Zelle **zu lesen** — also `formula` und
`value_state` in `qaf_summary_metric` zu speichern. Dann greift einer der fünf,
die Spezifikation bleibt unangetastet, und die Provenienz ist vollständig statt
ehrlich unvollständig. Kosten: eine Migration, auf Produktion ein
`deployment_only_blocker` — bauen und testen geht trotzdem.

Bleibt Weg 3 dennoch gewünscht, ist es eine **Produktentscheidung** mit
Spezifikationsbezug, kein Implementierungsdetail.

> **Eine Falle für den Fall, dass jemand `ValueState` doch erweitert:**
> `isWeakState()` in `qaf-v2-evidence-panel.tsx` vergleicht explizit
> (`state === 'error' || state === 'external_link'`) und hat keinen erschöpfenden
> `switch`. Ein neuer Zustand fällt dort **still auf `false`** durch, während
> `STATE_LABEL` vom Typsystem erzwungen wird. Beim Versuch war genau das der
> Fall: der Compiler verlangte das Label, aber der schwächste aller Zustände
> hätte als unbedenklich gegolten — ohne jede Warnung.

### Zum Namen, falls Weg 3 je entschieden wird: das Vokabular existiert schon

Eine erste Fassung nannte Weg 3 „eine Entscheidung über ein bewusst geschlossenes
Vokabular" und schlug den Namen `not_read` vor. Beides war falsch. Gemessen:

| Typ | Datei | Werte |
|---|---|---|
| `SectionState` | `ai-ready.ts:29` | `populated` · `empty_verified` · `not_computed` |
| `SectionEmptyState` | `chart-specs.ts:202` | `empty_no_findings` · `not_applicable` · `not_computed` |
| `SectionStatus` | `export-envelope.ts:122` | `populated` · `empty_no_findings` · `not_applicable` · `not_computed` |
| `WaterfallState` | `chart-specs.ts:36` | `ok` · `not_reconciled` · `not_computed` |

**Vier Vokabulare führen `not_computed`**, und `ai-ready.ts:79` formuliert das
Prinzip wörtlich:

> „`empty_verified` heisst geprüft und nichts gefunden. `not_computed` heisst
> nicht geprüft — dazu ist keine Aussage zulässig, auch keine beruhigende."

Genau das fehlt auf Zellebene. `ValueState` beschreibt ausschließlich Zustände
eines **gelesenen** Feldes.

Eine erste Fassung folgerte daraus, die Begründungslast drehe sich („warum gilt
auf Zellebene nicht, was auf Abschnittsebene viermal gilt?"). Das war zu stark:
die vier Abschnitts-Vokabulare stehen in Modulen, die keine
Spezifikationszusage tragen, während `ValueState` ausdrücklich Kap. 6.3 abbildet
und von einem Test bewacht wird. Der Vergleich taugt für die **Namenswahl**, nicht
als Freigabe.

**Und der Name muss `not_computed` sein, nicht `not_read`.** Ein fünfter Begriff
für dieselbe Sache wäre genau die Drift, gegen die dieses Programm mehrfach
angetreten ist. `empty` bleibt „gelesen und leer".

Beim Umsetzen zu prüfen: `VALUE_STATES` in derselben Datei ist eine
Laufzeit-Liste und wird vermutlich zur Validierung benutzt — alle Verwender
suchen, bevor der Wert hinzukommt. Ein `switch` ohne Default fällt sonst still
durch.

**Beobachtung, kein Loop-3-Auftrag:** vier parallele Abschnitts-Vokabulare mit
überlappenden Werten sind selbst ein Befund — `SectionEmptyState` und
`SectionStatus` unterscheiden sich nur um `populated`, und `SectionState` sagt
`empty_verified`, wo die anderen `empty_no_findings` sagen. Dieselbe Klasse wie
„keine parallele kanonische Feldregistry". Nicht in Loop 3 aufräumen, aber auch
nicht als fünftes Vokabular weiterwachsen lassen.

## Reihenfolge, die sich daraus ergibt

1. **Den Katalog sprachfähig machen: Formeländerungen von Wertänderungen
   trennen.** Das ist die Roadmap-Aufgabe aus Zeile 196, und der Abschnitt „Der
   Kern von Schritt 1" zeigt, warum sie zuerst kommt: `formel_geaendert`,
   `formel_zu_konstante` und das Trio `nicht_berechenbar`/`nicht_anwendbar`/
   `blockiert` haben in `DifferenceKind` **kein Ziel**. Ohne diesen Schritt muss
   jeder Produzent beim Abbilden entweder lügen (`changed`) oder etwas weglassen —
   beides ausdrücklich verboten. Kein Test nagelt `DifferenceKind` fest, also ist
   es eine Entwurfsentscheidung, keine Spezifikationsabweichung.
2. **Dann den SUM-Produzenten bauen** (`differences-from-summary.ts`), der aus den
   `SummaryMetricDiff`-Sätzen `RawDifference[]` macht, und ihn in
   `buildOverviewRun` einsetzen: `buildCatalog(...)` statt `[]` im zweiten
   Argument, plus die passenden IDs in `kachelEingaben`. **Die Zielform ist schon
   belegt** — `reference-case.ts:139` und `:152` zeigen, wie ein SUM-Satz aussieht,
   und die Referenzseite beweist, dass die Anzeige ihn korrekt rendert (das
   ID-Format aber aus `assignDifferenceIds` nehmen, nicht aus ihren Literalen).
   Damit ist Kriterium 1 für **einen** Bereich erfüllt.
   **Zur Zuordnung `metricKey` → `differenceId`:** `assignDifferenceIds`
   **sortiert**, die Reihenfolge trägt also nicht. Der Produzent kennt seine
   eigenen Tupel — gematcht wird über die vier Sortierschlüssel, nicht über den
   Index und nicht über die Beschreibung allein.
3. **Der fünfte Sortierschlüssel** vor den neuen Modulen, mit Permutationsprobe
   auf gleichgemachten Schlüsseln — nach der Messung oben behebt er eine
   bestehende Kopplung, keine hypothetische.
4. **Dann** `differences-from-material.ts` und `-manufacturing.ts`, gegen eine
   Kette, die schon trägt.
5. `scope_removed` über den bestehenden `NOT_YET_IMPLEMENTED`-Marker schließen.

Vor Schritt 1 die Barrel-Frage benennen (Abschnitt oben) — nicht lösen, aber
protokollieren.

Die Roadmap-Reihenfolge beginnt bei Schritt 3. Wer so anfängt, baut zwei Module,
die genauso unangeschlossen bleiben wie der Katalog heute.

## Was dieses Dokument nicht sagt

Es sagt nicht, dass der Katalog falsch gebaut ist. Er ist getestet und in sich
schlüssig; ihm fehlt ein Aufrufer.

Es sagt auch nicht, dass die Roadmap falsch ist. Sie ist unvollständig an genau
der Stelle, die man nicht sieht, wenn man Aufgaben abarbeitet statt den
Ist-Zustand zu messen.
