---
quick_id: 260907-efh
slug: cross-source-dedup-greift-nicht-windows-
date: 2026-09-07
status: planned
relates_to: 13-scraping-adapters-cross-source-dedup
windows_ref: 11
severity: high
phase: quick-260907-efh
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/src/tenders/tender-fingerprint.ts
- apps/api/src/tenders/tender-fingerprint.spec.ts
- apps/api/src/tenders/backfill-tender-source.ts
- apps/api/src/tenders/tender-dedup.service.ts
- apps/api/src/tenders/tender-dedup.service.spec.ts
- apps/api/src/tenders/tender-fingerprint-backfill.service.ts
- apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts
- apps/api/src/tenders/tenders.module.ts
autonomous: true
requirements: [SCHEMA-03]
estimate:
tokens: 62000
raw_tokens: 62000
tasks: 3
confidence: low
must_haves:
truths:
- "Ein DOE-Datensatz (cpvDivisions gefuellt, estimatedValue gesetzt) und ein Scraper-Datensatz (cpvDivisions leer, estimatedValue null) derselben Ausschreibung ergeben denselben Fingerabdruck."
- "Findet die Fingerprint-Stufe einen Kandidaten und tragen BEIDE Seiten einen Wert, dessen Groessenordnung sich unterscheidet, wird nicht zusammengelegt — es bleiben zwei Eintraege."
- "Fehlt der Wert auf einer der beiden Seiten, blockiert er die Zusammenlegung nicht; gleiche Groessenordnung blockiert ebenfalls nicht."
- "Aendert die Aenderungserkennung die Felder eines bestehenden Eintrags, wird sein Fingerabdruck mitgeschrieben — der gespeicherte Wert passt danach zum Inhalt der Zeile."
- "Auf einer Bestandsdatenbank tragen nach dem naechsten Start alle Zeilen einen Fingerabdruck im neuen Format; ein zweiter Start rechnet nichts mehr nach."
- "Eine frische Installation ohne Bestandsdaten durchlaeuft die Nachrechnung ohne Arbeit und ohne Fehler."
- "Die drei verbleibenden Grenzen stehen als Klartext im Code-Kommentar UND in der SUMMARY: (a) ein zweites Los gleicher Groessenordnung wird faelschlich zusammengelegt, (b) Quellen ohne Vergabestelle und ohne Frist matchen nur noch ueber den Titel, (c) genau ein solcher Fall existiert heute schon gemessen in der Datenbank."
artifacts:
- apps/api/src/tenders/tender-fingerprint.ts
- apps/api/src/tenders/tender-fingerprint.spec.ts
- apps/api/src/tenders/tender-dedup.service.ts
- apps/api/src/tenders/tender-dedup.service.spec.ts
- apps/api/src/tenders/tender-fingerprint-backfill.service.ts
- apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts
key_links:
- "tenderFingerprint() -> TenderDedupService.findMatch Stufe 3 -> Tender.fingerprint (indiziert, NICHT unique — eine Neuberechnung kann keine Bedingung verletzen)"
- "FINGERPRINT_VERSION_PREFIX -> Rueckgabewert von tenderFingerprint() UND Veraltet-Abfrage des Nachrechners: eine Konstante, zwei Nutzer, sonst rechnet der Nachrechner ewig oder nie"
- "valueBucketsContradict() -> Stufe-3-Treffer in TenderDedupService: der Wert haengt nicht mehr im Hash, sondern nur noch als Veto am Treffer"
- "TendersModule providers-Reihenfolge: TenderFingerprintBackfillService steht VOR TenderSchedulerService, damit die Nachrechnung vor der Cron-Registrierung laeuft"
---
Die dritte Dedup-Stufe (Fingerabdruck) legt echte Duplikate nicht zusammen. Der
Fingerabdruck hasht heute fuenf Segmente, von denen zwei — CPV-Divisionen und
geschaetzter Auftragswert — systematisch nur von einer Quellenart geliefert werden.
Dieselbe Ausschreibung ergibt aus DOE und aus einem Scraper verschiedene Hashes,
also greift die Stufe genau dort nicht, wofuer sie gebaut wurde (WINDOWS.md #11,
13-VERIFICATION.md Wahrheit 3).
Der Plan setzt die bereits getroffene, an der Live-Datenbank gemessene Entscheidung um:
CPV und Wert fliegen aus dem Hash, der Wert kehrt als Widerspruchspruefung am Treffer
zurueck, und die 16.255 Bestandszeilen werden ohne Handarbeit auf die neue Formel
nachgerechnet.
Purpose: Die Fingerprint-Stufe soll die Duplikate finden, die sie heute uebersieht.
Output: Neue Hash-Formel mit Versionsmarke, Wert-Veto im Resolver, automatische
Nachrechnung beim Start, plus Tests, die genau den Fall festnageln, um den es geht.
## Gemessene Ausgangslage (heute an der lokalen Datenbank erhoben, nicht geschaetzt)
| Messung | Wert |
|---|---|
| Zeilen in Tender | 16.255 |
| Zeilen mit gespeichertem Fingerabdruck | 16.255 (0 leer) |
| Fingerabdruck-Gruppen mit mehr als einer Zeile HEUTE | 0 |
| dieselbe Zahl nach reiner Neuberechnung mit der ALTEN Formel | 3 |
| dieselbe Zahl nach Neuberechnung mit der NEUEN Formel | 26 |
| davon Gruppen mit widersprechenden Wert-Groessenordnungen (Veto greift) | 2 |
## Zweiter Defekt, beim Nachmessen gefunden — bewusst mit aufgenommen
Die Zeile 3 der Tabelle ist neu und war in der Aufgabenstellung nicht enthalten.
Sie bedeutet: schon ohne jede Formelaenderung wuerden drei Gruppen zusammenfallen,
die heute auseinanderstehen. Ursache ist nicht die Formel, sondern
`TenderDedupService`: der Zweig, der bei geaenderter Aenderungssignatur Titel,
Vergabestelle, Frist und Wert einer bestehenden Zeile aktualisiert, schreibt den
Fingerabdruck NICHT mit. Der gespeicherte Wert driftet dadurch vom Inhalt der Zeile
weg. Beleg, an einem der drei Paare abgelesen: zwei Zeilen mit identischer
Vergabestelle ("Gemeinde Vellahn ueber Amt Zarrentin"), identischem Titel
("Hortanbau 19260 Vellahn Los 02 Erdarbeiten"), identischer Frist (2026-09-11 08:00),
identischer CPV-Division ({45}) und beidseitig leerem Wert tragen verschiedene
Fingerabdruecke; die aeltere Zeile wurde um 08:03:38 aktualisiert, ihr Fingerabdruck
stammt aber noch von 08:02:19.
Das ist derselbe Schaden aus einer zweiten Richtung: die Stufe findet das Duplikat
nicht. Es waere unehrlich, die Formel zu reparieren und die Drift stehen zu lassen —
Task 2 schliesst beides.
## Was ausdruecklich NICHT passiert
Keine Aehnlichkeits- oder Schwellenwertsuche. Der Wertvergleich ist ein exakter
Vergleich zweier Groessenordnungs-Stufen, kein Abstandsmass. Das Verbot aus dem
Dateikommentar von `tender-fingerprint.ts` bleibt in Kraft und bleibt im Kommentar
stehen.
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
@.planning/STATE.md
@CLAUDE.md
@.planning/phases/13-scraping-adapters-cross-source-dedup/13-VERIFICATION.md
@apps/api/src/tenders/tender-fingerprint.ts
@apps/api/src/tenders/tender-fingerprint.spec.ts
@apps/api/src/tenders/tender-dedup.service.ts
@apps/api/src/tenders/backfill-tender-source.ts
@apps/api/src/ldap/ldap-config.service.ts
Task 1: Hash-Formel auf [Vergabestelle, Titel, Frist] verengen, Versionsmarke einfuehren, Wert-Widerspruch als eigene Funktion
apps/api/src/tenders/tender-fingerprint.ts, apps/api/src/tenders/tender-fingerprint.spec.ts, apps/api/src/tenders/backfill-tender-source.ts
- Ein DOE-artiger Datensatz (cpvDivisions ['45'], estimatedValue 387291) und ein Scraper-artiger Datensatz (cpvDivisions [], estimatedValue null) mit gleicher Vergabestelle, gleichem Titel und gleicher Frist ergeben denselben Fingerabdruck. Das ist die Kernregression dieses Plans.
- Ein anderer Titel ergibt einen anderen Fingerabdruck.
- "Muenchen" und "München" tragen dasselbe zum Fingerabdruck bei.
- Zwei Fristen am selben Tag mit unterschiedlicher Uhrzeit ergeben denselben Fingerabdruck, zwei Fristen an verschiedenen Tagen nicht.
- Eine leere Vergabestelle (null) fuehrt zu einem leeren Segment, nicht zu einem Absturz.
- Der Rueckgabewert hat die Form "v2:" gefolgt von 64 Hexzeichen.
- Ein Goldwert nagelt die Segmentzahl und die Reihenfolge fest: Vergabestelle "Stadt Musterstadt", Titel "Neubau Turnhalle", Frist 2026-03-04T09:00:00Z ergibt exakt v2:6104874e683082d4e656e5585947629dcb8c667eb77888c456020ce86993be34. Dieser Wert wurde unabhaengig vom Produktivcode aus der handgeschriebenen kanonischen Zeichenkette "stadt musterstadt|neubau turnhalle|2026-03-04" gebildet — er faellt rot, sobald jemand ein viertes (auch leeres) Segment anhaengt oder die Reihenfolge dreht.
- valueBucketsContradict: null gegen 5000 ist false, 5000 gegen null ist false, null gegen null ist false, 12000 gegen 15000 ist false (gleiche Groessenordnung), 9000 gegen 90000 ist true.
In `apps/api/src/tenders/tender-fingerprint.ts`:
Die Signatur von `tenderFingerprint` auf genau drei Felder verengen: buyerName
(string oder null), title (string), deadlineAt (Date oder null). Die Parameter
cpvDivisions und estimatedValue entfallen ersatzlos aus der Signatur — das ist
Absicht: so kann niemand mehr glauben, sie flossen in den Hash. Aufrufer, die ein
breiteres Objekt in einer Variablen halten (der Resolver uebergibt seinen
normalisierten Datensatz), bleiben davon unberuehrt, weil TypeScript
Ueberschussfelder nur bei direkt hingeschriebenen Objektliteralen bemaengelt.
Die kanonische Zeichenkette besteht danach aus genau drei mit senkrechtem Strich
verbundenen Segmenten: normalisierte Vergabestelle, normalisierter Titel,
Frist-Tag. `cpvDivisionKey` faellt komplett weg. `normText` und `deadlineKey`
bleiben unveraendert.
Eine exportierte Konstante `FINGERPRINT_VERSION_PREFIX` mit dem Wert "v2:"
einfuehren und dem sha256-Hex voranstellen. Der Rueckgabewert ist damit
"v2:" plus Hex. Die Marke ist kein Schmuck: Task 3 erkennt an ihr, welche Zeilen
noch die alte Formel tragen. Die Spalte Tender.fingerprint ist indiziert, aber
nicht unique — eine Neuberechnung kann keine Bedingung verletzen.
`valueBucket` bleibt als private Hilfsfunktion erhalten (sie hat nur noch einen
Nutzer) und bekommt eine neue, exportierte Huelle
`valueBucketsContradict(a: number | null, b: number | null): boolean`. Sie liefert
ausschliesslich dann true, wenn BEIDE Werte gesetzt sind und ihre
Groessenordnungs-Stufen verschieden sind. Ist eine Seite null, kann der Wert nichts
aussagen und die Funktion liefert false. Diese Funktion lebt bewusst hier neben der
Stufenbildung, damit es genau eine Definition von "Groessenordnung" gibt.
Den Dateikommentar ehrlich neu schreiben. Er muss vier Dinge sagen, ohne sie zu
beschoenigen: (1) CPV und Wert sind aus dem Hash entfernt, weil sie quellenabhaengig
sind — an der Live-Datenbank gemessen liefern die Nicht-DOE-Quellen beide Felder nie,
und 21 gemessene Gruppen echter Duplikate wurden allein durch CPV auseinandergehalten,
typischerweise weil eine Zeile die Sach-Division traegt und die andere die 50
(Reparatur/Wartung). (2) Der Wert kommt als Widerspruchspruefung am Treffer zurueck,
nicht als Hash-Bestandteil; er rettet 2 der 3 gemessenen Los-Gruppen, die dritte
(117.647 gegen 386.554, gleiche Groessenordnung) wird faelschlich zusammengelegt —
das ist eine bewusst getragene Fehlzusammenlegung, weil eine falsche Zusammenlegung
sichtbar und reparierbar ist, 21 uebersehene dagegen der stille Totalausfall der
Stufe. (3) Der Vergleich ist exakt, keine Schwelle, keine Aehnlichkeit — das alte
Verbot bleibt gueltig und bleibt im Kommentar stehen. (4) Die Grenze: Datensaetze
ohne Vergabestelle und ohne Frist (RSS, E-Mail-Alarm) matchen danach nur noch ueber
den Titel. Fuer den Normalfall heisst das, sie treffen weiterhin keine DOE-Zeile;
trifft aber eine DOE-Zeile ihrerseits weder Vergabestelle noch Frist, reicht der
Titel allein zur Zusammenlegung. Genau ein solches Paar existiert heute in der
Datenbank gemessen (Titel "LSA242", einmal ueber service-bund, einmal ueber
doe-opendata) und wird nach dieser Aenderung zusammengelegt.
In `apps/api/src/tenders/backfill-tender-source.ts`: den Aufruf und die
Feldauswahl an die neue Signatur anpassen — cpvDivisions und estimatedValue
entfallen aus select und Aufruf, `decimalToNumberOrNull` wird dort nicht mehr
gebraucht und faellt weg. Ohne diesen Schritt bricht die Typpruefung, weil der
Aufruf dort ein direktes Objektliteral ist. Den Kopfkommentar der Datei um einen
Satz ergaenzen: das Skript bleibt der Weg fuer eine erzwungene Neuberechnung von
Hand, die automatische Nachrechnung uebernimmt ab Task 3 der Start des Dienstes.
In `apps/api/src/tenders/tender-fingerprint.spec.ts`: die acht Tests auf das neue
Verhalten umschreiben, nicht loeschen. Die beiden Tests, die heute
CPV-Reihenfolgeunabhaengigkeit und Wert-Stufenbildung am Hash pruefen, werden zu
ihren Nachfolgern: der erste zur Kernregression DOE-Form gegen Scraper-Form, der
zweite zu den Faellen von `valueBucketsContradict`. Fuer die Kernregression die
beiden Datensaetze als benannte Variablen mit den zusaetzlichen Feldern anlegen und
diese Variablen uebergeben — so beweist der Test zugleich, dass zusaetzliche Felder
den Hash nicht beeinflussen koennen, und spiegelt die Aufrufform des Resolvers.
Projektregel: keine Erwartung darf mit derselben Hilfsfunktion gebaut werden, die
der Produktivcode benutzt — der Goldwert oben ist handgerechnet und wird als
Literal hingeschrieben.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/tender-fingerprint.spec.ts
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check
tenderFingerprint nimmt drei Felder, liefert "v2:" plus 64 Hexzeichen und ergibt
fuer DOE-Form und Scraper-Form derselben Ausschreibung denselben Wert. Der
Goldwert-Test steht und ist gruen. valueBucketsContradict ist exportiert und
verhaelt sich in allen fuenf Faellen wie beschrieben. Die Typpruefung der API ist
sauber, das Skript kompiliert mit der neuen Signatur.
Task 2: Wert-Veto in der Fingerprint-Stufe des Resolvers, und Fingerabdruck bei Aktualisierung mitschreiben
apps/api/src/tenders/tender-dedup.service.ts, apps/api/src/tenders/tender-dedup.service.spec.ts
- Stufe 3 findet einen Kandidaten, dessen estimatedValue null ist, waehrend der eingehende Datensatz einen Wert traegt: es wird zusammengelegt (created=false), das Veto schweigt.
- Umgekehrt: eingehender Wert null, Kandidat mit Wert: es wird zusammengelegt.
- Beide Seiten tragen einen Wert derselben Groessenordnung (12000 und 15000): es wird zusammengelegt.
- Beide Seiten tragen einen Wert unterschiedlicher Groessenordnung (54471 und 387291): es wird NICHT zusammengelegt, es entstehen zwei Tender-Zeilen (created=true).
- Greift der Aktualisierungszweig (Treffer mit geaenderter Aenderungssignatur), enthaelt das geschriebene Datenobjekt auch den neu berechneten fingerprint.
- Die bestehenden Tests zu Stufe 1, Stufe 2, dem dedupActive-Riegel und ownerTenantId bleiben gruen.
In `apps/api/src/tenders/tender-dedup.service.ts`:
Den Fingerabdruck einmal am Anfang von `resolve` berechnen (die vorhandene Form
"vorberechneter Wert am Datensatz, sonst selbst rechnen" beibehalten) und diesen
einen Wert an beiden Stellen benutzen, an denen er gebraucht wird — im Anlegezweig
wie bisher und neu auch im Aktualisierungszweig.
Aktualisierungszweig: das Datenobjekt, das bei geaenderter Aenderungssignatur Titel,
Vergabestelle, CPV, Region, Frist, Wert und Verfahrensart auffrischt, bekommt
zusaetzlich das Feld fingerprint. Begruendung als Kommentar direkt daran: dieser
Zweig aendert genau die Felder, aus denen der Fingerabdruck gebildet wird; liesse
man ihn stehen, passte der gespeicherte Wert nicht mehr zum Inhalt der Zeile und die
Stufe 3 fande die Zeile nie wieder. An der Live-Datenbank gemessen halten heute drei
Gruppen echter Duplikate allein aus diesem Grund auseinander. ownerTenantId bleibt
unangetastet — dieser Zweig hat es nie geschrieben und schreibt es weiterhin nicht.
Fingerprint-Stufe in `findMatch`: die Feldauswahl der Kandidatensuche um
estimatedValue erweitern. Findet sich ein Kandidat, `valueBucketsContradict` aus
tender-fingerprint mit dem Wert des eingehenden Datensatzes und dem des Kandidaten
aufrufen. Liefert sie true, gilt der Kandidat als kein Treffer: die Methode faellt
durch auf null, der Aufrufer legt eine eigene Zeile an. Liefert sie false, wird wie
bisher ein Objekt aus id und contentHash zurueckgegeben — die Rueckgabeform der
Methode bleibt schmal, estimatedValue wird nur zur Pruefung gelesen und nicht
weitergereicht.
Der Wert kommt aus Prisma als Decimal, aus dem gefaelschten Prisma der Tests als
einfache Zahl. Deshalb eine kleine lokale Umwandlung benutzen, die null und
undefined auf null abbildet und alles andere durch Number schickt; Decimal traegt
valueOf und ueberlebt das unbeschadet.
Kommentar an der Stufe: das ist ein exakter Vergleich zweier Groessenordnungen und
ausdruecklich keine Aehnlichkeitssuche. Er spricht nur, wenn beide Seiten einen Wert
haben, und er rettet die Faelle, in denen zwei Lose desselben Bauvorhabens denselben
Titel tragen. Zwei Lose derselben Groessenordnung legt er weiterhin faelschlich
zusammen — das ist gemessen und bewusst getragen, nicht uebersehen.
In `apps/api/src/tenders/tender-dedup.service.spec.ts`: die vier Wert-Faelle als
eigenen describe-Block ergaenzen, jeweils ueber die vorhandene Bauweise mit dem
gefaelschten Prisma und dedupActive true. Damit die Stufe 3 ueberhaupt erreicht wird,
duerfen die Datensaetze weder ocid noch dieselbe Quelle-plus-Kennung teilen — das
macht der vorhandene Fingerprint-Test bereits vor, an ihm entlang bauen. Zusaetzlich
den bestehenden Test zum Aktualisierungszweig um die Erwartung erweitern, dass das
geschriebene Datenobjekt einen fingerprint enthaelt.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders/tender-dedup.service.spec.ts
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api type-check
Vier neue Tests decken die Wert-Widerspruchspruefung ab (null links, null rechts,
gleiche Groessenordnung, verschiedene Groessenordnung), der Aktualisierungszweig
schreibt den Fingerabdruck mit und ist dadurch abgedeckt, alle vorher vorhandenen
Tests der Datei sind weiterhin gruen.
Task 3: Nachrechnung der Bestandszeilen beim Start, an der Versionsmarke erkannt
apps/api/src/tenders/tender-fingerprint-backfill.service.ts, apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts, apps/api/src/tenders/tenders.module.ts
Warum ueberhaupt automatisch: nach der Formelaenderung traegt jede Bestandszeile
einen Hash der alten Formel und wuerde nie wieder auf einen neuen treffen. Die
Produktionsdatenbank wird von Hand nicht angefasst, und ein Bootstrap-Schritt, den
eine frische Installation still ueberspringt, ist in diesem Projekt bereits einmal
als Fehlerbild aufgetreten (Poll-Cron, Projektnotiz "Tender-Cron Bootstrap"). Die
Nachrechnung muss also von allein laufen, auf Bestand wie auf frischer Installation
dasselbe Ergebnis liefern — und darf nicht bei jedem Start 16.255 Zeilen anfassen.
Genau dafuer ist die Versionsmarke aus Task 1 da.
Neue Datei `apps/api/src/tenders/tender-fingerprint-backfill.service.ts` mit einem
injizierbaren Dienst `TenderFingerprintBackfillService`, der
OnApplicationBootstrap umsetzt und PrismaService injiziert — dieselbe Bauform wie
die Nachverschluesselung der LDAP-Bind-Passwoerter in
`apps/api/src/ldap/ldap-config.service.ts`, die als Vorlage dient.
Die Veraltet-Bedingung ist: fingerprint ist null ODER fingerprint beginnt nicht mit
FINGERPRINT_VERSION_PREFIX. Beide Faelle gehoeren zusammen in eine
ODER-Verknuepfung, weil die Nicht-beginnt-mit-Bedingung fuer leere Werte in SQL
nicht wahr wird und die leeren Zeilen sonst durchrutschen.
Eine oeffentliche Methode, die die Nachrechnung ausfuehrt und die Zahl der
geaenderten Zeilen zurueckgibt: in Seiten von 500 Zeilen die jeweils ersten noch
veralteten Zeilen mit id, buyerName, title und deadlineAt laden, fuer jede den neuen
Fingerabdruck berechnen und die Aktualisierungen einer Seite gebuendelt in einer
Transaktion schreiben. Die Seite braucht keinen Versatz: eine geschriebene Zeile
faellt aus der Veraltet-Bedingung heraus, die naechste Abfrage liefert automatisch
die naechsten. Abbruch, sobald eine Seite leer ist; zusaetzlich eine grosszuegige
Obergrenze an Durchlaeufen als Reissleine, damit ein unerwarteter Zustand nicht in
eine Endlosschleife fuehrt.
Der Lebenszyklus-Haken ruft diese Methode, protokolliert die Zahl der
nachgerechneten Zeilen auf Deutsch und schluckt einen Fehler mit einem Fehler-Log,
statt ihn zu werfen — ein misslungener Nachlauf darf den Start des Dienstes nicht
verhindern, genauso wie beim LDAP-Vorbild. Bei null veralteten Zeilen wird nichts
geschrieben und nichts geloggt.
In `apps/api/src/tenders/tenders.module.ts` den Dienst in die Anbieterliste
aufnehmen, und zwar VOR TenderSchedulerService: Nest ruft die
Bootstrap-Haken eines Moduls in der Reihenfolge der Anbieter auf, und die
Nachrechnung soll fertig sein, bevor der Poll-Cron registriert wird. Einen kurzen
Kommentar dazu an die Stelle setzen, sonst sortiert die naechste Aenderung die Liste
unbedacht um.
Neue Datei `apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts` mit
einem gefaelschten Prisma nach der Bauform der bestehenden Tender-Tests, die
folgende Faelle festhaelt: eine Datenbank ohne Zeilen loest keine Schreiboperation
aus und der Haken wirft nicht; Zeilen mit altem Hash und Zeilen mit leerem Hash
werden beide erfasst und tragen danach die neue Marke; ein zweiter Lauf direkt
danach schreibt nichts mehr; wirft die Datenbankabfrage, wirft der Haken trotzdem
nicht.
Zum Schluss die Nachrechnung einmal echt gegen die lokale Datenbank fahren (der
Verify-Block macht das), damit die Behauptung in der SUMMARY gemessen ist und nicht
geglaubt.
cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/tenders && pnpm --filter @tessera/api type-check
cd /home/vicolab/projects/tessera-ctl && DBIP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && GRP='SELECT count(*) FROM (SELECT fingerprint FROM "Tender" WHERE fingerprint IS NOT NULL GROUP BY fingerprint HAVING count(*)>1) g' && VOR=$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "$GRP") && pnpm --filter @tessera/api build && DATABASE_URL="postgresql://tessera:tessera_dev@$DBIP:5432/tessera" node apps/api/dist/tenders/backfill-tender-source.js && NACH=$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "$GRP") && ALT=$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc 'SELECT count(*) FROM "Tender" WHERE fingerprint IS NULL OR fingerprint NOT LIKE '"'"'v2:%'"'"'') && KONFLIKT=$(docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc 'SELECT count(*) FROM (SELECT fingerprint FROM "Tender" WHERE fingerprint IS NOT NULL GROUP BY fingerprint HAVING count(*)>1 AND count(DISTINCT floor(log(greatest("estimatedValue",1)))) FILTER (WHERE "estimatedValue" IS NOT NULL) > 1) g') && echo "Gruppen vorher=$VOR nachher=$NACH veraltet=$ALT wertkonflikt=$KONFLIKT" && test "$ALT" -eq 0 && test "$NACH" -gt "$VOR" && test "$NACH" -ge 20 && test "$KONFLIKT" -ge 1
Beim naechsten Neustart der lokalen API (macht der Nutzer selbst) darf im Log KEINE Meldung ueber nachgerechnete Fingerabdruecke stehen — die Datenbank wurde im Verify-Schritt bereits umgestellt, der Start muss ein reiner Leerlauf sein. Steht dort trotzdem eine Zahl, ist die Veraltet-Bedingung falsch herum und die Nachrechnung liefe bei jedem Start ueber alle 16.255 Zeilen.
Der Dienst existiert, ist im Modul vor dem Scheduler eingetragen und durch vier
Tests abgedeckt. Die lokale Datenbank traegt null veraltete Zeilen, die Zahl der
Fingerabdruck-Gruppen mit mehr als einer Zeile ist von 0 auf mindestens 20
gestiegen (erwartet nach heutiger Messung: 26), und mindestens eine dieser Gruppen
traegt widersprechende Wert-Groessenordnungen, wird von der Pruefung aus Task 2 also
kuenftig nicht zusammengelegt. Die gemessenen Zahlen stehen so in der SUMMARY, wie
der Befehl sie ausgegeben hat.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Quellportal -> Ingestion | Titel, Vergabestelle, Frist und Wert sind fremde, unvalidierte Daten und entscheiden nach dieser Aenderung mit weniger Feldern darueber, ob zwei Eintraege verschmelzen |
| Mandantenprivate Quelle (E-Mail-Alarm) -> plattformweiter Datensatz | Ein privat eingespeister Datensatz kann ueber die Fingerprint-Stufe an einen globalen Eintrag andocken |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-EFH-01 | Tampering | tender-fingerprint.ts / TenderDedupService Stufe 3 | medium | mitigate | Weniger Hash-Segmente heissen leichteres absichtliches Verschmelzen fremder Ausschreibungen. Gegenmassnahme im Plan: die Wert-Widerspruchspruefung aus Task 2 weist Zusammenlegungen zurueck, sobald beide Seiten einen Wert unterschiedlicher Groessenordnung tragen. Zusaetzlich bleibt der Riegel bestehen, dass die Stufe erst ab zwei aktiven Quellen ueberhaupt laeuft. |
| T-EFH-02 | Information Disclosure | TenderDedupService Stufe 3, ownerTenantId | low | accept | Docken ein mandantenprivater und ein globaler Eintrag aneinander, erscheint am globalen Eintrag eine zusaetzliche Quellenangabe. Der Inhalt war in dem Fall ohnehin oeffentlich (sonst haette er nicht getroffen), ownerTenantId wird vom Aktualisierungszweig unveraendert nie geschrieben, und die E-Mail-Alarm-Quelle ist ausgeliefert deaktiviert. Bewusst getragen, in der SUMMARY zu nennen. |
| T-EFH-03 | Denial of Service | TenderFingerprintBackfillService | low | mitigate | Eine Nachrechnung ueber 16.255 Zeilen bei jedem Start wuerde den Dienststart bremsen. Gegenmassnahme: Versionsmarke plus seitenweises Schreiben, sodass der zweite Start nichts mehr tut; der human-check in Task 3 prueft genau das nach. |
| T-EFH-SC | Tampering | npm/pip/cargo installs | n/a | n/a | Dieser Plan installiert kein Paket — keine neue Abhaengigkeit, kein Legitimitaets-Tor noetig. |
- `pnpm --filter @tessera/api exec vitest run src/tenders` ist vollstaendig gruen.
- `pnpm --filter @tessera/api type-check` ist sauber.
- Die lokale Datenbank enthaelt nach Task 3 keine Zeile mehr ohne Versionsmarke.
- Die Zahl der Fingerabdruck-Gruppen mit mehr als einer Zeile ist messbar gestiegen;
die gemessenen Zahlen (vorher/nachher/Wertkonflikt) stehen in der SUMMARY.
- Die drei benannten Grenzen stehen woertlich im Dateikommentar von
tender-fingerprint.ts und in der SUMMARY.
- Zwei Datensaetze derselben Ausschreibung aus DOE und aus einem Scraper ergeben
denselben Fingerabdruck — festgehalten in einem Test, der ohne diese Aenderung rot
waere.
- Zwei Lose desselben Bauvorhabens mit Werten verschiedener Groessenordnung bleiben
zwei Eintraege.
- Ein Eintrag, dessen Felder durch die Aenderungserkennung aufgefrischt werden, traegt
danach einen dazu passenden Fingerabdruck.
- Bestandsdatenbank und frische Installation kommen ohne Handgriff im selben Zustand
an; der zweite Start rechnet nichts nach.
- WINDOWS.md #11 kann geschlossen werden; 13-VERIFICATION.md Wahrheit 3 ist nicht mehr
durch die Hash-Formel blockiert (die Live-Gegenprobe mit einer zweiten aktiven
Scraper-Quelle bleibt davon unberuehrt offen).