From 4502664fb24bcdce0356e5d1b6a692804684f8a8 Mon Sep 17 00:00:00 2001 From: Schalli Date: Mon, 7 Sep 2026 10:34:18 +0200 Subject: [PATCH] docs(quick-260907-efh): Plan fuer Cross-Source-Dedup-Fix (WINDOWS #11) Fingerprint hasht kuenftig nur noch Vergabestelle/Titel/Frist, der Wert kehrt als Widerspruchspruefung am Treffer zurueck, Versionsmarke plus Bootstrap-Nachrechnung stellt Bestand und frische Installation gleich. Beim Erden gemessen und mit aufgenommen: der Aktualisierungszweig in TenderDedupService schreibt den Fingerabdruck nicht mit, wodurch drei Gruppen echter Duplikate schon heute allein durch Drift auseinanderstehen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K5jtbGzC5Sf9npJ3JCjKhq --- .../260907-efh-PLAN.md | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 .planning/quick/260907-efh-cross-source-dedup-greift-nicht-windows-/260907-efh-PLAN.md diff --git a/.planning/quick/260907-efh-cross-source-dedup-greift-nicht-windows-/260907-efh-PLAN.md b/.planning/quick/260907-efh-cross-source-dedup-greift-nicht-windows-/260907-efh-PLAN.md new file mode 100644 index 0000000..fceaa4e --- /dev/null +++ b/.planning/quick/260907-efh-cross-source-dedup-greift-nicht-windows-/260907-efh-PLAN.md @@ -0,0 +1,420 @@ +--- +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). + + + +Create `.planning/quick/260907-efh-cross-source-dedup-greift-nicht-windows-/260907-efh-SUMMARY.md` when done. + +Die SUMMARY muss die gemessenen Zahlen aus dem Verify-Schritt von Task 3 woertlich +enthalten und die drei Grenzen benennen: die faelschlich zusammengelegte Los-Gruppe +gleicher Groessenordnung, die Titel-allein-Zusammenlegung bei fehlender Vergabestelle +und Frist samt dem gemessenen Paar "LSA242", und die weiterhin offene Live-Gegenprobe +mit einer zweiten aktiven Quelle. +