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

292 lines
57 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](../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](../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](../v1-plan.md) — 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 K6**~~**tranché 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 `SectionPuzzle``SectionGame` 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](../v1-plan.md).
⚠️ **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](../test-plan.md) — 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](../v1-plan.md).
> ⚠️ **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](../v1-plan.md)) :
1. **`AuditLog` n'a aucune purge.** `VisitEvent` purge à 13 mois, `VisitorQuestion` à 90 jours, `AuditLog` à jamais. **C'est un sujet RGPD et rien d'autre** — la table porte `UserId`, et les valeurs avant-après d'une modification de `User` contiennent e-mail, prénom et nom. Ce n'est **pas** un sujet de volume, le flot machine étant coupé. **Donc à traiter au lot J.** Recommandation : **12 mois, uniforme**, avec le même verrou que les deux purges existantes — inerte tant qu'`Audit:RetentionDays` n'est pas défini, **le pg_dump quotidien n'étant toujours pas en place** : supprimer des lignes d'audit sans restauration fine est la pire combinaison.
2. **La bascule écrira ~2 700 lignes d'audit dans la transaction globale** de `MigrationController` (2 374 ressources + 307 sections + le reste), chacune avec un instantané JSON complet. Pas fatal, mais c'est neuf pour les sections et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run.
3.**`AuditLog` sans index sur `Timestamp` — fausse alerte, retirée.** Signalée par réflexe sur « la table va grossir » ; elle allait grossir à cause de la météo. Le flot machine coupé, il reste les éditions humaines — de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'un `ORDER BY Timestamp DESC LIMIT 50` trie en millisecondes sans index. **Pas d'index, pas de migration, gel du lot B intact.**
**Lot C2 et C3 — livrés le 2026-08-12. Le lot médias est terminé côté serveur.** `dotnet build` vert, `dotnet test` **163/163** (148 au départ, +7 pour C2, +8 pour C3).
**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.MapResourceId``IconResourceId` (**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** `MapResource``IconResource` : 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 **section**`SectionMap.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 DTO**`UpdateInstance` 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-12**`dotnet 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.<br>**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` ✅.<br>**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.**
---