Changelog
Unreleased
Hinzugefügt
added Academy: neuer Lerneinheit-Typ slides (Deck zum Durchklicken) + interaktive Lektions-Widgets, und der System-Onboarding-Kurs komplett neu als 8-Kapitel-Erzählung
, unit-progress.schema.ts, entities.ts, SlidePlayer.tsx, SlidesEditor.tsx, academy/widgets/, lms-grading.service.ts, lms-progress.service.ts, lms.routes.ts, onboarding-seed.service.ts; Konzept: docs/specs/onboarding-kurs-konzept.md). WHY: Der bisherige Onboarding-Kurs war strukturell sauber, aber didaktisch eine Feature-Tour: 12 Lektionen nach Modulen sortiert, jede Text-Unit ein HTML-Blob zum Scrollen, und textScrollPercent galt als „gelernt“. Ein kompetenter ITler findet den Ticket-Button allein; was er nicht allein findet, ist z. B. dass die Schnell-Erstellung „14 Uhr“ ohne „um“ stillschweigend ignoriert. Umsetzung — additiv, kein Backfill: Neuer type: 'slides' + slides[] an der Lerneinheit (jeder Slide kind: 'html' | 'widget' | 'checkpoint' mit stabiler id), Requirement slidesViewedPercent, viewedSlideIds[] am unit_progress — alles optional/zusätzlich, nichts zu migrieren. Fortschritt misst jetzt welche Slides gesehen wurden statt Scrollhöhe (evaluateRequirements-Zweig, processSlidesEvent, Route POST /unit-progress/:id/slides-event). Renderer SlidePlayer (Stepper, Fortschrittspunkte, Tastatur, Checkpoint-Reveal, Widget-Host via Allowlist — kein eingebetteter Code), Widgets nl-parser (reused die echte parseNaturalDate()), shortcut-showcase, dispatch-scoring (Ausblick), Autoren-UI SlidesEditor. Der System-Onboarding-Kurs ist als 8-Kapitel-Erzählung mit 19 Fragen neu geseedet, die gezielt die dokumentierten Stolperstellen prüfen; Auto-Dispatch-Fragen sind zurückgestellt (Feature noch nicht produktiv), die Auto-Dispatch-Slide bleibt als Ausblick. Rollout = Option B (von Moritz freigegeben): versionsbewusster Seed (contentVersion); ein älterer System-Kurs wird soft-gelöscht (wiederherstellbar) und ersetzt, laufende Enrollments verlieren ihren Fortschritt. Kein Backfill für das Feature selbst.
added Der HTTPS-Weg der Föderation wird jetzt end-to-end geprüft — zwei Installationen über den echten Transport
neu peer-federation.wire.test.ts, geändert peer-exchange.ts (remoteOutcomes exportiert). WHY: Der gesamte manuelle Test der Föderation lief über eine lokale Paarung (peerKind: 'local'). Die stellt im selben Prozess zu: kein Umschlag, kein HMAC, kein Zeitstempel, keine Protokollverhandlung, kein Ingress, keine Antwort zum Auswerten. Genau der Weg, den eine echte Partner-Installation nimmt, war damit nie gelaufen — und genau dort saß der Fehler, der jede Ablehnung der Gegenseite als „übertragen“ verbuchte. Über localhost ist das nicht nachstellbar: Der SSRF-Schutz blockiert localhost, 127.0.0.1 und alle privaten Bereiche, absichtlich ohne Umgehung (network-safety.ts), und ein Entwickler-Schalter dafür wäre eine Umgehung, die irgendwann in Produktion gesetzt wird. LÖSUNG: Zwei Fastify-Instanzen in einem Testprozess, verbunden über den echten Transport — ersetzt wird ausschließlich das globale fetch, das safeFetch am Ende aufruft. Alles davor und danach ist Produktionscode: planOutbound → Provider → Umschlag samt Versions-Gate → HMAC über ${ts}.${body} → SSRF-Prüfung (läuft scharf mit, deshalb TEST-NET-3 als Zieladresse statt einer privaten) → Ingress unter demselben /webhooks-Präfix wie in app.ts → Signatur, Rate-Limit, Capability, Engine → Antwort → Auswertung beim Absender. Sechs Zusicherungen: Kommentar reist und kommt an · eine 403-Ablehnung wirft und schreibt den Fingerabdruck nicht fort · eine 200-Ablehnung (skipped-not-holder unter der einseitigen Halter-Autorität) wird gelesen statt überhört · „Ihr Zeichen“ kommt zurück · beide Seiten protokollieren · eine gefälschte Signatur kommt nicht durch. Der Mutationstest hat den Aufbau zunächst widerlegt: In der ersten Fassung überlebten alle drei Mutationen (Ingress benennt das Antwortfeld um · Provider wertet nicht mehr aus · Signaturprüfung akzeptiert alles) unbeschadet — weil die einzige Ablehnung ein 403 war, der wirft, bevor die Antwort überhaupt gelesen wird. Der Test sah nach Abdeckung aus und prüfte die entscheidende Naht nicht. Erst der 200-Fall macht ihn wirksam; danach fällt jede der drei Mutationen. Bewusste Grenze: DNS und der echte Socket bleiben außen vor — dafür braucht es zwei erreichbare Hosts, und das gehört auf Staging. Kein Backfill (reiner Testcode + ein zusätzlicher Export).
added Föderation Welle 1: tenant_links als Vertrauens-Primitiv zwischen zwei codemetaOS-Installationen
neu tenant-link.ts (Typ + Schema), federation/{protocol,capabilities,signature,sync-log}.ts, peer-federation.ts (Node-only Subpath), tenant-link.service.ts, tenant-link.routes.ts (je + Tests), geändert entity-config.ts, permissions.ts, modules.ts, index-registry.ts, init.js, crud.routes.ts, sub-action-guard.ts, app.ts, docs/API.md. Das Fundament, noch ohne Kanal — Transport, Peer-Provider und Ingress folgen in Welle 2. Handshake: beidseitiger Konsens über einen invitationCode statt über Tenant-IDs, damit niemand fremde IDs enumerieren oder auf beliebige zeigen kann; Widerruf von beiden Seiten. Geheimnisse: zwei richtungsgetrennte, AES-256-GCM-verschlüsselte Werte — bewusst nicht gehasht, weil die HMAC-Prüfung in Welle 2 den Klartext zum Nachrechnen braucht; ein Hash trüge nur bei einem Bearer-Vergleich und gäbe weder Replay-Schutz noch Body-Bindung. Vier Schichten halten sie drin: protectedFields, Redaktion aus jeder generischen Antwort (derselbe Präzedenzfall wie takeBackToken weiter oben), eine DTO-Allowlist auf der Route, und Klartext-Rückgabe genau einmal beim Anlegen, Einlösen und Rotieren. Signatur-Bausteine nach dem Muster des Octoja-Webhooks (HMAC über Zeitstempel und Body, ±300 s) — ausdrücklich nicht nach dem generischen webhook-auth.ts, der keinen Zeitbezug hat und repo-weit ohnehin keinen Produktionsaufrufer besitzt. Fünf Befunde, die den Entwurf korrigiert haben: (1) 🔴 Das Feld heißt peerTenantId, nicht remoteTenantId — die einzige Funktion, die diese Collection heute schon liest (loadPeerConnection), fragt genau danach; eine Umbenennung hätte sie gebrochen. (2) 🔴 Der Sub-Action-Guard hätte den Einlösenden ausgesperrt: er löst über den tenant-eigenen Zugriffsfilter auf und verweigert der Gegenseite exakt die beiden Operationen, die beide Seiten laut ADR haben müssen — Lesen und Widerrufen. Drei dokumentierte Opt-outs, die Handler prüfen selbst über ein zweiseitiges Prädikat (ein dritter Tenant bekommt 404). (3) Ein Handshake über Installationsgrenzen kann nicht aus zwei Aufrufen bestehen — es gibt keine gemeinsame Datenbank; umgesetzt als dreistufiger Betreiber-Ablauf, dessen Service-Funktionen Welle 2 unverändert wiederverwendet. (4) Die Signatur-Datei kann nicht über den Haupt-Barrel laufen (node:crypto bricht das Web-Bundle) — eigener Subpath, samt der Falle, dass vitest.config.ts eine handgepflegte Alias-Liste hat. (5) Neu ergänzt: invitationExpiresAt (7 Tage) — ein nie ablaufender, uneingelöster Code ist ein dauerhafter Freifahrtschein, Peer zu werden. Verifiziert: 31 Shared-Tests (Replay in beide Richtungen, manipulierter Body, unlesbarer Zeitstempel als harter Fehlschlag, Versionsaushandlung, UND-Verknüpfung von Widerruf und Capability, kein Existenz-Orakel) + 32 Routen-Tests (vollständige Zustandsmaschine, doppeltes Einlösen, beidseitiger Widerruf, Rotation, Sichtbarkeit) + Redaktionstests; alle Drift-/Ratchet-Tests grün. Kein Backfill — die Collection hatte bisher keinen einzigen Schreiber. ⚠️ Offen: die Rotation ist nicht unterbrechungsfrei (kein Überlappungsfenster) — bis die Gegenseite das neue Geheimnis einträgt, steht der Kanal. Ein Überlappungsfenster ohne Ablauf-Sweep verdoppelt dauerhaft die Schlüssel, die eine Anfrage verifizieren können, und wäre schlechter; im Code vermerkt, in ADR 0445 als offene Frage geführt.
added Standard-Signatur und Standard-Absenderadresse auf drei Ebenen: mandantenweit, pro Org-Einheit, persönlich
) — neu email-defaults.ts (+ Test, 16 neu), email-sender-default.schema.ts, email-defaults.routes.ts, email-sender-default.service.ts, EmailSenderDefaultSelect.tsx, SignaturePreviewCard.tsx, OrgUnitEmailDefaultsPanel.tsx; geändert org-unit-members.ts (resolveUserOrgUnitChain), email-signature.schema.ts, index-registry.ts, signature.service.ts, signature.routes.ts, transition.service.ts, app.ts, send-email.ts, emailCompose.ts, signatures.ts, EmailComposeDialog.tsx, TenantSignatureManager.tsx, SignatureListPanel.tsx, de/en-Locales, docs/API.md. WAS: email_signatures.scope kennt zusätzlich 'org_unit' (mit orgUnitId), und die neue Collection email_sender_defaults hält je Ebene ein Standard-Postfach. Beides wird most-specific-first aufgelöst: persönlich → nächstgelegene Org-Einheit (über parentUnitId nach oben) → mandantenweit. Gepflegt wird es unter /settings/signatures (mandantenweit + ein Abschnitt „Pro Team/Abteilung“ mit OU-Auswahl) und in den persönlichen Einstellungen. WHY: Bei der Signatur gab es nur „alle gleich“ (Mandant) oder „jede:r pflegt selbst“ (User) — genau die Konstellation, in der bei 40 Mitarbeitenden 40 handgepflegte Signaturen entstehen und das Corporate Design zerfällt; Vertrieb, Support und Buchhaltung brauchen unterschiedliche, aber nicht individuelle Signaturen. Beim Absender gab es überhaupt keine Vorgabe: Der Composer nahm „das erste sendefähige Postfach“, also faktisch die Antwortreihenfolge von GET /email/accounts, und der Server fiel nach isNotificationSender auf accounts[0] zurück. Wer aus dem Vertrieb schrieb, verschickte damit standardmäßig aus support@ und merkte es erst, wenn die Kundenantwort im falschen Postfach lag. Die Kaskade liegt in @codemeta/shared, nicht in der API — die Signatur-Auflösung existierte bereits in zwei handkopierten Fassungen (API-Antwortpfad und Worker-Sendepfad), und genau diese Doppelung ist die Fehlerklasse, wegen der eine API-versendete Antwort eine andere Signatur trug als dieselbe Mail über den Worker; beide rufen jetzt dieselbe Funktion. Vier Entscheidungen, bei denen das Naheliegende falsch gewesen wäre: (1) Postfach-Referenz statt Freitextadresse — versendet werden kann nur über ein konfiguriertes Postfach, eine getippte Adresse wäre entweder unsendbar oder, wenn honoriert, eine Spoofing-Fläche. (2) Eigene Collection statt eines Felds pro Ebene — user_preferences ist mandantenübergreifend (Key ist die better-auth-Id), eine Postfach-Id ist aber mandantenspezifisch; ein Mehrmandanten-User hätte im zweiten Mandanten auf eine Id gezeigt, die es dort nicht gibt. (3) Reply-Kontext schlägt den Standard — eine Antwort auf eine an vertrieb@ gerichtete Mail geht aus vertrieb@ raus, auch wenn der persönliche Standard support@ ist, sonst zerreißt der Thread; serverseitig liegt die Kaskade in resolveOutboundEmailAccount entsprechend hinter Auswahl/from-Match/Thread-Postfach und vor isNotificationSender. (4) Ein frischer Compose startet wieder beim Standard (openCompose setzt die „vom Nutzer angefasst“-Marker zurück): die Absenderauswahl war bisher über die ganze Session klebrig, womit eine Standard-Absenderadresse nach dem ersten manuellen Wechsel unbeobachtbar geblieben wäre — eine Auswahl innerhalb eines Compose wird umgekehrt nie mehr von einer spät eintreffenden Server-Antwort überschrieben, denn Accounts, Signaturen und /email/defaults/effective laden unabhängig voneinander. Berechtigungen: mandantenweit settings_manage/admin_bypass; pro Org-Einheit zusätzlich die Leitung genau dieser Einheit (managerIds, Doppelspitze nach ADR 0148) — eine Teamleitung, die die Mail-Identität ihres Teams nicht selbst pflegen darf, macht die mittlere Ebene zur Admin-Bequemlichkeit; persönlich jede:r für sich, aber nur auf ein Postfach mit tatsächlichem Zugriff. Ebenen mit unbrauchbarem Postfach (inaktiv, gelöscht, geleert) werden beim Auflösen übersprungen statt hart zu scheitern — bewusst der Gegenpol zu ADR 0449, wo eine explizite Auswahl sichtbar scheitert; eine geerbte Vorgabe darf nicht jeden Versand blockieren, bis ein Admin es bemerkt. Nebenbei: email_signatures bekommt zwei Indizes, die die Collection vorher gar nicht hatte (sie stand nicht in der Index-Registry), und GET /signatures liefert jetzt zusätzlich die Signaturen der eigenen Teams. KEIN BACKFILL: Eine leere email_sender_defaults und org_unit-freie email_signatures reproduzieren exakt das bisherige Verhalten; Bestandsdokumente bleiben unangetastet, fehlendes orgUnitId wird als null gelesen.
added Signatur-Platzhalter: Fallback-Wert pro Platzhalter
neu signature-variables.ts (+ Test, 17 neu), geändert email-signature.schema.ts, signature.service.ts (+ Test), send-email.ts, SignatureEditor.tsx, SignatureVariableExtension.ts, signatureVariableUtils.ts, EmailComposeDialog.tsx, SignatureListPanel.tsx, TenantSignatureManager.tsx, MailbausteineSettingsPage.tsx, de/en-Locales, ADR 0454. WAS: Ein Platzhalter kann jetzt einen Ersatzwert tragen — {{phone|+49 231 555-0}}. Im Editor wird er gesetzt, indem man den Platzhalter-Chip anklickt; der Chip zeigt den Ersatz danach als Telefon › +49 231 555-0. WHY: Bisher wurde ein Platzhalter ohne Profilwert kommentarlos durch den Leerstring ersetzt. Die Zeile Tel.: {{phone}} ging damit bei jedem Mitarbeitenden ohne hinterlegte Telefonnummer als hängendes Tel.: an den Kunden raus — sichtbar beim Empfänger, unsichtbar beim Absender. Mandanten wollen dort die Zentrale stehen haben, und der sinnvolle Ersatz ist pro Platzhalter verschieden (Zentrale für phone, info@-Adresse für email), deshalb am Platzhalter statt global. Kein Backfill: Bestehende {{key}} behalten exakt ihre Bedeutung, der Fallback ist Opt-in beim Bearbeiten — eine fallbacks-Map am Dokument hätte für alle Bestandssignaturen ein Feld nachziehen müssen und wurde deshalb verworfen (ADR 0454). Nebenbei ersetzt die Änderung vier von Hand kopierte Kopien desselben {{key}}-Marker-Regex (API-Sendepfad, Worker-Sendepfad, Compose-Dialog, Editor-Vorschau) durch einen Resolver in @codemeta/shared; die vier waren bereits auseinandergelaufen (der Worker-Zweig ohne User-Kontext löste nur {{company}} auf), und ein Fallback, der nur auf zweien davon greift, hätte die Vorschau vom tatsächlich versendeten Text abweichen lassen. Der Fallback wird beim Einsetzen HTML-escaped — Mandanten-Signaturen schreibt ein Admin, gerendert werden sie in die Mail jedes Mitarbeitenden. Freitext-Felder der Mailbausteine ({{?Label}}) bleiben ausgenommen; sie füllt der Absender beim Einfügen.
added Ticketliste und Ticket-Boards: Filter „wird in Codemeta OS bearbeitet“ und Schlagwort-Filter
geändert entity-config.ts (+ Test, 25 neu), board-types.ts, crud.routes.ts (+ Test, 9 neu), board.routes.ts (+ Test, 6 neu), TicketsPage.tsx, boardTypes.tsx, BoardFilterBar.tsx, CreateBoardDialog.tsx, neu useTicketTagOptions.ts, de/en-Locales, docs/API.md. WHY: Während des TANSS-Cutovers liegen in derselben Liste zwei Sorten Tickets nebeneinander — gespiegelte, die TANSS führt und die Codemeta OS mit 423 gegen jede Bearbeitung sperrt (ADR 0377), und lokal geführte bzw. per Übernahme geholte (ADR 0432). Wer seine Arbeit sehen wollte, musste die Spiegel Zeile für Zeile überlesen; ein Filter darauf existierte nicht, weil „wird hier bearbeitet“ kein gespeichertes Feld ist, sondern die Ableitung aus source + externalOwnership. Deshalb ist der neue Parameter ?handledIn=codemeta|external kein gewöhnlicher Feldfilter: Die Route fängt den Schlüssel ab und mischt die Klausel aus dem neuen handledInFilter() per $and dazu. Dieses steht bewusst in derselben Datei wie das Prädikat isExternallyMastered(), das der 423-Guard und die Detailseite benutzen — ein Ticket, das die Liste unter „Nur in Codemeta OS“ zeigt und die Detailseite dann sperrt, wäre genau die Asymmetrie, gegen die ADR 0432 das Prädikat zentralisiert hat; ein Test führt Query und Prädikat über eine Dokumentenmatrix gegeneinander. Der Vergleich läuft case-insensitiv, weil source im Bestand gemischt geschrieben wurde ('WECLAPP' aus dem Rechnungs-Sync, 'weclapp' aus dem Order-Sync), und trifft nur exakte Quellwerte — source trägt bei Tickets auch den Erstellungskanal, ein E-Mail-Ticket (source: 'EMAIL') darf nie mitgefangen werden. In der Ticketliste steht der Filter zweifach zur Verfügung: als Feld „Bearbeitung“ im Filterdialog (beide Richtungen) und als Umschalter „Nur Codemeta OS“ in der Status-Klappliste. Beide schreiben denselben handledIn-Schlüssel in die Dialogfilter statt eigenen Seitenzustand zu führen — zwei Zustände für dieselbe Einschränkung liefen zwangsläufig auseinander (Chip an, Dialog leer), und so landet der Filter ohne Zusatzverdrahtung in Saved Views und der Sitzungswiederherstellung. Auf Boards wirkt er sowohl als Schnellfilter in der Filterleiste als auch als Dauerfilter der Board-Konfiguration; da handledIn kein Dokumentfeld ist, fängt board.routes.ts den Schlüssel in beiden Schleifen ab — landete er über die generische Zuweisung als itemFilter.handledIn in der Query, wäre das Board kommentarlos leer. Beide Scopes gewählt = keine Einschränkung. Der Schlagwort-Filter schließt die zweite Lücke: tags stand auf keiner Filter-Allowlist, ein Filter darauf wurde also serverseitig still verworfen; jetzt ist es in ALLOWED_FILTERS.tickets und in den allowedQuickFilters der Ticket-Boards. Mehrfachauswahl in der Liste wirkt als ODER (tags__in → MongoDB $in auf dem Array). Die Auswahlliste kommt aus dem gepflegten ticket_tags-Katalog (neuer gemeinsamer Hook in hooks/, weil Liste, Board-Leiste und Board-Dialog ihn brauchen und features/boards → features/tickets die Feature-Grenze verletzen würde); frei eingetippte Schlagworte tauchen dort bewusst nicht auf, weil die Liste sonst mit jedem Tippfehler wüchse — filterbar sind sie über tags__contains trotzdem. Kein Backfill: beide Filter lesen ausschließlich vorhandene Felder und tolerieren deren Fehlen (kein source = hier bearbeitet, kein tags = kein Treffer).
added Factorial-HR-Anbindung: ADR 0451 + Spec + Fundament (Provider, Client, Abbildungen, Echo-Guard, Webhook-Kette) — Sync-Läufe und Oberfläche stehen noch aus
neu ADR 0451, factorial-hr-integration.md, factorial-sync.ts (Subpath-Barrel @codemeta/shared/factorial), factorial/types.ts, factorial/client.ts (+ Test, 42), factorial/leave-mapping.ts (+ Test, 33), factorial/shift-mapping.ts (+ Test, 40), factorial/echo-guard.ts (+ Test, 29), _external-references-shared.ts, factorial.provider.ts, factorial-webhook.service.ts, factorial-registration.test.ts (16); geändert integrations.ts, common.ts, entities.ts, integration-providers.ts, queues.ts, vacation-request.schema.ts, sick-leave.schema.ts, attendance-shift.schema.ts, employee-profile.schema.ts, integration.service.ts, integration.routes.ts, webhook-ingress.routes.ts, vitest-Configs von api + worker. WHY: Systemhäuser, die Factorial als Personalsystem einsetzen, erfassen heute doppelt: Urlaub wird in Factorial beantragt, aber die Dispo-Konsole in Codemeta weiß nichts davon; Zeit wird am Ticket in Codemeta gebucht, muss aber für die Lohnabrechnung nach Factorial. Da Codemeta für jede Factorial-Domäne eine eigene produktive Implementierung hat, ist die Anbindung keine Lückenfüllung, sondern eine Hoheitsverhandlung — ohne explizite Festlegung entstünden zwei Systeme, die beide schreiben dürfen und deren Urlaubssalden auseinanderlaufen. WAS (Entscheidungen in ADR 0451): Hoheit pro Domäne statt pro System — Factorial führt Genehmigung und Kontingent, Codemeta den Roh-Stempel samt Hash-Kette und ArbZG-Prüfung, Krankheit fließt ausschließlich eingehend. Eigener Sync-Pfad statt generischem Entity-Sync, weil attendance_shifts kein Entity ist (kein ENTITY_CONFIG-Eintrag, append-only, hash-verkettet) und die Abwesenheits-Abbildung semantisch statt feldweise ist. Personen-Paarung ausschließlich über exakte Arbeits-E-Mail bzw. Personalnummer, sonst manuell — niemals Namensähnlichkeit: Eine falsche Zuordnung bedeutet hier nicht „Datensatz im falschen Ordner“, sondern „Krankmeldung von A erscheint bei B“. Abwesenheitsarten sind zuordnungspflichtig ohne Standardwert; der naheliegende Default VACATION wäre der teuerste Fehler, weil Elternzeit oder unbezahlter Urlaub still vom Jahreskontingent abzögen. Echo-Vermeidung zweifach (Mutationsquelle FACTORIAL/FACTORIAL_SYNC und Nutzlast-Hash in externalReferences) — die Quelle allein reicht nicht, weil Factorial auch bei bloßer Bestätigung ein Ereignis schickt, der Hash allein nicht, weil Factorial Werte normalisiert zurückgibt. Ein Fund beim Bauen: externalReferences musste in EXCLUDED_FIELDS von hash-chain.ts — die Kette arbeitet mit einer Ausschlussliste, nicht mit einer Auswahl zeitrelevanter Felder, also wäre jede gespiegelte Schicht ab dem ersten Sync-Lauf ein falscher HASH_MISMATCH im nächtlichen Verify gewesen (dieselbe Klasse wie der createdAt-Fehler aus ADR 0438 Phase 2, nur erst mit produktiver Anbindung sichtbar). Fünf Regressionstests halten das fest. Nebenbei entdrahtet: daysRequested wurde bisher nur in GET /vacation-preview gerechnet; die reine Faltung liegt jetzt als foldVacationDays in vacation-days.ts (+ Test, 25) und wird von vacation-approval.routes.ts und künftig vom Sync benutzt — eine zweite Implementierung wäre garantiert abgewichen, und dann zeigte ein importierter Antrag eine andere Tageszahl als ein lokal gestellter, ohne dass entscheidbar wäre, welche stimmt. Sicherheitsposition: Der Factorial-API-Key hat laut Hersteller Vollzugriff und läuft nie ab; er ist deshalb vorerst nur für den eigenen Mandanten freigegeben, OAuth2 ist Voraussetzung für Kundenmandanten (ADR 0451 §8). Die Webhook-Zustellung ist unsigniert und wird konsequent als Anstoß behandelt, nie als Datenquelle — parseWebhookPayload liest ausschließlich Kennungen; ein Test nagelt fest, dass keine Nutzdaten aus der unsignierten Nutzlast übernommen werden. STATUS — ehrlich: Fundament steht und ist getestet (160 neue Tests, API-Typecheck grün); noch nicht verdrahtet sind die eigentlichen Sync-Läufe (apps/worker/src/lib/factorial/), der Reconcile-Repeatable, die Personen-/Art-Zuordnungsmaske und das Einstellungs-Panel. Die Anbindung ist damit noch nicht benutzbar — der Provider ist auswählbar und testbar, es fließen aber keine Daten. KEIN BACKFILL: Alle neuen Felder sind optional mit Laufzeit-Fallback; Bestandsdokumente ohne externalReferences verhalten sich exakt wie bisher. Der Erstimport wird ausdrücklich gestartet und ist auf ein Startdatum begrenzt — kein automatischer Vollimport der Historie beim Aktivieren.
added TANSS-Webhooks lassen sich endlich registrieren — POST /integrations/:id/tanss-sync mit action: 'register-webhooks' plus Knopf „Webhooks registrieren“
geändert integration.routes.ts, IntegrationSettingsPage.tsx, neu tanss-webhook-registration.test.ts (5), de/en-Locales. WHY: Der Hybrid aus ADR 0362 war vollständig gebaut — Event-Regeln im Provider, Token-Ingress, 15-s-Dedupe, Inbound-Job, Handler — und trotzdem tot: Kein Codepfad hat je eine Regel in TANSS angelegt. Drei unabhängige Brüche, jeder für sich ausreichend: (1) tanss steht in LIGHTWEIGHT_PROVIDERS, deshalb prüft POST /integrations/:id/enable das ganz oben, setzt isActive und kehrt zurück — der Registrierungsblock darunter ist für TANSS unerreichbar; (2) selbst wenn er liefe, iteriert er entitySyncConfigs, das die Setup-Maske für Lightweight-Provider bewusst als [] anlegt, und der Zusatzpfad kennt nur salesOrder/shipment (weclapp) — kein Aufruf hätte je 'ticket' übergeben; (3) und käme einer durch, bekäme er die Slug-URL /webhooks/<slug>/tanss, weil die Callback-Berechnung an allen fünf Aufrufstellen config.provider === 'weclapp' ? tokenUrl : slugUrl lautet — der TANSS-Zweig des Ingress sitzt aber ausschließlich in der Token-Route. Eine dort registrierte Regel wäre unsichtbar kaputt: Delivery wird protokolliert, mit 200 quittiert, löst nichts aus. Der Ausfall war lautlos, weil der Delta-Pull weiterlief; die Latenz war einfach das Poll-Intervall (15 min Gate + bis zu 5 min bis zum nächsten Anstoß). Gemessen auf der Live-Instanz: registeredWebhooks: 0 und null Deliveries in 7 Tagen. WAS: Die Registrierung hängt an der vorhandenen TANSS-Route statt am generischen /enable — sie kennt den Config-Kontext und hat bereits eine Schaltfläche in der Maske. Die Callback-URL wird bewusst aus webhookConfig.webhookId gebaut statt aus dem gespeicherten ingressPath, damit sie nicht wieder auf der toten Slug-Route landen kann. Registriert wird für 'ticket' und 'support' — die Schlüssel von TANSS_TRIGGERS sind Singular, 'tickets' würde „No TANSS triggers defined“ werfen. Wiederholtes Klicken ist sicher: zuvor angelegte eigene Regeln werden erst per deleteWebhook abgeräumt, sonst sammelt TANSS bei jedem Klick einen weiteren Satz. Solange keine Regel registriert ist, warnt die Maske sichtbar — vorher war der tote Pfad von außen nicht erkennbar. Der Guard-Test nagelt alle vier Glieder der Kette fest (Action existiert und ist im Body-Typ zulässig, Entitätsnamen decken sich mit TANSS_TRIGGERS, Registrierung zielt auf die Token-Route, Ingress hat dort einen TANSS-Zweig, Idempotenz) — ein reiner Aufrufer-Grep hätte den Bruch nicht gefunden, weil registerWebhook sehr wohl Aufrufer hat, nur keinen, den eine TANSS-Config erreicht. Kein Backfill.
added Abschluss-Mail: Standard-Text und Anreden sind jetzt pro Mandant konfigurierbar, inkl. freier Anrede und grafischem Platzhalter-Editor
neu ticket-close-email-config.schema.ts (+ Test, 14), ticket-close-email-config.routes.ts, PlaceholderTextArea.tsx, useTicketCloseEmailConfig.ts, TicketCloseEmailSettingsPage.tsx, ADR 0450; geändert CloseEmailDialog.tsx (+ Test, 8), TicketDetailPage.tsx, TicketConfigSettingsPage.tsx, app.ts, index-registry.ts, init.js, direct-write-ratchet.test.ts, de/en-Locales. WHY: Der Dialog aus ADR 0223/ADR 0294 war an zwei Stellen unveränderlich, ohne dass das je entschieden worden wäre: der Standard-Text kam aus t('tickets.closeEmail.defaultMessage', …) — Formulierung ändern hieß Release —, und die Anrede war eine im Code gebaute Liste aus höchstens vier Varianten in einem StaticSelectForm ohne freeSolo. Für Adressaten außerhalb dieser vier Formen (Sammelpostfach, „Liebes Team der …“) blieb nur, die Zeile nachträglich im Mail-Editor zu überschreiben. Ein ticket_closed-Eintrag im E-Mail-Vorlagen-Katalog existiert nicht, und die Mailbausteine unter /settings/mailbausteine liest der Dialog nicht. WAS: Neue Mandanten-Collection ticket_close_email_configs (ein Dokument je Mandant, GET/PUT /api/v1/tickets/close-email-config) mit Standard-Text, den vier Anrede-Vorlagen und dem Schalter „freie Anrede erlauben”. Die Maske hängt als vierter Tab Abschluss-Mail an /settings/ticket-config — bewusst kein neuer Nav-Eintrag. Im Dialog gibt es zusätzlich den Eintrag „Eigene Anrede …“, der ein Textfeld einblendet; ohne Eingabe bleibt „Weiter zum Mail-Editor” deaktiviert, denn eine leere Anredezeile wäre schlechter als jede Vorlage. Platzhalter grafisch: PlaceholderTextArea rendert je Feld Chips für alle dort gültigen Platzhalter (Klick fügt am Cursor ein, nicht am Ende), darunter eine Live-Vorschau mit Beispieldaten, in der ersetzte Stellen farbig und ungültige durchgestrichen erscheinen, plus eine Klartext-Warnung für unbekannte Tokens — solange eine besteht, ist Speichern gesperrt. Der Katalog (TICKET_CLOSE_EMAIL_PLACEHOLDERS) ist geschlossen und pro Feld gescoped: Anrede-Vorlagen kennen nur die kontaktabgeleiteten Platzhalter, {{ticketNumber}} dort quittiert die API mit 422 statt das Token still zu verwerfen. Unbekannte Tokens rendern zur Laufzeit als leerer String, nie wörtlich, und renderTicketCloseEmailTemplate räumt anschließend Leerzeichen/Satzzeichen auf, damit „Hallo {{contactFirstName}}“ ohne Vornamen als „Hallo“ endet statt als „Hallo ,“. KEIN BACKFILL: Jedes Textfeld ist nullable; null heißt „nicht konfiguriert” und der Client nimmt seinen i18n-Text — also exakt das bisherige Verhalten. Ein Mandant, der die Seite nie öffnet, merkt nichts. Der bewusste Preis: ein selbst verfasster Text ist nicht mehr übersetzt und gilt für alle Sprachen; der Hinweistext auf der Seite sagt das ausdrücklich, und ein geleertes Feld wird serverseitig wieder null. Die Collection steht mit Begründung in der ALLOWLIST des Direct-Write-Ratchets (Klasse contract_notification_configs: mandantenseitig verfasste Vorlagen für einen Dialog, kein Entity, keine GoBD-Semantik — das auditierte Artefakt ist die versendete Mail). Die i18n-Keys tickets.closeEmail.salutationFormalM/F/Neutral/Informal (mit {{name}}) entfallen zugunsten von settings.closeEmail.defaults.*, damit die editierbare Vorlage dieselbe Platzhalter-Sprache spricht wie der Editor daneben. Verifiziert: 14 Shared-Tests (Rendering, Scope-Prüfung, Normalisierung) + 8 Dialog-Tests (i18n-Fallback ohne Konfiguration, konfigurierter Text mit Platzhaltern, konfigurierte Anrede, freie Anrede inkl. Submit-Sperre und Abschaltbarkeit), Ratchet- und Index-Drift-Tests grün.
added Fehlgeschlagene E-Mails erneut versenden — POST /messages/:id/resend plus Tab „Versandfehler“ im E-Mail-Eingang
) — geändert email.routes.ts, transition.service.ts, message.schema.ts, entity-config.ts, EmailInboxPage.tsx, neu sendFailureColumns.tsx, MessageSendFailureNotice.tsx, docs/API.md. WHY: Zum Sichtbarmachen (Eintrag unter „Fixed“) gehört die Handlung: Ohne Wiederholung bleibt dem Bearbeiter nur, die Antwort komplett neu zu tippen. Retry: Der Transition-Graph bekommt die Rückkante SEND_FAILED → SENT — bewusst als Kopie der DRAFT → SENT-Regel mit demselben requireRecipient-Guard und demselben sendEmailHook, denn ein Resend ist ein Send; ein eigener, schlankerer Retry-Pfad hätte über kurz oder lang Signatur-Injektion, Font-Wrapping oder Attachment-Auflösung verloren, ohne dass es auffällt. Zusätzlich SCHEDULED → SEND_FAILED, damit ein fehlgeschlagener verzögerter Versand (ADR 0324) nicht dauerhaft als „geplant“ erscheint — bisher blieb er ununterscheidbar von „wartet noch“ hängen. Doppelversand-Schutz zweistufig: (1) Statusgate — nur SEND_FAILED ist retrybar, alles andere 409; eine bereits gesendete Nachricht kann konstruktionsbedingt kein zweites Mal raus. (2) Atomarer In-Flight-Lock — der Lock-Write übergibt die Dokument-version an updateEntity und nutzt damit die vorhandene optimistische Concurrency innerhalb der Transaktion: Zwei gleichzeitige Klicks kollidieren dort, der Verlierer erreicht den Provider nie. Bewusst kein findOneAndUpdate — direkte Collection-Writes auf Entity-Collections sind per Ratchet verboten, und die Versionsprüfung liefert dieselbe Atomarität geschenkt. Der Lock verfällt nach 5 min (MESSAGE_SEND_LOCK_MS, gespiegelt vom Inbound-Muster aus ADR 0231), damit ein Absturz mitten im Versand die Nachricht nicht dauerhaft unversendbar macht. Schlägt der Retry erneut fehl, wird sendError aktualisiert (der alte Grund ist überholt), der Lock freigegeben, Status bleibt SEND_FAILED — beliebig oft wiederholbar. Permission message_create (wer senden darf, darf erneut senden), tenantId immer im Filter, RFC 7807 durchgängig. SLA: ticket.respondedAt (ADR 0241) braucht keinen Zusatzcode — alle Schreibpfade laufen über entity.service, und der Worker rechnet die Erstreaktion bei jedem Message-Statuswechsel aus der frühesten tatsächlich SENT-Nachricht neu; ein erfolgreicher Retry stoppt die Uhr, ein fehlgeschlagener lässt sie laufen. Neue Felder auf message (additiv, alle in protectedFields, damit kein Client ein Sendeergebnis fälscht oder den Lock hält): sendError {message, code, provider, at}, sendAttempts, lastSendAttemptAt, sendInProgress, sendStartedAt. Der Provider-Name wird im Hook an den geworfenen Fehler geheftet, damit die Anzeige sagen kann, welches Postfach abgelehnt hat. Übersicht: kein neuer Nav-Eintrag, sondern ein Quick-Filter-Tab „Versandfehler“ auf /email-inbox, der Ressource und Spalten des Grids tauscht (/api/v1/messages?status=SEND_FAILED statt /api/v1/email-inbox) — die beiden Ansichten sitzen auf verschiedenen Collections. Der Tab-Wert send_failed reist als routingStatus mit, steht aber nicht in der messages-Filter-Allowlist und wird serverseitig ignoriert; gefiltert wird ausschließlich über den injizierten status-Filter. Beide Grids teilen persistKey, damit der zuletzt gewählte Tab einen Reload überlebt. Kein Backfill.
Geändert
changed Die Systemrolle „Disposition“ darf Tickets standardmäßig löschen (ticket_delete)
geändert apps/api/src/services/role.service.ts (DEFAULT_ROLES, Rolle dispatcher): schemaPermissions für ticket von ['create', 'read', 'update'] auf ['create', 'read', 'update', 'delete']. WHY: Die Disposition ist die faktische Service-Desk-Leitung — sie weist zu, priorisiert, disponiert und hält die Queue sauber, konnte aber genau die dafür nötige Aufräum-Aktion nicht ausführen: Dubletten aus dem Mail-Ingress, versehentlich doppelt angelegte Tickets und Spam mussten bisher an Owner/Admin eskaliert werden, obwohl die Rolle bereits ticket_assign, ticket_budget_manage und alle Statusübergänge (transitions: ['*']) besitzt. Risiko begrenzt: delete ist im Ticketing ausschließlich ein Soft-Delete (deletedAt/deletedBy via softDeleteEntity) mit vollständigem JSON-Patch-Eintrag in history — reversibel und auditierbar; ein Hard-Delete existiert im Produkt nicht. Wirkung auf die Berechtigung: getUserPermissions() mappt die Action delete direkt auf die Permission ticket_delete (role.service.ts), die sowohl den generischen DELETE /api/v1/tickets/:id-Pfad (apps/api/src/routes/crud.routes.ts) als auch den „Ticket löschen“-Button im Detail-Sidebar (canDelete('ticket'), apps/web/src/features/tickets/TicketDetailPage.tsx) und den Ticket-Merge in apps/api/src/routes/bulk.routes.ts freischaltet. Der Data-Scope der Rolle für ticket ist bereits mode: 'all', die Löschberechtigung gilt also für alle Tickets des Mandanten. Kein Backfill nötig: Systemrollen sind code-verwaltet — ensureDefaultRoles() (ADR 0049) $sett schemaPermissions bei jedem Rollen-Abruf aus DEFAULT_ROLES neu, bestehende Mandanten übernehmen die Permission also automatisch beim nächsten Request. Wer die Disposition ohne Löschrecht betreiben will, dupliziert die Rolle (POST /roles/:id/duplicate) und entfernt delete in der Kopie — die Seed-Synchronisation fasst Custom-Rollen nicht an.
changed Zeiterfassung: Techniker dürfen eigene, noch nicht freigegebene Zeitbuchungen löschen — APPROVED-Löschung bleibt Prüfenden vorbehalten
geändert role.service.ts (System-Rolle „Technik“), time-record-guards.ts (neu checkTimeRecordDeletable), entity-delete-guards.ts, entity.service.ts (softDeleteEntity-Option actor), crud.routes.ts, bulk.routes.ts, TimeTrackingPage.tsx, TimeEntryDialog.tsx, ProjectTimeRecordList.tsx, Tests in time-record-guards.test.ts + entity-delete-guards.test.ts. WHY: Nutzerwunsch 07.08.2026: Techniker sollen falsch erfasste Leistungen selbst korrigieren können, statt für jede Fehlbuchung einen Admin zu bemühen — aber eine bereits freigegebene Buchung ist eine Prüferentscheidung und darf nicht still per Löschung aus der Welt geschafft werden (das wäre die Umgehung des Freigabe-Gates aus checkTimeRecordApprovalAllowed, das APPROVED→X auf dem PATCH-Pfad bereits Prüfenden vorbehält). Rolle: „Technik“ trägt jetzt time_record_delete; der bestehende Data-Scope created_by begrenzt weiterhin auf eigene Buchungen. Guard: checkTimeRecordDeletable ergänzt die GoBD-Löschsperre (RECORDED/INVOICED/CREDITED, gilt für alle) um die Regel „APPROVED nur mit time_record_approve/Admin“ (403 TIME_RECORD_APPROVED_DELETE_FORBIDDEN). Durchgesetzt am Choke-Point assertEntityDeletable — die HTTP-Pfade (Single-DELETE + beide Bulk-Routen) reichen dafür den neuen Permission-Kontext actor an softDeleteEntity durch; System-Aufrufer ohne actor (Worker, Integrations-Syncs — z. B. spiegelt TANSS extern bereits erfolgte Löschungen) unterliegen bewusst nur der GoBD-Sperre. Web: Die Löschen-Buttons (Leistungserfassungs-Liste, Zeitbuchungs-Dialog, Projekt-Zeiten-Tab) verstecken sich bei APPROVED ohne Freigaberecht, statt einen toten Button mit Server-403 zu zeigen; im Projekt-Zeiten-Tab war der Button bisher sogar für abgerechnete Status sichtbar (Server-422 erst nach Klick) — jetzt gleiche Sperrlogik wie die Liste. Verifiziert: 38 Tests der beiden Guard-Suiten grün (11 neue), typecheck api + web grün. Kein Backfill — reine Berechtigungs-/Guard-Änderung; Bestands-Tenants mit Custom-Rollen sind unberührt, die System-Rolle wird von ensureDefaultRoles beim nächsten Start aktualisiert.
Behoben
fixed 🔴 Eine Admin-Übersteuerung war im Nachhinein nicht von einer echten Kundenfreigabe zu unterscheiden
geändert approval.service.ts, approval-notification-text.ts, neu approval-override.test.ts (5 Tests) + 3 Tests in approval-create-gate-text.test.ts. WHY: loadApprovalForDecision lässt neben den adressierten Genehmigern auch jeden Administrator entscheiden. Das ist bewusst so und richtig — ohne diesen Ausweg blockiert ein Ticket für immer, wenn der Genehmiger im Urlaub ist, das Unternehmen verlassen hat oder die Regel auf einen falschen Kontakt zeigt. Der Preis: Ausgerechnet die Partei, gegen die das Gate schützt, kann überstimmen. Der Zweck einer Kundenfreigabe ist „der Kunde stimmt zu, bevor der Dienstleister abrechenbaren Aufwand erzeugt“ — und derselbe Dienstleister kann diese Zustimmung selbst erteilen. Tragbar ist das nur, wenn es erkennbar bleibt; gespeichert wurde aber allein decidedBy, und in der Historie stand danach schlicht „genehmigt“. Niemand konnte mehr sagen, ob der Kunde je gefragt wurde. LÖSUNG: decidedAsOverride wird bei Freigabe und Ablehnung festgehalten, und die Mitteilung an den Antragsteller sagt es ausdrücklich. Die Falle, die den Fix schwieriger macht als er aussieht: Token- und Portalweg schreiben keine Benutzer-ID in decidedBy, sondern den Kanal (email-token, customer-portal). Eine naive „steht der Entscheider in der Genehmigerliste?“-Prüfung hätte damit ausgerechnet die echte Kundenfreigabe als Übersteuerung gebrandmarkt — also das Gegenteil der Aussage. Verifiziert: 8 Tests, beide Mutationsrichtungen gefangen (Kanal-Ausnahme entfernt ⇒ Kundenfreigabe gilt als Übersteuerung · nie eine Übersteuerung melden). Kein Backfill — Bestandsentscheidungen tragen das Feld nicht und gelten damit als nicht-übersteuert; das ist die einzig ehrliche Lesart, weil die Information für sie nie erhoben wurde.
fixed Die fail_open-Alarmzeile nannte nicht, WELCHE Regel übergangen wurde
geändert ticket-create-gate.service.ts. WHY: outcome() setzte policyId fest auf null, für jede Entscheidung. Ausgerechnet auf der einzigen Zeile mit severity: 'error' — die meldet „eine konfigurierte Freigabepflicht wurde nicht durchgesetzt“ — fehlte damit die Angabe, um welche Regel es geht. Bei einem Tenant mit einer Handvoll Regeln kommt man über den Kunden noch hin; bei fünfzig Regeln auf zwanzig Kunden steht der Betreiber vor einem Alarm, den er nicht auflösen kann. Am 04.08.2026 im Test bestätigt: beide fail_open-Zeilen im System ohne Regel-ID. LÖSUNG: outcome() nimmt die Regel entgegen; jeder fail_open-Fall tritt ohnehin nach der Regelauflösung ein (davor wäre es no_policy) — die Angabe war vorhanden und wurde nur nicht mitgegeben. Gilt ebenso für blocked und already_approved.
fixed 🔴 Der Kunde bekam Workflow-Sprache statt Kundensprache — und die Übersicht verschwieg, dass eine Regel Tickets blockiert
geändert approval-pseudo-status.ts, approval-notification-text.ts, approval.service.ts, transition-approval.service.ts, ApprovalPoliciesPage.tsx, neu approval-create-gate-text.test.ts (8 Tests). WHY: Am 04.08.2026 ging an einen Kunden die Mail „Ein Workflow-Übergang erfordert Ihre Genehmigung: … Übergang: Wartet auf Freigabe → Offen“. Empfänger war der IT-Ansprechpartner beim Kunden — er weiß nicht, was ein Workflow-Übergang ist, kennt die internen Statusnamen nicht, und die Mail legt sie ihm offen. Die Bestätigung danach lautete „… für Ticket wurde genehmigt“ — ohne Ticketnummer und ohne Titel. URSACHE (eine für beide): Verständliche, kundengerechte Texte existierten längst — sie waren unerreichbar. isTicketCreateGate erkannte den Fall ausschließlich an einer synthetischen Statuskante (__CREATE__ → __CREATE_APPROVED__), die der real laufende Pfad gar nicht setzt: Der Erstellungs-Gate benutzt bewusst die echte Kante gateKey → initialKey, um Token-Weg, Ablauf und rejectionTargetStatus vom Guard zu erben. Der Gate wurde also zweimal gebaut, und der bessere Text gehörte zur Variante, die nicht läuft. Alle vier heute erzeugten Freigaben trugen transitionRuleId: PENDING_APPROVAL-OPEN — keine einzige die synthetische Kante. LÖSUNG (Variante 2, entschieden): Die echte Kante bleibt die Wahrheit; erkannt wird der Gate an einem Marker am Freigabesatz (isCreateGate) statt an einer Statuskante, die eine Absicht kodiert. Der Marker kommt aus meta.createGate, das der Erstellungspfad ohnehin setzt — die Absicht war da, sie kam nur nie am Datensatz an. Die synthetische Kante bleibt als zweiter Erkennungsweg erhalten, damit Bestandssätze ohne Marker weiterhin richtig gelesen werden. Zusätzlich zeigt die Übersichtsliste jetzt die Wahrheit: Der Typ-Chip unterscheidet „Freigabe vor Bearbeitung“, „Kundenbenachrichtigung“ und „Freigabe + Benachrichtigung“; vorher stand dort bei jeder Eröffnungsregel pauschal „Kundenbenachrichtigung“ — auch bei einer, die Tickets anhält. Die Spalte „Genehmigung“ gab für Ticket-Regeln grundsätzlich einen Strich zurück (sie war für Shop-Bestellungen gedacht) und zeigt jetzt Ja/Nein. Damit war die folgenreichste Einstellung der Seite nirgends ablesbar. Verifiziert: 8 Tests, drei Mutationen gefangen — Marker ignoriert (der Zustand von gestern), alles als Gate gelesen (dann bekäme ein Kollege Kundentexte), synthetische Kante vergessen (dann verlören Bestandssätze ihre Erkennung). 327 API-Testdateien / 4825 Tests, Lint und Typecheck grün. Kein Backfill. Review-Fixes (07.08.): (1) Die Mail-Textauswahl in createApprovalRequest las nur den Marker (Boolean(input.isCreateGate)) und nicht den Synthetik-Kanten-Rückfall — liefe der ältere Hook-Pfad (ticket.hooks.ts, synthetische Kante __CREATE__ → __CREATE_APPROVED__) je an, bekäme der Kunde dort wieder interne Statusnamen; sie geht jetzt wie alle anderen Stellen durch isTicketCreateGate. (2) Die beiden neuen Typ-Chip-Texte (approvalPolicies.type.createGateOutcome/.createGateAndNotify) existierten nur als Inline-Fallback im Code und fehlten in beiden Sprachdateien — englische Nutzer sahen deutschen Text; nachgetragen in de.json + en.json.
fixed 🔴 Über HTTPS galt JEDE Ablehnung der Gegenseite als angenommen — und die Partner-Ticketnummer war nirgends sichtbar
geändert providers/peer/provider.ts, types.ts (DeltaPushOutcome.remoteTicketNumber), outbound.ts, link.ts, ticket-external-link.ts (neu remoteTicketNumber), inbound.ts, peer-exchange.routes.ts, ticket-exchange.routes.ts, peer-host.ts, TicketExchangeChip.tsx, 6 neue Tests. WHY: Zwei Befunde auf demselben Weg. (1) Der Auswertungsfehler: Die Ergebnisworte der Gegenseite wurden bei einer entfernten Paarung am Feld data.outcomes gesucht — der Ingress antwortet aber mit data.applied: [{ kind, outcome }]. Die Auswertung fand also nie etwas, und „nichts gemeldet“ heißt absichtlich „angenommen“ (das ist die Jira-Semantik, die ein Peer ohne Ergebnisliste erben soll). Damit war der gerade erst behobene Fehler „zugestellt ≠ angewandt“ auf dem HTTPS-Weg vollständig wieder offen: Ein von der Gegenseite verworfenes skipped-not-holder, skipped-stale oder denied:… galt als übertragen, der Kopf-Fingerabdruck wurde fortgeschrieben, und die Änderung war dauerhaft als erledigt verbucht — genau der Weg, auf dem live ein „Gelöst“ verlorenging. Unsichtbar geblieben, weil der gesamte manuelle Test über eine lokale Paarung lief, die diesen Zweig nie nimmt. (2) Die fehlende Nummer: Die Verknüpfung merkte sich von der Gegenseite nur deren UUID. Der Austausch-Chip zeigte sie — und auch das nur, wenn eine externalUrl existiert, die es bei einem Peer nie gibt. Die Frage „welcher Vorgang ist das beim Partner?“ war aus der Oberfläche damit gar nicht zu beantworten, obwohl beide Seiten die Nummer kennen. LÖSUNG: Der Provider liest den tatsächlichen Ingress-Vertrag (applied) und akzeptiert die flache Liste des Loopbacks weiter; eine Gegenseite, die keins von beidem sendet, bleibt nachsichtig behandelt. Die Ticketnummer reist als remoteTicketNumber an jeder Zustellung mit, nicht nur an der Neuanlage — deshalb füllt sich das Feld bei bestehenden Paarungen beim nächsten Kommentar von selbst, statt einen Nachtrag auf Produktivdaten zu verlangen. Überschrieben wird nie: Meldet ein Transport keine Nummer, bleibt eine vorhandene stehen. Der Chip zeigt „Beim Partner: T-…“ jetzt auch ohne URL. Die Nummer ist ausdrücklich reine Anzeige (ADR 0445 §3.5 „ticketNumber ist Anzeigefeld, niemals Schlüssel“) — Sequenzen laufen pro Mandant, dieselbe Nummer existiert früher oder später in beiden Installationen; der Schlüssel bleibt externalId. Nicht das Feld „Ext. Ticket-Nr.“ auf der Ticketseite: das ist ticket.extTicketId, ein von Hand gepflegtes Feld für die eigene Referenz, und es zu belegen hieße, dem Anwender ein Feld wegzunehmen. Verifiziert: 331/331 Tests, Lint und Typecheck (shared + api + worker) grün. Vier Mutationen einzeln gegengeprüft und jede von einem Test gefangen: Nachtragspfad entfernt · Provider meldet die Nummer nicht zurück · HTTPS liest wieder das falsche Feld · HTTPS verwirft die Nummer. Dabei fiel eine eigene Testlücke auf: Ein erster Anlauf prüfte nur die Hilfsfunktion, nicht die Verdrahtung — die Mutation „lies wieder outcomes“ blieb grün, obwohl der Fehler genau dort saß. Der Test geht jetzt durch den echten pushDelta-Pfad. Kein Backfill.
fixed 🔴 Dasselbe Ticket stand bei beiden Seiten als das eigene — aus einer Bitte wurde in der Oberfläche eine Tatsache
geändert ticket-handover-core.ts, outbound.ts, link.ts, ticket-external-link.ts (neu returnRequestedAt), ticket-handover.service.ts, ticket-operations.routes.ts, TicketDetailPage.tsx. WHY: Die Halter-Regel aus ADR 0445 §7b war korrekt umgesetzt — nur der Halter übergibt, die Gegenseite darf ausschließlich bitten. Die Verknüpfung blieb bei einer Rückholung folgerichtig auf „der Peer hält es“, und es ging ein requestReturn raus. Die Ebene darüber hielt sich nicht daran: Der Knopf flippte trotzdem den lokalen handlingTier auf MSP, wies den Bearbeiter zu und meldete „Ticket übergeben“. Ticket-Feld und Verknüpfung widersprachen einander, und weil die Gegenseite unverändert „ich halte es“ sagte, erschien derselbe Vorgang bei beiden Seiten als der eigene. Das ist genau der Split-Brain, den §7 konstruktiv ausschließen wollte — nur eine Ebene höher als dort betrachtet. Sichtbare Folge: Die Gegenseite wies jeden Statuswechsel mit skipped-not-holder ab (völlig zu Recht, wir hielten es ja nicht), während die Oberfläche eine vollzogene Übergabe behauptete. LÖSUNG (Produktentscheidung: „als Bitte kennzeichnen“): Hält die Gegenseite das Ticket, ändert die Rückholung lokal nichts mehr — kein Tier-Flip, keine Zuweisung. Vermerkt wird nur der Wunsch; die Übergabe vollzieht ausschließlich der Halter, und erst dessen handover-Delta bewegt Tier, Halter und Zähler. Die Route meldet returnRequested, die Oberfläche sagt „Rückgabe angefragt — die Gegenseite entscheidet“ statt „Ticket übergeben“. Neues Feld returnRequestedAt war unvermeidlich: planOutbound leitete die Bitte bisher aus dem Widerspruch ab (link.owner === 'external' bei handlingTier === 'MSP') — also aus genau dem Zustand, den diese Änderung beseitigt. Ohne expliziten Träger gäbe es danach kein Signal mehr, und die Bitte würde nie gesendet. Sie hängt bewusst nicht an headerChanged (eine Bitte entsteht ohne Kopfänderung) und wird beim Halterwechsel in setOwner in beide Richtungen gelöscht — sonst wiederholte sie sich ewig und schriebe der Gegenseite bei jedem Sweep eine Notiz. Unverändert für Jira und AUTOMATION: Dort gibt es kein Übergabe-Protokoll, der Take-back ist die Rückgabe — eine Halter-Regel wäre eine Regression im ausgelieferten Pfad. Verifiziert: 634/634 Tests, Lint und Typecheck (shared + api + worker) grün; der live auseinandergelaufene Testzustand wurde auf einen konsistenten Stand zurückgesetzt. Kein Backfill — fehlendes returnRequestedAt heißt „keine offene Bitte“, also unverändertes Verhalten für jede Bestandszeile.
fixed 🔴 Tombstone-Cursor: Der Löschungs-Sendepfad (DSGVO Art. 17, ADR 0445 §9) wäre gegen Produktionsdaten nach dem ersten Sweep verstummt — Date vs. String
geändert ticket-exchange-port.service.ts und port.ts (loadDeletedMessages), test-port.ts, memory-db.ts, convergence.test.ts. WHY: Der Produktions-Soft-Delete (entity.service.ts) schreibt deletedAt als BSON-Date; die Tombstone-Query verglich aber { $gt: "<ISO-String>" } — und MongoDBs Type-Bracketing lässt $gt nie über die Typgrenze matchen, ein Date-Dokument wird von einer String-Grenze also NIE getroffen. Zusätzlich lieferte das Mapping String(m.deletedAt) für Dates die Form „Wed Aug 06 2026 …“, die lexikografisch bedeutungslos ist — Cursor-Vergleich (advanceTombstoneCursor) und sort() der Engine hätten auf Zufallsreihenfolge gearbeitet. Ergebnis: Nach dem ersten Cursor-Vorschub wäre in Produktion keine Löschung mehr gereist. Warum die Tests trotzdem grün waren: Das Engine-Fixture (test-port.ts) schrieb UND las deletedAt ausschließlich als ISO-String — es testete eine Datenform, die es in Produktion nicht gibt. FIX: Beide Ports mappen deletedAt jetzt per toISOString() (lexikografisch = chronologisch) und fragen typtolerant ab ($or aus Date-Zweig und $type: 'string'-Zweig, unter $and, weil ticketMessageRef den $or-Schlüssel belegt) — gemischte Bestände (Alt-Strings + Prod-Dates) reisen beide korrekt. Das Fixture löscht jetzt wie die Produktion per BSON-Date, das Mongo-Double bildet Type-Bracketing nach ($gt/$gte nur innerhalb desselben Typs, $type-Support), und der Konvergenz-Test deckt beide Typformen ab — genau der Test, der den Bug gefangen hätte. Kein Backfill (die Query toleriert beide Bestandsformen zur Laufzeit).
fixed 🔴 Die Rückholung vom Kunden überschrieb dessen „Gelöst“ mit „Offen“
geändert ticket-handover-core.ts (neu atCustomerStatuses + Port resolveAtCustomerStatuses), ticket-handover.service.ts, 3 neue Tests. WHY: Der Rückweg setzte den Offen-Status bedingungslos. Die Absicht war richtig — das Ticket muss aus dem geparkten „Beim Kunden“-Status heraus, sonst bliebe es unsichtbar in Boards und Dispositionen. Das Ergebnis war es nicht: Setzt der Kunde das Ticket auf „Gelöst“ und der MSP holt es zurück, wurde daraus wieder „Offen“. Die Aussage des Kunden war weg, lautlos, mitten in einem Vorgang, den beide Seiten sehen — und über die Föderation fällt es sofort auf, weil dort tatsächlich zwei Menschen auf dasselbe Ticket schauen und unterschiedliche Status lesen. Live beobachtet: Kunde T-2026-00003 „Gelöst“, MSP T-2026-00112 nach der Rückholung „Offen“. LÖSUNG: Die Rückholung hebt jetzt nur noch den Parkstatus auf. Kennt der Aufrufer die als handlingRole: 'at_customer' markierten Status, wird der Offen-Status ausschließlich dann geschrieben, wenn das Ticket auch wirklich geparkt steht; trägt es dagegen eine echte Aussage, bleibt sie stehen. Kennt der Aufrufer sie nicht — ein Pfad ohne Workflow-Zugriff, oder ein Mandant ohne markierten Status —, bleibt es beim Bestandsverhalten. Kein Backfill, und für jeden Tenant ohne at_customer-Marker ändert sich nichts. Verifiziert: 3 neue Tests (Parkstatus wird aufgehoben · „Gelöst“ bleibt stehen · ohne bekannte Parkstatus unverändertes Verhalten); 323/323 Tests, Lint und Typecheck (shared + api + worker) grün.
fixed 🔴 Die Föderation lief nie von allein — drei unabhängige stille Sperren auf demselben Pfad
geändert event-router.ts, sweeps.ts, reconcile-job.ts, provider-facts.ts, message-query.ts (neu ticketIdOfMessage), 4 neue Tests. WHY: Jede Übertragung musste bis hierhin von Hand angestoßen werden; automatisch passierte nichts — ohne Fehler, ohne Logzeile, ohne Eintrag im Sync-Protokoll. Drei Ursachen, jede für sich ausreichend:
- Der Event-Router entschied mit
if (after.ticketId), ob ein Push eingereiht wird. Ein in der Oberfläche geschriebener Kommentar trägt keinticketId(entityType: 'ticket'+entityId) — für genau die Nachrichten, um die es geht, wurde also nie ein Job erzeugt. Derselbe zu enge Vergleich wie im Outbound-Leser und im Ingress; die Regel steht jetzt einmal alsticketIdOfMessageinsharedund wird von allen drei Stellen benutzt. - Die stabile Job-ID sperrte das Ticket nach dem ersten Fehlschlag für 24 Stunden.
jobIdist hier bedeutungstragend (ein Ticket = eine ID) und sollte nur koaleszieren; BullMQ dedupliziert aber gegen jede ID, die noch existiert — auch gegen eine gescheiterte. MitremoveOnFail: { age: 24h }wurde aus einem vorübergehenden Fehler eine Tagessperre, undremoveOnComplete: { age: 3600 }war dieselbe Falle in klein (eine Stunde Funkstille nach jedem geglückten Push). Beides jetzttrue. Der Sweep hatte dasselbe Problem — die Begründung stand dort sogar korrekt im Kommentar und war nur auf den Erfolgsfall angewandt: Ausgerechnet der Lauf, der einen Fehler hätte heilen sollen, war durch den Fehler blockiert. Nachvollziehbarkeit geht nicht verloren — Fehlversuche stehen impeer_sync_logund als interne Notiz am Ticket; die Warteschlange ist nicht das Archiv. - Der 5-Minuten-Sweep war für Peers ein No-op.
reconcileLinkgleicht ausschließlich überprovider.pullSince()ab. Für Jira richtig — dort liegt die Wahrheit drüben. Der Peer-Provider hat aber gar keinen Pull: SeinpullSinceliefert bewusst immer leer, weil zwischen zwei codemeta-Installationen jede Seite ihre eigene Wahrheit schiebt. Der Sweep lief also sauber durch und tat nichts. Das ist der schwerste der drei Befunde, weil die Spezifikation den Reconcile ausdrücklich als den Sync bezeichnet und den Webhook nur als Beschleuniger: Für Peers war das Gegenteil wahr, der Ereignis-Pfad war die einzige Zustellung, und was dort verloren ging, war endgültig verloren. Ein Rückfallweg, der still nichts tut, ist schlimmer als keiner — man verlässt sich darauf. Neue Provider-StammdatenfragereconcileByPush(Jirafalse, Peertrue) statt eines ID-Vergleichs im Sweep; der Push läuft über denselben idempotenten Outbound wie der Ereignis-Pfad, erzeugt bei bereits übertragenem Stand also null Deltas. Verifiziert: ein Kommentar im MSP-Ticket erschien ohne jedes Zutun im Partner-Ticket,peer_sync_logwuchs von 8 auf 13 Zeilen; 293/293 Tests, Lint und Typecheck (shared + worker) grün. Kein Backfill (kein Peer in Produktion angelegt). Nebenbefund, der sich damit erledigt: die Vermutung, der Change-Stream des Workers „flappe“, war falsch — eine Sonde zeigt ihn mit und ohne Resume-Token stabil offen. Die Beobachtung stammte aus einem Fenster, in dem@codemeta/sharedgebaut wurde, und hat die Suche zwei Runden lang in die falsche Richtung gelenkt.
fixed 🔴 Ein gespiegelter Kommentar sah beim Partner aus, als hätte ihn jemand vor Ort geschrieben
geändert ticket-external-sync.ts (neu TicketReconcileMessage.authorName), message-query.ts (neu resolveMessageAuthorNames + applyAuthorLabel), inbound.ts, port.ts, ticket-exchange-port.service.ts, 11 neue Tests. WHY: Der Empfangspfad konnte die Kennzeichnung längst — die Senderseite füllte das Feld nie. Beim Partner stand der Kommentar damit ohne jede Herkunft zwischen den eigenen; bei Haftungs- und Nachvollziehbarkeitsfragen ist das nicht egal, und der Plan führt die Autorenkennzeichnung als Abnahmekriterium. LÖSUNG mit drei bewussten Entscheidungen: (1) Der Verfasser sitzt an der NACHRICHT, nicht am Ereignis. InboundEvent.authorLabel gab es bereits, aber ein ticket.created bringt die gesamte bisherige Unterhaltung mit — ein Label je Ereignis hätte allen mitgereisten Nachrichten denselben Verfasser aufgedrückt. Das ist schlechter als gar keine Zuschreibung, weil es glaubwürdig aussieht. (2) Das Format richtet sich nach dem Inhalt. Der Präfix stand als Markdown **Name:** im Code — und body trägt bei jedem in der Oberfläche geschriebenen Kommentar HTML. Beim Empfänger hätte wörtlich **Name:** über dem Text gestanden. applyAuthorLabel entscheidet anhand des Inhalts, maskiert den Namen und zieht htmlBody mit (die Oberfläche zeigt es bevorzugt an). (3) Die E-Mail-Adresse wird NICHT gesendet. Die Anreicherung im generischen CRUD fällt darauf zurück, weil sie intern anzeigt; hier verlässt der Wert das Haus — dieselbe Datenminimierung, aus der partyId gar nicht erst mitreist. Ohne auflösbaren Namen bleibt die Nachricht unbeschriftet statt einen Platzhalter zu erfinden. Warum Präfix und kein Feld: message.schema.ts ist der eingefrorene Octoja-Vertrag (additionalProperties: false, ADR 0186); ein authorName-Feld braucht dessen Freigabe, der Präfix ist der im Plan vorgesehene Rückfallweg. Ein späterer Wechsel ist folgenlos, weil der Dedup-Schlüssel extMessageId inhaltsunabhängig ist — die Nachricht wird dann an Ort und Stelle aktualisiert statt ein zweites Mal eingespielt. Ein Fehler auf dem Weg, festgehalten weil er lehrreich ist: die Auflösung las zunächst nur users.name. Dieses Feld tragen die geseedeten Nutzer; ein real registriertes Konto hat displayName/firstName/lastName. Im Test unsichtbar, in der Wirklichkeit total — die Reihenfolge folgt jetzt der Anreicherung im generischen CRUD und ist eigens getestet. Verifiziert: 293/293 Tests grün; live kommt der Kommentar als <p><strong>Test Tenant</strong></p><p>…</p> an, und der anschließende Gegen-Push erzeugt weiterhin keine Dublette — die geänderte Darstellung berührt den Dedup-Schlüssel nicht. Kein Backfill (kein Peer in Produktion angelegt).
fixed 🔴 Kein einziger von Hand geschriebener Kommentar wurde je zum Partner gespiegelt — nur maschinell erzeugte Notizen kamen an
neu message-query.ts, geändert port.ts und ticket-exchange-port.service.ts, neu visible-message-shape.test.ts. WHY: messages kennt zwei Wege, auf ein Ticket zu zeigen, und beide sind in Benutzung: Mail-Ingest, Worker, Seed und Octoja schreiben ticketId; die Ticket-Detailseite schreibt entityType: 'ticket' + entityId ohne ticketId (TicketDetailPage.tsx postet genau so). loadVisibleMessages las in beiden Hosts nur ticketId — und damit ausgerechnet die Nachrichten nicht, die ein Mensch getippt hat. Das ist der schlimmste denkbare Schnitt: der Kanal wirkte funktionsfähig, weil die automatischen Notizen ankamen, und ließ lautlos genau das aus, worum es geht. Kein Fehler, keine Logzeile, keine leere Antwort — die Abfrage lieferte einfach eine Teilmenge. Gefunden wurde es nicht durch den Code, sondern durch Auszählen: 119 von 120 Kommentaren der Datenbank tragen ticketId, und der eine, der es nicht tat, war der erste von Hand in der Oberfläche geschriebene. LÖSUNG: ticketMessageRef() in shared deckt beide Referenzformen ab und wird von beiden Ports benutzt — bewusst eine Fassung, weil API und Worker dieselbe Menge sehen müssen; zwei Kopien hätten davon abhängig gemacht, welcher Prozess gerade zustellt. Es gibt hier keine „richtige“ der beiden Formen: solange beide Schreibwege existieren, muss jeder Leser beide kennen. Verifiziert: neuer Test, der genau die Fehlerform prüft (eine Nachricht nur in der Oberflächen-Form muss im Ergebnis stehen) und dabei die Datenschutzgrenze mitnagelt — interne Notizen, fremde Tickets und fremde Mandanten bleiben draußen, denn eine Abfrage aufzuweiten darf sie nicht mit aufweichen. 276/276 Tests des Austausch-Verzeichnisses grün. Kein Backfill — reine Lese-Erweiterung.
fixed 🔴 Eine frisch eingerichtete Mandanten-Verknüpfung übertrug nichts — zwei unabhängige Ursachen, beide ohne jede Fehlermeldung
geändert tenant-link.service.ts, connection.ts (neu exportiertes emptyExchangeConfig). Beim manuellen E2E-Test aufgedeckt: Handshake sauber, Rechte gesetzt, Kollaborationsprofil im PEER-Modus, Übergabe erfolgreich — und auf der Gegenseite entstand nichts, ohne Eintrag im Sync-Protokoll, ohne Job, ohne Log. (1) exchange: null. Beide Handshake-Wege legten die Verknüpfung ohne Austausch-Konfiguration an. Der Reconcile-Sweep filtert aber auf exchange: { $ne: null } — was in MongoDB ein fehlendes Feld nicht trifft — und jeder Sync-Pfad verlangt über describeMissingConfig ein vorhandenes target und statusMap. Die Paarung war damit angenommen, berechtigt und für die gesamte Austausch-Maschinerie unsichtbar. Im Umsetzungsplan stand die fehlende Oberfläche für die Statusabbildung als Komfortlücke („nur über die API“); tatsächlich ist sie konstituierend. FIX: beide Wege setzen jetzt emptyExchangeConfig() — leer, aber gültig: Kommentare und die Anlage des Gegenstücks laufen sofort, Statuswechsel erst mit konfigurierter Abbildung. Für einen Peer korrekt, weil er anders als Jira kein Ziel-Vokabular braucht. (2) peerCapabilities: null. Die gemeinsame Zeile trägt zwei Rechtesätze: capabilities = was die Gegenseite bei uns darf, peerCapabilities = was wir bei ihr dürfen. Der Ingress liest für ein Ziel, das nicht der Zeileneigentümer ist, den zweiten — und normalisePeerCapabilities(null) liefert alles auf false. Jedes ausgehende Delta wurde mit Delta "…" is not permitted on this link abgewiesen, also genau in der Richtung, auf die es bei einer Eskalation ankommt. Die Einlöse-Route nimmt peerCapabilities zwar im Body entgegen, die Oberfläche fragt sie aber nie ab. FIX: alle drei Annahmewege (redeemLocalInvitation, acceptRemoteInvitation, completeRemoteHandshake) nutzen jetzt resolveInitialCapabilities — dieselbe Funktion wie die Einladungsseite, deren Dokumentation das Prinzip bereits wörtlich festhält: „Omitting capabilities entirely means ‚the sane default’ — NOT ‚deny everything’: a link nobody can use is not a safe default, it is a broken one that the next person fixes by granting everything at once.“ Genau das war auf dem Spiegelfeld nicht angewandt. createTicket bleibt auch hier implizit verweigert (ADR 0445 §5) und muss bewusst erteilt werden. Verifiziert: 51/51 Tests in tenant-link.routes.test.ts grün, eslint und api-Typecheck grün; der Outbound-Lauf wurde nach jedem der beiden Fixes einzeln zur Probe eingereiht und lief bis zur jeweils nächsten Ursache durch. Kein Backfill für Neuanlagen; bestehende Verknüpfungen (in Produktion: keine, das Feature ist unveröffentlicht) bräuchten ein Nachziehen beider Felder.
fixed Party-Auswahl: Lieferanten-Einträge zeigen jetzt ihre Lief-Nr. und sind damit nicht mehr mit gleichnamigen Kunden-Einträgen zu verwechseln
). WHY (Live-Feedback, IT-Solutions/Christian Hammes, 15.07.2026): Im Dialog „Neues Ticket erstellen“ lieferte die Suche im Feld „Kunde“ für eine Firma, die sowohl als Kunde als auch als Lieferant geführt wird, zwei optisch fast identische Zeilen — Firma · Kd-Nr. 11011 · Neuerburg und Firma · Neuerburg. Die zweite (Lieferant) wirkte wie ein Duplikat, weil die Sekundärzeile ausschließlich customerNumber rendert. Nummern werden aber rollenabhängig vergeben (party-numbering.service.ts): eine Party ohne Kundenrolle hat legitim keine customerNumber und blieb damit nummernlos. Fix: Die Sekundärzeile rendert zusätzlich Lief-Nr. <supplierNumber>, sobald gesetzt — [kind, custNo, suppNo, city]. Da isCustomer/isSupplier nicht exklusiv sind, zeigt eine Party mit Doppelrolle beide Nummern nebeneinander (Firma · Kd-Nr. 11011 · Lief-Nr. 5001 · Neuerburg), was den von Moritz empfohlenen „Firma nur einmal anlegen“-Weg sauber abbildet. Kein API-/Datenmodell-Eingriff: party.supplierNumber existiert bereits im Schema (party.schema.ts) und liegt schon auf der Leitung. Der Selector setzt searchEntity: 'parties', läuft im Tippen-Fall also über /api/v1/search (nicht über die /api/v1/parties-List-Route — useEntitySearch überspringt im Search-Modus das Preloading); beide Pfade liefern unprojizierte Dokumente (search.routes.ts hat kein Response-Schema und nur ein $project: { _user: 0 } im employee_profiles-Zweig), das Feld ist also bereits da. Ergänzt wurde lediglich das SelectableParty-Interface. Neue i18n-Keys partySelector.supplierNumberShort (de „Lief-Nr.“ / en „Supp. no.“). Kein Backfill — Parties ohne gesetzte supplierNumber verhalten sich exakt wie bisher (bewusst kein Rollen-Badge als Ersatz: der Melder hat die Nummer explizit als ausreichend benannt). Wirksamkeits-Caveat: Die Nummernvergabe für Lieferanten steht per Default auf manual; eine von Hand angelegte Lieferanten-Party ohne eingetragene Nummer bleibt weiterhin nummernlos. Für importierte Parties greift der Fix zuverlässig — der TANSS-Transformer setzt supplierNumber: isSupplier && !hasCustomerNumber ? displayId : null, d. h. genau der heute nummernlose Fall ist der, in dem supplierNumber gefüllt ist (weclapp-Sync analog). Bewusst keine Anpassung des Duplikat-Sonderfalls: dass dieselbe Firma zweimal als getrennte Parties existiert, bleibt eine Datenpflege-Frage.
fixed Buchungs-Guard: Tickets ließen sich trotz vollständig verbuchter Termine und Aufgaben nicht schließen — und in der Terminliste war gar nicht zu sehen, was blockiert
) — geändert transition.routes.ts (+ Test, 2 neu), TicketDetailPage.tsx, de/en-Locales. WHY: Der opt-in Guard aus ADR 0301 blockt RESOLVED/CLOSED, solange verknüpfte Termine oder Aufgaben keine Leistung tragen. Er hatte drei Fehler, die zusammen genau den gemeldeten Effekt ergaben — „alles verbucht, Ticket geht trotzdem nicht zu“, ohne jeden Hinweis, woran es liegt. (1) Aufgaben galten praktisch nie als verbucht. Die Prüfung suchte eine Leistung mit referenceType:'TASK' + referenceId = task._id. referenceType/referenceId ist aber die abgeleitete Abrechnungs-Primärreferenz, und dabei gewinnt das gröbste Arbeitsobjekt (PROJECT > TICKET > TASK, siehe derivePrimaryReference in time-record-links.ts). Eine Aufgabe, die an einem Ticket hängt — also exakt der Fall, um den es hier geht — leitet immer referenceType:'TICKET' ab. Die Sonde traf damit (fast) nichts, und jede Aufgabe am Ticket blockierte dauerhaft, egal wie viel Zeit darauf gebucht war. Gelesen wird jetzt der typisierte linkedTaskId; das Legacy-Paar bleibt im $or, weil Datensätze von vor dem Multi-Link-Modell nur die Primärreferenz tragen (kein Backfill). (2) Stornierte Termine blockierten ewig. Der Ausschluss filterte auf status: 'CANCELLED'|'NO_SHOW' — calendar_events hat aber überhaupt kein Feld status, die Stornierung liegt auf reservationStatus (calendar-event.schema.ts); alle Stornopfade schreiben ausschließlich dorthin. Der Filter war damit ein dauerhafter No-Op — ein stornierter Termin kann per Definition keine Leistung tragen und hielt das Ticket deshalb für immer offen. (3) Die Terminliste am Ticket zeigte nicht alles und nichts über den Buchungsstand. Sie fragte nur referenceType=TICKET&referenceId=… ab, der Guard prüft aber zusätzlich den typisierten linkedTicketId — und genau den setzt die Einsatzplanung (plan.ts) ohne Polymorph-Slot. Ein per Dispo geplanter Termin war in der Liste unsichtbar, verhinderte aber den Abschluss. Die Liste lädt jetzt beide Verknüpfungsformen und dedupliziert; jede Zeile trägt einen Chip „Verbucht“/„Nicht verbucht“, der aus derselben Verknüpfung abgeleitet wird wie der Server-Guard (sourceType:'CALENDAR_EVENT' + sourceId), damit Anzeige und 422 nicht auseinanderlaufen können. Unverbuchte Zeilen bekommen zusätzlich „Zeit buchen“ direkt in der Zeile — bisher war die Aktion, die den Guard überhaupt erfüllt, nur im Termin-Dialog erreichbar. KEIN BACKFILL: Alle drei Korrekturen lesen ausschließlich vorhandene Felder; sie machen den Guard durchlässiger, nie strenger, also kann kein bisher schließbares Ticket dadurch blockieren. Weiterhin bewusst so: Ein Termin gilt nur über sourceType/sourceId als verbucht — Zeit, die per Timer oder über die Zeiterfassung am Ticket gebucht wurde, erfüllt den Guard nicht, weil sie keinen Bezug zum konkreten Termin herstellt. Der neue Chip macht diesen Unterschied jetzt sichtbar, statt ihn erst beim Schließversuch als 422 auftauchen zu lassen.
fixed Ein in TANSS gelöschtes Ticket ließ sich in Codemeta OS nie mehr bearbeiten — die Übernahme scheiterte dauerhaft mit „bitte später erneut versuchen“
) — neu TicketTakeoverButton.test.tsx (4 Tests); geändert tanss-takeover.service.ts, tanss-takeover.service.test.ts (+5 Tests), ticket-takeover.routes.ts, TicketTakeoverButton.tsx, TicketDetailPage.tsx, client.ts, ticket.schema.ts, API.md, de/en-Locales. WHY: Die Übernahme eines TANSS-Spiegels setzt nach ADR 0432 zuerst den Tag „CodemetaOS“ im TANSS-Vorgang und gibt das Ticket erst danach hier frei — die Reihenfolge verhindert den Zustand „in Codemeta offen, in TANSS unmarkiert“, also zwei Teams am selben Vorgang. Wurde der Vorgang in TANSS aber gelöscht, bleibt der Spiegel hier stehen (source: 'TANSS', sourceRef.id zeigt ins Leere) und die read-only-Sperre aus ADR 0377 greift weiter. Die Zuweisung POST /api/v1/tags/assignment scheiterte dann bei jedem Versuch, und weil tanss-takeover.service.ts jeden Nicht-2xx-Status in denselben Fehler assign-failed zusammenfasste, war „TANSS gerade nicht erreichbar“ von „das Objekt gibt es nicht mehr“ nicht unterscheidbar. Der Nutzer bekam „Das Ticket bleibt gesperrt — bitte später erneut versuchen“: eine Aufforderung zu einem Versuch, der nie gelingen kann. Das Ticket war damit nirgends bearbeitbar — nicht in TANSS (dort gelöscht), nicht in Codemeta (dort gesperrt) — und nur noch per Datenbankänderung von Hand zu retten. WAS: Scheitert die Markierung mit assign-failed oder verify-failed, fragt der Service einmal nach, ob es den Vorgang überhaupt noch gibt: PUT /api/v1/tickets mit { ids: [<id>], includeDoneTickets: true } — dieselbe Abfrage, die der Delta-Sync benutzt. 🔴 Bewusst PUT: POST /api/v1/tickets legt in TANSS einen Vorgang an, ein Methodenfehler an dieser Stelle erzeugte bei jeder Prüfung ein neues Fremdsystem-Ticket (ein Test hält die Methode fest); und bewusst mit ids-Filter, weil ein leerer Filter-Body TANSS den gesamten Bestand liefern lässt. Nur eine wohlgeformte Antwort ohne diese Id wird zum neuen Fehlercode tanss-takeover-source-missing (422) — Netzfehler, Nicht-2xx, 403 („darf den Vorgang nicht sehen“) und eine nicht-numerische sourceRef.id bleiben beim ursprünglichen Fehler, das Ticket bleibt gesperrt. Nachgefasst wird nicht bei auth-expired/tag-unavailable/integration-missing: die sagen etwas über die Integration aus, nicht über den Vorgang, und ihre Handlungsanweisung („neu verbinden“ / „Tag anlegen“) wäre sonst verfälscht. Im Normalfall kostet das nichts — die Prüfabfrage läuft nur nach einem Fehlschlag. Der Übernahme-Dialog fängt den neuen Code ab, bleibt offen, erklärt die Lage und bietet „Ohne Markierung übernehmen“ an; dieser zweite Klick schickt { acknowledgeSourceMissing: true }. Das Flag hebt keine Prüfung auf: der Server markiert weiterhin zuerst und wertet es nur aus, nachdem er das Fehlen des Vorgangs selbst nachgewiesen hat — es ist also kein force. Festgehalten wird der Sonderfall über externalOwnershipTag: null bei gesetztem externalOwnership: 'LOCAL' (kein neues Feld, die Kombination ist eindeutig, weil LOCAL ausschließlich diese Route schreibt); der Banner auf der Ticketseite formuliert danach anders, statt wie bisher ersatzweise „CodemetaOS“ zu nennen und den nächsten Leser in TANSS nach einer Markierung suchen zu lassen, die es nicht gibt. In TANSS wird in diesem Fall auch kein Kommentar mehr versucht — es gibt dort nichts zu kommentieren. Kein Backfill — bestehende übernommene Tickets tragen unverändert ihren Tagnamen.
fixed Arbeitszeitplanung: Außer Owner und Administrator konnte niemand eine eigene Zeitplanung anlegen — der Button „+ Neue Zeitplanung“ fehlte in allen Mitarbeiterrollen (Technik, Disposition, Vertrieb, Einkauf, Buchhaltung, Personalabteilung, Teamleitung Technik, Akademie-Kursleitung) komplett
geändert role.service.ts, role.service.test.ts (+18 Tests). WHY: Die Self-Service-Berechtigung work_schedule_edit_own („Eigenen Wochenplan erstellen, bearbeiten und löschen“) war in permissions.ts deklariert und wurde von Frontend wie Backend geprüft — aber keiner einzigen Rolle zugewiesen. Owner und Administrator hatten sie nur zufällig über [...ALL_SPECIAL] (Vollausstattung), jede andere Rolle bekam sie nirgends: DEFAULT_ROLES enthielt keinen einzigen work_schedule*-Eintrag. Sichtbar wurde das nur als fehlender Button, nicht als Fehlermeldung: PeriodsTabContainer.tsx blendet die Aktionen über canEdit = (canEditOwn || canEditTeam) && policyAllowsManage aus, WeekPlanTab.tsx genauso — der Tab rendert also die eigene (leere) Periodenliste ohne jede Möglichkeit, etwas anzulegen. Hätte die UI den Aufruf trotzdem abgesetzt, wäre er in work-planning.routes.ts mit 403 „Berechtigung work_schedule_edit_own fehlt" geendet (POST/PATCH/DELETE /work-planning/entries). Das ist die Fehlerklasse „deklarierte, aber nie vergebene Permission“ — sie erzeugt keinen Fehler, sondern eine Oberfläche, in der eine Funktion einfach nicht existiert. WAS: work_schedule_edit_own wird in der Broadcast-Schleife für interne Mitarbeiterrollen (EMPLOYEE_ROLE_SLUGS_WITH_READ_ALL) verteilt, analog zu employee_profile_self_edit und den daily_*-Self-Service-Rechten. Ausdrücklich nicht verteilt wird work_schedule_edit (Team-Scope, Bearbeiten fremder Pläne) — Fremdbearbeitung bleibt eine bewusste Vergabe. Die Modell-Policy greift unverändert: bei FIXED/SHIFT/KAPOVAZ blockt das Backend Self-Service weiterhin hart (422), unabhängig von der Permission, und die UI zeigt dort statt der Buttons den WorkingTimePolicyBanner. Kein Backfill: ensureDefaultRoles() $sett specialPermissions bei der ersten Anfrage pro Mandant nach Prozessstart aus den DEFAULT_ROLES — bestehende Mandanten ziehen die Berechtigung beim Deploy automatisch nach. Ausnahme: duplizierte/eigene Rollen (isSystem: false) fasst die Synchronisation nie an — dort muss die Berechtigung im Rolleneditor manuell gesetzt werden.
fixed Arbeitszeitplanung: Die Team-Ansicht war für jede Rolle außer Owner/Administrator reines Lesen, und der Genehmigungs-Workflow für eingereichte Wochen hatte gar keinen Träger
geändert role.service.ts, role.service.test.ts. WHY: Dieselbe Lücke wie bei work_schedule_edit_own (Eintrag darüber), nur eine Ebene höher: work_schedule_view_team, work_schedule_edit, work_schedule_delete und work_schedule_approve waren deklariert und werden in work-planning.routes.ts an 14 Stellen geprüft — zugewiesen war keines davon. Personalabteilung und Teamleitung Technik konnten deshalb weder fremde Wochenpläne öffnen (ohne work_schedule_view_team erscheint der Mitarbeiter-Wähler gar nicht, isTeamView ist false) noch fremde Einträge ändern (PATCH/DELETE /work-planning/entries gaten auf work_schedule_edit/work_schedule_delete) noch eine eingereichte Woche freigeben (drei Routen gaten auf work_schedule_approve, u. a. POST /work-planning/approve). Der Submit-für-andere-Pfad (work-planning.routes.ts Zeile 840) prüft dieselben zwei Rechte — auch er war für alle außer Owner/Admin tot. WAS: Die vier Rechte gehen an personalabteilung und teamleitung-technik; beide Rollen tragen vacation_approve, time_record_approve bzw. overtime_approve bereits, die Arbeitszeitplanung war die verbliebene Lücke im Genehmigungsprofil. Bewusst nicht vergeben: work_schedule_create — im POST-Gate ist das ein Alias, der Eigen- und Fremdanlage zugleich freischaltet, während work_schedule_edit den Fremdfall bereits abdeckt. Ebenfalls unverändert: einfache Mitarbeiterrollen (Technik, Disposition, Buchhaltung, Vertrieb, Einkauf) bekommen ausschließlich den Self-Service-Scope; ein Test friert das ein. 🔴 Bekannte Eigenschaft der bestehenden Implementierung, die mit dieser Vergabe wirksam wird: work_schedule_view_team ist trotz des Namens nicht auf das eigene Team beschränkt. GET /work-planning/team filtert nur, wenn der Client orgUnitId mitschickt, und die Gates in PATCH/DELETE /work-planning/entries prüfen ausschließlich die Permission, nie die Team-Zugehörigkeit — die Teamleitung Technik sieht und plant damit mandantenweit, nicht nur ihr Team. Eine echte Eingrenzung wäre eine Änderung an work-planning.routes.ts (Auflösung über resolveOrgUnitMemberIds auch ohne orgUnitId-Parameter), nicht an der Rollendefinition. Kein Backfill — ensureDefaultRoles() zieht specialPermissions beim Deploy automatisch nach; duplizierte/eigene Rollen (isSystem: false) fasst die Synchronisation nie an.
fixed Terminfindungsassistent zeigte alle Belegungen um den UTC-Offset zu früh (im Sommer 2 h, im Winter 1 h) und schlug dadurch bereits belegte Zeiten als „frei“ vor
geändert SchedulingAssistant.tsx. WHY: POST /calendar/free-busy liefert busyBlocks[].start/.end als UTC-ISO-Strings — availability.service.ts baut sie über wallClockToUtcIso() bzw. übernimmt calendar_events.startAt, beides mit Z. Der Assistent hat die Uhrzeit aber direkt aus dem String geschnitten (iso.slice(11, 16)) und diese UTC-Wanduhr als lokale Zeit auf die Zeitachse gelegt. Ein Termin 08:00–09:00 Berlin (…T06:00:00.000Z) erschien damit als Balken von 06:00–07:00 — exakt der Versatz, mit dem Assistent und Einsatzplanung (FullCalendar, rechnet korrekt lokal) für denselben Termin unterschiedliche Positionen zeigten. Derselbe Formatbruch steckte im „Nächsten freien Slot finden“: die Kollisionsprüfung verglich block.start < slotEndISO lexikalisch, also "2026-08-11T06:00:00.000Z" gegen das naiv lokal gebaute "2026-08-11T08:00:00" — die Kandidatenslots waren um denselben Offset gegen die Belegungen verschoben, wodurch der Vorschlag reproduzierbar in einen besetzten Zeitraum fallen konnte. Der Hook useFreeBusy.ts macht es in getAvailability() seit jeher richtig (Vergleich über Date().getTime()), und die drei anderen Konsumenten (ParticipantPicker, PlanTasksDialog, useAssigneeAvailability) gehen ausschließlich über diesen Hook — der Fehler war also auf diese eine Komponente begrenzt. WAS: Neue Helfer localMidnightMs() und minutesFromLocalMidnight(); die Balkenposition rechnet jetzt Date(iso).getTime() gegen lokale Mitternacht des angezeigten Tages, wodurch der bisherige Sonderfall für über Mitternacht laufende Blöcke entfällt (sie ergeben natürlicherweise Werte unter 0 bzw. über 1440 und werden von den vorhandenen Clamps abgeschnitten). Die Slot-Suche vergleicht Belegungen und Kandidatenslots über Epoch-Millisekunden statt über Strings; die Belegungen werden pro Tag einmal vorkonvertiert statt in jeder 15-Minuten-Iteration erneut. Nebenbei mitgezogen: todayISO kam aus new Date().toISOString().slice(0, 10) und war zwischen lokaler Mitternacht und dem UTC-Offset noch der Vortag, sodass die „ab jetzt suchen“-Untergrenze in diesem Zeitfenster nicht griff — jetzt über das lokale Kalenderdatum. Kein Backfill — reine Anzeige- und Suchlogik im Frontend, die gespeicherten Termine waren immer korrekt.
fixed Signatur-Einstellungen: Jede mandantenweite Signatur trug das Abzeichen „Tenant-Standard“ — es sahen also mehrere Signaturen gleichzeitig wie der Mandanten-Standard aus
geändert SignatureListPanel.tsx, de/en-Locales. WHY: Die Karte bekam das Abzeichen über die Prop isTenantDefault, die der Aufrufer aber schlicht an jede Zeile mit scope === 'tenant' durchreichte — sie kodierte den Geltungsbereich, nicht das Flag isDefault. Mandantenweite Signaturen existieren bewusst mehrfach nebeneinander (ADR 0451: tenant / org_unit / user, aufgelöst most-specific-first), und genau eine davon trägt isDefault: true; resolveEffectiveEmailSignature fällt ausschließlich auf diese zurück. Angezeigt wurde stattdessen: alle. Die tatsächliche Standard-Signatur bekam obendrein zwei Abzeichen nebeneinander („Standard“ aus isDefault und „Tenant-Standard“ aus dem Scope), womit sie von den übrigen kaum zu unterscheiden war — die Frage „welche Signatur hängt an meinen Mails, wenn ich keine eigene setze?“ war aus dieser Ansicht nicht beantwortbar. Die Admin-Ansicht (TenantSignatureManager.tsx) machte es schon immer richtig und zeigt „Standard“ nur auf der Zeile mit isDefault — die beiden Oberflächen widersprachen sich also. WAS: Die Prop heißt jetzt isTenantScope (was sie immer war — die Buttons „Bearbeiten/Löschen/Als Standard setzen“ hingen bereits daran, weil mandantenweite Zeilen für den Benutzer schreibgeschützt sind) und steuert nur noch Aktionen und Farbe. Das Abzeichen hängt an signature.isDefault und wird genau einmal gerendert: für eigene Signaturen „Standard“ (Akzentfarbe), für die mandantenweite Standard-Zeile „Tenant-Standard“ (amber). Eine mandantenweite Signatur ohne isDefault bekommt gar kein Abzeichen — sie ist nur eine Vorlage zum Kopieren. Die Abschnittsüberschrift benannte ebenfalls „Tenant-Standard“ und verstärkte den Eindruck; sie heißt jetzt „Vom Mandanten bereitgestellt“ (neuer Key signatures.tenantSection) und benennt damit die Herkunft statt einen Vorrang. Bekannte Lücke, hier nicht angefasst: Die Liste filtert weiterhin nur auf scope === 'user' und 'tenant' — Signaturen mit scope: 'org_unit', die die API über listSignaturesVisibleToUser bereits mitliefert, fallen in dieser Ansicht still heraus. Praktisch folgenlos, solange es keine Oberfläche gibt, die welche anlegt (listOrgUnitSignatures in signature.service.ts hat bis heute keinen Aufrufer). Kein Backfill — reine Anzeige-Logik, die Daten waren korrekt.
fixed Voicemail-Benachrichtigungen konnten nie versendet werden — der Transport las vier Feldnamen, die es im Datenmodell nicht gibt
geändert telephony-consumer.ts, neu escape-html.ts. WHY: sendVoicemailEmail baute sich einen eigenen Nodemailer-Transport aus emailAccount.smtpHost / smtpPort / smtpUser / smtpPassword. Diese Feldnamen kommen im gesamten Repo ausschließlich an dieser Stelle und in den UI-Labels der Locale-Dateien vor — das Modell speichert config.smtp.auth.{user,pass} bzw. legacy flach smtp.{host,port,user,password} (siehe email.service.ts). host war damit immer undefined, Nodemailer warf bei jedem Voicemail-Ereignis, und der Fehler verschwand im umschließenden catch als generische Log-Zeile. Zusätzlich filterte die Query auf isActive: { $ne: false }, während der restliche Code active: true verwendet — die Kontoauswahl hätte also selbst mit richtigen Feldnamen abweichen können. WAS: Der handgerollte Transport entfällt; der Versand läuft über sendTenantEmail (email-provider.ts), das den designierten Benachrichtigungs-Absender wählt, Credentials entschlüsselt und die SMTP-Env- und Plattform-SES-Fallbacks dahinter hat. Schlägt alles fehl, gibt es jetzt eine explizite Warnung statt einer Exception. Anrufernummer und Durchwahl werden beim Einsetzen in den HTML-Body escaped (neuer kanonischer Worker-Helper escapeHtml; die sieben bestehenden lokalen Kopien sind im Kommentar als Migrationsziel vermerkt, damit nicht die achte entsteht). Kein Backfill.
fixed API: unbehandelte Promise-Rejections beendeten den Prozess ohne eine einzige Log-Zeile
geändert server.ts, pdf.service.ts. WHY: Die API startet rund 100 bewusste Fire-and-Forget-Nebeneffekte (void onEntityCreated(...), SpiceDB-Tupel-Sync, Audit-Nachläufe); für 106 davon gibt es kein .catch im unmittelbaren Umfeld. Gleichzeitig gab es keinen process.on('unhandledRejection')- und keinen uncaughtException-Handler (process.on( kam in src/ ausschließlich für SIGINT/SIGTERM vor). Seit Node 15 beendet eine unbehandelte Rejection den Prozess per Default — eine fehlgeschlagene Hintergrund-Nebenwirkung konnte also eine ansonsten gesunde, Traffic bedienende Instanz umbringen, und zwar ohne verwertbare Spur. Konkret erreichbar z. B. über den catch-Block in spicedb.service.ts, der u.relationship!.subject!.object!.objectType mit drei Non-Null-Assertions dereferenziert: Wirft dieser Ausdruck, verlässt die Exception den catch und die Promise rejected unbehandelt. WAS: unhandledRejection wird strukturiert geloggt und an Sentry gemeldet, der Prozess läuft weiter — diese Nebeneffekte sind konstruktionsbedingt nicht anfrage-kritisch, und eine Instanz zu töten ist die deutlich schlechtere Reaktion. uncaughtException behält dagegen den Fail-Fast-Exit, weil der Prozesszustand danach undefiniert ist; geschlossen wird hier die Lücke, dass so ein Absturz bisher völlig stumm blieb — genau der Punkt, der den Vorfall vom 2026-07-03 (/phone-analytics export-pdf, GlitchTip b97d18a9) so schwer auffindbar machte. Beide Handler werden vor listen() registriert, damit auch Boot-Fehler erfasst sind. Der Doc-Block in pdf.service.ts wurde nachgezogen: Er beschrieb korrekt, dass es keinen Handler gibt — das stimmt jetzt nicht mehr, die dortige Vorab-Validierung des Logos bleibt aber der eigentliche Fix, weil weiterhin beendet wird. Kein Backfill.
fixed 🔴 TANSS-Webhook verkürzte die Latenz nur auf 0–5 Minuten statt auf Sekunden — er öffnete lediglich das Intervall-Gate
geändert inbound-event.ts (+ Test, 3 neu), integration-actions-consumer.ts. WHY: Der Inbound-Handler setzte ausschließlich tanssSync.lastSyncAt = null. Der eigentliche Lauf startete damit erst beim nächsten turnusmäßigen Anstoß, und der kommt alle 5 Minuten — ein Ereignis wartete also im Mittel 2,5 Minuten, obwohl der Delta-Pull Sekunden dauert. WAS: Der Handler plant den Pull jetzt direkt ein (force: true, sonst verwirft ihn das Intervall-Gate). Das Öffnen des Gates bleibt als Netz erhalten: Scheitert das Einplanen (Redis weg), fällt die Latenz auf den Turnus zurück, statt dass der Job scheitert und in die Wiederholung geht. Gefahrlos, weil das Datenfenster an lastDeltaCursor hängt und nicht an lastSyncAt — ein geöffnetes Gate zieht keinen Riesen-Nachlauf nach sich. Entprellung über ein 15-s-Zeitfenster in der jobId: Ein Ticketwechsel löst in TANSS mehrere Trigger gleichzeitig aus (TICKET_UPDATED + STATUS_CHANGED + ASSIGNED), die so in einen Lauf fallen. Bewusst zeitgefenstert statt fest: Eine dauerhaft gleiche jobId würde ein Ereignis kurz nach einem gestarteten Lauf verwerfen, ohne dass dieser es abdeckt. Kein : in der jobId — BullMQ nutzt den Doppelpunkt als Schlüsseltrenner und macht den Job sonst still unauffindbar. Der Enqueue wird als Callback vom Consumer gestellt, damit der Handler keine Queue-Verbindung kennt und ohne Redis testbar bleibt. Kein Backfill.
fixed 🔴 Interne Rollen ohne party_create (Disposition, Technik) galten der API als Kundenportal-Nutzer — u. a. fehlte ihnen der Deeplink „In TANSS öffnen“ samt Vorgangsnummer
neue Datei internal-user.ts (+ Test, 4), geändert crud.routes.ts (beide stripInternalFields-Aufrufe), ticket.hooks.ts, angepasste Tests in ticket.hooks.test.ts (+1). WHY: Die Frage „interner Mitarbeiter oder Kundenportal-Nutzer?“ wurde über die Heuristik permissions.has('party_create') beantwortet („nur interne Nutzer dürfen Kunden anlegen“). Die ist in beide Richtungen falsch. Falsch negativ: Die Systemrolle Disposition hat auf party nur read (role.service.ts), Techniker ebenso — CRM-Stammdaten zu pflegen ist schlicht nicht ihre Aufgabe. Sie fielen damit in den Portal-Zweig, und stripInternalFields löschte ihnen laut INTERNAL_FIELDS_BY_SCHEMA ticket.sourceUrl und ticket.sourceRef aus jeder Listen- und Detail-Response; zusätzlich brach die Laufzeit-Herleitung des Links aus der TANSS-Basis-URL in enrichOne/enrichList sofort ab. Sichtbar wurde das als Hinweisbanner ohne Link und ohne #293328 bei TANSS-geführten Tickets (ExternalMasterBanner.tsx rendert weiter, weil er an source/externalOwnership hängt) — dazu jedes weitere Feld der Tabelle: invoice.notes, project.budget*, article.purchasePrice, contract.priceEscalation. Falsch positiv: Der Owner eines Kunden-Tenants hält party_create (Berechtigungen sind rollen-, nicht mandantentypgebunden) und bekam die internen Felder trotzdem zu sehen. Dieselbe Fehlklassifikation hatte am 14.07.2026 schon die Skill-Matrix vor Technikern versteckt — die Warnung dazu steht seitdem in useNavVisibility.ts. FIX: Ein benannter Helper isInternalUser(request) als einzige Antwort auf die Frage, gestützt auf request.isCustomerRole — das echte Signal aus role_definitions.isCustomerRole (ADR 0049/0099), das authorization.ts auf jeden Request im geschützten Scope setzt. Der Vergleich ist bewusst === false, damit ein Request ohne Autorisierungs-Middleware als nicht intern gilt (fail-closed). Die übrigen party_create-Prüfungen der API bleiben unangetastet — dort wird tatsächlich eine Party angelegt (Ticket-/Opportunity-Operationen, E-Mail-Inbox, Newsletter), das ist eine echte Berechtigungsforderung und keine Heuristik. Bewusst NICHT mitgeändert: der gleichnamige Fallback in useNavVisibility.ts für Nav-IDs ohne NAV_SCHEMA_MAP-Eintrag — der ist ein Sicherheitsnetz gegen Feature-Lecks in Kunden-Tenants und würde beim Umstellen weiter öffnen; er gehört mit der Nav-Registry zusammen überarbeitet. Verifiziert: 4 neue Helper-Tests (Portal-Nutzer, Disposition ohne party_create, Kunden-Tenant-Owner mit party_create, Request ohne Middleware), Hook-Tests auf isCustomerRole umgestellt + ein Regressionstest für den Disposition-Fall, pnpm --filter @codemeta/api typecheck grün. Kein Backfill — reine Response-Filterung, keine gespeicherten Daten betroffen.
fixed Ticket-Boards: Ticket-Art, Kontaktkanal und die Fristen-/Alters-Uhr konnten auf den Karten nie erscheinen
geändert board.routes.ts (ITEM_DTO_FIELDS), neue Tests in ticket-board.security.test.ts (2). WHY: GET /ticket-boards/:id/items schickt die Karten-Dokumente durch eine bewusste Allowlist, damit nicht bei jedem neuen Ticketfeld still eine Spalte nach außen wandert. In dieser Liste fehlten drei Felder, die die Karte tatsächlich liest: ticketType und contactChannel — beide sind im Board-Dialog als Kartenfeld anhakbar (TICKET_CARD_FIELD_OPTIONS in boardTypes.tsx), ihre Zweige in BoardTicketCard.tsx liefen aber immer ins Leere — und targets (die denormalisierten Service-Ziele aus ADR 0117a), aus denen TicketAgeIndicator die Frist und der linke Kartenrand den vertraglichen SLA-Bruch ableiten. Ohne das Feld blieb beides dauerhaft neutral: ein gerissenes SLA war auf dem Board nicht zu sehen. Der Fehler war rollenunabhängig und traf jedes Board. FIX: die drei Felder ergänzt, mit Kommentar, woher der Bedarf jeweils kommt. Verifiziert: 2 neue Tests — einer sichert die Kartenfelder inkl. targets zu, der zweite hält fest, dass sourceUrl/sourceRef weiterhin nicht über die Board-DTO nach außen gehen (die Allowlist bleibt eng). Kein Backfill.
fixed Kalendersync: Drei Defekte, durch die eine in Outlook verkürzte/geänderte Abwesenheit in Codemeta in alter Länge stehen blieb (Prod-Vorfall 2026-08-07: Dispo blockierte einen Techniker als „abwesend“, obwohl der Urlaub in Outlook längst verkürzt war)
geändert calendar-sync.service.ts, sync-consumer.ts, neue Tests in calendar-sync.service.test.ts (3 neu, 1 angepasst):
- Abgelaufener Delta-Link wurde still verschluckt — der Sync eines Users war dauerhaft tot, sah in der DB aber gesund aus.
deltaSyncholt den gespeichertenlastDeltaLink(absolute URL) perfetchWithTimeout(...).json()ohneresponse.ok-Prüfung. Graph-Delta-Tokens laufen ab (HTTP 410 Gone, CoderesyncRequired/syncStateNotFound); der Fehler-Body hat wedervaluenoch@odata.deltaLink→ der Lauf endete mit 0 Events, behielt den toten Delta-Link und stempelte trotzdemlastSyncAt— für Monitoring und DB-Forensik sah der Kanal gesund aus, verarbeitete aber nie wieder eine Änderung. FIX:response.okwird geprüft; bei 410/resyncRequiredwird der Delta-Link verworfen und im selben Job ein Full-Re-Sync gefahren (inkl. Reconciliation, die veraltete Docs aufräumt); jeder andere HTTP-Fehler wirft jetzt laut, sodass BullMQ retryt statt die Fehlerseite als leere Antwort zu behandeln. - Webhook-DELETE im Worker ignorierte Teilnehmer-Slots.
processCalendarWebhookEventmatchte beimchangeType: 'deleted'nur das Top-Level-microsoftEventId— das trägt aber ausschließlich die Mailbox-ID des Erstellers. Löschte ein Teilnehmer seine Outlook-Kopie (Graph-ID imparticipantSyncs.<userId>-Slot), passierte still gar nichts und sein Slot zeigte dauerhaft auf ein totes Graph-Event. FIX: Slot-bewusste Behandlung als Spiegel des 404-Zweigs vonfetchAndUpsertGraphEvent— Ersteller → Soft-Delete des ganzen Docs (inkl.$unsetder Dedup-Schlüssel wegen derdeletedAt-blinden Unique-Partial-Indizes), Teilnehmer → nur der eigene Slot wird geleert. - Legacy-Docs ohne
iCalUId/organizerEmailwaren für Updates aus anderen Mailboxen unerreichbar. Docs aus der Zeit vor der Persistierung der Dedup-Schlüssel (ADR 0168/0282) tragen keinen der beiden Schlüssel —findLiveWinnerkonnte ein eingehendes Update (andere Mailbox oder rotierte Graph-ID) deshalb nie an sie anhängen; das sichtbare Doc behielt seinen alten Zeitraum für immer. Genau das war der Prod-Fall: Die einzige lebende Kopie des Urlaubs stammte aus der Mailbox eines Kollegen (Termineinladung), war seit dem 02.06. eingefroren und hielt den Techniker in der Dispo auf „abwesend“. FIX: dritter Fallback-Lookup aufiCalUId: null+organizerEmail: null+ Titel + Startzeit, zusätzlich abgesichert darauf, dass der syncende User bereits am Doc beteiligt ist (createdBy/Teilnehmer/Slot) — sonst würde ein gleichnamiger Termin eines anderen Mitarbeiters gekapert. Der Lookup ist ein reiner Equality-Seek auf dem bestehenden(tenantId, title, startAt)-Index; bewusst ohnecreatedAt-Schranke (Legacy-Docs sind per Definition alt), der Query-Targeting-Regressionstest nimmt ihn entsprechend aus. Kein Backfill — der Fallback toleriert die alten Docs zur Laufzeit, statt sie umzuschreiben. Verifiziert: 34 Tests incalendar-sync.service.test.tsgrün (410-Recovery inkl. Delta-Link-Reset, Nicht-410-Fehler wirft ohnelastSyncAt-Stempel, Legacy-Fallback hängt verkürzten Termin ans richtige Doc statt Duplikat einzufügen), typecheck api + worker grün. Ops-Hinweis für den konkreten Vorfall: betroffene, bereits eingefrorene Docs heilen erst, wenn für den jeweiligen User wieder ein Update/Full-Sync eintrifft — für Sofort-BereinigunglastDeltaLinkdes betroffenen Users$unseten (erzwingt Full-Sync mit Reconciliation).
fixed Entity-E-Mail-Versand (Ticket/Vertriebschance/…): Konto-Auswahl wurde ignoriert — Mail ging über das erstbeste aktive Postfach des Tenants raus
geändert transition.service.ts + Tests (7), message.hooks.ts, message.schema.ts, emailCompose.ts. WHY (Live-Vorfall 07.08., Folgebefund zum M365-Absender-Fix aus PR #724): Jede aus einem Entity-Kontext komponierte Mail — Ticket („+ Neue E-Mail“, Antworten, Kommentar→E-Mail), Vertriebschance, Reklamation, Auftrag, Projekt, Board-Slideout, Checklisten-Schritt — läuft als message über die DRAFT→SENT-Transition, und deren sendEmailHook wählte das Konto mit einem blanken findOne({ tenantId, active: true }): das erstbeste aktive Postfach des Tenants, unabhängig von der Auswahl im Composer. Die Auswahl landete nur als from-Text auf der Message — Codemeta zeigte support@, versendet wurde über vertrieb@ (mit dem Provider-Fix aus PR #724 sogar explizit als vertrieb@ gestempelt). Exakt die Bug-Klasse, die für /email/send bereits geschlossen ist („kein stiller Fallback aufs erste aktive Konto“) — der Transition-Pfad war übersehen worden. FIX: Neuer Resolver resolveOutboundEmailAccount mit deterministischer Reihenfolge: (1) explizites emailAccountId auf der Message (neues additives Schemafeld; der Composer schickt seine Auswahl jetzt mit) — ist die Auswahl nicht mehr verfügbar (deaktiviert/gelöscht), schlägt der Versand sichtbar fehl (EMAIL_ACCOUNT_UNAVAILABLE) statt still über ein anderes Postfach zu gehen; (2) from-Adresse gegen die Konten gematcht (tolerant für Name <addr>); (3) Reply-Kontext — das Postfach, auf dem die beantwortete Inbound-Mail lag (inReplyTo → email_messages.accountId); (4) isNotificationSender; (5) erst zuletzt das alte „erstes aktives“. Nach dem Versand stempelt der Hook das tatsächlich benutzte Konto (emailAccountId) und den tatsächlichen from-Header auf die Message — die Timeline kann nie mehr einen anderen Absender behaupten als den, der auf der Leitung war; ein from, das nicht zum sendenden Konto gehört, wird durch dessen kanonische Adresse ersetzt statt als Header-Lüge mitgeschickt. AuthZ: message.hooks.ts beforeCreate prüft ein client-gesetztes emailAccountId gegen die Mailbox-Zugriffsrechte des Erstellers (ADR 0125, gleiche Regel wie /email/send) → 403 ohne Zugriff. Kein Backfill: Bestands-Messages ohne emailAccountId laufen über die Fallback-Kette (der from-Match greift bei allen Composer-Mails sofort, da from dort immer gesetzt ist). Bewusst NICHT angefasst (separater Befund): Die System-Mail-Pfade, die accounts[0] statt des designierten isNotificationSender-Postfachs nehmen — invitations.routes.ts, customer-portal-invite.service.ts, customer-portal-notifications.ts, dms-signature.service.ts, email-template.routes.ts, payroll.routes.ts, Workflow-Aktion send-email.ts (Worker, ohne accountId) und der Kampagnen-Fallback campaign.routes.ts:325. Dort gibt es keine Nutzer-Auswahl, die verraten würde — falscher Default-Absender ja, stiller Widerspruch zur Anzeige nein. Folge-Fix: gemeinsamer Helper, der isNotificationSender bevorzugt (Muster: Worker sendTenantEmail).
fixed TANSS-Sync: Tickets ganzer Firmen landeten ohne Kundenverknüpfung — 159 Stück gemessen
neu company-directory.ts (+ Tests); geändert db-id-map.ts (+ Tests), tanss-sync.ts, integrations.ts, IntegrationSettingsPage.tsx. WHY: Gemeldet 07.08.2026 — Tickets einer Firma tauchten in Codemeta nicht auf. Sie waren gespiegelt, aber ohne partyId und damit in der Ticketliste der Firma unsichtbar. Ursache ist strukturell: der Live-Sync legt keine Parties an (ADR 0377), also findet idMap.resolve('companies', …) nur Parties mit sourceRef aus der Migration. Für jede danach in TANSS angelegte Firma bleibt die Auflösung dauerhaft leer — und das passierte komplett stumm, weil es für Firmen im Gegensatz zu Status, Teams und Technikern keine Meldeliste gab. FIX: Zweitschlüssel TANSS displayId ↔ parties.customerNumber — dasselbe Feldpaar, das der Migrations-Transformer schon nutzte. Bei Mehrdeutigkeit wird bewusst nicht zugeordnet: gemessen standen 8 Parties unter identischem Namen und unterschieden sich nur über Kundennummer und Ort, ein geratener Treffer hinge die Tickets sichtbar an die falsche Niederlassung. Die displayId steht nicht in den linkedEntities (dort nur name), kommt also vom Einzelsatz GET /api/v1/companies/{id} (in der 10.15.0-Inventur verifiziert) und wird gelernt (companyDisplayIds, Budget companyDetailBudget Default 25). Verdrahtet über RunCollectors, wodurch der Ticket-Doc-Refresh des History-Spiegels die Verknüpfung an Bestands-Tickets nachzieht, ohne dass sie je „modified“ sein müssen. Nicht auflösbare Firmen erscheinen jetzt als unmappedCompanies in der Integrationsmaske. Verifiziert: 179 Worker-TANSS-Tests (12 neue, darunter der Mehrdeutigkeits-Guard und der Vorrang von sourceRef), web-Typecheck via tsgo, i18n-Parität.
fixed Termin-Dialog: „Zeit buchen“ fehlte auf fast allen Oberflächen — der Button existierte nur im Hauptkalender und in der Einsatzplanung
neu event-to-time-entry.ts (+ Tests, 8); geändert timeEntry.ts, TimerWidget.tsx (StoreTimeEntryDialog), EventDialogHost.tsx, CalendarPage.tsx, ResourcePlanningPage.tsx, TicketDetailPage.tsx, HrTicketDetailPage.tsx, ProjectAppointmentsTab.tsx, OpportunityAppointmentsTab.tsx, FleetCalendarPage.tsx, BookingsTab.tsx. WHY: Nutzer-Befund 07.08.2026: Ein Techniker, dem eine Disponentin einen Termin eingestellt hatte, öffnete ihn über den Termine-Tab des Tickets und fand keinen „Zeit buchen“-Button. Ursache war keine Berechtigung: Der Button rendert bei editMode && onBookTime (CreateEventDialog.tsx), aber nur 2 von ~26 Mount-Stellen reichten onBookTime durch — der EventDialogHost (Dashboard-Zeitstrahl) hatte den Handler seit seiner Einführung nie bekommen, Ticket-/HR-Ticket-Detail, Projekt-/Opportunity-Termine-Tabs, Fuhrpark und Ressourcen-Reservierungen ebenso wenig. Dazu Drift: Die Einsatzplanungs-Kopie des Handlers war abgespeckt (keine Ticket-/Projekt-Referenz, kein sourceType/sourceId → der Termin blieb nach dem Verbuchen doppelt im Kalender sichtbar). FIX: Das Termin→Zeitbuchung-Mapping (Referenz inkl. HR_TICKET-Fallback, Anfahrt→Fahrt, Arbeitsort-Ableitung, positionId ADR 0310, sourceType: 'CALENDAR_EVENT', 1000-Zeichen-Kappung) lebt jetzt EINMAL in lib/event-to-time-entry.ts. In die Buchungs-Beschreibung wird dabei nur noch die Termin-Beschreibung übernommen (Nutzer-Wunsch 07.08.2026) — der Titel wurde bisher vorangestellt und duplizierte sich in jeden Buchungstext; ohne Beschreibung fällt die Vorbelegung auf den Titel zurück (Pflichtfeld). Die Titel-Verkettung war zudem Mitursache der „mitten im Satz abgeschnittenen“ Buchungen: time_record.description war im Schema auf 1000 Zeichen begrenzt, Termin-Beschreibungen auf 2000 — der Überhang wurde still gekappt. Beide Limits sind jetzt einheitlich auf 5000 Zeichen angehoben (Nutzer-Wunsch 07.08.2026): time-record.schema.ts (1000→5000), calendar-event.schema.ts (2000→5000), das Body-Schema der Timer-Umwandlung POST /timers/:id/convert in timer.routes.ts (1000→5000) sowie die client-seitigen Kappungen in event-to-time-entry.ts und buildTaskTimeDescription.ts. Bewusst unverändert: internalNote (1000) und Timer-notes (1000, fließen in die interne Notiz, nicht in die Beschreibung). Reine Validierungs-Lockerung — Bestandsdaten sind alle kürzer, kein Backfill (bewusst lib/, nicht features/calendar/lib/ — auch Type-only-Cross-Feature-Imports zählen für den Boundary-Ratchet). Alle Edit-Mounts übergeben onBookTime; die neuen Oberflächen öffnen den bestehenden globalen TimeEntry-Store-Dialog (useTimeEntryStore → StoreTimeEntryDialog im TimerWidget) statt je einen lokalen TimeEntryDialog zu mounten — null neue Cross-Feature-Imports, Boundary-Ratchet grün. Dafür wurde der Store um die Termin-Felder erweitert (date/startTime/endTime, referenceType/referenceId, location, travel, positionId, sourceType/sourceId) und die Schnellerfassungs-Logik des StoreTimeEntryDialog („heute, jetzt − Dauer“) greift nur noch, wenn der Caller KEINE expliziten Zeiten mitbringt — sonst würde ein Termin von gestern 09:00–11:00 auf „heute, jetzt“ verbogen. CalendarPage und ResourcePlanningPage nutzen denselben Builder (Inline-Kopien entfernt; die Einsatzplanung bekommt damit erstmals Referenz + sourceType in die Vorbelegung). Verifiziert: 8 neue Builder-Tests + Feature-Boundary-Ratchet + EventDialogHost-Wiring-Test grün, tsgo-Typecheck web grün. Kein Backfill.
fixed TANSS-Integration: „Jetzt synchronisieren“ synchronisiert jetzt tatsächlich sofort und meldet das echte Ergebnis — vorher wurde der Klick innerhalb des Sync-Intervalls still verschluckt
geändert tanss-sync.ts (+ Tests), sync-consumer.ts, integration.routes.ts, IntegrationSettingsPage.tsx, de.json/en.json, docs/API.md. WHY: Der Button enqueute nur einen tanss-delta-pull-Job und meldete „gestartet“ — der Worker ließ den Job aber durch dasselbe Intervall-Gate laufen wie den 5-Minuten-Scheduler: lag der letzte Lauf weniger als syncIntervalMs (Default 15 min) zurück, wurde der Tenant kommentarlos übersprungen. Ein manueller Klick kurz nach einem automatischen Lauf tat also gar nichts, und die per Job-Data mitgegebene tenantId wurde vom Consumer nie gelesen. FIX: (1) processTanssDeltaPull nimmt jetzt { tenantId, force } entgegen — force umgeht das Gate (nur für den manuellen Klick; der Scheduler ruft weiter ohne Optionen auf), tenantId beschränkt den Lauf auf den auslösenden Tenant. (2) syncTenant gibt die Lauf-Statistiken zurück; der Consumer reicht sie als Job-Returnvalue durch. (3) Die API-Route POST /integrations/:id/tanss-sync (action sync) enqueut mit force: true und wartet per Zustands-Poll bis 30 s auf das Job-Ende (bewusst Poll statt QueueEvents — kein zweiter blockierender Redis-Client in der API); danach liest sie tanssSync.lastSyncAt/lastSyncError frisch und antwortet mit completed/success/stats. Läuft der Sync länger, kommt completed: false und er läuft im Hintergrund weiter (Ergebnis im Sync-Log). (4) Die UI zeigt statt pauschal „Sync gestartet“ das echte Ergebnis: „abgeschlossen: X neu, Y aktualisiert, Z unverändert“, den Fehlertext bei Fehlschlag oder „läuft noch“. Kein Backfill, additiv-kompatibel: ein alter Worker ignoriert die neuen Job-Felder schlicht (dann greift nur das alte Verhalten), ein neuer Worker behandelt Scheduler-Jobs ohne Data unverändert.
fixed Eine ausgehende E-Mail, deren Versand fehlschlug, verschwand spurlos — jetzt bleibt sie mit Grund, Zeitpunkt und Wiederholung in der Timeline stehen
) — neu message-send-failure.ts (+ Tests), MessageSendFailureNotice.tsx, sendFailureColumns.tsx; geändert transition.service.ts (+ Tests), transition.routes.ts, email.routes.ts, crud.routes.ts, message.schema.ts, entity-config.ts, TicketDetailPage.tsx, emailCompose.ts, EmailInboxPage.tsx, de.json/en.json, docs/API.md. WHY: Der Bearbeiter schrieb eine Ticket-Antwort, bekam einen Fehler-Toast — und in der Timeline stand nichts. Die Antwort war weg, ohne Beleg, ohne Wiederholung. Drei Ursachen griffen ineinander: (1) Wirft der sendEmailHook, sprang transition.routes.ts per Early-Return auf 422 ohne jeden DB-Write — die Nachricht blieb auf DRAFT, der Fehlertext existierte nur im HTTP-Body und war danach verloren. (2) SEND_FAILED wurde serverseitig nirgends gesetzt: nur das Frontend stempelte ihn, an genau einer von drei Stellen — die beiden anderen räumten den Beleg aktiv weg (emailCompose.ts löschte die Nachricht per DELETE, send-email-action.ts soft-deletet sie). (3) Selbst der eine gestempelte Fall war unsichtbar, weil die Timeline-Query status: { $nin: ['DRAFT','SEND_FAILED'] } filterte — der Kommentar „so it doesn’t linger silently“ in TicketDetailPage.tsx war faktisch wirkungslos. FIX: (a) Der Transition-Graph erklärt den Fehlstatus jetzt deklarativ über das neue TransitionRule.onAsyncFailure — kein schema === 'message'-Sonderzweig in der generischen Route (Guardrail #4). Wirft ein asyncHook, schreibt der Endpoint den Fehlstatus samt strukturiertem sendError über entity.service (Transaktion + history) und antwortet erst dann mit 422, nun zusätzlich mit failureStatus im Body, damit der Client weiß, dass sich die Entität bewegt hat. Beide Transition-Handler (/transition und /transitions/:key) teilen sich dafür einen Helfer. (b) Die Timeline blendet nur noch DRAFT aus. Ausnahme Kunden-Portal-Nutzer (isCustomerRole): Sie behalten den alten Filter — sie haben die Mail nie erhalten, können nichts wiederholen, und ihnen eine Nachricht zu zeigen, von der der Absender noch nicht weiß, dass sie niemanden erreicht hat, wäre aktiv irreführend. Die Internal-Note-Regel (message_view_internal) im selben Filterblock bleibt bewusst unangetastet — sie betrifft Inhalte, nicht den Sendezustand. (c) Die Client-Aufräumaktionen sind raus: emailCompose.ts löscht den Beleg nicht mehr, und die Ticket-Antwort stellt den Entwurfstext nicht mehr in den Composer zurück, wenn eine Nachricht existiert — das lud dazu ein, eine zweite Kopie neben die bereits im Feed stehende zu senden. Kein Backfill: Alt-Nachrichten auf SEND_FAILED tragen keine Fehlerfelder; jeder Leser geht durch resolveMessageSendFailure(), das auf „Ursache nicht gespeichert“ plus statusChangedAt zurückfällt und einen fehlenden Zähler als 1 liest. Ein fehlendes sendError heißt nicht festgehalten warum — nie es gab keinen Fehler. Verifiziert: 42 Tests grün (13 neue zu Rückkanten/onAsyncFailure/Bookkeeping, 20 neue zum Fallback-Verhalten inkl. Alt-Dokumenten ohne jedes Feld), typecheck shared + api + web grün, i18n-Parität grün. Bewusst NICHT enthalten: POST /email/send (freier Composer-Versand ohne Entity-Bezug) legt gar kein message-Dokument an und verliert den Fehler weiterhin im Worker-Log — das einzubeziehen hieße, dort Dokumente zu erzeugen, mit Wirkung auf Party-Aktivitätsliste und awaiting-reply-Aggregationen; in ADR 0449 als offener Punkt dokumentiert.
fixed Anrufauswertung: Die Suche nach einer Telefonnummer lief in einen 500er, sobald der Term lang genug war
geändert crud.routes.ts, crud.phone-calls-search.test.ts (4 neue Tests). WHY: Aus dem Betrieb (07.08.2026, Tenant Autohaus Eifel-Mosel, 6 Vorkommen in 60 s auf /phone-analytics): Beim Tippen einer Nummer lieferten die kurzen Zwischenstände noch 200, ab ca. neun Ziffern kippte jeder weitere Tastendruck auf GET /api/v1/phone_calls?…&q=… in einen 500er. Serverseitig steht dahinter MongoServerError 50 — error while multiplanner was selecting best plan :: operation exceeded time limit, im Stack ein FindCursor.
Warum die Länge des Terms entscheidet. Der Such-$or einer Nummernsuche ist per Konstruktion nicht indizierbar: Er trägt immer die unverankerten, separator-toleranten Regexes über callerNumber/calledNumber (nötig, solange der Legacy-Backfill der *Digits-Keys nicht überall gelaufen ist), und für ein $or müssten alle Zweige indizierbar sein. callScope=all setzt zusätzlich bewusst keinen Scope-Filter — es bleiben nur tenantId + deletedAt. Damit ist jeder tenantId-präfigierte Index ein Kandidat. Der Multiplanner beendet seine Trial-Phase normalerweise, sobald ein Kandidat 101 Treffer geliefert hat; eine seltene Nummer trifft aber fast nichts (im Vorfall: 2 Treffer in 87.452 Anrufen), ein kurzes Präfix dagegen sofort viele. Genau das ist die Längen-Schwelle. Verschärfend trägt sortSpec immer den _id-Tiebreaker (Seiten-Stabilität), sodass der SORT blockierend ist und gar nichts ausgibt, bevor er alles konsumiert hat — der 101-Treffer-Frühabbruch kann also nie feuern.
Der Fix war schon da, aber nur zur Hälfte. Nach demselben Vorfall am 05.08.2026 wurde ein hint auf den COMMON_ENTITY_INDEX {tenantId, deletedAt, _id} eingeführt, der genau einen Plan erzwingt und die Trial-Phase überspringt — aber nur für den withTotal-Count. Der Docs-find blieb ungehintet und lief zwei Tage später in exakt denselben Timeout. Beide Queries laufen in Promise.all, ein halber Fix ist damit keiner. Der Hint liegt jetzt als eine gemeinsame Konstante (phoneSearchPlanHint) vor und wird von findOpts und countOpts getragen; ein Test pinnt, dass beide denselben Hint führen.
Gemessen auf Prod (read-only explain('executionStats'), dieselbe Query): ohne Hint 24 verworfene Kandidatenpläne / 14.243 ms — die Query stand direkt an der 15-s-maxTimeMS-Wand, was das sporadische Verhalten erklärt; mit Hint 0 verworfene Pläne / 1.642 ms. Der Hint verliert keinen besseren Plan, weil ohnehin kein Index den Such-$or bedienen kann — er wählt den Scan direkt, statt ihn 25-mal zu erproben.
Bewusst weiterhin der COMMON_ENTITY_INDEX und nicht der Listen-Index {tenantId, deletedAt, startedAt, _id}: Letzterer wäre messbar schneller (605 ms, kein blockierender SORT, weil er die Sortierreihenfolge liefert), ist aber erst am 21.07.2026 dazugekommen. Ein hint auf einen in irgendeiner Datenbank noch nicht reconciliierten Index ist selbst ein harter 500er — also genau die Fehlerklasse, die hier verschwinden soll. 1,6 s liegen komfortabel im Budget; der schnellere Index bleibt eine Option, sobald die Reconciliation überall nachweislich durch ist.
Unverändert bleibt, dass bei callScope=mine/team nicht gehintet wird: dort ist der Scope-$or über die {tenantId, <feld>, startedAt}-Indizes als OR-Vereinigung bedienbar, ein Hint wäre eine Verschlechterung. Kein Backfill — reines Query-Shaping; die *Digits-Keys sind beim betroffenen Tenant zu 87.311/87.452 gefüllt, die restlichen Alt-Dokumente deckt der Regex-Fallback ab.
Sicherheit
fixed Default-Deny für die dritte Routen-Klasse, Tenant-Query-Guard und Schema-Validierung im Schreibpfad — Mechanik gebaut, Altlast eingefroren
) — neu route-authz-manifest.ts, route-authz-ratchet.test.ts + Baseline, tenant-query-guard.ts (+ Test, 17), entity-validation.ts, sub-action-optout-reasons.test.ts + Baseline; geändert app.ts, mongo.ts, entity.service.ts, sub-action-guard.ts, telephony.routes.ts. WHY: Drei Befunde des Reviews vom 2026-08-10 mit derselben Ursache — eine Regel existiert, aber nichts erzwingt sie, und ein Verstoß fällt nicht als Fehler auf: Eine vergessene Permission-Prüfung erzeugt keine Exception, sondern eine Route, die großzügiger ist als gewollt; ein vergessener tenantId-Filter liefert nicht weniger Daten, sondern Daten fremder Mandanten; ein ungeprüfter Write schlägt nicht fehl, sondern legt ein schema-widriges Dokument an. (1) S2 — namespaced Routen: Für generische CRUD-Routen und Sub-Actions existiert echtes Default-Deny (authorizationMiddleware bzw. subActionAuthzGuard nach ADR 0183, inkl. Boot-Assertion). Für die größte Klasse — mehrsegmentige Pfade wie /hr/team-overview, /telephony/trunks — gibt es keins: extractResource() liefert null, und die gesamte Prüfung hängt an if (resource && action). ROUTE_AUTHZ_MANIFEST klassifiziert jede solche Route als permissions/self/optout; der neue routeAuthzGuard erzwingt ausschließlich die permissions-Einträge, alles andere läuft unverändert durch — diese Asymmetrie macht den Rollout risikofrei, denn Verhalten ändert sich nur dort, wo man es beim Klassifizieren beabsichtigt. Der Ratchet friert 1.022 unklassifizierte Routen in 238 Dateien ein (Top: work-planning 28, dms 28, dispatch 22, billing 18); die Zahl darf nur sinken. (2) S1 — Tenant-Isolation: 2.381 direkte db.collection(-Aufrufe, kein tenant-scoped Wrapper, ~22 % der Filter ohne tenantId. Der neue Guard hängt an MongoDB Command Monitoring und meldet jede Query auf einer tenant-gebundenen Collection (abgeleitet aus ENTITY_CONFIG) ohne tenantId im Filter — je collection:command einmal, Zähler läuft weiter. Er beobachtet nur und blockiert nichts; in Dev/Test/CI an, in Produktion aus (TENANT_QUERY_GUARD=1|0). Das liefert erstmals eine echte Fundstellenliste aus dem Laufzeitpfad statt einer Grep-Schätzung, die weder Kontext noch tatsächlichen Aufruf kennt. (3) S4/F2 — Validierung: AJV lief an genau drei Stellen, alle in crud.routes.ts; die 249 übrigen createEntity-Call-Sites schrieben ungeprüft in dieselben Collections, gegen die der generische POST mit 422 abweist. createEntity/updateEntity validieren jetzt den bereinigten Body — standardmäßig nur meldend (ENTITY_VALIDATION_MODE=report), weil nach Jahren ungeprüfter Writes offen ist, wie viele Call-Sites heute formal schema-widrige Bodies schicken; enforce wird pro Bereich eingeschaltet, sobald dessen Logs sauber sind. Fail-open bei Infrastrukturfehlern (kein Schema/defekte Registry ⇒ keine Meldung, kein Wurf) — ein Validator, der beim eigenen Defekt Writes blockiert, wäre schlimmer als der Zustand, den er verbessern soll. BEWUSSTER PREIS: Die bestehenden Lücken bleiben zunächst offen. 945 Routen blind Permissions zuzuordnen hieße raten, und eine falsch geratene Permission ist von außen nicht von einem Ausfall zu unterscheiden; tenantId in 1.751 Call-Sites zu injizieren würde legitime Follow-up-Writes und alle Public-Token-Flows mitreißen. Der Gewinn ist, dass die Altlast ab sofort gezählt wird, nicht mehr wächst und mit jedem Modul dauerhaft schrumpft. Kein Backfill.
fixed SIP-Trunk-Routen der Telefonanlage waren für jeden authentifizierten Mandanten-Nutzer offen — jetzt hinter telephony_admin; dazu 13 Opt-out-Begründungen, die zwei erfundene Permissions zitierten
geändert telephony.routes.ts, sub-action-guard.ts, route-authz-manifest.ts. WHY: GET /telephony/trunks/:id/debug liefert Passwort-Status/-Präfix des SIP-Trunks und den rohen sofia status der Telefonanlage. Verifiziert per Grep: telephony.routes.ts enthielt null Permission-Prüfungen (weder requirePermission noch permissions.has noch isAdmin), gefiltert wurde ausschließlich nach tenantId. Der Endpunkt stand damit jeder Rolle des Mandanten offen. Gehalten wurde diese Lücke ausgerechnet von der Sicherheitsmechanik selbst: Die Route steht in SUB_ACTION_OPT_OUT (ADR 0183) — also außerhalb des fail-closed Guards — mit der Begründung „Gated by telephony_manage/telephony_use + tenant“. Beide Permissions haben nie existiert; es gibt ausschließlich telephony_admin (Gegenprobe: 1.441 Einträge in ALL_PERMS, keiner der beiden dabei). Dieselbe Behauptung trug 13 Telephony-Einträge. Das ist exakt die Fehlerklasse, die der Review für die 329 Opt-outs vorhergesagt hatte: Ein Opt-out entfernt den Guard, die Begründung daneben ist der einzige verbleibende Schutz — und sie wurde von nichts geprüft. WAS: /telephony/trunks, /trunks/status und /trunks/:id laufen über ROUTE_AUTHZ_MANIFEST gegen telephony_admin; /trunks/:id/debug und /trunks/:id/test sind Sub-Action-Form und bekommen preHandler: requirePermission('telephony_admin') direkt im Route-File. Die agentenseitigen Routen derselben Datei (/dnd, /pickup, /realtime/*, /recording/*, /transfer/*, /extensions/me) bleiben bewusst ungegatet — sie gehören zum normalen Arbeitsablauf am Telefon, und telephony_admin darauf würde den Betrieb brechen; sie brauchen eine eigene, feinere Permission (offen). Alle 13 Begründungen sagen jetzt die Wahrheit statt einer erfundenen Absicherung. SYSTEMISCH: Ein neuer Test prüft jedes permission-förmige Token in Opt-out-/Override-Begründungen gegen ALL_PERMS. Baseline: 36 Verstöße über 14 nicht existierende Namen (u. a. fleet_manage, analytics_view, file_view, dispatch_manage, security_manage) — eingefroren, darf nur schrumpfen. Wichtige Einordnung: Die meisten dieser 36 sind vermutlich reine Namens-Drift (z. B. existiert statt fleet_driver_manage ein fleet_driver_edit, und fleet-uvv-training.routes.ts hat 6 echte Checks) — die Routen sind also wahrscheinlich gegatet, nur falsch beschrieben. Nicht verifiziert und deshalb ausdrücklich offen: dispatch.routes.ts hat wie Telephony null Permission-Prüfungen und steht ebenfalls im Opt-out — vorrangig fachlich zu klären. Kein Backfill.
fixed CSV-Injection: der Worker-seitige Report-Renderer hatte die OWASP-Neutralisierung der API-Kopie nicht — und genau er verschickt die Reports per Mail
geändert report-renderer.service.ts. WHY: apps/worker/src/services/report-renderer.service.ts und analytics-report-renderer.service.ts sind Kopien voneinander (identische Interfaces auf identischen Zeilennummern, diff = 17 Zeilen) — und die einzige inhaltlich relevante Abweichung war ausgerechnet die Schutzmaßnahme: Die API-Kopie prefix-escaped Zellen, die mit =, +, -, @, Tab oder CR beginnen, die Worker-Kopie nicht. Die Richtung ist die ungünstigst mögliche: Der geschützte API-Pfad ist der manuelle Download, der ungeschützte Worker-Pfad ist der, der die CSV als Anhang an alle Report-Abonnenten zustellt (analytics-consumer.ts renderReport(...) → sendMail({ attachments })). Ein Tenant-Nutzer, der =cmd|'/c calc'!A0 in ein beliebiges Entity-Feld schreibt, erreichte damit jeden Empfänger eines geplanten Berichts, sobald dieser die Datei in Excel/LibreOffice öffnet. WAS: neutralizeFormula wortgleich aus der API-Kopie übernommen, inkl. Kommentar mit dem Hinweis, dass beide Kopien identisch bleiben müssen. Bewusst als Sofortfix in der Kopie statt als Konsolidierung nach packages/shared — die Zusammenlegung der beiden Renderer ist ein eigener Schritt, der Schutz durfte nicht darauf warten. Kein Backfill (bereits versendete Berichte sind nicht rückholbar; neu erzeugte sind ab sofort neutralisiert).
fixed Zwei Abhängigkeits-CVEs geschlossen: pdfjs-dist 5.7.284 → 6.2.108 (CVE-2026-16633, CVSS 8.6) und dompurify 3.4.12 → 3.4.13 (GHSA-55q2-fjhq-7xh7)
geändert package.json, apps/web/package.json, apps/api/package.json, PdfViewer.tsx, PdfPageView.tsx, DmsGridThumbnail.tsx, FieldDesigner.tsx, PublicSigningPage.tsx. Ersetzt die Renovate-PRs #721 und #732.
-
PDF.js (CVE-2026-16633): Beim Öffnen eines präparierten PDF wird beliebiges JavaScript im Kontext der Hosting-Domain ausgeführt, sofern
enableScriptingaktiv (Default!) ist und keinescript-src-CSP greift. Beide Voraussetzungen treffen auf uns zu — die produktive CSP setzt keinscript-src(siehe MEMORYlive_csp_no_script_src), die dokumentierte Notlösung schützt uns also nicht. Betroffen sind alle vier Browser-Renderpfade: DMS-Viewer, DMS-Grid-Thumbnails, eSign-Felddesigner und die unauthentifizierte öffentliche Signaturseite — Letztere rendert per Definition ein PDF, das von außen kommt. -
Bewusst nur
apps/webauf v6 gehoben. Die Wurzel-pnpm.overrideserzwangpdfjs-distglobal; ein pauschaler Sprung auf v6 hätte auchpdf-to-img(deklariert~5.6.205) undpdf-parse(deklariert exakt5.4.296) mitgerissen — die Node-seitige OCR-/Textextraktions-Kette aus ADR 0323/ADR 0435, die pdfjs über eigene Wrapper aufruft und deren Bruch kein Typecheck bemerkt (Ergebnis wäre stumm leeres OCR, exakt die Fehlerklasse aus ADR 0323). Da die CVE eine reine Browser-Lücke ist (JS-Ausführung im Kontext der Hosting-Domain), bleiben die beiden Node-Konsumenten über die gezielten Overridespdf-to-img>pdfjs-dist/pdf-parse>pdfjs-distauf5.7.284. Der Worker ist damit unverändert, das Angriffsfenster im Browser zu. -
Drei echte v6-Breaking-Changes angepasst (alle vom Typecheck gefunden, keiner davon in Renovates PR enthalten): (1)
PageViewport.convertToViewportRectangle()wurde entfernt ([api-minor], pdf.js#21426) — die Suchtreffer-Hervorhebung inPdfPageViewrechnet die Affintransformation[a,b,c,d,e,f]jetzt lokal, identische Mathematik, ohne Abhängigkeit von einem weiteren pdfjs-Internum. (2)getDocument()nimmt keinen blanken String mehr, nur nochDocumentInitParameters→ alle vier Aufrufstellen aufgetDocument({ url })umgestellt. (3)PDFDocumentProxy.destroy()ist weg; der Abbau läuft jetzt überPDFDocumentLoadingTask.destroy()— das ist ohnehin die korrektere Stelle, weil erst sie Worker, Transport und Page-/Font-Caches freigibt (die Off-Heap-Posten aus ADR 0435). Alle vier Cleanup-Pfade entsprechend umgebaut. -
enableScripting: falsewurde bewusst NICHT gesetzt. v6.2.108 schließt die Lücke bereits; das Abschalten wäre eine Verhaltensänderung an den AcroForm-Renderpfaden (eSign) ohne Not. Als zusätzliche Härtung separat erwägenswert. -
Zusätzlich (nicht Teil der sechs Bot-PRs, vom CI-Trivy-Gate dieses PRs aufgedeckt):
js-yaml4.3.0 → 4.3.1, GHSA-5p4m-2wfm-xmqj / CVE-2026-59870 (HIGH, quadratischer CPU-Verbrauch in der!!omap-Auflösung). Der Befund ist der Grund, warum derSecurity-Workflow aufdevseit dem 07.08.2026 durchgehend rot ist. Die Einschätzung vom 28.07.2026, 4.3.0 sei „von jedem offenen Advisory unberührt“, ist überholt: das Advisory wurde seither auf die 3.x- und 4.x-Linie ausgeweitet und hat mit 4.3.1 einen Patch bekommen. Renovate hat den Bump nie vorgeschlagen, weil diepackageRulesin renovate.json js-yaml<5.0.0bewusst stillstellen (sie halten die drei astro-Parents auf der letzten CJS-tauglichen 4.x-Linie, ADR-Kontext in PR #493) — der Patch wäre also unbemerkt liegengeblieben. Kein Override musste angefasst werden: die vorhandenen~4.3.0-Pins erlauben 4.3.1 bereits. Astro-Kompatibilität ist die Bedingung, an der dieser Pin hängt, deshalb explizit nachgewiesen:pnpm --filter @codemeta/devcenter typecheck(führtastro checkaus) läuft mit 0 Fehlern. -
.trivyignore: dokumentierte Unterdrückung für CVE-2026-16633. Der Browser-Pfad ist gefixt, nicht unterdrückt (web läuft auf 6.2.108). Was den Scan noch auslöst, sind genau die zwei absichtlich auf 5.7.284 gehaltenen Node-Transitiven — beide in ihrer neuesten Version, beide ohne pdfjs-v6-Unterstützung. Auf diesem Pfad ist die Lücke nicht ausnutzbar (headlosesgetDocument()in Node, keine Hosting-Domain, kein scripting-fähiger Viewer). Der Eintrag folgt der Hausform der beiden bestehenden Einträge (esbuild, brace-expansion) inklusive Ausstiegsbedingung. ⚠️ Bewusst zur Prüfung durch Moritz gestellt — eine ID-basierte Unterdrückung einer CVSS-8.6-CVE ist eine Posture-Entscheidung, kein Routinevorgang. -
DOMPurify (GHSA-55q2-fjhq-7xh7): Entfernt ein
IN_PLACE-Hook ein Element, blieb dessen abgehängter Teilbaum ausführbar — ein<img onload=…>darin feuerte nachsanitize(). Reines Lockfile-Update (^3.4.12deckte 3.4.13 bereits ab), betrifftapps/apiundapps/web. -
Verifiziert: typecheck web (tsgo) grün, typecheck api/worker/shared/devcenter grün,
pdf-viewer-search/sanitize-html/signaturessowie die Worker-OCR- und Sign-Suiten grün, ESLint auf allen fünf geänderten Dateien ohne neue Befunde. Kein Backfill. ⚠️ Vor dem Merge manuell zu prüfen (kein automatisierter Test deckt das Rendering ab): DMS-Viewer inkl. Textsuche/Trefferhervorhebung, DMS-Grid-Thumbnails, eSign-Felddesigner und die öffentliche Signaturseite.
v0.3.13 · 2026-08-07
Behoben
fixed Automations: Cron-/Manual-/Webhook-Trigger feuerten zusätzlich bei jedem Entity-Event — ein stündlicher Import lief real ~20×/Stunde und erzeugte Duplikate
geändert trigger-index.ts, useAutomationBuilder.ts, workflow.ts, neue Testdatei trigger-index.test.ts (6). WHY: Zwei Defekte griffen ineinander. (1) Der Builder stempelte auf jeden neuen Trigger-Node den Default entityType: 'tickets', operation: 'CREATE' — auch auf Cron-, Manual- und Webhook-Trigger, bei denen die Felder bedeutungslos sind und die UI sie nie anzeigt (sichtbar nur bei type: 'event'), also auch niemand entfernen konnte. (2) rebuildTriggerIndex indizierte ohne trigger.type-Filter rein nach entityType:operation — die toten Defaults wurden dadurch zu scharfen Event-Triggern. Prod-Messung am AutoManager-Import (Tenant-Workflow mit Cron 0 * * * * + Manual-Trigger): 1840 Läufe via tickets/CREATE vs. 192 via Cron; parallel laufende Event-Läufe (WORKFLOW_EVENT-Concurrency 3) passierten beide den Idempotenz-findOne von create_entity, bevor einer inserted hatte → 12 doppelte vehicle_sales_record-Dokumente, sichtbar als falsche Verkaufszahlen im Custom-Widget. FIX: (a) Worker: Nicht-Event-Trigger (type gesetzt und ≠ 'event') werden nicht mehr in den Entity-Event-Index aufgenommen; Legacy-Trigger ohne type bleiben matchbar (Back-Compat), Integration-Trigger (eigener Index) sind unberührt. (b) Builder: entityType/operation werden nur noch für type: 'event' gesetzt; Typ-Wechsel weg von ‘event’ entfernt die Felder, Wechsel zu ‘event’ ergänzt die Defaults (sonst entstünde ein undefined:undefined-Trigger, der nie feuert). (c) WorkflowTrigger.entityType/operation sind jetzt optional (?) — sie sind Event-Matching-Felder, keine Pflichtangaben aller Trigger-Typen. ⚠️ Verhaltensänderung, beabsichtigt: Bestands-Workflows, deren Cron-/Manual-Trigger bisher (ungewollt) auch auf Entity-Events liefen, tun das nach Deploy nicht mehr — genau das ist der Bug. Verifiziert: 6 neue Tests (Event/Legacy/Singular indiziert; Cron/Manual/Webhook nicht; Mixed-Workflow behält Event-Sibling; Integration-Index unberührt), typecheck shared+worker+api (turbo) und web (tsgo) grün.
fixed M365-Delegated-Versand ging als falscher Absender raus (Empfänger sah z. B. vertrieb@ statt des gewählten support@)
geändert m365.provider.ts, email.routes.ts, email.service.ts, EmailSettingsPage.tsx, de.json/en.json. WHY: Zwei Lücken trafen zusammen. (1) M365EmailProvider.send() schickte den Graph-sendMail-Call ohne from-Property — bei App-only-Tokens egal (die URL /users/{mailbox}/sendMail bestimmt den Absender), bei delegated Tokens stempelt Exchange dann aber den angemeldeten Token-Benutzer als Absender, nicht das konfigurierte Postfach. Codemeta zeigte überall das gewählte Konto (lokale Ableitung), die tatsächlich zugestellte Mail trug den Token-User. (2) Die Authorize-URL des „Postfach autorisieren“-Flows hatte weder prompt=select_account noch login_hint — Azure-SSO übernahm still die gerade aktive Browser-Session, sodass beim Autorisieren des zweiten Postfachs unbemerkt das Token des ersten Benutzers gespeichert wurde; der Callback prüfte die Identität nie. FIX: (a) send() setzt from jetzt explizit auf das konfigurierte Postfach — Exchange versendet damit korrekt (SendAs-Recht vorhanden) oder lehnt mit ErrorSendAsDenied sichtbar ab, statt still den falschen Absender zu nutzen; wirkt über den Cross-Project-Import (apps/worker/src/lib/email-provider.ts) auch für alle Worker-Sendepfade (sendTenantEmail, Kampagnen, Mahnlauf). (b) Authorize-URL mit prompt=select_account + login_hint=<Postfach>; der Callback ruft nach dem Token-Tausch Graph /me auf (best-effort, blockiert nie) und persistiert die tatsächliche Token-Identität als config.m365.authorizedAs, mit Warn-Log bei Abweichung. (c) Die E-Mail-Einstellungen zeigen bei Abweichung einen Warnhinweis „autorisiert als … — Versand braucht ‚Senden als’-Recht“ am Konto an. Kein Backfill — authorizedAs fehlt bei Bestandskonten schlicht (kein Hinweis, kein Fehlverhalten) und füllt sich bei der nächsten Re-Autorisierung; Bestandskonten mit falsch gespeicherter Identität behebt der Nutzer durch erneutes „Postfach autorisieren“ mit dem richtigen Login.
fixed TANSS-Techniker-Zuweisung: E-Mails stehen nur am Einzelsatz — jetzt zweistufig abgefragt und gelernt statt jeden Lauf neu
geändert employee-directory.ts (+ Tests), tanss-sync.ts, integrations.ts. WHY: Am 07.08.2026 am Live-System bestätigt: GET /api/v1/employees/technicians liefert alle Techniker mit Namen, aber ohne emailAddress — die Adresse steht ausschließlich am Einzelsatz GET /api/v1/employees/{id}. Der Weg ist also zwingend zweistufig; die Laufzeit-Erkennung aus Rev 3 hat den erwarteten Zweig getroffen. Ein Einzelabruf pro Techniker und Lauf wären bei ~40 Technikern im 15-Minuten-Takt aber ~3.800 Requests/Tag gegen die Produktivinstanz — für Stammdaten, die sich fast nie ändern. FIX: Gelernte Zuordnungen in automationConfig.tanssSync.employeeEmails (Id → Adresse); ein Lauf fragt nur noch Unbekanntes ab, eingeschwungen kostet das Verzeichnis einen Request. Drei Feinheiten tragen das: (1) der leere String ist ein vollwertiges Ergebnis („geprüft, keine hinterlegt“) — ohne diese Negativ-Markierung fragte jeder Lauf dieselben adresslosen Mitarbeiter erneut ab; (2) ein Abruffehler wird NICHT gelernt, sonst zementierte ein einzelner 500er einen Mitarbeiter dauerhaft als adresslos — und er ist jetzt warn statt debug, weil solche Fehler in Rev 3 im Produktions-Loglevel verschwanden; (3) employeeEmailsRefreshedAt rückt erst vor, wenn für jeden Techniker ein Ergebnis vorliegt, sodass ein vom Budget (employeeDetailBudget, Default 25) abgeschnittener Aufbau im nächsten Lauf weiterläuft. Nach employeeEmailTtlDays (Default 30) wird die Map einmal komplett ersetzt, damit nachgetragene Adressen ankommen und Einträge gelöschter Mitarbeiter verschwinden. Verifiziert: 167 Worker-TANSS-Tests (9 neue rund um Cache-Treffer, Negativ-Markierung, nicht-gelernte Fehlschläge, Budget-Abbruch und TTL).
fixed TANSS-Spiegel Rev 3: Die Techniker-Zuweisung blieb trotz Rev 2 leer — der abgefragte Endpunkt GET /api/v1/employees existiert nicht
geändert employee-directory.ts (+ Tests), tanss-sync.ts, integrations.ts, de.json/en.json. WHY: Rev 2 meldete in der Integrationsmaske jeden Techniker als „keine E-Mail in TANSS hinterlegt“, obwohl in TANSS überall eine gepflegt ist (Befund Marvin, 06.08.2026). /api/v1/employees ist ausschließlich POST — in docs/tanss-api.yml ebenso wie in der Endpunkt-Inventur der 10.15.0-Spec (scripts/ops/tanss-api-probe.endpoints.json, 858 Operationen); der GET lief in den catch und degradierte still. Der Pfad war aus extractor.ts:extractEmployees übernommen und dadurch fälschlich für produktionsbewährt gehalten — derselbe falsche Pfad steht dort weiterhin, der Mitarbeiter-Import der Migration kann nie funktioniert haben. FIX: (1) Korrekter Endpunkt GET /api/v1/employees/technicians. (2) Weil kein dokumentierter GET das vollständige Mitarbeiter-Objekt liefert (emailAddress steht in der Spec nur im POST-Body und bei Mail-Empfängern), prüft der Code zur Laufzeit nach statt zu raten: trägt die Liste Adressen, bleibt es bei einem Request; fehlen sie, werden Einzelsätze über GET /api/v1/employees/{id} nachgeladen, budgetiert über die neue Option employeeDetailBudget (Default 50). (3) Stiller Ausfall wird laut — kein einziger Mitarbeiter mit E-Mail ist jetzt ein log.error, und die Bilanz steht als stats.employeeDirectory im integration_sync_log. (4) Die UI-Meldung behauptete etwas über fremde Stammdaten, das der Sync gar nicht wissen kann; sie lautet jetzt „E-Mail nicht aus TANSS gelesen“ samt Hinweis, dass ein flächendeckendes Auftreten auf den Verzeichnisabruf zeigt, nicht auf die Pflege. Verifiziert: 158 Worker-TANSS-Tests, davon 9 neu für Endpunkt, Nachlade-Pfad, Budget, Einzelabruf-Fehler, Platzhalter-Adressen und die ERROR-Meldung.
v0.3.12 · 2026-08-06
Hinzugefügt
added Kampagnen: HTML-Code-Modus für die E-Mail-Aktion — extern gestaltete Templates überleben jetzt den Versand
geändert ActionSidePanel.tsx, campaign-email-template.schema.ts, de.json/en.json. WHY: Der E-Mail-Schritt kannte nur den Tiptap-RichTextEditor; dessen „HTML einfügen“ parst das Markup durch das ProseMirror-Schema und wirft dabei alles weg, was das Schema nicht kennt — Inline-Styles, Layout-Tabellen, <head>/<body>, sogar das display:none des Preheaders (der dann als sichtbare erste Textzeile beim Empfänger landete). Eine extern designte Kampagnen-Mail kam deshalb als ungestylter Fließtext an; gespeichert (und versendet) wurde Tiptaps Re-Serialisierung, nie das Original. Der Versandpfad (campaign-consumer.ts → send_email → Provider) reicht htmlContent nachweislich verbatim durch — kaputt ging es ausschließlich beim Editieren. Neu: Umschalter „Editor / HTML-Code“ am Nachricht-Feld (config.contentMode: 'RICH_TEXT' | 'RAW_HTML' — der Wert, den der Preflight-Check in campaign.routes.ts bereits referenzierte, ohne dass ihn je etwas setzte). Im HTML-Modus: Monospace-Textarea statt Tiptap, sandboxed iframe-Vorschau (Muster aus EmailTemplateSettingsPage), Platzhalter-Chips fügen per insertTokenAtCaret an der Cursor-Position ein. Bestands-Aktionen ohne contentMode, deren Body wie ein vollständiges HTML-Dokument aussieht, öffnen automatisch im HTML-Modus (kein Backfill nötig — reine Anzeige-Heuristik); im Editor-Modus warnt ein Hinweis, bevor ein Dokument durch Bearbeiten zerstört wird. E-Mail-Vorlagen (campaign_email_templates) tragen den Modus jetzt mit (neues optionales Schemafeld contentMode), damit eine Roh-HTML-Vorlage beim Laden nicht im falschen Modus landet.
added Teams: Mitglieder lassen sich über ihr Benutzerkonto zuordnen — auch ohne HR-Modul
neu AddTeamMemberDialog.tsx; geändert orgunit.routes.ts + Tests, TeamManagementPage.tsx + Tests, de.json/en.json, API.md. WHY: Die Seite Verwaltung → Teams ist bewusst nicht HR-lizenziert (org-units steht in der nonSchemaRoutes-Ausnahme der Licensing-Middleware), ihre beiden Auswahlfelder waren es aber: „+ Mitglied“ und die Teamleiter-Suche gingen über /api/v1/employee_profiles (Modul hr). Ein Tenant ohne HR-Paket konnte Teams anlegen, umbenennen, Kunden zuweisen und Mitglieder entfernen — nur nie eines hinzufügen. Damit war eine Kernfunktion der Rechtevergabe an ein Modul gekoppelt, das für sie gar nicht nötig ist: Berechtigungen hängen ausschließlich am Benutzerkonto.
POST /api/v1/org-units/:id/members nimmt jetzt employeeId ODER userId. Ein Team führt zwei Mitgliederlisten für dieselbe Sache: memberUserIds (rechte-relevant — SpiceDB-member-Relation und team_member-Datenscope) und memberEmployeeIds (HR-Sicht: Dienstplan, Skill-Matrix, Berichte). Beide Pfade pflegen jeweils die Gegenseite mit, sofern sie existiert — der userId-Pfad schlägt ein evtl. vorhandenes Profil nach, der employeeId-Pfad den verknüpften Account. Damit ist die Zuordnung unabhängig davon vollständig, über welchen Weg sie entstand (dieselbe Drei-Quellen-Logik wie resolveOrgUnitsMemberIds, ADR 0203). Der Kundenrollen-Guard (ADR 0099) gilt für beide Pfade.
DELETE …/members/:memberId löst beide ID-Arten auf (UUIDv7 ist global eindeutig, die Zuordnung also nie mehrdeutig) und räumt immer beide Arrays — wer über das Profil entfernt wird, muss auch seine Rechte verlieren, und umgekehrt. Eine ID, die weder Profil noch Account ist (gelöschter Datensatz), wird ebenfalls aus beiden Arrays gezogen, damit tote Referenzen nicht dauerhaft im Team stehen bleiben.
Mitgliederliste zeigt jetzt die Vereinigung beider Arrays. Wer nur unter memberUserIds stand, war in der UI unsichtbar, obwohl die Mitgliedschaft für Rechte und Daten-Sichtbarkeit voll wirksam war — der Fall trat schon vorher auf, weil PATCH /org-units/:id jede Teamleitung unbedingt in memberUserIds einträgt, auch ohne Mitarbeiterprofil. Account-basierte Zeilen sind als „Benutzerkonto (kein Mitarbeiterprofil)“ gekennzeichnet.
Im Dialog wählt man die Quelle, wenn beide erreichbar sind (Mitarbeiter / Benutzer); ohne HR-Lizenz steht nur die Benutzer-Auswahl. Die Teamleiter-Suche fällt ohne HR ebenfalls auf die Benutzer-Auswahl zurück — managerIds sind ohnehin users._id, der Umweg über das Profil war dort reiner Ballast. Der Dialog ist als eigene Datei angelegt statt in die Seite hineinzuwachsen (Guardrail #6); TeamManagementPage.tsx wird dadurch netto kleiner. Kein Backfill: rein additiv, Bestandsdaten bleiben unberührt.
added Tresor: importierte Zugangsdaten müssen einmalig als korrekt bestätigt werden
neu ImportConfirmationPanel.tsx + Tests; geändert secret.schema.ts + Tests, entity-config.ts, security.ts, secrets.routes.ts, SecretDetailDialog.tsx, VaultImportDialog.tsx, de.json/en.json, API.md. WHY: Importierte Einträge sind geraten, nicht gelesen. Die TANSS-Quelle (tanss.ts) klopft internalRemark — einen Freitext — gegen Schlüsselwortlisten (USERNAME_KEYS/PASSWORD_KEYS/URL_KEYS ab Zeile 4) ab und verteilt das Ergebnis auf Felder. Ein Fehlgriff (Passwort landet im Benutzernamen, zwei Zugänge in einer Bemerkung werden zu einem verschmolzen, ein Doppelpunkt zu viel im Kennwort) ist dabei nicht die Ausnahme, und er ist unsichtbar: Er fällt erst auf, wenn sich Monate später jemand vor Ort damit anmelden will. Der Tresor unterschied bis jetzt nicht zwischen „von Hand eingetippt“ und „aus Prosa geraten“ — beides sah gleich vertrauenswürdig aus.
Vier Klartext-Felder auf secret, kein Bruch der Zero-Knowledge-Grenze: importSource + importedAt (Herkunft, vom Import-Dialog beim Anlegen gesetzt) sowie importConfirmedAt + importConfirmedBy (die Bestätigung). Das sind Metadaten über den Datensatz, nie Inhalt davon — ADR 0084 bleibt unangetastet; Titel, Tags, Notizen und Payload werden weiterhin ausschließlich clientseitig ver-/entschlüsselt. importConfirmedAt/By liegen zusätzlich in protectedFields, damit der generische PATCH-Pfad die Bestätigung nicht fälschen kann — sie wird nur über die neue Route geschrieben, die sie per allowProtectedFields explizit freischaltet. importSource/importedAt bleiben schreibbar, weil geschützte Felder beim Insert abgestreift werden und der Import sie genau dort setzt.
Kein Backfill. Ein Eintrag ohne importSource gilt als „nicht importiert“ und zeigt nie ein Panel — Bestandsdaten und alles von Hand Angelegte bleiben unberührt. Die Kehrseite, bewusst in Kauf genommen: bereits vor dieser Änderung importierte Zugangsdaten tragen die Herkunftsmarke nicht und lassen sich deshalb nicht nachträglich zur Bestätigung einsammeln. Die Alternative wäre ein Rewrite aller Bestands-Secrets mit geratener Herkunft gewesen — für eine Attestierung, die gerade darauf beruht, dass ihre Herkunft feststeht, ist das der falsche Tausch.
POST /api/v1/secrets/:id/confirm-import hängt an secret_view, nicht an secret_edit. Die Bestätigung ist eine Aussage über einen Datensatz, den der Aufrufer ohnehin lesen darf, und derjenige, der das Kennwort tatsächlich ausprobiert, ist häufig ein Techniker ohne Schreibrecht — mit secret_edit hätte genau der ein Panel gesehen, das er nicht bedienen kann. Die Sichtbarkeit prüft weiterhin buildVaultSecretVisibilityFilter, also derselbe Filter wie die Liste. Der Schreibvorgang läuft über updateEntity (Transaktion + history-Eintrag), nicht als Direct-Write in der Route; dazu ein secret_import_confirmed-Audit-Event (neu in VAULT_AUDIT_EVENTS, Severity low). Idempotent: eine zweite Bestätigung liefert die bestehende Attestierung mit alreadyConfirmed: true zurück und schreibt weder Historie noch Audit-Event.
Das Panel sitzt unten im Slideout, außerhalb des Scroll-Bereichs zwischen Inhalt und Fuß, damit es nicht weggescrollt werden kann — und ist während des Bearbeitens ausgeblendet, wo es mit Speichern/Abbrechen konkurrieren würde. Es bietet zwei Wege: „Daten sind korrekt“ bestätigt, „Werte korrigieren“ schaltet direkt in den Bearbeiten-Modus — ein Panel, das nur bestätigen kann, erzieht zum Wegklicken. Den Quellennamen löst es über die IMPORT_SOURCES-Registry auf statt über eine lokale Kopie: Quellenschlüssel und i18n-Schlüssel weichen voneinander ab (tanss-company-extras → vault.import.sources.tanssCompanyExtras), eine abgeleitete Rate würde ins Leere greifen. Nach der Bestätigung verschwindet das Panel; in der Meta-Zeile bleibt „Import bestätigt: <Datum>“ stehen, damit die Attestierung am Datensatz sichtbar bleibt. Als eigene Datei angelegt, weil SecretDetailDialog.tsx mit ~1.400 Zeilen längst über dem max-lines-Budget liegt (Guardrail #6: extrahieren statt wachsen).
added Such-Performance: „Fallback“- und „Refill am Limit“-Quote im Engine-Vergleich
geändert search-metrics.service.ts, SearchPerformancePage.tsx, de.json/en.json, API.md. WHY: Der Engine-Vergleich zeigte für den Unified Index einen besseren p50 (189 ms vs. 385 ms) bei gleichzeitig schlechterem p95 (7.638 ms vs. 6.063 ms) — und die zwei Zahlen, mit denen sich dieser Widerspruch auflösen lässt, standen nicht auf der Seite. Ohne sie liest man die Tabelle zwangsläufig als „der Index bringt nichts“, was sie nicht belegt.
fallbackRate wurde bereits berechnet und ausgeliefert, aber nie gerendert — in SearchPerformancePage.tsx existierte das Feld nur in der Summary-Schnittstelle. Das ist die wichtigste Einordnung überhaupt: Eine als unified gezählte Anfrage führt zusätzlich den vollen Legacy-Fan-out für alle Entity-Typen aus, die router.ts zurückgibt (entitiesForFanOut = outcome.legacyEntityTypes in search.routes.ts). Bei hoher Fallback-Quote misst die Unified-Zeile also überwiegend den Legacy-Pfad und nennt ihn Unified — die Latenz enthält beide Wege.
refillCapped wurde pro Sample erfasst, aber nirgends aggregiert. Das Flag ist der Tail-Latenz-Indikator des Unified-Pfads: Es bedeutet, dass die Nachlade-Schleife in search-unified.service.ts alle MAX_REFILL_PASSES Durchläufe ausgeschöpft hat, also mehrere vollständige Retrieve-plus-Hydrate-Runden statt einer bezahlt wurden (Kandidaten-Limit 600 → 1.800 → 2.000, jede Runde beginnt ohne Cursor von vorn). Genau dieses Muster erzeugt einen p95-Ausreißer neben einem gesunden p50. Neu als refillCappedRate im $group-Stage und in SearchMetricsSummary; auf der Legacy-Zeile ist sie wie fallbackRate konstruktionsbedingt 0, weil beide Konzepte dort nicht existieren.
Dazu ein Hinweis zur Vergleichbarkeit unter der Tabelle. legacy und unified sind kein A/B-Test: Die Engine hängt am Tenant-Flag searchEngineV2, die beiden Zeilen fassen also unterschiedliche Tenants mit unterschiedlichen Datenmengen und Suchbegriffen zusammen. Bei aktuell 26 von 268 Messpunkten auf Unified ist der dortige p95 zudem faktisch der zweitschlechteste Einzelwert und p99 das Maximum — eine Aussage über die Engine lässt sich daraus nicht ableiten. Der Hinweis verweist deshalb auf die Tenant-Tabelle darunter, wo ein geflippter Tenant gegen sich selbst verglichen werden kann.
Rein additiv: keine neuen Felder auf search_metrics (beide Werte werden seit ADR 0408 geschrieben), kein Backfill, keine Änderung am Suchpfad selbst. Die Mindestbreite der Tabelle steigt von 560 auf 760 px, sie scrollt weiterhin im eigenen Container.
added Der Bearbeiter überlebt jetzt die Kundenhaltezeit — ein zurückgegebenes Ticket landet wieder bei dem, der es kennt
geändert ticket.schema.ts (neues optionales handoverPreviousAssigneeId), entity-config.ts (als protectedField), ticket-handover-core.ts (Parken + Wiederherstellen, neuer Port canRestoreAssignee), ticket-handover.service.ts (Eignungsprüfung), 4 neue Tests, octoja-rmm-api-contract.md (neue Tabelle additiver Ticketfelder). WHY: Die Übergabe setzt assignedToId: null, und nichts schrieb ihn je zurück — es gab kein Feld, das ihn sich merkt. Das Leeren selbst ist richtig: ein Ticket, an dem der MSP nicht arbeiten kann, muss aus „meine Tickets“, aus den Arbeitslisten und aus den bearbeiterbezogenen Erinnerungen verschwinden. Die Zuordnung wegzuwerfen war die falsche Hälfte davon. Ein zurückkommendes Ticket landete unzugewiesen im Pool: der Techniker mit dem Kontext bekam es nur zufällig wieder, und der Kunde hatte beim nächsten Kontakt womöglich einen anderen Ansprechpartner — bei einer Übergabe, die genau dem Kunden beim eigenen Problem helfen soll. LÖSUNG: handoverPreviousAssigneeId parkt den Bearbeiter für die Dauer der Kundenhaltezeit und wird bei der Rückgabe zurückgeschrieben. Vier bewusste Feinheiten: (1) Der Wert wird bei einer wiederholten Übergabe nicht überschrieben — dort ist assignedToId bereits null, und ein Überschreiben würde die Erinnerung löschen. (2) Die Eignungsprüfung ist ein Port, keine Regel im Kern: „darf zurückbekommen“ ist eine App-Frage (aktive Mitgliedschaft, Krankmeldung, genehmigter Urlaub), die der reine Kern nicht kennen soll. (3) Nicht geeignet ⇒ das Ticket kommt unzugewiesen zurück und die normale Disposition entscheidet — jemandem im Urlaub ein Ticket zuzuschieben ist schlechter als der Pool. (4) Der geparkte Wert wird bei der Rückgabe immer geleert, auch wenn nicht wiederhergestellt wurde: er beschreibt genau eine Haltezeit, und ein stehengebliebener Wert würde nach der nächsten Runde die falsche Person zurückholen. (5) Wurde das Ticket während der Kundenhaltezeit manuell neu zugewiesen, restauriert die Rückgabe nicht und lässt assignedToId komplett unangetastet — die manuelle Zuweisung ist die neuere, bewusste Entscheidung, und sie still mit dem geparkten Alt-Bearbeiter zu überschreiben wäre die bekannte Fehlerklasse „Re-Dispatch klaut manuelle Zuweisung“ (Review-Befund zu PR #709; eigener Test „does not restore when the ticket was manually reassigned during the customer hold“). Nebenwirkung, die hier hilft: das Zurückschreiben passiert synchron im selben Schreibvorgang wie die Rückgabe — anders als die Neu-Disposition, die am Event-Router und damit am Change-Stream hängt. Die Kontinuität überlebt also einen Worker-Ausfall. Verifiziert: 16/16 Tests der Datei grün (4 neu: parken, kein Überschreiben bei wiederholter Übergabe, Wiederherstellung bei Eignung, unzugewiesen ohne Eignung); der Schutz-Test „covers exactly the protected fields“ hat die Erweiterung erwartungsgemäß eingefordert; Drift-Test, eslint und api-Typecheck grün. Kein Backfill — fehlendes Feld heißt „nichts geparkt“, also unverändertes Verhalten für alle Bestandstickets. Octoja: rein additiv, optional, server-verwaltet. Zur Mitgliedschaftsprüfung: sie läuft über das tenant-scoped users-Dokument (isActive), nicht über user_tenant_mappings. Ein erster Anlauf tat Letzteres mit flachen {tenantId, userId}-Feldern und schlug im Live-Test still fehl: die Mapping-Collection ist auf authUserId gekeyt und trägt die Mitgliedschaft in einem tenants[]-Array (tenants.tenantId + tenants.userId) — die flachen Felder existieren nur an Demo-Seed-Zeilen. Die Abfrage traf damit die geseedeten Kollegen, aber ausgerechnet den Tenant-Owner nicht, dessen Rückgabe folglich nie zuwies. users ist tenant-scoped und beantwortet genau die gestellte Frage.
added Föderation Welle 3: bedienbar — Verwaltungsoberfläche, Installations-Identität, Sync-Protokoll, Migrationsweg
neu TenantLinksPanel.tsx, TenantLinkHandshakeDialog.tsx, TenantLinkCapabilityToggles.tsx, PeerSyncLogDialog.tsx, installation-identity.service.ts, peer-sync-log.routes.ts, scripts/migrate-peer-federation-indexes.ts, peer-federation-index-migration.ts (je + Tests); geändert tenant-link.routes.ts, peer-exchange.routes.ts, IntegrationSettingsPage.tsx, env.ts, .env.example, docs/DEVELOPMENT.md, docs/API.md. Einladung, Annahme, Widerruf, Rotation und Capabilities gingen bisher nur per API — für einen Betreiber unbenutzbar. Der Handshake wird jetzt geführt (Rollenwahl → Einladung → Einlösen → Abschluss) samt Erklärung, warum es drei Schritte sind; Codes und Geheimnisse erscheinen genau einmal, und der Dialog lässt sich erst schließen, nachdem der Nutzer bestätigt hat, sie gesichert zu haben. Der Widerruf zeigt zwei Listen — „das endet“ und „das läuft weiter“ — statt eines generischen „wirklich?“. Die Rotation nennt ausdrücklich, dass sie nicht unterbrechungsfrei ist. Über den Capability-Schaltern steht eine Warnung, solange createTicket aus ist, weil eine frische Verknüpfung sonst gar nichts spiegelt und niemand versteht warum. Installations-Identität: einmalig erzeugt und in der Datenbank abgelegt, mit PEER_INSTALLATION_ID als Override. Eine Env-Variable mit Default kann hier nicht funktionieren — der Default wäre auf jeder Installation, die ihn nie gesetzt hat, dieselbe Zeichenkette, zwei Installationen behaupteten also eine Identität; genau die Verwechslung, die dieses Feld ausschließen soll, und der erste Fall, der daran scheitert, ist „zwei lokale Instanzen koppeln”. Die Datenbank ist die Installation: alle Replicas teilen sie, zwei lokale Instanzen haben zwei Datenbanken und damit gratis zwei Identitäten. Zusätzlich liefern die Handshake-Routen jetzt ein Bündel, dessen Form dem Körper des nächsten Schritts entspricht — ein Blob kopieren statt sechs Felder abzutippen, von denen zwei 32 bzw. 64 Hexzeichen lang sind und ein falsches Zeichen als ununterscheidbares 401 endet. Sync-Protokoll je Verknüpfung und je Ticket, mit expliziter DTO-Allowlist; die Hash- und Cursor-Felder sind bewusst nicht dabei — das ist der Echo-Schutz. Zwei Befunde, die Vorgaben korrigiert haben: (1) 🔴 Für messages.extMessageId muss die Reihenfolge umgekehrt sein — erst droppen, dann anlegen. Alter und neuer Index teilen das Schlüsselmuster und unterscheiden sich nur in den Optionen; MongoDB weigert sich, beide gleichzeitig zu halten (Fehler 85/86). Mein Registry-Kommentar forderte das Gegenteil und wäre in der Praxis gescheitert. Gefahrlos ist es, weil der alte Index nicht unique war (und sein sparse wirkungslos): es geht keine Invariante verloren, nur der indexgestützte Lookup für die Bauzeit. Für ticket_external_links gilt unverändert erst anlegen, dann droppen — beide Fenster stehen in der Skript-Ausgabe. (2) scripts/*.test.ts wird von keinem Vitest-Projekt erfasst; die vorhandenen Skript-Tests laufen also nie, auch nicht mit dem in ihren Kopfzeilen dokumentierten Befehl. Die Entscheidungslogik der Migration liegt deshalb in packages/shared, wo sie tatsächlich getestet wird — und der Test fand dort prompt einen echten Fehler: das alte sparse-Index wurde als „bereits migriert“ erkannt und hätte ein falsches Unbedenklichkeitszeugnis ausgestellt. Verifiziert: 24 Migrations-Tests, 51 Handshake-, 13 Protokoll-, 31 Ingress-Sicherheitstests, 5 UI-Tests, i18n-Parität und Feature-Boundary-Ratchet grün; tsgo über web ohne Fehler. Kein Backfill für den Code — das Skript bricht ab, wenn die Duplikatszählung nicht null ist, statt einen Index-Build zu erzwingen.
Was in der Oberfläche noch fehlt: die feinere Status-/Prioritätsabbildung pro Verknüpfung (die Rollen-Achse greift, Feinabbildung nur per API), ein Einstieg auf der Ticket-Detailseite in dessen Sync-Protokoll (Endpoint und Tests existieren), ein Ping-Knopf, und
peerCapabilitiesist bewusst nur Anzeige.
added Föderation Welle 1b: die Ticket-Exchange-Engine wird peer-fähig
neu provider-facts.ts, geändert engine/{connection,link,inbound,outbound,echo,ports,attachments}.ts, types.ts, providers/jira/*, ticket-handover-core.ts, apps/worker/src/ticket-exchange/{outbound-job,reconcile-job,sweeps,port}.ts, apps/api/src/routes/{ticket-exchange,customer-collaboration}.routes.ts, index-registry.ts, init.js. Die Registry war nicht der Dispatch-Pfad, obwohl ihr eigener Kommentar das behauptet: 'jira' stand als Literal in Outbound-Job, Reconcile, Sweep und rund einem Dutzend Routenstellen — der Outbound brach bei jedem Nicht-Jira-Profil sofort ab, und der Sweep listete nur Jira-Konfigurationen, ein Peer wäre also nie geplant worden. Aufgelöst über eine neue, reine Stammdaten-Tabelle neben der Registry (die Registry hält das Verhalten und braucht den Node-Subpath, die Tabelle die Stammdaten und funktioniert schon vor dem Provider-Import). Bug dabei gefunden: linkSystemForCollaborationMode gab einem PEER-Profil system: 'automation' samt Tenant-Link als connectionId, während der Outbound unter 'peer' suchte — die Paarung entstand und fand nie jemand. Mehrere Peers pro Ticket: verbindungs-skopierte Auflösung mit Altbestands-Heilung (Zeilen ohne connectionId aus ADR 0299 werden nachgezogen statt dupliziert). handoverSeq und setOwner sind endlich verdrahtet — sie hatten null Aufrufer, wodurch der Zähler dauerhaft auf 0 blieb und die Sequenzprüfung einseitig war. Die Halter-Autorität ist strukturell gelöst statt per Konvention: ein eingehendes handover wird nur akzeptiert, wenn die Gegenseite tatsächlich Halter ist — sie kann abgeben, nie an sich ziehen; wer zurück will, sendet requestReturn (Notiz beim Halter, keine Zustandsänderung). Damit gibt es strukturell keinen Konflikt und v1 braucht keine Konflikt-Oberfläche. Die Regel hängt an einer neuen Capability, nicht am Providernamen — Jira behält false und bleibt damit bit-identisch. 🔴 Meine Annahme „das Schlüsselformat ist jetzt folgenlos änderbar“ war falsch. Der Jira-Marker-Regex hätte die instanzqualifizierte Form nicht erkannt — jeder bereits in Jira stehende Kommentar wäre beim nächsten Abgleich als fremd eingelesen worden, also die komplette Historie zurück ins eigene Ticket. Kein Datenbestand, aber ein Lesepfad; vier bestehende Tests haben es aufgedeckt, der Regex akzeptiert jetzt beide Formen. isVisibleAttachment war bestätigt wirkungslos: es prüfte att.internal, ein Feld, das es auf message.attachments[] nicht gibt — jeder Anhang ging durch. Besonders unangenehm: der bestehende Unit-Test setzte das Feld selbst und bestätigte damit eine Grenze, die es in Produktion nie gab. Repariert an der richtigen Achse (files.isInternal). Zwei Index-Änderungen mit ausformulierter Rollout-Reihenfolge: der Unique-Schlüssel auf ticket_external_links erlaubt jetzt mehrere Gegenstücke, und messages.extMessageId wird partial-unique — der Code verlässt sich ausdrücklich auf „deterministischer Upsert“, und ein Upsert gegen einen nicht-eindeutigen Schlüssel ist unter Nebenläufigkeit ein Rennen, das heute schon besteht. ⚠️ Vor dem Bauen auf Prod zählen: db.messages.aggregate([{$match:{extMessageId:{$gt:''},deletedAt:null}},{$group:{_id:{t:'$tenantId',e:'$extMessageId'},n:{$sum:1}}},{$match:{n:{$gt:1}}},{$count:'duplicates'}]) — 0 heißt backfill-frei, alles andere macht daraus einen Backfill mit Banner und Freigabe. Verifiziert: 554 Tests über shared/worker/api grün, typecheck über alle drei Workspaces, Index-Drift-Test grün. Kein Backfill für den Code.
added Föderation Welle 1: tenant_links als Vertrauens-Primitiv zwischen zwei codemetaOS-Installationen
neu tenant-link.ts (Typ + Schema), federation/{protocol,capabilities,signature,sync-log}.ts, peer-federation.ts (Node-only Subpath), tenant-link.service.ts, tenant-link.routes.ts (je + Tests), geändert entity-config.ts, permissions.ts, modules.ts, index-registry.ts, init.js, crud.routes.ts, sub-action-guard.ts, app.ts, docs/API.md. Das Fundament, noch ohne Kanal — Transport, Peer-Provider und Ingress folgen in Welle 2. Handshake: beidseitiger Konsens über einen invitationCode statt über Tenant-IDs, damit niemand fremde IDs enumerieren oder auf beliebige zeigen kann; Widerruf von beiden Seiten. Geheimnisse: zwei richtungsgetrennte, AES-256-GCM-verschlüsselte Werte — bewusst nicht gehasht, weil die HMAC-Prüfung in Welle 2 den Klartext zum Nachrechnen braucht; ein Hash trüge nur bei einem Bearer-Vergleich und gäbe weder Replay-Schutz noch Body-Bindung. Vier Schichten halten sie drin: protectedFields, Redaktion aus jeder generischen Antwort (derselbe Präzedenzfall wie takeBackToken weiter oben), eine DTO-Allowlist auf der Route, und Klartext-Rückgabe genau einmal beim Anlegen, Einlösen und Rotieren. Signatur-Bausteine nach dem Muster des Octoja-Webhooks (HMAC über Zeitstempel und Body, ±300 s) — ausdrücklich nicht nach dem generischen webhook-auth.ts, der keinen Zeitbezug hat und repo-weit ohnehin keinen Produktionsaufrufer besitzt. Fünf Befunde, die den Entwurf korrigiert haben: (1) 🔴 Das Feld heißt peerTenantId, nicht remoteTenantId — die einzige Funktion, die diese Collection heute schon liest (loadPeerConnection), fragt genau danach; eine Umbenennung hätte sie gebrochen. (2) 🔴 Der Sub-Action-Guard hätte den Einlösenden ausgesperrt: er löst über den tenant-eigenen Zugriffsfilter auf und verweigert der Gegenseite exakt die beiden Operationen, die beide Seiten laut ADR haben müssen — Lesen und Widerrufen. Drei dokumentierte Opt-outs, die Handler prüfen selbst über ein zweiseitiges Prädikat (ein dritter Tenant bekommt 404). (3) Ein Handshake über Installationsgrenzen kann nicht aus zwei Aufrufen bestehen — es gibt keine gemeinsame Datenbank; umgesetzt als dreistufiger Betreiber-Ablauf, dessen Service-Funktionen Welle 2 unverändert wiederverwendet. (4) Die Signatur-Datei kann nicht über den Haupt-Barrel laufen (node:crypto bricht das Web-Bundle) — eigener Subpath, samt der Falle, dass vitest.config.ts eine handgepflegte Alias-Liste hat. (5) Neu ergänzt: invitationExpiresAt (7 Tage) — ein nie ablaufender, uneingelöster Code ist ein dauerhafter Freifahrtschein, Peer zu werden. Verifiziert: 31 Shared-Tests (Replay in beide Richtungen, manipulierter Body, unlesbarer Zeitstempel als harter Fehlschlag, Versionsaushandlung, UND-Verknüpfung von Widerruf und Capability, kein Existenz-Orakel) + 32 Routen-Tests (vollständige Zustandsmaschine, doppeltes Einlösen, beidseitiger Widerruf, Rotation, Sichtbarkeit) + Redaktionstests; alle Drift-/Ratchet-Tests grün. Kein Backfill — die Collection hatte bisher keinen einzigen Schreiber. ⚠️ Offen: die Rotation ist nicht unterbrechungsfrei (kein Überlappungsfenster) — bis die Gegenseite das neue Geheimnis einträgt, steht der Kanal. Ein Überlappungsfenster ohne Ablauf-Sweep verdoppelt dauerhaft die Schlüssel, die eine Anfrage verifizieren können, und wäre schlechter; im Code vermerkt, in ADR 0405 als offene Frage geführt.
added Genehmigungspflicht vor Bearbeitung: das Ticket wartet jetzt tatsächlich, statt nur die Anfrage zu melden (ADR 0295 Fall 1)
neu ticket-approval-gate.ts (shared + api + worker), ticket-create-gate.service.ts, transition-approval.service.ts, ticket-create-gate-sweep.ts (je + Tests), geändert transitions.ts, crud.routes.ts, transition.routes.ts, approval.routes.ts, schema-config.routes.ts, entity.service.ts, dispatch/index.ts, event-router.ts, approval-expiry.ts sowie Workflow-Editor und Genehmigungsregel-Maske. WHY: Der __CREATE__-Hook aus ADR 0295 meldete die Genehmigung bereits an, sperrte das Ticket aber ausdrücklich nicht — es lief also trotz ausstehender Freigabe direkt in Dispatch und Bearbeitung. Genau das fordert die Anforderung anders: das Ticket darf nicht automatisch in den Arbeitsstatus wechseln, sondern verbleibt bis zur Freigabe im Wartestatus. Umsetzung: Der Wartestatus ist ein Rollenmarker StatusDefinition.approvalRole, kein Status-Key — Status sind pro Tenant frei definierbar, ein hartkodiertes PENDING_APPROVAL wäre bei jedem Tenant mit eigenen Keys falsch. Der Resolver hat bewusst keinen Literal-Fallback: ohne markierten Status liefert er [] und das gesamte Feature ist inert; ein Fallback auf einen gleichnamigen Status würde bei einem Tenant, der ihn anders nutzt, still den Dispatch abschalten. Der Gate-Status wird vor dem Insert gesetzt, damit History, Webhook, Event-Router, Board und die POST-Antwort denselben Zustand sehen — es gibt keinen Moment, in dem das Ticket ungegatet existiert. 🔴 Ein Fehler, den der Umsetzer an der eigenen ersten Verdrahtung gefunden und behoben hat: handleApprovalRequired ruft resolveTicketTransitionPolicy, und der filtert trigger:'create'-Regeln seit dem Kundenmail-Eintrag absichtlich heraus — der Aufruf hätte damit immer null ergeben, die Kompensation wäre bei jedem gegateten Ticket gelaufen und das Gate hätte nie gehalten. Gelöst über eine additive, opt-in meta.createGate-Übergabe; kein bestehender Aufrufer setzt sie, der Transition-Pfad ist nachweislich unverändert. Regressionstest hält das fest. Fail-open bei der Erstellung — bewusst gegenläufig zum Transition-Pfad, der mit 422 antwortet: fehlt Approver, Gate-Status oder Workflow, entsteht das Ticket ungegatet plus Warnung und Transparenzzeile. Ein 422 hieße, der Kunde könnte sein Ticket gar nicht anlegen. Das ist eine betriebliche, keine Compliance-Zusage — steht so im Code und in docs/API.md. Zwei Bestandslücken mitbehoben, ohne die das Feature nicht funktioniert hätte: executeApprovedTransition rief keine Entry-Actions (nach der Freigabe wären die des Initialstatus nie gelaufen), und nach der Freigabe wurde nie disponiert — beides jetzt eng auf den Gate-Fall begrenzt nachgezogen. Betriebsinvariante mit Nachweis: kein Ticket verbleibt dauerhaft im Gate ohne offene Anfrage; der Sweep hängt am bestehenden Expiry-Schedule (Begründung infrastrukturell, nicht fachlich) und liest den kanonischen CREATE-History-Eintrag statt den Zielstatus zur Sweep-Zeit neu aufzulösen — zwischen Erstellung und Recovery kann der Workflow geändert worden sein. Er heilt ausschließlich Create-Gates und terminiert nach drei Versuchen mit einer einmaligen Meldung. Verifiziert: 2097 Tests grün, typecheck shared/api/worker grün. Kein Backfill — approvalRole fehlt überall, der dritte shouldDispatch-Parameter defaultet auf [], historyMetadata ist optional. ⚠️ Offene Produktentscheidung: der ältere triggerOnCreate-Hook läuft unverändert weiter. Ein Kunde, bei dem beide Mechanismen konfiguriert sind, erhält zwei Genehmigungsanfragen — welcher gewinnen soll, ist noch nicht entschieden.
added KI-Wiedereröffnung: manuelle Bestätigung bei unsicherer Bewertung, Konfidenz im Ticket sichtbar, und der CLOSED-Fall
neu ai-reopen-suggestion.ts, ticket-ai-reopen-confirm-expiry.ts, ai-reopen-suggestion.service.ts, AiReopenSuggestionBanner.tsx (je + Tests), geändert ticket.schema.ts, entity-config.ts, link-email-to-ticket.ts, ticket-operations.routes.ts, ReopenBanner.tsx. Vervollständigt den Schwellwert aus dem vorigen Eintrag um die dritte von der Anforderung verlangte Option. Bestätigungs-Modus: ticket.resolved_mail_classifier.uncertain_mode = 'confirm' legt unsichere Fälle einem Menschen vor, statt automatisch zu entscheiden. Das Arbeitsartefakt ist eine Aufgabe über den vorhandenen createAutoTask-Helfer — bewusst kein neues Entity: damit gibt es Board, Benachrichtigung, Fälligkeit und Ticketverknüpfung ohne schema.zed, init.js, Permission oder Drift-Test-Eintrag. Der Zustand hängt additiv als aiReopenSuggestion am Ticket. 🔴 Die fünf neuen Felder stehen in protectedFields — sonst könnte jeder mit ticket_edit den Vorgang per generischem PATCH auf CONFIRMED setzen, ohne dass eine Wiedereröffnung stattfindet. Wettlaufsperre: durchgängig im Update-Filter, nie über ein vorheriges findOne — zwischen Lesen und Schreiben passt der Ablauf-Sweep genau hinein. Anlegen per findOneAndUpdate mit $ne: 'PENDING' (die zweite gleichzeitige Mail landet im Aktualisierungszweig, es entsteht keine zweite Aufgabe), Bestätigen/Verwerfen mit 'aiReopenSuggestion.status': 'PENDING' im Filter; matchedCount === 0 ⇒ 409, denn das Rennen ist der Normalfall, kein Serverfehler. Drei Backstops, damit nichts unbearbeitet liegen bleibt: Ablauf-Sweep alle 15 min (wendet nach Frist den Tenant-Fallback an), Auto-Close-Guard ($ne: 'PENDING', matcht fehlende Felder ⇒ kein Backfill), und eine Terminierung — Fehlversuche zählen in einer History-Zeile, nach dem dritten wird einmal gemeldet und der Fall übersprungen; der Vorgang bleibt bewusst PENDING, damit Banner und Aufgabe stehen bleiben und ein Mensch hinsieht. Sichtbarkeit: der bereits vorhandene Reopen-Banner zeigt jetzt Konfidenz, Modell und Basis (threshold / fallback / confirmed); der neue Vorschlags-Banner zeigt Einstufung, Konfidenz, Begründung und die beiden Schaltflächen. CLOSED-Fall: ticket.closed_mail_spawn_followup.classify_first (Default aus, also bit-identisches Bestandsverhalten) lässt den Klassifikator vor dem Folge-Ticket laufen — ein sicheres „Danke!“ erzeugt damit kein sinnloses Folge-Ticket mit eigener Nummer, eigener SLA-Uhr und eigenem Dispatch mehr. Auf diesem Zweig wird CONFIRM wie auto behandelt: ein geschlossenes Ticket hat niemanden, der bestätigen könnte, und Spawnen ist die umkehrbare Richtung. 🔴 Bugfix nebenbei: spawnOrReuseFollowupTicket suchte das bestehende Folge-Ticket über die hartkodierte Liste ['OPEN','IN_PROGRESS','WAITING'] — bei umbenannten Status fand es nie eines, und jede weitere Mail spawnte ein neues Ticket, also genau den Sturm, den die Wiederverwendung verhindern soll. Jetzt über die Lifecycle-Rollen (ADR 0304). Dieselbe Klasse Fehler wie im Inaktivitäts-Reminder zwei Einträge weiter unten. Review-Fix: der 15-Minuten-Ablauf-Sweep stützt sich auf den neuen Partial-Index idx_tickets_ai_reopen_pending_expiry (aiReopenSuggestion.expiresAt, nur Dokumente mit aiReopenSuggestion.status: 'PENDING'; registriert in index-registry.ts, gespiegelt in init.js) — ohne ihn wäre der Cross-Tenant-Discovery-Read ein Collscan über tickets alle 15 Minuten gewesen (Prod-Cache-Thrash-Klasse). Verifiziert: worker 53/53, api 5/5 + Direct-Write-Ratchet, shared Drift + Parity 39/39, web 39/39, typecheck über alle drei Workspaces grün. Kein Backfill — alle Felder additiv, alle neuen Filter $ne/$nin, beide Einstellungen per Default aus.
added Kundenbenachrichtigung bei Ticketeröffnung — pro Kunde über die bestehenden Genehmigungsregeln konfigurierbar
neu party-email.ts, approval-policy-key.ts, ticket-customer-notification.service.ts (je + Tests), geändert approval-policy.schema.ts, approval-policy.service.ts, email-templates.ts, transactional-email.ts, notifications-consumer.ts, ticket.hooks.ts, crud.routes.ts sowie die Genehmigungsregel-Maske. WHY: Manche Kunden wollen über jedes neu erstellte Ticket informiert werden. Bisher gab es dafür nichts: TICKET_CREATED war eine reine In-App-Notification an den internen Bearbeiter, und in EMAIL_TEMPLATE_TYPES existierte überhaupt kein Ticket-Template. Umsetzung: Die Konfiguration reiht sich in die bestehende approval_policy ein, statt eine zweite Per-Kunden-Konfiguration danebenzustellen — neue optionale Felder trigger: 'transition'|'create', notifyCustomerOnCreate, customerNotifyRecipient sowie der Ausgang action: 'none' für eine reine Benachrichtigungsregel ohne Genehmigungs-Gate. Versendet wird über den vorhandenen transaktionalen Mailweg, der jetzt zusätzlich einen Template-Typ annehmen kann; damit kommen Tenant-Branding und tenantweite Vorlagen-Overrides ohne Zusatzarbeit mit. Drei Fallen, die dabei entschärft wurden: (1) 🔴 resolveTicketTransitionPolicy behandelt fromStatus/toStatus === null als Wildcard — eine Eröffnungs-Regel hat beide null und hätte damit jede beliebige Statuskante dieses Kunden blockiert. Der Resolver filtert jetzt zuerst nach trigger; drei blockierende Tests halten das fest, inklusive des Falls, in dem die Eröffnungs-Regel neuer ist und den Tiebreak sonst gewonnen hätte. (2) Der Eindeutigkeitsschlüssel der Duplikat-Guards musste trigger aufnehmen, ohne den Schlüssel von Bestandsdokumenten zu verändern. Da die entitySchema-Achse bereits ein $or braucht, wandern beide Fragmente in ein $and — zwei gleichrangige $or-Schlüssel hätten sich im Objekt still gegenseitig überschrieben. Ein Test nagelt fest, dass ein Dokument ohne trigger exakt denselben Schlüssel erzeugt wie zuvor. (3) Es existierten zwei Kopien einer Party-E-Mail-Auflösung, die beide contactChannels[].purposes ignorierten; sie sind zu einer Utility zusammengeführt und beide Aufrufer darauf umgestellt — ohne purpose verhaltensneutral, per Test festgehalten. Bewusste Grenzen: kein Fallback auf createdBy (das schickte die Bestätigung an den MSP-Mitarbeiter statt an den Kunden); actionUrl nur bei vorhandenem Portalkonto, nie ein interner Ticket-Link nach außen; Inhalt ausschließlich kundeneigene Daten. Opt-out-Matrix: Default kein Versand; vom Kunden selbst im Portal angelegt ⇒ trotzdem bestätigen; Quelle E-Mail ⇒ nicht bestätigen (Autoresponder-Schleifenrisiko); gesetztes sourceRef (Import/RMM-Alarm) ⇒ nicht bestätigen; Doppelversand per jobId ausgeschlossen — Review-Fix: die deterministische jobId ist --separiert (ticket-created-mail-<tenantId>-<ticketId>), nicht :-separiert, denn BullMQ 5 lehnt Custom-Ids mit : synchron ab („Custom Id cannot contain :“, die bekannte :-in-jobId-Bugklasse, die schon einmal stillen Mailverlust verursacht hat), und der afterCreate-Hook hätte den Fehler nur als warn geschluckt — die Kundenmail wäre still nie versendet worden. Verifiziert: 647 Tests der API-Suite plus die neuen Einzelsuiten grün, typecheck api + worker grün, docs/API.md erweitert. Kein Backfill — alle Felder optional ohne JSON-Schema-Default, fehlendes trigger wird zur Laufzeit als 'transition' gelesen, und ohne Regel geht keine Mail.
Geändert
changed Plattform-Telemetrie liegt jetzt in der Time-Series-Collection platform_metric_series statt in platform_metric_sample
neu platform-metric-series.schema.ts (ersetzt platform-metric-sample.schema.ts), 0446-platform-metrics-as-timeseries-collection.md; geändert platform-metrics-scraper.ts, platform-kpi.ts + Tests, main.ts, support-platform.routes.ts, platform-alert-rule.ts, AlertRulesTab.tsx, API.md, RUNBOOK.md, platform-observability.md. WHY: platform_metric_sample war mit 121 M Dokumenten / ~36 GB (27,76 GB Data, 6,12 GB Storage, 7,95 GB Index über 4 Indizes) die mit Abstand größte Collection der Produktionsdatenbank — der Index war größer als die Daten auf Platte. Ursache ist die Speicherform, nicht die Datenmenge: 121 M winzige Dokumente à 230 B, jedes mit vier Indexeinträgen, darunter ein 36-Zeichen-String-_id (UUIDv7), den keine Query je liest.
Die Dimensionierungsannahme war kategorial falsch. Der alte Schema-Doc-Block rechnete mit „~50 Metriken × 1-Min-Buckets ≈ 72k docs/day“ und begründete damit ausdrücklich, dass keine Time-Series-Collection nötig sei. Real: 121 M / 90 Tage = ~934 Dokumente pro Minute ≈ 1,34 M/Tag, Faktor 18,6. Die Schätzung hat Metriken gezählt statt Serien — 52 Queues × 5 States = 260 Zeilen/min allein für die BullMQ-Gauges (fast durchgehend 0), dazu pro (method, route, status)-Kombination vier Serien (_delta, _total, _avg_seconds, _count). Verstärkt durch zwei Effekte: prom-client vergisst Label-Kombinationen nie, eine einmal um 03:00 Uhr getroffene Route erzeugt bis zum Prozess-Neustart jede Minute eine Zeile mit Delta 0 (die Schreibrate wächst also mit der Uptime, nicht mit dem Traffic), und _total legt den Lifetime-Zähler jede Minute in voller Breite neu ab, obwohl er aus den Deltas rekonstruierbar ist.
Warum das über Plattenplatz hinausging: ADR 0437 hatte am Vortag gezeigt, wie eine fette Collection den WiredTiger-Cache Atlas-weit thrasht und dadurch Namespaces mit intakten Indizes ausbremst. Dazu die laufende Schreibverstärkung von ~3.700 Index-Writes/min plus TTL-Löschungen in gleicher Rate am 90-Tage-Rand, jede ein eigener Oplog-Eintrag.
Umgesetzt als reine Speicherform-Änderung. Die Serien-Identität (metric, source, labels) wandert unter das metaField meta, bucketAt wird timeField, granularity: 'minutes'. MongoDB packt bis zu 1000 Messwerte derselben meta in ein Bucket-Dokument (bei dieser Granularität spannt ein Bucket 24 h), der Indexeintrag hängt am Bucket statt am Messwert, und einen _id pro Messwert gibt es nicht mehr — der teuerste Index entfällt komplett. Die TTL ist jetzt eine Collection-Option statt eines Index, MongoDB verwirft also ganze abgelaufene Buckets. Metriknamen, Labels, Aufteilung und 90-Tage-Retention bleiben identisch; die Response-Form von GET /platform/metrics bleibt flach — die Admin-Console brauchte keine Änderung. Erwartung: ~36 GB → deutlich unter 1 GB bei gleicher Funktionalität.
Der tote Index ist mit weggefallen. { source: 1, bucketAt: -1 } wurde von keiner Query benutzt — beide Lesepfade führen mit metric (GET /platform/metrics verlangt ihn als Pflichtparameter, readSamples() filtert {metric, source, bucketAt}) und werden vom Compound-Index bedient. Neu genügt ein {'meta.metric', 'meta.source', bucketAt}.
Kein Backfill, kein Migrationsschritt im Deploy. Die neue Collection trägt einen neuen Namen und wird beim Worker-Start angelegt; die alte bleibt bis zu einem manuellen db.platform_metric_sample.drop() unangetastet liegen. Deploy und Drop sind damit entkoppelt — es gibt kein Zeitfenster, in dem der Worker in eine indexlose 121-M-Collection schreibt. Bewusst kein stufenweises TTL-Absenken als Alternative: das hätte ~100 M Einzellöschungen und ebenso viele Oplog-Einträge erzeugt, also genau das Lastereignis aus ADR 0437 absichtlich ausgelöst. drop() ist dagegen eine Metadaten-Operation. Preis, bewusst akzeptiert: Die Console-Historie beginnt mit dem Deploy neu — Telemetrie ist wiederbeschaffbar und innerhalb der Regelfenster (5–15 min) sind alle KPIs sofort wieder aussagekräftig.
Gegen die stille Rückabwicklung abgesichert. Eine Time-Series-Collection entsteht ausschließlich durch createCollection mit timeseries-Option; trifft ein Insert zuerst auf den fehlenden Namespace, legt MongoDB implizit eine reguläre Collection an — das alte Speicherprofil wäre zurück, diesmal sogar ganz ohne TTL, weil die Ablaufzeit an den Collection-Optionen hängt. Deshalb wird ensurePlatformMetricSeriesCollection() vor jedem Insert awaited (memoisiert, nach dem ersten Aufruf kostenlos) statt nur einmal beim Boot als Fire-and-Forget, prüft nach dem Anlegen per listCollections, dass type === 'timeseries' ist, und wirft sonst mit Anleitung. Die Collection ist außerdem bewusst nicht in der Index-Registry und nicht in init.js eingetragen (begründete Ausnahme von ADR 0314, im Schema-Doc-Block festgehalten): der Reconcile-Pfad ruft createIndex, was den Namespace als reguläre Collection erzeugen würde, bevor der Worker drankommt.
Nicht Teil dieser Änderung: die schreibseitige Kardinalitätsreduktion (Zero-Gauges überspringen, _total weglassen, status → Statusklasse). Sie bleibt sinnvoll, ist nach dem Umbau aber kein Speicherproblem mehr und ändert Produktverhalten — deshalb separat zu bewerten.
Behoben
fixed Kampagnen: Empfänger-Vorschau zeigte „kein Treffer“ für Firmen mit allgemeiner E-Mail-Adresse (Fallback COMPANY_EMAIL) und für direkt anvisierte Personen
geändert campaign.routes.ts. WHY: Die beiden Vorschau-Endpunkte POST /campaigns/:id/recipients/preview und /recipients/list laden die gesampelten Parties mit einer Minimal-Projektion (partyType, name, displayName, firstName, lastName) und reichen das projizierte Dokument als opts.targetParty in resolveRecipients — der Resolver lädt dann selbst nicht mehr nach. Sowohl der COMPANY_EMAIL-Fallback als auch der DIRECT_PERSON-Kurzschluss lesen die Adresse aber aus targetParty.contactChannels, das die Projektion weggeworfen hatte → immer null → „kein Treffer“, obwohl die Firma eine allgemeine E-Mail-Adresse als Kontaktkanal pflegt. Nur die Vorschau war betroffen: der Worker-Sendepfad (campaign-consumer.ts) lädt das volle Dokument bzw. nutzt den vollständigen snapshot.fields, und die Start-Validierung übergibt gar kein targetParty (Resolver lädt selbst). Fix: contactChannels in beide Projektionen aufgenommen, mit Kommentar, warum das Feld dort nicht entfernt werden darf. Kontakte über party_relationships (der Normalfall) waren nie betroffen — deshalb zeigte dieselbe Vorschau für Firmen mit gepflegtem Ansprechpartner korrekt Treffer an. und übersprang den Checklisten-Reset** — geändert TaskSection.tsx. WHY: Der Serien-Mechanismus (ein Master-Dokument wandert vorwärts, Checklisten-Roll-over hängt am advance-series-Pfad, c773b9026) war in TasksPage und TaskSidePanel verdrahtet — die eingebettete Aufgaben-Liste auf Ticket-/Projekt-/Kunden-Detailseiten (TaskSection.handleComplete) schickte aber weiterhin einen blanken POST /transitions/DONE. Wer eine wiederkehrende Aufgabe von dort abhakte, beendete die komplette Serie statt der aktuellen Wiederholung, und der Checklisten-Roll-over (Feedback 019fa770, 28.07.2026 „Checkliste in wiederkehrenden Aufgaben“) lief nie. Jetzt nutzt TaskSection denselben isRecurringMaster()-Abzweig + completeRecurringOccurrence() wie die beiden anderen Aufrufer.
fixed Kanban-Boards: Repair-Skript für unsichtbare Board-Kopien aus der Zeit vor dem ownerId-Fix
neu repair-orphan-duplicated-boards.ts. WHY (Nachlauf zu Feedback 019fa741, 28.07.2026 „Board kopieren tut nichts“): Bis f752692e2 (29.07.2026) legte die Duplicate-Route Kopien ohne ownerId an (protected field wurde gestrippt) — mit visibility: 'private' + leerem sharedWith matchten sie keinen Zweig von GET /accessible und waren für niemanden sichtbar, auch nicht für den Ersteller. Diese Waisen liegen weiterhin in ticket_boards/project_boards. Das Skript (Dry-Run per --dry-run) setzt ownerId aus createdBy zurück (die Route hat den Verursacher immer korrekt in den MutationContext gegeben); Waisen ohne " (Kopie)"-Suffix oder ohne createdBy werden nur gemeldet, nie beschrieben. Prod-Lauf erst nach Freigabe durch Moritz. — neu secret.hooks.ts + Tests, asset.hooks.ts + Tests, ADR 0439, backfill-secret-asset-party.ts; geändert secret.schema.ts, crud.routes.ts, entity-hooks/index.ts + Tests, index-registry.ts, init.js. WHY: Mit Kundenkontext „GFU – Maschinenbau GmbH“ zeigte der Tresor 2 von 35 Geheimnissen. Die übrigen 33 hängen an GFU-Assets; ihr secret.partyId ist null, und der Filter war eine reine Feldgleichheit auf genau dieser Spalte (?partyId= → { partyId: '<uuid>' }, kein $or, kein Join). Zwei Umstände tarnten den Fehler: Die Detailansicht zeigte trotzdem „Verknüpfter Kunde: GFU“, weil SecretDetailDialog den Kunden live über asset → asset.partyId auflöst (autoResolvedParty) — die Anzeige suggerierte eine Verknüpfung, die im Datensatz nicht existierte. Und die Volltextsuche funktionierte: SEARCH_JOINS.secrets joint für ?q= längst auf assets, ?q=gfu fand alle 35. Genau diese Asymmetrie zwischen Suche und Filter war der Bug.
fixed Tresor-Kundenfilter zeigte asset-verknüpfte Zugangsdaten nicht (2 von 35 Treffern beim betroffenen Kunden)
neu secret.hooks.ts, asset.hooks.ts, 0447-secret-asset-party-denormalization.md, geändert crud.routes.ts, init.js, index-registry.ts, Backfill backfill-secret-asset-party.ts. WHY: Ein Geheimnis, das an einem Asset des Kunden hängt, trug den Kunden nirgends als eigenes Feld — der Kundenfilter (?partyId=) matchte allein die partyId-Spalte und verbarg damit jede asset-verknüpfte Zugangsdate, obwohl die Volltextsuche (?q=, joint assets) sie fand.
Der naheliegende Fix wäre schädlich gewesen und wurde verworfen. secret.partyId ist kein Link, sondern der Krypto-Scope-Selektor: Es schaltet den Client auf Customer-Key-Verschlüsselung, legt den Blind-Index (ADR 0089) unter den Customer-SSK und öffnet die Zeile in vault-visibility.ts für jeden Halter dieses Customer-Keys. Ein Backfill auf partyId hätte die 33 Bestands-Geheimnisse für alle GFU-Key-Holder listbar, aber nicht entschlüsselbar gemacht (ihr DEK ist personal-key-gewrappt; umschlüsseln kann ein Server-Skript wegen Zero-Knowledge nicht) — Geisterzeilen plus exakt die Metadaten-Preisgabe, die der Visibility-Filter laut eigener Doku verhindern soll.
Stattdessen ein getrenntes, rein fachliches Feld secret.assetPartyId — die denormalisierte asset.partyId des verknüpften Assets, ohne jede kryptografische Bedeutung und bewusst nicht Teil des Visibility-Filters. Wer ein Geheimnis vorher nicht sehen konnte, sieht es auch jetzt nicht. Der Filter matcht nun $or: [{ partyId }, { assetPartyId }] (gemergt nach dem kanonischen Muster des Phone-/partyScope-Blocks): ?partyId= bedeutet „gehört zu diesem Kunden“, nicht „hat diesen Wert in dieser Spalte“.
Abgeleitet wird serverseitig, nie vom Client übernommen. Es gibt fünf unabhängige Schreibpfade — drei postBody-Zweige in CreateSecretDialog, zwei in VaultImportDialog, dazu SecretDetailDialog und die Browser-Extension; eine Ableitung im Client hätte alle fünf einzeln treffen müssen und wäre fälschbar. secret.hooks.ts überschreibt einen mitgeschickten Wert daher immer, und beforeUpdate streift ihn ab, wenn die Asset-Verknüpfung gar nicht angefasst wird.
Kaskade bei Besitzerwechsel: Wandert ein Asset zu einem anderen Kunden, ziehen die daran hängenden Geheimnisse nach (asset.hooks.ts, afterUpdate, über updateEntity inkl. GoBD-Historie, fire-and-forget). Ohne das wäre die Denormalisierung vertraulichkeitsrelevant veraltet — Zugangsdaten des einen Kunden erschienen unter der Auswahl eines anderen. Beide Hooks liegen in der Hooks-Registry statt als neue Inline-Zweige in crud.routes.ts (Guardrail #4); für assets gab es dort noch keinen afterUpdate-Zweig, es kann also nichts doppelt laufen.
Backfill für Bestandsdaten nötig, aber keine harte Abhängigkeit — scripts/migrations/backfill-secret-asset-party.ts (Dry-Run per Default). Der Code läuft ohne ihn korrekt, für Altdaten bleibt der Kundenfilter bis zum Lauf nur unvollständig. Neuer Index {tenantId, assetPartyId}, weil beide Zweige eines $or einen eigenen Index brauchen (Lehre aus ADR 0437).
Nicht behoben, bewusst außerhalb dieser Änderung: Ein asset-verknüpftes, personal-key-verschlüsseltes Geheimnis bleibt für Kollegen unsichtbar, die nur den Customer-Key halten. Das ist kein Filter-, sondern ein Verschlüsselungsproblem und per Backfill grundsätzlich nicht lösbar — es bräuchte ein clientseitiges Re-Wrapping des DEK durch einen Nutzer mit entsperrtem Tresor.
fixed TANSS-Spiegel Rev 2: Kommentare/Mails erschienen als „Unbekannt“ und die Techniker-Zuweisung per E-Mail griff nie — die linkedEntities-Stubs von TANSS tragen keine emailAddress
neu employee-directory.ts (+ Tests), employee-display-name.ts; geändert tanss-sync.ts, mirror-ticket-history.ts, ticket-comment.transformer.ts, ticket-mail.transformer.ts, message.schema.ts, integrations.ts. WHY (E2E-Befund 06.08.2026 gegen die echte Instanz): meta.linkedEntities.employees der TANSS-Antworten sind Stubs ohne emailAddress (nur initials+name, belegt in docs/tanss-api.yml) — der E-Mail-Rückfall der db-id-map, im reinen Live-Sync die einzige Brücke von TANSS-Mitarbeitern zu Codemeta-Benutzern, lief damit systematisch leer. Zweitbefund: createdBy steht in NEVER_FROM_SOURCE des Upserts, der aufgelöste Autor erreicht das Message-Dokument also strukturell nie. FIX: (1) seedEmployeeDirectory lädt einmal pro Lauf das volle Verzeichnis (GET /api/v1/employees — derselbe Endpunkt, aus dem die Migration ihre Benutzer baute) in den Lauf-Vorrat; alle Stub-Merges sind fill-only (mergeEmployeeStubs), damit E-Mail-lose Stubs den Katalog nicht überschreiben; mirrorRecords reicht den Vorrat in die Transformation und die Meldeliste unmappedTechnicians trägt jetzt die echte Adresse. (2) Die Message-Transformer stempeln authorName (additiv im Schema) direkt aufs Dokument — Feed und Timeline-Anreicherung respektieren es bereits; damit zeigen auch Ex-Mitarbeiter und TANSS-Systemeinträge ('TANSS') einen Absender. (3) Revisions-Gate historyMirrorRev/HISTORY_MIRROR_REV: der Bestands-Backfill läuft einmalig neu an, weil der Reconcile geschlossene Tickets nie wieder besucht — bereits gespiegelte „Unbekannt“-Zeilen heilen als reguläre diff-Updates, Unverändertes bleibt unchanged. Kosten: +1 Request/Lauf. Verifiziert: 158 Worker-TANSS-Tests (20 neue: Verzeichnis-Seed/Degradation, fill-only-Merge, Namens-Drehung „Nachname, Vorname“, authorName-Vorrangkette, Rev-Reset).
fixed CRM-Kontext-Chip „Gefiltert nach: <Firma>“ fand die Ansprechpartner der Firma nicht (leere Liste, leerer CSV-Export)
neu party-group-filter.ts + Tests; geändert crud.routes.ts, parties.export.routes.ts. WHY: Der globale Kunden-Filter der Kontaktliste schickt ?partyGroupId=<orgId>; der Server expandierte das zu { $or: [{ _id }, { parentPartyId }] }. parentPartyId ist nach ADR 0098 aber ausschließlich die ORG→ORG-Konzernhierarchie (Tochterfirmen) — die PERSON ↔ ORG-Verknüpfung lebt allein als Kante in party_relationships. Der Chip traf damit nur die Firma selbst plus Töchter; schaltete man zusätzlich den Typ-Filter auf „Person“, fiel die Firma heraus und das Grid stand auf „0–0 von 0“. Das liest sich als „dieser Kunde hat keine Ansprechpartner“, obwohl die Kontakte existieren und auf der Kundenakte korrekt angezeigt werden — die Detailseite joint über organizationPartyId, die Liste konnte es nicht.
Die Expansion sitzt jetzt in der gemeinsamen Helper-Funktion buildPartyGroupClause(): ein indexgedeckter Lookup auf party_relationships ({ tenantId, organizationPartyId }, Projektion nur personPartyId, gedeckelt auf 2.000 Kanten) liefert die Kontakt-IDs, die als dritter $or-Zweig _id: { $in: … } dazukommen. Ohne Kontakte entsteht bewusst kein leerer $in-Zweig. Die Klausel ist rein verengend und wird wie zuvor per $and an den bereits scope-geprüften Filter gehängt — sie kann Zeilenrechte nicht ausweiten.
Zweiter, eigenständiger Bug im selben Zug: Der serverseitige CSV-Export (POST /api/v1/parties/export) führte partyGroupId in seiner Feld-Allowlist und baute daraus ein flaches { partyGroupId: '<id>' } — ein Feld, das auf keinem party-Dokument existiert. Mit aktivem Chip lieferte der Export deshalb still eine leere CSV. Der Schlüssel ist aus der Allowlist entfernt (mit Begründung an Ort und Stelle, damit er nicht zurückwandert) und läuft jetzt durch dieselbe Helper-Funktion wie die Liste, sodass Export und Ansicht deckungsgleich bleiben. Kein Datenumbau, kein Backfill — reine Query-Expansion zur Laufzeit.
fixed Passwort-Tresor: Liste zeigte „— verschlüsselt —“ statt des Namens, obwohl das Geheimnis entschlüsselbar war
geändert VaultPage.tsx, SecretDetailDialog.tsx. WHY: Die Titel-Entschlüsselung (ADR 0084) hing an einem eigenen Request GET /api/v1/secrets?limit=200 — ungefiltert und durch den CRUD-Limit-Cap hart auf 200 Zeilen begrenzt. Angezeigt wird aber das Ergebnis des EntityDataGrid, das über initialFilters auf Ordner und ausgewählten Kunden gefiltert und paginiert ist. Beide Mengen sind ab 200 Geheimnissen im Tresor schlicht disjunkt: mapSecretRows ist ein reiner Cache-Lookup und stößt selbst keine Entschlüsselung an, also blieben sichtbare Zeilen dauerhaft auf dem Platzhalter stehen — obwohl ihr DEK einwandfrei auflösbar war (der Detail-Dialog entschlüsselte dieselben Datensätze problemlos). Aufgefallen nach dem Import der TANSS-„wichtigen Firmeninformationen“, der den Tresor erstmals über die 200er-Schwelle hob; der Import selbst war korrekt.
Die Entschlüsselung hängt jetzt an onRowsChange — also exakt an den Zeilen, die das Grid geladen hat (roh, vor mapRows). Damit ziehen Filter-, Seiten- und Sortierwechsel die Entschlüsselung automatisch nach, und der zweite Request entfällt ersatzlos. Der pwned-Status-Fetch (ADR 0189) hing an derselben 200er-Liste und ist mitgewandert: Die Badges gelten jetzt für die sichtbare Seite statt für einen willkürlichen anderen Ausschnitt.
decryptVisible wird über ein Ref aufgerufen und ist bewusst keine Dependency des Effekts: Es schließt über decryptedTitles, wechselt also nach jedem entschlüsselten Batch die Identität und würde den Effekt sonst pro Batch erneut auslösen.
Zweiter, eigenständiger Bug in derselben Kette: Der Kopf des Detail-Panels rendert secret.title — das serverseitige Klartextfeld, das bei jedem v2-Geheimnis leer ist. Der entschlüsselte Titel lag im State, wurde aber nur im Edit-Modus verwendet, weshalb auch das Panel namenlos blieb. Jetzt title || secret.title (Legacy-Fallback bleibt).
fixed 🔴 Eine abgelehnte Genehmigung setzte den Status ohne Versionssprung — die optimistische Sperre lief dabei ins Leere
geändert approval.service.ts (rejectRequest), 1 neuer + 1 geschärfter Test in approval.service.test.ts. WHY: Ist auf der Kante ein rejectionTargetStatus konfiguriert, schiebt die Ablehnung die Entität dorthin. Das muss an updateEntity vorbei laufen, weil es in der Transaktion der Genehmigung passiert — nur wurde dabei auch der version-Zähler übergangen: { $set: { status, updatedAt } }, kein $inc. Damit hatte die optimistische Sperre nichts mehr zu greifen. Ein Client, der die Entität vor der Ablehnung geladen hat, hielt anschließend eine Version, die der Server weiterhin für aktuell hielt; sein nächster PATCH ging ohne 409 durch und konnte status auf den Vor-Ablehnungs-Wert zurückschreiben — die Ablehnung war damit still rückgängig gemacht. Das ist exakt der verlorene Schreibvorgang, gegen den version existiert, und er ist im Audit-Trail nicht erkennbar, weil beide Schreibvorgänge für sich legitim aussehen. Beim manuellen E2E-Test unmittelbar belegt: die Ablehnung schrieb vBefore=1 → vAfter=1, und die direkt folgende Bearbeitung im Browser lief immer noch auf vBefore=1, obwohl der Status serverseitig längst gewechselt war. FIX: $inc: { version: 1 } beim Statuswechsel, und der Audit-Eintrag führt die erhöhte Version, damit entityVersionAfter stimmt. Bewusst $inc statt before.version + 1 im $set: der Server rechnet, ein veralteter Lesewert kann das Ergebnis also nicht verschieben; der Audit-Wert stammt aus derselben Transaktion und ist konsistent, weil eine Nebenläufigkeit sie abbrechen würde. Verifiziert: 39/39 Tests der Datei grün — der bestehende Test prüfte den Aufruf mit exaktem Update-Dokument und war gegen den Fix rot, ist jetzt auf $inc erweitert; ein zweiter Test hält zusätzlich fest, dass version nicht zusätzlich per $set geschrieben wird (das würde gegen das $inc arbeiten). eslint und api-Typecheck grün. Kein Backfill — reine Vorwärtslogik. Betrifft alle Ablehnungen mit Zielstatus, nicht nur den neuen Erstellungs-Gate: Ticket-Close (ADR 0209), Shop-/Auftragsfreigaben und jede andere Kante mit konfiguriertem Ablehnungsziel.
fixed Ein Ticket, das beim Kunden startet, bekam den Status „liegt beim Kunden“ nicht
geändert entity-defaults.service.ts, 3 neue Tests in entity-defaults.service.test.ts. WHY: Der Anlegepfad löst handlingTier serverseitig auf (direction: CUSTOMER_FIRST ⇒ CUSTOMER), setzte aber keinen Status. Ein so entstandenes Ticket trug damit den Chip „Beim Kunden“ bei plainem Anfangsstatus — dieselbe Divergenz wie beim Handover (Eintrag unten), nur an der zweiten Stelle. Es wäre also genau das eine Ticket gewesen, das die Projektion überspringt, obwohl es die Kundenhaltezeit von der ersten Sekunde an hat. FIX: Löst handlingTier auf CUSTOMER auf, wird zusätzlich der als handlingRole: 'at_customer' markierte Status gesetzt. Kein markierter Status ⇒ leere Liste ⇒ Status unangetastet. Kein Konflikt mit dem Erstellungs-Gate, das später im selben POST-Handler läuft: es liefert für genau dieses Ticket die Entscheidung at_customer (bewusst eine Entscheidung, kein Übersprung) und fasst den Status nie an — die beiden können sich also nicht um das Feld streiten. Verifiziert: 33/33 Tests der Datei grün (3 neu: Status wird bei Kundenstart gesetzt, ohne markierten Status unangetastet, bei MSP-Ticket nie berührt); eslint und api-Typecheck grün. Kein Backfill.
fixed 🔴 Der Status „liegt beim Kunden“ war unerreichbar — die Übergabe setzte ihn nicht, und von Hand ließ ihn der Guard nicht zu
geändert ticket-handover-core.ts (neu atCustomerStatus + Port resolveAtCustomerStatus), ticket-handover.service.ts (Auflösung verdrahtet), 2 neue Tests in ticket-handover-core.test.ts. WHY: buildHandoverPatch schrieb einen Status ausschließlich auf dem Rückweg (openStatus im else-Zweig). Der Hinweg zum Kunden setzte handlingTier, assignedToId: null und handedToCustomerAt — aber keinen Status. Der als handlingRole: 'at_customer' markierte Status wurde damit von nichts vergeben. Zusammen mit dem Transition-Guard aus ADR 0298 §5-Ergänzung, der den manuellen Wechsel hinein mit 422 ablehnt („bitte die Übergabe nutzen“), war der Status strukturell unerreichbar: der Administrator markiert ihn, der Guard verbietet den Weg von Hand, und der vorgesehene Weg setzt ihn nie. Sichtbar wurde das als Divergenz, die genau diese Projektion verhindern soll — das Ticket trug den Chip „Beim Kunden“, während der Status weiter „In Bearbeitung“ sagte. Damit blieb die Kundenhaltezeit unsichtbar in nach Status gruppierten Boards, in Status-Zeitlimits, in Saved Views und in Auswertungen; also in allem, wofür ADR 0298 §5 überhaupt einen Status statt nur eines Flags verlangt. FIX: atCustomerStatus als Gegenstück zu openStatus, aufgelöst über den neuen Port resolveAtCustomerStatus aus getAtCustomerStatusKeys. Zero-Migration bleibt gewahrt: hat kein Status die Rolle, liefert der Resolver eine leere Liste ⇒ null ⇒ der Status bleibt unangetastet, exakt wie bisher. Der Port hängt zudem an derselben keepStatus-Bedingung wie resolveOpenStatus, damit die implizite Rückgabe weiterhin die vom Bearbeiter gewählte Zielstatus-Entscheidung behält. Gefunden beim manuellen E2E-Test: Übergabe geklickt, Chip und Bearbeiter-Entzug korrekt, Status unverändert. Verifiziert: 12/12 Tests der Datei grün, davon 2 neu (Status wird geschrieben wenn markiert; null und undefined lassen ihn unangetastet); der bestehende Test „schreibt nie einen Status Richtung Kunde“ ist zu „nutzt nie den Rückweg-Status Richtung Kunde“ geschärft, weil seine ursprüngliche Aussage genau den Fehler festschrieb. eslint und api-Typecheck grün. Kein Backfill — Bestandstickets in der Kundenhaltezeit behalten ihren Status; erst die nächste Übergabe schreibt ihn.
fixed Jedes per E-Mail-Link freigegebene Ticket warf beim Öffnen eine „HTTP 404“-Fehlermeldung — bei jedem Laden erneut
geändert TicketDetailPage.tsx (neu nonUserActorLabel, angepasste Zeitleisten-Anreicherung). WHY: Die Zeitleiste löst zu jedem Historien-Eintrag den Akteur über GET /api/v1/users/:id auf, damit dort Namen statt UUIDs stehen. Der öffentliche Freigabe-Token-Pfad schreibt als Entscheider aber das Sentinel 'email-token' — der Genehmiger hat per Definition keinen Nutzer (ADR 0295 D8). Der Lookup lief also gegen /users/email-token, bekam 404, und weil dieser eine Aufruf — anders als sein Nachbar wenige Zeilen darüber — ohne silent abgesetzt wurde, zeigte der API-Client seinen automatischen Fehler-Toast. Ergebnis: eine nackte, inhaltsleere „HTTP 404“-Meldung bei jedem Öffnen und jedem Neuladen eines Tickets, das je per Mail-Link freigegeben wurde — dauerhaft, weil die Zeitleiste bei jedem Laden neu geholt wird. Die Anzeige selbst war nie kaputt (der catch fiel auf die rohe ID zurück), es war reiner Lärm — aber Lärm, der wie ein echter Fehler aussieht und Vertrauen kostet. FIX: Sentinel-Akteure werden vorab erkannt und gar nicht erst nachgeschlagen: 'email-token' → „Freigabe per E-Mail-Link“, 'SYSTEM' und system:* → „System“. Das spart zusätzlich einen sinnlosen Roundtrip pro Sentinel. Der verbleibende echte Nutzer-Lookup läuft jetzt silent — ein nicht sichtbarer oder gelöschter Nutzer ist ein Anzeigedetail, kein Grund den Leser zu unterbrechen; der Fallback auf die rohe ID bestand ohnehin schon. Gefunden beim manuellen E2E-Test direkt nach der ersten erfolgreichen Token-Freigabe. Kein Backfill.
fixed 🔴 Die Token-Freigabeseite für externe Genehmiger hat nie funktioniert — endloser Spinner, ohne dass je eine Anfrage abgeschickt wurde
geändert App.tsx, ApprovalTokenPage.tsx. WHY: ApprovalTokenPage wurde aus einem reinen Pfad-Vergleich heraus gerendert (if (location.pathname.startsWith('/approvals/token/')) return <ApprovalTokenPage />), also außerhalb jedes <Route> — einen <Route path="/approvals/token/:token"> gab es nicht. Die Seite liest den Token aber über useParams(), und ohne Route-Match liefert das ein leeres Objekt. Folge: token === undefined, der Loader brach bei if (!token) return; ab, loading blieb auf true, und der Genehmiger sah dauerhaft eine ladende Karte — ohne dass im Netzwerk-Tab überhaupt eine Anfrage auftauchte. Die benachbarten öffentlichen Seiten (/invite/…, /s/…, /auth/reset-password) kommen ohne Route aus, weil keine von ihnen einen Pfadparameter liest; nur diese eine tat es. Damit war der komplette Freigabeweg für Genehmiger ohne codemetaOS-Konto (ADR 0295 D8) tot — also genau der Hauptanwendungsfall der Kundenfreigabe. Warum das so lange unbemerkt blieb: der Gate-Pfad verschickte wegen des emailApproval-Defaults (siehe Eintrag unten) gar keine Mail mit so einem Link — zwei Fehler, die sich gegenseitig verdeckt haben. Erst beide zusammen zu beheben macht den Weg begehbar. FIX: Der Pfad-Zweig rendert jetzt ein echtes <Routes><Route path="/approvals/token/:token" …/></Routes>, damit useParams() greift. Zusätzlich behandelt die Seite einen fehlenden Token als Fehlerzustand statt als Ladezustand: vorher war „kein Token“ ein stiller return bei loading: true, also strukturell ein Endlos-Spinner — jetzt erscheint dieselbe Meldung wie bei einem ungültigen Token. Das ist die eigentliche Lehre aus dem Fund: ein Early-Return im Loader, der den Ladezustand nicht auflöst, ist immer ein Hänger. Gefunden beim manuellen E2E-Test, nachdem der Netzwerk-Tab zeigte, dass keine Anfrage an /api/v1/public/approvals/… gestellt wurde, während dieselbe URL per curl sauber 200 lieferte. Kein Backfill.
fixed 🔴 Ein Kunden-Genehmiger ohne Portalkonto erfuhr nie von der Freigabeanfrage — die Freigabe-Mail wurde gar nicht verschickt
geändert ticket-create-gate.service.ts, 1 geänderter + 1 neuer Test in ticket-create-gate.service.test.ts. WHY: Der Erstellungs-Gate erbt seine Guard-Parameter von der Freigabe-Kante (<Wartestatus> → <Startstatus>) — dort leben rejectionTargetStatus, expiresInHours und emailApproval. Hat der Administrator auf diese Kante keinen Guard gehängt (der Normalfall: der Gate funktioniert auch mit einem nackten Statuspaar), war emailApproval schlicht nicht gesetzt und damit false. Folge: createApprovalRequest erzeugte keinen Token und verschickte keine Mail. Für einen internen Genehmiger fällt das nicht auf — er sieht die Anfrage in seinem In-App-Postfach. Für den Fall, für den dieser Gate überhaupt existiert, ist es fatal: der Genehmiger ist eine Kontakt-Party des Kunden und darf laut ADR 0295 D8 ausdrücklich kein Portalkonto haben. Ohne Konto gibt es kein In-App-Postfach, ohne Mail keinen Kanal — der Kunde erfuhr überhaupt nichts, und das Ticket blieb im Wartestatus liegen, bis der Expiry-Sweep es einsammelte. Still, weil an jeder anderen Stelle alles richtig aussah: Policy da, Anfrage angelegt, Ticket geparkt, Banner sichtbar. FIX: emailApproval ist im Create-Gate jetzt per Default true; die Kante darf es weiterhin überschreiben (params: { partyField: 'partyId', emailApproval: true, ...edgeGuard?.params }). Der ältere Soft-Gate-Pfad in ticket.hooks.ts setzt seit immer emailApproval: true — die beiden Pfade dürfen sich darin nicht widersprechen. Zur Einordnung des Mechanismus: die Freigabe läuft per Token-Link, nicht per Antwortmail — zwei Buttons in der Mail auf {APP_URL}/approvals/token/<token>?action=approve|reject, dahinter eine öffentliche Seite ohne Login mit eigenem, strengerem Rate-Limit (approval-public.routes.ts), Token-Gültigkeit 72 h. Freigabe per E-Mail-Antwort ist bewusst nicht implementiert: der From-Header ist fälschbar und Freitext-Zustimmung nicht belastbar. Verifiziert: 17/17 Tests der Datei grün, davon einer neu, der belegt dass eine bewusst auf false gesetzte Kante weiterhin gewinnt; eslint und api-Typecheck grün. Kein Backfill. ⚠️ Verhaltensänderung: Tenants mit einer aktiven Erstellungs-Freigaberegel verschicken ab dem Deploy tatsächlich Freigabe-Mails. Das ist die Absicht — vorher war die Funktion für externe Genehmiger wirkungslos —, aber es ist ausgehender Mailverkehr, der vorher nicht stattfand.
fixed 🔴 Der Freigabe-vor-Bearbeitung-Gate ließ sich durch einen gewöhnlichen Statusklick vollständig umgehen
geändert transition.routes.ts (neu isBlockedByPendingApprovalGate + approvalGatePendingProblem, verdrahtet in beiden Übergangs-Handlern), 6 neue Tests in transition.routes.test.ts. WHY: Der Erstellungs-Gate parkt das Ticket in einem Status mit approvalRole: 'pending_approval' und unterdrückt die Disposition — aber die Freigabe-Kante <Wartestatus> → <Startstatus> trägt keinen eigenen Guard. Damit konnte jeder Nutzer mit ticket_transition im Statusblock schlicht auf den Startstatus klicken und mit der Arbeit beginnen; die vom Kunden konfigurierte Freigabepflicht war eine Empfehlung, keine Regel. Am 30.07.2026 von Hand reproduziert: das Ticket verließ den Wartestatus per Statusklick, ohne dass eine Genehmigung entschieden wurde, und ohne jede Spur außer der gewöhnlichen Übergangszeile. Die Genehmigungsanfrage blieb pending zurück — ein Ticket in Bearbeitung mit offener Kundenfreigabe. FIX: Ein Lockstep analog zu ADR 0298 §5: ein Übergang aus einem Freigabe-Wartestatus heraus wird mit 422 TICKET_APPROVAL_PENDING abgelehnt, solange eine pending-Genehmigung zum Ticket existiert. Drei bewusste Grenzen: (1) Die Sperre hängt an der offenen Anfrage, nicht am Status allein — eine Ablehnung ohne rejectionTargetStatus lässt das Ticket im Wartestatus sitzen, und genau dieses Ticket muss von Hand wieder beweglich sein, sonst strandet eine Fehlkonfiguration es dauerhaft. (2) Der Erzwingen-Pfad (force: true + status_force/admin_bypass + Pflichtbegründung) bleibt unberührt — er kehrt lange vor der Prüfung zurück und ist der vorgesehene, auditierte Override. (3) executeApprovedTransition läuft nicht über diese Route, die Freigabe selbst ist also unbeeinflusst. Beide Handler sind verdrahtet: der Legacy-/transition-Endpoint schreibt dasselbe Statusfeld, und nur einen zu fixen wäre exakt die Fehlerklasse, die der Spiegel-Test abdeckt. Nebenbefund, bewusst NICHT gefixt: injectPendingApprovals filtert auf entityType: entity.schema, also den Singular ticket, während createApprovalRequest den Plural tickets schreibt — der Helfer findet für Tickets also nie etwas. Er speist die Guard-Auswertung; das zu ändern verschiebt, was bestehende Guards sehen, und gehört in eine eigene Änderung mit eigenem Blast Radius. Der neue Lockstep fragt deshalb selbst und explizit mit 'tickets' ab. Verifiziert: 27/27 Tests der Datei grün (6 neu: Freigabe-Kante blockiert, jedes andere Ziel blockiert, nach entschiedener Anfrage wieder frei, ohne markierten Status wird die Genehmigungstabelle gar nicht abgefragt, Legacy-Handler gespiegelt, andere Entitäten unberührt); errorCode ist neu, die Blocking-Tests können also nicht ohne den Guard bestehen; eslint und pnpm --filter @codemeta/api typecheck grün. Kein Backfill — ohne markierten Wartestatus ist die Prüfung inert und stellt keine Query.
fixed 🔴 Genehmigungsregeln mit Genehmiger-Typ „Rolle“ fanden seit dem 02.03.2026 auf keinem Tenant einen Genehmiger
geändert approval.service.ts (resolveApproverIds), neuer Test in approval.service.test.ts. WHY: Der $lookup in der Rollen-Auflösung jointe die Collection roles — die in diesem Produkt nicht existiert. Rollen liegen in role_definitions; alle übrigen ~25 Lesestellen in apps/api, apps/worker und scripts/ (u. a. approver-resolver.service.ts, assign.ts, incident-notify.ts) lesen korrekt role_definitions. Diese eine Stelle war die Ausnahme. Der Join lief also immer leer, approverType: 'role' gab immer [] zurück, und jede rollenbasierte Genehmigungsregel verhielt sich wie „kein Genehmiger erreichbar“: auf dem Übergangspfad ein 422 NO_REACHABLE_APPROVER (ADR 0295 D3), auf dem neuen Erstellungs-Gate der Fail-open-Pfad — das Ticket entstand ungegatet. Betroffen ist alles, was resolveApproverIds mit approverType: 'role' aufruft, also auch Shop-/Auftragsfreigaben und checklist-approval.service.ts, das 'role' als Default setzt. Für Anwender sah das aus wie „die Freigabe ist falsch konfiguriert“, nicht wie ein Fehler — deshalb ist der Bug seit knapp fünf Monaten unentdeckt. Gefunden beim manuellen E2E-Test des Erstellungs-Gates: die Telemetrie schrieb korrekt gateDecision: 'fail_open', skipReason: 'no_reachable_approver', severity: 'error' — der Wert dieser Zeile hat sich damit unmittelbar bewiesen. FIX: from: 'roles' → from: 'role_definitions', plus ein Kommentar, der die Ausnahme benennt, damit sie nicht zurückkehrt. WARUM DER BESTEHENDE TEST GRÜN WAR: Er stubbt aggregate() und liefert vorgefertigte Zeilen — die Pipeline wird nie ausgeführt, der falsche from ist für ihn unsichtbar. Der neue Test greift deshalb die tatsächlich gebaute Pipeline ab und prüft $lookup.from direkt; er ist gegen den alten Stand rot. Verifiziert: 38/38 Tests der Datei grün; die korrigierte Aggregation gegen die Dev-DB gegengerechnet (löst den Rollenträger auf, der vorher leer blieb). Kein Backfill — reine Leseänderung. Ops-Hinweis: Genehmigungsregeln, die bisher wegen dieses Fehlers 422 lieferten, funktionieren nach dem Deploy schlagartig. Das ist die Absicht, ändert aber sichtbar das Verhalten produktiver Regeln; Tenants mit rollenbasierten Freigaben sollten vorab wissen, dass die Freigabe ab jetzt wirklich angefordert wird.
v0.3.11 · 2026-08-06
Hinzugefügt
added Projekt-Detailseite: „Projekt löschen“ in der rechten Leiste unter der Team-Karte
geändert ProjectDetailPage.tsx, de.json/en.json. WHY: Ein Projekt ließ sich aus der Detailansicht heraus gar nicht löschen — der einzige Weg war die Listenansicht. Der Button übernimmt exakt die Behandlung des Ticket-Pendants aus TicketDetailPage.tsx: unauffälliger Text-Button am Fuß der Sidebar, TEXT_MUTED mit DANGER-Hover, davor ein ConfirmDialog mit Projekttitel. Gate ist isAdmin || canDelete('project') (Löschen ist wie überall ein Soft-Delete über DELETE /api/v1/projects/:id, Historie und Leistungen bleiben erhalten); nach Erfolg springt die Seite über onBack() zurück in die Liste. Keine API-Änderung, kein Backfill.
added KI-Zusammenfassung für TANSS-importierte Tickets — lazy on-open für den Bestand, eager beim Live-Sync für Neuzugänge
neu ai-summary-consumer.ts, ai-summary-queue.ts, message.hooks.ts; geändert queues.ts (QUEUE_NAMES.AI_SUMMARY + AI_SUMMARY_JOBS), llm-summarize.ts (summarizeTicketContent), tanss-sync.ts, ticket.hooks.ts, ticket.schema.ts/message.schema.ts (aiSummary/aiSummaryAttemptedAt), entity-config.ts, TicketDetailPage.tsx, de.json/en.json. WHY: Die Feed-„KI-Zusammenfassung“ hängt an message.aiSummary und wird nur vom Live-E-Mail-Ingest gesetzt — TANSS-Tickets durchlaufen den nie: migrierte Mails liegen ohne Summary in messages (ADR 0282b unterdrückt Migrations-Side-Effects bewusst), live-gesyncte Tickets haben gar keine Messages (Quell-Mail roh in der Description). Statt Backfill (freigabepflichtig, LLM-Kosten für nie geöffnete Tickets): (1) messages-enrichList- und tickets-enrichOne-Hooks enqueuen beim Öffnen fire-and-forget einmalige Jobs auf der neuen queue-ai-summary (Limiter 10/s — drosselt auch ADR-0440-Sync-Bursts); (2) der TANSS-Sync enqueued eager beim CREATE; (3) der Worker patcht history-frei (Präzedenz dispatch.aiSummary/attach-ai-summary), stempelt IMMER aiSummaryAttemptedAt (kein Re-Enqueue bei KI-aus) und invalidiert die offene Seite explizit via notifyEntityStreamChange('tickets', …). Neues Top-Level-Feld ticket.aiSummary in internalOnlyFields (Reconcile löscht es sonst) + protectedFields; UI-Block über der Description scoped auf isExternalMasterSource (bleibt nach Übernahme ADR 0432 sichtbar). Kein Backfill, alles Laufzeitverhalten mit Fallback (heuristischer Footer-Trim bleibt).
added TANSS-Sync: Kommentare und E-Mails werden jetzt laufend nach messages gespiegelt — inkl. einmaligem Bestands-Backfill über alle gespiegelten Tickets und zyklischem Reconcile
neu mirror-ticket-history.ts (+ Tests), 0443-tanss-history-message-mirror.md; geändert tanss-sync.ts (+ Tests), upsert-mirror.ts (+ Tests), integrations.ts, entity-config.ts, message.schema.ts, migration.ts, index-registry.ts, init.js, IntegrationSettingsPage.tsx, tanss-unidirectional-sync.md, de.json/en.json. WHY: Meldung 06.08.2026: Kommentare (intern/extern), E-Mails und Anhänge kamen beim TANSS-Abgleich nie an. Die Message-Transformer hingen ausschließlich am einmaligen Migrationsimport; die Schalter syncComments/syncEmails waren am 2026-07-30 als unverdrahtet entfernt worden. Kern des Fixes: ein vierter Quellschritt im Sync-Lauf, der pro Ticket GET /tickets/history/{id} zieht (die EINZIGE Quelle für Kommentare/Mails in TANSS — kein Listen-/Delta-Endpunkt) und die bestehenden Migrations-Transformer über den idempotenten upsertTanssMirror nach messages schreibt. Weil unbelegt ist, ob Kommentar-/Mail-Aktivität modifiedWithinTimeframe auslöst (ADR 0440 belegte nur: Anlage und Statuswechsel tun es NICHT), speist sich die Ticket-Auswahl aus drei Quellen mit EINEM harten Budget (historyFetchBudget, Default 150 GETs/Lauf): (1) die in diesem Lauf berührten Tickets — Delta-Scheiben sammeln bewusst auch unchanged (zählt ein Kommentar doch als Modifikation, ist genau dann das Ticket-Dokument unverändert); (2) ein einmaliger, cursor-gesteuerter Bestands-Backfill über alle ~2.700 gespiegelten Tickets inkl. geschlossener (Entscheidung 06.08.2026 — Cutover-Ziel ist, TANSS abschalten zu können; ~18 Läufe ≈ 4,5 h, gedrosselt); (3) ein zyklischer Reconcile über offene Spiegel (Default 30/Lauf) als Sicherheitsnetz, mit Messsonde reconcileOnlyCreated im integration_sync_log (bleibt sie ≈ 0, kann die Kadenz sinken). Jeder Cursor-Batch zieht zusätzlich mit EINEM PUT /tickets {ids} die Ticket-Dokumente durch den Spiegel-Kern — damit bekommen Bestands-Tickets auch die Techniker-/Team-Zuweisung (Fixed-Eintrag unten) und Statuswechsel nachgezogen, die in TANSS nicht als „Modifikation“ gelten. Flankierend wurde messages spiegelfähig gemacht: externalMaster (gespiegelte Messages read-only per 423, internalOnlyFields: ['reactions','aiSummary']; Reactions laufen über ihren atomaren Toggle-Pfad weiter), source/sourceRef additiv im Message-Schema, partieller idx_messages_source_ref (bewusst non-unique — Alt-Zeilen der Migration tragen nackte Ids mit möglicher Kommentar/Mail-Id-Kollision; der neue legacyMatch-Parameter des Upserts findet sie über das immer präfixierte extMessageId und hebt sie migrate-on-touch auf die präfixierte Form samt source: 'TANSS' an, statt Duplikate anzulegen). preserveSourceCreatedAt korrigiert nach dem CREATE gezielt das createdAt (der Helper setzt sonst „jetzt“ — der Backfill hätte alle Kommentare eines Tickets auf den Sync-Zeitpunkt kollabiert und die Timeline-Reihenfolge zerstört). UI: die beiden Schalter sind zurück (default AN, ausgegraut ohne Ticket-Sync). Bekannte Grenzen (in ADR dokumentiert): in TANSS gelöschte Kommentare bleiben stehen (additiver Spiegel); cid:-Inline-Bilder gespiegelter Mails bleiben broken, bis der Anhang-Baustein (ADR 0443 §4) folgt. Kein Daten-Backfill im Sinne der AGENTS-Regeln — der Catch-up ist reiner Neu-Import über die idempotente Pipeline.
added TANSS-Sync: Ticket-Anhänge und Screenshots werden als Dateien am Ticket gespiegelt — inkl. isInternal-Sichtbarkeitsgrenze in den files-Routen
neu mirror-attachments.ts (+ Tests); geändert client.ts (getBinary, + Tests), mirror-ticket-history.ts, integrations.ts, migration.ts, index-registry.ts/init.js (idx_files_source_ref), files.routes.ts (+ Tests), files.v1.routes.ts (+ Tests), attachment-query.ts (Kommentar), IntegrationSettingsPage.tsx, de.json/en.json. WHY: Dritter Teil der Meldung 06.08.2026 (Kommentare/Mails/Anhänge fehlten im TANSS-Abgleich). TANSS-Kommentare und -Mails referenzieren KEINE Anhänge — Anhänge hängen dort am Ticket, in zwei Formen mit unterschiedlichem Transport: Dokumente liefern nur Metadaten (GET /tickets/{id}/documents, inkl. internal-Flag); die Bytes gibt es ausschließlich über ein One-Shot-Download-Ticket ({key, url}, einmal gültig, 15 min, der util/files-Pfad ist undokumentiert) — deshalb wird ein Key immer sofort heruntergeladen und bei Fehlschlag NIE wiederverwendet (kein Insert ⇒ der Dedup-Miss des nächsten Laufs fordert einen frischen Key an). Screenshots kommen als vollständiges Base64 INLINE in der Listen-Antwort — Transfer unvermeidbar, deshalb nur im gezielten Per-Ticket-Kontext, mit Warnschwelle (20 MB) und Dedup VOR dem Decode. Der Spiegel reitet auf den Ticket-Batches des History-Schritts (touched/Backfill/Reconcile — der Bestands-Backfill zieht damit auch alle Alt-Anhänge nach), mit eigenem Download-Budget (attachmentDownloadBudget, Default 100/Lauf) und Größen-Deckel (attachmentMaxBytes, Default 50 MB; TanssClient.getBinary bricht per Content-Length schon VOR dem Body-Lesen ab). Ergebnis sind echte files-Zeilen am Ticket (messageId: null — ohne files-Zeile rendert die Anhangliste nichts; verified: true gegen den Unverified-Cleanup; gefährliche MIME-Typen auf octet-stream geklemmt, N-07-Gedanke; Dedup über sourceRef ticketDocument:<id>/ticketScreenshot:<id> + neuer partieller Index). Sichtbarkeit: interne TANSS-Anhänge tragen isInternal und hängen an derselben Grenze wie interne Nachrichten — der Permission message_view_internal: GET /files filtert sie ohne das Recht schon in der Query (auch Metadaten können intern sein), die Einzel-/Download-Routen antworten 404 (kein Existenz-Leak), und authorizeFileDownload prüft das Internal-Gate bewusst VOR dem Uploader-Shortcut; loadVisibleTicketAttachments (Ticket-Exchange → Kunde) filterte isInternal bereits vorsorglich — das Feld ist jetzt erstmals in Benutzung. UI: dritter Schalter „Anhänge synchronisieren“ (default an, ausgegraut ohne Ticket-Sync). Bekannte Grenze (ADR): cid:-Inline-Bilder gespiegelter Mails bleiben broken — die Mail-Anhang-Zuordnung existiert in TANSS schlicht nicht; Anhänge erscheinen auf Ticket-Ebene. Kein Backfill im AGENTS-Sinne; S3-Zuwachs einmalig beim Bestands-Durchlauf.
added Riverbird RMM: Konnektor verdrahtet — Polling, Geräte-/Kunden-Sync, Settings-Panel mit Kunden-Mapping
neu riverbird/sweeps.ts (+ Tests), riverbird.routes.ts, connect.ts, RiverbirdIntegrationPanel.tsx; geändert queues.ts (RIVERBIRD_JOBS), sync-consumer.ts, riverbird-sync.ts, permissions.ts (riverbird_manage), app.ts, IntegrationSettingsPage.tsx, de.json/en.json. WHY: Der komplette Riverbird-Provider (Auth mit Session-Ticket, WCF-Client, Polling-Engine mit Transition-Erkennung + First-Sweep-Seeding, Alert→Ticket-Inbound) lag seit ADR 0326 fertig in packages/shared/src/services/rmm/providers/riverbird/ — war aber nirgends verdrahtet: kein Worker-Job rief ihn auf, keine Route existierte, kein Panel. Genau der Fehlermodus aus dem „Fertig heißt: der Nutzerpfad läuft“-Leitsatz (ADR 0363). Jetzt: (1) Drei BullMQ-Repeatables (upsertJobScheduler, Guardrail #10 — kein setInterval) auf der SYNC-Queue — 30-s-Poll-Fan-out (pro Connection gegated über pollIntervalSeconds 30–300 s + lastReconcileAt-Watermark, reauth_required-Connections werden schon im Sweep übersprungen statt alle 30 s einen Failed-Job zu erzeugen), stündlicher Geräte-, täglicher Kunden-Sync; JobIds mit - (BullMQ lehnt : ab). (2) /api/v1/riverbird/*: status, connect (validiert per echtem /REST/Login, AES-256-GCM-verschlüsselte Ablage von Username+Passwort+Application-ID — die von Moritz am 2026-07-12 freigegebene Credential-Ausnahme aus ADR 0326 §5 —, SSRF-Guard auf der tenant-eigenen Instanz-URL), disconnect, settings-PATCH (merge-sicheres dot-notation $set via patchRiverbirdSettings), Kunden-Mapping-Endpunkte und manuelle Sync-Trigger (gleiche stabile JobIds wie der Sweep → manueller Klick und fälliger Sweep kollabieren zu einem Job). (3) Settings-Panel analog Octoja/NinjaOne mit Verbindungsstatus, Severity→Prioritäts-Policy, Poll-Intervall und der Kunden-Mapping-Tabelle: unzugeordnete Riverbird-Kunden zuerst, Kundennummern-Vorschläge per konfigurierbarem Feld (customerNumberField, IT Fabrik nutzt Fax) — nie Auto-Merge („no ORG fuzzy dedup“-Invariante), Verknüpfen immer per Klick. Meldungen laufen ab Verbindung in rmm_alerts + die bestehende 0186/0244-Ticket-Policy; das provider-agnostische Alarm-Regelwerk (Spec alarm-management.md) folgt als eigener Arbeitsstrang und speist dann Octoja UND Riverbird in denselben Regel-Stack. Kein Backfill — alles additiv, Inbetriebnahme pro Tenant erst mit hinterlegten Credentials.
added Riverbird: Ticket-Erstellung kollidierte mit dem TANSS-Unique-Index auf tickets.sourceRef; ein Fehl-Event tötete den ganzen Poll-Batch; ein failed Job blockierte das Polling bis zu 24 h
geändert inbound.ts, sweeps.ts, riverbird.routes.ts. WHY (Live-Befund beim ersten echten Poll): (1) tickets trägt einen Unique-Index (tenantId, sourceRef.system, sourceRef.id) (Re-Import-Sicherheit der TANSS-/weclapp-Spiegel). Der Riverbird-Inbound schrieb — wie Octoja/NinjaOne — die Geräte-ID als sourceRef.id; das zweite Ticket desselben Geräts (zweiter gestörter Check bzw. neue Episode nach Schließung) starb mit E11000 … sourceRef.id: "3398". Riverbird-Tickets tragen jetzt die episoden-eindeutige externalEventId als sourceRef.id — die Ticket-Wiederverwendung läuft ohnehin über rmm_alerts.ticketId, der Geräte-Bezug über linkedEntities/rmm_alert; sourceRef ist auf RMM-Tickets reine Provenienz. (Octoja/NinjaOne haben dieselbe latente Kollision — bewusst NICHT mit angefasst, Octoja ist LIVE; als Folge-Arbeit notiert.) (2) processRiverbirdAlertEvents iteriert jetzt fehlertolerant pro Event: Der Poll flusht den State-Mirror VOR der Pipeline, ein geworfener Fehler verwarf also stillschweigend alle restlichen Transitions des Sweeps (sie feuern nie nach — der Mirror ist schon weiter). (3) removeOnFail: { age: 300 } statt 24 h auf allen Riverbird-Jobs: BullMQ verwirft Re-Adds mit belegter stabiler JobId stillschweigend — ein einziger Fehlschlag hätte Polling UND manuelle Trigger einen Tag lang blockiert; jetzt heilt sich das nach max. 5 Minuten. Verifiziert am Live-System: nach dem Fix liefen Polls durch und erzeugten echte Tickets (T-2026-00022 ff.) aus Check-Transitions inkl. Recovered-Markierung.
added Ansprechpartner-Notiz: optionaler Gültigkeitszeitraum (analog zu den Kunden-Hinweisen)
geändert party-relationship.schema.ts (additiv noteValidFrom/noteValidUntil), entities.ts, ContactNote.tsx + Tests, PartyDetailPage.tsx, CreatePartyDialog.tsx, de.json/en.json. WHY: Nutzerwunsch 05.08.2026, direkter Aufsatz auf die beiden Features vom selben Tag: Die Ampel-Notiz am Kontakt (unten) deckt zeitgebundene Hinweise („in Elternzeit bis …“, „vertritt X im August”) nicht ab — ohne Ablauf muss die Notiz manuell entfernt werden, und wird es erfahrungsgemäß nicht. Die Kontakt-Notiz bekommt deshalb dieselbe Zeitraum-Semantik wie party.notices[]: optionale Felder noteValidFrom/noteValidUntil (ISO-Datum, beide Enden inklusiv und einzeln offen — daher zwei einzelne DatePicker statt des DateRangePicker, der ein geschlossenes Paar erzwingt). Außerhalb des Zeitraums wird die Notiz an den Anzeige-Flächen (Kontaktkarte, „Wichtige Kontakte“-Hero-Karten) komplett ausgeblendet — bewusst wie bei den Kunden-Meldungen, nicht nur entfärbt; in den Bearbeiten-Dialogen bleibt sie natürlich sichtbar und löschbar. Die Aktiv-Prüfung ist KEINE Kopie: ContactNote nutzt die vorhandenen Shared-Helper isPartyNoticeActive/todayIsoDate und die „Gültig …“-Formatierung formatNoticeValidity aus dem PartyNoticeBanner weiter (auf der Kontaktkarte als kleine Zeile unter der Notiz, in den kompakten Hero-Karten nur im Tooltip). Das PATCH schickt beide Felder immer mit, damit das Leeren eines Datums die Grenze auch wirklich entfernt; ältere localStorage-Entwürfe des CreatePartyDialog werden beim Wiederherstellen mit '' aufgefüllt (gleiches Muster wie bei noteSeverity). Kein Backfill: fehlende/null-Felder gelten zur Laufzeit als unbegrenzt gültig — Bestandsnotizen verhalten sich exakt wie bisher.
added Kunden-Hinweise: Gültigkeitszeitraum (z. B. Betriebsferien) + wichtige Hinweise ganz oben auf der Kundenseite
geändert party.schema.ts, entities.ts, PartyNoticeBanner.tsx, PartyDetailPage.tsx, de.json/en.json; neu party-notice.ts. WHY: Feature-Wunsch 05.08.2026: Ein Kunde ist in Betriebsferien, Kontaktversuche laufen ins Leere — das soll am Kunden hinterlegbar sein, abteilungsübergreifend sichtbar, und „wichtige Notizen ganz oben“. Der Mechanismus dafür existierte bereits: die Kunden-Hinweise (party.notices[], seit b968a5b96) mit Wichtigkeit und Banner im Ticket + Leistungserfassungs-Dialog. Es fehlten zwei Dinge. (1) Zeitraum: Betriebsferien sind zeitgebunden, ein Hinweis ohne Ablauf muss manuell entfernt werden (und wird es erfahrungsgemäß nicht). Hinweise haben jetzt optionale Felder validFrom/validUntil (beide Enden einzeln offen — deshalb zwei einzelne DatePicker statt des DateRangePicker, der immer ein geschlossenes Paar erzwingt); außerhalb des Zeitraums verschwindet der Hinweis von allen Banner-Touchpoints automatisch. Fehlende/null-Werte gelten als unbegrenzt — Alt-Hinweise verhalten sich exakt wie bisher, kein Backfill. (2) Prominenz: Auf der Kunden-Detailseite erscheinen aktive Hinweise der Stufen Warnung/Kritisch jetzt als farbiges Banner ganz oben (vor „Wichtige Kontakte“); Info-Hinweise bleiben bewusst der Sektion „Hinweise zum Kunden“ vorbehalten, sonst wäre der Seitenkopf bei TANSS-Importen mit vielen Info-Notices zugestellt. Die Sektion selbst sortiert jetzt aktiv → kommend → abgelaufen, innerhalb der Gruppe nach Wichtigkeit (stabil, TANSS-Rang bleibt erhalten), zeigt den Zeitraum an und blendet nicht-aktive Einträge auf 55 % Deckkraft ab (sichtbar für die Pflege, aber hinter den gültigen zurücktretend). Die Gültigkeits-/Sortierlogik liegt als pure Funktionen in @codemeta/shared (partyNoticeValidity, isPartyNoticeActive, sortPartyNotices), damit Banner und Detailseite nicht auseinanderdriften. Ticket-/Leistungs-Banner filtern unverändert nach ihren Flags, zusätzlich jetzt nach dem Zeitraum. aufgeschlüsselt nach Überstunden / Wochenende / Notdienst** (Aufsatz auf ADR 0441) — neu overtime-export.service.ts + Tests; geändert time-account.routes.ts, API.md. WHY: Nutzerwunsch 05.08.2026: Für die Lohnbuchhaltung braucht es eine Datei über alle Mitarbeitenden und einen Zeitraum, getrennt nach den drei Kategorien. Die Datenbasis existiert seit ADR 0441 (categoryMinutes je Transaktion), es fehlte ausschließlich die Ausgabe — exportierbar war bisher nur die Zeitbuchungsliste über den generischen Grid-Export, also Leistungen statt genehmigter Überstunden und ohne Kategorien. Neuer Endpunkt GET /time-accounts/overtime-approvals/export?from=&to=&userId= (Permission overtime_approve) liefert eine .xlsx mit zwei Blättern: Buchungen (eine Zeile je genehmigter Überstunde inkl. Kunde/Bezug, Beschreibung und Genehmiger) und Summen (je Mitarbeiter plus Gesamtzeile und Dezimalstunden). Bewusst ein eigener Endpunkt statt eines Client-Exports der Freigabe-Queue: deren Route ist auf 200 Zeilen gedeckelt und wird ohne Zeitraum aufgerufen — ein Export daraus hätte bei größeren Zeiträumen still abgeschnitten, ausgerechnet in einer Abrechnungsgrundlage. Der Export begrenzt stattdessen über den Zeitraum und antwortet oberhalb von 20 000 Zeilen mit 422 statt zu kürzen. Drei fachliche Fallstricke sind im Service festgeschrieben und durch Tests gepinnt: (1) Kategorien überlappen — eine Sonntagabend-Buchung ist gleichzeitig Wochenende und Überstunde, minutes ist laut ADR 0441 die Vereinigung; die Spalte „Gesamt“ leitet sich deshalb nie aus den Kategoriespalten ab, und das Summenblatt weist ausdrücklich darauf hin. (2) Die Freigabe darf kürzen (ADR 0438 Phase 3) — gerechnet wird mit creditedMinutes ?? minutes, der Antragswert erscheint als eigene Spalte „Beantragt“, statt die Kürzung geraten auf die Kategorien zu verteilen. (3) Fehlende Aufteilung (Zeilen aus der Zeit vor den Fensterregeln) landet in einer Spalte „Ohne Zuordnung“ statt in einer erfundenen Kategorie; beide Zusatzspalten erscheinen nur, wenn der Datensatz sie braucht. Zeiten stehen als echte Excel-Zeitwerte ([h]:mm) statt als Text, bleiben also summier- und filterbar. Kein Backfill, keine Schema-Änderung, rein lesend.
added Mein Feedback: Anwendungsfall und Screenshots werden endlich angezeigt (und der Anwendungsfall ist durchsuchbar)
neu FeedbackScreenshots.tsx; geändert FeedbackPage.tsx, de.json/en.json. WHY: Nutzerwunsch 04.08.2026 („Damit Stefan die wiederfindet“): Der Melde-Dialog fragt bei einem Feature-Wunsch nach dem Anwendungsfall und lässt bis zu drei Screenshots anhängen — auf /feedback war davon anschließend nichts mehr zu sehen. Wer zehn Wünsche eingereicht hat, konnte den gesuchten nur noch am Titel erkennen, obwohl gerade der Screenshot das wiedererkennbare Merkmal ist. Es fehlte ausschließlich die Darstellung: GET /api/v1/feedback gibt das komplette Dokument zurück (feedback.routes.ts), aber das Feedback-Interface der Seite deklarierte useCase/screenshotFileIds gar nicht erst, und der aufgeklappte Kartenbereich rendert seit jeher nur Beschreibung + Kommentare. Neu: ein Block „Anwendungsfall“ analog zur Beschreibung (nur bei FEATURE_REQUEST gesetzt, deshalb schlicht &&-gerendert) und eine Thumbnail-Leiste mit Klick-Lightbox. Zusätzlich zeigt die eingeklappte Kartenzeile einen Bild-Zähler neben dem bestehenden Kommentar-Zähler — sonst müsste man zum Wiederfinden jede Karte einzeln aufklappen. Die Client-Suche (Titel + Beschreibung) berücksichtigt jetzt auch den Anwendungsfall, inklusive Treffer-Hervorhebung; alle drei Felder werden auf der Karte gerendert, ein Treffer bleibt also erklärbar. Zwei nicht offensichtliche Details: Die Bild-URL läuft über withTenantQuery() (file-url-rewrite.ts) — ein nacktes <img src> kann keinen X-Tenant-Id-Header senden, die Tenant-Middleware akzeptiert dafür den ?_tid=-Query-Fallback. Und die Galerie liegt in einem eigenen Modul, nicht inline: FeedbackPage.tsx liegt mit ~1,4k Zeilen weit über dem 800-Zeilen-Limit. Reine Frontend-Änderung — keine API-, keine Schema-Änderung, kein Backfill. Bewusst nicht enthalten: die Bug-Felder (reproductionSteps/expectedBehavior/actualBehavior) sind in der Übersicht weiterhin unsichtbar — gleiche Lücke, aber nicht Teil dieses Wunsches. Nachtrag im selben Zug: ein „Alle aufklappen / Alle einklappen“-Knopf in der Toolbar (links neben der Suche). Dafür musste die Liste vom Akkordeon (expandedId: string | null, genau eine offene Karte) auf ein Set offener Ids umgestellt werden — mit „alle aufklappen“ ist mehr als eine Karte offen, folglich schaltet ein Kartenkopf jetzt nur noch seine eigene Karte um statt die vorherige zuzuklappen. Der Knopf bezieht sich ausschließlich auf die sichtbare Liste: bei aktivem Tab-Filter oder aktiver Suche gilt „alles offen“ erst, wenn alle gefilterten Karten offen sind, und das Zuklappen fasst auch nur diese an — sonst würde der Knopf „fertig“ behaupten, während ausgeblendete Karten noch zu sind. Tab-Wechsel und Sucheingabe setzen die Auswahl weiterhin zurück, der ?id=-Deeplink aus einer Benachrichtigung klappt seine Karte weiterhin auf (jetzt additiv, statt andere zuzuklappen).
added Ansprechpartner: Notiz mit Ampel-Wichtigkeit — sichtbar auf der Kontaktkarte UND bei „Wichtige Kontakte“
neu ContactNote.tsx + Tests; geändert party-relationship.schema.ts (additiv noteSeverity), entities.ts (ContactNoteSeverity + PartyRelationship.noteSeverity), PartyDetailPage.tsx, CreatePartyDialog.tsx, de.json/en.json. WHY: Nutzerwunsch 05.08.2026: Wichtige Hinweise zu einem Ansprechpartner („nur vormittags erreichbar“, „Eskalationskontakt“, „zahlt schlecht“) sollen ins Auge fallen. Ein Freitextfeld gab es bereits — party_relationship.notes wurde als graues Kursiv am Kartenende gerendert und ging dort neben Rollen-Chips und Kontaktdaten schlicht unter; in den Hero-Karten „HAUPTANSPRECHPARTNER“/„NOTFALLKONTAKT“ auf der Firmen-Übersicht wurde es gar nicht angezeigt, obwohl genau das die Stelle ist, auf die man vor einem Anruf schaut. Statt eines zweiten Freitextfelds (das die Frage „welches Feld ist das richtige?“ erzeugt hätte) bekommt das bestehende notes eine Ampel: noteSeverity: NONE | GREEN | YELLOW | RED. Bei NONE — dem Default — ist das gerenderte Markup byte-gleich zu vorher, alle Bestandsnotizen sehen also unverändert aus; ab GREEN wird die Notiz zu einem farbigen Callout mit Punkt (RED zusätzlich fetter), Farben ausschließlich über die Theme-Tokens SUCCESS/WARNING/DANGER, damit Dark Mode und Branding greifen. Setzbar im Inline-Edit der Kontaktkarte, im „+ Hinzufügen“-Dialog und — neu — im CreatePartyDialog, wo die Beziehungsnotiz bisher überhaupt nicht gemappt wurde (das dortige notes ist die Firmen-/Personen-Notiz, die Zeile heißt deshalb relNotes; ältere localStorage-Entwürfe werden beim Wiederherstellen aufgefüllt, sonst würde ihr Notizfeld uncontrolled und der Submit auf .trim() werfen). Das PATCH schickt noteSeverity immer mit, damit ein Zurücksetzen auf „Ohne“ die Farbe auch wirklich löscht. Ausgelagert in ein eigenes Modul statt inline in die 9,3k-Zeilen-God-Component PartyDetailPage.tsx; die Ampel-Notiz im Ticket-Ansprechpartner (TicketHeroContactCell) ist bewusst NICHT Teil dieser Änderung — dort müssten die Relationship-Daten erst angereichert werden. Kein Backfill: fehlendes/null-Feld zählt zur Laufzeit als NONE (normalizeContactNoteSeverity).
added Aufgaben: Mehrfachverknüpfung — Zusatzlinks (linkedEntities[]) neben der Hauptverknüpfung
geändert task.schema.ts, entities.ts (Task.linkedEntities + EntityLink.entityType-Union an die Schema-Enums angeglichen), task.hooks.ts (+ Tests), TaskSidePanel.tsx, LinkedEntitiesSection.tsx (title jetzt optional; Picker-Popover auf Z_INDEX.DRAWER_POPUP gehoben — das Task-Side-Panel liegt im FIXED_DIALOG-Band 3100, beim MUI-Default 1300 öffnete der Picker unsichtbar DAHINTER und der „Verknüpfung hinzufügen“-Knopf wirkte tot), de.json/en.json. WHY: Feedback-Ticket „Aufgaben - Verknüpfung“ (04.08.2026, Teil 2): Eine Aufgabe soll gleichzeitig mit der Vertriebschance, dem Kunden und ggf. dem Ansprechpartner verknüpft werden können — bisher gab es genau EINE Verknüpfung (referenceId/referenceSchema). Umsetzung über das etablierte linkedEntities[]-Muster (ADR 0386, gleiche Item-Form wie phone_call), aber mit bewusst ANDERER Semantik als dort: Die Einzelreferenz wird nicht deprecated, sondern bleibt die Hauptverknüpfung — an ihr hängen Vorgänger-Geschwister-Logik, ?partyScope=-Aggregation, projectId-$or-Expansion, Kanbans, Planung und TANSS-Spiegel; linkedEntities[] trägt nur die Zusatzlinks. Im Aufgaben-Side-Panel sitzt unter „Kunde / Verknüpfung“ jetzt die Zeile „Weitere Verknüpfungen“ mit der bestehenden LinkedEntitiesSection (Chips + Peek + Navigation, editierbar für Manager, read-only sonst, verborgen wenn leer und nicht editierbar). Serverseitig normalisieren beforeCreate/beforeUpdate in den Task-Hooks: Array-Duplikate und Einträge, die die Hauptverknüpfung doppeln, werden verworfen — auch beim Referenzwechsel, der einen dadurch doppelt gewordenen Zusatzlink mit aufräumt (das Bestandsdokument wird nur gelesen, wenn der Patch die nötige Hälfte nicht mitbringt; ein Referenzwechsel ohne Überschneidung erzeugt bewusst KEINEN linkedEntities-Patch in der Änderungshistorie). Kein Backfill — Feld ist additiv mit default: [], fehlende Werte lesen sich als „keine Zusatzlinks“. Bewusste Lücke (Folgeschritt): Entity-Aufgabenlisten (?referenceSchema=&referenceId=) und die ?partyScope=-Aggregation sehen Zusatzlinks noch NICHT — eine nur sekundär verknüpfte Aufgabe erscheint im „Aufgaben“-Tab der Ziel-Entität erst nach der geplanten Query-Expansion (Phase 3 des Plans, inkl. Multikey-Index).
added Leistungszuschläge: Rechenkern für manuell wählbare Zuschläge + Feiertags-Fenster (ADR 0442, S1+S2)
geändert service-type.schema.ts (additiv applyMode: 'AUTO'|'MANUAL' + alsoOnHolidays), surcharge-segments.ts (+ Tests), time-record-budget.service.ts (+ Tests). WHY: Unsichtbarer erster Teil von ADR 0442 — ohne die neuen Felder verhaltensgleich (durch die grünen Bestands-Suiten belegt). (1) NEUTRAL-Feiertags-Modus in den ADR-0431-Bausteinen: Die bestehende IGNORE-Semantik UNTERDRÜCKT Wochentagsregeln an Feiertagen (Vertrags-Logik ADR 0163, dort gewollt) — hätte die Abrechnung echte Feiertagsdaten bekommen, hätte ein Feiertags-Samstag den Wochenendzuschlag verloren. Der Adapter serviceTypeSurchargeToRule mappt Leistungszuschläge deshalb nie mehr auf IGNORE, sondern auf NEUTRAL („Feiertag ändert nichts“) bzw. mit alsoOnHolidays auf ALSO_ON_HOLIDAYS („Fenster gelten zusätzlich an jedem Feiertag“); ohne Feiertagsflags sind NEUTRAL und IGNORE identisch (Test gepinnt). (2) applyMode: 'MANUAL' wird im Adapter zu einem Ganztags-Fenster (00:00–23:59, alle Tage): Ein angewählter manueller Zuschlag deckt damit die volle Buchung ab — statt wie bisher als „0 Minuten getroffen“ still aus der Abrechnung zu fallen — und überlebt via isAllDayRule auch Buchungen ohne Uhrzeiten (DAY-Basis). (3) Abrechnung (splitServiceTypeSurcharges, jetzt async): reicht die neuen Felder durch und löst Feiertage NUR bei Bedarf über den gecachten holiday-resolver auf (Buchungsdatum + Folgetag wegen Mitternachts-Buchungen, Regions-Kaskade des buchenden Users, Tenant-Fallback ohne User); der Uhrzeiten-lose DAY-Slice trägt das Feiertags-Flag ebenfalls. Es zählen unverändert nur ANGEWÄHLTE Zuschläge. S3 (Dialog: nie vorblenden, Feiertags-Erkennung) und S4 (Editor + „Aus Überstunden übernehmen“) folgen — erst dann ist das Verhalten sichtbar.
added Überstunden-Freigabe: voller Buchungs-Kontext in der Queue + Auto-Aufklappen der Markierungs-Sektion im Dialog
geändert time-account.routes.ts, OvertimeApprovalsTab.tsx, TimeEntryDialog.tsx, de.json/en.json. WHY (Queue-Kontext): Die Freigabe-Karte zeigte nur Hauptbezug + Beschreibung — für die Entscheidung fehlten Kunde, gebuchte Uhrzeiten und die weiteren Verknüpfungen, der Teamleiter musste die Buchung öffnen. Jetzt steht neben dem Datum die gebuchte Zeit („17:00–20:00 · 3:00 h · 0:15 Pause“ — Dauer bewusst brutto wie erfasst, Pause separat; ohne Uhrzeiten nur die Dauer), und die Kontextzeile beginnt mit dem Kunden und listet ALLE Verknüpfungen der Buchung (typisierte Multi-Links aus ADR 0433 über resolveLinks mit Legacy-Fallback auf den Hauptbezug: Projekt-/Ticket-Nummer + Titel, Aufgaben-Titel, Ansprechpartner, Assets) plus Beschreibung, mit Volltext im Tooltip. Serverseitig löst die Queue das bulk auf (fünf $in-Lookups über tickets/projects/tasks/parties/assets); der Hauptbezugs-Typ TASK bekam dabei erstmals ein Label (vorher zeigten Aufgaben-Buchungen gar keinen Bezug). WHY (Auto-Aufklappen): Wählt die Regel-Engine eine Kategorie automatisch vor (z. B. Buchung berührt das Überstunden-Fenster), passierte das hinter dem standardmäßig EINGEKLAPPTEN „Überstunden & Sonderdienst“-Header — samt der Pflicht-Begründung, deren 422 dann überraschte. Die Sektion klappt jetzt automatisch auf, sobald die Erkennung anschlägt; manuelles Zuklappen bleibt bestehen, bis sich Datum/Uhrzeiten/Regeln (der autoKey) ändern.
added Regelbasierte Überstunden-Fenster: Settings-Editor + Kategorie-Minuten in der Freigabe-Queue (ADR 0441, R4 — Feature damit vollständig)
neu OvertimeMarkingRulesSection.tsx; geändert TimeTrackingSettingsPage.tsx (Einbindung als dritte Karte), OvertimeApprovalsTab.tsx, overtime-marking-rules.ts (Hook) (Query-Key exportiert), 0441-rule-based-overtime-windows.md (Status Accepted, R1–R4 umgesetzt), de.json/en.json. WHY: Der letzte Baustein des ADR — die Regeln brauchen eine Pflege-Oberfläche, und der Teamleiter muss in der Queue sehen, WIE ein Betrag zustande kam. Settings (Karte „Überstunden-Regeln“ auf der Zeiterfassungs-Settings-Seite, tenant_admin_manage-gated wie die Nachbarkarten): pro Kategorie Umschalter „Volle Dauer (manuell)“ / „Zeitfenster“; im Fenster-Modus eine Fenster-Liste mit Wochentags-Chips (Mo–So, Intl-lokalisierte Kurzlabels, ISO-1–7-Konvention des Settings), von/bis über den vorhandenen TimePicker (kein natives <input type="time">) und Entfernen-Knopf; Hinweiszeile erklärt Ganztags-Shorthand („00:00–23:59“) und Mitternachts-Wrap (22:00–06:00); die Wochenend-Kategorie hat zusätzlich den Schalter „Feiertage zählen wie Wochenende“ (shared ToggleSwitch). Jede Interaktion speichert sofort (optimistischer Local-State mit Rollback — Muster der Nachbarkarten); der Wechsel auf „Zeitfenster“ ohne Fenster sät ein valides Startfenster Mo–Fr 18:00–23:59 (das Leitbeispiel), damit nie ein 0-Minuten-Zustand persistiert wird. Der Save ist merge-sicher: PATCH /api/v1/tenant merged settings nur auf oberster Ebene, settings.overtime würde als Ganzes ersetzt — der Saver holt das aktuelle overtime-Objekt und spreadet marking hinein (dasselbe Muster wie useSaveTimeEntrySettings, ADR-0433-Falle), und invalidiert neben dem eigenen Query auch den Regel-Query des TimeEntryDialogs, sodass eine Regeländerung sofort in der Buchungsmaske wirkt. Queue: Die Kategorie-Chips der Anträge zeigen jetzt die beantragten Minuten je Kategorie („Überstunden · 2:00“ — minutes der Zeile bleibt die Vereinigung, die Anteile dürfen sich überlappen; Alt-Zeilen ohne categoryMinutes zeigen wie bisher nur das Label), und Anträge mit windowFallback tragen einen Warnchip „Ohne Uhrzeiten — volle Dauer“ mit Tooltip-Handlungsanweisung (Betrag übers Minuten-Feld anpassen). Kein Backfill; ohne gespeichertes Setting zeigt der Editor die wirksamen Defaults (Überstunden/Notdienst manuell, Wochenende Sa+So+Feiertage ganztägig).
added Regelbasierte Überstunden-Fenster: Dialog-Regel-Engine mit Chip-Minuten und Auto-Vorwahl (ADR 0441, R3)
neu overtime-marking-rules.ts (Hook) + Tests; geändert TimeEntryDialog.tsx, tenant.routes.ts, de.json/en.json. WHY: Der Techniker soll VOR dem Speichern sehen, was seine Markierung beantragt — nicht erst der Teamleiter in der Queue. Die aktiven Markierungs-Chips zeigen jetzt die Minuten ihrer Kategorie nach den Tenant-Fensterregeln („Überstunden · 2:00“, bei Schnitt 0 warnend „· 0 Min.“), darunter steht der insgesamt beantragte Betrag („Zur Freigabe eingereicht: 2:00 h”) mit Warnhinweisen bei 0-Schnitt (keine Freigabe-Anfrage) und beim Ohne-Uhrzeiten-Fallback (volle Dauer). Gerechnet wird mit exakt derselben computeOvertimeCategoryMinutes aus @codemeta/shared, die der Server beim Anlegen der PENDING nutzt — Anzeige und Buchung können nicht driften. Die hartkodierte Sa/So/Feiertags-Auto-Erkennung ist durch die Regel-Engine ersetzt: Auto-vorgewählt werden alle Kategorien im WINDOWS-Modus, deren Fenster die Buchung berührt; ohne Uhrzeiten nur bei GANZTÄGIGEN Fenstern (nur die sind vom Datum allein wissbar — dieselbe Überlegung wie isAllDayRule, ADR 0431 §5; „ab 18 Uhr“ wählt eine uhrzeitlose Buchung also nicht vor). Manuelle Ab-/Anwahl bleibt wie bisher bestehen, bis sich Datum, Uhrzeiten, Pausen oder Regeln ändern; MANUAL-Kategorien (Default: Überstunden, Notdienst) werden nie automatisch angefasst — beim Default-Setting ist das Verhalten damit deckungsgleich mit der alten Erkennung, nur läuft der Feiertags-Check jetzt als gecachte React-Query statt als Roh-Fetch im Effect. Dafür wurde overtime in die PUBLIC_SETTINGS_KEYS des GET /api/v1/tenant aufgenommen (jeder buchende Mitarbeiter braucht die Regeln; der Geschwister-Key settings.overtimeMode aus ADR 0438 bleibt bewusst admin-only). Regeln-Laden, Feiertags-Auflösung, Berechnung und Auto-Vorwahl leben im neuen Hook statt im 3,5k-Zeilen-Dialog (God-Component-Auflage aus dem ADR); der Dialog behält nur Rendering und den Setz-Effect. R4 (Settings-UI + Queue-Anzeige) folgt.
added Regelbasierte Überstunden-Fenster: Rechenkern + Server-Verdrahtung (ADR 0441, R1+R2)
neu overtime-marking-rules.ts (+ Tests mit dem gepinnten Leitbeispiel 17–20 Uhr / „ab 18:00“ → 120 Minuten); geändert surcharge-segments.ts (nur additive Re-Exports isMinuteInTimeWindow/clockTimeToLocalMinutes — die Fenster-Arithmetik mit Mitternachts-Wrap und „23:59“-Endetag wird wiederverwendet statt kopiert, die Zuschlags-holidayMode-Gates passen semantisch nicht und bleiben draußen), time-account-transaction.schema.ts (additiv categoryMinutes + windowFallback), shared/index.ts, overtime-marking.service.ts (+ Tests), time_record.hooks.ts, time-account.routes.ts. WHY: Umsetzung der ersten beiden ADR-0441-Phasen — eine als Überstunde/Wochenende/Notdienst markierte Buchung beantragt nicht mehr pauschal die volle Dauer, sondern das Ergebnis der Tenant-Fensterregeln (tenants.settings.overtime.marking, gelesen über resolveOvertimeMarkingRules mit tolerantem Normalizer — kaputte Settings können das Buchen nie brechen). Kern ist die EINE geteilte Funktion computeOvertimeCategoryMinutes(record, rules, { categories, isHolidayByDate }) → { perCategory, unionMinutes, fallback }, die später auch Dialog-Chips (R3) und Payroll nutzen: pro Kategorie MANUAL (volle Brutto-Dauer, das ADR-0438-Verhalten) oder WINDOWS (Minuten-Schnitt Buchungszeit × Fenster auf Basis der ADR-0431-Slices — Pausen im Fenster abgezogen, Buchungen über Mitternacht je Tageshälfte mit eigenem Wochentag, ISO-Wochentage 1–7 im Setting mit dokumentierter Konvertierung zur JS-getDay()-Welt der Slices). submitOvertimeForTimeRecord schreibt minutes = Vereinigung über die markierten Kategorien (eine Minute zählt nie doppelt), categoryMinutes getrennt (Basis der späteren kategoriegetrennten Sätze) und windowFallback für Buchungen ohne Uhrzeiten (PO-Entscheid: volle Dauer als Fallback, in der Queue als Warnung sichtbar); Schnitt 0 erzeugt keine PENDING mehr und storniert eine noch offene (z. B. nach Verschieben der Uhrzeiten aus dem Fenster). Der Idempotenz-Vergleich prüft zusätzlich Kategorie-Anteile + Fallback — Alt-PENDINGs aus ADR 0438 werden dadurch beim nächsten Edit der Buchung automatisch auf den neuen Stand gebracht, kein Backfill. Feiertage löst der Server nur bei Bedarf über den gecachten holiday-resolver auf (Buchungsdatum + Folgetag wegen Wrap, Mitarbeiter-Regions-Kaskade); der Hook-Resync feuert jetzt auch auf startTime/endTime/breaks/breakMinutes (reines Verschieben der Uhrzeiten ändert den Schnitt), und die Approver-Notification nennt den beantragten Betrag statt der Buchungsdauer. Die Freigabe-Queue (GET /time-accounts/overtime-approvals) liefert categoryMinutes + windowFallback mit (Anzeige folgt in R4). Ohne Tenant-Setting ist einzig das Wochenende auf WINDOWS (Default „Sa+So ganztägig + Feiertage“ = die bisher im Dialog hartkodierte Auto-Erkennung, jetzt konfigurierbar); Überstunden/Notdienst bleiben MANUAL und verhalten sich byte-identisch zu ADR 0438. Drei bewusste Präzisierungen am Wochenend-Fall mit Uhrzeiten: Pausen zählen nicht mehr als beantragte Minuten, eine als „Wochenende“ markierte Werktags-Buchung ohne Feiertag erzeugt keine PENDING mehr (statt voller Dauer), und So→Mo-Buchungen über Mitternacht beantragen nur noch den Sonntags-Anteil. R3 (Dialog-Regel-Engine + Chips) und R4 (Settings-UI + Queue-Anzeige) folgen separat.
added ADR 0439 (Entwurf): Regelbasierte Ticket-Generierung aus Vertragspositionen
neu 0439-contract-position-ticket-rules.md; Phase 1 (Datenmodell + Entity-Registrierung) umgesetzt: neu contract-ticket-rule.schema.ts; geändert contract.schema.ts (optionales ticketRules[]-Aktivierungs-Array, kein Backfill), entity-config.ts, permissions.ts (contract_ticket_rule_view/create/edit/delete), index.ts, index-registry.ts + init.js (Entity-Indexe + sparse Sweep-Index ticketRules.nextCreateDate auf contracts), schema.zed, schema.service.ts (STATIC_SCHEMAS), API.md. Phase 2 (API-Validierung) umgesetzt: neu contract_ticket_rule.hooks.ts (+ Tests); geändert entity-hooks/index.ts — referentielle Validierung über das AJV-Schema hinaus: hängende Artikel-/Checklisten-/Aufgaben-Vorlagen-/Team-/Bearbeiter-Referenzen und Checklisten-Vorlagen mit fremdem targetEntityType werden beim Schreiben mit 400 abgelehnt, statt Monate später still im Worker-Sweep zu fehlen (Ticket ohne Checkliste, das niemand bemerkt). Kein Inline-Branch in crud.routes.ts (Guardrail #4). Phase 3 (Worker-Sweep) umgesetzt: neu contract-ticket-generate.service.ts (+ Tests); geändert queues.ts (CONTRACT_TICKET_GENERATE), contract-consumer.ts (Handler + täglicher Repeatable 00:45 via upsertJobScheduler). Der Sweep spiegelt strukturell runContractBilling: Scan ACTIVE/EXPIRING-Verträge mit fälligen ticketRules[]-Aktivierungen (nextCreateDate <= heute), Dormancy-Guards (Regel gelöscht/inaktiv, Position fehlt/inaktiv/außerhalb validFrom–validUntil, Fälligkeit nach contract.endDate — ADR-0146-Kappung; alles überspringt OHNE Cursor-Fortschritt), Ticket via createWorkerEntity (Sequenznummer, History, searchText, serviceTargets: true, contractPositionRef, dueAt = Fälligkeit 12:00 UTC), danach Checklisten via spawnChecklistFromTemplate und Aufgaben-Vorlagen über die interne API-Bridge mit Flat-Task-Fallback (best-effort — Vorlagen-Fehler verlieren nie den Cursor-Fortschritt, ein Retry würde das Ticket duplizieren, nicht die Vorlagen reparieren). Idempotenz zweifach: Cursor wird UNMITTELBAR nach dem Erzeugen vorgerückt (Crash-Fenster = 1 Ticket, Muster expiry-notify) plus Existenz-Check auf tickets.sourceRef { system: 'contract-ticket-rule', id: '<activationId>:<dueDate>' } — das Schema definiert sourceRef als Objekt, der ADR-Entwurf hatte einen String vorgesehen (ADR korrigiert). Kein Nachholen verpasster Perioden: höchstens EIN Ticket pro Lauf und Aktivierung, der Cursor springt auf den nächsten zukünftigen Termin; Monats-Intervalle re-ankern auf dem Starttag mit Monatsend-Klemmung (31.01 → 28.02 → 31.03 statt dauerhaft 28.). Phase 4 (Settings-Seite) umgesetzt: neu ContractTicketRulesPage.tsx + ContractTicketRuleDialog.tsx (Regel-Editor: Artikel-Multi-Match, Intervall + Vorlauf, Ticket-Vorlage mit Platzhalter-Hinweis, Prio/Typ/Team/Bearbeiter, Checklisten-/Aufgaben-Vorlagen-Picker; Checklisten-Auswahl clientseitig auf targetEntityType 'ticket' gefiltert — gespiegelt zur Server-Validierung aus Phase 2); geändert ArticleSelector.tsx (+ neues ArticleMultiSelectorForm, generischer Shared-Baustein), entity-selectors/index.ts, settings/routes.tsx (Route-Manifest, NICHT App.tsx — Guardrail #5), settings-registry.ts (Bereich Service-Desk, Icon Contract), de.json/en.json. Phase 5 (Vertrags-Reiter „Tickets“) umgesetzt — Feature damit vollständig: neu ContractTicketsTab.tsx (eigene Datei — ContractDetailPage.tsx liegt bereits weit über dem 800-Zeilen-Budget); geändert ContractDetailPage.tsx (Tab-Id, Sichtbarkeit, Render-Switch — auch für LICENSE-Verträge sichtbar, da Regeln per Entscheidung 1 beide Positions-Arrays matchen), entities.ts (Contract.ticketRules + ContractTicketRuleActivation-Typ), de.json/en.json. Oben Regel-Bereich: aktive Aktivierungen (Start/nächste Fälligkeit/Link aufs letzte Ticket, Pausieren-Switch, Entfernen mit Bestätigung) plus Vorschläge aus dem clientseitigen Artikel-Match (rule.matchArticleIds × position.articleId, items[] UND licensePositions[]) und „Regel manuell aktivieren“ für Positionen ohne Match; Aktivierung im Dialog mit Pflicht-Startdatum (DatePicker min = heute — Entscheidung 3), geschrieben über den normalen Vertrags-PATCH (patchContract → Änderungshistorie gratis, contract_edit-gated). Unten EntityDataGrid aller Vertrags-Tickets (Filter contractId; Spalten Nummer/Titel/Status via Transition-Config/Bearbeiter/Fällig/Erstellt) mit „Automatisch“-Badge und „Nur automatische“-Toggle über den serverseitig allowlisteten Filter sourceRef.system (Entscheidung 6; sourceRef wird nur für Kundenportal-Nutzer gestrippt, der Vertragskontext ist intern). WHY: Feature-Wunsch (Patrick Schwarz, 04.08.2026): Hinter Vertragspositionen stehen wiederkehrende Leistungen (monatliche Backup-Prüfung, quartalsweise Wartung), für die heute jemand manuell Tickets anlegen muss — vergessene Termine sind unerbrachte und unfakturierte Leistungen. Entscheidung: neue mandantenweite Regel-Entität contract_ticket_rule (Settings-Seite; Match über matchArticleIds gegen position.articleId beider Positions-Arrays, Intervall-Enum + optionaler Vorlauf in Tagen, Ticket-Vorlage mit Platzhaltern, Checklisten-/Aufgaben-Vorlagen, Team/Bearbeiter aus der Regel), Aktivierungen als optionales Array contract.ticketRules[] am Vertrag (einzeln pro Position, Pflicht-Startdatum, Cursor nextDueDate/nextCreateDate nach dem Vorbild der Vertragsabrechnung), täglicher Worker-Sweep CONTRACT_TICKET_GENERATE (BullMQ-Repeatable im contract-consumer, Ticket via createWorkerEntity inkl. SLA-Targets und contractPositionRef, Idempotenz über Cursor + sourceRef-Existenz-Check, kein Nachholen verpasster Perioden), neuer Vertrags-Reiter „Tickets“ (oben Regel-Vorschläge/Aktivierungen, unten DataGrid aller Vertrags-Tickets mit „automatisch“-Badge und -Filter). Kein Backfill — alle neuen Felder optional, Automatisierung greift nur nach expliziter Aktivierung. Details, Guards (Position inaktiv/Vertrag beendet → Erzeugung schläft) und Abgrenzung zum deprecateten TICKET-Kanal aus ADR 0198 im ADR.
added Überstunden-Sichtbarkeit: Freigabestatus an jeder Buchung, „ausstehend“-Ausweis in Konto-Ansichten, Analytics-Widgets erstmals funktionsfähig
geändert overtime-marking.service.ts + Tests, time_record.hooks.ts, crud.routes.ts, TimeEntryDialog.tsx, OvertimeTab.tsx, MyTimeAccountPage.tsx, predefined-dashboards.ts, time-account-closing.ts (worker), de.json/en.json. WHY: Sichtbarkeits-Prinzip aus dem ADR — eine Markierung darf an keinem Punkt ihres Lebenszyklus stillschweigend verschwinden. (1) Status-Enrichment über die Entity-Hooks (enrichList/enrichOne auf time_records, Bulk-Lookup via getOvertimeMarkingStatuses — jüngste nicht-gelöschte MANUAL_OVERTIME-Transaktion pro Record gewinnt, damit Storno+Neuanlage und erneute Einreichung nach Ablehnung den aktuellen Stand zeigen): jede geflaggte Buchung trägt overtimeApprovalStatus/overtimeCreditedMinutes/overtimeApprovalNote in Listen- und Detail-Antworten, ohne eigenen Lookup-Request der Clients. (2) TimeEntryDialog zeigt den Status als Badge (bei Ablehnung inkl. Teamleiter-Begründung, bei Genehmigung inkl. gutgeschriebener Stunden). (3) OvertimeTab: KPI „Freigabe ausstehend“ aus dem neuen pendingApproval-Feld der Overview. (4) MyTimeAccountPage: „davon X:XX ausstehend“ neben dem Listentitel, Status-Chips an den Transaktionen, PENDING/REJECTED-Beträge gedämpft (saldo-neutral) und APPROVED zeigt den wirksamen creditedMinutes-Betrag. (5) Analytics-Fix: die drei Überstunden-Widgets (nach Mitarbeiter / KPI / nach Abteilung) matchten seit jeher auf das nicht existierende Feld overtime statt isOvertime — lieferten also konstant 0; zusätzlich sind isOvertime/isWeekend/isEmergencyService jetzt in ALLOWED_FILTERS für time_records, damit der Widget-Drill-Through (?isOvertime=true) nicht mehr still ignoriert wird. (6) Worker: runDailyBalance überspringt OVERTIME-Konten im MANUAL-Modus — deren DAILY_BALANCE-Zeilen waren funktionslose Duplikate der FLEX-Zeilen (Befund B10; das Closing bucht dort nichts mehr), reine Storage-Kosten; Modus wird einmal pro Tenant und Lauf aufgelöst.
added Teamleiter-Freigabe für Überstunden: Permission, Queue-Kontext, Minuten-Anpassung ohne Hash-Bruch, Benachrichtigungen
geändert role.service.ts, time-account.routes.ts, time-account-transaction.schema.ts, overtime-marking.service.ts + Tests, time_record.hooks.ts, OvertimeApprovalsTab.tsx, de.json/en.json, API.md. WHY: Der Markierungs-Flow aus Phase 2 endete in einer Queue, die faktisch nur admin_bypass sah — der im ADR-0050a-Text behauptete Rollen-Seed für overtime_approve war nie umgesetzt. Jetzt: (1) Permission-Seed an „Teamleitung Technik“ und „Personalabteilung“ — ensureDefaultRoles synchronisiert specialPermissions der System-Rollen bei jedem Tenant-Sync, die Vergabe propagiert also ohne Backfill auf Bestands-Tenants. (2) Queue-Kontext für die Entscheidung: Kategorie-Chips (overtimeFlags), verknüpfte Zeitbuchung inkl. Ticket-/Projekt-Label (Bulk-Lookup via sourceRef), Antragszeitpunkt, und ein „Gleitzeitkonto aktiv“-Warnbadge (hasFlexAccount) als Doppelzählungs-Hinweis — bewusst Hinweis statt Verrechnung, siehe ADR. (3) Minuten-Anpassung bei Freigabe (POST …/approve Body-Feld minutes, nur Reduzieren): der beantragte, gehashte tx.minutes wird dank Befund B14 nie mehr mutiert — der wirksame Betrag (Anpassung und/oder Limit-Kappung) landet im neuen additiven Feld creditedMinutes; die APPROVED-Historie zeigt beide Werte (gutgeschrieben + durchgestrichener Antrag). (4) Saldo-Korrektheit: /overtime-overview zählte bislang JEDE Transaktion in Saldo und Spalten — auch saldo-neutrale PENDING/REJECTED; mit den massenhaft neuen Markierungs-PENDINGs hätte die Übersicht aufgeblähte Salden gezeigt. Aggregation filtert jetzt approvalStatus ∉ {PENDING, REJECTED} und rechnet mit creditedMinutes ?? minutes; ausstehende Minuten kommen separat als pendingApproval: { count, minutes }. (5) Benachrichtigungen über den bestehenden createNotification-Pfad (interne User-IDs, Dedup, Delegations-Fan-out): Rollenträger von overtime_approve bei neuer PENDING-Markierung (Antragsteller ausgenommen, Deeplink auf die Queue), Antragsteller bei Genehmigung (inkl. gutgeschriebener Minuten) und Ablehnung (inkl. Pflicht-Begründung). Die Regenerierung der docs/roles-permissions-Matrix ist bewusst vertagt (Working Tree enthielt fremde unkommittierte Seeds aus ADR 0439) — nachholen auf dev nach Merge beider Stränge.
added Überstunden-Markierung → Freigabe-Queue: markierte Zeitbuchungen erzeugen jetzt PENDING-Buchungen auf dem Überstundenkonto
neu overtime-marking.service.ts + Tests, time-record-overtime-hook-registration.test.ts; geändert time_record.hooks.ts, entity-hooks/types.ts + crud.routes.ts (Contract-Erweiterung afterDelete — generischer Slot direkt nach softDeleteEntity, kein Inline-Branch), TimeEntryDialog.tsx, de.json/en.json. WHY: Die Markierung „Überstunden & Sonderdienst“ war bis dato folgenlos — sie speiste weder Konto noch Auswertung (Bestandsaufnahme ADR 0438: „Welt 3 bewirkt saldotechnisch nichts“). Jetzt: afterCreate/afterUpdate/afterDelete-Hooks rufen den neuen Service — ≥1 Flag ⇒ idempotente PENDING-MANUAL_OVERTIME-Transaktion (volle Buchungsdauer, Kategorie-Flags 1:1 kopiert, sourceRef → time_records, Hash-Kette; OVERTIME-Konto wird bei Bedarf mit nicht-negativen Limits auto-provisioniert) in der bestehenden Teamleiter-Queue; Flags entfernt/Buchung gelöscht ⇒ Storno der offenen PENDING per Soft-Delete (chain-sicher, entschiedene Transaktionen werden nie angefasst); Dauer/Datum/Flags geändert ⇒ Storno + Neuanlage. Hook-Fehler brechen den Buchungs-Request bewusst NICHT ab (Log + idempotenter Resync beim nächsten Edit). overtimeReason ist jetzt Pflicht bei gesetzter Markierung — serverseitig 422 im beforeCreate/beforeUpdate (validiert den effektiven Zustand Bestand+Patch), clientseitig mit i18n-Toast + Hinweis „wird zur Freigabe an die Teamleitung übermittelt“. Die Auto-Erkennung „Wochenende/Feiertag“ prüft neben dem Wochentag jetzt auch public_holidays (neuer ALLOWED_FILTERS-Eintrag public_holidays: ['date','region'] — vorher wurde ?date= still ignoriert und die ganze Liste geliefert); Regionen werden bewusst nicht gefiltert (Dialog kennt die User-Region nicht, Über-Flaggung ist per Chip abwählbar und läuft durch die Freigabe). Verdrahtungs-Guard-Test nach der ADR-0363-Lektion: pinnt Registry-Vollständigkeit UND den afterDelete-Invocation-Point in crud.routes.ts. Prüffrage aus dem ADR geklärt (read-only, Dev): kein Tenant nutzt die SUBMITTED→APPROVED-Workflow-Kante — kein Doppel-Freigabe-Risiko.
Geändert
changed Globale Suche: Identitäts-Boost — ein Treffer im NAMEN rankt über einem Treffer in E-Mail-Domain/Adresse
geändert search-ranking.ts (neu identityFieldBoost), search.routes.ts (rankAndTrim), search-unified.service.ts (Schritt 4), search-golden-queries.test.ts (Boost in der Harness + Desitin-Fall), search.routes.test.ts. WHY: Dritter Teil des „desitin“-Reports 05.08.2026 und direkte Nutzerfrage („Match auf den Namen höher werten als Match in der Mail?“ — ja): searchText ist ein flacher, deduplizierter Token-Beutel, die Feld-Herkunft geht beim Schreiben verloren — scoreSearchText kann die Firma namens „Desitin“ nicht vom Mitarbeiter mit …@desitin.de-Mailadresse unterscheiden, beide landen im selben Score-Bucket (~120) und die Reihenfolge zwischen gleichrangigen Typen war dem Zufall der Sekundärsortierung überlassen. Statt eines zweiten, feld-getrennten Index (Backfill!) wird die Herkunft zur Query-Zeit rekonstruiert: Beide Ranking-Stellen haben das voll hydratisierte Dokument in der Hand, identityFieldBoost(doc, tokens, joinedQuery) prüft die Identitätsfelder (name/displayName/firstName/lastName/title/subject/userName) und addiert gestaffelt +60 (exakt) / +50 (Präfix) / +40 (alle Tokens an Wortgrenzen) / +25 (Substring) — kalibriert auf SEARCH_SCORE_BUCKET 15, also 2–4 Buckets, sodass ein Namens-Treffer zuverlässig über einem Satelliten-Treffer liegt, ein exakter Telefon-Hit (300) aber weiter dominiert. Fuzzy-/Typo-Treffer bekommen bewusst keinen Boost (kein includes-Hit). Passt zum dokumentierten Vertrag des Moduls („Ranking lebt zur Query-Zeit, nie im Index“) und gilt per geteiltem Helper identisch für Legacy- und Unified-Pfad — abgesichert durch die erweiterte Golden-Queries-Harness (alle 21 Bestandsfälle unverändert grün, MRR/recall-Gates unverändert) plus je einen Ordnungs-Test auf Route- und Harness-Ebene (Person „Desitin Werkschutz“ vor Person mit @desitin.de-Mail). Kein Backfill, kein Reindex.
changed Überstunden-Freigabe-Queue: Mitarbeiter-Filter, Strg+Enter, DialogScrim, aufgeräumte Karten, Entscheider-Name
geändert OvertimeApprovalsTab.tsx, time-account.routes.ts, de.json/en.json. WHY: Erster manueller Testdurchlauf des neuen Flows: (1) Mitarbeiter-Filter (StaticSelect in der Kopfzeile, clientseitig über die geladenen max. 200 Einträge; die Offen-Summe folgt dem Filter) — bei mehreren Teams war die Queue sonst ein ungefiltertes Sammelbecken. (2) Strg/Cmd+Enter bestätigt den Entscheidungs-Dialog (Haus-Konvention, fehlte hier schlicht), ESC schließt, das Begründungsfeld hat Autofokus, der Submit-Button trägt den Shortcut als Tooltip. (3) Der handgerollte Backdrop mit Magic-Number-zIndex ist durch den shared DialogScrim ersetzt — behebt nebenbei die bestehende ESLint-Warnung und das Occlusion-Risiko unterhalb des Timer-FAB (ADR 0191). (4) Karten-Polish: Initialen-Avatar statt Warn-Dreieck, Wochentag im Datum, Begründung als Zitat-Block, einheitliche Chip-Optik, abgelehnte Beträge gedämpft, Kontextzeile mit Ellipsis statt Umbruch. (5) Die Historie zeigt den Namen des Entscheiders statt einer rohen ID (approvalDecisionByName, Server-Enrichment über den bestehenden User-Lookup; Alt-Zeilen mit better-auth-ID fallen auf „—“ zurück, kein Backfill). Nachschliff aus derselben Test-Session: der Mitarbeiter-Filter nutzt jetzt StaticSelectForm (Input-Optik mit Chevron + Suchfeld statt des als reiner Text wahrgenommenen Inline-StaticSelect), und der Avatar zeigt das Profilbild des Users (userImage im Queue-Enrichment, AvatarImage mit Initialen-Fallback bei fehlendem Bild/Ladefehler).
changed Überstunden-Automatik deaktiviert: Tenant-Modus overtimeMode mit Default MANUAL
geändert time-account-closing.ts + Tests, time-account.routes.ts, API.md. WHY: PO-Entscheidung (ADR 0438): Überstunden entstehen künftig ausschließlich über markierte Zeitbuchungen + Teamleiter-Freigabe; die Outside-Flex-Automatik aus ADR 0050a wird stillgelegt, aber nicht zurückgebaut. Neuer Resolver resolveOvertimeMode(db, tenantId) liest tenants.settings.overtimeMode ?? 'MANUAL' — der Default greift ohne jede Datenmigration (kein Backfill, prospektiv), AUTO_FLEX reaktiviert das alte Verhalten pro Tenant. Zwei prospektive Verhaltensänderungen ab dem nächsten Monatsabschluss: (1) das OVERTIME-Konto bekommt keine automatische OVERTIME_OUTSIDE_FLEX-Buchung mehr (weder CREDIT noch PENDING; die monthly_closing-Zeile entsteht weiter als Statistik und $inc-Ziel des Approve-Pfads, Pending-/Credited-Zähler bleiben 0); (2) das FLEX-Konto bucht wieder das kombinierte Tagesdelta statt flexDelta − shortfall — sonst verschwänden Outside-Flex-Stunden aus beiden Konten (das war Befund B1 der Bestandsaufnahme: ohne OVERTIME-Konto lösten sich diese Minuten in Luft auf). Historische Salden bleiben unangetastet; bestehende PENDING-Einträge in der Queue entscheidet HR normal. /overtime-proposals liefert im MANUAL-Modus { proposals: [], mode } (Kurzschluss vor dem teuren Attendance-Scan); das neue mode-Feld steht in allen Antworten, die proposals.length > 0-Guards im OvertimeTab blenden die Vorschlags-Sektion dadurch ohne Web-Änderung aus. DAILY_BALANCE inkl. Inside/Outside-Split läuft unverändert weiter (saldo-neutral, informativ). Testabdeckung: bestehende ADR-0050a-Tests laufen jetzt explizit mit AUTO_FLEX-Tenant; neu 3 MANUAL-Tests (OVERTIME bucht nichts trotz Modell mit Auto-Credit; FLEX bucht +90 kombiniert wo AUTO 0 ergäbe; explizites MANUAL = Default) + Resolver-Test (unbekannte Werte fallen auf MANUAL zurück). Rollback = overtimeMode: 'AUTO_FLEX' am Tenant.
changed Zeitkonto-Transaktionsschema deklariert den real genutzten MANUAL_OVERTIME-Pfad + Kategorie-Flags; /time-accounts/ensure provisioniert mit denselben Limits wie die Modell-Zuweisung
geändert time-account-transaction.schema.ts, time-account.routes.ts. WHY: Der Selfservice-/HR-Buchungspfad schrieb seit seiner Einführung subType: 'MANUAL_OVERTIME' plus submittedBy/submittedAt — nichts davon stand im Schema (additionalProperties: false): Schema-Drift, die jede spätere AJV-Validierung dieser Dokumente hätte scheitern lassen. Jetzt deklariert, dazu additiv overtimeFlags (isOvertime/isWeekend/isEmergencyService, 1:1 vom markierten time_record kopiert) als Kategorie-Dimension für die unterschiedliche Vergütung der drei Überstunden-Typen — bewusst die drei Flags statt einer abgeleiteten Einzelkategorie, damit Kombinationen (Sonntags-Notdienst) verlustfrei bleiben und die Satz-/Stacking-Regel erst am Auszahlungspunkt angewendet wird (ADR 0438). Außerdem setzte /time-accounts/ensure (manueller Konto-Anlage-Pfad) alle Limits auf null, während ensureAccountsForAssignment für OVERTIME minBalanceMinutes: 0 vergibt — je nach Anlageweg konnte dasselbe Überstundenkonto also negativ werden oder nicht; die Route nutzt jetzt dieselben Werte (OVERTIME/LONG_TERM nicht-negativ, ANNUAL mit 12 Monaten Verfallsfrist). Alles additiv, Bestandsdaten unberührt, kein Backfill.
changed Ticket-Übernahme aus TANSS: zusätzlich zum Tag jetzt ein interner Kommentar im TANSS-Vorgang
geändert tanss-takeover.service.ts + Tests, ticket-takeover.routes.ts, integrations.ts, API.md. WHY: Aus dem Betrieb (04.08.2026): Beim Übernehmen wurde der Vorgang in TANSS bisher nur mit dem Tag CodemetaOS markiert — ein farbiges Etikett, das niemandem erklärt, was es bedeutet. Wer den Vorgang in TANSS öffnet, arbeitet weiter, als wäre nichts. Neu wird nach dem bestätigten Tag ein interner, angehefteter Kommentar geschrieben (POST /api/v1/tickets/{id}/comments, internal: true, ?pinned=true): „wird ab sofort in Codemeta OS bearbeitet“, Hinweis auf den Tag und darauf, dass dort keine Änderungen mehr übernommen werden, plus Codemeta-Ticketnummer, Deeplink (wenn APP_URL gesetzt) und Name des übernehmenden Mitarbeiters — in TANSS sonst nicht erkennbar, dort schreibt ein technischer API-Benutzer. Text ist bewusst deutsch (Inhalt im Fremdsystem, kein vom Frontend übersetzter API-Fehler). Bewusst best-effort und nach dem Tag: der Tag ist die fachliche Zusage, die Notiz Begleitinformation — ein Abbruch beim Kommentar hinterließe den Vorgang in TANSS markiert und in Codemeta trotzdem gesperrt, also schlechter als eine fehlende Notiz; Fehler werden geloggt, die Übernahme läuft weiter. Geschrieben wird nur bei einer frischen Markierung (alreadyTagged === false), sonst sammelte jeder Wiederholungsklick eine weitere identische Notiz an. Abschaltbar per Mandant über automationConfig.tanssSync.takeoverComment: false.
Behoben
fixed Anrufliste: Die Nummernsuche über alle Anrufe (callScope=all) starb bereits in der MongoDB-Planauswahl — blockierende Sortierung im find, kein Frühabbruch im withTotal-Count
geändert index-registry.ts, init.js, crud.routes.ts (+ Tests). WHY: Auto-Bugreport 05.08.2026 (Tenant Autohaus Eifel-Mosel, Fingerprint e_63aurb): MongoServerError 50 — error while multiplanner was selecting best plan auf ?q=004915143816391&callScope=all&withTotal=true&sort=startedAt. Gleicher Tenant, gleiche Sekunde und gleiche Wurzel wie e_skro4f (Eintrag unten), aber ein anderer Pfad: callScope=all setzt bewusst gar keinen Scope-Filter, der dortige createdBy-Index verengt hier also nichts, und gestorben ist der countDocuments (Promise.all Index 1), nicht der find — und zwar in der Planauswahl, nicht in der Ausführung. Übrig bleiben als Prädikate nur tenantId + deletedAt; damit ist jeder der ~15 tenantId-präfigierten Indizes von phone_calls ein Kandidat, und weil die Nummern-Klauseln unverankerte Regexes sind, produziert keiner davon schnell Treffer. Beide Operationen können deshalb den 101-Treffer-Frühabbruch des Multiplanners nicht auslösen — aus unterschiedlichen Gründen: der Count hat kein Limit, der find hätte eins, aber crud.routes hängt an jede List-Sortierung _id als Tiebreaker (Paging-Vertrag), die echte Sortierung ist also {startedAt:-1, _id:-1} und der Listen-Index endete auf startedAt:-1 → nur ein PRÄFIX der Sortierung → blockierende SORT-Stage → Ergebnis erst nach vollständigem Lesen. Jeder Kandidat läuft damit sein volles Trial-Budget aus und die 15 s maxTimeMS sind weg, bevor überhaupt ein Plan steht. Exakt die Pathologie des Assets-format=ids-Vorfalls vom selben Tag — und exakt der dort als offen notierte Punkt („der _id-Tiebreaker gilt für jede List-Query, aber nahezu alle Compound-Indizes enden ohne _id“), hier zum zweiten Mal eingeschlagen. Zwei Teile: (1) Index {tenantId, deletedAt, startedAt:-1} → {tenantId, deletedAt, startedAt:-1, _id:-1}. Damit liefert der Index die Sortierung vollständig, die SORT-Stage entfällt, die Liste streamt und bricht am Seitenlimit ab — das heilt den find-Pfad und nebenbei die normale, ungefilterte Anrufliste. ⚠️ Deploy-Hinweis (Prod): Der Reconciler legt den neuen Index automatisch an (anderer Schlüssel = anderer Name, kein 85/86-Konflikt); der alte tenantId_1_deletedAt_1_startedAt_-1 ist danach echtes Präfix des neuen und damit redundant — einmalig manuell db.phone_calls.dropIndex('tenantId_1_deletedAt_1_startedAt_-1'), sonst bleibt er als zusätzlicher Multiplanner-Kandidat liegen und ein Teil des Effekts verpufft. (2) hint auf dem Count, wenn auf phone_calls eine ?q=-Suche aktiv ist und callScope=all — ein Hint erzwingt genau einen Plan und überspringt die Trial-Phase komplett. Gehintet wird bewusst der COMMON_ENTITY_INDEX {tenantId, deletedAt, _id} statt des Listen-Index: der Count braucht keine Sortierung (dieselben Equality-Bounds genügen), und dieser Index existiert per Konstruktion in JEDER Datenbank — ein Hint auf einen erst beim Boot reconciliierten Index wäre selbst eine 500er-Quelle. Bei mine/team wird nicht gehintet: dort ist der Scope-$or über die {tenantId, <feld>, startedAt}-Indizes als OR-Vereinigung bedienbar, ein Hint wäre eine Verschlechterung. Der Count bleibt damit ein voller Tenant-Scan, aber einer statt fünfzehn Trial-Läufen. Drei zusätzliche Regressionstests (Hint bei all, kein Hint bei mine, kein Hint ohne Suchterm). Kein Backfill, keine API-Änderung. Die Hint-Logik liegt bewusst inline in crud.routes.ts statt in einem entity-hook: List-Query-Shaping VOR dem find ist die vom Hooks-Vertrag ausdrücklich noch nicht abgedeckte Kategorie (adjustListQuery ist als Erweiterung notiert).
fixed Anrufliste: Die Nummernsuche lief in einen 500er (?q=<Rufnummer>&callScope=mine) — der dritte Zweig des Scope-Filters hatte keinen Index und riss damit die Index-Nutzung des gesamten $or
geändert index-registry.ts, init.js, crud.routes.ts (+ neu crud.phone-calls-search.test.ts). WHY: Auto-Bugreport 05.08.2026 (Tenant Autohaus Eifel-Mosel, Fingerprint e_skro4f, von /telephony/calls): MongoServerError 50 — Executor error during find command: codemeta.phone_calls :: operation exceeded time limit auf GET /api/v1/phone_calls?q=004915143816391&callScope=mine&withTotal=true&sort=startedAt. Der Vorfall vom 21./22.07.2026 (e_63aurb) hatte bereits die teuerste Hälfte entschärft — ein reiner Ziffern-Term ersetzt seither die Standard-Suchfelder (inkl. des langen Freitexts aiSummary) durch kurze Nummern-/Id-Klauseln. Die Klauseln sind aber weiterhin unverankerte Regexes über callerNumber/calledNumber und die *Digits-Keys, und das ist beabsichtigt: die Rohformat-Fallbacks werden gebraucht, solange der Legacy-Backfill scripts/migrations/backfill-phone-call-search-keys.ts nicht überall gelaufen ist, und ein Substring auf einem Ziffern-Key ist per Konstruktion nicht index-seekbar. Die Suche kann sich also nie selbst verengen — die einzige indizierbare Verengung der Anfrage ist der callScope-Filter, und genau der war kaputt: callScope=mine baut $or: [{handledById}, {createdBy}, {rungUserIds}], für handledById und rungUserIds existieren {tenantId, <feld>, startedAt:-1}-Indizes, für createdBy nicht. MongoDB kann ein $or nur dann als OR-Vereinigung von Index-Scans bedienen, wenn jeder Zweig indizierbar ist; ein einziger Zweig ohne Index verwirft den Plan für das gesamte $or. Der Planner fiel damit auf {tenantId, deletedAt, startedAt:-1} zurück und prüfte die komplette Anrufhistorie des Tenants Dokument für Dokument gegen beide $ors — dazu parallel dasselbe nochmal als countDocuments für withTotal=true. Ergebnis: 15 s maxTimeMS (der Defense-in-depth-Deckel auf allen List-Queries) gerissen, 500 statt Ergebnis. Dass ausgerechnet createdBy fehlte, sah lange harmlos aus, weil praktisch jeder Anruf createdBy: 'starface-webhook' trägt und die Suche nach einer User-UUID dort nichts trifft — genau deshalb ist der Index aber billig und unverzichtbar: er muss nicht selektiv sein, er muss nur existieren, damit der OR-Plan überhaupt zustande kommt. Zwei Teile: (1) Index {tenantId, createdBy, startedAt:-1} (sparse) auf phone_calls in Registry + init.js; der Boot-Reconciler legt ihn automatisch an (neuer Schlüssel = neuer Name, kein 85/86-Konflikt), kein Ops-Schritt, kein Backfill. Damit bleibt der Scan auf die eigenen Anrufe des Nutzers beschränkt und die Regexes laufen über Hunderte statt Zehntausende Dokumente. Wirkt genauso für callScope=team. (2) Vorlauf-Query — der parties-Vorlauf, der Kunden-/Kontakt-Treffer in die Anrufsuche einfaltet, lief auch bei einem reinen Ziffern-Term mit fünf unverankerten Namens-Regexes plus dem searchText-Token-Zweig. Treffen können die nicht (looksLikePhoneQuery verlangt ≥3 Ziffern und keine Buchstaben, ein Name enthält so etwas nicht), sie kosten aber je Tastendruck einen vollständigen Scan über alle Parteien des Tenants, weil das .limit(201) bei null Treffern nie früh abbricht. Für Ziffern-Terme bleibt jetzt nur customerNumber übrig. Der ebenfalls indizierte parties.phoneDigits-Key wird hier bewusst nicht mitgesucht: wer eine Nummer eingibt, sucht die Anrufe mit dieser Nummer, nicht die komplette Anrufhistorie des Kunden, dem sie gehört. Vier Regressionstests nageln den Query-Aufbau fest (Scope-$or bleibt eigenständiger Top-Level-$or, Ziffern-Term ohne aiSummary/callerName/note, kanonischer Ziffern-Key 004915143816391 → 4915143816391, Namens-Term behält den vollen Parteien-$or). Offen (bewusst nicht mit angefasst): callScope=all hat keine Verengung und scannt bei einer Nummernsuche weiterhin die ganze Tenant-Historie. Der saubere Ausweg ist der in der Index-Registry beschriebene: Rohformat-Fallbacks streichen und auf ^-verankerte Ziffern-Key-Präfixe umstellen — das setzt den Backfill voraus (Moritz-freigabepflichtig) und opfert die Suffix-Suche, gehört also in einen eigenen Durchgang.
fixed Assets-Select-All: GET /assets?schema=asset.endpoint.pc&format=ids lief in einen 500er — der Index vom 24.07. deckte die Sortierung nicht ab
geändert crud.routes.ts (+ Tests), index-registry.ts, init.js. WHY: Auto-Bugreport 05.08.2026 (Tenant IT Fabrik, Fingerprint e_63aurb): MongoServerError 50 — error while multiplanner was selecting best plan :: operation exceeded time limit. Derselbe Vorfall wie am 23.07.2026, der mit dem Index {tenantId, schema, deletedAt, createdAt:-1} (Commit 025fae101) als behoben galt. Der Index existiert in Prod — er greift nur nicht: crud.routes.ts hängt seit dem Paging-Fix an jede List-Sortierung _id als Tiebreaker an (ohne ihn fallen Zeilen zwischen Seitengrenzen durch), die tatsächliche Sortierung ist also {createdAt:-1, _id:-1}. Nach dem Equality-Präfix bleibt vom Index nur {createdAt:-1} — ein Präfix der Sortierung, nicht umgekehrt, damit kann er sie nicht liefern. explain gegen Prod bestätigt: SORT über IXSCAN, limitAmount: 5001. Und genau deshalb schlägt der Fehler schon bei der Planauswahl zu: Eine blockierende Sortierung liefert erst nach vollständigem Lesen ein Ergebnis, der 101-Treffer-Frühabbruch des Multiplanners kann nie greifen, jeder der drei tenantId,schema,*-Kandidaten läuft sein volles Trial-Budget aus — die 15 s maxTimeMS (Defense-in-depth-Deckel auf allen List-Queries) sind weg, bevor überhaupt ein Plan steht. Passt zum Muster: die normale Assets-Liste (Limit 51, kleiner Top-K-Sort) lief, nur format=ids mit Limit 5001 kippte um. Zwei Teile: (1) Code — der Select-All-Pfad (ADR 0221) reicht die IDs an den Bulk-Endpunkt weiter, der sie in Chunks abarbeitet; die Reihenfolge ist für jeden Konsumenten bedeutungslos. format=ids sortiert deshalb nur noch nach _id und umgeht auch die Aggregations-Sortierpfade (Enum-Rang, Joined-Sort, Case-insensitive) — streambar aus jedem tenant-präfigierten Index, keine blockierende Stage, und die Auswahl bleibt bei Truncation trotzdem deterministisch (welche 5000 Zeilen zurückkommen), was ein sortierloses find nicht wäre. Wirkt sofort mit dem Deploy, ohne Ops-Schritt. (2) Index — die Registry-/init.js-Definition bekommt das fehlende _id: -1 ({tenantId, schema, deletedAt, createdAt:-1, _id:-1}), damit auch die reguläre paginierte Assets-Liste ohne SORT-Stage auskommt; sonst fällt sie beim nächsten Wachstumsschritt genauso um. ⚠️ Deploy-Hinweis (Prod): Der Reconciler legt den neuen Index automatisch an (anderer Schlüssel = anderer Name, kein 85/86-Konflikt); der alte tenantId_1_schema_1_deletedAt_1_createdAt_-1 ist danach ein echtes Präfix des neuen und damit redundant — einmalig manuell db.assets.dropIndex('tenantId_1_schema_1_deletedAt_1_createdAt_-1'), das entfernt nebenbei einen Multiplanner-Kandidaten. Kein Backfill, keine API-Änderung. Vier Regressionstests sichern den Sortier-Vertrag von format=ids (inkl. Gegenprobe, dass die normale Liste ihren createdAt/_id-Tiebreaker behält). Offen als eigener Durchgang: Der _id-Tiebreaker gilt für jede List-Query, aber nahezu alle Compound-Indizes der Registry enden ohne _id — bei kleinen Ergebnismengen fällt das nie auf, bei der nächsten hochkardinalen Kombination schon.
fixed Buchungs-Analytics: GET /bookings/analytics/summary warf für jeden Tenant mit mindestens einer Buchung 500
geändert booking-analytics.routes.ts, neu date-from-string-guard.test.ts. WHY: Auto-Bugreport 05.08.2026 (Tenant IT Fabrik, Fingerprint e_10zwir7, von /calendar/booking-links): MongoServerError 241 — $dateFromString requires that 'dateString' be a string, found: date with value 2026-07-02T09:13:08.363Z. Die Vorlaufzeit-Kennzahl (avgLeadTimeDays) rechnete startAt − createdAt und parste beide Felder mit $dateFromString. Das ist nur für startAt richtig: Das JSON-Schema deklariert es als string/date-time und alle Schreibpfade liefern ISO-Strings. createdAt dagegen wird von entity.service.ts (createdAt: now, new Date()) als echtes BSON-Date geschrieben — die Feld-Deklaration im Client-Schema sagt darüber nichts. $dateFromString toleriert keinen Date-Input, sondern wirft und bricht damit die gesamte Aggregation ab: ein einziges Dokument genügt, der Endpunkt war also nicht sporadisch, sondern dauerhaft kaputt, sobald überhaupt eine Buchung existierte (die vier Nachbar-Endpunkte funktionierten, weil sie nur startAt parsen). Fix: neuer lokaler Helper asDate() mit { $convert: { input, to: 'date', onError: null, onNull: null } } — $convert akzeptiert Strings und Dates und liefert bei kaputten Werten null statt zu werfen; $subtract/$divide propagieren das null, $avg ignoriert es, eine unlesbare Zeile kostet also einen Datenpunkt statt des ganzen Requests. by-service/by-technician brauchten nichts (keine Datums-Konvertierung); popular-slots und trend wurden trotzdem mit umgestellt und bekommen ein $match: { parsedStart: { $ne: null } }, damit ein einzelnes fehlerhaftes startAt dort künftig ebenfalls nur die eine Zeile aussortiert. Zusätzlich der Guard-Test date-from-string-guard.test.ts: $dateFromString auf $createdAt/$updatedAt/$deletedAt ist in apps/api/src und apps/worker/src ab sofort verboten (Null-Toleranz, keine Ausnahmeliste) — diese Felder sind immer BSON-Dates, jede solche Stelle ist per Konstruktion ein 500. Keine API-Änderung, kein Backfill; die Kennzahlen erscheinen mit dem Deploy erstmals überhaupt.
fixed Doppelter Unique-Key (Mongo E11000) kam als 500 „unerwarteter Fehler“ heraus und erzeugte zusätzlich einen Plattform-Bug
geändert problem.ts (+ Tests). WHY: Eine Verletzung eines Unique-Index ist ein Konflikt, kein Serverfehler. In Produktion traf das POST /api/v1/activities gegen den Unique-Index {tenantId, sourceType, sourceEntityId} (Aktivität zu einem Telefonat protokollieren, das bereits eine hatte): Der Nutzer bekam einen nichtssagenden 500-Toast, und weil app.ts jeden 5xx als Plattform-Defekt einsammelt, entstand für ganz normale Doppel-Eingabe zusätzlich ein Auto-Feedback-Eintrag im Support-Panel.
toProblem() mappt err.code === 11000 jetzt auf ein RFC-7807-409 mit errorCode: 'DUPLICATE_KEY' — gematcht am numerischen Treibercode, damit problem.ts keinen mongodb-Import braucht (dieselbe Technik wie bei ReferenceIntegrityError/LlmProviderUnavailableError). Die rohe Treibermeldung wird bewusst nie weitergereicht: sie enthält Collection, Indexnamen und die kollidierenden Key-Werte inklusive tenantId. Stattdessen werden nur die Feldnamen gemeldet, und auch nur, wenn der Index der Konvention feld_1_feld_1 folgt (tenantId wird als implizit herausgefiltert); ein handbenannter Index wie uniq_booking_slot_lock fällt auf den generischen Wortlaut zurück, statt den Indexnamen zu leaken. Frontendseitig greift das bereits vorhandene DUPLICATE_KEY→errors.duplicateKey-Mapping in client.ts; der generische CRUD-Schreibpfad hat mit duplicateKeyProblem() (crud.routes.ts) eine eigene, spezifischere Behandlung — toProblem() deckt jetzt zusätzlich alle übrigen Routen ab. Die Änderung gilt plattformweit für jeden Unique-Index, nicht nur für activities. Drei Tests in problem.test.ts decken Feldnamen-Extraktion, Nicht-Leaken und den generischen Fallback ab. Kein Backfill.
fixed Anrufe: Kunde und Ansprechpartner werden jetzt automatisch verknüpft — das Rufnummern-Matching traf den Großteil des CRM nie
geändert phone-party-resolve.ts (+ neu phone-party-resolve.test.ts), webhook-ingress.routes.ts, phone-call-import.routes.ts (+ Tests), neu phone-call-link-suggestion.routes.ts, app.ts, neu PhoneCallLinkSuggestion.tsx, PhoneCallDetailPage.tsx, de.json/en.json. WHY: Feature-Wunsch 31.07.2026 (Tenant IT Fabrik, Anruf 019fb715-…): „Kunde und AP wurden identifiziert, dann sollte auch automatisch verknüpft werden.“ Der Ingest versuchte die Verknüpfung längst — sie schlug nur still fehl, und zwar für praktisch jede weclapp-synchronisierte Partei. Zwei unabhängige Defekte: (1) phoneMatchRegex baute aus den letzten acht Ziffern ein zusammenhängendes Muster (/36336690/) und ließ es gegen contactChannels.value laufen — Nummern liegen aber in gespaltener internationaler Notation (+49 2363 3669-0), ein Trenner fällt mitten ins Fenster und der Treffer bleibt aus. (2) Der schnelle Pfad über den vorberechneten phoneDigits-Key hilft nur Dokumenten, die den Key haben; weclapp-gesyncte Parteien haben ihn nicht (am Live-Datensatz „ONZ Services eGbr“, Kd. 52909, +49 2363 3669-0 verifiziert). Ergebnis: partyId blieb null, obwohl die Nummer im CRM stand, und der Nutzer sah einen Namen im Hero (der kommt aus dem Starface-Telefonbuch, nicht aus Codemeta) neben einem leeren „Verknüpfter Kunde“. Ersetzt durch tolerantPhoneRegex, das Nicht-Ziffern zwischen den signifikanten Ziffern erlaubt (3[^0-9]*6[^0-9]*3…) und damit jede gespeicherte Schreibweise trifft, ohne Ziffern zu überspringen — …3669-140 matcht weiterhin nicht auf …3669-0. Die Exaktheitsprüfung passiert danach in JS über die Kandidaten-Dokumente, statt sich auf ein evtl. fehlendes Feld zu verlassen; phoneDigits bleibt reiner Beschleuniger, kein Backfill nötig. Zweiter Teil: der Treffer landet jetzt im richtigen Slot. partyId ist der Kunde (eine ORGANIZATION — die Detailseite reicht ihn dem AP-Picker als organizationPartyId durch), contactPartyId die Person dort. Die alte Suche gab genau eine Partei zurück und schrieb sie immer nach partyId, sodass eine Kontakt-Rufnummer eine PERSON in den Kunden-Slot legte und „Ansprechpartner“ dauerhaft leer blieb (dort stand contactPartyId: null sogar hart verdrahtet). Der neue Resolver liefert beide Slots: Person → AP + Arbeitgeber aus der eindeutigen aktiven party_relationship; Organisation → nur Kunde; mehrdeutig → gar keine Verknüpfung (ein falscher Kunde ist schlimmer als keiner, weil ein gefüllt aussehendes Feld niemand nachprüft). Der bereits existierende Read-Time-Resolver für die Kundenakte-Timeline hatte denselben Regex-Defekt und wurde mit umgestellt — beide Pfade teilen sich jetzt eine Datei. Der Teams-Pfad bleibt bewusst unangetastet: dort stehen userPrincipalNames statt Rufnummern in den Nummernfeldern, eine Telefon-Auflösung wäre dort sinnlos. Bestandsdaten (der gemeldete Anruf inklusive) bleiben ohne Rewrite unverknüpft, deshalb zusätzlich GET /api/v1/phone-calls/:id/link-suggestion: löst die Nummer zur Lesezeit neu auf und bietet den Treffer auf der Detailseite als Ein-Klick-Vorschlag an (gespeichert wird erst bei Bestätigung, ein Zugriff pro Aufruf, data-scope-geprüft). Damit wird der Altbestand aufholbar, ohne einen Batch-Write gegen Live-Daten — bewusst als Vorschlag statt stiller Auto-Verknüpfung.
fixed Globale Suche: Namens-Treffer konnten aus dem Kandidatenfenster des Unified-Index verdrängt werden, bevor sie je gerankt wurden
geändert mongo-engine.ts + neu mongo-engine.test.ts. WHY: Zweiter Teil des „desitin“-Reports 05.08.2026: Ranking kann kein Dokument retten, das nie Kandidat wurde. Die Kandidaten-Fetches der Engine sind unsortiert (natürliche _id-Reihenfolge) und gedeckelt (60 für Selector-Queries, 600 für die Palette) — ein Kunde mit vielen Mitarbeitern, deren E-Mail-Domain die Query enthält (@desitin.de fließt in den searchText), füllt das Fenster, bevor das Firmen-Dokument selbst gelesen wird; die Firma fehlte dann komplett, obwohl sie das Ranking angeführt hätte. Fix nach exakt dem Muster, mit dem die Engine bereits Precise-Treffer vor dem Trigram-Widener schützt („heisel findet nichts“-Bug): eine Identity-Stufe davor — trigrams: { $all: <Query-Trigramme> }. Das trigrams-Feld wird ausschließlich aus den Identitätsfeldern gebaut (FUZZY_FIELDS: name/title/…), ein Dokument mit ALLEN Query-Trigrammen trägt die Query also im Namen, nicht bloß in E-Mail/Adresse. Die Stufe bekommt bewusst nur das halbe Kandidatenbudget, damit reine E-Mail-/Telefon-Treffer weiterhin garantierten Platz behalten; Folge-Stufen schließen bereits geholte Ids per $nin aus. Kein Pendant im Legacy-Fan-out nötig: dessen Fenster ist mit 200 pro Collection (statt geteilt über alle Typen) deutlich größer, und eine dritte Query pro Collection und Tastendruck wäre auf dem 75-Collection-Fan-out ein echter Latenzpreis. Kein Backfill, keine Index-Änderung (der trigrams-Multikey-Index existiert).
fixed Globale Suche: partyType-/hasIntegration-Filter gingen auf Unified-Search-Tenants still verloren — Personen im „Nur Organisationen“-Kunden-Selector
geändert search.routes.ts + Tests. WHY: Bug-Report 05.08.2026: Im Vertriebschancen-Dialog listete das Kunde-Feld (das per partyTypeFilter="ORGANIZATION" ausschließlich Firmen zeigen soll) bei der Suche nach „desitin“ nur Personen mit @desitin.de-Mailadresse — die Firma „Desitin Arzneimittel GmbH“ fehlte komplett, obwohl sie existiert, korrekten searchText hat und das Ranking Organisationen ohnehin vor Personen einsortiert (searchItemPriority 110 > 100). Ursache: GET /search reicht partyType und hasIntegration nur an den Legacy-Fan-out durch (findFilter.partyType); der Unified-Index-Pfad (ADR 0408, Tenant-Flag searchEngineV2) bekam beide Parameter nie — und könnte sie auch nicht anwenden, denn search_documents trägt weder partyType noch externalReferences. Auf geflippten Tenants wurden die Selector-Filter damit stillschweigend verworfen; zusätzlich verdrängten die vielen Personen-Treffer (E-Mail-Domain fließt in den searchText) die Firma aus dem unsortiert gefüllten Kandidatenfenster des Index (60 Dokumente in _id-Reihenfolge), sodass sie nie gescored wurde. Fix nach dem bestehenden Refusal-Muster des Scope-Translators („was der Index nicht beweisen kann, geht an Legacy“): Sobald partyType oder hasIntegration gesetzt ist, wird parties vom Unified-Pfad ausgenommen und in den Legacy-Fan-out geroutet, der beide Filter zur Find-Zeit anwendet; alle anderen Entity-Typen der Anfrage bleiben auf dem Index. Betrifft neben dem Vertriebschancen-Dialog auch den Angebots-Dialog (weclapp-verankerte Parties via hasIntegration=weclapp). Zwei Regressionstests decken den reinen Selector-Fall (entities=parties → Index wird gar nicht konsultiert) und den gemischten Fall (parties,tickets → nur parties fällt zurück) ab. Kein Backfill, keine Index-Änderung; die strukturelle Alternative (partyType ins SearchDocument-Schema + Mapper-Version-Bump) ist als späterer Rückbau möglich.
fixed Automations-Builder: Klick auf einen Lauf in der Ausführungs-Historie schluckte Netzwerkfehler still und erzeugte stattdessen einen HIGH-Auto-Bugreport
geändert ExecutionHistoryPanel.tsx, ExecutionLogPanel.tsx. WHY: Bug-Report 04.08.2026 (GlitchTip e_u2228q, Fingerprint TypeError: Failed to fetch auf /settings/automations/:id). selectInstance in der Historien-Leiste war als try { … } finally { … } ohne catch geschrieben und ruft api.get mit silent = true auf — der API-Client unterdrückt in diesem Modus seinen eigenen Netzwerk-Toast und wirft nur. Der Aufrufer ist onClick={() => selectInstance(instanceId)}, verwirft das Promise also; jede fehlgeschlagene Anfrage wurde damit zur unhandledrejection, die der globale Reporter (error-reporter.ts) als HIGH-Auto-Feedback anlegte. Für den Nutzer war der Klick gleichzeitig völlig stumm: kein Toast, kein Fehlerzustand, nur der Spinner ging aus. Auslöser im gemeldeten Fall war eine reine Konnektivitätsstörung — die Breadcrumbs zeigen 20 GET /api/v1/public/maintenance/active → network error innerhalb von ~10 ms (der aufgestaute 60-s-Poll aus maintenance.store.ts nach Standby/VPN-Reconnect), 23 s vor dem Klick. Die vorhandene Unterdrückung solcher Meldungen (chunk-errors.ts isTransientLoadError) griff nicht, weil sie für „Failed to fetch“ zusätzlich navigator.onLine === false verlangt — bei totem DNS/VPN meldet Chrome weiter online: true. Fix: catch in selectInstance und im gleichartig gebauten loadOutputs des Log-Panels (onLoad des „Daten laden“-Prompts), das den Fehler mit errors.network als Toast sichtbar macht; der Toast-Store dedupliziert identische Nachrichten, ein Ausfall-Burst ergibt also weiterhin eine einzige Meldung. Deckt zugleich den Auto-Select-Pfad ab (void selectInstance(…, { graphOnly: true }) beim Öffnen des Panels), der dieselbe Rejection erzeugte. Die Bewertungsregel des globalen Reporters bleibt bewusst unangetastet — ein „Failed to fetch“ bei echter Online-Verbindung kann ein reales CORS-Problem sein und soll weiter gemeldet werden. Keine neuen i18n-Keys, keine API-Änderung, kein Backfill.
fixed Vertrags-Ticket-Regeln: Wiederholungen laufen jetzt kalendarisch, und ausgewählte Vorlagen wurden ohne Namen angezeigt
geändert contract-ticket-generate.service.ts + Tests, ContractTicketRuleDialog.tsx, ContractTicketsTab.tsx, 0439-contract-position-ticket-rules.md, de.json/en.json. WHY (Rhythmus): „Monatlich“ war an den Tag des Startdatums gebunden — Start am 04.08. ergab 04.09., 04.10. usw., inklusive Monatsend-Klemmung (31.01 → 28.02 → 31.03). Für Wartungsrhythmen ist das die falsche Verankerung: gemeint ist „im August, im September“, nicht „alle 30 Tage ab dem Vierten“. addIntervalIso rechnet deshalb nicht mehr Startdatum + N Monate, sondern springt auf den Anfang des nächsten Kalender-Zeitraums — 1. des Monats, 1. Jan/Apr/Jul/Okt (Quartal), 1. Jan/Jul (Halbjahr), 1. Januar (jährlich und die Mehrjahres-Takte), wöchentlich der folgende Montag. Die erste Fälligkeit bleibt bewusst das bei der Aktivierung gewählte Startdatum: der Picker ist mit „Startdatum (erste Fälligkeit)“ beschriftet, ein stilles Verschieben um bis zu eine Periode wäre die unangenehmere Überraschung. Der Parameter anchorDay entfällt damit ersatzlos; die Monatsend-Klemmung wird gegenstandslos, weil der Zieltag immer der Erste ist. Bereits gespeicherte Cursor werden nicht umgeschrieben — sie wandern beim nächsten Lauf von selbst auf den Periodenanfang. WHY (Vorlagen-Chips): Beim erneuten Öffnen einer Regel erschienen die ausgewählten Checklisten- und Aufgaben-Vorlagen (und der Bearbeiter) als leere Pillen mit bloßem „ד. GET /api/v1/:resource/:id antwortet mit { data: doc }; resolveByIds reichte die Hülle an die Multi-Selektoren weiter, deren getDisplayText folglich auf undefined traf und einen Leerstring lieferte — die Auswahl war korrekt gespeichert, nur unlesbar. Jetzt wird .data ausgepackt (Listen-Endpunkte lieferten von Anfang an das richtige Feld, deshalb trat der Fehler nur im Bearbeiten-Fall auf).
fixed TANSS-Sync: neu angelegte Tickets kamen nie an — nicht verzögert, sondern dauerhaft
neu 0440-tanss-new-ticket-discovery.md; geändert tanss-sync.ts + Tests, integrations.ts. WHY: Ein in TANSS sichtbares Ticket (#293328, angelegt 04.08. 08:55 UTC) fehlte im Spiegel, obwohl der Sync nachweislich gesund lief — lückenlose Erfolgsläufe alle 20 Minuten, lastSyncError: null, Cursor 6 Minuten alt, und die höheren, also später angelegten Ids 293330/293331 kamen im selben Lauf an. Ausgeschlossen wurden der Reihe nach: Zeitfenster, Zeitzonenversatz, Sichtbarkeit des API-Users (143 unzugewiesene Tickets kamen an), fehlende Firma (42 gespiegelte Tickets tragen partyId: null), der Projekt-Ticket-Drop im Transformer (project: false) und ein Überspringen im Upsert (skipped: 0). Die direkte TANSS-Abfrage auf das Fenster 08:43:20–09:08:20 lieferte dann den Befund: vier deutlich ältere Tickets, die in diesem Fenster bearbeitet wurden — und nicht #293328, obwohl das im Fenster angelegt und im Fenster auf „Kontaktaufnahme“ gesetzt wurde. modifiedWithinTimeframe wertet also weder die Anlage noch eine Statusänderung als Änderung; ein Delta-Pull kann Neuanlagen damit prinzipiell nicht sehen, und ein per E-Mail importiertes Ticket, das niemand anfasst, fehlt für immer (im Messbereich 293300–293335: 16 von 36 Ids). Ein createdWithinTimeframe gibt es in der Spec nicht. Fix: zwei zusätzliche Quellen pro Lauf, beide ohne Zeitfenster und beide durch denselben idempotenten upsertTanssMirror (doppelte Zustellung kostet nur ein unchanged). (1) Offener Pool — GET /api/v1/tickets/general („all open tickets which are not assigned to any employee“; verifiziert: #293328 steht darin), begrenzt durch „offen UND unzugewiesen“ statt durch einen Filter, mit Warnschwelle bei 500 Einträgen, damit die Größenannahme nicht still verfällt. Diese Quelle holt zugleich den bisher übersprungenen Altbestand nach. (2) Id-Nachlauf — der Pool deckt nur offene, unzugewiesene Tickets ab; ein sofort zugewiesenes und nie bearbeitetes stünde weiterhin nirgends. TANSS-Ids sind aufsteigend, also fragt der Sync pro Lauf { ids: [n+1 … n+250], includeDoneTickets: true } — die Id-Spanne ist die Bound, die dem Zeitfenster fehlt. Der Cursor lastSeenTicketId rückt ausschließlich auf tatsächlich gesehene Ids vor: die Spanne wird vorwärts geraten, nicht abgefragt, ein blindes Vorrücken um die Batchgröße würde also Tickets überspringen, die erst nach dem Lauf entstehen; eine leere Spanne darf nur unterhalb einer im selben Lauf belegt existierenden Id übersprungen werden (löst zugleich das Hängenbleiben an Id-Löchern des mit anderen Objekttypen geteilten Nummernkreises). Startwert ist die höchste bereits gespiegelte Id — der Nachlauf sichert Neuzugänge ab Einführung, für den Altbestand ist der Pool zuständig; ein tieferer Start liefe zehntausende Ids vor cutoffDate ab. Ohne jeden Anker setzt er aus, statt bei Id 1 loszulaufen. Refactoring nebenbei: gemeinsamer Spiegel-Kern (resolveMirrorTarget + mirrorRecords) für alle drei Quellen statt dreifacher Kopie, und Statusnamen ohne Zuordnung werden einmal pro Lauf gemeldet statt je Zeitscheibe. Kein Backfill — reines Laufzeitverhalten, alle Konfigurationsfelder optional mit sicheren Defaults.
fixed Veranstaltungen: Sessions ließen sich überhaupt nicht mehr bearbeiten — jedes Speichern brach mit „Use POST /:resource/:id/transition to change status“ ab
geändert crud.routes.ts. WHY: In Prod gemeldet (05.08.2026): Der Dialog „Session bearbeiten“ quittierte jeden Speichern-Klick mit einer roten Fehlermeldung, unabhängig davon, welches Feld geändert wurde — Titel, Uhrzeit, Beschreibung, alles. Ursache ist eine Lücke zwischen zwei Annahmen. Der generische PATCH-Pfad in crud.routes.ts blockt jede Anfrage, die status im Body trägt, und verweist auf den Übergangs-Endpunkt; ausgenommen sind per Allowlist genau die Entitäten mit trivialem Lebenszyklus ohne registrierte Übergänge (articles, service_catalog_items, announcements, …). event_sessions gehört fachlich in diese Gruppe — status ist dort keine Workflow-Phase, sondern schlicht die Sichtbarkeit auf der öffentlichen Event-Seite (DRAFT/PUBLISHED/CANCELLED, siehe event-session.schema.ts) — stand aber nicht in der Allowlist. Der SessionDialog schickt wie jede Bearbeitungsmaske im Projekt das vollständige Formular, also immer auch status, und lief damit selbst dann in den Wächter, wenn die Sichtbarkeit unverändert blieb. Warum der Hinweis in der Fehlermeldung nicht weiterhalf: Für event_sessions sind gar keine Übergänge registriert — POST /:resource/:id/transition hätte ebenfalls abgelehnt. Es gab also keinen funktionierenden Weg, eine bestehende Session zu ändern; nur das Anlegen funktionierte, weil der Wächter ausschließlich auf dem PATCH-Pfad sitzt. Fix: event_sessions in die Allowlist aufgenommen, mit derselben Begründung wie articles. Bewusst nicht der Weg über den Dialog („status nur senden, wenn geändert“): das hätte den Fehler nur verdeckt und den Sichtbarkeits-Umschalter im Dialog dauerhaft funktionslos gelassen. Der Zugriff bleibt über die generische event_session_edit-Berechtigung gedeckelt, es kommt keine Prüfung weg. Kein Backfill — reine Wächter-Lockerung, Bestandsdaten bleiben unberührt.
fixed Wiederkehrende Aufgabe ohne Fälligkeitsdatum war nach dem ersten Abhaken still beendet
geändert recurrence.ts + Tests, task.hooks.ts + Tests, entity-hooks/index.ts, lib/complete-task.ts + Tests (in derselben Änderung aus features/tasks/complete-recurring-occurrence.ts hierher gezogen — siehe folgender Eintrag). WHY: Aus dem Betrieb (04.08.2026): „Aufgabe mit täglicher Wiederholung anlegen, abhaken — es taucht keine neue, nicht abgeschlossene Aufgabe auf.“ Reproduziert am Prod-Datensatz „Rechnungskontrolle Einkauf“ (DAILY/1, angelegt 09:02:05, 13 s später status: DONE und recurrence.endDate: '2026-08-04'). Ursache: Die gesamte Serien-Mechanik (ADR 0094d) hängt an getMasterAnchor, das ausschließlich scheduledStartAt || dueDate las — und keine der beiden Anlage-Oberflächen verlangt ein Fälligkeitsdatum, wenn man die Wiederholung einschaltet. Ohne Anker griff in completeRecurringOccurrence ein Fallback, der statt der Occurrence den Master auf transitions/DONE schickte; der DONE-Handler (transition.routes.ts) interpretiert „Master DONE“ als „Serie beendet“ und stempelt recurrence.endDate = heute. Die Serie war damit endgültig tot — und die UI meldete das mit Konfetti und „Erledigt — Serie abgeschlossen“ auch noch als Erfolg. Drei Teile: (1) getMasterAnchor fällt auf createdAt zurück (Anker-Kette Zeit-Slot → Fälligkeit → Anlagetag). Das heilt Bestandsdaten ohne Backfill und wirkt gleichzeitig im Client und im serverseitigen advance-series. Kein Nebeneffekt im Kalender: dessen Expander (expandRecurringTask in CalendarPage.tsx) prüft scheduledStartAt || dueDate selbst und ruft den Helfer nicht auf, ankerlose Serien tauchen dort also weiterhin nicht als virtuelle Occurrences auf. Der Anlagetag als erste Occurrence ist dieselbe Annahme, die isValidOccurrenceDate mit createdAt als unterer Schranke ohnehin trifft — das Occurrence-Raster verschiebt sich nicht. (2) Server-Default statt UI-Guard: neuer beforeCreate im tasks-Entity-Hook setzt dueDate = heute, wenn eine recurrence ohne jeden Datums-Anker ankommt. Bewusst defaulten statt mit 400 ablehnen — ein Pflichtfeld, das die Oberfläche nicht als solches ausweist, wäre eine Sackgasse; und bewusst serverseitig, weil ein Dialog-Guard die anderen Schreibpfade (To-Do-Sync, Workflow-Aktionen, API-Clients) nicht abdeckt. Materialisierte Occurrences (seriesMasterId) bleiben unangetastet. (3) Kein stiller Serien-Abbruch mehr: der ankerlose Fallback wirft jetzt, statt den Master abzuhaken — der Aufrufer zeigt den Fehler-Toast, die Aufgabe bleibt offen. Ein Fehlschlag, den man sieht, ist besser als ein „Erfolg“, der die Serie löscht. Kein Backfill, aber bereits still beendete Serien reparieren sich nicht von selbst: dort muss recurrence.endDate entfernt und der Status zurückgesetzt werden.
fixed Sync-Log der Integrationen antwortete mit 500, sobald eine Integration mehr als 20 Einträge hatte
neu iso-cursor.ts + Tests; geändert integration.routes.ts, tanss-sync.ts. WHY: Automatisch erfasster Bug-Report aus Prod (04.08.2026, IT Fabrik, /settings/integrations): Aufklappen des Sync-Logs → GET /api/v1/integrations/:id/sync-log?limit=20 → 500. Ursache: integration_sync_log.startedAt ist über die Writer hinweg uneinheitlich typisiert — alle weclapp-/TeamViewer-/Import-Pfade schreiben ein BSON-Date, der TANSS-Sync schrieb startedAt.toISOString(), also einen String. Der Handler berechnete den Next-Cursor mit data[last].startedAt.toISOString() direkt auf dem Rohwert; auf den String-Zeilen existiert die Methode nicht → TypeError → 500. Der Cursor wird nur bei hasMore berechnet, deshalb schlug es exakt ab dem 21. Log-Eintrag einer TANSS-Integration um und wirkte vorher unauffällig. Drei Teile: (1) neuer Helfer toIsoCursor() (eigenes Modul statt Inline-Funktion in der 137k-Zeilen-Route, damit testbar ohne den halben Abhängigkeitsgraphen zu laden) normalisiert Date/String/Epoch-Millis und liefert bei Müll null, statt zu werfen. (2) Der Cursor-Filter war aus demselben Grund nicht nur nutzlos, sondern falsch: In der BSON-Typordnung steht String vor Date, also erfüllte jede String-Zeile ein { $lt: new Date(cursor) } unabhängig vom Zeitpunkt — Seite 2 hätte Seite 1 wiederholt. Jetzt typunabhängiger Vergleich über $expr + $convert … to: 'date' mit onError/onNull: null. (3) tanss-sync.ts schreibt startedAt künftig als Date — das ist nicht nur Konsistenz: der TTL-Index auf startedAt (90 Tage, init.js) greift ausschließlich bei Date-Werten, die TANSS-Zeilen wären also nie abgelaufen. Kein Backfill: Bestandszeilen bleiben Strings, der Endpoint toleriert beide Formen dauerhaft; die Alt-Zeilen laufen nicht per TTL ab, sind aber ein reines Betriebsprotokoll ohne fachliche Wirkung.
fixed Zeitkonto-Selfservice verglich zwei ID-Räume, die nie matchen — /me-Routen liefer(t)en leer, eigener userId-Param ergab 403
geändert time-account.routes.ts. WHY: time_accounts.userId hält die tenant-scoped users._id (UUIDv7), aber neun Stellen der Datei filterten bzw. verglichen mit request.userId — der better-auth-Session-Id (24-hex). Per read-only DB-Check verifiziert (0 von 3 Dev-Konten matchen eine Auth-Id): /me/time-accounts, /me/time-accounts/:id/transactions und /me/time-report liefen für jeden Nutzer ins Leere; in /flextime-overview, /overtime-overview, /overtime-proposals und /time-accounts/annual-progress kippte der isSelf-Check in die falsche Richtung — wer seine eigene interne ID übergab, galt als fremd und bekam ohne time_account_view_all einen 403. Unauffällig blieb das, weil die HR-Tabs explizit interne ?userId= übergeben (dieser Pfad war korrekt); kaputt war nur die Selbst-Sicht — das dokumentierte „silent no-match“-Muster aus apps/api/CLAUDE.md. Alle Stellen nutzen jetzt (request.internalUserId ?? request.userId); der 4-Augen-Check in approve/reject vergleicht submittedBy tolerant gegen beide ID-Räume, weil Bestands-PENDINGs vor diesem Fix die Auth-Id tragen können (Runtime-Fallback, kein Backfill). approvalDecisionBy/updatedBy stempeln damit ebenfalls die interne ID.
fixed Anrufauswertung-PDF: „Verpasst: 0“ bei jedem Team, weil verpasste Anrufe in „Ohne Team“ landeten
geändert phone-call-pdf.service.ts (PDF-Export der Anrufauswertung) und phone-call-report-pdf.service.ts (wöchentlicher Report). WHY: Beide Reports gruppierten Anrufe ausschließlich über handledById → Team. Ein verpasster Anruf hat aber per Definition niemanden, der abgehoben hat — der Import setzt handledById bei INBOUND nur, wenn der letzte geklingelte Schritt ein auflösbarer USER war; bei Gruppen-/Queue-Klingeln (der Normalfall im Servicedesk) bleibt es null. Ergebnis: praktisch alle verpassten Anrufe sammelten sich unter „Ohne Team“, jedes echte Team zeigte „Verpasst: 0“. Fix: Anrufe ohne handledById werden jetzt über die callFlow-Ring-Attribution zugeordnet (gleiche Shared-Helper extractPickupSteps/extractRingingAccountIds wie das Dashboard): erst wer abgehoben hat, dann die geklingelten Mitglieder in Flow-Reihenfolge; das erste auflösbare Mitglied mit Team gewinnt (Single-Attribution — jeder Anruf zählt in genau einem Team, Summen bleiben konsistent; das Dashboard zählt bewusst multi-attributiv pro geklingeltem Mitarbeiter, daher können Team-Zahlen dort höher sein). OUTBOUND ohne Handler löst analog über callerAccountId auf. Bei verpassten Anrufen wird bewusst kein Handler-Name in der Anrufliste angezeigt (Team-Zuordnung ja, aber niemand hat den Anruf „bearbeitet“). Zusätzlich geschlossen: die dokumentierte handledById-Dual-Id-Space-Lücke (users._id oder employee_profiles._id für Telefonie-only-Mitarbeitende) — employee_profiles werden jetzt per userId und _id aufgelöst, damit Anrufe von Mitarbeitenden ohne Login nicht mehr in „Ohne Team“ fallen; deren Anzeigename kommt aus dem Profil. Kein Backfill — reine Query-/Aggregationsänderung zur Report-Zeit, keine persistierten Daten betroffen.
Sicherheit
fixed 🔴 Ticket-Exchange: Jira-Webhook UND 5-Minuten-Sweep waren faktisch auf einen Stundentakt gedrosselt, plus drei weitere Phase-1-Defekte
geändert webhook-ingress.routes.ts, link.ts, outbound-job.ts, index-registry.ts, init.js, neue Tests in connection.test.ts (3). Vier Befunde aus der Bestandsaufnahme zu ADR 0363:
- Job-ID-Kollision zwischen Webhook und Sweep. Der Sweep reiht Reconciles unter
jobId: tex-<connectionId>mitremoveOnComplete: trueein und begründet in acht Kommentarzeilen, warum eine Aufbewahrung den 5-Minuten-Takt zerstören würde: BullMQ dedupliziert gegen jede noch existierende Job-ID, auch gegen eine bereits erledigte. Der Jira-Webhook nutzte dieselbe Job-ID — aber mitremoveOnComplete: { age: 3600 }. Nach dem ersten erledigten Reconcile wurde damit eine Stunde lang jede weitere Einreichung derselben Verbindung still verworfen: die elf folgenden Sweep-Takte und jeder weitere Webhook-Ping. Beide Mechanismen liefen also höchstens stündlich, ohne Fehlermeldung. JetztremoveOnComplete: trueauf beiden Seiten; die gewollte Deduplizierung („kein zweiter Reconcile, solange einer läuft“) bleibt. softDeleteLinkignoriertesystem. Der Eindeutigkeits-Index ist{tenantId, ticketId, system}— ein Ticket darf gleichzeitig an einem Jira-Vorgang und (ab Phase 2) an einem Peer-Tenant hängen. DasupdateOneohnesystem-Filter erwischte davon eine willkürlich und ließ die andere stehen: der Nutzer sah „getrennt“, der Sweep synchronisierte weiter. JetztupdateMany(ohnesystemwerden alle Paarungen des Tickets getrennt — das entspricht der RouteDELETE /tickets/:id/exchange/link, die gar keinen System-Selektor hat), plus ein optionalersystem-Parameter für gezieltes Trennen.- Falsche Audit-Quelle im ausgehenden Pfad.
runTicketExchangeOutbounderzeugte seinen Mutations-Port mitsource: 'EXTERNAL_INBOUND', obwohl er ausgehend arbeitet — die dabei geschriebenen internen Notizen (abgelehnter Status → Status-Drift, fehlgeschlagene Anhänge) trugen im Audit-Trail „von außen gekommen“.EXTERNAL_INBOUNDist zudem genau die Quelle, die der Event-Router überspringt, um Echos zu verhindern; sie im Outbound zu setzen vermischt zwei Bedeutungen an der Stelle, an der Nachvollziehbarkeit der ganze Zweck ist. JetztWORKER. - Fehlender Index für die Arbeitseinheit des Sweeps. ADR 0363 §1a macht die Verbindung zur Arbeitseinheit, aber
findLinksForConnection({tenantId, connectionId, deletedAt}) lief ungestützt — ein Collection-Scan alle 5 Minuten je Verbindung. Index ergänzt, in Registry undinit.jsgespiegelt (Drift-Test grün). - Bewusst NICHT hier erledigt:
bumpHandoverSequndsetOwnerhaben weiterhin null Aufrufer, womit die gesamte Split-Brain-Auflösung aus ADR 0363 §5 inert ist (handoverSeqbleibt dauerhaft0). Das ist kein Ein-Zeilen-Fix, sondern eine Verhaltensänderung im Übergabepfad, die zur Peer-Semantik gehört — sie wird mit der Cross-Installation-Föderation zusammen entworfen und umgesetzt, statt hier vorweggenommen zu werden. Verifiziert: 3 neue Tests (alle Paarungen trennen / gezielt eine trennen / nichts zu trennen →false), 195 Tests der Ticket-Exchange- und DB-Suiten grün, typecheck api + worker grün. Kein Backfill — der neue Index ist nicht unique.
fixed Der öffentliche Take-back-Endpoint war ein trivialer DoS: Collection-Scan pro Anfrage bei 6000 Anfragen/Minute
geändert customer-collaboration.routes.ts, index-registry.ts, init.js, .env.example, docs/DEVELOPMENT.md, docs/API.md, neue Testdatei customer-collaboration.security.test.ts (9). WHY: POST /webhooks/ticket-collab/:token/takeback (ADR 0299) ist die einzige unauthentifizierte Route, die Ticket-Zustand ändert — ohne Session, ohne X-Tenant-Id; das opake Token ist gleichzeitig Authentifizierung und Mandanten-/Party-Auswahl. Drei Probleme trafen zusammen: (1) Auf takeBackToken lag kein Index, die Auflösung ist aber bauartbedingt findOne({ takeBackToken }) über alle Mandanten — also ein Collection-Scan pro Anfrage. (2) /webhooks/… beginnt nicht mit /api/, matcht damit keinen Präfix des globalen Limiters und fiel auf dessen Read-Bucket von 6000 Anfragen/Minute pro IP zurück. (3) Es gab kein Schema — Token- und Body-Prüfung waren handgeschriebene Zeilen im Handler, jede Müll-Anfrage erreichte also die Datenbank. Zusammen: 6000 Collection-Scans pro Minute aus einer einzigen Quelle, ohne jede Anmeldung. FIX: (a) Unique-Index auf takeBackToken, bewusst ohne tenantId-Präfix (das Token ist der Mandantenselektor) und als Partial-Filter statt sparse nach ADR 0381 — ein sparse-unique-Index kollidierte über die vielen Zeilen ohne Token. unique, weil zwei Profile mit demselben Token den Selektor mehrdeutig machten. (b) Deklariertes Schema: :token muss exakt 64 Hex-Zeichen sein (genau das Format der Rotationsroute), Body braucht ticketId, Zusatzfelder werden verworfen — alles vor dem Handler, sodass eine Müll-Anfrage keine einzige DB-Abfrage kostet. (c) Zwei Rate-Limit-Eimer in UND-Verknüpfung nach dem Vorbild der Automations-Webhooks (pro Token + engerer Per-IP-Anteil), geprüft vor der Token-Auflösung, mit Retry-After. Verifiziert: 9 Tests — Token-Format, unbekanntes Token, fehlendes/zusätzliches Body-Feld, Party-Isolation (fremdes Ticket → 404, kein Handover), Idempotenz, Drosselung inkl. der Zusicherung, dass das Limit vor der DB greift. Kein Backfill. ⚠️ Ops: Der neue Unique-Index schlägt beim Anlegen fehl, falls in Produktion zwei Profile denselben Token tragen — vorher lesend zählen: db.customer_collaboration_profiles.aggregate([{$match:{takeBackToken:{$type:'string'}}},{$group:{_id:'$takeBackToken',n:{$sum:1}}},{$match:{n:{$gt:1}}}]). Die beiden Rate-Limit-Env-Vars TICKET_COLLAB_RATE_LIMIT_MAX/TICKET_COLLAB_RATE_LIMIT_WINDOW_SECONDS sind zusätzlich zu .env.example/docs/DEVELOPMENT.md auch im Env-Schema env.ts registriert (Guardrail #12).
fixed customer_collaboration_profiles.takeBackToken war über die generischen CRUD-Reads für jeden collaboration_profile_view-Inhaber auslesbar
geändert crud.routes.ts (redactSensitiveFields), CollaborationProfilesPanel.tsx, neue Tests in crud.routes.test.ts (3). WHY: Das Feld ist der opake Bearer-Token, den das Fremdsystem eines Kunden an POST /webhooks/ticket-collab/:token/takeback präsentiert, um ein Ticket an den MSP zurückzugeben (ADR 0299). Es steht in protectedFields (entity-config.ts) — das schützt aber ausschließlich Schreibzugriffe. GET /api/v1/customer-collaboration-profiles und GET /…/:id lieferten den Klartext-Token unverändert aus, und die Ticket-Detailseite ruft genau diese Liste bei jedem Öffnen eines Tickets mit partyId still im Hintergrund ab (TicketDetailPage.tsx) — der Token lag also im Netzwerk-Tab jedes Mitarbeiters mit Lese-Recht auf Kollaborationsprofile. Wer ihn kopiert, kann fremde Tickets dieser Party per Webhook aus der Kunden-IT zurückholen, ohne jede Sitzung. FIX: redactSensitiveFields — bisher ausschließlich für analytics_datasources.connectionConfig zuständig — löscht takeBackToken jetzt aus jeder generischen Listen- und Detailantwort. Die Redaktion ist rollenunabhängig (auch für admin_bypass), weil der Serverschlüssel serverseitig bleibt; der einzige sanktionierte Weg zum Klartext ist weiterhin POST /customer-collaboration-profiles/:id/rotate-take-back-token, das ihn genau einmal zurückgibt. Im Web wurde das jetzt tote Feld aus dem Profile-Typ entfernt (mit Begründung im Kommentar), damit niemand darauf aufbaut — gerendert wurde es ohnehin nie, nur die Rotations-Antwort wird angezeigt. Verifiziert: 3 neue Tests decken Detail-Response, Listen-Response (der real ausgenutzte Pfad) und die Nicht-Betroffenheit fremder Ressourcen ab; sie laufen bewusst als Admin, weil die Rolle ausdrücklich nicht Teil der Entscheidung ist. Alle 66 Tests in crud.routes.test.ts grün. Kein Backfill — reine Response-Filterung, die gespeicherten Tokens bleiben gültig. Ops-Empfehlung: Tokens von Parteien, deren Profil bereits produktiv angelegt war, einmalig rotieren — sie waren gegenüber allen internen Lese-Berechtigten exponiert. Reihenfolge beachten: erst NACH dem Deploy dieses PRs rotieren — vorher schriebe die Rotation den neuen Token wieder im Klartext in die history (siehe den folgenden Eintrag zur History-Redaktion); nach dem Deploy invalidiert die Rotation zugleich alle früher über History/Timeline exponierten Tokens.
fixed takeBackToken leckte bei jeder Rotation im Klartext in die history (Audit-Trail + Timeline)
geändert history-redaction.ts (HISTORY_REDACTED_FIELDS), neue Tests in history-redaction.test.ts (3). WHY: Die Rotationsroute POST /customer-collaboration-profiles/:id/rotate-take-back-token (customer-collaboration.routes.ts) persistiert den neuen Token über updateEntity — und writeHistory speichert JSON-Patch (replace /takeBackToken <klartext>) plus after-Snapshot. Der Redaktionsmechanismus aus ADR 0397 (redactHistorySnapshot) kannte nur event_registrations, also stand der Klartext-Token dauerhaft in der history-Collection und wurde über GET /:resource/:id/timeline an jeden mit Zugriff auf das Profil ausgeliefert — womit die read-seitige CRUD-Redaktion (voriger Eintrag) über den Umweg Timeline umgangen werden konnte. FIX: customer_collaboration_profiles: ['takeBackToken'] in HISTORY_REDACTED_FIELDS; die Redaktion greift in writeHistory vor dem Diff, sodass weder Snapshot noch Patch den Token je enthalten (Platzhalter [redacted], Feld-Key bleibt als Audit-Beleg erhalten). Der Timeline-Read-Pfad hat keinen read-seitigen Redaktions-Hook — der Fix ist deshalb bewusst write-seitig; Bestandszeilen mit bereits geloggten Klartext-Tokens werden nicht umgeschrieben (kein Backfill), sondern durch die o. g. Ops-Rotation NACH dem Deploy invalidiert. Verifiziert: 3 neue Tests, darunter einer, der den Rotationsfall nachstellt (redigierte Snapshots → createJsonPatch) und zusichert, dass kein Klartext-Token in den Patches landet.
v0.3.10 · 2026-08-03
Hinzugefügt
added Vertriebschancen: Filterleiste (Wert-Bereich + Abschluss-Zeitraum) jetzt auch in der Listenansicht
neu OpportunityFilterBar.tsx + Tests (ersetzt OpportunityBoardFilterBar.tsx), crud.opportunities-value-filter.test.ts; geändert crud.routes.ts, OpportunitiesPage.tsx, OpportunityBoardView.tsx, de.json/en.json, API.md. WHY: Die Leiste („Wert ab … bis“, Abschluss-Presets Überfällig / Nächste 7 Tage / Dieser Monat / Dieses Quartal / Ohne Datum, Wiedervorlage) gab es nur auf dem Board — in der Liste fehlten genau die beiden Filter, die sich über Options-Chips nicht ausdrücken lassen.
Warum das nicht einfach „Komponente eine Ebene höher rendern“ war: Das Board rendert aus dem vollständig geladenen Datensatz (fetchAllOpportunities) und filtert deshalb clientseitig. Die Liste ist ein EntityDataGrid mit serverseitiger Offset-Pagination — derselbe clientseitige Filter hätte dort nur die ~25 Zeilen der aktuellen Seite gefiltert, während Zeilenzahl, Seitenzahl und Excel-Export ungefiltert geblieben wären. Die Leiste übersetzt ihren Zustand in der Liste daher in echte Query-Parameter und läuft über den extraViewState-Kanal (ADR 0279), damit gespeicherte Ansichten sie miterfassen.
value musste dafür serverseitig filterbar werden — ohne Backfill. Das Feld ist ein Dezimal-String (^\d+(\.\d{1,2})?$) und war bewusst nicht in ALLOWED_FILTERS, weil value__gte=1000 lexikografisch verglichen hätte („9.99“ über „10000.00“). Statt eines numerischen Spiegelfelds — das einen Rewrite aller Bestands-Vertriebschancen gebraucht hätte — gibt es das neue, deklarative NUMERIC_STRING_FILTER_FIELDS (Geschwister von NUMERIC_STRING_SORT_FIELDS): Für die dort gelisteten Felder werden __gte/__gt/__lte/__lt in ein $expr mit $convert … to: 'double' umgeleitet. Min und Max werden zu einem $expr AND-verschmolzen (sonst überschriebe die zweite Grenze die erste) und über andMergeFilter eingehängt, damit ein bestehendes Data-Scope-$or nicht verloren geht. onError/onNull: 0 spiegelt exakt das clientseitige Number(o.value) || 0 des Boards, sodass beide Ansichten dieselbe Menge auswählen. Bewusster Trade-off: $expr nutzt keinen Index — die Klausel läuft immer zusammen mit dem Tenant-Filter, der Scan bleibt also auf einen Mandanten begrenzt. Als Nebenwirkung der Allowlist-Aufnahme ist ?sort=value jetzt zulässig und über NUMERIC_STRING_SORT_FIELDS ebenfalls numerisch — vorher wurde die Sortierung auf der Wert-Spalte still verworfen.
Ein Filter, ein Besitzer: Der expectedCloseDate-Zeitraum ist aus dem Filter-Dialog entfernt worden. In mergedFilters schlagen Dialog-Werte den extraViewState, d. h. ein gesetzter Dialog-Zeitraum hätte jeden Klick auf ein Abschluss-Preset still geschluckt. Der Fälligkeits-Zeitraum (dueDate) bleibt im Dialog, die Leiste fasst ihn nicht an. Ebenso liegt der „Wiedervorlage“-Umschalter jetzt nur noch in der Leiste (vorher zusätzlich als extraToggles-Eintrag im Phasen-Filter, solange die Liste aktiv war) — kein doppeltes Bedienelement für denselben Zustand.
Board und Liste können nicht auseinanderlaufen: Beide Pfade leiten sich aus derselben closePresetWindow() ab (relatives Preset → absolutes YYYY-MM-DD-Fenster); ein Test vergleicht für jedes Preset die clientseitige Prädikatsfunktion gegen eine Nachbildung dessen, was MongoDB mit den erzeugten Parametern täte, inkl. Type-Bracketing (undatierte Datensätze fallen aus einem Range-Match heraus, „Ohne Datum“ ist deshalb __isEmpty statt eines offenen Bereichs). Beim Wiederherstellen einer gespeicherten Ansicht wird ein Fenster nur dann zurück auf ein Preset abgebildet, wenn es heute noch dieselben Daten ergibt — eine im Q2 gespeicherte Ansicht bedeutet in Q3 nicht mehr „dieses Quartal“ und fällt bewusst auf „kein Preset“ zurück, statt ein veraltetes absolutes Fenster stillschweigend weiterzuführen.
added Kundenliste: Spalte „Hauptansprechpartner“ — sortier- und filterbar
neu party.hooks.ts, parties-list-config.tsx; geändert crud.routes.ts, entity-hooks/index.ts, PartiesPage.tsx, index-registry.ts, init.js, de.json/en.json, API.md. WHY: Aus dem Betrieb: „Man muss in der Kundenliste nach Hauptansprechpartner sortieren und filtern können.“ Ging nicht — die Spalte existierte gar nicht, und beides scheiterte an derselben Ursache: Der Hauptansprechpartner steht nicht auf dem party-Dokument. Nach ADR 0098 lebt die PERSON ↔ ORG-Verknüpfung ausschließlich als Kante in party_relationships (organizationPartyId + isPrimary: true → personPartyId), der Name dann noch eine Collection weiter auf der Person; parentPartyId ist für ORG → ORG reserviert. Die Detailseite paginiert sich diese Kanten heute client-seitig zusammen (fetchAllPartyRelationships, weil die CRUD-Liste limit bei 200 deckelt) — als Listenspalte wäre das pro Seite ein zweiter, unbegrenzter Fetch, und sortieren/filtern könnte man ohnehin nur die geladene Seite.
Kein Backfill, kein denormalisiertes Feld. Der naheliegende Weg wäre primaryContactPartyId auf der Party gewesen — das hätte einen Rewrite aller Bestandsfirmen gebraucht (genau die Klasse Änderung, die seit dem searchText-Vorfall aus ADR 0163 Moritz’ Freigabe braucht) und dazu einen Konsistenzpfad, der die Kante und das Spiegelfeld für immer synchron hält. Stattdessen ist alles rein lesend.
Drei Bausteine, alle drei generisch statt als Parties-Sonderfall: (1) SORT_JOINS kannte bisher nur den Vorwärts-FK (localField auf dem Quelldokument). Neu ist die Variante reverse: { foreignField, match?, hop? } — die Sub-Pipeline matcht organizationPartyId === $_id, verengt über match auf isPrimary: true, sortiert die Kanten nach _id, nimmt die erste und löst per hop den Personennamen im selben Sub-Pipeline-Durchlauf auf. Das Ergebnis hat exakt die Form des Vorwärts-Joins, deshalb greifen $toLower-Faltung, Coalesce-Fallback und _id-Tiebreaker unverändert. tenantId + deletedAt: null liegen auf beiden Hops. (2) ENRICHED_NAME_FILTERS (ADR 0237) bekam dieselbe Erweiterung als via: { collection, refField, ownerField, match? }: Namensregex → Personen-IDs → Kanten → Firmen-IDs, angewandt als _id $in / $nin. __isEmpty/__isNotEmpty können keinen lokalen FK prüfen und lösen deshalb die Menge der Firmen mit qualifizierender Kante auf. (3) Die Anzeige läuft über einen neuen parties-Eintrag der Entity-Hooks-Registry — kein neuer Inline-resource === 'parties'-Zweig, den Guardrail #4 verbietet.
Warum _id-Ordnung an zwei Stellen wiederholt wird: Anreicherung und Sort-Join lösen die Kante unabhängig voneinander auf. Hat eine Firma versehentlich zwei isPrimary-Kanten, würden ohne feste Ordnung Anzeigewert und Sortierschlüssel auseinanderlaufen — die Liste sähe dann falsch sortiert aus, obwohl der Server korrekt sortiert hat. Beide nehmen deshalb die Kante mit der kleinsten _id.
Neuer Index { tenantId, isPrimary, organizationPartyId, deletedAt } auf party_relationships (Registry + init.js): Anreicherung, Sort-Join und die „hat/hat keinen“-Filter fragen alle mit genau diesem Prädikat; ohne isPrimary/deletedAt im Index müsste Mongo für jede Kante der Firma das Dokument nachholen. Rein additiv, kein Datenumbau.
Nebenbei entschärft: PartiesPage.tsx lag mit 823 Zeilen über dem max-lines-Budget von 800 und wäre durch Spalte + Filter weiter gewachsen. Spalten-, Filter-, Quick-Filter- und Rollentab-Definitionen sind nach parties-list-config.tsx ausgezogen (reiner Move, keine Verhaltensänderung); die Seite ist damit wieder unter dem Limit (Guardrail #6: extrahieren statt wachsen).
added Checklisten-/Übergangs-Aktion „E-Mail senden“: Ticket-Kontakt als Empfänger + Modus „zur Prüfung öffnen“
neu send-email-action.ts, rule-interpolate.ts, 0436-send-email-rule-action-recipients-and-delivery.md; geändert rule-hooks.ts, transitions.ts, rule-executor.ts + Tests, checklist.routes.ts, approval-policy.service.ts, ChecklistPanel.tsx, GuardActionEditor.tsx, TransitionPropertiesPanel.tsx, de.json/en.json, API.md. WHY: Aus dem Betrieb: „Wenn man eine Checkliste in einem Ticket hat, muss man eine Mail an den zugewiesenen Kontakt des Tickets senden können.“ Ging nicht — die Empfängertypen waren user/role/field, und field liest den Feldwert als interne Benutzer-ID. Der Ticket-Kontakt steckt aber in contactPartyId und ist eine Party, deren Adresse in contactChannels[type=EMAIL] liegt; es führte schlicht kein Weg zum Kunden.
Vier neue Empfängertypen: contact (Kontakt am Objekt, contactPartyId), party (Kunde/Firma, partyId), party_field (beliebiges Feld, dessen Wert als Party-ID aufgelöst wird — deckt auch Assets/Projekte/Verträge ab) und email (freie Adresse(n), komma-/semikolongetrennt). Die Party→Adresse-Auflösung nutzt partyEmail() aus approval-policy.service.ts (dort nur exportiert, nicht kopiert), also dieselbe Regel wie der Informations-Pfad aus ADR 0163: primärer EMAIL-Kanal → erster EMAIL-Kanal → Legacy-party.email. user/role/field behalten ihre Semantik unverändert.
Neuer Versandmodus sendMode: review bereitet die Mail nur vor — die Item-PATCH-Antwort trägt sie in pendingEmails, das Frontend öffnet den EmailComposeDialog vorbefüllt, der Bearbeiter prüft und sendet. auto sendet sofort. Der Default hängt am Trigger, nicht an einer globalen Einstellung: ein Checklisten-item-check läuft auf review (es gibt eine UI, an die übergeben werden kann), jeder andere Trigger auf auto. Damit erbt der Checklisten-Fall die Haltung aus ADR 0223 (Kundenmail wird vor dem Absenden gelesen), ohne Übergangs-Aktionen zu verkrüppeln. Weil nur der Checklisten-Host übergeben kann, blendet der Workflow-Editor den Modus-Selektor aus; erreicht dennoch eine review-Aktion den Executor aus einem anderen Trigger, wird das als Fehler geloggt statt ungeprüft gesendet.
Nebenfix im Regel-Editor: GuardActionEditor verwendete param.key als React-Key. Mehrere Parameter teilen sich denselben key und unterscheiden sich nur über dependsOnValue (recipientValue ist je nach Empfängertyp Benutzer-Picker, Rollen-Picker, Feld-Picker oder Freitext) — mit den neuen Varianten wären das fünf Geschwister mit identischem Key gewesen. Der Key enthält jetzt die Variante.
added Worker-Neustart aus dem Admin-Panel (Platform Console → Workers)
neu worker-restart.service.ts + Tests, codemeta-worker-restart.path, codemeta-worker-restart.service, 0434-worker-restart-from-admin-panel.md; geändert support-platform.routes.ts, PlatformConsolePage.tsx, permissions.ts, env.ts, worker-health.ts, README.md, .env.example, DEVELOPMENT.md, API.md. WHY: Bisher war ein hängender Worker (Change-Stream-Stall, Leader-Flap, renew-lock-Storm) nur per SSH + systemctl restart codemeta-worker zu beheben — das Admin-Panel konnte den Zustand zwar anzeigen (Queue-Snapshot, ADR-0184-Heartbeat-Alarm), aber nichts dagegen tun.
Bewusst ein harter Neustart, kein Graceful-Signal über die Queue: Der Anlass, den Knopf zu drücken, ist fast immer, dass der Worker nicht mehr reagiert. Alles, was seine Mitarbeit braucht — ein BullMQ-Job, ein HTTP-Call an seinen Health-Server auf 127.0.0.1:9091 — versagt genau in diesem Fall. systemd braucht sie nicht.
Warum kein sudo systemctl restart aus der API: codemeta-api@.service setzt NoNewPrivileges=true; der Kernel ignoriert damit das setuid-Bit von sudo, jeder Aufruf aus dem API-Prozess scheitert — die bestehende NOPASSWD-sudoers-Zeile hilft nur dem Deploy-Skript, das in einer normalen SSH-Shell läuft. Das Hardening der internetseitigen Unit für einen Komfort-Button aufzuweichen wäre der falsche Tausch. Stattdessen schreibt die API nur eine Marker-Datei nach /var/lib/codemeta/worker-restart.request (liegt ohnehin in ihren ReadWritePaths); die neue root-eigene Path-Unit holt sie ab, löscht sie und startet die Worker-Unit neu. Die Oneshot-Unit nimmt keine Argumente aus der Datei — wer schreiben darf, kann also nicht beeinflussen, was neu gestartet wird; der JSON-Inhalt (wer, wann, warum) dient nur dem Journal.
Kein stilles „hat geklappt“: POST wartet, bis der Marker abgeholt wurde, und antwortet erst dann mit 202. Fehlen die Units, verschwindet die Datei nicht — die API räumt sie nach 15 s selbst weg (sonst würde sie Stunden später bei der Installation einen Überraschungs-Neustart auslösen) und meldet, dass niemand den Trigger abgeholt hat. 202 heißt bewusst „systemd hat übernommen“, nicht „Worker ist wieder da“: den Beweis liefert die neue Instanztabelle im Panel (instanceId, Leader-Rolle, Laufzeit, RSS, letzter Heartbeat aus worker_heartbeats), die nach dem Auslösen zwei Minuten lang im 3-s-Takt pollt — der Operator sieht die alte Instanz verschwinden und die Laufzeit bei Null neu anlaufen.
Nebenfix am Worker: worker-health.ts schrieb den Heartbeat erst nach dem ersten 30-s-Intervall, der Shutdown-Pfad löscht das Dokument aber. Nach jedem Neustart (auch bei jedem Deploy) war worker_heartbeats dadurch bis zu 30 s leer — genug, um wie ein Worker auszusehen, der nicht wiederkommt. Jetzt wird einmal sofort geschrieben.
Absicherung: eigene Permission support_worker_restart (nicht support_panel_access, das gated nur Lesen), Pflicht-Begründung ab 5 Zeichen, Eintrag im Support-Audit-Log (actionType: 'worker_restart', inkl. Fehlausgang), Cooldown von 5 Minuten über einen Redis-Key — also geteilt über beide API-Instanzen. Standardmäßig aus: ohne WORKER_RESTART_ENABLED=true ist der Button deaktiviert und nennt im Tooltip den Grund. Kein Backfill.
Ops-Schritt nötig: die zwei neuen systemd-Units müssen einmalig installiert und codemeta-worker-restart.path aktiviert werden (Anleitung inkl. Verdrahtungsprobe in infra/systemd/README.md). Ohne sie ändert sich am Verhalten nichts.
Bewusst nicht in dieser Änderung: ein echtes Job-Drain vor dem Neustart. closeRegisteredConsumers() existiert in apps/worker/src/lib/register-consumer.ts, wird im Produktivpfad aber nirgends aufgerufen — laufende Jobs verlieren beim Exit ihren Lock und werden nach lockDuration requeued. Das ist derselbe Vertrag, auf den sich jeder Deploy heute schon verlässt, und die Erwartung ist im Bestätigungsdialog benannt; ein Drain nachzurüsten ist eine eigene Änderung (und deckt ohnehin nur die ~10 auf registerConsumer() migrierten Consumer ab).
added Zeitbuchung: Mehrfach-Verknüpfungen unter „Auftrag“ + tenant-konfigurierbare Verknüpfungs-UI
neu 0433-time-record-multi-links.md, time-record-links.ts (+ Tests), time_record.hooks.ts, useReferenceItems.ts, MultiReferenceSelector.tsx, time-entry-reference-settings.ts; geändert time-record.schema.ts, entities.ts, index-registry.ts, init.js, crud.routes.ts (ALLOWED_FILTERS + enrichTimeRecords), timer.routes.ts, merge.service.ts, support.transformer.ts, TimeEntryDialog.tsx, ReferenceSelectorForm.tsx (Refactor auf den shared Hook, Kalender unverändert), timerecord-drafts.ts, TimerWidget.tsx, time-entry-field-settings.ts, TimeTrackingSettingsPage.tsx, TicketTimeTrackingTab.tsx, ProjectTimeTab.tsx, de.json, en.json. WHY: „Auftrag“ konnte nur EIN Objekt verknüpfen — wer auf ein Ticket buchte, verlor Projekt- und Ansprechpartner-Kontext; ein Timer mit Aufgabe + Projekt verlor bei der Konvertierung einen der beiden Links. Jetzt: max. je 1 Projekt/Ticket/Aufgabe (hierarchisch konsistent — Ticket ∈ Projekt, Aufgabe ∈ Ticket/Projekt, serverseitig validiert) plus beliebig viele Ansprechpartner/Assets als neue Felder linkedProjectId/linkedTicketId/linkedTaskId/linkedContactPersonIds/linkedAssetIds.
Abrechnung bleibt ein Anker: referenceType/referenceId werden server-authoritativ als Hauptbezug abgeleitet — gröbstes Objekt zählt (PROJECT > TICKET > TASK > CONTACT_PERSON > ASSET, PO-Entscheidung): Projekt verknüpft ⇒ Projektabrechnung, Ticket-Budget unberührt; Sekundär-Links sind rein informativ. Dadurch laufen alle ~35 Bestand-Konsumenten (resolveBillingUnit/ADR 0274, Budget-Guards, Coverage, weclapp-Push, SORT_JOINS) unverändert. Kein Backfill: Alt-Datensätze tragen nur das Paar; Leser synthetisieren die typisierten Felder zur Laufzeit (resolveLinks/synthesizeLinksFromLegacy); neue Datensätze schreiben beide Darstellungen. CONTRACT/PHONE_CALL bleiben legacy-only unangetastet. Ticket-/Projekt-Zeit-Tabs lesen dual (Paar + linked-Feld, dedupe existierte schon), damit Sekundär-Links auftauchen; enrichTimeRecords füllt Ticket-Nr./Projekt-Nr./Aufgaben-Titel jetzt nebeneinander. TANSS-Transformer schreibt die Felder mit (Spiegel ist replace — sonst löschte jeder Reconcile sie wieder); Ticket-Merge zieht auch Sekundär-Links um.
Einstellungen (settings.timeEntry.referenceConfig, Seite „Zeiterfassung“): Darstellung „ein Auswahlfeld für alle Typen“ (Chips, Default = bisheriges Verhalten) oder „ein Auswahlfeld pro Typ“; pro Typ „Anbieten“ und „Pflicht“ (mind. eine Verknüpfung des Typs, Dialog-Validierung + serverseitig 422 im Entity-Hook; enabled: false blendet nur aus, lehnt nichts ab). ⚠️ Die fieldVisibility-Speicherung schrieb settings.timeEntry bisher wholesale und hätte den neuen Geschwister-Key gelöscht — jetzt merge-sicher (useSaveTimeEntrySettings). Picker-Ladelogik in den shared Hook useReferenceItems extrahiert (Hierarchie-Scoping: Projekt gewählt ⇒ nur dessen Tickets/Aufgaben; Ticket gewählt ⇒ nur dessen Aufgaben); der Kalender-Einzelpicker nutzt denselben Hook verhaltensidentisch.
added TANSS-Ticket in Codemeta OS übernehmen — Knopf im Read-only-Banner, quittiert durch einen Tag „CodemetaOS“ in TANSS
neu 0432-tanss-ticket-takeover.md, tanss-takeover.service.ts, ticket-takeover.routes.ts, TicketTakeoverButton.tsx, tanss-takeover.service.test.ts; geändert ticket.schema.ts, entity-config.ts, integrations.ts, entity.service.ts, tanss.provider.ts, app.ts, upsert-mirror.ts, ExternalMasterBanner.tsx, TicketDetailPage.tsx, client.ts, de.json, en.json. WHY: Aus TANSS gespiegelte Tickets sind hier read-only (ADR 0377) — der Banner erklärte die Sperre bisher nur, bot aber keinen Ausweg. Wer trotzdem in Codemeta arbeiten wollte, hatte nur unsaubere Wege (in TANSS schließen und hier neu anlegen → Historie weg). ADR 0377 benennt dieselbe Lücke selbst: „Die Drain-Phase endet nicht von selbst […] am Ende muss der Rest nach Codemeta migriert werden.“ Jetzt gibt es dafür einen Knopf pro Vorgang, statt einer Aktion am Ende.
Ownership als eigenes Feld, source bleibt stehen: ticket.externalOwnership: 'LOCAL' (+ …TakenAt/…TakenBy/…Tag) hebt die Sperre auf; der Guard prüft jetzt source === provider && externalOwnership !== 'LOCAL' im UPDATE- und im DELETE-Pfad. Der naheliegende Weg — beim Übernehmen source löschen — wurde verworfen: source/sourceRef sind die Herkunft (der Deeplink „In TANSS öffnen“ lebt davon), und der Sync findet den Spiegel ohnehin über sourceRef, nicht über source — ein gelöschtes source hätte den Reconcile also gar nicht aufgehalten, ein zusätzlich gelöschtes sourceRef den Vorgang beim nächsten Lauf ein zweites Mal angelegt. Herkunft und Führung sind zwei Fragen und bekommen zwei Felder. Kein Backfill: fehlendes externalOwnership = unverändert Spiegel.
Der Tag ist die Bedingung, nicht die Nachbereitung: POST /tickets/:id/takeover setzt zuerst den Tag in TANSS (GET /api/v1/tags → ggf. POST /api/v1/tags → POST /api/v1/tags/assignment) und schreibt die Ownership nur bei Erfolg. Andersherum entstünde bei jedem TANSS-Ausfall genau der Zustand, den die Ownership-Trennung ausschließen soll: in Codemeta offen, in TANSS unmarkiert, zwei Teams am selben Vorgang. Verifiziert wird durch Zurücklesen — linkTypeId (11 für Tickets) steht in der TANSS-Spec nur als Beispiel und ist nirgends definiert; ein falscher Wert liefert eine 201 und markiert ein anderes Objekt, die Quittung allein ist also kein Beleg. Tagname und linkTypeId sind über automationConfig.tanssSync.takeoverTagName/takeoverLinkTypeId konfigurierbar (Defaults CodemetaOS/11). Fünf getrennte Fehlercodes statt eines Sammelcodes (tanss-takeover-integration-missing|auth-expired|tag-unavailable|assign-failed|verify-failed), weil sich die Handlungsanweisung unterscheidet: „Integration neu verbinden“ vs. „Tag einmalig in TANSS anlegen“ vs. „später erneut versuchen“. Ein 403 auf dem Tag-Anlegen wird bewusst nicht als abgelaufenes Token gemeldet — es ist ein fehlendes TANSS-Recht, und die falsche Meldung schickte den Administrator zum wirkungslosen Neuverbinden.
Zwei Stellen, die sonst still gebrochen wären: (1) Die vier Ownership-Felder stehen in protectedFields — ein generischer PATCH verwirft sie, geschrieben werden sie nur von der Route über allowProtectedFields; ohne das wäre die ganze Konstruktion einen Ein-Zeilen-PATCH von ihrer Umgehung entfernt (Sperre weg, TANSS unmarkiert). (2) upsertTanssMirror steigt bei externalOwnership === 'LOCAL' mit skipped aus — nach dem sourceRef-Match, damit kein Duplikat entsteht. Das ist die Hälfte der Entscheidung, die nur im Worker sichtbar ist: ein grüner API-Test hätte den Datenverlust beim nächsten Reconcile nicht bemerkt. Beides ist getestet (upsert-mirror.test.ts, entity.service.external-master-tanss.test.ts); die @codemeta/shared-Mocks der beiden External-Master-Tests mussten LOCAL_OWNERSHIP mitbekommen, sonst wäre die Konstante dort undefined und jedes Dokument gälte als übernommen.
Abgrenzung zu ADR 0376 (bidirektionaler Sync, Rejected): Dessen drei Blocker (kein 409, PUT /tickets/{id} als Voll-Ersatz, kein modifiedSince) treffen hier nicht zu — POST /tags/assignment ist additiv, betrifft eine Nebenbeziehung, überschreibt keinen Ticketinhalt und ist wiederholbar. Was nach TANSS geht, ist kein Datenabgleich, sondern eine Notiz an die Kollegen im anderen System. ADR 0376 bleibt rejected. Berechtigung: ticket_edit + Datenscope (wer bearbeiten dürfte, darf übernehmen; auditiert über die History). Der Banner bleibt nach der Übernahme stehen — in Akzentgrün mit umgekehrter Aussage, weil Herkunft und Deeplink weiter gelten. Nicht enthalten: gespiegelte time_records bleiben gesperrt (GoBD-Kette umhängen ist ein eigener Vorgang, kein Nebeneffekt eines Knopfdrucks) und es gibt keine Rücknahme in der Oberfläche (der Reconcile überschriebe danach die hier gemachten Änderungen).
added Profilrahmen (Achievement-Level) jetzt für Kolleg:innen sichtbar — UserAvatar-Komponente + denormalisierter effektiver Tier
neu avatar-frame.service.ts, UserAvatar.tsx, 0428-avatar-frame-colleague-visibility.md; geändert profile.routes.ts, achievement.routes.ts, users.routes.ts, crud.routes.ts (enrichEmployeeProfiles), VacationDetailPage.tsx, EmployeeDetailPage.tsx, UnifiedOrgChart.tsx. WHY: ADR 0418 zeigte den Rahmen nur an der eigenen Person (Sidebar/Einstellungen) — ein Rang, den niemand sonst sieht, ist keiner. Die dort offene Auflösung userId → tierId ist jetzt ein denormalisierter Anzeige-Cache: users.avatarFrameTierId trägt den effektiven Tier (resolveFrameTier(tenantXp, wahl), also pro Tenant gegen die dortigen XP geklammert — in Tenant A verdientes Gold erscheint in Tenant B nie). Gestempelt wird beim Preferences-PATCH (alle Tenants des Auth-Nutzers) und lazy + $ne-guarded auf GET /achievements/me (läuft bei jedem App-Load über den Easter-Eggs-Store → kein Backfill, Bestandsnutzer werden beim ersten App-Öffnen gestempelt; fehlendes Feld = kein Rahmen). Kein history-Eintrag: rekonstruierbarer Cache, die Audit-Spur liegt an Preference/Achievements. Ausgeliefert über GET/POST /users* (avatarFrameTierId) und enrichEmployeeProfiles (userFrameTierId). Frontend-seitig schließt UserAvatar die strukturelle Lücke, dass es nur den Bild-Lader AvatarImage gab (kennt nur einen src, nicht die Person; 56 Aufrufstellen mit je eigenem Initialen-Fallback): eine Komponente für Bild + Initialen + Rahmen. Flächen: Urlaubsantrag-Detail (Antragsteller-Card), Mitarbeiter-Detail-Hero (HR), Organigramm-Personenknoten, Ticket-Detail-Kopf und Vertriebschancen-Detail-Kopf (Bearbeiter-/Verantwortlichen-Chip) sowie der eigene Sidebar-Avatar. Für die beiden Detail-Köpfe trägt EntitySelectorInline.tsx ein optionales avatarFrameTierId — der Rahmen erscheint nur am Trigger-Chip, nicht in der Dropdown-Auswahlliste (dort ist der Avatar reine Trefferkennung), und wird bei avatarShape: 'rounded' ignoriert (Firmenlogo-Kachel ist keine Person).
Der Rahmen wird dauerhaft gezeichnet. Eine hover-gebundene Variante (Ring erscheint zusammen mit der animierten Avatar-Variante, ADR 0390 §7) war gebaut und wurde wieder entfernt: Ein Rang, der nur beim Draufzeigen erscheint, wirkt wie etwas, das der Avatar versteckt — und wäre auf Touch-Geräten gar nicht erreichbar gewesen. Neuer Test UserAvatar.test.tsx pinnt die verbleibende Eigenlogik der Komponente: Das Bild wird um die skalierte Ringbreite eingerückt, wenn ein Rahmen greift, und behält die volle Größe bei fehlender, unbekannter oder per showFrame={false} unterdrückter Tier-ID (unbekannte IDs müssen still rahmenlos rendern, nicht werfen). Einstellungsseite und ProfileFramePicker bleiben bewusst bei AvatarFrame: dort ist der Avatar ein Crop-Preview-Blob bzw. die animierte Variante mit eigener Perk-Gate-Logik, und der Picker rendert dasselbe Element in jedem Swatch — der children-Fall, für den AvatarFrame existiert. Dichte Flächen (Datagrids, Kommentar-/Nachrichtenlisten, Presence-Stacks) bleiben gemäß ADR 0418 rahmenlos. Server-Test: profile.routes.test.ts pinnt das Per-Tenant-Clamping beim Stempeln.
added Verstecktes Achievement „Käferjäger“ schaltet den gleichnamigen Profilrahmen frei — Rahmen erstmals an ein einzelnes Achievement statt an XP gebunden
neu 0429-achievement-bound-avatar-frames.md; geändert achievements.ts, achievement-levels.ts, achievement-perks.ts, avatar-frame.service.ts, profile.routes.ts, FeedbackDialog.tsx, ProfileFramePicker.tsx, UserSettingsPage.tsx, Sidebar.tsx, AvatarFrame.tsx, UserAvatar.tsx, de.json, en.json. WHY: ADR 0418 konnte Rahmen nur als Rang ausdrücken — sechs Stufen an XP-Schwellen, unlockedTierIds() immer ein Präfix der Leiter. Ein Rahmen, den man durch eine bestimmte Tat verdient (0 XP + Egg schlägt vollen Katalog ohne Egg), passt in diese Form nicht. Als siebte Leiterstufe hätte er zwei Invarianten gebrochen, die achievement-levels.test.ts pinnt und auf die sich die Validierung in profile.routes.ts stützt (streng steigende Schwellen, Präfix-Eigenschaft); als Perk wäre er falsch einsortiert („Perk = Fähigkeit, Level = Rang“, ADR 0418 §2) und hätte eine support-editierbare XP-Schwelle gebraucht, die es hier nicht gibt.
Deshalb eine zweite Registry neben der Leiter: SPECIAL_FRAMES mit requiredAchievementId statt minXp; ACHIEVEMENT_TIERS bleibt unverändert. Gemeinsame Basis ist das neue Interface AvatarFrameDef (id/labelKey/ring) — das, was ein Renderer wirklich braucht; AvatarFrame.tsx liest ohnehin nur ring und ist jetzt dagegen typisiert, sonst hätte jede Zeichenstelle einen Cast gebraucht. Zwei Lookup-Maps: ACHIEVEMENT_TIER_BY_ID bleibt leiter-only (sein Test pinnt undefined für unbekannte IDs), das neue FRAME_BY_ID deckt beide ab und wird von UserAvatar und profile.routes.ts benutzt — Kolleg:innen sehen den Rahmen damit über den ADR-0428-Stempel genauso wie eine Leiterstufe.
resolveFrameTier(totalXp, storedTierId, unlockedAchievementIds) bekam einen Pflichtparameter ohne Default: Ein = [] hätte jede vergessene Aufrufstelle still auf den XP-Rahmen zurückfallen lassen, und ein falscher Rahmen sieht aus wie ein plausibler — nichts hätte kaputt gewirkt. Als Pflicht wird die Auslassung zum Compile-Fehler (drei Aufrufstellen angepasst). Analog liefert das neue getUserAchievementState() XP-Summe und freigeschaltete IDs aus derselben Query (getUserAchievementXp() delegiert dorthin): Aus 40 XP lässt sich nicht ablesen, aus welchem Egg sie stammen.
Auslöser: Klick auf das Käfer-Badge im Header von „Bug melden“ (FeedbackDialog.tsx) — bewusst kein <button>, kein aria-label, kein tabIndex, kein Pointer-Cursor: Ein Easter Egg, das sich beim ersten Tab-Druck ankündigt, ist keins, und ein echter Button dort setzte für jeden, der wirklich einen Bug meldet, einen zusätzlichen Tab-Stopp vor das Titelfeld. Einzige Affordance ist ein Hover-Wackeln; der Klick spielt eine Squash-Animation (transform-origin: bottom, unter prefers-reduced-motion abgeschaltet). unlock() dedupliziert und synchronisiert selbst.
Im Picker erscheint ein gesperrter Geheimrahmen als Kachel mit ??? + „Geheimes Achievement“ (neues Feld SpecialFrame.secret, neuer i18n-Key profile.frame.lockedSecret) — sichtbar genug als Anreiz (Picker-Doktrin aus ADR 0418: „eine Belohnung, die man nicht sieht, ist kein Anreiz“), ohne Name oder Bedingung ins DOM zu schreiben. Die XP-Fortschrittszeile bleibt leiter-only, sonst wäre „noch N XP bis Gold“ falsch.
Bewusste Nebenwirkung ohne Backfill: bug_squasher bringt 40 XP, MAX_ACHIEVEMENT_XP steigt damit 540 → 580, und da legendary.minXp auf die Konstante zeigt, steigt die Legendary-Schwelle mit. Wer heute exakt 540 XP hat, fällt auf Platin zurück, bis er den Käfer findet — resolveFrameTier() nimmt einen nicht mehr gehaltenen Rahmen aktiv zurück (Support-Reset-Vertrag aus ADR 0418). Das ist der Preis dafür, dass „Legendary = wirklich alles gesammelt“ gilt; betroffen sind ausschließlich 100 %-Sammler, für die das Egg ein Klick ist. Die gespeicherte Rahmenwahl bleibt unangetastet, sie wird nur bis zum Fund anders aufgelöst. Tests: neun neue Fälle in achievement-levels.test.ts (u. a. dass kein Spezialrahmen in tierFromAchievementXp/unlockedTierIds/xpToNextTier leckt und dass die referenzierte Achievement-ID existiert — ein Tippfehler dort erzeugte sonst einen nie freischaltbaren Rahmen, über den zur Laufzeit nichts berichtet) sowie vier in ProfileFramePicker.test.tsx.
Emblem am Rahmen: RingSpec trägt jetzt ein optionales badge: { icon, color, position? }. Ein Emblem bekommen nur Rahmen, deren Bedeutung die Ringfarbe nicht ausdrücken kann — heute zwei: chitin den Käfer in Rot und legendary eine Krone. Der Käferjäger-Ring ist dabei von irisierendem Regenbogen auf Schwarz-Rot umgestellt (drei Wechsel rundherum, #1c2128/#f85149); das Käfer-Rot ist einer dieser beiden Stops, denn Ring und Emblem lesen nur als ein Objekt, solange die Badge-Farbe im Ring vorkommt. Drei Details, die sonst als Renderfehler durchgingen: Erster und letzter Stop sind dieselbe Farbe (ein Conic-Gradient schließt bei 0°, ein Wechsel dort ergäbe eine harte Naht auf zwölf Uhr, die mitrotiert), der dunkle Ton ist nahezu-schwarz statt #000 (auf dunklem Theme verschwände die halbe Ringfläche im Hintergrund) und der Glow wechselt von Violett auf Rot. Legendary ist die einzige Leiterstufe mit Emblem, weil sie als einzige mehr bedeutet als „eine Stufe über Platin“: Sie ist der vollständige Katalog, und Vollständigkeit kann eine Ringfarbe nicht sagen. Die Krone ist golden (#ffd166) und bricht damit bewusst die Faustregel „Farbe aus den eigenen Stops“ — dieser Ring durchläuft vier Farbtöne, jeder einzelne wirkte auf der Krone willkürlich, und eine nicht-goldene Krone liest als beliebiges Icon. Neues Icon Icons.Crown (über die icon()-Factory gebaut, nicht als Fest-Größe wie Trophy, weil das Badge mit dem Avatar skaliert). Beide sitzen in derselben Ecke (top-right, der Default): Dass Embleme als eine Klasse von Sache lesen, wiegt schwerer als eine kopfmittig getragene Krone — die gemeinsame Ecke lässt sie wie ein System aussehen statt wie Deko pro Rahmen (eine kopfmittige Variante war gebaut und wurde zurückgenommen). Die Einstellmöglichkeit bleibt: position kennt sechs Werte, Default top-right; die zentrierten überhängen bewusst stärker als die Ecken (auf dem Scheitelpunkt wirkt der Eck-Versatz eingesunken statt aufgesetzt), und bottom-right sollte auf Avataren mit Anwesenheitspunkt ungenutzt bleiben, da PresenceBadge seinen Overlay bei exakt bottom: 0; right: 0 zeichnet und im Sidebar-Avatar beide am selben Container hängen.
added Kampagnen-Empfänger-Drawer: Account Manager der Firma + Absprung auf die Firmen-Detailseite
geändert CampaignItemDrawer.tsx, de.json, en.json. WHY: Feedback 31.07.2026 (IT Fabrik, /campaigns/:id): „In der Detailansicht wäre der zuständige Account Manager schön“ und „man sollte von der Detailansicht auch auf die Firma wechseln können“. Der Drawer zeigte bisher ausschließlich Kampagnen-Eigenes (Status, Ergebnis, Gespräche) plus den eingefrorenen Aufnahme-Snapshot — wer beim Abtelefonieren wissen wollte, wem der Kunde gehört, musste die Kampagne verlassen, im CRM suchen und den Kontext neu aufbauen.
Der Account Manager kommt bewusst NICHT aus dem Snapshot. snapshot.fields ist das komplette Party-Dokument zum Zeitpunkt der Aufnahme (campaign-consumer.ts fields: target ?? {}) und enthält responsibleUserId technisch bereits — aber eingefroren. Genau dieses Feld wechselt jedoch (Zuständigkeitsübergabe, Austritt), und ein veralteter AM sieht nicht falsch aus, sondern plausibel: Man ruft den Falschen an und merkt es nicht. Der Drawer lädt die Party deshalb live per GET /api/v1/parties/:id. Nebeneffekt, der die Rechteprüfung geschenkt mitbringt: Ohne party_view schlägt der GET fehl und weder AM-Zeile noch Firmen-Link erscheinen — kein zusätzlicher Guard nötig.
Berücksichtigt ist die Vererbung nach ADR 0182b: Bei einer PERSON ohne eigenen AM (hasOwnResponsibleUser === false) stehen die Manager der verknüpften Firmen in inheritedResponsibleUserIds — es können mehrere sein. Die Lesart „hat einen eigenen AM“ spiegelt exakt PartyDetailPage.tsx (expliziter Boolean gewinnt, ältere Dokumente ohne das Feld fallen auf responsibleUserId != null zurück); geerbte Werte werden als solche gekennzeichnet, damit niemand sie für eine persönliche Zuständigkeit hält. Namen kommen aus der users-Map, die der Drawer ohnehin lädt — kein zusätzlicher Request.
Der Absprung hängt am Empfänger-Namen in der Drawer-Kopfzeile: ein echtes <a href="/crm/:id">, kein reines onClick. Damit wechselt der einfache Klick per Router, Ctrl/Cmd/Mittelklick öffnet die Firma im neuen Tab und lässt die Kampagne offen — bei einer Abtelefonier-Liste der häufigere Wunsch. Zusätzlich in PeekTrigger (ADR 0324) gewickelt: Hover zeigt die Firmen-Peek-Karte, man kann also nachsehen, ohne überhaupt zu wechseln. Die Peek-Karte liegt auf Z_INDEX.DRAWER_POPUP (3700) und damit über dem Drawer (1000).
Bewusste Abgrenzung: Beides greift nur bei targetEntityType === 'party' (die Bezüge „Firmen & Kontakte“ / „Ansprechpartner“, also praktisch alle Kampagnen). Für Geräte-, Vertrags-, Ticket- oder Vertriebschancen-Kampagnen erreicht man die Firma nur indirekt über ein partyId auf der Zielentität — das wäre ein zweiter Nachlade-Schritt und bleibt offen. Keine Backend-Änderung, kein Backfill.
added Feedback-Seite: Suchleiste über Titel und Beschreibung (mit Treffer-Hervorhebung)
neu FeedbackSearch.tsx, FeedbackSearch.test.tsx; geändert FeedbackPage.tsx, de.json, en.json. WHY: Feedback 20.07.2026 (IT Fabrik, /feedback): „Ich suche gerade ein Feedback zu den Aufgaben, nur finde es zum verrecken nicht.“ Die Seite hatte als einzigen Filter die vier Typ-Tabs (Alle/Bugs/Features/Feedback) — bei mehreren Dutzend eigenen Einträgen bleibt nur Scrollen, und die Karten sind eingeklappt, zeigen also nur den Titel. Das Suchfeld sitzt rechts neben der Tab-Leiste in derselben Zeile (Toolbar bleibt einzeilig), Esc leert es, ein X-Button setzt zurück.
Bewusst client-seitig: Die Liste lädt ohnehin bereits bis zu 200 eigene Einträge in einem Rutsch (GET /api/v1/feedback?limit=200), gefiltert wird also über die schon geladenen Daten — kein Debounce, kein Roundtrip, Treffer erscheinen beim Tippen. Der Endpunkt bekam kein q-Parameter: Bei „meine eigenen Feedbacks“ ist die 200er-Grenze praktisch nie erreicht, und ein serverseitiger Regex-Scan hätte für den gemeldeten Fall nur Latenz addiert. Damit das nicht still lügt, blendet die Seite eine Zeile ein, wenn meta.total größer ist als die geladene Menge — dann durchsucht die Suche nachweislich nur die zuletzt geladenen Einträge.
Gefaltet wird auf beiden Seiten (Query wie Text): Kleinschreibung, Diakritika entfernt, ß → ss. „grosse“ findet „Größe“, „MOGLICHKEIT“ findet „Möglichkeit“ — sonst scheitert eine Suche auf einer deutschen Oberfläche an genau den Zeichen, die man beim Tippen am ehesten weglässt. Mehrere Wörter sind UND-verknüpft und dürfen sich auf Titel und Beschreibung verteilen. Die Hervorhebung arbeitet nicht auf dem gefalteten Text, sondern führt einen Index-Rückweg mit: Ein Treffer im gefalteten String wird über eine Positions-Map auf die Originalzeichen abgebildet, damit „grosse“ (6 Zeichen) auch „Größe“ (5 Zeichen) korrekt umrandet statt um ein Zeichen zu verrutschen — der Fall ist in FeedbackSearch.test.tsx gepinnt, zusammen mit dem Verschmelzen überlappender Treffer.
Suche und Match-Logik liegen in einer eigenen Datei, weil FeedbackPage.tsx mit ~1340 Zeilen längst über dem 800-Zeilen-Limit liegt; die Seite selbst wächst nur um die Verdrahtung. Nebenbei behoben: Der useMemo für die gefilterte Liste stand hinter dem if (!hasAccess) return NoPermission(); — ein Rules-of-Hooks-Verstoß, der bei wechselnder Berechtigung die Hook-Reihenfolge geändert hätte. Alle Hooks stehen jetzt vor dem Early Return. Keine Backend-Änderung, kein Backfill.
added E-Mail-Import: Outlook-.msg-Dateien werden serverseitig geparst; Drag-&-Drop-Import jetzt auch auf Vertriebschance und Beschwerde
neu msg-import.service.ts, useEmailImport.ts, EmailDropZone.tsx, 0430-outlook-msg-email-import.md; geändert eml-import.service.ts, eml-import-route.ts, ticket-operations.routes.ts, provider.interface.ts, TicketDetailPage.tsx, OpportunityDetailPage.tsx, ComplaintDetailPage.tsx; entfernt features/tickets/useEmlImport.ts. WHY: Feedback „E-Mail Drag & Drop von Outlook“ 31.07.2026: Wer eine Mail direkt aus dem klassischen Outlook-Desktop-Client in den Browser zieht, übergibt keine RFC-822-Datei, sondern ein virtuelles <Betreff>.msg (OLE/CFB-Container) — der bisherige Import lehnte das mit „nur .eml“ ab; der frühere Fix zeigte lediglich einen Hinweis-Toast. Jetzt parst ein reiner Parser (@kenjiuno/msgreader + RTF-De-Encapsulation via rtf-stream-parser/decompressrtf, Encoding via iconv-lite) das .msg in dieselbe kanonische EmailMessage-Form wie parseEmlBuffer; die Formatentscheidung ist ein Magic-Byte-Sniff, kein Dateinamens-Raten. Downstream (Anhänge, Audit-History) ist für beide Formate identisch (importEmailToEntity, vormals importEmlBufferToEntity).
Dedupe auf Message-ID: Dieselbe Mail ein zweites Mal auf dieselbe Entität gezogen (oder bereits per Mailbox-Sync/M365-Add-in importiert) ist jetzt ein idempotenter No-Op mit duplicate: true statt einer doppelten Nachricht. Außerdem schreibt der Import jetzt die tenant-interne users._id (request.internalUserId) statt der better-auth-Session-ID in createdBy/uploadedBy/History-Actor.
Frontend generalisiert: Der Drag-&-Drop-/Dateiauswahl-Flow lag als useEmlImport im Tickets-Feature und war nur dort verfügbar. Er lebt jetzt feature-neutral als hooks/useEmailImport.ts + components/shared/EmailDropZone.tsx (kein Cross-Feature-Import) und hängt zusätzlich am E-Mail-Tab von Vertriebschance und Beschwerde; der Ticket-Sonderweg in ticket-operations.routes.ts (~130 Zeilen Kopie) ist auf die parametrisierte registerEmlImportRoute-Factory zurückgebaut. Neues Outlook/OWA kann konstruktionsbedingt nicht in Webseiten hinaus draggen (Drop ohne File-Objekte) — dort erscheint weiterhin die Anleitung „erst auf den Desktop ziehen“. Kein Backfill.
added Minutengenaue Zuschlagsaufteilung — Segmentierer + ADR (Phase 1 von 5)
neu surcharge-segments.ts, surcharge-segments.test.ts, 0431-minute-accurate-surcharge-segmentation.md; geändert index.ts. WHY: Zuschläge auf den Stundensatz entscheiden heute alles oder nichts für die gesamte Buchung, am Startzeitpunkt — matchSurcharges() in contract-coverage.service.ts nimmt einen Zeitpunkt, time-record-budget.service.ts friert base × (1 + Σ pct/100) als ratePerHour ein. Eine Buchung 17:00–20:00 bekommt damit gar keinen Abendzuschlag, obwohl zwei ihrer drei Stunden im Fenster ab 18:00 liegen; umgekehrt bekommt eine Buchung, die im Fenster startet und danach weiterläuft, den Zuschlag auf die volle Dauer. Beide Richtungen kosten Geld und fallen niemandem auf.
splitIntoSurchargeSegments() zerlegt eine Buchung in eine lückenlose, überschneidungsfreie Segmentliste mit den je Segment greifenden Regeln, der nach Stacking angewandten Prozentzahl und dem Faktor — plus minutesBySurchargeId (füttert später appliedSurcharges[].appliedMinutes) und dem minutengewichteten effectiveFactor, aus dem der Mischsatz entsteht (17:00–20:00 mit +15 % ab 18:00 → 1,10 → 110 €/h bei 100 €/h Basis, entspricht exakt 330 € für drei Stunden). Reine Funktion ohne I/O und ohne Zeitzonen-Auflösung: Aufrufer übergeben Minuten ab lokaler Mitternacht pro Kalendertag — derselbe Vertrag wie computeSurchargeBreakdown (ADR 0036b), das auf der Personalseite bereits minutenweise rechnet.
Drei Fälle, die vorher nirgends behandelt waren: Buchungen über Mitternacht (Freitag 22:00 → Samstag 02:00 wechselt den Wochentag mitten in der Buchung — genau der Fall, in dem ein Wochenendzuschlag heute komplett danebenliegt), Zeitfenster über Mitternacht (22:00–06:00, als OR gegen denselben Tag geprüft — die Semantik, die contract-coverage.service.ts seit jeher nutzt), und die 23:59-Grenze: Fensterenden sind exklusiv, ein Fenster bis 23:59 ließ die letzte Minute des Tages unbelegt; 23:59 wird jetzt als Tagesende gelesen, weil ein HH:MM-Feld „24:00“ nicht ausdrücken kann.
Stacking ist ein Parameter: ADDITIVE (Default, heutiges 1 + Σ pct/100) oder MAX (höchster Einzelzuschlag gewinnt, Tarifvertragspraxis und Default der Personalseite). Bei reinen Rabatten wählt MAX konsequenterweise den kleinsten Rabatt. Die Wochentagsliste folgt bewusst der strengeren Vertragslesart (null = alle Tage, leeres Array = kein Tag), weil contract.conditions.overtimeSurcharges mit daysOfWeek?.includes(dow) ?? false gated; Leistungsart-Zuschläge meinen mit einer leeren Liste „alle Tage“ und müssen in Phase 3 auf null normalisiert werden — die Abweichung ist am Typ dokumentiert. holidayMode (ADR 0163) spiegelt matchSurcharges() exakt, inklusive der nicht offensichtlichen Regel, dass ein Feiertag bei IGNORE Wochentagszuschläge unterdrückt, damit eine ONLY_ON_HOLIDAYS-Regel übernehmen kann ohne doppelt zu greifen.
Noch nichts davon ist verdrahtet — kein Aufrufer, keine Verhaltensänderung, keine Beträge betroffen. Phasen 2–5 (Stacking konfigurierbar + Überlappungswarnung in den Editoren, serverseitige Berechnung, Persistenz der Segmente inkl. Einzelpreise, Ausweisung unter der Rechnungsposition) stehen im ADR. Kein Backfill — Datensätze ohne Segmente behalten ihren eingefrorenen ratePerHour.
added Zuschlags-Stapelmodus konfigurierbar + Überschneidungs-Warnung in beiden Zuschlags-Editoren (Phase 2 von 5)
neu SurchargeOverlapWarning.tsx, SurchargeStackingModeSelect.tsx; geändert surcharge-segments.ts, contract.schema.ts, service-type.schema.ts, entities.ts, ContractConditionsTab.tsx, ServiceTypeSettingsPage.tsx, de.json, en.json. WHY: Greifen zwei Zuschläge auf derselben Minute, addierte die Abrechnung sie bisher fest verdrahtet (1 + Σ pct/100) — ein Abend- und ein Wochenendzuschlag ergaben zwangsläufig +115 %, ohne dass jemand das entscheiden oder auch nur sehen konnte. Neues Feld surchargeStackingMode: 'ADDITIVE' | 'MAX' an zwei Ebenen mit Feld-Präzedenz nach dem Vorbild von pricingDateBasis (ADR 0415): contract.conditions.surchargeStackingMode > service_type.billing.surchargeStackingMode (Mandanten-Default) > ADDITIVE, aufgelöst durch resolveSurchargeStackingMode(). Der Default ist bewusst keine Präferenz, sondern der Status quo — jeder andere Default hätte Bestandsverträge still neu bepreist. MAX lässt den höchsten Einzelzuschlag der Minute gewinnen (Tarifvertragspraxis, entspricht dem Default der Personalseite in working-time-model.schema.ts).
Wichtiger als der Modus ist die Warnung. Kollisionen soll man wegkonfigurieren, nicht auflösen — dafür prüft findSurchargeOverlaps() paarweise, ob sich Wochentage und Zeitfenster zweier Zuschläge schneiden, und benennt Tag und Spanne konkret („Abendzuschlag und Wochenendzuschlag überschneiden sich Sa 18:00–24:00 → +115 %“). Rein beratend, blockiert nie das Speichern: Überschneidungen sind legitim. Die Prüfung nutzt denselben Minuten-Durchlauf wie der Segmentierer statt Fenster analytisch zu schneiden — Mitternachts-Wrap und die 23:59-Kurzform sind dort bereits gelöst, und ein zweiter Ort für dieselbe Arithmetik ist genau, wie die beiden auseinanderlaufen würden. Feiertagsmodi bleiben absichtlich außen vor: Ob ein Tag ein Feiertag ist, steht zur Konfigurationszeit nicht fest, und die interessante Kollision (Feiertag fällt auf einen Samstag) lässt sich durch Editieren von Zeitfenstern ohnehin nicht beheben — sie bei jedem Speichern zu melden wäre Lärm. Genau dafür existiert der Stapelmodus.
Beide Editoren teilen sich Warnung und Auswahl als Komponenten in components/shared — zwei Ebenen derselben Einstellung, die unterschiedlich formuliert sind, sind genau, wie eine Präzedenzkette missverstanden wird. Die jeweiligen Datenformen (Vertrag: flaches Fenster + Prozent als Dezimal-String; Leistungsart: timeRanges[] + Prozent als Zahl) werden über die neuen Adapter contractSurchargeToRule() / serviceTypeSurchargeToRule() auf die gemeinsame Regelform gebracht — dieselben Adapter, die Phase 3 zum Bepreisen braucht. Der Leistungsart-Adapter normalisiert dabei eine leere daysOfWeek-Liste auf null: Der Leistungsart-Editor meint damit „alle Tage”, der Vertrag „kein Tag“ — unnormalisiert hätte die Warnung Zuschläge stillschweigend als nie greifend behandelt.
Die Auswahl erscheint erst ab dem zweiten Zuschlag (vorher gibt es nichts zu stapeln). Nebenbei: Die Extraktion der Auswahl hat ContractConditionsTab.tsx wieder unter die 800-Zeilen-Grenze gebracht, die die Verdrahtung zuvor gerissen hatte. Weiterhin keine Verhaltensänderung an der Abrechnung — das Feld wird gespeichert und angezeigt, gelesen wird es erst von Phase 3. Kein Backfill.
added Zuschlags-Aufteilung wird am Zeiteintrag gespeichert — inkl. Einzelpreis je Segment (Phase 4 von 5)
neu time-record-segmentation-schema.test.ts; geändert time-record.schema.ts, entity-config.ts, time-record-budget.service.ts, surcharge-segments.ts, crud.routes.ts, timer.routes.ts. WHY: Phase 3 rechnet minutengenau, warf das Ergebnis aber weg — übrig blieb ein Mischsatz von z. B. 110 €/h, den niemand nachrechnen kann. Genau deshalb ist die Persistenz laut ADR Bedingung für den Mischsatz und nicht Beiwerk. Neu am Datensatz: appliedSurcharges[].appliedMinutes (Anteil je Zuschlag) und das Objekt surchargeSegmentation mit basis, stackingMode, effectiveFactor und segments[] — je Segment date, fromTime/toTime (lokale Wandzeit, 24:00 = Tagesende), minutes, surchargeIds[], percentTotal und ratePerHour.
Der Einzelpreis wird eingefroren, nicht abgeleitet. Er ließe sich aus Basissatz × Faktor rekonstruieren — aber der Basissatz kann sich ändern (neue datierte Vertragsstaffel, geänderte Kundenkondition), und ein bereits ausgewiesener Betrag darf sich nicht rückwirkend bewegen. Dieselbe Einfrier-Logik wie bei ratePerHour (ADR 0139/0163). Aus demselben Grund wird der Stapelmodus mitgespeichert: Wechselt die Vertrags-/Mandanten-Einstellung später, würde eine alte Buchung sonst nach heutigen Regeln gelesen. basis trennt minutengenau von tagesbasiert — ohne das Feld wären fehlende Segmente nicht von „keine Zuschläge“ zu unterscheiden.
Rundung, ausdrücklich dokumentiert: Σ (minutes × Segment-Satz) kann vom Rechnungsbetrag um Cents abweichen, weil die Abrechnung billableMinutes × ratePerHour (Mischsatz, 2 NK) rechnet. Die Rechnung bleibt die Autorität, die Segmente sind die Aufschlüsselung daneben — als Warnung an der Schema-Beschreibung, damit das niemand als Fehler meldet.
surchargeSegmentation steht in protectedFields: Ein client-gelieferter Breakdown wäre eine Rechnungszeile, die niemand berechnet hat. Der PATCH-Pfad reicht das Feld über die allowProtectedFields-Ausnahme durch und setzt es beim Re-Snapshot explizit auf null, wenn keine Zuschläge mehr greifen — sonst stünde die alte Aufschlüsselung neben dem neuen Satz.
Neuer Guard-Test, weil hier ein stiller Laufzeitfehler drohte: appliedSurcharges[] und die Segmente haben beide additionalProperties: false. Ein im Snapshot ergänztes, im Schema vergessenes Feld hätte den Write zur Laufzeit abgelehnt, während jeder Unit-Test um computeBudgetSnapshot grün geblieben wäre — die Buchung wäre am Nutzer gescheitert, nicht am Test. Der Test kompiliert das Schema mit denselben AJV-Optionen wie schema.service.ts und validiert echte Snapshot-Payloads, inklusive Altdatensätzen ohne die neuen Felder. Kein Backfill — fehlende Segmente heißen „vor der Umstellung gebucht“, und die Rechnung liest weiterhin den eingefrorenen ratePerHour.
added Zuschlags-Aufteilung wird ausgewiesen — auf der Rechnung und im Buchungsdialog; Leistungsart-Zuschläge wirken überhaupt erst (Phase 5 von 5)
geändert time-record-budget.service.ts, invoice-template-context.service.ts, invoice-line-items.service.ts, invoice-template-vocab.ts, invoice-template-defaults.ts, SurchargeSelector.tsx, TimeEntryDialog.tsx, crud.routes.ts, de.json, en.json. WHY (der eigentliche Fund): Beim Bauen der Ausweisung zeigte sich, dass service_type.billing.surcharges[] keinerlei Wirkung auf den Rechnungsbetrag hatte. Beide Abrechnungspfade rechnen billableMinutes × ratePerHour; in ratePerHour buk der Server aber ausschließlich Vertragszuschläge ein. Der Dialog sendet den Basissatz aus dem Preis-Resolver und die Auswahl getrennt als appliedSurcharges — ein Feld, das invoice-line-items.service.ts und billing.service.ts nur als Gate für die Neubepreisung lesen, nie als Faktor. Ein im Dialog gewählter Zuschlag wurde angezeigt, gespeichert — und ignoriert. Dieselbe Fehlerklasse wie die beiden vorherigen Funde: Die Oberfläche konnte etwas setzen, das der Server nicht verarbeitet.
computeBudgetSnapshot() segmentiert sie jetzt mit denselben Bausteinen wie Vertragszuschläge. Zwei bewusste Festlegungen: Vertragszuschläge behalten Vorrang (ADR 0163 — dieselbe Präzedenz, die der Dialog durch Ausblenden der Auswahl erzwingt), und nur die vom Nutzer gewählten Zuschläge werden bepreist, nicht alle passenden. Die Auswahl ist eine Entscheidung; würde der Server jede passende Regel anwenden, wäre Abwählen wirkungslos. Ein gewählter Zuschlag, dessen Fenster die Buchung verfehlt, deckt 0 Minuten ab — sichtbar gemacht durch die Minutenanzeige am Chip.
Rechnung: neue Template-Variable time.surchargeBreakdown (im Vokabular dokumentiert), gefüllt aus den in Phase 4 gespeicherten Segmenten: „17:00–18:00: 1,0 Std. à 50,00 € · 18:00–20:00: 2,0 Std. à 57,50 €“. Sie steht im Default-Template der service-Position hinter einem {{#if}} — bestehende Rechnungen ohne Segmente ändern sich dadurch nicht. Bewusst leer, wenn es nichts zu erklären gibt: kein Split, nur ein Segment (dann ist der Mischsatz der Segmentsatz), oder eine Buchung ohne Uhrzeiten. Ohne diese Zeile steht auf der Rechnung ein Mischsatz wie 55,00 €/h, den der Kunde gegen die konfigurierten 50,00 € + 15 % nicht nachrechnen kann — das ist der Grund, warum die Segmente überhaupt gespeichert werden.
Dialog: Jeder Zuschlags-Chip zeigt seinen Minutenanteil, berechnet mit derselben shared-Funktion, die der Server bepreist, inklusive Pausenabzug. Der wichtigste Fall ist · 0 Min. — Zuschlag gewählt, wirkt aber nicht. Damit entfällt auch die Start-Semantik der Vorauswahl (mode: 'overlap'): Eine Buchung, die ein Fenster berührt, wird vorgewählt und für genau die Minuten darin berechnet. Unter der alten Alles-oder-nichts-Bepreisung musste das start-only bleiben, sonst wäre eine 17:00–20:00-Buchung komplett bezuschlagt worden.
Beträge ändern sich für Buchungen ohne Vertragsdeckung, bei denen ein Leistungsart-Zuschlag gewählt ist — vorher wirkungslos, jetzt anteilig wirksam. Freigegeben, weil das Rechnungswesen noch nicht produktiv genutzt wird (Stand 03.08.2026). Bereits gebuchte Datensätze bleiben unangetastet. ADR 0431 ist damit vollständig umgesetzt (Status Accepted). Kein Backfill.
added Rückruf: Firma als Freitext erfassbar — nicht mehr nur als CRM-Organisation
neu CallbackCompanySelector.tsx; geändert callback.schema.ts, crud.routes.ts, CreateCallbackDialog.tsx, CallbackDetailPage.tsx, CallbacksPage.tsx, de.json, en.json. WHY: Feedback 03.08.2026 (IT Fabrik, /callbacks): Die Spalte „Firma“ war in der Praxis durchgehend leer. Ursache war eine Asymmetrie im Anlege-Dialog: Die Kontaktperson ist seit jeher free-solo (CallbackContactSelector → commitFreeText() schreibt den getippten Namen nach contactName, „Als Freitext übernehmen“ im Dropdown), die Firma war dagegen ein reiner PartySelectorForm mit partyTypeFilter="ORGANIZATION" — nur Auswahl aus dem CRM, kein Freitext, und auch das enableCreate-Prop („+ Neue Firma“) war nicht gesetzt. Ruft jemand an, dessen Firma (noch) nicht im CRM steht, rutschte der Kontaktname als Freitext durch und die Firma fiel still weg. Am Telefon verlässt niemand den Dialog, um erst im CRM eine Organisation anzulegen.
Neu ist CallbackCompanySelector — dieselbe Free-Solo-Mechanik wie beim Kontakt (Dropdown mit CRM-Organisationen, „Als Freitext übernehmen“, ↑/↓ + Enter, Esc, Inline-Clear). Gespeichert wird entweder partyId (CRM-Verknüpfung, Anzeigename kommt read-time über die vorhandene partyName-Anreicherung) oder das neue Freitextfeld companyName — nie beides. Diese Exklusivität ist an jeder Schreibstelle durchgesetzt: Der Create-Dialog sendet companyName nur ohne partyId, und die Detailseite setzt beim Wechsel Freitext↔CRM den jeweils anderen Wert aktiv auf null, damit keine Karteileiche zurückbleibt. Tippen in ein Feld mit CRM-Auswahl löst die Verknüpfung (wie beim Kontakt) — aus der Auswahl wird wieder Freitext.
Die Anzeige zieht überall denselben Fallback partyName ?? companyName: Detailseite (neue CompanyLine-Komponente, ersetzt die zweifach duplizierte Firmenzeile — ein Freitextname bekommt keinen /crm/-Link, ein toter Link wäre schlechter als keiner) und Listenspalte (Freitext kursiv/gedämpft mit Stift- statt Gebäude-Icon, damit auf einen Blick erkennbar ist, welche Rückrufe noch ins CRM überführt gehören). Der Fallback der Listenspalte sitzt im valueGetter, nicht nur im renderCell — sonst wäre die Spalte im Excel-Export leer geblieben.
Serverseitig drei Ergänzungen, damit die Spalte nicht Werte zeigt, mit denen das Backend nicht umgehen kann: companyName in den searchFields von callback_requests (Volltextsuche ?q=); neues optionales alsoMatchLocalField an EnrichedNameFilterCfg, damit ein Spaltenfilter auf „Firma“ beide Quellen berücksichtigt (alle Operatoren — contains/startsWith/endsWith/isAnyOf/ne/notContains/isEmpty/isNotEmpty; ohne das hätte „Firma enthält …“ die Freitext-Zeilen still übergangen, obwohl die Spalte sie anzeigt); sowie ein SORT_JOINS-Eintrag für callback_requests mit coalesceLocalFields: ['companyName'] — dasselbe Muster wie assets.location. Letzteres behebt nebenbei einen vorbestehenden Mangel: ?sort=partyName fiel bisher auf die createdAt-Sortierung zurück, der Spaltenkopf toggelte also, ohne die Reihenfolge zu ändern.
Zusätzlich sichtbar gemacht: initialPartyId (Einstieg „Rückruf anlegen“ von der CRM-Firmenseite / nach einem Anruf) war bisher ein rein verdeckter Submit-Fallback — die Firma stand nirgends im Formular und tauchte erst nach dem Speichern auf. Der Dialog löst sie jetzt beim Öffnen auf und zeigt sie im Feld; über companyTouchedRef greift der Fallback nicht mehr, sobald der Nutzer das Feld selbst anfasst (vorher ließ sich eine vorbelegte Firma gar nicht entfernen). Kein Backfill — companyName ist nullable, Bestands-Rückrufe ohne Firma bleiben schlicht ohne Firma; alte persistierte Dialog-Entwürfe (ADR 0214), die unter company noch eine ganze SelectableParty tragen, werden per Runtime-Fallback normalizeDraftCompany() normalisiert statt verworfen. Nicht enthalten (bewusst, separat zu entscheiden): Die Anrufkonsole (F2 → Rückruf) übergibt weiterhin nur suggestedContact.partyId, das im Dialog als Kontaktperson landet, nie als Firma; und der „Übernehmen“-Klick im Vorschlags-Banner leitet die Firma nicht aus der Kontaktperson ab (deriveCompanyFromContact hängt nach wie vor nur am Kontakt-Dropdown). Beides bleibt eine eigenständige Lücke, durch die Rückrufe ohne Firma entstehen.
added ADR 0427 — Teams-Link-Unfurling für Ticket-URLs (Entwurf, noch nicht implementiert); löst Phase 3 aus ADR 0361 auf
neu 0427-teams-link-unfurling-for-ticket-urls.md; ergänzt um einen Rückverweis 0361-link-previews-for-shared-entity-urls.md. WHY: Nutzermeldung — ein in Teams eingefügter Ticket-Link (https://os.codemeta.de/tickets/<uuid>) erzeugt für jedes Ticket dieselbe generische Kachel („Codemeta OS — All-in-One Plattform für Systemhäuser…“), weil Caddy für /tickets/* die statische SPA-Shell ausliefert (infra/Caddyfile.prod) und Microsofts Unfurler weder eine Session hat noch JavaScript ausführt. Gewünscht sind Ticketnummer, Betreff und Beschreibung in der Kachel.
Verhältnis zu ADR 0361: Dieselbe Ursache war dort bereits analysiert und in drei Phasen zerlegt (Phase 1 = entity-spezifischer document.title, umgesetzt; Phase 2 = route-bewusste generische og:-Tags, weiterhin offen; Phase 3 = Teams-App mit Link Unfurling). ADR 0427 löst ausschließlich Phase 3 für Tickets auf und beantwortet dabei alle fünf dort offengelassenen zu klären-Punkte; ADR 0361 wird nicht ersetzt und bleibt für Phase 1/2 gültig. Entscheidend geänderte Ausgangslage: Die in 0361 als offen geführte Bot-Framework-Registrierung ist inzwischen erledigt — die Teams-Message-Action aus ADR 0120 brauchte sie ohnehin. Neu gegenüber 0361 ist außerdem die Antwort auf dessen Punkt „Mandanten-Zuordnung bei Multi-Tenant-Nutzern”: Die Verknüpfung zeigt auf authUserId (Identität), der Tenant wird pro Unfurl aus dem Ticket ∩ user_tenant_mappings abgeleitet statt im Verknüpfungssatz eingefroren.
Der ADR entscheidet sich gegen den naheliegenden Weg (serverseitige Open-Graph-Tags hinter einer Bot-User-Agent-Weiche) — der wäre ein öffentlicher, unauthentifizierter Leseendpunkt für Betreff und Beschreibung jedes Tickets, dessen URL je irgendwo aufgetaucht ist; der User-Agent ist frei fälschbar. Stattdessen: composeExtensions[].messageHandlers (Typ link) auf dem bereits registrierten Teams-Bot (M365_TEAMS_BOT_ID, Manifest-Generator m365-manifest.service.ts, Endpunkt POST /api/v1/m365/teams-bot/messages in m365.routes.ts), der die Message-Action aus ADR 0120 trägt — die dort noch als Blocker angenommene Azure-Bot-Registrierung ist damit bereits erledigt, das Unfurling ist ein zweiter invoke-Typ plus Manifest-Block.
Kernpunkte: Bot-Framework-JWT wird künftig verifiziert (der Endpunkt ist heute bewusst unauthentifiziert und liefert bislang nur eine öffentliche Task-Module-URL — sobald Kundendaten zurückgehen, ist das nicht mehr tragbar); Identität über from.aadObjectId nur als Nachschlageschlüssel auf eine neue Systemsammlung m365_teams_user_links, die ausschließlich durch einen verifizierten Codemeta-Login entsteht (Abgrenzung zum Security-Finding M-7 in m365-auth.ts, wo der mailbox-Claim unverifiziert ist und autorisierungsfrei bleibt); danach der reguläre Autorisierungspfad (MSP-Tenant-Guard, m365_use, ticket_view + Data-Scopes); composeExtension/anonymousQueryLink wird explizit mit leerem Ergebnis beantwortet. Kein Backfill — die Sammlung startet leer, der Manifest-Block ist additiv. Wichtigste dokumentierte Einschränkung: die Karte autorisiert den Absender, nicht das Publikum — einmal eingefügt sehen sie alle Chat-Teilnehmer inkl. föderierter Externer, weshalb der Beschreibungs-Auszug an einem Tenant-Schalter hängt und interne Kommentare nie in der Karte landen.
Geändert
changed Timer-Konvertierung: Hauptbezug-Priorität PROJECT > TICKET > TASK statt TICKET > PROJECT
geändert timer.routes.ts, TimerWidget.tsx. WHY: PO-Entscheidung „gröbste Verknüpfung zählt“ (siehe Added-Eintrag zu ADR 0433). Ein Timer, der Ticket UND Projekt trägt, wird künftig über das Projekt abgerechnet (vorher Ticket); zugleich übertragen beide Frontend-Mapper und der Convert-Endpoint jetzt ALLE Timer-Links verlustfrei — vorher ging bei Aufgabe + Projekt einer der beiden Links verloren, und die zwei Mapper hatten sogar widersprüchliche Prioritäten (TASK-first im Store-Dialog vs. TICKET-first im Convert-Dialog).
changed Kampagnen-Liste: Status-Filter von Einfach- auf Mehrfachauswahl umgestellt (ResponsiveTabs → StatusFilter)
geändert CampaignsPage.tsx. WHY: Feedback 31.07.2026 (IT Fabrik, /campaigns): „Ich kann alle Abgeschlossenen anzeigen lassen, aber nicht alle außer den Abgeschlossenen.“ Die Status-Leiste war eine ResponsiveTabs-Reihe mit genau einem aktiven Wert, der als status=COMPLETED (Exact-Match) in die Query ging — jede Auswahl von zwei oder mehr Status war damit strukturell unmöglich, nicht nur umständlich. Die Seite nutzt jetzt dieselbe StatusFilter-Pille mit Checkbox-Dropdown wie Projekte und Tickets (components/data-grid/StatusFilter.tsx), inklusive eingebauter „Alle“-Reset-Zeile; „alles außer Abgeschlossen“ ist damit eine Auswahl der übrigen Status. Ein echtes NOT-Filter bleibt bewusst außen vor: der generische CRUD-Handler kennt __in/__ne, aber kein __nin — Mehrfachauswahl deckt den gemeldeten Fall ohne Backend-Änderung ab.
Der Filterschlüssel wechselt von status auf status__in (→ Mongo $in, crud.routes.ts Listen-Handler). Das ist kein Kosmetik-Rename: Ein status=DRAFT,READY würde als Exact-Match auf den String „DRAFT,READY“ auflaufen und still null Treffer liefern. campaigns führt status bereits in ALLOWED_FILTERS, die Operator-Suffixe werden pro Feld abgeleitet — keine Backend-Änderung, kein Backfill.
Zwei Details, die sonst stille Fehler ergeben hätten: (1) STATUS_EXTRA_KEYS listet neben status__in weiterhin den Legacy-Key status, obwohl der nie mehr geschrieben wird — nur so reicht das Grid eine vor diesem Deploy gespeicherte Ansicht bzw. den persistierten localStorage-/sessionStorage-Zustand an onExtraViewStateRestore durch, statt den alten Einzelwert als unsichtbaren Exact-Match neben der neuen Auswahl hängen zu lassen (dieselbe Migrationsfalle, die TicketsPage beim gleichen Umbau abfangen musste). (2) Das redundante Status-Feld im Filter-Dialog ist entfernt: dialogFilterValues hat in EntityDataGrid.mergedFilters Vorrang vor extraViewState, ein zweites Status-Feld hätte also die Toolbar-Auswahl je nach Dialog-Zustand wirkungslos gemacht. Nebenbei behoben: Das Dropdown bietet alle acht Kampagnen-Status an — die alte Tab-Reihe kannte nur sechs, ENDED_PENDING_TASKS, ARCHIVED und FAILED waren über die Toolbar gar nicht erreichbar, obwohl ENDED_PENDING_TASKS in der Liste real vorkommt.
changed Renovate-PRs #661/#695/#696/#699/#700 lokal auf dev angewandt statt gemergt — inkl. CVE-2026-14257 (brace-expansion, HIGH) in allen drei Major-Linien
geändert package.json, apps/api/package.json, apps/web/package.json, apps/desktop/package.json, apps/print-agent/package.json + die übrigen Workspace-Manifeste, pnpm-lock.yaml (Relock), tools/sales-pipeline/package-lock.json. WHY: Auftrag „alle Renovate-PRs prüfen, lokal anwenden, dann schließen“. Die fünf offenen PRs waren teils Wochen alt und schlugen inzwischen jeweils zu niedrige Zielversionen vor; ein Merge hätte Renovate in derselben Nacht Folge-PRs öffnen lassen.
- Security —
brace-expansion(CVE-2026-14257 / GHSA-mh99-v99m-4gvg, HIGH):expand()deckelt die Anzahl der Ergebnisse (max, Default 100 000), nicht ihre Länge; verkettete Brace-Gruppen ('{a,b}'.repeat(1500), ~7,5 KB) treiben den Prozess in ein unfangbares OOM. Betroffen sind alle drei Linien, die im Repo aus strukturellen Gründen koexistieren (eslint@8→minimatch@3;exceljs→archiver@5→readdir-glob@1→minimatch@5;@typescript-eslint@7→minimatch@9). Die Overrides gehen auf1.1.18/2.1.4/5.0.9— jeweils die höchste Version der eigenen Major-Linie (Advisory: gepatcht ab 1.1.17 / 2.1.3 / 5.0.8). - Bewusst NICHT wie in PR #696 vorgeschlagen: Renovate wollte den
brace-expansion@1-Override auf2.1.3heben, also über die Major-Grenze. Der Vorschlag stammt aus einer Zeit, als die 1.x-Linie noch keinen Fix hatte; inzwischen existiert1.1.17/1.1.18. Ein Major-Sprung ist hier unnötiges Risiko in genau der Ecke, die schon einmal weh getan hat (PR #636/#637 wollten@1/@2auf5.0.7heben — v5 exportiertexports.expandbenannt stattmodule.exports = expandTop, wasminimatch@3/@9zur Laufzeit zerlegt, und zwar latent hinter dem Brace-Guard, also an der CI vorbei). Geprüft und protokolliert:1.1.18und2.1.4behalten den direkten Funktions-Export,5.0.9bleibt beim benannten — die Konsumenten sehen also exakt die Form, die sie vorher hatten. - Non-Major-Welle (#699) + Lockfile-Maintenance (#700) über
pnpm update -rin einem Durchgang; dadurch liegen die Auflösungen auf dem Stand von heute statt auf dem der PR-Erstellung. Direkt sichtbar u. a.@mui/x-*9.6→9.10.1,@sentry/*10.59→10.69,@aws-sdk/*3.1042/3.1073→3.1101,@ai-sdk/*,bullmq5.79→5.81,fastify5.8.5→5.11,mongodb7.3→7.5,react/react-dom19.2.7→19.2.8,astro7.0→7.1.6,@tiptap/*3.29.1→3.29.2 (die exakt gepinnten Einträge inapps/apiundapps/web),stripe22.x,puppeteer25.2.1→25.4.0. Transitiv überschreiten genau zwei Pakete eine Major-Grenze —chromium-bidi16→17 undhtml-parse-stringify3→4 —, beide von ihren direkten Eltern (puppeteer-corepinnt17.0.2,react-i18next@17.0.11verlangt^4.0.1) so vorgegeben und nicht durch einen der unbeschränkten>=-Overrides hereingezogen; das wurde eigens gegen das alte Lockfile geprüft. - Zwei Korrekturen an dem, was
pnpm updatevon sich aus tat: Es hatte beielectron-vite(apps/desktop) undnode-windows(apps/print-agent) das Caret entfernt und damit zwei Prerelease-Ranges zu harten Pins gemacht — semantisch eine andere Aussage (^6.0.0-beta.1schließt ein späteres stabiles 6.x ein,6.0.0-beta.1friert auf die Beta ein). Beide wieder auf Caret gesetzt. Die übrigen angehobenen Range-Untergrenzen bleiben stehen: sie sind alle within-major und dokumentieren, was tatsächlich aktualisiert wurde. - Kein Backfill (reine Dependency-Änderungen, kein Schema-, Daten- oder Laufzeitpfad berührt). Verifiziert:
pnpm typecheck(turbo, alle Workspaces) grün,pnpm install --frozen-lockfilekonsistent (CI-Installationspfad),brace-expansion-Export-Form aller drei Linien empirisch nachgestellt. Die verbleibenden Peer-Warnungen (@emoji-mart/react↔React 19,@signpdf↔pdfkit,better-auth↔@better-auth/utils,@astrojs/mdx↔astro 7,@tiptap/y-tiptap3.0.6 vs. gefordertes^3.0.7) sind vorbestehend und unverändert — insbesondere die y-tiptap-Warnung stand schon vor dem Update im Lockfile (3.29.1verlangte bereits^3.0.7) und ist kein Effekt dieser Änderung.
changed ~45 ADR-Verweise im TANSS-Code zeigten auf sachfremde ADRs
geändert u. a. tanss-sync.ts, db-id-map.ts, upsert-mirror.ts, inbound-event.ts, entity-config.ts, ticket.schema.ts, tanss-unidirectional-sync.md, tanss-bidirectional-sync.md. Der gesamte TANSS-Komplex zitierte durchgängig „ADR 0366/0367“ — diese Nummern gehören real zu 0366-phone-call-opportunity-link.md und 0367-analytics-predefined-trusted-registry.md. Gemeint waren durchweg 0376 (Rückschreibpfad, Rejected) und 0377 (Ownership pro Datensatz); die Markdown-Links in den beiden Specs zeigten dadurch auf nicht existierende Dateien. Die drei legitimen Verweise auf ADR 0366 aus dem Anruf-/Kalender-Aktivitätskontext blieben unverändert.
Behoben
fixed Admin-Seite „Such-Performance“ lief in einen 504 — die Coverage-Berechnung zählte 75 Collections mit einem ungeindexten Filter
geändert support-search.routes.ts + neu support-search.routes.test.ts, SearchPerformancePage.tsx. WHY: GET /api/support/v1/search-performance antwortete nicht mehr (im Browser sichtbar als CORS-Fehler — ein 504 vom Proxy trägt keine Access-Control-Allow-Origin-Header, der eigentliche Fehler steckt eine Ebene tiefer). computeIndexCoverage feuerte pro Aufruf countDocuments({ deletedAt: null }) gegen alle 75 Collections aus SEARCHABLE_COLLECTIONS, alle gleichzeitig. countDocuments ist eine echte Aggregation, und kein Entity-Index beginnt mit deletedAt — die sind sämtlich tenantId-präfigiert. Jeder dieser 75 Aufrufe war damit ein Collection-Scan über den WAN nach Atlas; zusammen haben sie zusätzlich den Mongo-Pool leergezogen (dieselbe Klasse wie ADR 0395), weshalb der Folge-Request 500 statt 504 lieferte. Ausgerechnet die Seite, die Suchprobleme diagnostizieren soll, war damit genau dann nicht erreichbar, wenn die Suche Probleme hat.
Der Kommentar über der Funktion beschrieb bereits die richtige Lösung („estimatedDocumentCount is used for the expected side — it is metadata-only“) — nur stand sie nicht im Code. Jetzt schon: estimatedDocumentCount() liest Collection-Metadaten ohne Scan. Es zählt allerdings auch soft-gelöschte Dokumente mit, und das ist hier kein Problem, weil beide Seiten des Verhältnisses denselben Bias tragen: Zu einer soft-gelöschten Entität existiert eine Tombstone-Projektion, sie wird also links wie rechts mitgezählt. Das Verhältnis bleibt das Drift-Signal, das es sein soll; die absoluten Zahlen sind Näherungen und werden auf der Seite als „≈“ ausgewiesen.
Die verbliebenen exakten Zählungen (Tombstones, veraltete mapperVersion) laufen nur noch gegen search_documents, tragen ein maxTimeMS-Budget und degradieren auf null → „–“ in der Kachel, statt die ganze Seite scheitern zu lassen. Dazu ein 60-Sekunden-Cache (die Kennzahl bewegt sich langsam, die Seite pollt im Minutentakt) und ein Concurrency-Limit von 8, damit der Pool auch bei kaltem Cache nicht mehr die Ursache eines Ausfalls sein kann.
Der neue Test pinnt genau die Eigenschaften, deren Fehlen den Ausfall verursacht hat: kein gefilterter Count gegen eine Quell-Collection, Metadaten-Zählung für jede indizierte Collection, begrenzte Parallelität, Zeitbudget auf jedem exakten Count, und ein Timeout ergibt null statt einer geplatzten Antwort.
fixed Vier kreuz-mandantige Queries ohne passenden Index (Examined:Returned bis 12,5 Mio. : 1)
geändert index-registry.ts, init.js; dokumentiert in ADR 0437. WHY: Im selben Profiler-Lauf sichtbar, aber unabhängig vom Workflow-Vorfall. Alle vier teilen dieselbe Fehlerklasse: Ein Index existiert, ist aber tenantId-präfigiert, während die Query kreuz-mandantig läuft — falscher Prefix, also COLLSCAN.
user { id: 1 } sparse ist der teuerste, weil er auf dem Request-Pfad liegt: Der Auth-Lookup in tenant.ts (und Geschwister in plugins/auth.ts, middleware/support-auth.ts, Feedback-/Invitations-Routen) matcht $or: [{ id }, { _id }], weil better-auth die Id je nach Shape auf _id oder auf id ablegt (ADR 0234/0238). MongoDB bedient ein $or nur dann aus Indizes, wenn jeder Zweig indiziert ist — _id ist es immer, id war es nicht, also degradierte der komplette $or zum COLLSCAN von user: 56,5 k Ausführungen in 24 h, 534 Docs examined je zurückgegebenem Doc, 53,89 min Gesamtlaufzeit.
calendar_events { tenantId, seriesMasterId } sparse — tasks hatte diesen Index, calendar_events nie; jeder Serien-Lookup scannte die komplette Event-Collection des Mandanten (12.527.541 : 1).
calendar_events { deletedAt, startAt } für den minütlichen Reminder-Sweep (event-reminders.ts), der 1.440-mal täglich kreuz-mandantig scannte (746 : 1). Bewusst kein Partial-Index auf reminders.0: Der Planner prüft die partialFilterExpression syntaktisch gegen die Query, und ein Prädikat auf dem Array-Element-Pfad reminders.0 wird nicht als von einem Prädikat auf reminders subsumiert erkannt — der Index bliebe schlicht ungenutzt. Das Compound liefert für das 7-Tage-Fenster einen zusammenhängenden Range, die reminders-Prüfung bleibt billiger Residual-Filter auf der Restmenge.
webhook_ingress_events { provider, processedAt } für den Graph-Webhook-Drain (sync-consumer.ts), der { provider: $in […], processedAt: $exists false } ohne tenantId filtert (2.887.945 : 1). Fehlende Felder indizieren als null, $exists: false wird also vom processedAt-Key bedient.
Kein Backfill nötig: Reine Index-Adds; der Reconcile aus ADR 0314 fährt die Registry beim nächsten API-Start automatisch aus.
fixed Aktion „E-Mail senden“ auf Übergängen und Checklisten-Schritten hat nie eine Mail gesendet
geändert rule-executor.ts + Tests; neu send-email-action.ts; ADR 0430. WHY: runSendEmail schrieb eine Zeile in die Collection mail_queue. Diese hat seit ADR 0295 keinen Consumer mehr — sie wurde dort ausdrücklich als „dead collection“ durch enqueueTransactionalEmail ersetzt, die Regelaktion aber nicht mitgezogen. Jede konfigurierte „E-Mail senden“-Aktion war damit ein stiller No-Op: Zeile landet in Mongo, niemand liest sie, kein Fehler, kein Hinweis in der Oberfläche. Der Editor ließ sich vollständig ausfüllen (Empfängertyp, Betreff, Nachricht) und tat nichts.
Versand jetzt über den Ticket-Mail-Pfad, nicht über Transaktions-Mail: Die Mail wird als messages-Entität (messageType: 'EMAIL', direction: 'outbound') an der Subjekt-Entität angelegt und DRAFT → SENT überführt, was den bestehenden sendEmailHook in transition.service.ts auslöst. Bewusst nicht enqueueTransactionalEmail: eine Kundenmail zum Ticket muss in der Timeline stehen und über das Tenant-Postfach gehen, damit die Antwort des Kunden wieder im Ticket landet — eine gebrandete Transaktions-Mail hat keinen Thread, eine Antwort darauf ginge ins Leere. Der Weg erbt außerdem Signatur, Standard-Schriftart, Anhang-Auflösung und SES-Fallback, statt einen zweiten Mailtransport aufzumachen. Scheitert der Versand, wird der eben angelegte messages-Datensatz wieder soft-deleted (analog zur Aufräumlogik des Composers), damit keine nie versendete Mail als Phantom-„Ausgang“ in der Timeline stehen bleibt. Findet sich keine erreichbare Adresse, wird nichts gesendet und mit recipientType/recipientValue geloggt statt still abzubrechen.
⚠️ Verhaltensänderung mit Kundenwirkung: Übergangs-Aktionen vom Typ sendEmail, die bisher nichts taten, senden ab diesem Deploy tatsächlich. Bestehende Workflow-Konfigurationen vorher prüfen. Kein Backfill nötig; mail_queue-Altzeilen bleiben liegen und sind weiterhin bedeutungslos (Aufräumen ist ein reiner Ops-Schritt).
fixed Outlook-Kalendersync blieb für einzelne Postfächer dauerhaft stehen („Delta sync failed“, E11000)
neu diagnose-calendar-dedup-collisions.mjs; geändert calendar-sync.service.ts, calendar-sync.service.test.ts. WHY: Worker-Log vom 2026-08-03 — zwei Nutzer eines Tenants produzierten im Minutentakt E11000 … calendar_events index: tenantId_1_iCalUId_1_startAt_1, ihr Kalender bekam aus Outlook gar nichts mehr.
Ursache, zwei Defekte übereinander: (1) Die Kollisions-Erholung gegen die beiden unique-partial-Dedup-Indexe (ADR 0168 {tenantId, iCalUId, startAt}, ADR 0282 {tenantId, organizerEmail, title, startAt}) hing ausschließlich am Insert-Pfad. Ein Update verletzt dieselben Indexe aber genauso, sobald es iCalUId/organizerEmail erstmals anheftet (die Delta-API liefert sie auf Serien-Occurrences oft verspätet nach) oder startAt verschiebt — und alle vier Update-Stellen in upsertCalendarEvent reichten den rohen E11000 durch. (2) Die Batch-Schleife in deltaSync fing pro Event nichts ab: ein einziges kollidierendes Event riss den kompletten Lauf des Postfachs ab — und zwar bevor lastDeltaLink geschrieben wurde. Der nächste Zyklus holte denselben Batch, scheiterte am selben Event, und so weiter. Deshalb blieben auch alle unbeteiligten Termine desselben Postfachs stehen; von außen sah es aus wie „Sync tot“, im Log stand aber nur ein einzelner Duplikatschlüssel.
Fix: findLiveWinner, das Dead-Twin-Strippen und ein neues mergeDuplicateInto sind aus dem Insert-catch herausgezogen und liegen jetzt als gemeinsame Helfer um jeden Schreibvorgang (updateWithDedupRecovery). Bei E11000 auf einem Update wird zuerst der unsichtbare Blocker gesucht — ein soft-gelöschtes Dokument, das seine Dedup-Keys behalten hat (die Indexe filtern nicht auf deletedAt, jeder Lookup schon) — dessen Keys entfernt und einmal neu versucht. Existiert stattdessen ein lebendes Dokument mit dem Schlüssel, ist das eigene Dokument ein Alt-Duplikat desselben Meetings: Teilnehmer werden vereinigt, jede Postfach-Verknüpfung wandert nach participantSyncs (damit das nächste Delta jedes Nutzers den Gewinner per Id wiederfindet statt erneut zu duplizieren), der Verlierer wird soft-gelöscht und verliert seine Dedup-Keys — dieselbe Semantik wie scripts/dedupe-calendar-events-by-ical.mjs, nur zur Laufzeit und pro Einzelfall. Beides wird als warn mit beiden Dokument-Ids geloggt. Die Cross-Mailbox-Suche im Upsert benutzt jetzt denselben Helfer statt einer zweiten Kopie derselben zwei Queries.
Zusätzlich: ein kaputtes Event beendet nicht mehr den ganzen Lauf. Die Schleife fängt pro Event, zählt failed in die Sync-Statistik (taucht in „Delta sync completed“ auf) und macht weiter; lastDeltaLink wird gespeichert. Bewusste Abwägung: ein übersprungenes Event verliert diese eine Änderung — Graph liefert es bei der nächsten Änderung erneut, der Full-Sync gleicht es ohnehin ab. Das ist strikt besser, als jedes andere Event desselben Postfachs dauerhaft einzufrieren.
Kein Backfill. Der Fix heilt Alt-Duplikate genau dann, wenn der Sync ihnen wieder begegnet. Für den Überblick über den Bestand gibt es das rein lesende scripts/migrations/diagnose-calendar-dedup-collisions.mjs (kein --apply): zeigt, ob beide Dedup-Indexe in der Ziel-DB überhaupt existieren, wie viele soft-gelöschte Dokumente noch Dedup-Keys tragen, welche lebenden Duplikatgruppen es gibt, wem ein konkreter Kollisionsschlüssel aus dem Log gehört (--key='<iCalUId>@<startAt>') und welche Postfächer eine veraltete lastSyncAt haben — Letzteres ist der direkte Indikator für „hier läuft der Delta-Sync gegen die Wand“. Eine Massenbereinigung bleibt scripts/dedupe-calendar-events-by-ical.mjs und braucht weiterhin ausdrückliche Freigabe.
Regressionsschutz: vier neue Tests in calendar-sync.service.test.ts — Dead-Twin-Heilung auf dem Update-Pfad (inkl. der Zusicherung, dass nur deletedAt: {$ne: null} und nie das eigene Dokument gestrippt wird), Merge in den lebenden Gewinner (Teilnehmer-Union, gefaltete participantSyncs, Soft-Delete des Verlierers mit $unset), Weiterreichen eines E11000, für den es nichts zu heilen gibt, und der Nachweis, dass ein fehlschlagendes Event den deltaLink nicht mehr blockiert.
fixed Rückruf-Detailseite: „Weitere Rückrufe von …“ listete die Rückrufe ALLER Anrufer
geändert crud.routes.ts. WHY: Beim manuellen Test des Firma-Freitextfelds am 03.08.2026 aufgefallen: Das Panel unter „Weitere von Klaus Müller“ zeigte zusätzlich Rückrufe von Herrn Baumann und Frau König. Das Panel fragt GET /api/v1/callback_requests?contactPartyId=… ab (Fallback ?contactPhone=…, wenn der Rückruf keinen CRM-Kontakt hat) — aber weder contactPartyId noch contactPhone standen in der Filter-Whitelist von callback_requests. Die generische Filterschleife verwirft nicht gelistete Felder stillschweigend, die Abfrage lief also ungefiltert und lieferte die letzten Rückrufe des gesamten Mandanten, während die Überschrift „Weitere von <Name>“ das Gegenteil behauptete. Ein Bearbeiter, der die Historie eines Anrufers prüft, bekam damit fremde Vorgänge als dessen eigene präsentiert. Beide Felder sind jetzt gelistet. Exakt dieselbe Fehlerklasse wie der bereits dokumentierte tasks.completedAt-Fall (Sortier-Allowlist) — ein verworfener Parameter erzeugt keine Fehlermeldung, sondern ein plausibel aussehendes falsches Ergebnis. Data-Scopes bleiben unberührt: Der Zeilenfilter der Berechtigung greift unabhängig von dieser Allowlist, die Änderung weitet also keinen Zugriff aus, sondern verengt eine zu breite Antwort. Kein Backfill.
fixed Zeiteintrag-Anlage warf den kompletten Rate-Snapshot weg — serviceNature war bei 0 von 814 Datensätzen gesetzt, consumedAmount bei 19
neu SNAPSHOT_ONLY_FIELDS in create-protected-allowlist.ts; geändert crud.routes.ts, timer.routes.ts. WHY: createEntity() entfernt alle protectedFields aus dem Body, sofern der Aufrufer sie nicht per allowProtectedFields freigibt — und time_records fehlte in CREATE_PROTECTED_ALLOWLIST. Der POST-Handler berechnet den ADR-0139-Snapshot und schreibt ihn auf den Body, createEntity löschte ihn unmittelbar danach wieder. Betroffen waren fünf Felder: appliedRatePerHour, appliedRateSource (ADR 0415), consumedAmount, serviceNature (ADR 0205) und surchargeSegmentation (ADR 0431). Auf der Dev-Datenbank gemessen (03.08.2026, 814 Zeiteinträge): serviceNature 0× gesetzt, consumedAmount und appliedRatePerHour je 19×, appliedRateSource 16× — die wenigen Treffer stammen aus späteren PATCHes, wo die Ausnahme bereits greift. ADR 0205 war damit seit seiner Einführung vollständig wirkungslos, und die Task-/Projekt-Controlling-Aggregation summierte über ein Feld, das fast nie befüllt war. Der Timer-Stopp-Pfad (timer.routes.ts) hatte dieselbe Lücke.
Gefunden durch manuelles Durchklicken, nicht durch Tests: Typecheck, 5759 shared-Tests, 139 API-Tests und der neue Schema-Guard waren alle grün, weil keiner von ihnen den Weg durch createEntity nimmt — sie prüfen den Snapshot, nicht seinen Verbleib. Erst eine echte Buchung über die Oberfläche zeigte, dass ratePerHour (nicht protected) ankam und alles andere fehlte.
Sicherheitsdetail: Die Allowlist wirkt unbedingt und kann einen server-berechneten Wert nicht von einem client-gelieferten unterscheiden. Der POST-Handler löscht die fünf Felder deshalb vor dem Snapshot aus dem Request-Body — sonst hätte ein Client sie in genau den Fällen selbst setzen können, in denen der Snapshot nicht läuft (fehlende durationMinutes/serviceTypeId). Kein Backfill — Altdatensätze bleiben ohne Snapshot; ein Nachziehen wäre eine eigene, freigabepflichtige Migration.
fixed Vertragszuschläge wurden gegen Mitternacht statt gegen die Buchungszeit geprüft — Abendzuschläge griffen nie, Nachtzuschläge immer (Phase 3 von 5)
geändert contract-coverage.service.ts, time-record-budget.service.ts, crud.routes.ts, timer.routes.ts, surcharge-segments.ts, contract-coverage.service.test.ts. WHY: resolveCoverageForTimeRecord setzte at: new Date(tr.date) — und time_record.date ist ein reines Datum ohne Uhrzeit, also 00:00Z. Der als „Startzeitpunkt“ gedachte Match lief damit faktisch gegen Mitternacht: Ein Abendzuschlag (18:00–23:59) konnte über den Vertragspfad nie greifen, ein Nachtzuschlag (22:00–06:00, Mitternachts-Wrap) griff dagegen auf jede Buchung des Tages. Aufgefallen ist das nie, weil sämtliche Bestandstests der Zuschlagslogik ganztägige Fenster (00:00–23:59) verwenden — die einzige Fensterform, für die Mitternacht zufällig das richtige Ergebnis liefert. Sechs neue Tests decken jetzt genau die Lücke ab, inklusive der Nacht-Regression als Dauerpin.
matchSurcharges() ist durch resolveSurchargeSegmentation() ersetzt: Die Coverage bekommt die Zeitspanne der Buchung (booking.startTime/endTime, Pausen ausgeschnitten), zerlegt sie über splitIntoSurchargeSegments() (Phase 1) und liefert surchargeSegmentation mit dem minutengewichteten effectiveFactor plus appliedMinutes je Zuschlag. time-record-budget.service.ts liest nur noch diesen Faktor statt selbst 1 + Σ pct/100 zu bilden — eine 17:00–20:00-Buchung zahlt den Abendzuschlag jetzt auf zwei ihrer drei Stunden (Mischsatz 110 €/h bei 100 €/h Basis) statt auf keine. Die Feiertagsauflösung läuft pro Kalendertag, sonst bekäme eine Buchung über Mitternacht den Feiertagsstatus des falschen Tages. COVERAGE_RESOLVER_VERSION 10 → 11.
Ohne Uhrzeiten fällt die Auflösung auf Tagesebene zurück (basis: 'DAY'): Dann greifen nur ganztägige Regeln, weil ein Wochenendzuschlag aus dem Datum entscheidbar ist, ein Abendzuschlag aber nicht — er wird übersprungen statt geraten. Minutenzahlen werden in dem Fall bewusst nicht ausgeliefert, weil es keine gibt. Das ist strikt korrekter als das bisherige Verhalten und der Grund, warum alle Bestandstests (ganztägige Fenster) unverändert grün bleiben.
Zeitzonen-Falle mitbehoben: startTime/endTime sind laut Schema ISO-UTC, die Zuschlagsfenster aber in Ortszeit konfiguriert — die UTC-Stunden direkt zu lesen hätte jede Buchung um den Zonen-Offset verschoben (eine 18:00-Buchung erschiene als 16:00 und verfehlte ihr eigenes Fenster). Neu utcToLocalDateAndMinutes() in packages/shared; der Slice-Builder akzeptiert jetzt beide real vorkommenden Formen (ISO-Zeitstempel aus time_records, HH:MM aus Formularen). Der Default Europe/Berlin entspricht der Hartverdrahtung, die time-account-closing.ts und arbzg-validation.service.ts je einzeln bereits enthalten — eine dritte Kopie zu bauen wäre genau der Fehler gewesen, den Phase 0 gerade behoben hat; ein Zusammenzug der beiden bestehenden bleibt offen, weil beide an Zeitkonten/ArbZG hängen. Die in employee_profile.timeZone bereits dokumentierte Zonen-Präzedenz (employee → org_unit → tenant, explizit „für Zuschlag-Zeitfenster“) ist als Parameter vorbereitet, aber noch nicht verdrahtet.
Ab hier ändern sich Beträge. Zuschläge werden anteilig statt alles-oder-nichts berechnet; Buchungen mit Zeitfenster-Zuschlägen fallen je nach Lage höher oder niedriger aus. Freigegeben, weil das Rechnungswesen noch nicht produktiv genutzt wird (Stand 03.08.2026). Bereits gebuchte Datensätze bleiben unangetastet — die Rechnung liest den eingefrorenen ratePerHour. Kein Backfill.
fixed TANSS-Tickets: Tags landeten als nackte Zahlen („79“, „83“) statt als Klartext („Eskalation GF“, „Prio 1“) — in der Migration UND im laufenden Spiegel-Sync
neu tag-catalogue.ts, ticket.transformer.test.ts, fix-tanss-numeric-ticket-tags.ts; geändert ticket.transformer.ts, extractor.ts, tanss-sync.ts, tanss-sync.test.ts. WHY: Kundenmeldung 03.08.2026: In TANSS gepflegte Tags erschienen an Tickets als Zahlen-Chips. /api/v1/tickets hängt an ein Ticket nur tags: [{ id }] — die Namen stehen ausschliesslich im separaten Katalog /api/v1/tags (TanssTag mit name, backgroundColor, groupTagId), und auch meta.linkedEntities der Ticketabfrage führt ihn nicht mit. Der Ticket-Transformer hatte den Katalog also nie zur Hand und schrieb mangels Alternative String(t.id). Der Extractor besitzt zwar eine extractTags()-Methode, sie wird aber von keiner Migrationsphase aufgerufen, und der tagAssignmentTransformer ist ein als solcher dokumentierter No-op-Platzhalter — der Katalog erreichte den Transformer auf keinem Weg. Jetzt löst resolveTagNames() im Transformer ID → Name auf, gefüttert aus linkedEntities.tags.
Beide Schreibpfade waren betroffen, weil sie sich denselben Transformer teilen. Die Einmal-Migration lädt den Katalog über ensureTagCatalogue() einmal pro Lauf (gleiches Muster wie das bestehende ensureDeviceReferenceData() für Hersteller-/CPU-/OS-Namen; in-place-Mutation ist Pflicht, weil der Migration-Consumer die Referenz aus getLinkedEntities() bereits vor der Extraktionsschleife festhält). Der Dauerspiegel (tanss-sync.ts) lädt ihn einmal pro Tenant-Lauf und hängt ihn pro Seite in die linkedEntities — einmal pro Lauf, nicht pro Zeitscheibe (ein Lauf hat bis zu 12 Scheiben, der Katalog ändert sich darin nicht; unnötige Last auf der Produktivinstanz des Kunden ist bei TANSS ein belegtes Risiko, siehe OOM-Vorfall 17.07.2026) und nur wenn Tickets überhaupt gespiegelt werden — Supports tragen keine Tags. Die Ladelogik liegt deshalb als gemeinsamer Helfer in lib/tanss/tag-catalogue.ts: läge sie nur im Extractor, blieben genau die laufend gespiegelten Tickets bei den Zahlen. Dass gespiegelte Tickets Tags überhaupt führen, ist Absicht und in entity-config.ts so festgehalten — tags steht bewusst nicht in externalMaster.internalOnlyFields, weil TANSS sie mitliefert.
Die TANSS-Gruppe wandert bewusst NICHT ins Label: Im TANSS-UI ist „MSP Technik“ der farbige Präfix vor dem eigentlichen Tag; Codemeta-Tags sind flache Strings, und der Gruppenname würde jeden Chip verdoppeln, wo ohnehin fast alle Tags aus derselben Gruppe kommen. Preis dieser Entscheidung: Zwei gleichnamige Tags aus verschiedenen Gruppen fallen zusammen. Bei Katalog-Ausfall oder unbekannter ID bleibt die Zahl als Label stehen statt zu verschwinden — ein hässlicher Tag ist besser als eine still gelöschte Zuordnung; der Ausfall wird geloggt und bricht keinen Sync-Lauf ab (eigener Test). Kein searchText-Bezug (Tags gehören nicht zu SEARCHABLE_FIELDS), also keine Suchindex-Abhängigkeit.
⚠️ Bestandsdaten benötigen eine einmalige Reparatur: Der Code-Fix wirkt nur für künftige Läufe; bereits geschriebene Tickets tragen die Zahlen dauerhaft in der DB. scripts/migrations/fix-tanss-numeric-ticket-tags.ts (Dry Run als Default, --apply schreibt, --tenant grenzt ein) erwartet die rohe Antwort von GET /api/v1/tags als JSON-Datei — TANSS selbst spricht es nicht an, weil der Login ein Einmal-TOTP braucht (Refresh-Token-Regel). Angefasst werden nur Tickets mit sourceRef.system === 'tanss', nur rein numerische Tag-Werte und nur solche, deren Zahl eine bekannte Katalog-ID ist; Ticket-Update und history-Eintrag laufen in einer Transaktion, der Vorzustand steht in patches[0].value und ist daraus rekonstruierbar. Reihenfolge zwingend: erst Code deployen, dann das Skript — umgekehrt schreibt der nächste Spiegel-Lauf (Standardintervall 15 min) die Zahlen wieder zurück. DO NOT RUN ohne Moritz’ explizite Freigabe.