# Bug D — « déplier » n'est pas « sélectionner » : `/search?folder_path=` renvoyait la Boîte de réception en HTTP 200 **Date** : 10/08/2026 (soir) **Instance** : hermes-tt **Statut** : **CAUSE TROUVÉE, CORRIGÉE ET VALIDÉE** · bug C corrigé dans le même passage · 46 notes polluées purgées · **bug E découvert par les tests et bloqué** (recherche élargie par OWA) **Fichiers** : `/volume1/docker/hermes-mail-browser/app/main.py` — `go_to_folder_path` (D, C) · `/volume1/docker/nyora-notes-tt/hermes_mail_client.py` — `_verifier_portee` (E) **Sauvegardes** : `main.py.bak-20260810-212013-pre-fix-DC` · `hermes_mail_client.py.bak-20260810-*-pre-fix-E` Suite directe de [`nyora-notes-tt-parsing-owa-pua-10-08-2026.md`](nyora-notes-tt-parsing-owa-pua-10-08-2026.md) (bugs A/B corrigés, D mitigé par `HermesFolderScopeError`, C diagnostiqué). Ce runbook ne traite que la **cause de fond** de D et le correctif de C. La désambiguïsation des chemins reste décrite dans [`mail-o365-folder-path-disambiguation.md`](mail-o365-folder-path-disambiguation.md), et l'anti-toggle du 02/08 dans [`mail-o365-toggle-expand-non-idempotent-fix.md`](mail-o365-toggle-expand-non-idempotent-fix.md). --- ## Symptôme `/search?folder_path=X` renvoyait le contenu de la **Boîte de réception** pour certains chemins, en **HTTP 200, sans erreur**. Déterministe, lié au chemin, indépendant des emojis : | Chemin | Résultat | |---|---| | `00 2026\|⏱️ suivi` | 81 items correctement scopés ✅ | | `00 2026\|Relance marchés RLA` | 76 items tous libellés « Boîte de réception » ❌ | | re-test du premier | à nouveau correct | ## Cause **Sur cette UI, déplier un dossier et le sélectionner sont deux gestes distincts — et le code les confondait.** La portée d'une recherche OWA est le **dossier courant** ; le dernier segment du chemin doit donc être *sélectionné*, pas seulement *déplié*. L'ancienne boucle de `go_to_folder_path` ne cliquait la **ligne** que par défaut, quand aucun bouton d'expansion n'était trouvé : ```python if match["expanded"] != "true": # (1) si déjà déplié : aucun clic du tout expand_btn = row.locator("button").first if await expand_btn.count() > 0: await expand_btn.evaluate("el => el.click()") # (2) chevron : déplie, ne sélectionne pas else: await row.evaluate("el => el.click()") # seul chemin qui sélectionne ``` D'où le comportement, **entièrement déterminé par la présence d'enfants** : - **dossier feuille** → pas de chevron → repli sur le clic de la ligne → **sélection réelle → OK** ; - **dossier avec enfants** → soit seul le chevron est cliqué (2), soit — s'il était déjà `aria-expanded="true"`, l'état d'expansion persistant d'un appel à l'autre — **aucun clic** (1). Le dossier n'est jamais sélectionné. `goto_owa_root()` ayant rechargé la racine, le dossier courant reste la Boîte de réception, et la recherche s'y exécute. Le branchement (1) est le fix anti-toggle du 02/08 : correct pour l'expansion, mais il a laissé la **sélection sans aucun chemin de code**. Les deux cas discriminants s'expliquent exactement : `⏱️ suivi` est une feuille, `🔁 relance marchés rla` un conteneur déplié (2 enfants). Portée mesurée sur l'arbre réel : **63 dossiers sur 336** ont des enfants — tous inatteignables. ## Correctif Séparer les deux responsabilités dans la boucle : - **segments intermédiaires** → seule l'expansion compte (comportement du 02/08 inchangé, pour que les enfants soient montés dans le DOM) ; - **dernier segment** → clic **toujours sur la ligne**, jamais sur le chevron, puis **confirmation de la sélection via `aria-selected`**, avec 3 tentatives et attente croissante. Sans confirmation → `HTTPException(502)` nommant le dossier demandé *et* le dossier réellement sélectionné. `snapshot_folders()` lisait déjà `aria-selected` et l'exposait en `selected` : la machinerie de vérification existait, elle n'était simplement pas utilisée. ## Bug C — dossiers hors Réception (même passage) `cur = inbox_candidates[0]` ancrait **tout** chemin sous la Boîte de réception, rendant ses frères de niveau 2 (`éléments envoyés`, `archive`, `brouillons`…) inatteignables. Le **premier segment** est désormais cherché d'abord sous la Boîte de réception, puis — à défaut seulement — parmi les dossiers de niveau 2 du compte. Cet ordre garantit que les chemins existants (`00 2026`, `01 2025`, `02 2024`…) gardent exactement leur résolution : aucune régression possible. ## Pollution induite, et sa purge La dédup `message_id` est **globale** (`SELECT id FROM notes WHERE message_id = ?`, sans filtre de dossier). Les messages de la Réception ingérés sous un `folder_path` arbitraire y occupaient donc l'identifiant du vrai message, ce qui **aurait empêché leur ré-ingestion correcte** au backfill. La purge était un prérequis, pas un nettoyage cosmétique. Détection : dernière ligne de `raw_excerpt` (= libellé du dossier réel, signal déjà utilisé par `HermesFolderScopeError`) contredisant le `folder_path` assigné. **46 notes sur 399 (11,5 %)**, réparties sur **10 dossiers, tous des conteneurs** — signature exacte de la cause : | Notes | Dossier | |---|---| | 18 | `00 2026\|Consultations` | | 6 | `02 2024\|RLA` | | 4 | `02 2024\|RLA\|E. Partielles` | | 3 | `00 2026\|DR Zone Sud`, `01 2025\|Consommables 2025`, `02 2024\|Inventaire 2023`, `02 2024\|RLA\|AO 76/2024 E FO` | | 2 | `00 2026\|Automated`, `02 2024\|DR Zone Sud`, `02 2024\|RLA\|AO 66/2024 E Curatif` | Contenus manifestement étrangers à leur dossier : notifications de release logicielle sous `Inventaire 2023`, veille infra sous `RLA`, newsletter mode sous `Consultations`. Procédure (script `backups/purge_bugD.py`, dry-run par défaut, `--apply` pour exécuter) : sauvegarde `VACUUM INTO` → re-mesure de la liste (jamais figée) → suppression `notes_vec` (lié par `rowid`), `note_links`, `notes` → **rebuild de `notes_fts`** → checkpoints des 10 dossiers remis à `pending` avec `last_processed_*` à NULL. Résultat : 399 → **353 notes, 353 vecteurs, 0 incohérence résiduelle**. ### Deux pièges rencontrés pendant la purge - `no such module: vec0` — `notes_vec` est une table virtuelle sqlite-vec : passer par `setup_db.get_db_connection()`, pas par `sqlite3.connect()` brut. - `database is locked` — le container garde une connexion ouverte. Faire `docker stop nyora-notes-tt`, exécuter dans un container éphémère (`docker run --rm` sur la même image, mêmes montages), puis `docker start`. Le code étant cuit dans l'image, le script se dépose dans `backups/` (monté rw). ## Validation Batterie de 9 appels après rebuild (`--no-cache` + `--force-recreate`), garde-fou actif : | Cas | Avant | Après | |---|---|---| | `00 2026\|suivi` (feuille, référence) | 81 items scopés | **81/81** — aucune régression | | `00 2026\|Relance marchés RLA` (conteneur déplié) | 76 items « Boîte de réception » | **19/19** correctement scopés | | `02 2024\|RLA` (conteneur profond) | — | **27/27** | | `…\|AO 66/2024 E Curatif\|Notification` (homonyme) | — | **8/8** | | `Éléments envoyés` (bug C, frère L2) | inatteignable | **40/40** | | 3 appels consécutifs sur le même conteneur | alternance succès/échec (02/08) | stable, aucune alternance | **Piège de méthode** : une première série de tests a tourné en double (un job `nohup` chaîné derrière le build avait survécu à sa session SSH). Les deux séries tapant le même navigateur et `@serialize_owa` sérialisant les appels côté serveur, leurs résultats s'entrelaçaient — un test renvoyait la portée du test précédent. Toujours lancer ces batteries sous `flock`, et se méfier d'un résultat qui « pointe vers le dossier d'à côté ». ## Bug E — OWA élargit la recherche sur un dossier sans message direct Découvert par ces mêmes tests. `00 2026|Consultations` renvoyait 40 items dont le libellé majoritaire était `Archive` à **10/40** — un mélange, **stable sur 3 appels**. Même profil sur `01 2025|Cons° animation commerciale`. Ce n'est **pas** un reliquat du bug D, et les deux effets ont été isolés : `/inbox` sur ces mêmes dossiers répond « liste des messages introuvable » — c'est-à-dire **dossier vide** — au lieu du contenu de la Boîte de réception. La navigation est donc correcte ; c'est la **recherche** qui s'élargit. Quand un dossier ne contient aucun message *direct* (ses messages sont dans ses sous-dossiers), OWA étend de lui-même la requête à toute la boîte aux lettres, en HTTP 200. Correctif côté client (`_verifier_portee`) : **politique inversée**. L'hétérogénéité était traitée comme une indétermination et laissait passer l'ingestion ; elle est désormais un motif de blocage (`HermesSearchScopeWidenedError`). Justification empirique : une recherche réellement scopée est uniforme **même sur un dossier à enfants** (`02 2024|RLA` → 27/27, `Relance marchés RLA` → 19/19). L'uniformité est la norme d'une portée correcte ; son absence est une preuve suffisante. Sans ce durcissement, le backfill aurait classé des messages de toute la boîte sous ces `folder_path` — le mode de corruption exact que la purge ci-dessus venait de réparer. ## Leçon généralisable > Sur une UI React/Fluent, **le succès d'un clic ne prouve pas son effet**. Un chevron d'expansion > et la ligne qui le porte sont deux cibles distinctes aux effets distincts. Après toute > navigation dont dépend la suite, confirmer l'état atteint par un **attribut structuré** > (`aria-selected`) et échouer explicitement sinon — un 200 qui répond sur le mauvais périmètre > coûte infiniment plus cher qu'une erreur franche. Corollaire tiré du bug E : quand un garde-fou doit trancher sur des données bruitées, **le cas « je ne peux pas conclure » mérite d'être mesuré avant d'être traité comme inoffensif**. Ici il recouvrait un second mode de corruption, et la politique permissive choisie par prudence était précisément ce qui l'aurait laissé passer. --- ## Suite du 10/08 au soir — pilote, et le blocage qui reste ### Ce que le pilote a validé Ingestion réelle sur la Boîte de réception : **25 notes, portée 25/25 correcte**. Le fix D/C est donc validé au-delà de la lecture — le pipeline classe bien au bon endroit. ### BLOQUANT — `message_id` n'est PAS stable dans le temps Le prérequis considéré comme « levé par le fix A » ne l'est pas. Lot pilote de 10 dossiers, avec re-scan volontaire d'un dossier déjà ingéré : **4 notes créées au lieu de 0** (~5 %). Cause : `message_id` est dérivé des champs normalisés **dont `received_date`**, et la date n'est pas extraite de façon déterministe d'un passage à l'autre : | Message | 1er passage | 2e passage | |---|---|---| | « Veille marché du 10 Août 2026 » | `2026-08-10` | **`date_inconnue`** | | « Veille marché du 07 Août 2026 » | **`date_inconnue`** | `2026-08-07` | | « Veille marché du 09 Juin 2026 » | `2026-06-10` | `2026-06-09` (30 s d'écart, même passage) | Le fix A a bien supprimé le décalage positionnel dû au glyphe PUA, mais la **reconnaissance de motif de date** reste non déterministe sur certains rendus OWA (le troisième cas, à un jour d'écart, sent le fuseau ou la date relative). **Piste de correctif retenue : sortir `received_date` du calcul du `message_id`.** Fiabiliser le parsing de date ne ferait que déplacer le problème — un identifiant de déduplication ne doit pas dépendre du champ le plus fragile de la chaîne. Tant que ce n'est pas fait, le workflow n8n récurrent ferait gonfler la base à chaque passage. ### Concurrence SQLite — deux pipelines, un seul fichier Le pilote s'est heurté à des `database is locked`. Deux causes cumulées : 1. **Régression n8n** : `Q6ZAXgd8HYoQbk9K` (auto-sync OneDrive) tournait **toutes les 10 min au lieu de 2 h depuis le 07/08** — le correctif avait été *enregistré mais jamais publié*, la version active gardant `minutesInterval: 10` et le paramètre imbriqué parasite. Corrigé en republiant. Piège déjà listé dans PROTOCOL-INFRA (« une modif écrite n'est pas une modif live »). 2. **Connexion zombie** : le verrou survit à la fin des exécutions et n'a cédé qu'à un `docker restart` — des fonctions de `mcp_server` ne ferment pas leur connexion sur exception. Le commentaire du code (« verrou dédié `onedrive_sync_lock` […] aucune raison qu'ils se bloquent mutuellement ») est **faux au niveau SQLite** : des verrous applicatifs distincts ne changent rien au fait que les deux pipelines écrivent dans le même fichier, donc se disputent le même verrou. ### Débit mesuré **~49 s par message** (79 messages en 64 min), dominé par Mimo + embedding. Après ce gros dossier, `hermes-mail-browser` a renvoyé deux HTTP 503 et 7 dossiers ont échoué en timeout à 300 s. Un backfill de ~3 500 messages se dimensionne donc **en dizaines d'heures**, et doit être découpé et espacé plutôt que lancé d'un bloc. ### Divers `pipeline_lock` contenait 4 lignes violant sa contrainte `CHECK (id = 1)`, ce qui faisait échouer `PRAGMA integrity_check`. Supprimées → `integrity_check: ok`. **Aucun code du dépôt ne fait d'`INSERT` sur cette table** (uniquement `UPDATE … WHERE id = 1`) : origine non élucidée, à surveiller. État de la base à la clôture : **468 notes / 468 vecteurs, `integrity_check: ok`**, checkpoints 54 `done` / 128 `error_404` / 7 `error` / 5 `error_409` / 10 `pending` / 1 `in_progress`.