Files
nas-runbooks/hermes-tt/mail-o365-bug-D-deplier-nest-pas-selectionner.md
T
Claude (hermes-perso) f3b9cc10d9 hermes-tt: bug D resolu (deplier != selectionner) + bug C + bug E decouvert et bloque
Cause de fond du bug D : go_to_folder_path confondait l'expansion d'un dossier
et sa selection. Seules les feuilles etaient reellement selectionnees ; tout
dossier a enfants laissait le dossier courant sur la Boite de reception, d'ou
un /search en HTTP 200 sur le mauvais perimetre. 63 dossiers sur 336 concernes.

Correctif : segments intermediaires = expansion seule ; dernier segment = clic
sur la ligne + confirmation par aria-selected, sinon 502 explicite.
Bug C corrige dans le meme passage (premier segment resolu aussi parmi les
freres de niveau 2, apres la Boite de reception pour retrocompatibilite).

Pollution induite mesuree et purgee : 46 notes sur 399, sur 10 dossiers tous
conteneurs. 399 -> 353 notes, 0 incoherence residuelle.

Bug E decouvert par les tests : sur un dossier sans message direct, OWA elargit
lui-meme la recherche a toute la boite. Politique du garde-fou inversee --
l'heterogeneite bloque desormais l'ingestion au lieu de la laisser passer.
2026-08-10 22:20:59 +01:00

177 lines
10 KiB
Markdown

# 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.