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

10 KiB

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.pygo_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 (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, et l'anti-toggle du 02/08 dans 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é :

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, notesrebuild 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: vec0notes_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.