# V1 — plan d'implémentation jusqu'à la mise en prod
> Établi le 2026-08-10 en croisant [STATUS.md](STATUS.md) (§1 à §6bis), [kanban.html](kanban.html) (43 cartes) et des vérifications directes dans le code.
>
> Ce fichier ne remplace ni STATUS.md ni le kanban : il donne l'**ordre d'exécution** et les **liens entre chantiers**, que ni l'un ni l'autre ne portent — le kanban trie par état, STATUS.md par domaine, et personne ne dit ce qui doit précéder quoi.
>
> Règle héritée du §1ter et maintenue ici : **ce qui touche au schéma part avant la bascule, ce qui est additif attend.**
---
## 0. Ce que la vérification du code corrige dans les docs
Trois écarts relevés avant d'établir l'ordre. Ils déplacent des chantiers entiers.
| Ce que disent les docs | Ce que dit le code (2026-08-10) | Conséquence sur le plan |
|---|---|---|
| §4 : « Rien n'est committé depuis le 2026-07-17 », 75 + 40 + 14 + 4 fichiers en working tree | **Périmé.** Les 4 repos ont un commit du **2026-08-09**. Reste non committé : `manager-service` 41 fichiers (= le lot 3 RAG du 10/08), `manager-app` 2, `tablet-app` 4, `myinfomate-landing` 7, `visitapp-web` 1 commit non poussé | Le point reste en tête mais devient une demi-heure, pas une urgence à trois semaines de travail |
| §1quater : `GetReferencedResourceIds()` « réclamé par le plan offline, à écrire dans la même passe » que les 13 `GetEmbeddableText()` | **Déjà écrit** dans les 13 sous-types (`Data/SubSection/*.cs`) — la passe commune a bien eu lieu. Mais **appelé nulle part** : le switch de collecte de `ConfigurationController.Export` est commenté en bloc (`:~400-577`), 180 lignes mortes | Le bug offline nº1, présenté comme « bloquant produit », coûte **le branchement d'une méthode existante** à la place d'un bloc mort. Il descend en coût, pas en priorité |
| §1quater et §6bis : « reste à corriger la landing, `features.reqPerMonth` dit encore req/mois » | **Corrigé.** La clé n'existe plus dans `myinfomate-landing/src` | Un item de moins ; le kanban avait raison, STATUS.md est en retard sur deux sections |
À répercuter dans STATUS.md quand ce plan sera exécuté.
---
## 1. Les liens qui déterminent l'ordre
C'est la partie qui manque partout ailleurs. Neuf liens réels, dont quatre imposent l'ordre et cinq évitent de travailler deux fois.
### Liens bloquants — l'ordre n'est pas négociable
| # | Lien | Pourquoi |
|---|---|---|
| L1 | **Rotation des secrets → création de l'environnement prod** (point 16 du §1quinquies) | Écrit noir sur blanc au §1quinquies : « ne pas créer la prod avec les clés committées ». Créer la prod d'abord, c'est rotationner deux fois. **⚠️ Modifié le 2026-08-11 : la rotation quitte le lot A pour le lot I (I0), mais le lien reste entier.** Ce n'est pas un assouplissement du lien, c'est un déplacement des deux bouts : les 6 repos sont privés sur un Gitea auto-hébergé, donc l'exposition ne justifie pas d'ouvrir le backlog avec elle. **La rotation doit toujours précéder I3.** Elle redevient urgente si un repo passe public, gagne un collaborateur externe, ou est cloné par un CI tiers |
| L2 | **Gel du schéma → réécriture de `MigrationController`** | `BuildSection` doit écrire dans le schéma **final**. Tout renommage ou unification de colonne fait après oblige à repasser dans les 13 branches du mapping. Voir §2 lot B |
| L3 | **`pg_dump` → bascule (point 18)** *et* **→ activation de `visit-events-purge`** | Un seul item débloque deux choses : le filet du jour J, et le job de purge des stats volontairement inactif tant que `Stats:RetentionDays` n'est pas défini |
| L4 | **RGPD → mise en service de `VisitorQuestion`** — **repoussé en fin de backlog le 2026-08-11 (lot J)** | Ce lien reste vrai, mais il ne contraint plus l'ordre : **rien ne part en prod avant que tout le backlog soit terminé** (§1quater de STATUS.md). Le lot J étant le dernier avant la bascule, le volet RGPD est en place avant la première question de visiteur réelle — la journalisation ne tourne d'ici là que sur des bases de développement. **La condition tient tant que cette porte tient** : si un déploiement anticipé était décidé, le lot J redeviendrait bloquant |
| L19 | **Portage des apps visiteur (lot K) → bascule** — **ajouté le 2026-08-12** | Le contrat d'API a changé avec Postgres v3 : `ConfigurationDTO.roundedValue`, `GeoPointDTO.latitude/longitude` ne sont plus servis. **Les apps déployées parlent l'ancien contrat.** Basculer sans avoir porté et publié les apps, c'est dégrader les clients existants le jour J — sans erreur visible, juste des cartes vides. ⚠️ **Le parc des téléphones visiteurs ne se force pas** : c'est ce qui impose la stratégie de coexistence du lot I plutôt qu'une bascule sèche |
### Liens d'économie — même code, une seule passe
| # | Lien | Ce qu'on paie deux fois sinon |
|---|---|---|
| L5 | **Backfill médias ⊂ `MigrationController`** (écart e du §1quinquies) | Le §1quater le range dans le lot médias, le §1quinquies dit « à faire *dans* la migration ». Les deux ont raison : il faut **un seul calculateur** de `StoragePath`/`SizeBytes`, appelé par le backfill local et par la migration. Deux implémentations divergeraient sur les 7 types URL et la ligne sans blob |
| L6 | **Rename `MapResourceId` → `IconResourceId` = écart (g)** du §1quinquies | Le §6bis le classe en « nettoyage backend », le §1quinquies en écart de migration. C'est le même changement. Le faire pendant le lot B et l'écart (g) tombe tout seul |
| L7 | **Port PG fermé + `app.myinfomate.be` + compose prod = un seul fichier** | Trois cartes distinctes du kanban (une « Urgent », une « Planifié », une dans le §1quinquies) qui touchent le même `docker-compose` et le même Traefik. Les faire séparément, c'est trois fenêtres de redéploiement |
| L8 | **Nettoyage `manager_api_new` → 5 chantiers manager-app** | Compression images, exposition du watermark, écran audit log, gestion des users, Guide IA lot 4 : tous dans manager-app, tous privés de `flutter analyze` comme garde-fou tant que les 14 fichiers modèle orphelins produisent 68 erreurs de bruit |
| L9 | **Aperçu de conversation (Guide IA lot 4) ≡ « Mode preview / cible Assistant-Persona »** de todo-features | Deux specs pour le même écran, dans deux documents. Converger avant de coder, comme le note déjà le §1quater |
### Liens de dépendance fonctionnelle
| # | Lien | Effet |
|---|---|---|
| L10 | **Backfill `SizeBytes` → quota de stockage autoritaire** | Sans backfill, `SizeBytes = 0` partout : un quota « autoritaire » laisserait tout passer sur l'existant. L'ordre est backfill puis contrôle, jamais l'inverse |
| L11 | **§21 (test offline sur device) → priorité des 4 autres bugs offline** | Le §21 est le seul moyen de savoir si « ça marche mal dans le fort » vient de là. Il conditionne l'urgence, pas l'existence, des bugs de fraîcheur / extensions / purge / échecs silencieux |
| L12 | **Déploiement `app.myinfomate.be` → test §18 (onboarding)** | L'onboarding vend un plan Essentiel web-only. Jouer le parcours d'inscription de bout en bout sans le web déployé valide tout sauf ce que le client achète |
| L13 | **Rétention 13 mois (livrée) → `GetSummary` en mémoire** | La fenêtre est passée de 30 jours à 13 mois : `eventsQuery.ToList()` charge maintenant 13 mois d'événements avant d'agréger. C'est la rétention livrée qui a rendu ce point pressant, pas un nouveau besoin |
| L14 | **Bascule multi-instances → post-filtrage HNSW non éprouvé** | Le RAG a tourné sur une base à **une seule instance** — exactement le cas où le problème de post-filtrage ne se voit pas. La bascule crée le cas de test en prod. À couvrir avant, par Testcontainers ou à défaut à la main sur deux instances locales |
| L15 | **Stats de l'assistant IA ⊂ écran Guide IA** | « Ce que demandent vos visiteurs » est le **deuxième onglet** de l'écran Guide IA (§7 du plan), et cette coquille à onglets n'existe pas encore. Deux chantiers séparés = la coquille construite deux fois |
| L16 | **Design de l'onglet → job de regroupement en thèmes** | Ici le design pilote le backend : la forme d'affichage détermine la forme des données agrégées. Job écrit d'abord = job réécrit |
| L17 | **Socle visuel `constants.dart` → les 3 écrans** | 11 tailles de police dans `lib/Components/`, pas d'échelle typo. Socle d'abord : les écrans deviennent de l'assemblage. Socle après : 3 repasses |
| L18 | **§5 Aperçu de conversation → arbitrage A/B de SectionParcours** | Le seul gain réel de l'option B est une colonne d'aperçu visiteur en direct — déjà couverte par le §5 du Guide IA (lui-même à converger avec le Mode preview, L9). Le gain de B tombe, **A reste** |
---
## 2. Les lots, dans l'ordre
Neuf lots. Le chemin critique passe par A → B → G → H ; les lots C à F sont parallélisables mais tous obligatoires avant H.
### Lot A — Sauver et assainir (½ jour) — **révisé le 2026-08-11**
Rien de créatif, tout est un verrou pour la suite.
| Quoi | Où | Lien |
|---|---|---|
| ~~Committer le lot 3 RAG du 10/08~~ | — | ✅ **Fait le 2026-08-11.** Les **9 repos** sont propres et synchronisés avec leur remote. `manager-service` `18e4240`, `manager-app` `eb8e849`. ⚠️ **`DOCS/` est lui-même un repo git** (branche `main`) — facile à oublier quand on énumère les repos de code, et c'est là que vivent ce plan, STATUS.md et le kanban |
| ~~**Rotation des secrets**~~ | `manager-service` | 📅 **Déplacée au lot I (I0)** le 2026-08-11 — repos privés sur Gitea auto-hébergé. **L1 tient : elle précède toujours I3.** Voir la note de L1 pour les conditions qui la font remonter |
| ~~Nettoyer `manager_api_new` : supprimer les 14 fichiers modèle orphelins + `lib/api/openApiTest.dart`~~ | `manager-app` | ✅ **Fait — constaté le 2026-08-12** : `lib/model/` est à **139 fichiers** (contre 153), `lib/api/openApiTest.dart` n'existe plus. Le bruit des 68 erreurs est tombé et `flutter analyze` redevient exploitable. **L8 est levé.** L'explication du mécanisme reste ci-dessous, elle vaut pour la règle « ne jamais régénérer » |
| ~~Réparer le build `tablet-app`~~ | `tablet-app` | ➡️ **Déplacé au lot K le 2026-08-12. Le diagnostic « un seul défaut, purement Gradle » était doublement faux** — il a tenu tant que personne n'avait lancé un vrai build. Ce n'est pas un correctif de lot A, c'est un chantier de rattrapage. Voir le lot K |
| ~~Sécurité rapide : `EnableSensitiveDataLogging` coupé en prod, backdoor `#if DEBUG` retirée~~ | `manager-service` | ✅ **Vérifié le 2026-08-12.** `EnableSensitiveDataLogging` est sous `#if DEBUG` (`Startup.cs:253-259`) et le Dockerfile publie en `-c Release`, donc le bloc n'est pas compilé en prod ; la backdoor d'`AuthenticationController` est retirée (un commentaire daté du 11/08 marque l'emplacement). ⚠️ **Reste non audité** : le troisième point de la ligne d'origine, « exceptions internes plus exposées » (`HandleError`) |
**Pourquoi le nettoyage de `manager_api_new` est sans risque de build** — vérifié le 2026-08-11, à ne pas re-douter :
`lib/api.dart` déclare **139 `part`** pour **153 fichiers** dans `lib/model/`. Les 14 restants portent `part of openapi.api;` mais **ne sont déclarés `part` par personne**. En Dart, un fichier `part` non listé par sa bibliothèque est **hors du graphe de compilation** : l'analyzer le lit (il parcourt tout `lib/`), le compilateur ne le voit jamais. C'est toute l'explication des 68 erreurs de bruit — et la garantie que **supprimer ces fichiers ne peut pas changer un résultat de build.**
`lib/api/openApiTest.dart` part avec eux pour une raison différente : ce n'est pas du modèle mort, c'est **le déclencheur de la génération** (annotation `@Openapi` + `build_runner`, dernier run daté du 2026-05-07), importé nulle part. Le supprimer ne retire aucun code exécuté — ça **verrouille la règle du projet** : tant qu'il est là, un `flutter pub run build_runner build` lancé par réflexe régénère le client et écrase les éditions manuelles (`onboarding_api.dart`, le câblage d'`AIApi`, le mapping `isGood` → `isCorrect`).
> À ne pas régénérer : `manager_api_new` s'édite à la main (règle du projet). Le nettoyage supprime des fichiers orphelins **et le moyen de régénérer** ; il ne relance rien.
### Lot B — Geler le schéma (2 à 3 jours) — le point d'articulation
**C'est le lot que ni STATUS.md ni le kanban n'isolent**, et c'est lui qui conditionne le coût du lot G. Tant qu'un changement de modèle reste ouvert, réécrire `MigrationController` revient à le réécrire deux fois (**L2**).
| Quoi | Décision préalable | Lien |
|---|---|---|
| ~~Renommer `SectionMap.MapResourceId` → `IconResourceId`~~ ✅ **2026-08-11** | Aucune, déjà tranché au §6bis | **L6** = écart (g), **qui tombe**. ⚠️ Il fallait renommer **aussi la propriété de navigation `MapResource`** → `IconResource` : la convention EF l'appariait au FK, la laisser aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs. Sites touchés : `SectionMap.cs:25/26/45/77`, `SectionFactory:227/559`, `ResourceController:469`, `MigrationController:469` |
| ~~**Supprimer `SectionEvent.ParcoursIds`**~~ ✅ **2026-08-11** — champ, DTO, `SectionFactory:35/199/529`, et **un montage de test** (`SectionEventControllerTests:47`) que l'investigation initiale n'avait pas couvert : elle disait « zéro contrôleur, zéro service », ce qui était exact, mais les tests n'étaient pas dans le périmètre. La ligne retirée était une simple initialisation, sans rapport avec l'assertion du test | ✅ **Tranché le 2026-08-11 : supprimer.** Investigation : le champ n'est **lu ni écrit nulle part** (entité + DTO + migrations, zéro contrôleur, zéro service), son commentaire dit `// Liens vers GeoPoints spécifiques` ce qui contredit son nom, et le lien événement↔parcours existe dans l'autre sens via `GuidedPath.SectionEventId`, lui bien entretenu (`SectionMapController:396/449`, `SectionController:878`). Argument décisif : il est porté par **`SectionEvent`, pas par `ProgrammeBlock`** — il ne peut donc pas exprimer « les parcours de tel jour », le cas carnaval pour lequel on le gardait | Nettoyage §1 « À faire » |
| ⛔ ~~Supprimer `SectionEvent.IconResourceId`~~ **— écarté le 2026-08-11 : ce champ n'existe pas** | **Erreur de doc, à ne pas rouvrir.** `SectionEvent` n'a aucun `IconResourceId`. La ligne visée (`SectionEvent.cs:105`) appartient à la classe **imbriquée `MapAnnotation`**, déclarée dans le même fichier — et elle est **très utilisée** : `SectionEventController:59/230/330`, `SectionAgendaController:58`, `SectionMapController:349/560`, plus les deux `.SelectMany(a => ResourceId(a.IconResourceId))` de `GetReferencedResourceIds` (lignes 50 et 53). `MapAnnotation` est partagée par SectionEvent, SectionAgenda **et** SectionMap : la supprimer aurait cassé les icônes d'annotation des trois types **et** la collecte de ressources offline | §6bis |
| ~~Unifier `QuestionType`~~ → **sorti du lot B le 2026-08-11** | ✅ **Tranché : on ne touche pas au schéma.** Le trio TextLibre/Digicode/ExpectedAnswer **n'existe pas dans le code** : l'enum a trois valeurs (`Simple`, `MultipleChoice`, `Puzzle`) référencées à **un seul endroit** côté serveur, et le digicode n'est pas un type mais un comportement dérivé d'une réponse numérique. Reste une propreté front : les comparaisons par entier brut (`…?.value == 2 ? 'Puzzle' : …`) dans `showNewOrUpdateGuidedStep.dart:409` et `guided_step_challenge.dart:14` → **déplacé au lot E** | **Ne bloque plus `MigrationController`** |
| Implémenter `Section.meterZoneGPS` côté visiteur (la colonne existe, une constante à 100 m la remplace) | Aucune, tranché au §6bis | Bug M3 du kanban — même passe |
| Flag watermark sur `Instance` + exposition dans la config, retrait du `if instanceId == "633ee379…"` de `ResourceController:265` | Aucune | Point 6 du §1ter |
Sortie du lot : `dotnet build` + `dotnet test` verts, une migration EF unique regroupant les renommages, et **plus aucun changement de modèle en attente**.
✅ **Lot B livré le 2026-08-11.** Migration unique `20260811130738_LotB_FreezeSchema`. **Référence tenue : 130/130 tests avant comme après**, build **Debug et Release** verts (Release vérifié parce que le point sécurité ci-dessous change ce que Release compile).
Deux points vérifiés plutôt que supposés :
- **EF a généré un `RenameColumn`, pas un drop+add** pour `MapResourceId` → `IconResourceId` : les icônes déjà configurées survivent à la migration. C'était le vrai risque du lot.
- **L'avertissement « may result in the loss of data » d'EF ne porte que sur le `DropColumn` de `ParcoursIds`** — c'est l'intention du lot, pas un effet de bord.
**Reste hors de ce lot** : `Section.meterZoneGPS` côté visiteur (Flutter/TypeScript, pas le schéma) — la colonne serveur est correctement mappée, c'est le bug M3 qui attend son implémentation visiteur.
### Lot C — Terminer le lot médias (2 à 3 jours)
L'étape A du §1quater est faite (`StoragePath`/`SizeBytes` écrits à `Create` depuis le 10/08). Reste l'étape C.
| # | Quoi | Note |
|---|---|---|
| ~~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~~ ✅ **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 |
### Lot D — Bugs offline (1 à 2 jours, après mesure)
**Commencer par le test, pas par le code** (**L11**).
| Ordre | Quoi |
|---|---|
| D0 | **Jouer le test-plan §21 sur un device** — 15 cas. Sortie : le seul bug restant (D5) se manifeste-t-il réellement. **Débloqué depuis K5** (2026-08-12, les trois flavors construisent). Sert aussi à confirmer le volet visiteur de D1, qui ne tient que sur `flutter analyze`, **et les correctifs D3/D4 ci-dessous** — d'où l'ordre retenu : coder D3/D4 d'abord, un seul passage sur device ensuite.
⚠️ **D2, D3 et D4 sont partis sans attendre D0** ; **D5 seul reste conditionné**. Motif : c'étaient des défauts certains et lisibles dans le code, pas des hypothèses à mesurer.
⚠️ **Deux cas à ajouter au §21 avant de le jouer**, ouverts par D2 : (1) **une montée v3 → v4 sur une base existante**, avec une visite déjà téléchargée — elle ne doit **rien** re-télécharger ; (2) **un device portant des `.unknown`** — le MP3 doit être re-téléchargé et devenir lisible. Le second est le seul qui prouve que D3 répare le parc existant, pas seulement les installations neuves |
| ~~D1~~ ✅ **2026-08-11** | **Fait des deux côtés, et le bloc mort était plus gros qu'annoncé : 157 lignes dans `Export`, plus 126 dans `Import`.** Avant : 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. **Serveur** : `Export` matérialise les entités (pas seulement leurs DTO) et appelle `GetReferencedResourceIds(language)` ; `Import` cesse de redécouvrir les ressources section par section et crée toute la charge en une passe (`createResource` est idempotente). **Client** : `downloadConfiguration.dart` enregistre la charge au lieu de parser `section.data` type par type — ce qui répare aussi la purge, `usedImageOrAudioIds` pilotant la suppression des fichiers obsolètes.
**4 tests** ajoutés, dont un qui vérifie par réflexion que **les 13 sous-types implémentent la collecte** : le trou d'origine venait d'un `switch` où un type oublié passait dans le `default` sans bruit. `dotnet test` **148/148**, `flutter analyze` sans erreur.
⚠️ Le volet visiteur n'est vérifié que par l'analyse — l'APK de `mymuseum-visitapp` ne compile pas (Gradle/NDK). **Confirmation sur device au §21**, qui reste ouvert |
| ~~D2~~ ✅ **2026-08-12** | **Filtre de fraîcheur livré de bout en bout.** `dateUpdate` exposé dans `ResourceDTO` (**champ DTO, pas une colonne** — le gel du lot B tient), rempli par `Resource.ToDTO()` depuis le `DateUpdate` qu'estampille déjà `StampAuditableEntities`. Client `manager_api_new` **édité à la main**. Côté device : colonne `dateUpdate` sur la table locale `resources`, base **v3 → v4**, et le filtre passe de « le fichier est là » à `isResourceOutdated`. `dotnet test` **205/205** (2 tests ajoutés), `flutter build web` (manager-app) ✅, APK `dev` de `mymuseum-visitapp` ✅.
⚠️ **La prémisse de la carte était fausse, et ça change ce que D2 répare.** « Une image remplacée dans le CMS ne remonte jamais sur le device » : ce cas **n'existe pas**. Vérifié le 2026-08-12 — `Upload` appelle `GenerateHexId()` à chaque fois (`ResourceController:276`), manager-app téléverse vers `pictures/{instanceId}/{resourceId}` (`resources_screen:221`), donc **remplacer une image crée un nouvel id et une nouvelle URL** : le fichier est absent du device et l'ancien filtre le téléchargeait déjà. La popup d'édition, elle, ne change que le libellé. **Aucun flux ne réécrit le blob d'un id existant.**
✅ **Le vrai cas de péremption, lui, existe — et c'est D3 qui l'a créé** : les MP3 déjà téléchargés le sont en `.unknown`, et « le fichier est présent » les déclarait à jour. Corriger la table d'extensions ne les répare pas : rien ne les redemande. `isResourceOutdated` traite donc `.unknown` comme absent — **sans ça, D3 était inerte sur tout device déjà en service**.
⚠️ **La montée v4 laisse les lignes existantes à `NULL`, délibérément** : « fichier présent, date inconnue » vaut **à jour**. Backfiller à zéro aurait fait re-télécharger l'intégralité des visites de tous les visiteurs sur leur réseau mobile, au premier lancement.
⚠️ **Piège trouvé en écrivant : la boucle en masse de D1 écrasait la date.** `DatabaseHelper.insert` fait un **UPDATE de toute la ligne** quand l'id existe — la boucle qui enregistre la charge (héritée de D1) repassait derrière la boucle de téléchargement et remettait la date à nul. Elle réécrit désormais la date connue localement. Et pour une ressource dont le téléchargement a **échoué**, c'est l'ancienne date qui est conservée, jamais celle du serveur — sinon un fichier absent se serait déclaré à jour |
| ~~D3~~ ✅ **2026-08-12** | **`audio/mpeg` ajouté** à `_getExtensionFromContentType` (`downloadConfiguration.dart:494`). `audio/mp3` est conservé à côté : il n'est pas standard, mais rien ne dit qu'aucun blob n'est servi avec. Sans ça, un MP3 tombait dans le `?? "unknown"` et se retrouvait sur disque en `.unknown` |
| ~~D4~~ ✅ **2026-08-12** | **Purge des fichiers réactivée** — `resource.deleteSync()` (`:139`), enveloppé d'un `try/catch` comme les autres suppressions du fichier. Elle est correctement bornée : elle ne balaie que `localPath/{configurationId}/`, comparé à la charge de ressources complète que D1 a rendue exhaustive.
⛔ **`cleanLocalResources` (`:216`) reste désactivée — délibérément.** Deux découvertes du 2026-08-12 : la table locale `resources` n'a **pas de colonne `configurationId`** (`DatabaseHelper.dart:227-232`), elle est **globale à toutes les visites** — et le paramètre `configuration` de la fonction n'est **jamais utilisé**. L'activer aurait purgé les lignes DB de **toutes les autres visites téléchargées** à partir des ids d'une seule configuration. Et ça n'aurait rien apporté : **rien ne lit ces lignes pour le rendu**, `CachedCustomResource:118-119` et `loading_common:105-106` retrouvent le fichier en **listant le répertoire**, pas via la colonne `path`.
⚠️ **La prémisse du plan était fausse** : « le second chemin appelle bien `cleanLocalResources` (`:455`) » — cette ligne est **dans un bloc commenté** (`/*class DownloadConfiguration {` en `:328`, `*/` en `:461`). Il n'y a **qu'un seul chemin vivant**, et `cleanLocalResources` n'est appelée nulle part. Ce n'était donc pas le motif `Create`/`Upload` de C1/C3.
**Dette laissée ouverte** : ~130 lignes de classe morte (`:328-461`) et la fonction `cleanLocalResources` elle-même, jamais appelée. Nettoyage hors périmètre du lot D |
| ~~D5~~ | ✅ **2026-08-13.** Les échecs sont collectés au lieu de disparaître dans un `print("NOT SUCCESSS")`, et `download` rend `false` si la liste n'est pas vide. Message dédié au visiteur — **nouvelle clé `downloadIncomplete` dans les 10 langues**, sans quoi `getFromLocale` aurait rendu une chaîne vide.
⚠️ **Découvert en câblant l'écran** : sans état d'échec, une visite incomplète restait sur « téléchargement en cours » **indéfiniment**. Le compteur d'avancement n'est incrémenté qu'en cas de succès, donc il n'atteignait jamais le total et l'écran ne concluait jamais — le visiteur attendait devant une barre figée.
✅ Relancer ne re-télécharge que ce qui manque : `isResourceOutdated` voit les fichiers déjà présents |
### Lot D-bis — Les trois chantiers de design (1,5 à 2 semaines)
> **État mesuré le 2026-08-10, plus ouvert que ce qu'annoncent STATUS.md et le kanban.**
>
> | Écran | Docs | Code |
> |---|---|---|
> | Guide IA | « onglet Configuration livré le 07/08 » | `guide_ia_screen.dart` : 545 l., **5 cards en colonne unique, aucun `TabBar`**. Manquent le §4 (Sources de connaissance + recherche vectorielle) et le §5 (Aperçu de conversation) |
> | Stats assistant IA | « onglet Vocal (V1) » spécifié | `statistics_screen.dart` : 1481 l., « vocal » n'apparaît **que dans un commentaire**. Stats assistant à zéro |
> | SectionParcours | 2 options maquettées | Les 3 popups intactes. Rien n'a commencé — **résolu par DB4 le 2026-08-11 : les 3 popups sont supprimées** |
**Le problème de méthode avant le contenu.** Les trois maquettes sont des artifacts hébergés sur un **autre compte Claude** : illisibles depuis la machine de dev (constaté le 2026-08-10, cf. le §« Comment mettre à jour ce fichier » de STATUS.md). Toute session qui reprend un de ces écrans redessine — c'est la cause directe du Guide IA livré à la moitié de sa spec.
Même découpage que le kanban : **contenu versionné dans le repo, diffusion par artifact depuis le compte propriétaire.** La convention existe déjà (`DOCS/claude design/`, 3 maquettes HTML). Les plans texte, eux, sont précis et suffisent à régénérer les maquettes.
| Ordre | Quoi | Lien |
|---|---|---|
| ~~**DB0**~~ ✅ 2026-08-11 | **HTML des maquettes rapatrié** dans `DOCS/claude design/` — `guide-ia-screen.html` (31,8 Ko), `statistics-screen.html` (27,4 Ko), `sectionparcours-refonte-flux.html` (45,6 Ko), JS interactif compris. Méthode : bascule temporaire sur le compte propriétaire, lecture de la source, copie. L'assistant `visitapp-web` n'a jamais été publié ; le kanban vit déjà dans `DOCS/kanban.html` | La source de vérité du design vit désormais dans le repo, l'artifact ne sert plus qu'à la diffusion. **Constat** : Guide IA et Statistiques déclarent des tokens **rigoureusement identiques** — c'était déjà un design system, jamais porté en Dart. Et `--brand #264863` **est** `kPrimaryColor` |
| ~~**DB1**~~ ✅ 2026-08-11 | **Socle visuel dans `constants.dart`** : 11 rôles typographiques, 8 pas d'espacement, 5 rayons, paddings de carte et de page. `flutter analyze` propre. **Décision du 2026-08-11 : l'échelle vient des maquettes, les couleurs restent celles de l'app** — un écran neuf se fond dans manager-app, il n'y ouvre pas une seconde palette. Seules les valeurs sans équivalent existant sont reprises (`kSurface2/3`, `kLine`, `kLineSoft`, `kInk3`, `kWarning`). Pas de thème sombre. Reste : `size.width * 0.2` de `NumberInputContainer` | **L17** — sert aussi les 12 autres types de section. La reprise des écrans existants vers ces tokens est un chantier distinct (« repasse UI globale »), pas un effet de bord de celui-ci |
| ~~**DB2**~~ ✅ 2026-08-11 — **puis retiré par DB4 le même jour**, comme prévu | **SectionParcours — plus de perte silencieuse.** Confirmation avant d'abandonner du travail non enregistré, sur les **trois** niveaux de dialogue (Parcours, Étape, Question — chacun perdait tout ce qui était saisi sous lui), plus interception du retour navigateur par `PopScope`. 3 clés i18n FR/EN/NL. `flutter build web` ✅ | **Arbitrage du 2026-08-11 : DB2 se limite au garde-fou, la persistance incrémentale part avec DB4.** Motif : les deux sont **alternatives, pas cumulables** — si chaque étape est persistée à la saisie, le garde-fou n'a plus d'objet. Et la persistance impose de créer le parcours dès la 1ʳᵉ étape (donc des parcours à moitié remplis en base si l'utilisateur renonce, et un « Annuler » qui n'annule plus rien) : c'est précisément la sémantique qu'apporte le rail d'étapes de l'option A. L'écrire maintenant, c'est la câbler dans les callbacks `onSave` que DB4 supprime |
| DB3 | **Écran Guide IA complet, 2 onglets, stats assistant comprises**. 🔨 **Front livré le 2026-08-11** : coquille à onglets, §5 Aperçu de conversation branché sur le **vrai** `POST /api/AI/chat` (`AIApi` était dans le client généré mais jamais exposé dans `client.dart` — câblé), onglet « Ce que demandent vos visiteurs » avec ses tuiles, son bloc ambre des questions sans réponse et ses trois cartes de barres, 24 clés i18n FR/EN/NL. `flutter analyze` propre, `flutter build web` ✅.
**Complété le 2026-08-11** : carte §4 « Ce que connaît votre guide » sur `GET /api/Ai/knowledge/{id}` (agrégats `ContentEmbedding`), journalisation `VisitorQuestion` dans `AiController.Chat`, et `GET /api/Ai/insights/{id}` qui remplit l'onglet. `dotnet test` 130/130, `flutter build web` ✅.
**Reste dans ce lot** : l'onglet « Vocal » des stats et la vérification à l'œil (DB5). Le job de thèmes et tout le volet RGPD sont **déplacés au lot J**, en fin de backlog. La purge à 90 jours est livrée (`VisitorQuestionPurgeService`) | **L15**, **L16** — le contrat de données est fixé par `GuideIaInsights` dans `visitor_questions_tab.dart`, et `GuideInsightsDTO` en est le miroir exact : c'est lui que le job de thèmes doit remplir, pas l'inverse. **L9** : le §5 converge avec le Mode preview |
| ~~**DB4**~~ ✅ 2026-08-11 | **SectionParcours — la forme, option A livrée.** Les trois `showNewOrUpdate…` sont supprimés. `Parcours/Fields/` porte les trois widgets autonomes (`ParcoursFields`, `EtapeFields`, `QuestionFields`) ; `guided_path_editor.dart` est la coquille : fil d'Ariane, rail d'étapes réordonnable toujours visible, panneau de détail, pied de page « Enregistré ». Une question se **déplie dans le panneau de son étape** — profondeur maximale **2**, la fenêtre puis la traduction. **Sauvegarde à la saisie** : `guided_path_api.dart` unifie `sectionParcoursApi` / `sectionMapApi`, un débounce de 700 ms écrit le parcours (PUT), les étapes (POST/PUT/DELETE) et leurs questions ; les changements de structure (ajout, suppression, réordonnancement) partent sans attendre. **Le garde-fou de DB2 est retiré** — 3 clés i18n supprimées, 14 ajoutées FR/EN/NL. `flutter build web` ✅ | **L18** — **A** retenue. Deux pièges du backend, tranchés à la lecture du contrôleur : `UpdateGuidedPath` **supprime les étapes absentes du DTO**, donc le payload n'emporte que celles qui ont déjà un id — les autres attendent leur `CreateGuidedStep` et seraient dupliquées. Et il n'y a **pas** d'endpoint QuizQuestion (voulu) : l'id entier attribué par `SyncQuizQuestions` est récupéré après coup **par `order`**, seul repère stable entre la liste locale et celle du serveur. Le parcours est **créé à la première modification**, pas à l'ouverture : fermer une fenêtre neuve intacte ne laisse rien en base. Si une écriture échoue, la fenêtre refuse de se fermer et le pied de page porte un « Réessayer » |
| DB5 | **Boucle de vérification, à chaque écran** : `flutter run -d chrome`, capture, comparaison côte à côte avec la maquette | `flutter analyze` ne prouve rien sur du design — et reste inexploitable tant que le lot A n'a pas nettoyé `manager_api_new` (**L8**) |
Ne change pas : la popup de traduction (un niveau justifié, langues verticales, pas d'onglets), et la conception des composants de `Components/` — leur problème est le dimensionnement, pas la structure.
### Lot E — Parité et finitions visiteur (1 à 2 jours)
| Quoi | Réf. |
|---|---|
| ~~Fournisseur de carte honoré en web~~ ✅ **2026-08-11 — tranché : option a, le web reste sur Leaflet** | W1. **Ce n'était pas un oubli de câblage** : le web reçoit déjà `mapProvider`, `mapType` et `mapTypeMapbox` (`client.ts:44-45`). Mais « honorer le fournisseur » n'a pas d'équivalent Leaflet gratuit — côté Flutter ce sont **deux SDK** (`GoogleMapView` / `MapBoxView`, `map_page.dart:142-148`) ; MapBox exige un token payant, et les tuiles Google en Leaflet violent leurs conditions.
⚠️ **L'option a ne pouvait pas se faire comme annoncée.** « Retirer le champ pour les instances web » est **impossible** : le fournisseur est porté par la **section** (`SectionMap.MapMapProvider`, `SectionAgenda.AgendaMapProvider`), pas par l'instance — et une même Configuration est liée à plusieurs `ApplicationInstance` via `AppConfigurationLink`, donc **servie au mobile comme au web**. Masquer le champ aurait supprimé le réglage mobile.
**Ce qui a été fait à la place** : le champ reste (il gouverne toujours le mobile) et manager-app **cesse de laisser croire qu'il vaut pour le web** — mention explicite sous les deux sélecteurs (`map_config.dart`, `agenda_config.dart`), 1 clé i18n FR/EN/NL. Zéro ligne côté `visitapp-web` : Leaflet inconditionnel était déjà le comportement. `flutter build web` ✅ |
| ~~PDF en média d'étape~~ ✅ **2026-08-11** | Les trois volets faits. **Côté manager-app** : la liste de types du sélecteur de médias devient un paramètre (`kSliderContentResourceTypes` par défaut) et l'étape passe `kGuidedStepResourceTypes` = les types du slider + `Pdf`. **Côté visitapp-web** : cas PDF dans `ResourceViewer.tsx`, réutilisant le `PdfViewer` existant et son proxy `/api/pdf` (contournement CORS déjà en place), importé en `dynamic({ssr:false})` comme le fait `PdfSection`. **Côté mymuseum-visitapp** : ⚠️ **pas dans `showElementForResource`** — cette fonction fait un `return CachedCustomResource(...)` **avant** son `switch`, donc tout son switch est du code mort. Le vrai dispatcher est `CachedCustomResource`, qui a **deux** switch (ressource distante / fichier local déjà téléchargé) tombant tous deux sur `Text("Not supported type")` : les deux sont traités. L'enum du client s'appelle `ResourceType.Pdf`, pas `PDF`.
`npm run build` ✅, `flutter build web` (manager-app) ✅. Le volet mymuseum n'est vérifié que par `flutter analyze` — son APK ne compile pas (Gradle/NDK, cf. §1bis) |
| ~~Comparaisons `QuestionType` par entier brut~~ ✅ **2026-08-11** | Descendu du lot B. Le générateur nommait les valeurs `number0/1/2`, ce qui poussait le front à comparer des entiers. Alias nommés ajoutés à la main dans `question_type.dart` (`simple`, `multipleChoice`, `puzzle`, d'après l'enum serveur `Simple`/`MultipleChoice`/`Puzzle`), `values` inchangé. Les deux sites visés sont corrigés (`showNewOrUpdateGuidedStep`, `guided_step_challenge._kindOf`) **et** les 7 usages `number*` de `showNewOrUpdateQuizQuestion` migrés — plus aucun `QuestionType.number` dans les trois apps. Les libellés français en dur de ce dialogue relèvent de la dette i18n générale, laissés en place |
| ~~Refonte du flux SectionParcours~~ | **Déplacé au lot D-bis** (DB2 et DB4) — c'est un chantier de design, pas une finition de parité |
### 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.
✅ **Volet `manager-service` terminé le 2026-08-13.** Customer Portal livré, ratio jetons → questions calé sur du réel ; le rate limiting, l'endpoint `ApplicationInstance` et les quotas seed **étaient déjà faits** et n'attendaient qu'une vérification (détail dans les lignes concernées). `dotnet build` vert, `dotnet test` **211 au total : 197 passés, 14 sautés** (Testcontainers, pas de démon Docker), 0 échec.
✅ **Miroir de la conversation vocale livré le 2026-08-13.** `flutter analyze lib` sans erreur, APK `dev` ✅, `dotnet test` **215**, `flutter build web` ✅.
**Une seule conversation, portée par `VisitAppContext.assistant`.** Le chat écrit, le vocal et le proactif partagent l'instance ; les trois `AssistantService` séparés ont disparu. Le `maxHistory: 6` du vocal s'aligne sur 10 : deux surfaces qui partagent une conversation ne peuvent pas la tronquer différemment selon le point d'entrée, et la concision vocale vient d'`isVoice`, qui change le prompt côté serveur.
⚠️ **Le vrai obstacle n'était pas l'affichage mais la forme du chat** : `AssistantChatSheet` gardait ses messages en `List` — des bulles déjà construites. On ne rejoue pas une conversation à partir de widgets, et un tour vocal survenu pendant que la feuille était fermée n'aurait jamais pu y entrer. Le service porte désormais les tours **en données** (`AssistantTurn`) et notifie ; la feuille rend depuis eux et se met à jour en direct.
⚠️ **Deux listes, et c'est délibéré** : `_history` (envoyée au modèle) et `_turns` (affichée) ne coïncident pas. Un message d'erreur se montre sans repartir au guide — il n'a pas à s'excuser d'une panne au tour suivant ; et le prompt d'un déclenchement proactif part au guide **sans jamais s'afficher**, seule sa réponse apparaît, marquée « À voix haute ». Sans ce marquage, un visiteur ouvrant le chat trouverait des messages qu'il n'a jamais tapés.
✅ **`conversationId` est enfin envoyé, et le champ manquait dans le client généré** — c'est ce qui expliquait que personne ne l'envoyait. Ajouté à la main dans `manager_api_new`. Il est renouvelé quand la conversation est vidée : sinon la visite entière d'un même visiteur n'en formerait qu'une. **2 tests serveur** fixent le lien entre deux tours et le repli — la branche `Guid.NewGuid()` n'était couverte par rien.
✅ **Canal vocal des stats livré le 2026-08-13, et L9 refermé.** `dotnet test` **218** (dont un test Postgres), APK `dev` ✅, `flutter build web` ✅.
**Le vocal est un attribut, pas un canal** — la correction du 12/08 est appliquée telle quelle. `StatisticsService.track` porte `isVoice`, qui pose `"voice": true` dans `VisitEvent.Metadata` — **colonne existante, donc aucune migration et le gel du lot B tient**. `StatsSummaryDTO.VoiceSessions` compte les **sessions ayant utilisé le vocal**, et `statistics_screen` lit cet agrégat au lieu d'`appTypeDistribution['Voice']`, qui ne se serait jamais rempli.
⚠️ **Le `sectionView` vocal est émis après la synthèse, pas avant** : un déclenchement dont le TTS échoue n'a rien fait entendre, le compter serait faux. ⛔ **Aucun événement par question posée**, conformément à l'arbitrage : elles sont déjà dans `VisitorQuestion` et remontent dans l'onglet Guide IA.
⚠️ **Le test InMemory ne prouve rien ici** — le provider évalue le `Contains` côté client, donc une requête intraduisible passerait au vert. Un test Postgres a été ajouté à côté ; il se saute sans démon Docker, comme les 14 autres.
⛔ **L9 : la convergence n'était pas un chantier, c'était un malentendu de doc.** La « cible Assistant / Persona » du Mode preview (`todo-features.md:599-603`) est **exactement** ce que le §5 du Guide IA a livré le 11/08 — `_sendPreview` interroge le vrai `/api/AI/chat`. Les trois autres cibles du Mode preview (mobile, web, kiosk en iframe, sélecteur de viewport, preview-token) sont une feature bien plus large, **hors V1**. Rien à coder.
⚠️ **Mais l'aperçu polluait les stats du client** : chaque essai de personnalité par le gestionnaire était journalisé comme une question de visiteur. Corrigé dans la même passe — voir le renommage ci-dessous.
⚠️ **`IsAutoTriggered` renommé en `IsVisitorQuestion` (défaut `true`).** Le nom d'hier ne couvrait qu'un des deux cas : le vrai sens n'est pas « déclenché automatiquement » mais « ce tour n'est pas une question de visiteur », ce qui vaut aussi pour l'aperçu du gestionnaire. Renommé pendant qu'un seul commit en dépendait. Le défaut à `true` est délibéré : une app visiteur déjà publiée, qui n'envoie pas le champ, continue de journaliser — **un test le fixe**.
**Ce qui reste au lot F, et dans quel repo** — le backend ne bloque plus rien :
- ~~`manager-app`~~ ✅ **livré le 2026-08-13** : bouton du Customer Portal et diviseur `aiTokensPerQuestion`.
- ~~`mymuseum-visitapp`~~ ✅ **livré le 2026-08-13** : déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux.
- ~~L9, aperçu de conversation~~ ⛔ **sans objet** — livré par DB3 le 11/08, le reste du Mode preview est hors V1.
🎉 **Le lot F est clos le 2026-08-13.** Ce qui reste avant la bascule : le **lot J** (RGPD), **K6** (publier les APK), **K9** (repasse visuelle de la borne, sacrifiable), le **lot H** (tests, dont D0 et §21 sur device), **D5**, et le **lot I** (infra + bascule).
**Volet proactif + M3 du 2026-08-13 (`mymuseum-visitapp`) — ce que le code a corrigé de l'énoncé :**
⚠️ **La garde ne devient pas `proactiveModeEnabled` seul, et c'est le point qui compte.** `_trigger` appelle le LLM **d'abord** et ne parle qu'ensuite via `activeVoiceOrchestrator?.ttsEngine` — le `?.` avale le cas « aucun mode vocal actif ». Avec la garde recommandée par le plan, un visiteur traversant une zone avec l'app en poche et le vocal éteint **aurait consommé des jetons Gemini facturés au quota du client pour une phrase que personne n'entend**, toutes les 30 s par point. La garde retenue est `proactiveModeEnabled && activeVoiceOrchestrator?.isRunning == true` : la vraie condition n'est pas « quel matériel », c'est « y a-t-il quelqu'un pour écouter ».
⛔ **Il n'y avait pas de troisième mode à inventer : `VoiceMode.voiceOnly` existe déjà** (`voice_controller.dart:16`) et construit **le même** orchestrateur que le mode lunettes — wake word, STT, TTS Gemini, LLM. Le mode `glasses` n'ajoute que la connexion des lunettes. L'audioguide sur téléphone était donc déjà là, et `VoiceModeSheet` propose déjà les deux tuiles. **Le proactif est un sous-réglage, pas une tuile** : l'opposer aux deux autres forcerait à choisir entre « je pose des questions » et « on me parle ».
⚠️ **`glassesEnabled` est supprimé, pas corrigé.** Ses trois usages étaient morts : la garde du proactif, et deux dans `AssistantChatSheet` — dont un (`:129`) déjà doublé par `MetaGlassesService.isConnected`, la vraie condition. La pastille « Lunettes / Déconnecté » (`:239`) n'avait **jamais été affichée à personne**, le drapeau n'étant jamais vrai ; elle s'accroche désormais à l'état réel de connexion.
⚠️ **Deux pièges de format sur les points GPS, vérifiés plutôt que supposés.** `currentSections` porte des **maps JSON brutes**, pas des DTO (même idiome que `assistantSuggestions` et `ScannerDialog`), et `latitude`/`longitude` y sont des **chaînes** — c'est le type du `SectionDTO` généré. D'où `double.tryParse`. `meterZoneGPS` devient le rayon par section, la constante ne servant plus que de défaut : **c'est là que M3 se ferme**.
✅ **Le bloquant est levé le 2026-08-13 — `AiChatRequest.IsAutoTriggered`.** `RecordVisitorQuestion` écrivait `Question = request.Message` (`AiController:135`), or le prompt d'un déclenchement automatique est une consigne machine (« Tu es un guide audio de musée. Le visiteur vient d'entrer dans la zone X. Accueille-le… »). Chaque passage devant une œuvre aurait ajouté **une fausse question de visiteur** dans l'onglet Guide IA, écran qui existe précisément pour montrer ce que les humains demandent — et, `HasAnswer` se déduisant des sources, aurait **gonflé le bloc ambre « questions sans réponse »**. Même famille que `WeatherSyncService` noyant le journal d'audit.
> **Tranché : ne pas journaliser du tout, plutôt que marquer d'un drapeau.** Une ligne `VisitorQuestion` sert au rapport de trous de contenu et aux thèmes du lot J ; un prompt que le système s'est écrit à lui-même n'alimente ni l'un ni l'autre. La garder « marquée » obligerait **chaque futur agrégat** à penser à l'exclure — la classe de panne exacte qu'on venait de fermer sur `AuditedTypes`.
>
> ⚠️ **Mais les jetons restent comptés.** `RecordUsage` est appelé dans tous les cas : ces tours coûtent de l'argent réel au quota du client, et un audioguide proactif peut en consommer beaucoup. Les masquer du compteur serait pire que le bruit qu'on retire. **2 tests** fixent la paire — pas de ligne journalisée, compteur incrémenté — parce que c'est la combinaison qui compte, pas chaque moitié isolément.
>
> Trois repos, trois commits : `manager-service` (DTO + contrôleur + tests, `dotnet test` **213**), `manager-app` (`manager_api_new` étendu **à la main**, `flutter build web` ✅ — le client est partagé, donc vérifié) et `mymuseum-visitapp` (`AssistantService.chat` + l'appelant proactif, APK `dev` ✅).
✅ **Code mort supprimé le 2026-08-13 — `flutter analyze lib` rend zéro erreur, une première pour ce repo.** Trois fichiers Dart : `wake_word_service.dart`, `glasses_qr_scanner_service.dart` et `glasses_tts_service.dart` (l'ancien TTS ElevenLabs, importé par les deux seuls autres). Ils portaient les **4 erreurs** `kElevenLabsApiKey` / `kElevenLabsVoiceId` et l'APK se construisait quand même : personne ne les importait — un îlot hors du graphe de compilation, même mécanique que les 14 fichiers orphelins de `manager_api_new`. **C'étaient les ancêtres de `Services/Glasses/`.**
⚠️ **Piège d'homonymie, à ne pas re-déclencher** : `android/…/WakeWordService.kt` porte le même nom et est **bien vivant** — service natif déclaré au manifeste et piloté par `MainActivity`, c'est lui qui fait tourner le wake word. Seul le Dart est parti.
✅ **`impl/elevenlabs_tts_engine.dart` est conservé** : c'est l'implémentation de génération courante, gardée comme option si des clients jugeaient la voix Gemini insuffisante — beaucoup plus cher, donc pas un défaut.
⚠️ **Corollaire qui rétrécit le chantier du miroir vocal** : j'avais compté **cinq** `AssistantService` distincts, donc cinq historiques. Deux étaient dans ce code mort. **Il y en a trois de vivants** : le chat (`AssistantChatSheet:70`), le vocal (`MyInfoMateLlmClient:18`, `maxHistory: 6`) et le proactif (`geo_beacon_trigger_service:62`). À unifier, mais trois, pas cinq.
⛔ **Le choix de voix du client n'était pas honoré — corrigé le 2026-08-13, et ce n'était pas au programme.** Le sélecteur Viva (`Sulafat`) / Marco (`Umbriel`) existe dans manager-app depuis le 07/08 et écrit `Instance.GuideVoiceId` ; `mymuseum-visitapp` ne lisait **jamais** ce champ. **Même forme que W1** : un réglage offert par le CMS que l'app visiteur ignore. La voix vient désormais de l'instance ; le repli du build passe d'`Algieba` — jamais écoutée par personne — à `Sulafat`.
⚠️ **Et aucun APK ne parlait avec la voix du produit** : `GeminiTtsEngine` n'est choisi que si `GEMINI_API_KEY` est injectée au build, ce qui n'était fait nulle part — tous les builds retombaient **en silence** sur la voix système d'Android. Le repli reste le défaut (les tests de terrain ne coûtent alors ni jetons ni argent) mais il s'annonce dans les logs, `constants.dart` porte la commande et `launch.json` a une configuration « Dev + voix Gemini ».
⚠️ **À trancher au lot J** : le STT n'est pas Google non plus — `WhisperSttEngine` pointe sur `api.openai.com` si `WHISPER_API_KEY` est définie (elle est vide aujourd'hui, c'est le device qui transcrit). L'activer ajouterait **OpenAI comme sous-traitant**, que le §8.5 des CGU ne mentionne pas.
**Volet `manager-app` du 2026-08-13 — deux décisions prises en écrivant, à ne pas re-trancher :**
⚠️ **Le bouton du portail ne remplace pas celui du Checkout, il prend sa place quand l'essai est fini.** L'écran n'affichait rien du tout hors essai (`if (isTrialActive)` enveloppait le seul bouton) : un client payant arrivait sur un écran sans action. C'est maintenant Checkout **pendant** l'essai, portail **après** — les deux ne coexistent jamais, souscrire deux fois n'a pas de sens.
⚠️ **Le 409 a son propre message, et c'est le point qui se serait vu en prod.** Les 4 instances de la bascule viennent de Mongo et n'ont pas de `StripeCustomerId` : leur montrer « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. Le message dit que leur facturation passe directement par nous. **4 clés i18n FR/EN/NL.**
⚠️ **Le diviseur vient du serveur, et son absence masque la ligne au lieu de mentir.** `guide_ia_screen` appelle `GET /api/Instance/{id}/quota` par `http` direct — le motif déjà établi dans cet écran pour `knowledge` et `insights`, un agrégat en lecture seule ne justifiant pas d'étendre `manager_api_new`. Si l'appel échoue, `~N questions` **disparaît** : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — c'est exactement ce que le `/1000` faisait depuis le début.
| Quoi | Lien |
|---|---|
| ~~Volet RGPD~~ | **Déplacé au lot J**, fin de backlog (décidé le 2026-08-11) |
| ~~Insertion `VisitorQuestion`~~ · ~~purge 90 j~~ · ~~onglet « Ce que demandent vos visiteurs »~~ | ✅ **Livrés le 2026-08-11** |
| ~~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 » |
| ~~**Déclenchement proactif pour tous**~~ | 🔨 **Livré le 2026-08-13** (garde, cycle de vie, points GPS, réglage visiteur), `flutter build apk --flavor dev` vert. **M3 fermé dans la même passe**, comme l'annonçait la colonne de droite. Détail sous le tableau. Ce qui reste du proactif est le **test sur device**, pas du code.
Énoncé d'origine : ✅ **Tranché : le proactif sort du domaine réservé aux lunettes.** Un téléphone qui parle à l'approche d'une œuvre, écouteurs aux oreilles, **c'est l'audioguide de musée classique** — vendable très au-delà des Ray-Ban, et sur du matériel que le visiteur a déjà.
⚠️ **Ce n'est pas « retirer une garde », c'est câbler une fonction morte.** Mesuré le 2026-08-12 : `GeoBeaconTriggerService.instance.start()` **n'est appelé nulle part**, `glassesEnabled` est déclaré `= false` et **jamais assigné**, `proactiveModeEnabled` non plus, et `geoPoints` porte le commentaire « à peupler depuis la configuration » — ce qui n'est fait nulle part. Quatre choses à brancher, pas une.
**Le travail** : (1) la garde redevient `proactiveModeEnabled` seul — **ce que la doc de la classe décrivait déjà**, le `&& glassesEnabled` n'y a jamais figuré ; (2) un appel à `start()` au bon moment du cycle de vie ; (3) `geoPoints` peuplé depuis la configuration ; (4) un réglage visiteur pour activer le mode ; (5) `VoiceSource` devient **obligatoire** et non plus confortable — voir la ligne « canal Vocal ».
✅ **Recommandation sur les écouteurs : ne pas les détecter.** Le mode est **opt-in** — le visiteur qui l'active a choisi d'être parlé. Détecter la sortie audio ajoute une dépendance pour arbitrer une situation que l'utilisateur a déjà tranchée. À revoir seulement si le test montre le contraire. | ⚠️ **Fusionne avec le bug M3**, et c'est l'économie à ne pas rater. Il existe **deux implémentations de géodéclenchement qui s'ignorent** : `GeoBeaconTriggerService` (morte, 20 m en dur pour le GPS, 3 m pour les beacons) et `configuration_page.dart:43` (vivante), dont la ligne est `int meterToBeacon = 100; // 15 meters` — **la valeur dit 100, le commentaire dit 15**. Aucune des deux ne lit `meterZoneGPS`. **Corriger M3 et brancher le proactif, c'est le même chantier** : décider laquelle des deux devient la bonne, puis lui faire lire le rayon configuré par section. Les traiter séparément, c'est corriger une constante et laisser l'autre. Même raisonnement que **L5**.
⚠️ **Nouveau périmètre V1, assumé** : ~2 à 3 jours, M3 compris. À arbitrer contre la date de bascule au même titre que K9 |
| **Miroir de la conversation vocale dans le chat — ajouté le 2026-08-12** | ✅ **Tranché : le chat affiche en direct ce qui se dit aux lunettes.** Ouvrir l'assistant pendant que les lunettes tournent montre la même conversation, transcrite au fil de l'eau — une conversation, deux surfaces.
⚠️ **Le coût n'est pas l'affichage, c'est l'unification des historiques.** Vérifié le 2026-08-12 : `MyInfoMateLlmClient` construit **son propre** `AssistantService` (`maxHistory: 6`), distinct de celui du chat écrit. Aujourd'hui, poser une question aux lunettes puis ouvrir le chat du téléphone donne un interlocuteur qui ne sait rien de ce qui vient d'être demandé. C'est **ça** le chantier ; la transcription, elle, est déjà disponible — `VoiceOrchestrator` expose `lastTranscription` et `lastTtsText` en `ValueNotifier`, personne ne les affiche.
✅ **Bénéfice de bord qui justifie de le faire proprement** : `VisitorQuestion.ConversationId` est un GUID de session, « seul lien entre deux tours ». Deux historiques = deux conversations distinctes en base pour un même visiteur qui a simplement changé de surface. **Unifier l'historique répare aussi les agrégats du Guide IA**, et donc ce que le job de thèmes du lot J regroupera.
⛔ **Écarté** : afficher passivement la dernière question/réponse dans `VoiceModeSheet` (2-3 h, zéro risque) — ça montrait que ça marche sans réparer la coupure. ~1 à 2 jours | **`mymuseum-visitapp`.** ⚠️ **Ne rien redessiner : l'UI existe déjà** — `GlassesStatusWidget` est monté en haut à droite de l'accueil (`home_3.0.dart:465`), avec état par couleur, pastille de mode actif et tap vers `VoiceModeSheet` (connexion, choix de mode, désactivation). `GlassesDebugPanel` existe aussi et **se garde** : c'est l'outil de test. Le seul écart avec « une icône tout le temps présente » est volontaire — elle se cache si `applicationInstanceDTO?.isAssistant != true` (`GlassesStatusWidget.dart:32`), donc invisible chez MDLF et le Fort, visible sur l'instance MyInfoMate. **À savoir avant de tester chez un client et de conclure à un bug.** |
| **Onglet / canal « Vocal » dans les stats** | ✅ **Maintenu en V1 — décidé le 2026-08-12**, les lunettes Ray-Ban étant ramenées en V1 (activées d'abord sur la seule instance MyInfoMate, pour observer en vrai). **Mais le chantier n'est pas où le plan le disait.**
⚠️ **L'écran est déjà fait, c'est l'émission qui manque.** `statistics_screen.dart` traite déjà `AppType.Voice` : libellé de canal (`:189`), part du vocal (`:205-210`), KPI conditionnel (`:533-541`), takeaway (`:480`). **Rien à écrire dans `manager-app`** — tout le travail est dans `mymuseum-visitapp`.
**Trois trous mesurés le 2026-08-12 :** (1) `MyInfoMateLlmClient:40` envoie `AppType.Mobile` alors que son commentaire `:9` annonce Voice ; (2) `StatisticsService` est construit en dur avec `appType: 'Mobile'` (`home_3.0.dart:715`), son champ étant même documenté `// "Mobile" or "Tablet"` ; (3) **le plus lourd — le flux lunettes n'émet aucun `VisitEvent`** : tous les `track()` sont dans des écrans d'interface, `Services/Glasses/`, `wake_word_service` et `geo_beacon_trigger_service` n'en appellent aucun. Une visite vocale ne produit aujourd'hui ni session, ni section vue, ni durée.
**Périmètre des événements — tranché : session + contenu lu à voix haute.** Une session `Voice` à l'activation des lunettes, un `sectionView` quand le guide lit réellement une section (déclenchée par QR, beacon ou question). ⛔ **Pas d'événement par question posée** : elles sont déjà dans `VisitorQuestion` et remontent dans l'onglet Guide IA — les compter ici serait le même fait dans deux écrans. Ce périmètre est celui qui fait marcher les KPI existants et l'export PDF **sans retouche**, puisqu'il produit les mêmes types d'événements que le tactile | ⚠️ **Le canal du chat et celui des stats se décident séparément — c'est ce qui rend le chantier sûr**, et il a fallu le vérifier pour le savoir.
⛔ **Le « fallback Voice → Mobile » n'existe pas.** `guide-ia-screen-plan.md:226` l'affirme et le commentaire d'`AiController:356` le répète : **les deux sont faux.** Le code fait un `FirstOrDefault` sur le canal demandé puis `Forbid()` (`:357-361`). Envoyer `AppType.Voice` au chat sans avoir créé une `ApplicationInstance` de ce type rendrait donc **403 à chaque question** — extinction silencieuse des lunettes. C'est très probablement l'origine du `Mobile` en dur : le mur a été rencontré et contourné, le commentaire est resté sur l'intention.
✅ **Décision : le chat reste en `Mobile`.** `POST /api/Stats/event` est `[AllowAnonymous]`, ne cherche **aucune** `ApplicationInstance` et ne contrôle **aucun** canal (`StatsController:35-50`). Motif de fond, qui est aussi l'objection à retenir : les lunettes tournent **dans** l'app mobile, qui a déjà chargé son canal et ses configurations ; un canal Voice ferait jongler la même app entre deux canaux — piège déjà rencontré en K2 avec `getConfigurations` — pour zéro bénéfice de contenu.
⛔ **CORRECTION du 2026-08-12, quelques heures après la ligne ci-dessus : les `VisitEvent` ne partent PAS en `Voice` non plus. Le vocal n'est pas un canal, c'est un attribut.**
**Ce qui a fait tomber la première version** : la décision du miroir vocal (ligne suivante de ce tableau) rend explicite qu'un visiteur passe des lunettes à l'écran **dans une même session** — c'est tout son objet. Or `StatisticsService` génère **un seul `sessionId` par lancement**, et `AppTypeDistribution` compte **une entrée par session**, tranchant une session ambiguë sur son **événement le plus ancien** (`StatsController:196-203`). Donc : commencer au doigt puis mettre les lunettes ⇒ toute la session compte `Mobile`, le vocal est invisible ; commencer aux lunettes puis ouvrir l'écran ⇒ toute la session compte `Voice`, navigation tactile comprise. **Le KPI ne dit pas « la part du vocal » mais « la part des sessions dont le premier événement était vocal »** — un chiffre décidé par un accident d'ordre. La première décision supposait sans le dire qu'une session est *soit* vocale *soit* tactile.
✅ **Retenu : marquer les événements consommés à la voix dans `VisitEvent.Metadata`** — colonne JSON qui **existe déjà** et que `GetSummary` exploite déjà pour six agrégats, donc **aucune migration et le gel du lot B tient**. Le KPI devient « part des sessions ayant utilisé le vocal », vrai et calculable.
**C'est la conception que le code applique déjà ailleurs** : `VisitorQuestion` n'a pas de canal vocal, il a un booléen `IsVoice` **à côté** de son `AppType` — le vocal y est un mode d'interaction, pas une plateforme. `AppType.Voice` garde alors son sens d'origine : un déploiement lunettes **autonome** avec sa propre `ApplicationInstance`, cas pour lequel l'enum a été créé et qui n'est pas celui-ci.
⚠️ **Ce que cette correction coûte, et que la première version niait** : « zéro ligne dans `manager-app` » **devient faux**. `_voiceSessions` lit `appTypeDistribution['Voice']` (`statistics_screen.dart:205-206`) et devra lire un nouvel agrégat exposé par `GetSummary`. ~3 h, pas un chantier — mais à ne pas découvrir en route.
**À faire dans la même passe** : corriger le commentaire de `MyInfoMateLlmClient:9` pour qu'il dise *pourquoi* c'est Mobile, et corriger `guide-ia-screen-plan.md:226`. Les deux propagent la même croyance fausse |
| ~~`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**~~ | ✅ **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~~ | ✅ **Traités le 2026-08-13 — et deux des trois étaient déjà faits.**
**Ratio jetons → questions** : livré côté serveur. `InstanceQuotaDTO.aiTokensPerQuestion` est désormais **mesuré** sur les `VisitorQuestion.TokensUsed` de l'instance, avec un seuil de 20 questions avant de faire foi — sans ce seuil, une seule réponse citant un long article doublerait la moyenne et le crédit restant affiché changerait de moitié d'un rafraîchissement à l'autre. En dessous du seuil, repli sur l'hypothèse de la grille tarifaire.
⚠️ **Le `/1000` de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client.** `guide_ia_screen.dart:428` fait `(used / 1000)`, alors que la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit **10 000 jetons par question** (`MyInfoMateDbContext:619`). Le gestionnaire lisait donc un crédit restant **dix fois trop généreux**. Le repli du serveur est calé sur 10 000, pas sur 1 000. **Reste à faire dans `manager-app`** (session séparée) : diviser par `aiTokensPerQuestion` au lieu de la constante.
⛔ **Endpoint de mise à jour d'`ApplicationInstance` : il existe déjà.** `ApplicationInstanceController` porte un CRUD complet — `PUT /api/ApplicationInstance` (`:115`) et `POST` (`:75`) — et `InstanceController.Updateinstance` écrit déjà `IsMobile`/`IsTablet`/`IsWeb` (`:209-211`). Activer un canal est donc **déjà faisable par l'API** ; ce qui manque est l'écran SuperAdmin, explicitement parqué en V2 au §5. Rien à écrire ici.
⛔ **Quotas seed 5M/20M : déjà ajustés** par `AlignSubscriptionPlansWithPricing` — le seed dit Essentiel 0, Pro 0, Premium 20 M, Enterprise `long.MaxValue`. Le « 5M » de cette ligne datait d'avant l'alignement | §1quater |
| ~~**Rate limiting sur les API Keys**~~ | ✅ **Livré — constaté le 2026-08-13**, la ligne du 12/08 ne notait qu'une décision. Tout est en place : politique `ai` partitionnée par `InstanceId` (`Startup.cs:279-299`), `app.UseRateLimiter()` (`:344`) et les **deux** endpoints décorés (`AiController:312/349`). Plafond 120/min, 429 + `Retry-After: 60`.
⚠️ **L'ordre du pipeline est un choix, pas un hasard** — un commentaire le dit dans `Startup.cs` : le limiteur est **après `UseCors`** (sinon un 429 sans en-têtes CORS s'affiche comme une erreur CORS dans le navigateur et `visitapp-web` ne voit jamais le vrai code) et **après `UseAuthentication`** (sinon la partition n'a pas encore le claim d'instance et tout le monde tombe dans le même seau). Ne pas réordonner |
| ~~**Stripe Customer Portal**~~ | 🔨 **Backend livré le 2026-08-13.** `StripeService.CreateBillingPortalSessionAsync` + `POST /api/onboarding/billing-portal`, calqué sur `checkout-session` : Stripe héberge la page, on ne renvoie qu'une URL de redirection. **2 tests** (les deux gardes ; l'appel Stripe lui-même part sur le réseau et n'est pas couvert).
⚠️ **Une garde qui n'était pas dans l'énoncé : `StripeCustomerId` peut être nul.** Les 4 instances de la bascule viennent de Mongo et n'ont **jamais vu Stripe** — `CreateCustomerAsync` n'est appelée que par l'inscription self-service (`OnboardingController:224`). Sans la garde, elles seraient parties chercher un portail pour un client inexistant et auraient reçu une erreur d'API Stripe illisible côté front. C'est un **409**, distinct du 404 d'instance inconnue.
**Reste dans `manager-app`** (session séparée) : le bouton dans `Screens/Billing/subscription_screen.dart`, qui ne sait toujours que lancer un Checkout de souscription |
| ~~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
Les 8 écarts a→h du §1quinquies. Ne démarre qu'après les lots B et C1 (**L2**, **L5**).
| Écart | Quoi | Attention |
|---|---|---|
| ~~c~~ ✅ **2026-08-11** | `Instance` : 4 champs migrés sur ~35 — **requalifié : ce n'était pas un oubli de mapping.** `OldInstance` et l'export réel ne contiennent que `_id`, `Name`, `DateCreation`, `PinCode`. Les 31 autres colonnes **n'existent pas dans la source** : elles sont nées avec Postgres v3. Il n'y a donc rien à mapper, il faut **générer, dériver ou décider**. `PublicApiKey` → générer · `WebSlug` → dériver du `Name` (`SlugHelper` existe) · `IsMobile`/`IsTablet`/`IsWeb` → dériver · `SubscriptionPlanId` → décidé.
**Livré** : `PublicApiKey` générée avec le même schéma que l'inscription self-service (`ap_` + 32 octets cryptographiques) · `WebSlug` via `SlugHelper.GenerateUniqueSlug` (accents et collisions déjà gérés) · `IsTablet`/`IsMobile` dérivés des configurations Mongo, comme le fait déjà `MigrateApplicationInstancesAsync` ; `IsWeb` reste faux, le canal web n'existait pas · plan affecté par nom, un nom inconnu retombant sur **Pro**, jamais sur un plan qui offrirait l'IA · `IsAssistant` allumé seulement si le plan donne des jetons.
⚠️ `ApplyPlanQuotas` est **dupliqué** depuis `InstanceController` plutôt qu'appelé : la migration ne doit pas casser si ce contrôleur change de forme. Les deux doivent rester alignées | **Le plus dangereux : aucune erreur levée.** `PublicApiKey` null = les apps visiteur ne s'authentifient plus ; `WebSlug` null = visitapp-web injoignable ; `SubscriptionPlanId` null = quotas à 0, donc `AiTokensPerMonth = 0`, donc **rien ne s'indexe** et l'IA tourne sans compteur. Réutiliser `ApplyPlanQuotas` |
| ~~a~~ | ⛔ **Fausse alerte — vérifiée dans l'export le 2026-08-11.** `BuildSection` couvre **exactement les types que Mongo contient**. L'enum va de `Map`=0 à `Weather`=10, puis `Event`=11 et `Parcours`=12 ; or `TabletDb.Sections.json` (327 sections) ne contient **que les valeurs 0 à 10**. `SectionEvent` et `SectionParcours` **n'existaient pas dans Mongo** — ce sont des types nés avec Postgres v3. Le `default` est un filet, pas un trou | Le kanban parlait de « perte de données » et du « scénario carnaval » : ce scénario est du **contenu à créer** dans Postgres, pas du contenu à migrer. Vérifié aussi : le renommage `SectionPuzzle` → `SectionGame` est **déjà traité** — la branche `Game` parse `OldPuzzleDTO` et pose `GameType = GameTypes.Puzzle`, et la valeur 8 n'a pas bougé |
| ~~b~~ ✅ **2026-08-11** | Collections filles jamais remplies | **Vrai, et mesuré** : `QuizQuestions = new()` jetait les questions de **5 sections quiz, soit 41 questions**, réponses comprises. Migrées. Le type de validation n'existant pas dans l'ancien modèle (tout y était à choix multiples), elles arrivent en `MultipleChoice` plutôt qu'au défaut `Simple`. **En revanche `EventAgendas = new()` et l'absence de `GuidedPath` sont corrects** : les agendas Mongo ne portent que `mapProvider` et `resourceIds`, aucun événement, et SectionParcours est né avec Postgres |
| ~~d~~ ✅ **2026-08-11** | `Role = UserRole.ContentEditor` en dur | **Vrai, et pire que décrit** : Mongo **n'a pas de champ Role**, donc les 10 utilisateurs y avaient un accès complet. Le défaut les **dégradait tous en silence** — plus personne n'aurait pu gérer les utilisateurs de son instance. Passé à `InstanceAdmin`, ce qui préserve leurs droits d'hier et correspond au rôle que l'inscription self-service donne au premier utilisateur. ⚠️ Aucun `SuperAdmin` n'est créé par la migration : à poser à la main |
| ~~e~~ ✅ **2026-08-11** | Colonnes `Resource` non renseignées, `SizeBytes` déduit d'un `HEAD` avalé | **L5 fermé** : passe par `ResourceStorage.Apply`, le même calculateur que la création et le backfill — `StoragePath` n'était pas écrit **du tout**. Et l'échec du `HEAD`, jusqu'ici silencieux, **remonte dans le rapport** : une ressource à 0 octet que le quota compte pour rien, ça se sait |
| ~~f~~ | ⛔ **Fausse alerte — vérifiée dans l'export le 2026-08-11.** Ces trois colonnes **n'existent pas dans Mongo** : les forcer à `false` est le bon défaut, pas une perte. Contrôle inverse fait aussi — les cinq réglages qui, eux, **existent** dans l'export (`IsDate`, `IsHour`, `IsSectionImageBackground`, `RoundedValue`, `ScreenPercentageSectionsMainPage`) **sont bien mappés**, sur `AppConfigurationLink` | Rien à corriger |
| g | `MapResourceId = dto?.iconResourceId` | **L6** — tombe avec le lot B |
| ~~h~~ ✅ **2026-08-11** | Pas de transaction globale, `catch` qui renvoie 200 OK | Transaction unique sur les neuf étapes : soit tout est là, soit rien. Et l'échec fatal renvoie désormais **500** — le piège était qu'un script de bascule testant le code HTTP concluait au succès, l'erreur n'étant que dans le corps. En `dryRun`, pas de transaction ouverte : rien n'est écrit |
Puis : **dry run** (`dryRun=true`) et **comparaison des comptages par entité**. C'est la seule preuve que les écarts sont fermés.
🔨 **Joué le 2026-08-11 sur l'export du 1ᵉʳ avril.** Automatisé en test (`MigrationDryRunTests`), qui **se saute si aucun Mongo n'écoute** sur `MIGRATION_TEST_MONGO` (défaut `mongodb://localhost:27018`) — la suite reste donc verte sans dépendance. `dotnet test` **144/144**.
⚠️ **`MigrationController` lit un MongoDB vivant, pas les fichiers de `migration-data/`.** Ces JSON sont un export : il faut les importer dans un Mongo avant tout dry run. Recette (rien à installer sur l'hôte) :
```bash
docker run -d --name myim_mongo_dryrun -p 27018:27017 mongo:6
# monter ailleurs que sous /data : l'image mongo y déclare déjà des volumes
for c in Instances Users Configurations Resources Sections Devices; do
docker run --rm --network container:myim_mongo_dryrun -v "$PWD/migration-data:/import:ro" mongo:6 mongoimport --uri mongodb://localhost:27017 --db TabletDb --collection $c --file /import/TabletDb.$c.json --jsonArray --drop
done
dotnet test --filter MigrationDryRunTests
```
**Résultat, et ce qu'il a trouvé du premier coup :**
| Entité | Mongo | Migré |
|---|---|---|
| Instances | 4 | 4 |
| Users | 10 | 10 |
| Configurations | 15 | 15 |
| Resources | 2374 | 2374 |
| **Sections** | **327** | **307** + 20 signalées |
| Devices | 46 | 46 |
**20 sections manquaient, avec `Erreurs : 0`** — la perte silencieuse même. Diagnostic : elles référencent **deux configurations qui n'existent plus** dans Mongo (`6863c9af…`, `6863cac9…`) — 19 articles et un slider, du contenu MDLF (« Senteur : fraises », « Pupitre vigne », « Ruche 7 »). Ce n'est **pas un défaut de la migration** : les sections sont collectées configuration par configuration, donc les orphelines n'étaient jamais énumérées, et la FK `ConfigurationId` les refuserait de toute façon. Le défaut était le **silence**. Elles partent désormais dans `Skipped`, et le test affirme l'invariant qui compte : `migrées + signalées = Mongo`.
> ⚠️ **Décision produit en attente** : ces 20 sections sont du contenu réel qui n'arrivera pas en Postgres. Soit on recrée les 2 configurations manquantes dans Mongo **avant** la bascule pour les récupérer, soit on acte leur perte. À trancher avec MDLF, pas en interne.
⚠️ **L'export date du 1ᵉʳ avril, la prod tourne encore sur Mongo** : ce dry run valide la mécanique, pas les comptages du jour J. **À rejouer sur un `mongodump` frais** juste avant la bascule — de préférence avec un utilisateur Mongo `readAnyDatabase` créé pour l'occasion, plutôt qu'en pointant l'app de dev sur la prod : les six `DatabaseService` exposent `InsertOne`/`ReplaceOne`/`DeleteOne`, et `dryRun` ne protège que Postgres.
### Lot H — Tests end-to-end (1 semaine)
Ordre du §2 de STATUS.md, corrigé par **L12**.
| Ordre | Quoi | Note |
|---|---|---|
| H1 | **§0 — porte d'entrée build**, 5 commandes | Possible seulement après le lot A (tablet-app) |
| H2 | **§8bis / §8ter / §8quater** — écran Statistiques, export PDF, rétention | Jamais ouvert dans un navigateur. Sur le PDF : accents, et logo — s'il manque, c'est le CORS du bucket Firebase, le rapport doit se générer quand même |
| H3 | **§19 — parité par type de section**, puis **§19.13 cas E** (chasse au trésor / carnaval) | Le cas E valide SectionMap + SectionParcours + géodéclenchement + questions + mode jeu d'un coup. Le cas 0 valide les 4 correctifs du 09/08 |
| H4 | **§18 — onboarding self-service** | **L12** — après le déploiement web du lot I. Prérequis de config : alias `onboarding@myinfomate.be` et `whsec_` de la Stripe CLI |
| H5 | §1-17 par lot, §16 non-régression | |
### Lot K — Apps visiteur à niveau pour la bascule (3 à 5 jours) — **ajouté le 2026-08-12**
> **Ce lot n'existait pas, et il est bloquant.** Il est né d'un vrai build de `tablet-app`, le premier depuis des mois : le diagnostic des docs (« un seul défaut, purement Gradle, peut-être déjà réglé par `5a6701d` ») ne tenait que parce que personne n'avait lancé la commande.
>
> ⚠️ **Le point qui fait de ce lot un bloquant de la bascule, et que le plan ne portait nulle part : le contrat d'API a changé, les apps déployées parlent l'ancien.** `ConfigurationDTO.roundedValue` et `GeoPointDTO.latitude/longitude` ne sont plus servis par Postgres v3. Une tablette en service chez MDLF ou au Fort ne s'arrêterait pas proprement le jour J — elle afficherait des cartes sans points et des écrans dégradés. Voir **L19**.
✅ **`tablet-app` construit — `flutter build apk --debug` vert le 2026-08-12**, APK produit pour la première fois depuis le 16 avril. K1, K2 et K4 sont livrés et **prouvés par un build**, pas seulement par `flutter analyze`. L'APK passe de 174 à 248 Mo : effet attendu du cran 3 (bibliothèques natives non compressées).
**K1 — Outillage Android `tablet-app`, 2026-08-12. Dix crans**, chacun dicté par l'erreur du précédent — à ne pas re-découvrir un par un. ⚠️ **Le compte de « six crans » écrit 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.
| # | Changement | Ce qu'il débloquait |
|---|---|---|
| 1 | Gradle **7.5 → 8.11.1** | le plugin Kotlin exigeait ≥ 7.6.3 |
| 2 | AGP **7.2.0 → 8.5.0**, Kotlin **1.9.0 → 2.0.10** | `onVariants$default` : Kotlin 1.9 appelle une API absente d'AGP 7.2 |
| 3 | `android.bundle.enableUncompressedNativeLibs` **retirée** | option supprimée en AGP 8.1. Valait `false`, le défaut est `true` : les `.so` ne sont plus compressés dans l'AAB |
| 4 | AGP → **8.7.3** | minimum exigé par Flutter (8.6.0) |
| 5 | AGP → **8.9.1** | réclamé par `androidx.browser:1.9.0` et `androidx.core:core-ktx:1.17.0`, tirés par les plugins |
| 6 | `jcenter()` → `mavenCentral()` | jcenter est arrêté depuis 2021 |
| 7 | `jvmargs` **1536M → 4096M** | `Java heap space` dans `JetifyTransform` sur les jars Flutter |
| 8 | `android.enableJetifier` **true → false** | Jetifier ne sert plus à rien depuis qu'AndroidX est partout, et c'est lui qui saturait la heap |
| 9 | **`resolutionStrategy` sur `androidx.lifecycle` retiré** | ⚠️ **Le plus instructif** : il forçait la version **2.4.0** avec le commentaire « To fix mapbox issue ». Il réglait un problème mapbox d'il y a trois ans et **causait** celui d'aujourd'hui — `mapbox_maps_flutter` 2.21.1 appelle `setViewTreeLifecycleOwner`, absent de 2.4.0, et sa compilation Kotlin échouait. Ne pas le remettre : un commentaire le dit dans `android/build.gradle` |
| 10 | Kotlin **2.0.10 → 2.3.10** | les dépendances tirent `kotlin-stdlib 2.3.10`, métadonnées binaires incompatibles avec un compilateur 2.0. Satisfait au passage l'exigence Flutter ≥ 2.2.20, annoncée comme prochaine rupture |
**Les crans 7, 8 et 9 sont des alignements sur `mymuseum-visitapp`**, qui utilise le même plugin mapbox et ne rencontre aucun de ces problèmes : `-Xmx4096M`, `enableJetifier=false`, aucun forçage de `lifecycle`. **Réflexe à garder pour K5** : avant de chercher, comparer les deux `gradle.properties` et les deux `build.gradle`.
⚠️ **Un bloc `buildscript` entièrement commenté dans `android/build.gradle`** déclarait AGP 8.5.0 / Kotlin 2.0.10 alors que la config appliquée vivait dans `settings.gradle` (AGP 7.2.0 / Kotlin 1.9.0). Il a fait conclure deux fois à une configuration qui n'était pas celle du build — **il est supprimé**, les versions ne sont plus déclarées qu'à un seul endroit. C'est le troisième piège « bloc commenté » du projet, après les deux pistes Dart écartées le 11/08.
⚠️ **Dette d'outillage restante, non bloquante, à traiter en V2** : Flutter annonce l'abandon de Kotlin < 2.2.20, et **8 plugins appliquent le Kotlin Gradle Plugin** (`mapbox_maps_flutter`, `video_player_android`, `webview_flutter_android`, `device_info_plus`, `fluttertoast`, `package_info_plus`, `shared_preferences_android`, `wakelock_plus`) — les futures versions de Flutter refuseront de construire dans ce cas. **`compileSdkVersion 36` n'est pas négociable** : sans lui, plus de mise à jour publiable sur le store.
| # | Quoi | Note |
|---|---|---|
| ~~**K2**~~ ✅ **2026-08-12** | **Portage livré. Les 132 erreurs de contrat sont tombées** ; `flutter analyze lib` ne renvoie plus que les 6 erreurs `PuzzleDTO`, qui sont K4. Fait : `TabletAppContext.currentAppConfigurationLink` (copie du getter mymuseum), `lib/Helpers/geo.dart` avec l'extension `lat`/`lng`, 67 accès `roundedValue` rebranchés, `main_view` et les 4 vues carte portées.
⚠️ **Deux bugs latents trouvés — c'est ce qui justifiait de le faire à la main.**
**(1)** `applicationInstanceDTO` n'était assigné **que si `instanceDTO.isAssistant == true`** (`main_view:339`) : il servait de drapeau d'assistant. Ce DTO portant désormais les `AppConfigurationLink`, le getter aurait renvoyé `null` sur **toute instance sans IA — donc MDLF et le Fort** — et les cinq réglages seraient retombés sur leurs défauts, sans erreur ni log. Corrigé : assignation inconditionnelle + drapeau explicite `isAssistantEnabled`, qui **préserve le ET** instance × canal (les deux gardes de `AiController:315/360`).
**(2)** `ConfigurationDTO.isTablet` a disparu : le filtre « configurations pour tablette » de `getConfigurations` n'avait plus de source. Il porte désormais sur les `AppConfigurationLink` du canal `AppType.Tablet`, même appel que `main_view`. ⚠️ **À valider sur device (§0/§18)** : si un client a des configurations non rattachées à son canal tablette, l'écran de sélection sera **vide**. L'ancien code avait le même angle mort, mais la donnée qui pilote le filtre a changé.
⚠️ **Piège de vérification** : `flutter analyze` écrit `error - `, pas `error • ` — un grep sur la mauvaise forme a fait conclure « 0 erreur » alors qu'il en restait 10 |
| ~~K2 — notes de conception~~ | ⚠️ **Deux conventions de coordonnées coexistent** dans le projet — `GeoPointDTO` en `[lng, lat]`, géométries de `GuidedStep` en `[lat, lng]`. Une confusion **ne lève aucune erreur**, elle déplace les points. Détail et tableau dans **STATUS.md §1sexies**. Fondations déjà posées le 2026-08-12 : `TabletAppContext.currentAppConfigurationLink` et `lib/Helpers/geo.dart`.
**Portage du contrat d'API** — **132 erreurs Dart, 3 causes** : `ConfigurationDTO.roundedValue`/`isDate`/`isHour`/`isSectionImageBackground`/`screenPercentageSectionsMainPage` ont migré vers **`AppConfigurationLinkDTO`** (73 err.) · `GeoPointDTO.latitude/longitude` → **`GeometryDTO? geometry`**, effet de PostGIS (40 err.) · les `min()` sur `dynamic` en découlent (19 err.) | **Le modèle est déjà écrit** : `mymuseum-visitapp` a fait ce portage (`visitAppContext.currentAppConfigurationLink?.roundedValue`, `Models/visitContext.dart`). Copier, ne pas concevoir. Les 40 sites `latitude`/`longitude` se traitent par **une extension Dart sur `GeoPointDTO`** plutôt qu'un à un |
| ~~**K3**~~ ✅ **2026-08-12** | **`SectionEvent` livré sur le kiosk**, `flutter build apk --debug` vert, APK produit. `Screens/Event/event_view.dart` : bande héros à 26 %, programme à gauche avec bloc « en cours », carte `flutter_map` vive à droite, détail d'un bloc **sous la carte** et non en `showModalBottomSheet`. Volet parcours exclu, conformément à l'arbitrage ci-dessous.
⚠️ **Un seul constructeur de marqueurs au lieu des quatre de mymuseum.** `MapAnnotationDTO` et `MapAnnotation` portent des champs **rigoureusement identiques** mais sont deux types Dart distincts — d'où `_buildMarkers`/`_buildBlockMarkers` et `_buildPolylines`/`_buildBlockPolylines` là-bas. Normalisé ici par une classe interne à deux fabriques : c'est le raisonnement de **L5** appliqué au front, et il porte d'autant plus que **la convention de coordonnées est l'endroit exact où une divergence ne lève aucune erreur**.
⚠️ **Nouvelle clé i18n `event.live` dans les 10 langues** de `Helpers/translations.dart`. **FR, EN, NL, DE, IT, ES sont sûres ; PL, CN, UK et AR sont de ma main et demandent une relecture humaine.** Sans les 10, `getFromLocale` renvoie `""` et la pastille rendrait une boîte vide | **Périmètre kiosk — tranché le 2026-08-12.** ⛔ **`tablet-app` couvre 10 des 13 types, pas 11 — recompté dans le `switch` le 2026-08-12.** `main_view.getContent` traite Map, Slider, Video, Web, Menu, Quiz, Pdf, Game, Agenda, Weather. **`SectionArticle` (type 6) manque** : son `case` est commenté « TODO » et `Screens/Article/article_view.dart` fait 1 Ko — un article tombe dans le `default`, « Ce type n'est pas supporté ». **Ce type-là existait dans Mongo**, contrairement aux deux ci-dessous : c'est un vrai retard, à trancher (le supporter ou l'assumer) et non à compter comme couvert. Énoncé d'origine, qui reste valable : `tablet-app` couvre **11 des 13** types. **`SectionParcours` : exclu définitivement** — un parcours guidé fait marcher le visiteur avec géodéclenchement, ça n'a aucun sens sur borne fixe. **`SectionEvent` : à supporter** — une borne d'accueil qui affiche le programme du jour et le plan est le cas d'usage kiosk par excellence, et c'est le scénario du §19.13 cas E | Ni l'un ni l'autre n'est un retard : les deux types **sont nés avec Postgres v3**, ils n'ont jamais existé dans Mongo (cf. lot G, écart a) |
| ~~**K4**~~ ✅ **2026-08-12** | **Écran Game repris de mymuseum.** Les 6 fichiers de `Screens/Sections/Game/` sont dans `tablet-app/lib/Screens/Game/`, contexte adapté ; `lib/Screens/Puzzle/` supprimé ; `main_view` bascule sur `SectionType.Game` / `GameDTO` / `GamePage`. **Gains** : le puzzle glissant (mélange par mouvements valides, donc toujours soluble), le bouton d'indice, et un dimensionnement qui respecte le ratio de l'image — 508 lignes contre 237 à l'ancien `puzzle_view`.
⚠️ **Une décision de rendu à valider à l'œil** : mymuseum code ses couleurs **en dur par flavor client** (`kMainColor0/1/2` — rouge MDLF, bleu Fort). `tablet-app` n'a pas de flavor : un seul APK sert tous les clients et la charte vient de la configuration choisie au pincode. Recopier ces constantes aurait affiché du bleu chez MDLF. Le dégradé et le bouton d'indice sont donc **dérivés de `configuration.primaryColor`** (même parsing que `audio_player`/`loading_common`, repli `kTestSecondColor`, 3 nuances par `Color.lerp`). Plus juste sur le fond, mais **le rendu diffère de la version mobile** : si le dégradé dessiné à la main est préféré, figer les trois couleurs |
| ~~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**~~ ✅ **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 |
| **K9** | **Repasse visuelle de la borne : écran principal en bento + orientation sur tous les types — ajouté le 2026-08-12.**
**Ce n'est pas une extension de la maquette K3/K7, c'est sa généralisation.** Cette maquette a tranché six règles de paysage pour **deux** types (Article, Event) ; les neuf autres n'ont jamais été repassés, et l'écran d'accueil encore moins. Les trancher type par type au fil de l'eau donnerait onze mises en page différentes sur la même borne — c'est le raisonnement de **L5**, **L15** et de la note de K7 (« une seule passe de design pour les deux »), appliqué au reste.
**a) L'écran principal — le gros morceau, et le seul qui demande du code neuf.**
⚠️ **Vérifié le 2026-08-12 : le kiosk n'a pas le bento.** `main_view.dart:403-405` construit un `GridView.builder` avec un `SliverGridDelegateWithFixedCrossAxisCount` — **toutes les tuiles font la même taille**, il n'y a pas de notion de span. Et `tablet-app/pubspec.yaml` ne déclare qu'une seule dépendance locale, `manager_api_new` : **`myinfomate_layout` n'y est pas**, conformément au `CLAUDE.md` racine (« pas par `tablet-app`, kiosk hors périmètre pour l'instant »). C'est cette phrase-là que ce point rouvre.
✅ **Mais la donnée est déjà là, et déjà atteignable — c'est ce qui fait chuter le coût.** Les spans sont portés par **`AppConfigurationLinkDTO.gridColSpan` / `gridRowSpan`**, donc **par canal** : le canal Tablet a déjà ses propres valeurs, réglables dans `manager-app` (sélecteur de forme ou glisser du coin), et l'aperçu live de `app_configuration_link_screen` les rend déjà en bento. Surtout, **K2 a déjà posé `TabletAppContext.currentAppConfigurationLink`** — le getter existe, il a été écrit pour les cinq réglages de configuration. Il ne reste donc ni à câbler la donnée ni à concevoir l'algorithme : ajouter `myinfomate_layout` en dépendance locale et appeler `bentoLayout()` comme le fait `mymuseum-visitapp` (`home_3.0.dart:538` et `:736-737`).
**Bénéfice de bord** : l'aperçu de `manager-app` devient enfin fidèle pour le canal tablette — aujourd'hui il montre un bento que la borne ne rend pas.
**b) Inventaire par type — verdict du 2026-08-12, à ne pas re-solliciter.**
**À revoir** : `SectionSlider` (le plus faible visuellement), `SectionWeather` (retouche légère).
**À garder tel quel** : `SectionAgenda` — c'est la référence, les autres s'alignent dessus plutôt que l'inverse.
**Réputés bons, à confirmer à l'œil en paysage** : `SectionGame` (puzzle), `SectionWeb`, `SectionMap`, `SectionVideo`.
**Non tranchés, à regarder dans la même passe** : `SectionMenu`, `SectionQuiz`, `SectionPdf`. `SectionArticle` et `SectionEvent` sont déjà couverts par la maquette K3/K7 et ne se rouvrent pas.
**c) `SectionVideo` — les types de vidéo, un vrai trou.**
⚠️ **Vérifié : YouTube marche, Vimeo n'existe pas.** `SectionVideo.VideoSource` porte « soit un id de ressource uploadée, soit une URL YouTube/**Vimeo** » (`SectionVideo.cs:21`), et le kiosk a bien un `Components/video_viewer_youtube.dart` branché dans `cached_custom_resource:51` et `show_element_for_resource:89`. Mais **`vimeo` n'apparaît nulle part dans le code Dart des deux apps visiteur** — une URL Vimeo tombe donc dans le lecteur de fichier et ne lit rien. Le backend promet un format que le client ne sait pas jouer.
⚠️ **Corollaire hors ligne, déjà dans le code** : `GetReferencedResourceIds` exclut explicitement les `VideoSource` commençant par `http` (`SectionVideo.cs:25-27`) — une vidéo YouTube ou Vimeo **n'est pas téléchargeable**. Une borne sans réseau affichera un lecteur vide, et c'est correct. À ne pas « corriger » : c'est un choix, pas un oubli.
**d) ~~Orientation~~ ✅ Tranché le 2026-08-12 : paysage seul en V1, **mais le portrait est un objectif V2 explicite**.** Une seule maquette à dessiner, les six règles de K3/K7 s'étendent aux neuf autres types.
⚠️ **Ce n'est pas « paysage en dur » pour autant, et c'est la seule contrainte que cet arbitrage impose au code.** Le portrait étant annoncé pour V2, une mise en page qui suppose la largeur **partout** obligerait à tout rouvrir. Règle à tenir : les décisions qui dépendent de l'orientation — le sens de partage (deux colonnes contre deux rangées), le côté du rail média, la position de la carte — passent par **un point de décision unique et nommé** (une constante, un helper, peu importe), au lieu d'être dispersées en `Row(` littéraux dans onze écrans. En V1 ce point renvoie toujours « paysage » ; en V2 il lit `MediaQuery.orientation`. **Le surcoût en V1 est nul, le gain en V2 est le chantier entier.** ⚠️ Ne pas aller plus loin : pas de second jeu de règles, pas de maquette portrait, pas de `if portrait` spéculatif dans les écrans — seulement l'endroit où le brancher plus tard.
Corollaire sur le déjà-livré : `Article` et `Event` (K3/K7) sont à repasser sur ce point de décision quand K9 les traversera, sinon la V2 aura deux idiomes.
**e) `SectionVideo` — support Vimeo ✅ V1, tranché le 2026-08-12.** Lecteur Vimeo dans **les deux apps visiteur**, sur le modèle du `video_viewer_youtube.dart` existant, branché aux deux mêmes points de dispatch (`cached_custom_resource:51`, `show_element_for_resource:89`). ⚠️ **`tablet-app` et `mymuseum-visitapp` ont chacun leur copie de ces trois fichiers, à l'identique** — c'est le motif qui a fait diverger `Create`/`Upload` en C1 puis C3, et `_buildMarkers` en K3 : écrire le lecteur une fois, le copier consciemment, ne pas laisser les deux versions dériver. ⚠️ Rappel : comme YouTube, une URL Vimeo **reste hors ligne indisponible** (`SectionVideo.cs:25-27`) — c'est voulu, ne pas le « corriger » dans la même passe. ~0,5 jour | **Chantier de design, pas de portage** — donc même méthode que D-bis : maquette HTML versionnée dans `DOCS/claude design/` d'abord, code ensuite, et **boucle de vérification à l'œil** façon DB5 (`flutter analyze` ne prouve rien sur du visuel).
⚠️ **Position dans l'ordre** : c'est du **confort visuel, pas un bloquant de bascule**, contrairement à K2/K3/K4 qui réparaient un contrat d'API cassé. Ça peut donc passer **après** K6 sans dégrader personne — les bornes sont des appareils gérés, une seconde publication d'APK ne coûte rien. À arbitrer contre la date de bascule.
**Estimation : 4 à 6 jours**, dont ~1 de maquette. Le bento seul est de l'ordre de la demi-journée, la dépendance et la donnée étant déjà en place |
| **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
> **Placé en fin de backlog le 2026-08-11**, à la demande. C'est tenable pour une seule raison : la porte « rien en prod avant la fin du backlog ». La journalisation des questions ne tourne d'ici là que sur des bases de développement, donc aucun visiteur réel n'est concerné. **Si cette porte saute, ce lot remonte devant.**
Les CGU §8 ont été réécrites le 2026-08-11 (`cgu-myinfomate.md`) et le texte d'information visiteurs existe (`mention-information-visiteurs.md`). Ce qui reste :
✅ **Lot J : tout le codable est livré le 2026-08-13** — J1, J2, J3, J4 (les deux apps visiteur) et la purge du journal d'audit. `dotnet test` **229**, APK `dev` ✅, `flutter build web` ✅, `npm run build` ✅. Migration unique `LotJ_ThemeAggregatesAndCollectionSwitch` : une colonne + une table, rien d'autre — vérifié dans le fichier généré. **Ce qui reste au lot J ne s'écrit pas en code** : relecture juridique du §8, conditions réelles de Google, DPA séparé, et les traductions relues de la mention.
⚠️ **Les thèmes sont une liste fixe de 8, pas des thèmes découverts par l'IA** — décidé le 2026-08-13. Des libellés régénérés à chaque passage produiraient « Horaires » en janvier et « Questions d'horaires » en février : deux lignes d'agrégat distinctes, et une courbe qui ne veut rien dire. Or la table existe précisément pour porter cet historique. **Ajouter un thème reste possible, en renommer un coupe l'historique en deux.**
⚠️ **Le job tourne à 2 h, la purge à 3 h 30, et l'ordre compte** : une question purgée avant d'avoir été classée ne compte dans aucun agrégat et rien ne peut la rattraper.
⚠️ **Le plafond de 500 par passage ne perd rien, il retarde** — et c'est le point qui a été discuté. Les questions sont traitées **les plus anciennes d'abord** ; avec un passage par jour, il faudrait un volume soutenu supérieur au plafond *chaque jour pendant trois mois* pour qu'une question atteigne la purge sans thème. Un avertissement part dès que le retard dépasse un passage. **Les jetons ne sont pas décomptés du quota du client** : il n'a pas demandé ces appels.
⚠️ **Un lot en échec n'est pas marqué « Autre » pour s'en débarrasser** : ses questions restent non classées et repassent en tête au tour suivant. Marquer serait une perte définitive maquillée en résultat. Et une réponse du modèle à laquelle il manque une ligne **ne décale pas les suivantes** — la relecture se fait par numéro, jamais par position ; un test le fixe.
| # | Quoi | Note |
|---|---|---|
| ~~J1~~ ✅ | **Table d'agrégats `QuestionThemeMonthly`** | `(InstanceId, mois, thème, compteur)`, index sur `(InstanceId, Month)`. **Ne porte que des compteurs** — ni texte, ni session, ni langue : aucune donnée personnelle, donc rien qui justifierait de la purger, ce qui est précisément ce qui l'autorise à survivre. ⚠️ **`Insights` lit désormais `topics` et `themes` dans cette table, pas dans les questions de la fenêtre** — les lire dans les questions faisait disparaître l'historique au 91ᵉ jour, sans erreur ni trace |
| ~~J2~~ ✅ | **`QuestionThemingService`**, quotidien à 2 h | Classement par lots de 25 en un appel — un appel par question multiplierait le coût par 25 pour le même travail. **6 tests** |
| ~~J3~~ ✅ | **`Instance.IsVisitorQuestionCollectionEnabled`**, défaut `true` | Garde dans `Chat`, exposé au DTO, et **interrupteur dans l'écran Guide IA** — sans UI le client ne pourrait pas exercer le refus dont il est responsable. Le libellé dit ce que couper coûte (l'onglet cesse de se remplir) et ce que couper ne fait pas (rien n'est effacé, les questions déjà là vivent jusqu'à leur purge) |
| ~~J4~~ ✅ | **Mention aux visiteurs** | Livrée dans **`mymuseum-visitapp`** (`VisitorPrivacyNotice`) **et `visitapp-web`** (panneau dépliable dans l'en-tête de l'assistant), texte repris **mot pour mot** de `mention-information-visiteurs.md` dans les deux.
⚠️ **Trois langues seulement (FR/NL/EN), repli sur l'anglais — c'est un choix.** Les apps en portent dix et six, mais traduire une mention de protection des données sans relecture humaine serait pire que la servir en anglais : une nuance perdue sur « nous n'enregistrons pas votre adresse IP » n'est pas une coquille d'interface. **À demander avec la relecture juridique (J5).**
⚠️ **Trois copies du même texte** (le document, le Dart, le TSX) : une correction se fait dans le document d'abord, les deux autres suivent. C'est écrit dans les deux fichiers de code |
| ~~J8~~ ✅ | **Purge du journal d'audit** — ajouté le 2026-08-13 | ⚠️ **Poste oublié à la première passe du lot J** : le plan l'y assignait explicitement (« à traiter au lot J, pas au lot F »), il n'avait pas été fait. `AuditLog` porte un `UserId`, et les valeurs avant/après d'une modification de `User` contiennent e-mail, prénom et nom — **conservés sans limite** jusqu'ici, quand `VisitEvent` purge à 13 mois et `VisitorQuestion` à 90 jours.
**12 mois, uniforme**, via `AuditLogPurgeService` calqué sur `VisitEventPurgeService`. Pas 13 : les 13 mois des stats servent à comparer une saison à la précédente, les recopier ici serait du mimétisme — **un test fige cet écart voulu**.
⚠️ **Inerte tant qu'`Audit:RetentionDays` n'est pas défini**, même verrou que les `VisitEvent` : pas de pg_dump, pas de suppression définitive.
⚠️ **`ExecuteDeleteAsync` n'est pas traduisible par le provider InMemory** — la suppression se teste contre un vrai Postgres. L'écrire en chargeant les lignes puis `RemoveRange` l'aurait rendue testable en mémoire au prix de charger un an de journal : dégrader le code de production pour satisfaire un provider de test |
| J5 | **Faire relire le §8 des CGU** | Document contractuel, rédigé côté produit et non validé juridiquement. Priorité au §8.6 (sous-traitants) et au §8.5 (transfert hors UE) |
| J6 | **Vérifier les conditions réelles de Google** | Le §8.5 dit « peut impliquer un transfert hors UE » — prudent mais vague. Savoir si l'API Gemini utilisée offre une résidence européenne, et si un DPA est signé. La réponse réécrit le §8.5 |
| J7 | **Décider si un DPA séparé est requis** | L'article 28 exige un acte écrit. Le §8 en tient partiellement lieu ; une commune ou un musée subsidié en demandera un en annexe |
**Un point qui n'est pas un problème** : aucune demande d'effacement individuelle n'est exécutable, puisque rien n'est rattachable à une personne. C'est acceptable — la protection tient à l'absence d'identifiant plus la purge, pas à une procédure de droits qui ne pourrait pas s'appliquer.
### Lot I — Infra prod et bascule (2 à 3 jours + la fenêtre)
| # | Quoi | Lien |
|---|---|---|
| **I0** | **Rotation des secrets** : JWT signing key, clés Gemini/OpenWeather, connection strings, mot de passe MQTT, token Telegram → variables d'environnement, purge de l'historique Git, suppression de `RELEASE/`.
⚠️ **Inventaire élargi le 2026-08-12 : les apps Flutter en portent aussi.** Trouvé en réparant le build tablet-app : `SDK_REGISTRY_TOKEN=sk.…` — un token **MapBox secret** (préfixe `sk.`, pas `pk.`), au nom du compte perso, en clair dans `tablet-app/android/gradle.properties`, versionné. Il sert à télécharger le SDK MapBox au build, donc **le retirer casse le build** tant qu'il n'est pas passé en variable d'environnement — à faire dans la même passe, pas avant. **Passer les 3 repos Flutter au peigne** : l'inventaire d'origine ne couvrait que `manager-service`, on aurait rotationné la moitié des secrets en croyant le sujet clos | **L1** — déplacée du lot A le 2026-08-11. **Doit précéder I3** : la prod ne doit pas naître avec les clés de l'historique, sinon il faut rotationner deux fois. C'est le premier geste du lot I, pas une option |
| I1 | **`pg_dump` quotidien** en cron, en complément du snapshot OVH | **L3** — puis activer `Stats__RetentionDays=395` et vérifier que `visit-events-purge` ne supprime rien |
| I2 | Backup Mongo automatique en cron (dump + `docker cp` vers l'hôte) | Filet du rollback |
| I3 | **Un seul chantier compose/Traefik** : `Dockerfile.postgres` épinglé par digest, **port 5432 fermé**, entrée `app.myinfomate.be` + Dockerfile `visitapp-web` | **L7** — trois cartes, un fichier. **L1** : après la rotation des secrets |
| I4 | `dotnet ef database update` sur la base vide, puis vérifier `postgis` **et** `vector` présents | Une base en retard d'une migration fait planter l'API dès le login — c'est arrivé en local le 09/08 |
| **I9** | **Renseigner `Firebase:StorageBucket`** (`.appspot.com`) dans la config de prod, puis **lancer le backfill C2** : `POST /api/Resource/backfill-storage?dryRun=true` d'abord, relire `Orphans`/`Unsized`, puis `dryRun=false` | ⚠️ **Deux conséquences si on l'oublie.** Le clé est vide par défaut : sans elle, `Delete` **ne supprime aucun blob** — il ne casse rien, il ne nettoie pas, et C6 (retrait du code client) deviendrait une perte de fonction. Et sans le backfill, le quota de C3 porte sur des `SizeBytes` à 0 pour tout l'existant : il laisse tout passer (**L10**).
⚠️ **Piège de build repéré le 2026-08-12** : `dotnet restore` échoue (401) si la source NuGet `git.dev-espaces-naturels.lu` est déclarée dans l'image — elle est sans rapport avec ce projet. À vérifier dans le Dockerfile avant de rebuilder, le nouveau `Google.Cloud.Storage.V1` étant le premier paquet ajouté depuis longtemps |
| I5 | **Plan de retour arrière écrit**, avant le jour J | « Un rollback qu'on improvise à 23 h n'est pas un rollback » |
| I6 | `pg_dump` avant, puis **rejouer instance par instance** (`instanceId=…`), en commençant par la plus petite | |
**Plans — décisions du 2026-08-11, à ne pas re-trancher.**
**1. Les lignes en base sont alignées sur la grille de tarifs** (migration `AlignSubscriptionPlansWithPricing`, appliquée). La base semait `Starter` / `Standard` / `Premium` / `Essentiel` : noms **et quotas** désalignés. Corrigé :
| Id | Nom | Stockage | Jetons IA | Stats | Rétention | Avancées |
|---|---|---|---|---|---|---|
| `plan-essentiel` | Essentiel | 1 GB | **0** (non inclus) | ✓ | 30 j | ✗ |
| `plan-pro` | Pro | 15 GB | **0** (non inclus) | ✓ | 30 j | ✗ |
| `plan-premium` | Premium | 50 GB | 20 M (~2 000 req) | ✓ | 395 j | ✓ |
| `plan-enterprise` | Enterprise | 0 = illimité | `long.MaxValue` | ✓ | 395 j | ✓ |
`plan-starter` **supprimé** (aucun équivalent commercial, et piège à 0 jeton). `plan-standard` → `plan-pro`, avec repointage SQL des `Instances` **avant** la suppression : EF avait scaffoldé les `DeleteData` en tête, ce qui aurait buté sur la clé étrangère ou laissé des instances sans plan, donc à quotas nuls.
⚠️ **Sémantique du `0`, asymétrique et volontaire** : pour le stockage `0` = illimité, pour les jetons `0` = pas d'IA. D'où la sentinelle `long.MaxValue` sur Enterprise — un `0` l'aurait privé d'assistant. Ne pas « harmoniser » sans repasser dans `CheckQuota`, `AiController` et `SectionIndexingInterceptor`.
⚠️ **Le plan ne porte que 5 champs.** « App native », « offline + beacons », « push », « traduction automatique » sont dans la grille commerciale mais **pas dans la table** : ils se règlent par instance. La base ne les applique pas, l'affectation le fait.
**2. Add-on IA sur Essentiel.** L'IA n'est pas incluse dans Essentiel ni Pro, mais se vend en add-on activé à la main après paiement. **Aucun code à écrire** : `ApplyPlanQuotas` ne recopie les quotas du plan que si le plan change, donc surcharger `Instance.AiTokensPerMonth` survit — le commentaire de la méthode le prévoit (« un client peut recevoir un geste commercial sans changer de plan »).
> ⚠️ **Piège à connaître** : le rattrapage d'indexation (`BackfillInstanceAsync`) est déclenché par `UpdateInstance`, en C#. **Activer l'add-on directement en SQL ne le déclenche pas** — le client paierait un guide qui ne connaît rien de son contenu déjà saisi. Après l'UPDATE, cliquer le bouton de relance SuperAdmin de l'écran Guide IA.
**3. Affectation des 4 clients existants** — pour la bascule, aucun client Essentiel :
| Instance Mongo | Plan | Conséquence |
|---|---|---|
| MyInfoMate instance | `plan-premium` | IA active |
| VisitNamur instance | `plan-premium` | IA active |
| MDLF instance | `plan-pro` | ⚠️ **pas d'IA** |
| Fort Saint-Héribert instance | `plan-pro` | ⚠️ **pas d'IA** |
> ⚠️ **À arbitrer avant le lot H** : Pro n'inclut pas l'IA, donc rien ne s'indexera pour MDLF et le Fort et leur guide ne répondra jamais. Sur 4 instances, la chaîne RAG ne serait éprouvée que sur 2 — or le lien **L14** demande justement de tester le post-filtrage HNSW **à deux instances au moins**. Deux sorties : les passer en Premium le temps des tests, ou leur poser un quota IA à la main (l'add-on ci-dessus), en pensant au rattrapage d'indexation.
**4. ~~Écart restant, hors bascule~~** ✅ **Corrigé le 2026-08-13.** La FAQ de `myinfomate-landing` (`src/data/segments.ts`) appelait le plan à 179 € « **Bundle** » alors que les cartes de tarifs disent « Premium » : **41 occurrences en 4 langues** (FR, EN, NL, DE) passées en « Premium ». Vérifié : plus aucun « Bundle » dans `myinfomate-landing/src`, et les formes composées des langues germaniques suivent (`Bundle-abonnementen` → `Premium-abonnementen`, `Bundle-Pläne` → `Premium-Pläne`). `npm run build` vert. Aucun identifiant touché — les 41 occurrences étaient toutes de la prose.
| I7 | **Recette de bascule — [test-plan.md §22](test-plan.md)**, ajoutée le 2026-08-11. Comptages globaux, puis **par type de section**, collections filles, colonnes générées, et comparaison à l'écran ancienne app vs nouvelle. ⚠️ **À jouer pendant que Mongo est encore lisible** : c'est la seule fenêtre où les deux bases coexistent, après la comparaison est impossible | Le dry run compare des **comptages**, le §22 compare le **contenu** — un compte juste ne dit pas qu'un quiz a gardé ses questions ni qu'un article a gardé ses traductions. Les écarts b et c ne se voient qu'ici |
| I8 | Bascule DNS / API, Mongo gardé en lecture seule quelques jours, `pg_dump` après | ⚠️ **Remplacé par la stratégie de coexistence ci-dessous, décidée le 2026-08-12** |
**Stratégie de bascule — coexistence sur deux serveurs (décidée le 2026-08-12).**
Plutôt qu'une bascule sèche avec fenêtre de maintenance : **un second serveur** porte la nouvelle prod Postgres, les **nouvelles versions des apps** pointent vers lui, et l'ancienne prod Mongo continue de servir les apps déjà installées. Pas de bascule DNS, pas de fenêtre, et un retour arrière qui consiste à ne rien faire.
C'est la bonne réponse au problème que **L19** pose et qu'aucune manœuvre technique ne résout : on ne force pas la mise à jour du téléphone d'un visiteur.
**Ce qui rend la coexistence saine — une seule source d'écriture :**
Le contenu n'est écrit que par le back-office. Dès que `manager-app` pointe sur la nouvelle prod, **l'ancienne devient figée**, en lecture seule de fait. Les vieilles apps voient un contenu gelé au jour J, ce qui est acceptable quelques semaines — et surtout, **les deux bases ne divergent jamais**. Si les deux restaient écrivables, il n'y aurait plus de source de vérité au bout d'une journée.
⚠️ **Ce qui ne marche pas : « recopier de la nouvelle prod vers l'ancienne ».** Il n'existe pas de chemin Postgres → Mongo. `MigrationController` va dans un seul sens, et écrire l'inverse coûterait le même travail — pour alimenter une base qu'on éteint. **L'ancienne prod se fige, elle ne se synchronise pas.** Si un contenu doit absolument arriver sur les vieilles apps, il se saisit à la main des deux côtés, ou on attend le renouvellement du parc.
**Conséquences à tenir :**
- **Le domaine** : les anciennes apps gardent `api.mymuseum.be`, les nouvelles reçoivent une nouvelle adresse. Aucun DNS ne bouge — c'est ce qui rend le rollback trivial.
- **Coût** : un second serveur OVH le temps de la transition. À arbitrer contre le risque d'une bascule sèche ; à ce stade du projet, ça les vaut.
- **Extinction** : décider d'une date de fin de l'ancienne prod, sinon elle survit trois ans. Critère proposé : quand le contenu gelé devient gênant pour un client, ou quand le parc s'est renouvelé.
- **Les tablettes kiosk ne sont pas concernées par l'attente** : ce sont des appareils gérés, mis à jour directement — elles passent sur la nouvelle prod le jour même.
- **I5 (plan de retour arrière) est à réécrire** : avec deux serveurs, le rollback n'est plus une restauration de dump mais « republier l'app qui pointe vers l'ancienne adresse ». Plus simple, mais tributaire des délais de validation des stores — ce qui devient le vrai risque à couvrir.
### Après la prod
Reprendre contact avec **Louise Smets — Musée d'Ixelles**, une fois la prod stabilisée. Premier temps d'écoute ; rien n'est su de son rôle exact.
---
## 3. Chemin critique et parallélisation
```
A (½-1j) ──► B (2-3j) ──► G (3-4j) ──► H (1 sem.) ──► I (2-3j + fenêtre)
│ │
│ └──► C1 ──► C2 ──► C3
│ C4 (C5 ✅)
├──► K1 ✅ ──► K5 ✅ ──► D0 (test device) ◄── débloqué ──► D1..D5
│ └──► K2 ──► K3, K4 ──► K6 (publier) ──► bascule (L19)
├──► D-bis : DB0 ──► DB1 ──┬──► DB2
│ ├──► DB3 ──► F (job de thèmes)
│ └──► DB4 ✅ (A tranchée et livrée)
├──► E
└──► F
J (RGPD) ──► I0 (secrets) ──► I3 ──► reste de I
```
- **Chemin critique : A → B → G → H → I.** Tout le reste s'y greffe.
- **Le lot K s'y greffe par la fin** : K2/K3/K4 sont parallélisables avec C, D, E et F, mais **K6 (publier les apps) précède la bascule** (**L19**). ~~Et **K5 précède D0**~~ — **K5 est livré le 2026-08-12, D0 n'est plus bloqué** : l'APK `mymuseum-visitapp` existe pour les trois flavors, le test §21 sur device peut être joué. C'est aujourd'hui **le prochain geste qui demande un appareil**, et le seul qui dise si les bugs offline D2-D5 se manifestent réellement (**L11**).
- ~~**Une question à trancher avant K6**~~ ✅ **Réglée le 2026-08-13, par le propriétaire du projet : `Meta-Rayban-Test` est la branche de travail à jour, pas un POC.** Le nom est trompeur — il date de la première expérimentation — mais tout le travail V1 de `mymuseum-visitapp` y vit, les lunettes n'en sont qu'une partie (et elles restent invisibles si l'instance n'a pas l'assistant). Idem pour `AI-Assistant-test` côté `tablet-app`. **On développe et on publie depuis ces branches, il n'y a rien à clarifier avant K6.** ⚠️ **Le §5bis de STATUS.md affirmait le contraire** (« un POC jamais mergé ») et c'est ce qui a fabriqué cette fausse alerte — corrigé le 2026-08-13.
- **C, D, D-bis, E, F sont parallélisables** entre eux, mais **C1 doit précéder G** (L5) et **B doit précéder G** (L2).
- **DB3 doit précéder le job de thèmes du lot F** (L16) : c'est le seul endroit du plan où le design commande le backend.
- **La boucle du graphe a disparu le 2026-08-11.** Elle venait de : I3 dépend de la rotation des secrets (dans le lot A), et H4 dépend de I3 (L12). En déplaçant la rotation en **I0**, la contrainte devient linéaire — `I0 → I3 → H4` — et il n'y a plus de cycle à contourner. **Contrepartie à ne pas rater** : H4 (test d'onboarding) exige `app.myinfomate.be` déployé, donc I3, donc I0. Concrètement, **la rotation des secrets doit être faite avant la fin du lot H**, pas au tout dernier moment. C'est le seul endroit où le report du 11/08 crée une échéance interne.
**⚠️ Seconde révision du 2026-08-12 : +4 à 6 jours pour K9** (repasse visuelle de la borne, bento compris). ⚠️ **Ne pas le compter comme du bloquant** : contrairement au reste du lot K, K9 ne répare aucun contrat cassé — il peut passer après K6 et après la bascule, au prix d'une seconde publication d'APK qui ne coûte rien sur un parc géré. C'est le premier poste à sacrifier si la date de bascule serre.
**⚠️ Révision du 2026-08-12 : +3 à 5 jours pour le lot K**, découvert en lançant le premier vrai build de `tablet-app`. Ce n'est pas du périmètre ajouté, c'est du travail qui était déjà dû et que personne n'avait vu : sans lui, la bascule dégrade les clients existants.
**Ordre de grandeur : 5 à 6 semaines**, dont ~1 semaine de tests, 3-4 jours sur `MigrationController` seul, 1,5 à 2 semaines sur le lot design et 2-3 jours sur le lot J (RGPD). **Les trois arbitrages structurants sont tranchés depuis le 2026-08-11** — `ParcoursIds` et `QuestionType` au lot B, le flux SectionParcours au lot D-bis : le plan n'attend plus de décision, seulement du développement.
---
## 4. Arbitrages en attente
| Sujet | Options | Bloque |
|---|---|---|
| ~~`SectionEvent.ParcoursIds`~~ | ✅ **2026-08-11 — supprimer.** Champ mort, et structurellement incapable du cas jour/bloc | — |
| ~~`QuestionType`~~ | ✅ **2026-08-11 — ne pas toucher au schéma.** Sort du lot B ; reste une propreté front au lot E | — |
| ~~**Fournisseur de carte en web (W1)**~~ | ✅ **2026-08-11 — option a : le web reste sur Leaflet, quoi que le client configure.** Ni MapBox ni Google Maps JS : pas de coût récurrent ni de seconde librairie carto avant la prod. **Correction apportée à l'option** : le champ ne peut pas être retiré pour le web, il est porté par la section et partagé entre plateformes — il est donc conservé et **documenté dans l'interface** comme réglage mobile/tablette. Voir le lot E | — |
| ~~Flux de configuration SectionParcours~~ | ✅ **2026-08-11 — A, et livrée.** Fenêtre unique avec rail d'étapes, profondeur 2. B écartée : son seul gain réel était une colonne d'aperçu visiteur, déjà couverte par le §5 du Guide IA (**L18**). Le travail fait pour A reste transposable — le widget à panneaux ne dépend pas du `Dialog` qui l'entoure | — |
| CGU §5 face aux contrats déjà signés | Vérifier ce que les clients existants ont signé avant de considérer le sujet clos | Lot F (volet RGPD dans le même document) |
| ~~**Périmètre des thèmes (J1/J2)**~~ | ✅ **2026-08-12 — complet : job de regroupement _et_ table d'agrégats.** Le §8.4 des CGU n'est donc pas à amender. Rappel du mécanisme, à ne pas re-déduire : `ThemeId` est une **colonne de `VisitorQuestion`**, donc la purge à 90 jours emporte le regroupement avec la question — la table d'agrégats `(InstanceId, mois, thème, compteur)` est ce qui rend la promesse tenable, et elle ne porte que des compteurs, donc aucune donnée personnelle à protéger. **Échéance réelle : avant la première question d'un visiteur réel**, pas avant la bascule — rien n'est collecté aujourd'hui, le délai de 90 jours n'a pas commencé à courir | — |
| ~~**`meterZoneGPS` côté visiteur (M3)**~~ | ✅ **2026-08-12 — `mymuseum-visitapp` + `visitapp-web`.** `tablet-app` exclu : SectionParcours est hors périmètre kiosk (tranché en K3), le code n'y serait jamais exécuté | — |
| ~~**Rate limiting et Customer Portal**~~ | ✅ **2026-08-12 — les deux en V1.** Rate limiting : `Instance.PublicApiKey` est **lisible dans le navigateur** dès que `visitapp-web` est en ligne (`ApiKeyAuthenticationHandler:52`), et `/api/AI/chat` est le seul endpoint qui coûte de l'argent réel. Customer Portal : l'état exact est plus précis que ne le disait ce plan — l'e-mail d'échec pointe vers `{managerAppUrl}/billing` (`StripeWebhookController:124`), pas vers une URL Stripe fantôme, **et l'écran existe** ; mais `subscription_screen.dart` ne sait que lancer un Checkout de souscription, donc un client dont la carte expire arrive sur une page qui lui propose l'abonnement qu'il a déjà | — |
---
## 4bis. Reprendre ce plan dans une conversation neuve
**Ordre de lecture** — trois fichiers suffisent, dans cet ordre :
1. **Ce fichier** — les 9 lots, les 18 liens, le chemin critique. C'est le seul document qui dit *dans quel ordre*.
2. **[STATUS.md](STATUS.md) §1sexies** — le même découpage, relié au reste de l'état du projet.
3. **[kanban.html](kanban.html)** — où en est chaque chantier, carte par carte.
Les plans détaillés de `v2/` ne se lisent qu'au moment d'attaquer le lot concerné.
**Paralléliser sur plusieurs conversations — règle apprise le 2026-08-12.** Les lots se répartissent bien par repo, mais **chaque session ne doit committer que son repo**. Deux sessions ont débordé le même jour : l'une a committé `DOCS/` avec son travail `manager-app`, l'autre a emporté trois fichiers `manager-service` d'un chantier voisin dans son commit. Rien n'a été perdu, mais un `git add -A` en parallèle finit par emporter du travail à moitié fini. À écrire dans le prompt de chaque session : *« ne touche rien hors de `/`, et ne commit que celui-là »*.
**Amorce à copier telle quelle :**
> *« Lis `DOCS/v1-plan.md` en entier, puis `DOCS/STATUS.md` §1sexies. Le plan découpe la V1 en 9 lots (A à I plus D-bis) avec leurs dépendances ; le chemin critique est A → B → G → H → I. Dis-moi où en est chaque lot d'après les documents, puis attaque le lot que je te désigne. Ne relance jamais la génération de `manager_api_new` : il s'édite à la main. Vérifie l'état réel dans le code avant de croire un document — plusieurs points de STATUS.md se sont révélés périmés. »*
**Trois pièges vérifiés en vrai, à ne pas re-découvrir :**
- **`flutter analyze` global est inexploitable** (68 erreurs de fichiers modèle orphelins, hors graphe de compilation). Filtrer sur le dossier travaillé. Seuls `flutter build web`, `dotnet build`, `dotnet test` disent la vérité. **Cause exacte établie le 2026-08-11** : 139 `part` déclarés dans `api.dart` pour 153 fichiers dans `lib/model/` — cf. lot A. Le nettoyage du lot A supprime ce bruit.
- **La base locale peut avoir une migration de retard**, ce qui fait planter l'API dès le login sans que le compilateur ne dise rien.
- **Le fichier du kanban et la version publiée peuvent avoir divergé** — toujours `WebFetch` l'artifact et fusionner avant de republier, jamais `force`.
✅ **Le prérequis « committer avant tout » est levé.** L'alerte de ce matin (`manager-service` 44 fichiers dont 9 non suivis, `manager-app` 12) est **traitée** : revérifié le 2026-08-11, les **9 repos** (DOCS compris) sont propres et synchronisés avec leur remote. Rien n'est plus en attente sur un seul disque. Ne pas re-planifier cette étape.
---
## 5. Ce qui reste hors V1 — ne pas y toucher
Reporté en V2 et confirmé ici : SectionForm, ressource 360°, AR image tracking (Mind AR), kiosk web, ingestion documentaire + OCR, TTS pré-généré, guides multiples et thématiques, talking head, génération de visites, envoi mensuel automatique du rapport de stats, XR (Ray-Ban / Quest), dashboard super-admin V2, facturation V2.
**Ajouté le 2026-08-12 : l'orientation portrait de la borne.** Objectif V2 assumé, pas V1 — voir **K9 point d**, qui décrit la seule chose à faire en V1 pour que ce soit un branchement et non une réécriture : faire passer les décisions dépendant de l'orientation par un point unique et nommé, qui renvoie « paysage » en dur pour l'instant. **Ne rien dessiner ni spéculer d'autre.**
**Ajouté le 2026-08-12 : l'écran SuperAdmin d'activation de l'add-on IA et des canaux d'une instance.** Il rendrait l'add-on vendable en libre-service, mais la V1 n'en a pas besoin : les 4 clients de la bascule sont affectés à leur plan (§lot I, point 3), et doter une instance à la main reste un geste interne, fait une fois, sur une base qu'on contrôle. Le mode d'emploi SQL est dans **STATUS.md §1sexies « Add-on IA »** — trois gestes, dont le bouton de relance d'indexation qu'un `UPDATE` seul ne déclenche pas. À faire d'un bloc avec l'endpoint de mise à jour d'`ApplicationInstance` (canaux activables), qui reste lui au lot F : c'est le même écran, mais seule la partie canaux est V1.
⛔ **Les lunettes Ray-Ban sortent de cette liste le 2026-08-12 — décidé : elles sont en V1.** La ligne « XR (Ray-Ban / Quest) » ci-dessus ne vaut plus que pour **Quest**.
**Ce que « en V1 » veut dire ici, et ce que ça ne veut pas dire.** L'add-on est activé **par toi, instance par instance** — donc pas de libre-service, pas d'écran d'activation à écrire (le point ci-dessus reste V2). **Au démarrage, la seule instance concernée est MyInfoMate**, la tienne : l'objectif est d'observer des stats vocales réelles avant de proposer quoi que ce soit à un client. C'est ce qui rend l'inclusion tenable — aucune promesse externe ne dépend de cette date.
**Ce que ça ajoute concrètement au code V1** : le canal Vocal des stats (lot F, ligne « canal Vocal »), c'est-à-dire l'émission d'événements `Voice` dans `mymuseum-visitapp` — l'écran, lui, est déjà fait. **Rien d'autre.** Le POC lui-même existe et est démontrable.
⚠️ **Deux réserves qui survivent à cette décision.** La branche `Meta-Rayban-Test` n'est **toujours pas mergée**, ce qui rejoint la question déjà ouverte au §3 avant K6 — et elle devient plus pressante, puisqu'on ne publie plus « un POC » mais une fonction V1. Et **ne pas vendre l'add-on avant la bascule TTS ElevenLabs → Gemini** : ça reste vrai, et l'activation sur une seule instance interne n'y touche pas.
**Ajouté le 2026-08-13 : le finetuning de l'expérience vocale — voir `voice-latency-plan.md`.** Latence, accusés de réception parlés, langues supportées. **Ce n'est pas un lot V1** : c'est du réglage qui se décide **après** que le flux vocal ait été testé de bout en bout, et le document le dit explicitement. Deux choses en sortent tout de suite et sont les seules à traiter avant les tests :
- ⛔ **L'assistant vocal est officiellement limité à FR/NL/EN/DE — décidé le 2026-08-13.** `_toLangCode` ne mappe que ces quatre langues et **renvoie `fr-FR` par défaut** pour les six autres déclarées dans `constants.dart` : un visiteur en italien se fait déjà répondre en français, en silence. À refléter dans le CMS et la doc commerciale. Le reste de l'app garde ses 10 langues. **Ajouter une langue plus tard coûte peu** — les traductions `voice.*` existent déjà pour les 10, Whisper est multilingue, le wake word est phonétique.
- ⛔ **Et les quatre langues annoncées ne sont pas réellement supportées** : `_isStopCommand`, `_isRepeatCommand`, `_isQrScanCommand` et `_isPhotoCommand` cherchent des mots **français en dur**. Un visiteur néerlandophone qui dit « herhaal » n'est pas compris, et `_isQrScanCommand` matche sur `code` — un anglophone demandant « what's the *code* of this painting » déclenche un scan QR. **Tester le multilingue avec ces listes, c'est tester autre chose que ce qu'on croit** : à corriger avant le lot H, pas après.