From 396debb4ff0dcf6ceb9a8cc1af309187a503febd Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 12 Aug 2026 16:03:20 +0200 Subject: [PATCH] =?UTF-8?q?C4=20et=20K8=20livr=C3=A9s,=20et=20le=20travail?= =?UTF-8?q?=20DOCS=20de=20la=20session=20manager-service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce commit porte deux sessions à la fois — voir la note de collision en fin de message. C4 — compression des images à l'upload, flutter build web vert. Un seul helper appelé par les deux chemins d'upload de resources_screen, le motif exact qui a fait diverger Create et Upload côté serveur en C1 puis en C3. La compression se fait avant resourceCreate parce que le pré-vol de quota de C3 porte sur sizeBytes à la création. Écart assumé à l'énoncé « JPEG q82 » : un PNG à canal alpha reste un PNG, sinon la transparence se remplit de noir. Défaut préexistant trouvé au passage : le second chemin d'upload ne renseignait sizeBytes ni avant ni après, donc tout ce qui est passé par là compte 0 octet au quota. K8 — retour à l'accueil après 5 min, flutter build apk vert. Écrit une seule fois dans section_page_detail, qui enveloppe les treize types. Le retour passe par le même dispose, donc il émet un sectionLeave avec sa durée réelle : K8 répare une statistique en plus d'un écran. Checklist D0 ajoutée — DOCS/claude design/checklist-d0-offline.html. Les 15 cas du §21, chaque groupe relié au bug qu'il décide : 21.2 tranche D2 et D4, 21.1.4 tranche D3, 21.4.1 tranche D5. Les cas 21.1.2/3/5/6 sont la première vérification RÉELLE de D1, corrigé le 11/08 mais validé par l'analyse seule. Si tout passe, le lot D se ferme et le plan perd un à deux jours — c'est le lien L11. COLLISION SUR DOCS, à retenir. La session manager-service avait des modifications stagées et non commitées sur STATUS.md, kanban.html et v1-plan.md, alors que son prompt lui demandait de ne pas toucher à DOCS/. Rien n'est perdu : ses changements et les miens coexistent, et ils sont tous dans ce commit — deux cartes ferées de son côté (tests du RAG, dettes serveur audit & plafond users) plus ses done-items. Ce que ça a produit : mes compteurs de kanban étaient calculés par arithmétique à partir d'un état déjà périmé, donc faux de trois cartes. Ils sont désormais recalés sur la MESURE — on compte les
et on écrit le résultat, on ne raisonne plus par delta. Planifié 19, six colonnes cohérentes, 44 done-items, bandeau et libellé alignés. Leçon pour la suite : un compteur dérivé d'un delta est faux dès qu'une autre main touche le fichier. Mesurer, toujours. Co-Authored-By: Claude Opus 5 --- STATUS.md | 38 +- claude design/checklist-d0-offline.html | 467 ++++++++++++++++++++++++ kanban.html | 59 ++- v1-plan.md | 18 +- 4 files changed, 543 insertions(+), 39 deletions(-) create mode 100644 claude design/checklist-d0-offline.html diff --git a/STATUS.md b/STATUS.md index 40e4282..7aa3da2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -412,10 +412,42 @@ Remplacé par `GetReferencedResourceIds()`, déjà implémentée sur les 13 sous - **Compteur utilisateurs** : « X / 5 » et bouton d'ajout désactivé au plafond. **Le SuperAdmin en est exclu** — son `GET /api/User` renvoie toutes les instances, compter cette liste contre un plafond *par instance* n'aurait aucun sens. Le point « masquer le rôle SuperAdmin à un InstanceAdmin », resté en question dans `todo-features.md`, **était déjà fait** (`_allowedRoles` filtre sur `r >= callerRole`) : vérifié, pas réimplémenté. - **Trois pièges relevés en câblant, à ne pas re-découvrir** : `AuditController` renvoie les entités **brutes**, pas un DTO (champs d'`AuditLog` en camelCase) ; `invokeAPI` **ne lève pas** sur un code d'erreur et son résultat était ignoré dans `users_screen`, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé, ce qui rendra le futur 422 du plafond visible sans retouche ; et `DropdownButtonFormField` **ne relit pas `initialValue`** sur reconstruction (`FormField.didUpdateWidget` ne traite que `forceErrorText`), donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par un `DropdownButton` piloté. -⚠️ **Deux dettes backend ouvertes par ce chantier, laissées ouvertes à dessein** (le périmètre était `manager-app` seul) : +✅ **Les deux dettes backend qu'il avait ouvertes sont fermées le 2026-08-12** — voir le volet `manager-service` ci-dessous. -1. **Le plafond de 5 n'existe pas côté serveur.** `UserController.CreateUser` ne compte rien, pas de 422, et `SubscriptionPlan` ne porte aucun champ utilisateur. Ce qui est livré est un **garde-fou d'interface** : un POST direct sur l'API passe toujours. S'il devait varier par plan, c'est une colonne — donc un changement de schéma **après** le gel du lot B. -2. **Aucune section n'est jamais journalisée.** `AuditedTypes.Contains(entry.Entity.GetType())` (`MyInfoMateDbContext.cs:126`) exige l'égalité **exacte** de type, or `Section` est **abstraite** (`Section.cs:17`) : le type runtime est toujours `SectionMap`, `SectionQuiz`… La ligne `typeof(Section)` d'`AuditedTypes` ne matche donc rien. Le journal couvre `Resource`, `Configuration`, `Device`, `User`, `Instance` — mais **pas le contenu**, précisément ce que l'usage cible voulait tracer. Correctif : `Any(t => t.IsInstanceOfType(entry.Entity))`. ⚠️ `EntityType` portera alors `SectionMap` et non `Section` : soit normaliser côté serveur, soit élargir la liste du filtre front aux 13 sous-types. Le filtre « Section » est **déjà dans l'écran** et ne rend aucune ligne d'ici là. +**Lot F — le volet `manager-service` est livré le 2026-08-12 : les quatre chantiers de dette backend.** `dotnet test` **163 → 200**, aucun test sauté. Quatre commits, `manager-service` seul. + +**1. Le journal d'audit voyait tout sauf le contenu.** `AuditedTypes.Contains(entry.Entity.GetType())` exigeait l'égalité **exacte** de type, or `Section` est **abstraite** : le type runtime est toujours `SectionMap`, `SectionQuiz`… Aucune des 13 sortes de section n'était journalisée — soit précisément ce que l'écran du 12/08 devait tracer. `Resource`, `Configuration`, `Device`, `User` et `Instance` passaient, eux : ils sont concrets, et **c'est ce qui rendait le trou invisible**. Remplacé par une remontée à la classe de base auditée (`IsInstanceOfType`). + +> **Tranché : normalisation côté serveur.** `EntityType` porte `Section`, pas `SectionMap`. Trois raisons : le filtre « Section » **déjà présent** dans l'écran se met à rendre des lignes sans toucher `manager-app`, donc sans coordonner deux repos ; élargir le filtre front aurait coûté 13 entrées de liste et 39 clés i18n **et** laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme ici ; et le sous-type concret **n'est pas perdu**, le discriminateur TPH est une propriété du modèle, donc sérialisée dans `NewValues` (`"Discriminator": "Article"`). Un test le vérifie — ce n'était pas une supposition. + +8 tests, dont un **par réflexion** qui affirme que les 13 sous-types concrets résolvent vers `Section` : la liste de types d'origine n'obligeait personne à la suivre, c'est ce qui l'a laissée devenir fausse. + +**2. Le plafond de 5 utilisateurs existe enfin côté serveur.** `CreateUser` rend **422**. Deux décisions, écrites dans le code : **5 en dur** — le faire varier par plan serait une colonne sur `SubscriptionPlan`, donc une migration après le gel du lot B, **dette V1 assumée** ; et **SuperAdmin non soumis au plafond**, seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client, ce qui s'aligne sur le front qui ne lui montre déjà pas le compteur. L'inscription self-service n'est pas concernée (premier utilisateur d'une instance neuve). Rien à retoucher côté front : le résultat d'`invokeAPI` est déjà lu depuis le correctif du 409. + +**3. `GetSummary` agrège en SQL** au lieu de charger 13 mois en mémoire. Les six agrégats qui se lisent dans le JSON de `Metadata` ne remontent plus que **deux colonnes**, et seulement pour leur type d'événement. Effet de bord voulu : les stats avancées ne sont plus calculées puis effacées pour les plans qui n'y ont pas droit. Un changement de sémantique délibéré : « la langue de la session » était prise sur le premier événement rendu par la base, **dans un ordre indéfini** — c'est désormais le plus ancien horodatage. + +⚠️ **La branche « stats avancées » n'était couverte par aucun test** : les cas existants ne seedent pas d'`Instance`, donc `hasAdvancedStats` était toujours faux et la moitié de la méthode n'était jamais exécutée. 11 tests ajoutés, **plus 4 contre un vrai Postgres** — le provider InMemory évalue tout côté client, il ne dit rien de la traduction SQL et une requête intraduisible y passerait au vert. + +**4. Le vector store est éprouvé à deux instances, sur un vrai Postgres.** 14 tests Testcontainers sur l'image de `Deployment/Dockerfile.postgres`. Ils se sautent proprement (`SkippableFact`) sans démon Docker — vérifié : **186 passés / 14 sautés / 0 échec**. Établi : pgvector **0.8.6** (donc `SET hnsw.iterative_scan`, posé à chaque recherche par `SearchAsync`, est accepté — sur une version antérieure **toute recherche échouait en production**) ; extensions `vector` et `postgis` bien créées par les migrations ; et à deux instances déséquilibrées 30 contre 1, `SearchAsync` rend le bon nombre de résultats, tous de la bonne instance, aucune fuite. + +> ⚠️ **Deux résultats contraires à ce que le plan supposait — à ne pas re-supposer à l'envers.** +> +> - **Ce qui protège du post-filtrage n'est pas le parcours itératif, c'est l'index sur `(InstanceId, ContentType)`.** Le planificateur filtre par instance d'abord et trie exactement : l'index HNSW n'est jamais touché, donc il n'y a rien à post-filtrer. Vérifié à 620 lignes (test) et à **22 000** hors suite, avec 2 000 lignes pour l'instance cible. Un test fige ce plan — si cet index disparaissait, la recherche se dégraderait **sans qu'aucune erreur ne le dise**. +> - **Et cet index retiré, `hnsw.iterative_scan = relaxed_order` ne rattrape rien** : le parcours s'épuise après ~335 lignes (`Rows Removed by Filter` à l'`EXPLAIN ANALYZE`) sans atteindre l'instance minoritaire, et rend zéro. Le même jeu de données avec l'index HNSW construit **après** l'insertion rend bien ses 20 lignes : c'est la **connectivité du graphe** qui décide, et nos migrations créent l'index sur une table vide. Le SET est correct et sans coût, on le garde — mais il ne couvre pas ce qu'on croyait. + +**Outillage, pour ne pas le redécouvrir** : `Testcontainers.PostgreSql` est épinglé en **3.10.0** — la 4.x parle l'API Docker 1.44 et l'engine local plafonne à 1.43. Et l'image est construite par le **CLI docker**, pas par le constructeur d'images de Testcontainers : celui-ci relit le `FROM` pour pré-tirer l'image de base et ne sait pas parser `tag@sha256:`. Ce digest protège la base d'un changement de glibc sous ses index — il ne se retire pas pour arranger un test. + +**5. Le journal ne suit plus les écritures de la machine — régression du point 1, corrigée le même jour.** `WeatherSyncService` écrit `section.WeatherResult` sur cron, à **6 h et 13 h**. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la **prévision OpenWeather complète en avant ET en après** — quelques dizaines de Ko, deux fois par jour, par section météo, indéfiniment. Le stockage est le moindre problème : **ce bruit noie les modifications humaines** que l'écran d'audit existe pour montrer. + +Correctif : une liste de colonnes machine (`WeatherResult`, `WeatherUpdatedDate`, `DateUpdate`) exclues du journal, et **aucune ligne produite** quand une modification ne touche qu'elles — plutôt qu'une ligne au diff vide, qui aurait déplacé le bruit sans le retirer. `DateUpdate` y est pour une raison distincte de la météo : estampillé à chaque `SaveChanges`, il figurait dans tous les diffs sans jamais rien y apprendre. **Le signal était déjà là** — un test écrit le matin même devait l'écarter à la main (`newValues.Keys.Where(k => k != "DateUpdate")`) pour rester lisible. Quand un test doit filtrer une donnée pour être lisible, la donnée n'a rien à y faire. Vérifié au passage : `AgendaSyncService` écrit des `EventAgenda`, non audités, et ne touche pas la ligne `Section` — lui n'est pas concerné. + +> ⚠️ **Comment il a été trouvé, parce que la méthode compte plus que le bug** : en cherchant à justifier un index sur `Timestamp` que j'avais signalé par réflexe. La question « pourquoi un index ? » a montré que ce qui faisait grossir la table n'était pas ce que je croyais. + +⚠️ **Deux dettes nouvelles, et une fausse alerte retirée** (détail dans [v1-plan.md lot F](v1-plan.md)) : + +1. **`AuditLog` n'a aucune purge.** `VisitEvent` purge à 13 mois, `VisitorQuestion` à 90 jours, `AuditLog` à jamais. **C'est un sujet RGPD et rien d'autre** — la table porte `UserId`, et les valeurs avant-après d'une modification de `User` contiennent e-mail, prénom et nom. Ce n'est **pas** un sujet de volume, le flot machine étant coupé. **Donc à traiter au lot J.** Recommandation : **12 mois, uniforme**, avec le même verrou que les deux purges existantes — inerte tant qu'`Audit:RetentionDays` n'est pas défini, **le pg_dump quotidien n'étant toujours pas en place** : supprimer des lignes d'audit sans restauration fine est la pire combinaison. +2. **La bascule écrira ~2 700 lignes d'audit dans la transaction globale** de `MigrationController` (2 374 ressources + 307 sections + le reste), chacune avec un instantané JSON complet. Pas fatal, mais c'est neuf pour les sections et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run. +3. ⛔ **`AuditLog` sans index sur `Timestamp` — fausse alerte, retirée.** Signalée par réflexe sur « la table va grossir » ; elle allait grossir à cause de la météo. Le flot machine coupé, il reste les éditions humaines — de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'un `ORDER BY Timestamp DESC LIMIT 50` trie en millisecondes sans index. **Pas d'index, pas de migration, gel du lot B intact.** **Lot C2 et C3 — livrés le 2026-08-12. Le lot médias est terminé côté serveur.** `dotnet build` vert, `dotnet test` **163/163** (148 au départ, +7 pour C2, +8 pour C3). diff --git a/claude design/checklist-d0-offline.html b/claude design/checklist-d0-offline.html new file mode 100644 index 0000000..3e4c743 --- /dev/null +++ b/claude design/checklist-d0-offline.html @@ -0,0 +1,467 @@ +D0 — Visite hors ligne, checklist terrain + + + +
+ +
+
+ D0 · test-plan §21 + mymuseum-visitapp + 15 cas + 12 août 2026 +
+

Visite hors ligne — ce qu'on va vraiment mesurer

+

+ Cette passe ne cherche pas à valider l'app : elle cherche à savoir + lesquels des quatre bugs offline restants existent encore. Chaque groupe de cas + ci-dessous pointe vers un bug précis. Un groupe qui passe élimine son bug ; un groupe qui échoue + lui donne sa priorité. C'est le seul moyen de trier D2 à D5 autrement qu'au jugé. +

+
+ +
+

Avant de commencer

+

+ Les trois APK sont construits depuis le 12 août — c'est ce que K5 a débloqué. Prendre le flavor + du lieu testé, pas dev, pour être sur les vraies données. +

+
cd mymuseum-visitapp
+adb install -r build/app/outputs/flutter-apk/app-mdlf-debug.apk
+# ou app-fortsaintheribert-debug.apk
+
+# 21.1.4 demande d'inspecter les fichiers téléchargés :
+adb shell run-as be.unov.mymuseum.mdlf ls -l files/
+
+

Ce qui a changé depuis la dernière fois qu'on a regardé

+

+ D1 est corrigé. Le switch de collecte des ressources était + commenté des deux côtés — 157 lignes mortes dans Export, 126 dans + Import. Une visite téléchargée n'embarquait que l'image de la configuration, + celle du loader et l'image de chaque section : ni contenus d'articles, ni audios, ni icônes de + carte, ni images de quiz. C'est réparé, mais vérifié par l'analyse seulement. + Les cas 21.1.2, 21.1.3, 21.1.5 et 21.1.6 sont donc la première vérification réelle + de ce correctif — s'ils échouent, ce n'est pas un bug de plus, c'est D1 qui n'a pas tenu. +

+
+
+ +
+

21.1 — État réel du téléchargement

+ +
+
+

Ce que la visite embarque

+ valide D1 · révèle D3 +
+ +
+ 21.1.1 +
+ Télécharger une visite, puis passer en mode avion + La visite s'ouvre sans erreur. +
+ +
+ +
+ 21.1.2 +
+ Ouvrir un Article contenant des images de contenu + Les images s'affichent. Premier test réel de D1. +
+ +
+ +
+ 21.1.3 +
+ Lancer un audio dans cet Article + L'audio se lit. Un échec ici peut venir de D1 ou de D3 — l'extension du fichier le départage, voir 21.1.4. +
+ +
+ +
+ 21.1.4 +
+ Inspecter le dossier local de la configuration + Un fichier par ressource, avec une vraie extension. Un .unknown sur un audio, c'est D3audio/mp3 n'est pas un MIME standard, c'est audio/mpeg. +
+ +
+ +
+ 21.1.5 +
+ Ouvrir un Quiz avec images de questions et de réponses + Les images s'affichent. Test réel de D1. +
+ +
+ +
+ 21.1.6 +
+ Ouvrir une SectionMap téléchargée + Fond de carte, icônes et points présents. Les icônes d'annotation sont précisément ce que D1 ne collectait pas. +
+ +
+
+
+ +
+

21.2 — Fraîcheur du contenu

+ +
+
+

Ce qui se met à jour, et ce qui se nettoie

+ décide D2 et D4 +
+ +
+ 21.2.1 +
+ Remplacer une image dans manager-app (même ressource), re-télécharger + L'image mise à jour remplace l'ancienne. Sinon D2 — le filtre incrémental teste la présence du fichier, pas sa version : une ressource déjà là n'est jamais re-téléchargée. +
+ +
+ +
+ 21.2.2 +
+ Relancer un téléchargement sans aucune modification + Rien n'est re-téléchargé, message « à jour ». C'est le contrôle inverse de 21.2.1 : si D2 est corrigé trop grossièrement, tout se re-télécharge à chaque fois. +
+ +
+ +
+ 21.2.3 +
+ Supprimer une ressource dans manager-app, re-télécharger + Le fichier local est purgé. Sinon D4 — le deleteSync() est commenté. ⚠️ D1 a rebranché usedImageOrAudioIds, qui pilote cette purge : elle peut être réparée sans qu'on l'ait touchée. +
+ +
+
+
+ +
+

21.3 — Types non disponibles hors ligne

+ +
+
+

Ce qui doit dire non proprement

+ aucun bug connu — cas de contrôle +
+ +
+ 21.3.1 +
+ En mode avion, ouvrir une section Weather + Message explicite « nécessite une connexion ». Ni écran blanc, ni spinner infini. +
+ +
+ +
+ 21.3.2 +
+ Idem sur une section Web + Message explicite. +
+ +
+ +
+ 21.3.3 +
+ Idem sur une section Agenda + Message explicite. +
+ +
+ +
+ 21.3.4 +
+ Ouvrir une SectionVideo pointant sur YouTube, en mode avion + Message explicite, distinct d'une vidéo téléversée qui, elle, doit se lire. C'est le cas qui sépare « pas de réseau » de « pas téléchargé ». +
+ +
+
+
+ +
+

21.4 — Robustesse

+ +
+
+

Ce qui se passe quand ça rate

+ décide D5 +
+ +
+ 21.4.1 +
+ Couper le réseau pendant un téléchargement + Le nombre d'échecs est remonté et la visite n'est pas annoncée « téléchargée ». Sinon D5 — les échecs partent dans un print. C'est le pire des quatre : le visiteur croit avoir sa visite. +
+ +
+ +
+ 21.4.2 +
+ Relancer après un téléchargement partiel + Seules les ressources manquantes sont récupérées. +
+ +
+
+
+ +
+

Comment lire le résultat

+

+ Le but n'est pas une colonne de coches vertes. C'est de rentrer avec quatre verdicts. +

+ +
+
+ D2 +

Fraîcheur. Décidé par 21.2.1 et 21.2.2. Si une image remplacée ne remonte pas, le filtre doit porter sur la version et non la présence. Conséquence produit : une correction de contenu ne parvient jamais aux visites déjà téléchargées.

+
+
+ D3 +

Extensions. Décidé par 21.1.4, confirmé par 21.1.3. Un .unknown sur un audio suffit à trancher — inutile d'attendre que la lecture échoue.

+
+
+ D4 +

Purge. Décidé par 21.2.3. Peut déjà être réparé par effet de bord de D1 : à vérifier avant de planifier quoi que ce soit.

+
+
+ D5 +

Échecs silencieux. Décidé par 21.4.1. Le plus grave des quatre parce qu'il est invisible : une visite incomplète s'annonce complète, et l'erreur ne se découvre que dans le fort, sans réseau.

+
+
+ +
+

Si tout passe

+

+ Ce n'est pas un résultat décevant, c'est un résultat qui ferme le lot D : + D2 à D5 tombent, et le plan perd un à deux jours de travail non nécessaire. Le lien + L11 dit exactement cela — le §21 conditionne l'urgence des quatre + bugs, pas leur existence. Noter aussi la plainte d'origine, « l'appli marche mal dans le + fort » : si rien ne se reproduit ici, c'est qu'elle vient d'ailleurs, et ça vaut d'être su. +

+
+
+ +
+ DOCS/claude design/checklist-d0-offline.html · dérivée de test-plan.md §21 · reporter les + résultats dans le §21 et dans STATUS.md +
+ +
diff --git a/kanban.html b/kanban.html index 694948c..5d54a93 100644 --- a/kanban.html +++ b/kanban.html @@ -448,7 +448,7 @@

Vue d'état consolidée depuis DOCS/. Source de vérité : STATUS.md — cette page en est le reflet, pas le remplaçant. Filtrez sur V1 pour ne voir que ce qui reste avant la mise en prod. L'ordre d'exécution, lui, est dans v1-plan.md : ce tableau dit où en est chaque chantier, pas lequel bloque lequel.

- Mise à jour · 2026-08-11
+ Mise à jour · 2026-08-12
Postgres v3 · pas encore en prod
@@ -458,9 +458,9 @@
2Migration v3
5Bugs ouverts
6À tester
-
22Planifié
+
19Planifié
8Bascule prod
-
41Fait récemment
+
44Fait récemment
@@ -617,15 +617,8 @@
-

Planifié

22
+

Planifié

19
-
-
tablet-appLot K · K8 — nouveau 12/08
-

Retour automatique à l'accueil après inactivité

-

Un visiteur part sans fermer l'écran ; le suivant trouve l'article du précédent. Délai proposé : 5 minutes. Sorti de la maquette K3/K7 parce que ce n'est ni l'un ni l'autre — c'est un comportement de borne qui vaut pour les treize types.

-

À écrire une seule fois, dans section_page_detail : il enveloppe déjà toutes les sections et porte déjà les crochets initState/dispose des statistiques. Bénéfice de bord : le retour passant par le même dispose, il émet un sectionLeave avec sa durée réelle au lieu de laisser une consultation ouverte jusqu'au prochain visiteur — la statistique devient juste, en plus de l'écran.

- v1-plan.md lot K — K8 · maquette kiosk-paysage -
@@ -644,17 +637,10 @@
-
manager-serviceDevenu pressant 09/08
-

GetSummary agrège en mémoire

-

eventsQuery.ToList() charge tous les événements de la période avant d'agréger. Tenable tant que la fenêtre était de 30 jours ; elle vient de passer à 13 mois. À passer en SQL avant de vendre le rapport annuel à un gros site — sinon c'est la mémoire du conteneur qui arbitre.

- todo-features.md — Plans & Quotas -
- -
-
manager-serviceDette · découvert 09/08
-

Aucun test ne couvrira le RAG

-

Ajouter ContentEmbedding a cassé 116 des 124 tests d'un coup : la suite tourne sur EF InMemory, qui ignore le type Vector et refuse de valider le modèle. Contourné en excluant l'entité hors Npgsql — les tests repassent, mais vector store, recherche cosinus, index HNSW et contrainte anti-doublon Hangfire restent structurellement non testables. Piste : Testcontainers.PostgreSql sur l'image Dockerfile.postgres.

- STATUS.md §1quater — Dette ouverte +
manager-serviceRGPD · lot J
+

AuditLog n'a aucune purge

+

VisitEvent purge à 13 mois, VisitorQuestion à 90 jours, AuditLog à jamais. C'est un sujet RGPD et rien d'autre : la table porte UserId, et les valeurs avant-après d'une modification de User contiennent e-mail, prénom et nom. Ce n'est pas un sujet de volume — l'inondation par WeatherSyncService est coupée depuis le 12/08, la table croît désormais au seul rythme des éditions humaines. Donc à traiter au lot J, pas au lot F. Recommandation : 12 mois, uniforme, via un AuditLogPurgeService calqué sur VisitEventPurgeService — 12 et non 13, le 13 mois des stats existe pour comparer une saison à la précédente et le recopier ici serait du mimétisme. ⚠️ Reprendre le même verrou : purge inerte tant qu'Audit:RetentionDays n'est pas défini, le pg_dump quotidien n'étant toujours pas en place — supprimer des lignes d'audit sans restauration fine est la pire combinaison. Écarté : garder les Delete plus longtemps que les Create/Update, ça double les règles pour un cas qu'une sauvegarde couvre déjà.

+ v1-plan.md lot F · STATUS.md §1sexies
@@ -757,13 +743,6 @@ todo-features.md — AR
-
-
backendouvert par le front du 12/08
-

Audit & plafond users — les deux dettes serveur

-

Le front est livré (12/08), ces deux points ne le sont pas. (1) Aucune section n'est journalisée : AuditedTypes.Contains(entry.Entity.GetType()) exige l'égalité exacte, or Section est abstraite — le type runtime est toujours SectionMap, SectionQuiz… donc typeof(Section) ne matche jamais. Le journal couvre ressources, configurations, devices, users et instances, mais pas le contenu — précisément ce que l'écran devait tracer. Correctif : Any(t => t.IsInstanceOfType(…)), en décidant si EntityType porte alors SectionMap ou reste normalisé à Section. (2) Plafond de 5 utilisateurs : UserController.CreateUser ne compte rien, pas de 422 — le compteur livré est un garde-fou d'interface, un POST direct sur l'API passe toujours. Le retour d'erreur du POST est déjà branché côté front : le 422 sera visible sans retouche.

- todo-features.md · v1-plan.md lot F · STATUS.md §1sexies -
-
produitV2 · nouveau 12/08

Écran SuperAdmin — add-on IA & canaux d'une instance

@@ -866,9 +845,21 @@

Fait récemment

-

Quarante et un chantiers clos entre le 5 et le 12 août 2026.

+

Quarante-quatre chantiers clos entre le 5 et le 12 août 2026.

+
+ C4 — compression des images à l'upload, 2560 px / JPEG q82 + flutter build web vert. Un seul helper appelé par les deux chemins d'upload de resources_screen — le motif exact qui a fait diverger Create et Upload côté serveur, sur StoragePath en C1 puis sur le pré-vol de quota en C3. ⚠️ La compression se fait avant resourceCreate, pas avant l'upload : le quota de C3 porte sur sizeBytes à la création, donc compresser après aurait fait décider le serveur sur la taille d'origine — et un quota qui compte 12× trop se remplit 12× trop vite. + ⚠️ Écart assumé à l'énoncé : un PNG à canal alpha reste un PNG. Le passer en JPEG remplirait la transparence de noir et abîmerait logos et filigranes ; seul l'encodage diffère, le redimensionnement s'applique aux deux, et le type MIME suit. Si le ré-encodage grossit l'original — ce qui arrive sur une petite image déjà bien encodée — l'original est conservé. ⚠️ Défaut préexistant trouvé au passage : le second chemin d'upload ne renseignait sizeBytes ni avant ni après. Tout ce qui est passé par là compte 0 octet au quota sur l'existant. +
+ +
+ K8 — la borne revient à l'accueil après 5 min sans interaction + Un visiteur part sans fermer l'écran ; le suivant trouvait le contenu du précédent. Écrit une seule fois, dans section_page_detail, qui enveloppe les treize types et portait déjà les crochets des statistiques — dans les écrans, il aurait été écrit treize fois. + Bénéfice de bord qui n'en est pas un : le retour passe par le même dispose, donc il émet un sectionLeave avec sa durée réelle. Sans lui, une consultation abandonnée restait ouverte jusqu'au visiteur suivant et gonflait la durée mesurée d'autant — K8 répare une statistique en plus d'un écran. Listener plutôt que GestureDetector pour réarmer : il observe les pointeurs sans concurrencer les gestes des écrans en dessous. ⚠️ Non vérifié : le comportement après 5 minutes réelles, et le fait que l'accueil soit bien la première route de la pile. +
+
La borne couvre 12 des 13 types — SectionArticle (K7) et SectionEvent (K3) livrés flutter build apk --debug vert, APK produit. Seul SectionParcours reste dehors, et définitivement — un parcours guidé fait marcher le visiteur. Les deux écrans suivent la maquette paysage : rail média ancré + colonne de lecture plafonnée pour l'article, bande héros à 26 % + programme + carte vive pour l'événement, détail d'un bloc sous la carte et non en showModalBottomSheet. @@ -908,6 +899,14 @@ Trois de ces crans sont des alignements sur mymuseum-visitapp, qui utilise le même plugin mapbox sans rencontrer aucun de ces problèmes. Réflexe à garder : comparer les deux gradle.properties et les deux build.gradle avant de chercher. Ce que ça débloque : compileSdkVersion 36, sans lequel plus aucune mise à jour n'est publiable sur le store. ⚠️ Le compte de « six crans » annoncé plus tôt dans la journée était provisoire — il datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite.
+
+ Lot F côté manager-service : les quatre chantiers de dette backend, en une passe + dotnet test 163 → 200, aucun test sauté, quatre commits, manager-service seul. 1) Le journal d'audit voyait tout sauf le contenu : AuditedTypes.Contains(entry.Entity.GetType()) exigeait l'égalité exacte, or Section est abstraite — aucune des 13 sortes de section n'était journalisée, soit précisément ce que l'écran du 12/08 devait tracer. Resource, Configuration, Device, User et Instance passaient, eux : ils sont concrets, et c'est ce qui rendait le trou invisible. Tranché : normalisation côté serveurEntityType porte Section, donc le filtre déjà présent dans l'écran se met à rendre des lignes sans toucher manager-app ; élargir le filtre front aurait coûté 13 entrées et 39 clés i18n et laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme. Le sous-type n'est pas perdu : le discriminateur TPH est sérialisé dans NewValues, un test le vérifie. 8 tests, dont un par réflexion sur les 13 sous-types — la liste d'origine n'obligeait personne à la suivre, c'est ce qui l'a laissée devenir fausse. + 2) Plafond de 5 utilisateurs : CreateUser rend 422. 5 en dur (le faire varier par plan serait une colonne, donc une migration après le gel du lot B — dette V1 assumée) et SuperAdmin exempté, seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client. Rien à retoucher côté front. 3) GetSummary agrège en SQL au lieu de charger 13 mois en mémoire ; les six agrégats qui se lisent dans le JSON de Metadata ne remontent plus que deux colonnes, pour leur seul type d'événement, et les stats avancées ne sont plus calculées puis effacées pour les plans qui n'y ont pas droit. ⚠️ La branche « stats avancées » n'était couverte par aucun test : les cas existants ne seedent pas d'Instance, donc hasAdvancedStats était toujours faux et la moitié de la méthode n'était jamais exécutée. + 4) Le vector store est éprouvé à DEUX instances, sur un vrai Postgres — 14 tests Testcontainers sur l'image de Dockerfile.postgres, qui se sautent proprement sans démon Docker (186 passés / 14 sautés / 0 échec). pgvector 0.8.6 : le SET hnsw.iterative_scan que pose SearchAsync est accepté — sur une version antérieure, toute recherche échouait en production. À 30 contre 1, la recherche rend le bon nombre de résultats, tous de la bonne instance, aucune fuite. ⚠️ Deux résultats contraires à ce que le plan supposait : ce qui protège du post-filtrage n'est pas le parcours itératif mais l'index sur (InstanceId, ContentType) — le planificateur filtre d'abord et trie exactement, l'index HNSW n'est jamais touché (vérifié à 620 lignes et à 22 000) ; et cet index retiré, relaxed_order ne rattrape rien, le parcours s'épuisant après ~335 lignes sans atteindre l'instance minoritaire. Le même jeu de données avec l'index HNSW construit après l'insertion rend bien ses 20 lignes : c'est la connectivité du graphe qui décide, et nos migrations créent l'index sur une table vide. Outillage : Testcontainers épinglé en 3.10.0 (la 4.x parle l'API Docker 1.44, l'engine local plafonne à 1.43) et image construite par le CLI docker — le constructeur de Testcontainers ne sait pas parser le tag@sha256: du Dockerfile, digest qui protège la base d'un changement de glibc sous ses index et ne se retire pas pour un test. + ⚠️ Et une régression du point 1, trouvée et corrigée le même jour. WeatherSyncService écrit section.WeatherResult sur cron, à 6 h et 13 h. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la prévision OpenWeather complète en avant ET en après — quelques dizaines de Ko, deux fois par jour, par section météo. Le stockage est le moindre problème : ce bruit noie les modifications humaines que l'écran existe pour montrer. Correctif : colonnes machine (WeatherResult, WeatherUpdatedDate, DateUpdate) exclues du journal, et aucune ligne produite quand une modification ne touche qu'elles — plutôt qu'une ligne au diff vide, qui aurait déplacé le bruit sans le retirer. DateUpdate y est pour une raison distincte : estampillé à chaque SaveChanges, il figurait dans tous les diffs sans rien y apprendre — et le signal était déjà là, un test écrit le matin même devait l'écarter à la main pour rester lisible. AgendaSyncService vérifié au passage : il écrit des EventAgenda, non audités. ⚠️ Trouvé en cherchant à justifier un index sur Timestamp signalé par réflexe — la question « pourquoi un index ? » a montré que ce qui faisait grossir la table n'était pas ce que je croyais. L'index est retiré du backlog : pas de migration, gel du lot B intact. dotnet test 203/203. +
+
Écran d'audit log et compteur d'utilisateurs — le lot F côté manager-app Deux chantiers dans la même passe, manager-app seul. Écran « Activité » sur GET /api/Audit : filtres instance / type d'entité / utilisateur / plage de dates, pagination 50, clic sur une ligne → détail avant-après en table, entrée de menu réservée à role.value == 0 comme la policy de l'endpoint. Les ids sont résolus en noms, un id inconnu restant affiché tel quel — le journal doit rester lisible après la disparition de ce qu'il décrit. Compteur « X / 5 utilisateurs » et bouton d'ajout désactivé au plafond, SuperAdmin exclu : sa liste couvre toutes les instances, la compter contre un plafond par instance n'aurait aucun sens. manager_api_new n'a pas été touchéhttp.get direct au Bearer, comme le Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main. 40 clés i18n FR/EN/NL, flutter build web ✅. ⚠️ Trois pièges relevés en câblant : AuditController renvoie les entités brutes, pas un DTO ; invokeAPI ne lève pas sur code d'erreur et son résultat était ignoré, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé ; et DropdownButtonFormField ne relit pas initialValue sur reconstruction, donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par un DropdownButton piloté. Les deux dettes serveur qu'il ouvre sont sur leur propre carte, en Planifié. diff --git a/v1-plan.md b/v1-plan.md index 78ea154..008e8cb 100644 --- a/v1-plan.md +++ b/v1-plan.md @@ -119,7 +119,7 @@ L'étape A du §1quater est faite (`StoragePath`/`SizeBytes` écrits à `Create` | ~~C1~~ ✅ **2026-08-11** | **Calculateur extrait** dans `Helpers/ResourceStorage.cs` (statique, comme `ImageHelper`/`SlugHelper` — logique pure, pas d'I/O, donc appelable sans DI depuis `MigrationController` et le futur backfill). Trois membres : `HasBlob(type)`, `PathFor(type, instanceId, resourceId)`, `Apply(resource, sizeBytes)`. **13 tests** fixent l'invariant des types URL. `dotnet test` **143/143** | **L5** — consommé par C2 *et* par l'écart (e) du lot G.
⚠️ **L'extraction a révélé la divergence qu'elle devait empêcher** : il y a **deux** chemins de création dans `ResourceController`, et le chemin **multipart** (téléversement) écrivait `SizeBytes` mais laissait **`StoragePath` nul** — seul le chemin JSON écrivait les deux. Les deux passent désormais par `Apply`. C'est une partie des lignes que C2 doit rattraper.
⚠️ **Angle mort laissé volontairement** : `Update` peut faire passer `Type` d'un type URL à un type fichier, laissant `StoragePath` nul sur une ligne qui a maintenant un blob. Non corrigé ici — recalculer le chemin sur une ressource existante pointerait vers un objet qui n'a pas bougé dans le bucket. À trancher avec C3 (quota), pas avant | | ~~C2~~ ✅ **2026-08-12** | **Backfill livré** : `POST /api/Resource/backfill-storage?dryRun=&instanceId=`, SuperAdmin, **`dryRun` à true par défaut** (la migration se joue sur une base vide, celui-ci sur des lignes de production). `StoragePath` par `ResourceStorage.PathFor`, `SizeBytes` par HEAD. **7 tests** | ⚠️ **La méthode annoncée ici était inapplicable** : « listing du bucket Firebase » supposait un client de stockage que le serveur n'avait pas. Le sondage passe par HEAD sur l'URL publique, comme la migration.
**Le sondeur est extrait, pas recopié** — `Helpers/ResourceSizeProbe.cs`, consommé par `MigrationController` *et* le backfill (même raisonnement que L5).
⚠️ **L'extraction a bouché un trou** : l'original ne notait l'échec que dans son `catch`, or un HEAD sur un blob absent **ne lève pas** (404 sans `Content-Length`). Ces ressources arrivaient à 0 octet **sans figurer dans le rapport**. Corrigé des deux côtés.
Le « 37 sur 45 » n'était pas vérifiable : le backfill rend son propre inventaire, `Orphans` (pas d'URL) et `Unsized` (URL muette) séparés — ce sont deux causes distinctes | | ~~C3~~ ✅ **2026-08-12** | **Quota autoritaire livré** : pré-vol sur les **deux** chemins de création, suppression du blob à `Delete`, et l'angle mort d'`Update` tranché. **8 tests**, `dotnet test` **163/163** | **L10** respecté — après C2.
⚠️ **Le pré-vol existait déjà à moitié, non documenté** : `Upload` (multipart) contrôlait et renvoyait 413, `Create` (JSON) ne contrôlait rien — or c'est le chemin qu'emprunte manager-app. Le dépassement passait par la porte de service.
⚠️ **Et les deux lectures du quota divergeaient** : `Upload` lisait le **plan**, `GetQuota` lisait l'**instance** avec le plan en repli. Une instance à quota surchargé — le mécanisme de l'add-on — affichait un chiffre et se faisait bloquer sur un autre. Fermé par `Helpers/StorageQuota`, seule source de vérité.
✅ **L'angle mort de C1 était une fausse crainte** : `PathFor` ne produit qu'un `pictures/{instanceId}/{resourceId}` — **le type n'entre pas dans le chemin**, il décide seulement s'il y en a un. Recalculer ne peut pas pointer ailleurs. `Update` rejoue donc `Apply` | -| C4 | Compression images côté client, 2560 px / JPEG q82 (~12×) | `manager-app`. Indépendant de C2 : ne touche que les nouveaux uploads | +| ~~C4~~ ✅ **2026-08-12** | **Compression livrée**, `flutter build web` vert. `Helpers/ImageCompressor.dart`, appelé par les **deux** chemins d'upload de `resources_screen` — le même motif qui a fait diverger `Create` et `Upload` côté serveur en C1 puis en C3.
⚠️ **La compression se fait avant `resourceCreate`, pas avant l'upload** : le pré-vol de quota de C3 porte sur `sizeBytes` à la création. Compresser après aurait fait décider le serveur sur la taille d'origine et compté au quota une image qui n'existe pas.
⚠️ **Écart assumé à l'énoncé « JPEG q82 »** : un PNG à canal alpha **reste un PNG**, sinon la transparence se remplit de noir et les logos sont abîmés. Le redimensionnement s'applique dans les deux cas, le type MIME suit. Et si le ré-encodage grossit l'original, l'original est conservé.
⚠️ **Défaut préexistant trouvé au passage** : le second chemin d'upload ne renseignait `sizeBytes` **ni avant ni après**. Tout ce qui est passé par là compte **0 octet au quota** sur l'existant — même famille que C1/C3, et un argument de plus pour le backfill C2 | `manager-app`. Indépendant de C2 : ne touche que les nouveaux uploads | | C6 | **Retirer la suppression de blob de `manager-app`** — `show_resource_popup.dart:130-137` | `manager-app`. Depuis C3 le serveur supprime le blob **avant** la ligne : le client tombe désormais sur un objet déjà absent. Ce n'est plus dangereux, c'est devenu **du code mort**. ⚠️ Ne pas le retirer *avant* d'avoir renseigné `Firebase:StorageBucket` en prod (I9), sinon plus personne ne supprime | | ~~C5~~ ✅ **2026-08-12** | Alerte de budget GCP **posée dans la console** | Fait côté GCP, rien à vérifier dans le code | @@ -172,6 +172,8 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales, ### Lot F — Guide IA lot 4 et dette produit (1 semaine) +🔨 **Volet `manager-service` livré le 2026-08-12** — les quatre chantiers de dette backend sont fermés en une passe : journalisation des sections, plafond de 5 utilisateurs, `GetSummary` en SQL, tests du vector store. `dotnet test` **163 → 200**. Restent l'aperçu de conversation (L9), l'onglet « Vocal », les restes non chiffrés, le rate limiting et le Customer Portal. + | Quoi | Lien | |---|---| | ~~Volet RGPD~~ | **Déplacé au lot J**, fin de backlog (décidé le 2026-08-11) | @@ -179,13 +181,17 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales, | ~~Job de regroupement en thèmes~~ | **Déplacé au lot J** (J2) — il conditionne la promesse d'agrégats des CGU | | Aperçu de conversation | **L9** — converger avec « Mode preview / cible Assistant-Persona » | | Onglet « Vocal » dans les stats | | -| `GetSummary` agrégé en SQL au lieu de `eventsQuery.ToList()` | **L13** — devenu pressant par la rétention 13 mois | +| ~~`GetSummary` agrégé en SQL~~ | ✅ **Livré le 2026-08-12.** Sessions distinctes, sommes de durée par session, durée moyenne par section, top sections, visites par jour, distributions par session, top articles et total des scans QR partent en SQL. Les six agrégats qui se lisent dans le JSON de `Metadata` (POI, agenda, quiz, jeux, menus, validité des QR) ne remontent plus que **deux colonnes**, et seulement pour leur type d'événement. **Les stats avancées ne sont plus calculées puis effacées** pour les plans qui n'y ont pas droit : les six requêtes ne partent pas. Un changement de sémantique délibéré : « la langue de la session » était prise sur le premier événement rendu par la base, dans un ordre indéfini — c'est désormais le plus ancien horodatage.
⚠️ **La branche « stats avancées » n'était couverte par aucun test** : les cas existants ne seedent pas d'`Instance`, donc `hasAdvancedStats` était toujours faux et la moitié de la méthode n'était jamais exécutée. 11 tests ajoutés, plus 4 contre un vrai Postgres — le provider InMemory évalue tout côté client et ne dit **rien** de la traduction SQL | **L13** | | ~~Écran d'audit log~~ · ~~gestion des users par instance (compteur UI)~~ | 🔨 **Front livré le 2026-08-12**, `manager-app` seul. Écran `Screens/Audit/` sur `GET /api/Audit` (filtres instance / type / utilisateur / dates, pagination 50, détail avant-après), entrée de menu `menuId: 13` réservée à `role.value == 0`. Compteur « X / 5 utilisateurs » et bouton d'ajout désactivé au plafond, SuperAdmin exclu (sa liste couvre toutes les instances). 40 clés i18n FR/EN/NL, `flutter build web` ✅. **`manager_api_new` intact** : `http.get` direct au Bearer, comme le Guide IA — une lecture seule ne justifie pas d'étendre un client qui s'édite à la main.
⚠️ **Deux dettes backend ouvertes par ce chantier, volontairement non traitées** (voir les deux lignes suivantes) | **L8** | -| **Plafond de 5 utilisateurs côté serveur** | ❌ `UserController.CreateUser` ne compte rien, pas de 422. Ce qui est livré côté front est un **garde-fou d'interface** : un POST direct sur l'API passe toujours. Le plafond n'est pas non plus dans `SubscriptionPlan` (5 champs, aucun sur les utilisateurs) — s'il devait varier par plan, c'est une colonne, donc un changement de schéma **après** le gel du lot B. Le retour d'erreur du POST est déjà branché côté front : le 422 sera visible sans retouche | -| **Aucune section n'est journalisée** | ❌ `AuditedTypes.Contains(entry.Entity.GetType())` (`MyInfoMateDbContext.cs:126`) exige l'égalité exacte, or `Section` est **abstraite** (`Section.cs:17`) : le type runtime est toujours `SectionMap`, `SectionQuiz`… donc `typeof(Section)` ne matche jamais. Le journal couvre `Resource`/`Configuration`/`Device`/`User`/`Instance` mais **pas le contenu** — précisément ce que l'écran devait tracer. Correctif : `Any(t => t.IsInstanceOfType(...))`. ⚠️ `EntityType` portera alors `SectionMap` et non `Section` : soit normaliser côté serveur, soit élargir la liste du filtre front aux 13 sous-types | +| ~~**Plafond de 5 utilisateurs côté serveur**~~ | ✅ **Livré le 2026-08-12.** `CreateUser` rend **422** au plafond. Deux décisions écrites dans le code : **5 en dur** (le faire varier par plan serait une colonne sur `SubscriptionPlan`, qui n'en porte aucune sur les utilisateurs — donc une migration après le gel du lot B : **dette V1 assumée**), et **SuperAdmin non soumis au plafond** — c'est la seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client, et ça s'aligne sur le front, qui ne lui montre déjà pas le compteur. L'inscription self-service n'est pas concernée : elle crée le premier utilisateur d'une instance neuve. Rien à retoucher côté front, le résultat d'`invokeAPI` est déjà lu depuis le correctif du 409 | +| ~~**Aucune section n'est journalisée**~~ | ✅ **Livré le 2026-08-12.** Remontée à la classe de base auditée (`IsInstanceOfType`) au lieu de l'égalité exacte.
**Tranché : normalisation côté serveur.** `EntityType` porte `Section`. Trois raisons : le filtre « Section » **déjà présent** dans l'écran se met à rendre des lignes sans toucher `manager-app`, donc sans coordonner deux repos ; élargir le filtre front aurait coûté 13 entrées et 39 clés i18n **et** laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme ici ; et le sous-type concret **n'est pas perdu**, le discriminateur TPH est une propriété du modèle, donc sérialisée dans `NewValues` (`"Discriminator": "Article"`). Un test le tient, ce n'était pas une supposition. 8 tests, dont un par réflexion qui affirme que les 13 sous-types concrets résolvent vers `Section` | +| **`AuditLog` n'a aucune purge** | ⚠️ **Dette ouverte par le correctif ci-dessus, à trancher.** `VisitEvent` purge à 13 mois, `VisitorQuestion` à 90 jours, `AuditLog` à jamais — et le journal couvre désormais le contenu, donc il ne grossira plus au rythme d'avant. **C'est un sujet RGPD, et rien d'autre** : `AuditLog` porte `UserId`, et les `OldValues`/`NewValues` d'une modification de `User` contiennent e-mail, prénom et nom. Ce n'est **pas** un sujet de volume — le flot machine coupé (ligne suivante), la table croît au rythme des éditions humaines. **Donc à traiter au lot J, pas ici. Recommandation : 12 mois, uniforme**, via un `AuditLogPurgeService` calqué sur `VisitEventPurgeService` — 12 et non 13, le 13 mois des stats existe pour comparer une saison à la précédente et le recopier ici serait du mimétisme. ⚠️ **Reprendre le même verrou** : purge inerte tant qu'`Audit:RetentionDays` n'est pas défini, parce que le pg_dump quotidien n'est toujours pas en place et que supprimer des lignes d'audit sans restauration fine est la pire combinaison. Écarté : garder les `Delete` plus longtemps que les `Create`/`Update` — ça double les règles pour un cas qu'une sauvegarde couvre déjà | +| ~~**`WeatherSyncService` inondait le journal**~~ | ✅ **Corrigé le 2026-08-12 — régression du correctif ci-dessus, trouvée en creusant « pourquoi un index ? ».** `WeatherSyncService` écrit `section.WeatherResult` sur cron, à 6 h et à 13 h. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la **prévision OpenWeather complète en avant ET en après** — quelques dizaines de Ko, deux fois par jour, par section météo. Le stockage est le moindre problème : ce bruit **noie les modifications humaines** que l'écran existe pour montrer. Correctif : une liste de colonnes machine (`WeatherResult`, `WeatherUpdatedDate`, `DateUpdate`) exclues du journal, et **aucune ligne produite** quand une modification ne touche qu'elles. `DateUpdate` y est pour une raison distincte — estampillé à chaque `SaveChanges`, il figurait dans tous les diffs sans rien y apprendre ; le signal était là, un test devait l'écarter à la main pour rester lisible. Vérifié : `AgendaSyncService` écrit des `EventAgenda`, non audités, et ne touche pas la ligne `Section` | +| ~~`AuditLog` n'a pas d'index sur `Timestamp`~~ | ⛔ **Fausse alerte, retirée le 2026-08-12.** Signalée par réflexe sur « la table va grossir » — elle allait grossir à cause de la météo, pas des humains. Le flot machine coupé, il reste les éditions réelles : de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'un `ORDER BY Timestamp DESC LIMIT 50` trie en millisecondes sans index. **Donc pas d'index, pas de migration, et le gel du lot B n'est pas remis en cause** | +| **La bascule écrira ~2 700 lignes d'audit dans la transaction globale** | ⚠️ **Vu le 2026-08-12.** 2 374 ressources + 307 sections + le reste, chacune avec un instantané JSON complet, dans la transaction unique posée par l'écart (h) du lot G. Pas fatal, mais c'est **neuf pour les sections** et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run | | Restes non chiffrés : ratio jetons → questions (`/1000`) calé sur du réel, endpoint de mise à jour d'`ApplicationInstance` pour rendre les canaux activables, quotas seed 5M/20M ajustés | §1quater | | Rate limiting sur les API Keys, Stripe Customer Portal | §1 « À faire » | -| Tests du vector store via `Testcontainers.PostgreSql` sur `Dockerfile.postgres` | **L14** — au minimum, éprouver le post-filtrage HNSW **à deux instances** avant la bascule | +| ~~Tests du vector store via `Testcontainers.PostgreSql`~~ | ✅ **Livré le 2026-08-12 — et deux résultats contredisent ce que cette ligne supposait.** 14 tests sur l'image de `Deployment/Dockerfile.postgres`, donc le même PostGIS + pgvector que la prod ; ils se sautent proprement (`SkippableFact`) sans démon Docker — vérifié : 186 passés / 14 sautés / 0 échec.
**Établi** : pgvector **0.8.6**, `SET hnsw.iterative_scan` est accepté — ce SET est posé à chaque recherche par `SearchAsync` et n'existe qu'à partir de 0.8, donc sur une version antérieure **toute recherche échouait en production** ; les extensions `vector` et `postgis` sont bien créées par les migrations ; et à deux instances, l'une saturant l'axe de la question à **30 contre 1**, `SearchAsync` rend le bon nombre de résultats, tous de la bonne instance, **aucune fuite d'un client vers un autre**.
⚠️ **1. Ce qui protège du post-filtrage n'est PAS le parcours itératif, c'est l'index sur `(InstanceId, ContentType)`.** Le planificateur filtre par instance d'abord et trie exactement : l'index HNSW n'est jamais touché, donc il n'y a rien à post-filtrer. Vérifié à 620 lignes (test) et à **22 000** hors suite, avec 2 000 lignes pour l'instance cible. Un test fige ce plan — si cet index disparaissait, la recherche se dégraderait **sans qu'aucune erreur ne le dise**.
⚠️ **2. Et si on retire cet index, `hnsw.iterative_scan = relaxed_order` ne rattrape rien** : le parcours s'épuise après ~335 lignes (`Rows Removed by Filter` à l'`EXPLAIN ANALYZE`) sans atteindre l'instance minoritaire, et rend **zéro**. Le même jeu de données avec l'index HNSW construit **après** l'insertion rend bien ses 20 lignes : c'est la **connectivité du graphe** qui décide, et nos migrations créent l'index sur une table vide. Le SET de `SearchAsync` est correct et sans coût — on le garde — mais **il ne couvre pas ce qu'on croyait**.
**Outillage** : `Testcontainers.PostgreSql` épinglé en **3.10.0** (la 4.x parle l'API Docker 1.44, l'engine local plafonne à 1.43) et l'image construite par le **CLI docker**, pas par le constructeur d'images de Testcontainers — il relit le `FROM` pour pré-tirer l'image de base et ne sait pas parser `tag@sha256:`. Ce digest protège la base d'un changement de glibc sous ses index : il ne se retire pas pour arranger un test | **L14** | ### Lot G — `MigrationController` à niveau + dry run (3 à 4 jours) — le plus gros @@ -285,7 +291,7 @@ Ordre du §2 de STATUS.md, corrigé par **L12**. | ~~K4 — énoncé d'origine~~ | **`SectionPuzzle` → `SectionGame` + le type glissant.** Le serveur a renommé et porte `GameTypes { Puzzle, SlidingPuzzle }` ; `tablet-app` a encore un écran `Puzzle/` qui ne connaît que l'ancien. **Reprendre le code de `mymuseum-visitapp/lib/Screens/Sections/Game/`** — `game_page.dart` et `sliding_puzzle_piece.dart` existent déjà là-bas, et cette version est **moins buguée** que celle de tablet-app | Récupération, pas réécriture | | ~~**K5**~~ ✅ **2026-08-12** | **Les trois flavors de `mymuseum-visitapp` construisent** (`dev`, `mdlf`, `fortsaintheribert`), exit 0, APK à l'appui. **D0 est débloqué.**
⛔ **L'énoncé était faux : il n'y avait pas de « même rattrapage » à faire.** Sa config Android était **déjà à niveau** — Gradle 8.11.1, AGP 8.9.0, `-Xmx4096M`, `enableJetifier=false`, aucun forçage d'`androidx.lifecycle`. Les dix crans de K1 ont amené **`tablet-app` jusqu'à `mymuseum`** ; le lot supposait la symétrie du problème, elle n'existait pas. Le réflexe du diff annoncé en K1 a donné la réponse en deux `cat`.
**Ce qui restait, et qui est fait** : les deux avertissements du build. NDK **27.0.12077973 → 28.2.13676358** (`speech_to_text` le réclame nommément) et Kotlin **2.1.0 → 2.3.10** (seuil 2.2.20 annoncé comme rupture ; c'est la version de `tablet-app`, donc un alignement). Six builds — trois avant, trois après. APK de **382 à 349 Mo**.
⚠️ **Un cran de plus existe, délibérément non pris** : Kotlin réglé, Flutter réclame Gradle 8.14.0 et AGP 8.11.1. C'est du **terrain neuf pour les deux repos** (`tablet-app` est à 8.11.1/8.9.1, mêmes avertissements latents) — le faire ici seul **désaligne** les deux apps. À faire d'un bloc sur les deux, ou pas du tout : c'est la dette d'outillage déjà parquée en V2 | Son code était déjà porté sur le nouveau contrat — **c'est ce qui aurait dû mettre la puce à l'oreille** : le repo qui a servi de modèle au portage K2 n'avait pas de raison d'être en retard d'outillage sur celui qui le copiait | | ~~**K7**~~ ✅ **2026-08-12** | **`SectionArticle` livré**, `flutter build apk --debug` vert. `Screens/Article/article_view.dart` remplace le stub `Text("TODO Article")` et le `case` est décommenté — **l'import de `ArticleView` était déjà en tête de `main_view`**, laissé par celui qui avait écrit le TODO. Rail média à 38 %, colonne de lecture plafonnée à 720 px, les trois états incomplets, `isContentTop` qui inverse les colonnes.
⚠️ **La zone C de la maquette n'appartient pas à l'écran** : `section_page_detail:184/198` dessine déjà le bouton retour, avec les clés `back`/`menu` selon `isFromMenu`. Un article qui aurait dessiné son pied de page en aurait affiché deux. **À corriger dans la maquette.**
⚠️ **L'audio se résout d'abord dans `contents`, l'API en repli** : `ContentDTO` porte son `resource` complet (idiome déjà utilisé par `marker_view`), donc l'audio est trouvé **sans appel réseau** quand il est embarqué — donc hors ligne aussi. `resourceGetDetail` n'intervient qu'à défaut. Plus robuste que mymuseum, qui appelle l'API systématiquement en ligne.
Énoncé d'origine : **`SectionArticle` sur le kiosk — ajouté le 2026-08-12**, en recomptant les types couverts. Son `case` est commenté « TODO » dans `main_view.getContent` et `Screens/Article/article_view.dart` fait 1 Ko : un article rend « Ce type n'est pas supporté ». **Contrairement à `SectionEvent` et `SectionParcours`, ce type existait dans Mongo** — c'est donc un vrai retard, pas un type né avec Postgres v3, et il sortira de la migration avec du contenu réel.
**Le code est une reprise de `mymuseum-visitapp/lib/Screens/Sections/Article/`**, pas une conception. **Le travail n'est pas là** : il est dans le **passage en paysage**. Les écrans de mymuseum sont pensés pour un téléphone tenu debout ; une borne est large, et un article rendu en colonne unique sur 1280 px de large est illisible. C'est le seul endroit du lot K où il y a une décision de mise en page à prendre, pas une copie à faire.
✅ **Maquette faite et validée le 2026-08-12** — `DOCS/claude design/kiosk-paysage-article-event.html`, une seule passe pour K3 et K7. Six règles communes (deux colonnes ancrage/flux, texte à 65 caractères, héros à 26 % au lieu de 52 %, plus de `showModalBottomSheet`, carte sans « Agrandir », accents dérivés de `configuration.primaryColor` selon le précédent K4) et quatre décisions tranchées : **fond clair** aux valeurs réelles de l'app (`kBackgroundColor` / `kBackgroundLight`), **pas de sélecteur de langue** (choisie en amont), **hors dates : aucune prose**, et le **retour automatique** sorti du périmètre (ligne suivante).
⚠️ **Deux vérifications faites dans le code plutôt que supposées.** Les **statistiques sont déjà acquises** : `section_page_detail.dart:60/72` émet `sectionView` et `sectionLeave` avec durée, et il enveloppe les treize types — l'article sera compté dès que son `case` entre dans le `switch`, sans une ligne à écrire. ⛔ **Et une erreur à ne pas rouvrir : j'avais écrit que `tablet-app` n'a aucun i18n. C'est faux.** `lib/Helpers/translations.dart` porte une table de **10 langues** (FR, EN, NL, DE, IT, ES, PL, CN, AR, UK), ~150 entrées, lue par `TranslationHelper.getFromLocale` à **19 endroits**, avec déjà une clé `back`. Ce n'est pas de l'ARB : chercher `l10n/` ou `AppLocalizations` ne le trouve pas — c'est une table à la main, le même pattern que mymuseum, décrit dans le `CLAUDE.md` du repo. **Conséquence** : un libellé traduit coûte une clé × 10 langues, pas un chantier. La règle « hors dates, aucune prose » reste défendable — rien à traduire vaut mieux que quelque chose à traduire — mais c'est désormais un **choix, pas une contrainte**, et le motif d'origine était faux. ⚠️ Corollaire pour l'implémentation : le bouton « Retour » passe par la clé `back`, il ne s'écrit pas en dur | **À traiter avec K3** : `SectionEvent` pose exactement la même question de paysage (héros, programme, carte), et la trancher deux fois donnerait deux mises en page différentes sur la même borne. Une seule passe de design pour les deux, même raisonnement d'économie que **L5** et **L15**.
**Trois états incomplets couverts par la maquette** : sans audio (le dock tombe, l'image reprend la hauteur), sans photo (le rail tombe, l'audio passe en tête de la colonne de lecture), texte seul (une colonne centrée, toujours plafonnée). ⚠️ **`audioIds` est une `List`** : « sans audio » est un état **du visiteur**, pas de l'article. Règle actée le 2026-08-12 — **pas d'entrée pour la langue choisie, pas de lecteur du tout** : ni lecteur grisé, ni repli sur une autre langue. Et **`isContentTop` existe déjà** ; en paysage il n'y a plus de « dessus », donc **le drapeau devient le côté** : `isContentTop == true` → texte à gauche et rail média à droite, sinon média à gauche. Le rail change de bord, pas de contenu. À câbler sous peine de rendre sans effet un réglage déjà offert dans `manager-app` | -| **K8** | **Retour automatique à l'accueil après inactivité — ajouté le 2026-08-12**, sorti de la maquette K3/K7. Un visiteur part sans fermer l'écran ; le suivant trouve l'article du précédent. Délai proposé : **5 minutes**.
**À écrire une seule fois pour les treize types**, dans `section_page_detail` — il enveloppe déjà toutes les sections et porte déjà les crochets `initState`/`dispose`. Bénéfice de bord : le retour passant par le même `dispose`, il émet un `sectionLeave` **avec sa durée réelle** au lieu de laisser une consultation ouverte jusqu'au prochain visiteur. **La statistique devient juste, en plus de l'écran** | Ce n'est ni K3 ni K7 : c'est un comportement de borne qui vaut pour tous les types. À ne pas glisser dans le port des deux écrans, sinon il sera écrit deux fois puis une troisième | +| ~~**K8**~~ ✅ **2026-08-12** | **Livré**, `flutter build apk --debug` vert. `kKioskIdleTimeout` à 5 minutes dans `constants.dart`, minuteur et `popUntil(isFirst)` dans `section_page_detail`, réarmé par un `Listener` — **pas un `GestureDetector`**, qui entrerait en concurrence avec les gestes des écrans en dessous : une carte qu'on fait glisser ou un quiz qu'on tape ne doivent rien perdre. ⚠️ **Non vérifié** : le comportement réel après 5 minutes, et le fait que l'accueil soit bien la première route de la pile — à confirmer au §0.
Énoncé d'origine : **Retour automatique à l'accueil après inactivité — ajouté le 2026-08-12**, sorti de la maquette K3/K7. Un visiteur part sans fermer l'écran ; le suivant trouve l'article du précédent. Délai proposé : **5 minutes**.
**À écrire une seule fois pour les treize types**, dans `section_page_detail` — il enveloppe déjà toutes les sections et porte déjà les crochets `initState`/`dispose`. Bénéfice de bord : le retour passant par le même `dispose`, il émet un `sectionLeave` **avec sa durée réelle** au lieu de laisser une consultation ouverte jusqu'au prochain visiteur. **La statistique devient juste, en plus de l'écran** | Ce n'est ni K3 ni K7 : c'est un comportement de borne qui vaut pour tous les types. À ne pas glisser dans le port des deux écrans, sinon il sera écrit deux fois puis une troisième | | **K6** | **Publier les APK à jour**, puis vérifier le renouvellement du parc | **L19**. Les tablettes kiosk sont des appareils que tu gères — mise à jour maîtrisée. **Les téléphones des visiteurs, non** : aucune manœuvre technique ne force une mise à jour, d'où la stratégie de coexistence du lot I | ### Lot J — RGPD & conformité (2 à 3 jours) — juste avant le lot I