DOCS/v1-plan.md
Thomas Fransolet fd5e1d7b3c Le lot F est clos
Canal vocal livré, et L9 refermé : la « cible Assistant / Persona » du Mode
preview est exactement ce que le §5 du Guide IA a livré le 11/08 — ce n'était pas
un chantier mais un malentendu entre deux documents. Les trois autres cibles du
Mode preview restent hors V1.

Kanban : la carte du canal vocal quitte Planifié, qui retombe à 22. Compteurs
mesurés : Fait récemment 57. Republié sur l'artifact.

Reste avant la bascule : lot J (RGPD), K6, K9, lot H (tests dont D0), D5, lot I.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:32:57 +02:00

136 KiB
Raw Blame History

V1 — plan d'implémentation jusqu'à la mise en prod

Établi le 2026-08-10 en croisant STATUS.md (§1 à §6bis), 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 VisitorQuestionrepoussé 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) → basculeajouté 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 MapResourceIdIconResourceId = é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 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 isGoodisCorrect).

À 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.MapResourceIdIconResourceId 2026-08-11 Aucune, déjà tranché au §6bis L6 = écart (g), qui tombe. ⚠️ Il fallait renommer aussi la propriété de navigation MapResourceIconResource : 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 QuestionTypesorti 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:14dé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 MapResourceIdIconResourceId : 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-appshow_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 <id>.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 <id>.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 <id>.unknown
D4 2026-08-12 Purge des fichiers réactivéeresource.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 Échecs de téléchargement remontés au lieu d'un print — une visite incomplète ne doit pas s'annoncer téléchargée

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 L18A 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<Widget> — 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.Metadatacolonne 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.
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
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
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
É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)
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
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 StripeCreateCustomerAsync 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

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 SectionPuzzleSectionGame 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) :

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'API132 erreurs Dart, 3 causes : ConfigurationDTO.roundedValue/isDate/isHour/isSectionImageBackground/screenPercentageSectionsMainPage ont migré vers AppConfigurationLinkDTO (73 err.) · GeoPointDTO.latitude/longitudeGeometryDTO? 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
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 SectionPuzzleSectionGame + 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-12DOCS/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<TranslationDTO> : « 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 Listenerpas 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 SliverGridDelegateWithFixedCrossAxisCounttoutes 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 :

# Quoi Note
J1 Table d'agrégats de thèmes Les CGU promettent que les regroupements survivent à la purge. Or ThemeId est une colonne de VisitorQuestion : la purge du 90ᵉ jour l'emporte avec la ligne. Sans cette table, la promesse est vide et le client perd son historique au 91ᵉ jour
J2 Job de regroupement en thèmes Sans lui ThemeId reste nul, donc J1 ne contiendrait rien. Il remplit topics dans GuideInsightsDTO — forme déjà arrêtée par l'écran
J3 Interrupteur de collecte par instance ⚠️ Le client est responsable de traitement mais n'a aujourd'hui aucun moyen de refuser la collecte : elle est inconditionnelle dès que l'assistant est actif. Drapeau sur Instance + garde dans RecordVisitorQuestion
J4 Afficher la mention aux visiteurs Le texte existe, il n'est branché ni dans visitapp-web ni dans mymuseum-visitapp. Un lien depuis l'assistant suffit
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 (<project-id>.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-standardplan-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-abonnementenPremium-abonnementen, Bundle-PlänePremium-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, 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 D0K5 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-11ParcoursIds 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 §1sexies — le même découpage, relié au reste de l'état du projet.
  3. 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 <repo>/, 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.