DOCS/status/ordre-execution-v1.md
2026-09-03 14:00:51 +02:00

57 KiB
Raw Blame History

1sexies. Ordre d'exécution de toute la V1 — et le lot design (ajouté le 2026-08-11)

← Section §1sexies du tableau de bord : STATUS.md

Ce fichier dit ce qu'il reste, par domaine. Le kanban dit où en est chaque chantier. Ni l'un ni l'autre ne disait dans quel ordre, ni ce qui bloque quoi.

v1-plan.md — 9 lots (A à I plus D-bis), 18 liens de dépendance, chemin critique, arbitrages en attente. 4 à 5 semaines, portées à 5-6 avec le lot design.

Chemin critique : A (assainir) → B (geler le schéma) → G (MigrationController) → H (tests) → J (RGPD) → I (bascule). C, D, D-bis, E, F se parallélisent.

Lot K — les apps visiteur parlent l'ancien contrat (ajouté le 2026-08-12)

Un lot entier qui manquait, et il bloque la bascule. Découvert en lançant le premier vrai flutter build apk sur tablet-app depuis des mois — la commande que trois documents disaient « à reconfirmer ».

Deux couches, pas une : dix crans d'outillage Android (corrigés le 12/08, détail dans v1-plan.md lot K — Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 1.9.0 → 2.3.10, jcenter mort, heap Gradle relevée, Jetifier coupé, forçage d'androidx.lifecycle retiré), puis 132 erreurs Dart que le Gradle masquait depuis le début.

⚠️ Le compte de « six crans » qui circulait 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. C'est le même piège que le tableau ci-dessus documente — un état intermédiaire pris pour un état final.

Les 132 erreurs tiennent en 3 causes : ConfigurationDTO.roundedValue/isDate/isHour/isSectionImageBackground/screenPercentageSectionsMainPage ont migré vers AppConfigurationLinkDTO (73) · GeoPointDTO.latitude/longitude sont devenus GeometryDTO geometry avec PostGIS (40) · les min() sur dynamic en découlent (19). mymuseum-visitapp a déjà fait ce portage (visitAppContext.currentAppConfigurationLink, Models/visitContext.dart) : c'est une copie, pas une conception. Côté visitapp-web, le pendant existe aussi — le helper getGeoPointLatLng() de src/lib/geo.ts.

⚠️ Ce qui en fait un bloquant de la bascule — lien L19, que le plan ne portait nulle part. Les tablettes en service chez MDLF et au Fort parlent l'ancien contrat. Le jour J, elles ne s'arrêteraient pas proprement : cartes sans points, écrans dégradés, aucune erreur visible. Et le parc des téléphones visiteurs, lui, ne se force pas — d'où la stratégie de coexistence adoptée au lot I (deux serveurs, l'ancienne prod figée, aucun DNS qui bouge).

K5 livré le 2026-08-12 — et le lot n'était pas ce que le plan décrivait. Les trois flavors de mymuseum-visitapp (dev, mdlf, fortsaintheribert) construisent, exit 0, APK à l'appui. La prémisse « son APK ne compile pas » était périmée : 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 du 12/08 ont amené tablet-app jusqu'à mymuseum — il n'y avait pas la même migration à refaire ici. Le réflexe du diff noté ci-dessus a donné la réponse en deux cat.

Restait le contenu réel du lot, 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 (sous le seuil 2.2.20 annoncé comme rupture par Flutter ; c'est la version de tablet-app, donc un alignement, pas une version neuve). Six builds — les trois flavors avant, les trois après. APK de 382 à 349 Mo.

⚠️ Un cran de plus existe, délibérément non pris. Kotlin réglé, le validateur Flutter en révèle deux autres : Gradle 8.11.1 → 8.14.0 et AGP 8.9.0 → 8.11.1, « soon be dropped ». Même cascade que les dix crans de tablet-app — chaque correctif découvre le suivant. Arrêté là parce que 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 au lieu de les aligner. À faire d'un bloc sur les deux, ou pas du tout — c'est la dette d'outillage déjà parquée en V2.

Question de branche, à trancher avant K6tranché le 2026-08-13 : Meta-Rayban-Test (et AI-Assistant-test côté tablet-app) sont les branches de travail à jour. On développe et on publie depuis elles, il n'y a pas de merge préalable à faire. Voir le §5bis, dont la description « POC jamais mergé » est ce qui avait fabriqué cette alerte.

Ce que K5 débloque : D0 (test-plan §21 sur device), donc la mesure des bugs offline D2-D5 et la confirmation du volet visiteur de D1 — qui ne tient aujourd'hui que sur flutter analyze.

D2, D3 et D4 livrés le 2026-08-12, sans attendre D0 (arbitrage du plan : défauts certains, pas des hypothèses à mesurer). audio/mpeg ajouté à la table d'extensions, purge des fichiers obsolètes réactivée, et filtre de fraîcheur livré de bout en bout (dateUpdate serveur → DTO → client généré → base locale v4). dotnet test 205/205, flutter build web (manager-app) , APK dev de mymuseum-visitapp . Reste D0 et D5.

⚠️ Deux prémisses du plan étaient fausses, corrigées dans v1-plan.md :

  • « Les deux chemins de téléchargement divergent » (le motif Create/Upload de C1/C3, rapproché de D4) : il n'y a qu'un seul chemin vivant. Les lignes 328-461 de downloadConfiguration.dart sont dans un bloc commenté — le cleanLocalResources(:455) cité comme preuve est du code mort, et cette fonction n'est appelée nulle part.
  • cleanLocalResources n'est pas réactivable telle quelle : la table locale resources n'a pas de colonne configurationId, elle est globale à toutes les visites téléchargées. L'activer sur les ids d'une seule configuration aurait effacé les lignes des autres. Sans bénéfice, de surcroît : rien ne lit ces lignes pour le rendu, le fichier est retrouvé en listant le répertoire.

⚠️ D2 ne réparait pas ce que la doc annonçait — vérifié dans le code le 2026-08-12. « Une image remplacée dans le CMS ne remonte jamais sur le device » : ce cas n'existe pas. Upload génère un nouvel id à chaque téléversement, manager-app écrit dans pictures/{instanceId}/{resourceId} — remplacer une image donne donc un nouvel id et une nouvelle URL, que l'ancien filtre téléchargeait déjà. La popup d'édition ne change que le libellé. Aucun flux ne réécrit le blob d'un id existant.

Le vrai cas de péremption est celui que D3 vient de créer : les MP3 déjà sur les devices y sont en <id>.unknown, et « le fichier est présent » les déclarait à jour. Sans D2, D3 ne réparait que les installations neuves — le parc existant serait resté muet. isResourceOutdated traite .unknown comme absent.

⚠️ Deux pièges tranchés en écrivant D2 : la montée v3 → v4 laisse les dates existantes à NULL et les considère à jour (backfiller à zéro aurait fait re-télécharger toutes les visites de tous les visiteurs sur leur réseau mobile) ; et la boucle en masse héritée de D1 écrasait la date que la boucle de téléchargement venait d'écrire, DatabaseHelper.insert faisant un UPDATE de la ligne entière.

⚠️ Dette relevée au passage, non traitée : les trois lib/api/swagger.yaml (manager-app, mymuseum-visitapp, tablet-app) sont périmés — leur ResourceDTO n'a ni sizeBytes (C1/C3) ni dateUpdate. Ce sont des artefacts de génération, et le client s'édite à la main : ils ne sont plus la source de vérité, mais ils la contredisent en silence.

K3 et K7 livrés le 2026-08-12 — flutter build apk --debug vert, APK produit. La borne couvre désormais 12 des 13 types : SectionArticle (K7) et SectionEvent (K3) sont branchés dans main_view.getContent. Seul SectionParcours reste dehors, et définitivement — un parcours guidé fait marcher le visiteur.

Les deux écrans suivent la maquette DOCS/claude design/kiosk-paysage-article-event.html : rail média ancré + colonne de lecture plafonnée pour l'article, bande héros à 26 % + programme + carte vive pour l'événement, détail d'un bloc sous la carte plutôt qu'en showModalBottomSheet.

Quatre choses que le code a dictées, contre ce que la maquette ou mymuseum annonçaient :

  • ⚠️ La barre de pied de la maquette n'appartient pas aux écrans. section_page_detail:184/198 dessine déjà le bouton retour, avec les clés back/menu selon isFromMenu. Les écrans qui auraient dessiné le leur en auraient affiché deux. La maquette est à corriger sur ce point.
  • ⚠️ Un seul constructeur de marqueurs au lieu des quatre de mymuseum. MapAnnotationDTO et MapAnnotation ont des champs rigoureusement identiques mais sont deux types Dart distincts — d'où la duplication là-bas. Normalisé ici par une classe interne à deux fabriques. C'est L5 appliqué au front, et il porte d'autant plus que la convention [lng, lat] est l'endroit exact où une divergence ne lève aucune erreur.
  • ⚠️ L'audio d'article se résout dans contents avant l'API. 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'est qu'un repli. Mymuseum appelle l'API systématiquement en ligne.
  • ⚠️ Nouvelle clé i18n event.live dans les 10 langues. Sans les 10, getFromLocale renvoie "" et la pastille « en cours » rendrait une boîte vide. FR, EN, NL, DE, IT, ES sont sûres ; PL, CN, UK et AR sont de ma main et demandent une relecture humaine.

⚠️ Ce qui n'est pas prouvé : le rendu. Aucun des deux écrans n'a été vu à l'œil — build vert ne veut pas dire mise en page juste, c'est exactement la leçon du §1bis appliquée au design. Et aucun SectionEvent n'existe en base (le type est né avec Postgres v3) : c'est le §19.13 cas E qui en créera un.

Périmètre kiosk tranché le 2026-08-12 : tablet-app couvre 11 des 13 types — corrigé le 2026-08-12 : c'est 10, et le compte cachait un vrai manque. Le switch de main_view.getContent traite Map, Slider, Video, Web, Menu, Quiz, Pdf, Game, Agenda, Weather. SectionArticle (type 6) n'est pas traité : son case est commenté « TODO » depuis l'origine et Screens/Article/article_view.dart fait 1 Ko. Un article tombe donc dans le default — « Ce type n'est pas supporté ». Contrairement à SectionEvent et SectionParcours, ce type-là existait dans Mongo : c'est un vrai retard, pas un type né avec Postgres v3. À trancher — le supporter ou l'assumer — mais pas à compter comme couvert. SectionParcours est exclu définitivement (un parcours guidé fait marcher le visiteur — pas de sens sur borne fixe) ; SectionEvent est à supporter (borne d'accueil affichant le programme du jour, §19.13 cas E). Ni l'un ni l'autre n'est un retard : les deux types sont nés avec Postgres v3. S'ajoute le renommage SectionPuzzleSectionGame et le nouveau SlidingPuzzle, à récupérer depuis mymuseum-visitapp/lib/Screens/Sections/Game/ — sa version est moins buguée que le Puzzle/ de tablet-app.

Ce que ça coûte : +3 à 5 jours. Ce n'est pas du périmètre ajouté, c'est du travail déjà dû que personne n'avait vu.

tablet-app construit à nouveau — flutter build apk --debug vert le 2026-08-12. K1, K2 et K4 sont livrés et prouvés par un build, pas seulement par flutter analyze. L'outillage a demandé dix crans (le compte de « six » écrit plus tôt dans la journée datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite) — tableau complet dans v1-plan.md lot K.

⚠️ Trois des quatre derniers crans étaient de simples alignements sur mymuseum-visitapp : -Xmx4096M au lieu de 1536M, enableJetifier=false, et surtout le retrait d'un resolutionStrategy qui forçait androidx.lifecycle à 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). J'ai cherché ces causes une par une alors qu'un diff des deux gradle.properties et des deux build.gradle les donnait toutes d'un coup. Réflexe pour K5 : commencer par ce diff.

K4 livré le 2026-08-12 — les 6 fichiers de mymuseum-visitapp/lib/Screens/Sections/Game/ repris dans tablet-app/lib/Screens/Game/, Screens/Puzzle/ supprimé, main_view sur SectionType.Game. La tablette gagne le puzzle glissant, le bouton d'indice et un dimensionnement au ratio de l'image. ⚠️ Un choix de rendu à valider à l'œil : mymuseum code ses couleurs en dur par flavor client (rouge MDLF, bleu Fort) ; tablet-app n'ayant pas de flavor — un seul APK, la charte vient de la configuration — le dégradé est dérivé de configuration.primaryColor. Le rendu diffère donc de la version mobile.

K2 livré le 2026-08-12 — les 132 erreurs de contrat sont tombées. flutter analyze lib ne renvoie plus que 6 erreurs PuzzleDTO, qui relèvent de K4. Livré : TabletAppContext.currentAppConfigurationLink, lib/Helpers/geo.dart (extension lat/lng + coordinateKey), 67 accès roundedValue rebranchés, main_view et les 4 vues carte portées.

Deux bugs latents trouvés au passage — la raison pour laquelle un chercher-remplacer aurait été dangereux :

  • ⚠️ applicationInstanceDTO servait de drapeau d'assistant. Il n'était assigné que si instanceDTO.isAssistant == true (main_view:339). Ce DTO portant désormais les AppConfigurationLink, le nouveau getter aurait renvoyé null sur toute instance sans IA — donc MDLF et le Fort Saint-Héribert — et roundedValue, isDate, isHour, isSectionImageBackground, screenPercentageSectionsMainPage seraient tous retombés sur leurs valeurs par défaut. Sans erreur, sans log : des écrans qui ne ressemblent plus à ce qui est configuré. Corrigé par une assignation inconditionnelle et un drapeau explicite isAssistantEnabled, qui préserve le ET instance × canal — les deux gardes qu'on retrouve côté serveur en AiController:315 et :360.
  • ⚠️ Le filtre « configurations pour tablette » n'avait plus de source. ConfigurationDTO.isTablet a disparu : le canal est porté par l'ApplicationInstance et le rattachement vit dans ses AppConfigurationLink. getConfigurations filtre désormais là-dessus. À 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é — donc à revérifier sur les 4 instances.

⚠️ Troisième piège de vérification de la journée : flutter analyze écrit error - , pas error • . Un grep sur la mauvaise forme a fait conclure « 0 erreur » alors qu'il en restait 10. Après le | tail qui masque le code de sortie, c'est le deuxième faux vert obtenu par un filtre mal formé — toujours compter les erreurs en relisant le log complet.

⚠️ Deux conventions de coordonnées coexistent — vérifié le 2026-08-12, ne pas « harmoniser » à l'aveugle

Le projet écrit les coordonnées dans deux ordres différents selon le producteur. Les deux sont en production, et une confusion ne lève aucune erreur : elle place les points ailleurs, sur une carte qui reste plausible.

Donnée Ordre Écrit par Lu par
GeoPointDTO.geometry, et toute géométrie passée par GeometryMapper [lng, lat] — GeoJSON standard GeometryMapper.ToDto sérialise { point.X, point.Y } ; MigrationController:798 construit new Point(lon, lat) mymuseum-visitapp/Models/agenda.dart:72 lit bien coords[1]=lat, coords[0]=lng · tablet-app/lib/Helpers/geo.dart (créé le 12/08)
GuidedStep.geometry [lat, lng] — inversé manager-app, map_geometry_picker visitapp-web/src/lib/geo.ts, qui le documente explicitement

Pourquoi on ne corrige pas : l'ordre inversé des GuidedStep est déjà écrit en base. Le redresser demanderait une migration de données et une reprise simultanée de tous les lecteurs — pour un gain purement esthétique, sur un chantier qui n'en a pas besoin avant la bascule.

Ce qu'il faut faire à la place : ne jamais recopier un helper de coordonnées d'un contexte à l'autre. getStepGeometryCenter (visitapp-web) et GeoPointPosition (tablet-app) se ressemblent et sont incompatibles. Chaque helper porte sa convention en commentaire — les lire avant de s'en servir.

⚠️ À rouvrir si SectionParcours arrive un jour dans une app qui lit aussi des GeoPoint : elle aurait alors les deux conventions dans le même écran. C'est aujourd'hui le cas de visitapp-web et de mymuseum-visitapp — vérifié : chacun utilise le bon helper pour la bonne donnée, mais c'est l'endroit exact où une future erreur se logera.

D1 — le bug offline nº1 est corrigé (2026-08-11). Le switch de collecte des ressources était commenté des deux côtés : 157 lignes mortes dans ConfigurationController.Export, 126 dans Import, et le pendant dans downloadConfiguration.dart. Une visite téléchargée n'embarquait donc que l'image de la configuration, celle du loader et l'image de chaque section.

Remplacé par GetReferencedResourceIds(), déjà implémentée sur les 13 sous-types. Côté import et côté client, la charge est enregistrée en une passe au lieu d'être redécouverte section par section — ce qui répare au passage la purge des fichiers obsolètes, pilotée par usedImageOrAudioIds. 4 tests, dont un qui vérifie par réflexion que les 13 sous-types implémentent la collecte : le trou venait d'un switch où un type oublié passait dans le default sans bruit. dotnet test 148/148.

⚠️ Le volet visiteur ne tient que sur flutter analyze — l'APK de mymuseum-visitapp ne compile pas (Gradle/NDK, §1bis). À confirmer sur device au §21.

Lot G — les 8 écarts sont clos et le dry run est joué (2026-08-11). dotnet build vert, dotnet test 143/143.

Trois des huit n'existaient pas. Le plan les décrivait en « perte de données » ; l'export Mongo dit qu'il n'y a rien à perdre :

  • (a) BuildSection couvre exactement les types présents : les 327 sections de l'export ne contiennent que les valeurs 0 à 10. SectionEvent et SectionParcours sont nés avec Postgres v3.
  • (f) IsQRCode/IsSearchText/IsSearchNumber n'existent pas dans Mongo — false est le bon défaut. Contrôle inverse fait : les cinq réglages qui existent (IsDate, IsHour, IsSectionImageBackground, RoundedValue, ScreenPercentageSectionsMainPage) sont bien mappés sur AppConfigurationLink.
  • (g) tombé avec le rename du lot B.

Recette de bascule : test-plan.md §22 — comparaison contenu par contenu entre l'app d'aujourd'hui et la base migrée, à jouer pendant que Mongo est encore lisible. Reliée depuis I7.

Dry run : automatisé en test (MigrationDryRunTests, se saute sans Mongo), joué sur l'export du 1ᵉʳ avril. Il a trouvé du premier coup 20 sections manquantes avec Erreurs : 0 — elles référencent 2 configurations supprimées dans Mongo (contenu MDLF). Pas un défaut de migration, mais un silence : elles sont désormais signalées dans Skipped. ⚠️ Décision produit en attente : recréer les 2 configurations avant la bascule pour récupérer ce contenu, ou acter sa perte. ⚠️ À rejouer sur un dump frais avant le jour J — l'export a 4 mois et la prod tourne encore sur Mongo.

Les cinq vrais, corrigés :

  • (b) mesuré : 5 sections quiz portent 41 questions, toutes jetées. Migrées avec leurs réponses, en MultipleChoice (le type de validation n'existait pas dans l'ancien modèle). Les agendas n'ont aucun événement dans Mongo et les parcours n'y existent pas : leurs collections vides sont correctes.
  • (c) tout est généré ou dérivé, cf. v1-plan.
  • (d) Mongo n'a pas de champ Role : le ContentEditor en dur dégradait les 10 utilisateurs en silence. Passé à InstanceAdmin. ⚠️ Aucun SuperAdmin n'est créé par la migration.
  • (e) passe par ResourceStorage (L5) ; StoragePath n'était pas écrit du tout. L'échec du HEAD remonte désormais dans le rapport.
  • (h) transaction unique, et 500 au lieu de 200 OK sur échec fatal.

Lot F — le volet manager-app est livré le 2026-08-12 : écran d'audit log et compteur d'utilisateurs. flutter build web , flutter analyze propre sur les dossiers travaillés. manager_api_new n'a pas été touché — l'écran passe par un http.get direct au Bearer, comme _loadKnowledge / _loadInsights / _reindex du Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main, et le déclencheur de génération n'existe plus depuis le lot A.

  • Écran d'audit : Screens/Audit/audit_screen.dart + audit_entry.dart, entrée de menu menuId: 13 conditionnée à role.value == 0 — même règle que la policy SuperAdmin de l'endpoint. Filtres instance / type d'entité / utilisateur / plage de dates, pagination 50, clic sur une ligne → détail avant-après en table. Les ids d'instance et d'utilisateur sont résolus en noms via instanceGet() / userGet(), un id inconnu de l'annuaire restant affiché tel quel : le journal doit rester lisible après la suppression de ce qu'il décrit.
  • Compteur utilisateurs : « X / 5 » et bouton d'ajout désactivé au plafond. Le SuperAdmin en est exclu — son GET /api/User renvoie toutes les instances, compter cette liste contre un plafond par instance n'aurait aucun sens. Le point « masquer le rôle SuperAdmin à un InstanceAdmin », resté en question dans todo-features.md, était déjà fait (_allowedRoles filtre sur r >= callerRole) : vérifié, pas réimplémenté.
  • Trois pièges relevés en câblant, à ne pas re-découvrir : AuditController renvoie les entités brutes, pas un DTO (champs d'AuditLog en camelCase) ; invokeAPI ne lève pas sur un code d'erreur et son résultat était ignoré dans users_screen, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé, ce qui rendra le futur 422 du plafond visible sans retouche ; et DropdownButtonFormField ne relit pas initialValue sur reconstruction (FormField.didUpdateWidget ne traite que forceErrorText), donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par un DropdownButton piloté.

Les deux dettes backend qu'il avait ouvertes sont fermées le 2026-08-12 — voir le volet manager-service ci-dessous.

Lot F — le volet manager-service est livré le 2026-08-12 : les quatre chantiers de dette backend. dotnet test 163 → 200, aucun test sauté. Quatre commits, manager-service seul.

Volet manager-service refermé le 2026-08-13 — Stripe Customer Portal (backend) et ratio jetons → questions calé sur du réel. 6 tests, dotnet test 211 : 197 passés, 14 sautés (Testcontainers sans démon Docker), 0 échec. Trois des cinq points restants étaient déjà faits : rate limiting, endpoint ApplicationInstance, quotas seed — détail au §« Restes non chiffrés » ci-dessus et dans v1-plan.md lot F.

⚠️ Ce qui reste du lot F n'est plus dans manager-service : le bouton du portail et le diviseur aiTokensPerQuestion dans manager-app (petit), et surtout les trois chantiers mymuseum-visitapp — déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux. Plus l'aperçu de conversation (L9), indépendant.


🎉 Les lots F et J sont clos le 2026-08-13 — il ne reste plus de développement V1

Tout ce qui suit a été livré dans la même journée, sur 5 repos. dotnet test 229 (213 passés, 16 sautés faute de démon Docker), APK dev de mymuseum-visitapp , flutter build web de manager-app , npm run build de visitapp-web , flutter analyze lib sans erreur sur les deux apps Flutter.

Lot F — Stripe Customer Portal (backend + bouton), ratio jetons → questions mesuré, déclenchement proactif, M3, miroir de la conversation vocale, conversationId, émission des événements vocaux. L9 n'était pas un chantier : la « cible Assistant / Persona » du Mode preview est ce que le §5 du Guide IA a livré le 11/08 — un malentendu entre deux documents, pas du travail.

Lot J — table d'agrégats de thèmes, job de regroupement quotidien, interrupteur de collecte par instance (avec son UI), mention d'information dans les deux apps visiteur, purge du journal d'audit. D5 est livré avec eux.

Les cinq pièges de la journée, à ne pas re-découvrir :

  1. ⚠️ La garde du proactif recommandée par le plan aurait coûté de l'argent. _trigger appelle le LLM avant de parler, et le ?. sur l'orchestrateur avale le cas « aucun mode vocal actif » : un visiteur traversant une zone avec l'app en poche et le vocal éteint consommait des jetons facturés au client pour une phrase que personne n'entend. La garde interroge l'orchestrateur, pas le matériel.
  2. ⚠️ Un écran peut mentir sans erreur. Sans état d'échec, une visite incomplète restait sur « téléchargement en cours » indéfiniment — le compteur n'étant incrémenté qu'en cas de succès, il n'atteignait jamais le total. Le bug d'origine (D5) était le print avalé ; celui-là était pire et n'était écrit nulle part.
  3. ⚠️ Le choix de voix du client n'était pas honoré. Le sélecteur Viva/Marco existait depuis le 07/08 et écrivait GuideVoiceId ; l'app visiteur ne lisait jamais ce champ. Même forme que W1. Et aucun APK ne parlait avec la voix du produit, GEMINI_API_KEY n'étant injectée nulle part — repli silencieux sur la voix système.
  4. ⚠️ Trois écrans journalisaient de fausses questions de visiteur : le mode proactif (son prompt machine) et l'aperçu de conversation (le gestionnaire testant sa personnalité). Le drapeau s'appelle IsVisitorQuestion, défaut true — un client publié qui ne l'envoie pas continue de journaliser.
  5. ⚠️ Le même code copié trois fois porte sa décision une seule fois. _toLangCode limitait le vocal à FR/NL/EN/DE dans les trois copies, mais ne le disait que dans une : les deux autres ressemblaient à un oubli à corriger. Idem pour les trois AssistantService, qui coupaient la conversation en morceaux.

Ce qui reste, et qui ne s'écrit pas en code : la relecture juridique du §8 des CGU, les conditions réelles de Google, le DPA séparé, et les traductions relues de la mention visiteurs (FR/NL/EN aujourd'hui, repli anglais assumé). Puis K6, K9, le lot H (tests, dont D0 sur device) et le lot I.

1. Le journal d'audit voyait tout sauf le contenu. AuditedTypes.Contains(entry.Entity.GetType()) exigeait l'égalité exacte de type, or Section est abstraite : le type runtime est toujours SectionMap, SectionQuiz… Aucune des 13 sortes de section n'était journalisée — soit précisément ce que l'écran du 12/08 devait tracer. Resource, Configuration, Device, User et Instance passaient, eux : ils sont concrets, et c'est ce qui rendait le trou invisible. Remplacé par une remontée à la classe de base auditée (IsInstanceOfType).

Tranché : normalisation côté serveur. EntityType porte Section, pas SectionMap. Trois raisons : le filtre « Section » déjà présent dans l'écran se met à rendre des lignes sans toucher manager-app, donc sans coordonner deux repos ; élargir le filtre front aurait coûté 13 entrées de liste et 39 clés i18n et laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme ici ; et le sous-type concret n'est pas perdu, le discriminateur TPH est une propriété du modèle, donc sérialisée dans NewValues ("Discriminator": "Article"). Un test le vérifie — ce n'était pas une supposition.

8 tests, dont un par réflexion qui affirme que les 13 sous-types concrets résolvent vers Section : la liste de types d'origine n'obligeait personne à la suivre, c'est ce qui l'a laissée devenir fausse.

2. Le plafond de 5 utilisateurs existe enfin côté serveur. CreateUser rend 422. Deux décisions, écrites dans le code : 5 en dur — le faire varier par plan serait une colonne sur SubscriptionPlan, donc une migration après le gel du lot B, dette V1 assumée ; et SuperAdmin non soumis au plafond, seule porte de service tant qu'aucun champ ne permet de relever la limite d'un client, ce qui s'aligne sur le front qui ne lui montre déjà pas le compteur. L'inscription self-service n'est pas concernée (premier utilisateur d'une instance neuve). Rien à retoucher côté front : le résultat d'invokeAPI est déjà lu depuis le correctif du 409.

3. GetSummary agrège en SQL au lieu de charger 13 mois en mémoire. Les six agrégats qui se lisent dans le JSON de Metadata ne remontent plus que deux colonnes, et seulement pour leur type d'événement. Effet de bord voulu : les stats avancées ne sont plus calculées puis effacées pour les plans qui n'y ont pas droit. Un changement de sémantique délibéré : « la langue de la session » était prise sur le premier événement rendu par la base, dans un ordre indéfini — c'est désormais le plus ancien horodatage.

⚠️ La branche « stats avancées » n'était couverte par aucun test : les cas existants ne seedent pas d'Instance, donc hasAdvancedStats était toujours faux et la moitié de la méthode n'était jamais exécutée. 11 tests ajoutés, plus 4 contre un vrai Postgres — le provider InMemory évalue tout côté client, il ne dit rien de la traduction SQL et une requête intraduisible y passerait au vert.

4. Le vector store est éprouvé à deux instances, sur un vrai Postgres. 14 tests Testcontainers sur l'image de Deployment/Dockerfile.postgres. Ils se sautent proprement (SkippableFact) sans démon Docker — vérifié : 186 passés / 14 sautés / 0 échec. Établi : pgvector 0.8.6 (donc SET hnsw.iterative_scan, posé à chaque recherche par SearchAsync, est accepté — sur une version antérieure toute recherche échouait en production) ; extensions vector et postgis bien créées par les migrations ; et à deux instances déséquilibrées 30 contre 1, SearchAsync rend le bon nombre de résultats, tous de la bonne instance, aucune fuite.

⚠️ Deux résultats contraires à ce que le plan supposait — à ne pas re-supposer à l'envers.

  • Ce qui protège du post-filtrage n'est pas le parcours itératif, c'est l'index sur (InstanceId, ContentType). Le planificateur filtre par instance d'abord et trie exactement : l'index HNSW n'est jamais touché, donc il n'y a rien à post-filtrer. Vérifié à 620 lignes (test) et à 22 000 hors suite, avec 2 000 lignes pour l'instance cible. Un test fige ce plan — si cet index disparaissait, la recherche se dégraderait sans qu'aucune erreur ne le dise.
  • Et cet index retiré, hnsw.iterative_scan = relaxed_order ne rattrape rien : le parcours s'épuise après ~335 lignes (Rows Removed by Filter à l'EXPLAIN ANALYZE) sans atteindre l'instance minoritaire, et rend zéro. Le même jeu de données avec l'index HNSW construit après l'insertion rend bien ses 20 lignes : c'est la connectivité du graphe qui décide, et nos migrations créent l'index sur une table vide. Le SET est correct et sans coût, on le garde — mais il ne couvre pas ce qu'on croyait.

Outillage, pour ne pas le redécouvrir : Testcontainers.PostgreSql est épinglé en 3.10.0 — la 4.x parle l'API Docker 1.44 et l'engine local plafonne à 1.43. Et l'image est construite par le CLI docker, pas par le constructeur d'images de Testcontainers : celui-ci relit le FROM pour pré-tirer l'image de base et ne sait pas parser tag@sha256:. Ce digest protège la base d'un changement de glibc sous ses index — il ne se retire pas pour arranger un test.

5. Le journal ne suit plus les écritures de la machine — régression du point 1, corrigée le même jour. WeatherSyncService écrit section.WeatherResult sur cron, à 6 h et 13 h. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la prévision OpenWeather complète en avant ET en après — quelques dizaines de Ko, deux fois par jour, par section météo, indéfiniment. Le stockage est le moindre problème : ce bruit noie les modifications humaines que l'écran d'audit existe pour montrer.

Correctif : une liste de colonnes machine (WeatherResult, WeatherUpdatedDate, DateUpdate) exclues du journal, et aucune ligne produite quand une modification ne touche qu'elles — plutôt qu'une ligne au diff vide, qui aurait déplacé le bruit sans le retirer. DateUpdate y est pour une raison distincte de la météo : estampillé à chaque SaveChanges, il figurait dans tous les diffs sans jamais rien y apprendre. Le signal était déjà là — un test écrit le matin même devait l'écarter à la main (newValues.Keys.Where(k => k != "DateUpdate")) pour rester lisible. Quand un test doit filtrer une donnée pour être lisible, la donnée n'a rien à y faire. Vérifié au passage : AgendaSyncService écrit des EventAgenda, non audités, et ne touche pas la ligne Section — lui n'est pas concerné.

⚠️ Comment il a été trouvé, parce que la méthode compte plus que le bug : en cherchant à justifier un index sur Timestamp que j'avais signalé par réflexe. La question « pourquoi un index ? » a montré que ce qui faisait grossir la table n'était pas ce que je croyais.

⚠️ Deux dettes nouvelles, et une fausse alerte retirée (détail dans v1-plan.md lot F) :

  1. AuditLog n'a aucune purge. VisitEvent purge à 13 mois, VisitorQuestion à 90 jours, AuditLog à jamais. C'est un sujet RGPD et rien d'autre — la table porte UserId, et les valeurs avant-après d'une modification de User contiennent e-mail, prénom et nom. Ce n'est pas un sujet de volume, le flot machine étant coupé. Donc à traiter au lot J. Recommandation : 12 mois, uniforme, avec le même verrou que les deux purges existantes — inerte tant qu'Audit:RetentionDays n'est pas défini, le pg_dump quotidien n'étant toujours pas en place : supprimer des lignes d'audit sans restauration fine est la pire combinaison.
  2. La bascule écrira ~2 700 lignes d'audit dans la transaction globale de MigrationController (2 374 ressources + 307 sections + le reste), chacune avec un instantané JSON complet. Pas fatal, mais c'est neuf pour les sections et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run.
  3. AuditLog sans index sur Timestamp — fausse alerte, retirée. Signalée par réflexe sur « la table va grossir » ; elle allait grossir à cause de la météo. Le flot machine coupé, il reste les éditions humaines — de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'un ORDER BY Timestamp DESC LIMIT 50 trie en millisecondes sans index. Pas d'index, pas de migration, gel du lot B intact.

Lot C2 et C3 — livrés le 2026-08-12. Le lot médias est terminé côté serveur. dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).

C2 — backfill. POST /api/Resource/backfill-storage?dryRun=&instanceId=, SuperAdmin, dryRun à true par défaut : la migration se joue sur une base vide, ce backfill sur des lignes de production. Il rend son propre inventaire plutôt que de reprendre le « 37 lignes sur 45 » du plan, invérifiable d'ici — Orphans (aucune URL, blob peut-être jamais téléversé) et Unsized (URL présente, bucket muet) sont deux causes distinctes, jamais additionnées.

  • ⚠️ La méthode annoncée était inapplicable. Le plan disait « SizeBytes par listing du bucket Firebase » : le serveur n'avait aucun client de stockage. 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 pour ResourceStorage : deux copies auraient divergé sur ce qui compte, le sort réservé aux échecs.
  • ⚠️ L'extraction a bouché un trou que personne ne cherchait. L'original ne notait l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas : il répond 404, sans Content-Length. Ces ressources arrivaient à 0 octet sans figurer dans le rapport — invisibles au quota et invisibles au diagnostic, exactement ce que le commentaire d'origine voulait empêcher. Corrigé des deux côtés ; MigrationController peut donc remonter quelques lignes de rapport de plus qu'avant, sur des cas réellement muets.

C3 — quota autoritaire. Pré-vol sur les deux chemins de création, suppression du blob à Delete, angle mort d'Update tranché.

  • ⚠️ Le pré-vol existait déjà à moitié, et ce n'était écrit nulle part. Upload (multipart) contrôlait et renvoyait 413 ; Create (JSON) ne contrôlait rien — or c'est le chemin qu'emprunte manager-app, qui crée la ligne puis téléverse. Tout dépassement passait par la porte de service.
  • ⚠️ Les deux lectures du quota divergeaient. Upload lisait le quota du plan, GetQuota celui de l'instance avec le plan en repli. Une instance à quota surchargé — le mécanisme même de l'add-on — affichait un chiffre à l'écran et se faisait bloquer sur un autre. Fermé par Helpers/StorageQuota, désormais seule source de vérité pour les deux.
  • Delete supprime le blob AVANT la ligne, et un échec renvoie 502 en conservant la ligne. Le choix se résume ainsi : une ressource encore listée se rattrape, un blob dont plus aucune ligne ne porte le chemin est facturé sans que rien ne le désigne. Non configuré, le service renvoie NotConfigured — il ne fait jamais semblant d'avoir nettoyé.
  • L'angle mort laissé ouvert par C1 était une fausse crainte. On redoutait qu'un recalcul de chemin « pointe vers un objet qui n'a pas bougé ». Or PathFor ne construit 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.

Aucun secret nouveau, contrairement à ce qu'on croyait en tranchant. FirebaseAdmin était déjà référencé pour les notifications push et Startup charge déjà un service account via Firebase:CredentialsPath : Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la clé Firebase:StorageBucket, vide par défaut — donc à renseigner en prod (I9), sans quoi Delete ne supprime rien.

⚠️ Deux suites à ne pas perdre : manager-app supprime toujours le blob de son côté (show_resource_popup.dart:130-137), après coup et en avalant l'échec — c'est devenu du code mort depuis C3, à retirer (C6), mais pas avant que Firebase:StorageBucket soit posé. Et dotnet restore échoue en 401 si la source NuGet git.dev-espaces-naturels.lu est déclarée dans l'image Docker : elle n'a rien à voir avec ce projet, et Google.Cloud.Storage.V1 est le premier paquet ajouté depuis longtemps — le prochain build d'image la rencontrera.

Lot C1 — livré le 2026-08-11. Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs, avec 13 tests (dotnet test 143/143). Il ferme le lien L5 : C2 (backfill) et l'écart (e) du lot G appelleront le même code au lieu d'en écrire deux.

  • ⚠️ L'extraction a prouvé la divergence qu'elle devait empêcher. ResourceController a deux chemins de création : le chemin multipart écrivait SizeBytes mais laissait StoragePath nul, seul le chemin JSON écrivait les deux. Le §1quater annonçait « StoragePath/SizeBytes écrits à Create depuis le 10/08 » — c'était vrai d'un chemin sur deux. Une partie des lignes à rattraper par C2 vient de là.
  • Angle mort laissé ouvert sciemment : Update peut faire passer Type d'un type URL à un type fichier, laissant StoragePath nul sur une ligne qui a désormais un blob. Recalculer le chemin sur une ressource existante pointerait vers un objet qui n'a pas bougé dans le bucket — à trancher avec C3 (quota).

Lot B — livré le 2026-08-11. Le schéma est gelé, le lot G est débloqué. Une seule migration EF (20260811130738_LotB_FreezeSchema) : rename SectionMap.MapResourceIdIconResourceId (l'écart (g) du §1quinquies tombe avec), suppression de SectionEvent.ParcoursIds, et Instance.IsImageWatermark remplaçant le instanceId == "633ee379…" en dur de ResourceController. 130/130 tests avant comme après, build Debug et Release verts.

  • ⚠️ Le rename imposait aussi la propriété de navigation MapResourceIconResource : la convention EF l'appariait au FK, la laisser en place aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs.
  • Point de risque levé par vérification : EF a généré un RenameColumn, pas un drop+add — les icônes déjà configurées survivent. L'avertissement « may result in the loss of data » ne porte que sur le DropColumn de ParcoursIds, qui est l'intention du lot.
  • « Supprimer SectionEvent.IconResourceId » était une erreur de doc — ne pas rouvrir. SectionEvent n'a pas ce champ : la ligne visée appartient à la classe imbriquée MapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, et lue par cinq contrôleurs plus les deux SelectMany de GetReferencedResourceIds. La supprimer aurait cassé les icônes d'annotation des trois types de section et la collecte offline.
  • Sécurité rapide du lot A, dans la même passe : le « backdoor #if DEBUG » était un bloc de AuthenticationController.Authenticate qui écrasait l'email et le mot de passe reçus par test@email.be — toute compilation Debug authentifiait n'importe quelle saisie. Retiré. EnableSensitiveDataLogging passe sous #if DEBUG (idiome déjà utilisé dans Startup.cs pour CORS et Hangfire ; le Dockerfile publiant en -c Release, c'est un verrou réel).
  • Reste du volet sécurité, chiffré et non fait : 112 retours new ObjectResult(ex.Message) { StatusCode = 500 } exposent le message d'exception interne, répartis sur 17 contrôleurs. Les 136 autres ex.Message sont des 400/404 volontaires, à garder. Startup.UseExceptionHandler(HandleError) existe mais ne traite que RequestException. C'est un chantier à part, pas une retouche.

Lot E — livré le 2026-08-11. PDF en média d'étape (les trois volets : liste de types paramétrable, ResourceViewer.tsx, CachedCustomResource), comparaisons QuestionType par entier brut, et W1 tranché : le web reste sur Leaflet (option a — ni MapBox ni Google Maps JS, donc aucun coût récurrent ajouté avant la prod).

⚠️ W1 ne pouvait pas se faire comme l'option le disait. « Retirer le champ pour les instances web » est impossible : le fournisseur est porté par la sectionSectionMap.MapMapProvider et 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. Le champ est donc conservé, et manager-app cesse de laisser croire qu'il vaut pour le web (mention sous les deux sélecteurs). Côté visitapp-web, zéro ligne : Leaflet inconditionnel était déjà le comportement.

⚠️ Piège de doc corrigé au passage : le plan disait « cas PDF dans getElementForResource ». Cette fonction de mymuseum-visitapp fait un return CachedCustomResource(...) avant son switch — tout son switch est du code mort. Le vrai dispatcher est CachedCustomResource, et il en a deux (ressource distante / fichier local de l'offline). Suivre la doc à la lettre aurait produit un correctif inopérant et « vérifié » par une analyse verte.

Add-on IA sur un plan sans IA — mode d'emploi et correctifs (2026-08-12)

Le mécanisme tient sans code. ApplyPlanQuotas ne repart du plan que si SubscriptionPlanId change (InstanceController:227) : surcharger Instance.AiTokensPerMonth sur une instance Pro survit, et le compteur mensuel se réarme seul sur AiUsageMonthKey. C'est ce qui permet de donner l'IA à MDLF et au Fort Saint-Héribert sans les passer en Premium — nécessaire pour éprouver le post-filtrage HNSW sur 4 instances plutôt que 2 (L14).

Mais l'activation est une manip, pas un geste produit. AiTokensPerMonth n'est exposé dans aucun DTOUpdateInstance ne le lit jamais. Trois gestes, dans cet ordre :

  1. UPDATE "Instances" SET "AiTokensPerMonth" = …, "IsAssistant" = true WHERE "Id" = '…'
  2. IsAssistant = true sur les ApplicationInstance du client — garde séparée (AiController:360), celle qu'on oublie : l'instance est dotée, le chat refuse quand même
  3. Le bouton de relance d'indexation de l'écran Guide IA (POST /api/Ai/reindex/{id}, SuperAdmin) — obligatoire : le rattrapage automatique est câblé dans UpdateInstance en C# (:234), un UPDATE SQL ne le déclenche pas, et le client paierait un guide qui ignore tout de son contenu déjà saisi

⚠️ Changer son plan plus tard écrase la surcharge et le remet à 0 jeton sans rien signaler. Pro → Premium est sans risque (Premium donne plus) ; tout autre mouvement casse l'add-on en silence.

⚠️ Aucune valeur d'add-on n'est arrêtée. Premium est à 20 M. Tant qu'un chiffre n'est pas fixé, deux clients payant la même chose n'auront pas le même quota.

Facturation : rien de neuf. StripeService ne connaît qu'EssentielPriceId (:56) — Pro, Premium et Enterprise sont déjà facturés hors Stripe, à la main. L'add-on rejoint un processus existant, il n'ajoute pas de dette.

Deux correctifs livrés le 2026-08-12dotnet build vert, dotnet test 148/148 :

  • BackfillInstanceAsync met en file un job par section au lieu de les traiter en boucle. L'unité de reprise devient la section : avec [AutomaticRetry(Attempts = 2)] posé sur la classe, un échec d'embedding à la 280ᵉ section faisait retenter le job entier, donc ré-embedder les 279 déjà indexées, deux fois — des appels facturés pour rien et autant de chances de retomber sur la limite de débit. Le chemin de backfill cesse d'être un chemin à part : c'est celui de l'intercepteur. Effet de bord utile : l'avancement est visible section par section dans /hangfire. IngestSectionCountingAsync disparaît, son int ne servait plus.
  • Backoff sur 429/503 dans GoogleEmbeddingService : 3 tentatives, Retry-After s'il est fourni, exponentiel sinon. Absorbe la rafale courte sans jamais remonter jusqu'à Hangfire.

📅 Écran SuperAdmin d'activation — reporté en V2, décidé le 2026-08-12. Exposer AiTokensPerMonth dans InstanceDTO ferait passer les trois gestes ci-dessus à un seul, et le backfill repartirait tout seul puisque UpdateInstance le déclenche déjà sur la transition 0 → >0. Mais la V1 n'a besoin d'aucun add-on : les 4 clients de la bascule sont affectés à leur plan (§1quinquies, plans), et doter une instance à la main reste un geste interne, fait une fois, sur une base qu'on contrôle. Ce qui rend le report tenable, c'est que la procédure SQL ci-dessus est écrite : elle ne se redécouvre pas.

Ce qui ferait remonter ce chantier : vendre l'add-on à un client réel avant la bascule. Une manip SQL sur une instance en production qui tourne n'a pas le même coût que sur une base de dev, et c'est là que le formulaire devient le moyen sûr.

À faire d'un bloc, le jour venu, avec l'endpoint de mise à jour d'ApplicationInstance (canaux activables) — c'est le même écran. ⚠️ Mais seule la partie add-on part en V2 : le volet canaux reste au lot F, il est dans les restes non chiffrés de la V1.

Contrôle du code du 2026-08-11 (2e passe) — ce qui a bougé depuis la rédaction du plan

Tout vérifié dans le code, pas dans les docs. Deux points du lot A tombent, un point du lot A est différé, un nouveau suspect apparaît.

Point Ce que disaient les docs (matin du 11/08) Ce que dit le code (soir du 11/08)
Lot A étape 1 — committer « 44 fichiers modifiés dont 9 non suivis, prérequis absolu » Fait. 9 repos propres et synchronisés avec leur remote — DOCS/ en est un, ce qu'on oublie en énumérant les repos de code. Voir §4
Lot A — rotation des secrets Verrou L1, ouvre le lot A 📅 Différée en fin de backlog, juste avant I3, à la demande du 2026-08-11 — repos privés auto-hébergés. Reste le verrou de I3, ne disparaît pas. Voir §3
Lot A — nettoyage manager_api_new « 14 fichiers modèle orphelins » Diagnostic fermé : 139 part déclarés pour 153 fichiers, les 14 sont hors graphe de compilation → suppression sans effet possible sur un build. Plus openApiTest.dart, qui est le déclencheur de génération. Voir §3
Lot A — build tablet-app « échec Gradle, seul build cassé » commit 5a6701d (« drapeaux du migrateur Flutter ») a peut-être réglé le Gradle, à reconfirmer par un vrai build. Le problème reste purement Gradle : une piste Dart a été ouverte puis écartée le même jour — marker_view.dart:456 référence bien ContentGeoPoint (classe hors graphe), mais la ligne est dans un bloc commenté, comme sa jumelle de mymuseum-visitapp:367. flutter analyze sur les deux fichiers : zéro erreur. Ne pas rouvrir cette piste
DB3 — écran Guide IA « 🔨 front livré » Confirmé livré des deux côtés. Onglets par boutons custom (guide_ia_screen.dart:249-265) — pas un TabBar, ce qui explique pourquoi le grep du plan concluait « aucun onglet » : la coquille existe bien. Backend : AiController knowledge/{instanceId}:190 et insights/{instanceId}:232, VisitorQuestionPurgeService.cs présent
Onglet « Vocal » des stats « reste à faire » Confirmé absent. statsChannelVoice n'est qu'un libellé de canal (statistics_screen.dart:189), pas un onglet
Lot B — les 4 changements de modèle « rien fait » Confirmé, aucun n'a bougé : SectionEvent.ParcoursIds:28, SectionMap.MapResourceId:25, hardcode watermark ResourceController.cs:265, et MeterZoneGPS sans aucun usage visiteur (zéro occurrence dans mymuseum-visitapp/lib hors swagger, zéro dans visitapp-web/src) — le bug M3 est réel
Lot C1 — calculateur extrait « à faire » Confirmé à faire : StoragePath n'apparaît que dans ResourceController et Resource.cs, aucun service
Lot D1 « méthode existe, appelée nulle part » Confirmé : hors des 13 sous-types, GetReferencedResourceIds n'apparaît que dans sa déclaration abstraite (Section.cs:85) et un test

RGPD reporté en fin de backlog le 2026-08-11 (lot J) — table d'agrégats de thèmes, job de regroupement, interrupteur de collecte par instance, mention affichée aux visiteurs, relecture juridique du §8 des CGU, vérification des conditions Google. C'est tenable parce que la porte « rien en prod avant la fin du backlog » tient : la journalisation ne tourne d'ici là que sur des bases de développement, aucun visiteur réel n'est concerné. Si un déploiement anticipé était décidé, ce lot redeviendrait bloquant.

Le lot B n'existait nulle part : tout changement de modèle encore ouvert (rename MapResourceId, arbitrage ParcoursIds, unification QuestionType, flag watermark) doit passer avant la réécriture de BuildSection, sinon on écrit le mapping deux fois — et après la bascule, c'est une migration de données sur la prod.

Lot D-bis — les trois écrans de design

Constat du 2026-08-11, plus ouvert que ce que ce fichier annonçait : l'écran Guide IA « livré le 07/08 » était 5 cartes en colonne unique sans onglets, les stats de l'assistant étaient à zéro, et la refonte SectionParcours n'avait pas commencé.

Cause identifiée : les maquettes vivaient dans des artifacts d'un autre compte, illisibles depuis la machine de dev — chaque session redessinait. Réglé : le HTML est rapatrié dans claude design/ (guide-ia-screen.html, statistics-screen.html, sectionparcours-refonte-flux.html). Même découpage que le kanban — contenu dans le repo, diffusion par artifact.

Étape État
DB0 — rapatrier les maquettes 2026-08-11
DB1 — socle visuel constants.dart 2026-08-11 — 11 rôles typographiques, 8 espacements, 5 rayons. Échelle des maquettes, couleurs de l'app : un écran neuf se fond dans manager-app, il n'y ouvre pas une seconde palette. Pas de thème sombre
DB2 — sauvegarde au fil de l'eau SectionParcours 2026-08-11, puis retiré le même jour par DB4. Le garde-fou (confirmation d'abandon sur les trois dialogues + PopScope) a fermé la perte silencieuse le temps que la persistance arrive. Les trois dialogues n'existent plus et il n'y a plus de travail non enregistré à perdre : les 3 clés i18n sont supprimées. C'était l'issue prévue par l'arbitrage — les deux étaient alternatives, pas cumulables
DB3 — écran Guide IA 2 onglets 🔨 2026-08-11 — coquille à onglets, aperçu de conversation sur le vrai /api/AI/chat, onglet « Ce que demandent vos visiteurs » alimenté par GET /api/Ai/insights/{id}, carte « Ce que connaît votre guide » sur GET /api/Ai/knowledge/{id}, journalisation VisitorQuestion branchée dans AiController.Chat. 34 clés i18n FR/EN/NL. Reste : le job de regroupement en thèmes, la purge 90 j, le RGPD dans les CGU avant mise en service, l'onglet « Vocal » des stats
DB4 — forme SectionParcours 🔨 2026-08-11 — option A livrée : une fenêtre, un rail d'étapes, profondeur 2. Les trois showNewOrUpdate… sont supprimés au profit de guided_path_editor.dart (coquille : fil d'Ariane, rail réordonnable, panneau, pied « Enregistré ») et de trois widgets de champs autonomes — ParcoursFields, EtapeFields, QuestionFields dans Parcours/Fields/. Une question se déplie dans le panneau de l'étape, plus dans une 4ᵉ fenêtre.
Sauvegarde à la saisie : guided_path_api.dart masque le choix sectionParcoursApi / sectionMapApi, un débounce de 700 ms écrit le parcours (PUT), chaque étape (POST/PUT/DELETE) et ses questions avec elle. Le parcours est créé à la première modification, pas à l'ouverture — fermer une fenêtre neuve sans rien saisir ne laisse rien en base. Deux pièges du backend : 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 feraient un doublon) ; et les questions n'ayant pas d'endpoint, leur id entier est récupéré après coup par order, seul repère stable entre les deux listes. Échec d'écriture : la fenêtre refuse de se fermer et le pied de page porte un « Réessayer ». 14 clés i18n FR/EN/NL, 3 retirées (DB2). flutter analyze propre sur le dossier, flutter build web .
Reste : DB5, la vérification à l'œil contre la maquette

AIApi était dans le client généré mais exposé nulle part dans client.dart — les 18 autres façades y sont. Câblé le 2026-08-11.

Arbitrages tranchés le 2026-08-11 — le lot B s'allège :

  • SectionEvent.ParcoursIds → supprimer. Le champ n'est lu ni écrit nulle part (entité, DTO, migrations — zéro contrôleur, zéro service), et son commentaire dit // Liens vers GeoPoints spécifiques, ce qui contredit son nom. Le lien événement↔parcours existe dans l'autre sens, GuidedPath.SectionEventId, lui bien entretenu. Décisif : ParcoursIds est porté par SectionEvent, pas par ProgrammeBlock — il ne peut donc pas exprimer « les parcours de tel jour », précisément le cas carnaval invoqué pour le garder.
  • QuestionType → on ne touche pas au schéma, et il sort du lot B. Le trio TextLibre/Digicode/ExpectedAnswer n'existe pas dans le code : l'enum vaut Simple / MultipleChoice / Puzzle, référencé à un seul endroit côté serveur, et le digicode est un comportement dérivé d'une réponse numérique, pas un type. Reste une propreté front (comparaisons par entier brut dans showNewOrUpdateGuidedStep.dart:409 et guided_step_challenge.dart:14), déplacée au lot E. MigrationController n'est plus bloqué que par un seul changement de modèle.