diff --git a/STATUS.md b/STATUS.md
index 97b9861..92dde8d 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -32,10 +32,14 @@ Résumé de la table de suivi de ce fichier :
| manager-app | ✅ `flutter build web` | débloqué : `// @dart=2.18` manquant dans `manager_api_new/lib/api/onboarding_api.dart` (fichier ajouté à la main pour l'onboarding, sans l'annotation de version que porte `api.dart`) — **cassait les 3 apps Flutter d'un coup** |
| mymuseum-visitapp | ❌ `flutter build apk` (**revérifié le 2026-08-11**) | Était ✅ le 2026-08-06, débloqué par la même correction + typage explicite dans `guided_step_challenge.dart:83`. **Casse maintenant côté Gradle**, pas côté Dart : `ndkVersion = "28.2.13676358"` réclamé dans `android/app/build.gradle`, avertissements KGP sur 11 plugins. Côté Dart il reste 4 erreurs, **toutes sur la branche Ray-Ban** (`kElevenLabsApiKey`/`kElevenLabsVoiceId` absents de `constants.dart`, jamais committés — voir §5bis), dans des fichiers hors du graphe de `main.dart`. Même famille que tablet-app : l'environnement Android a bougé |
| visitapp-web | ✅ `npm run build` | débloqué : helper `getGeoPointLatLng()` dans `src/lib/geo.ts`, les 7 erreurs venaient d'un `unknown` non narrowé. ⚠️ ce type d'erreur est **invisible en `next dev`** |
-| tablet-app | ❓ | échec Gradle sur `flutter_tools/gradle/build.gradle.kts` — **seul build encore cassé** au 2026-08-06. ⚠️ **Peut-être corrigé depuis** : commit `5a6701d` « Drapeaux du migrateur Flutter (builtInKotlin, newDsl) ». À reconfirmer par un vrai build, pas par le log. **Le problème est purement Gradle** : une piste Dart (`ContentGeoPoint`) a été ouverte puis **écartée** le 2026-08-11, cf. §1sexies — ne pas la rouvrir |
+| tablet-app | ❌ `flutter build apk` — **mesuré pour de vrai le 2026-08-12** | ⛔ **Le diagnostic « un seul défaut, purement Gradle, peut-être réglé par `5a6701d` » était doublement faux.** Il a tenu des mois parce que personne n'avait lancé la commande. Réalité : **six crans d'outillage** (Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 1.9.0 → 2.0.10, option AGP supprimée, jcenter mort) — **corrigés le 2026-08-12** — **puis 132 erreurs Dart** que le Gradle masquait : l'app est restée sur **l'ancien contrat d'API**, celui d'avant Postgres v3. Détail complet et suite : **[v1-plan.md lot K](v1-plan.md)** |
**Leçon** : `flutter analyze` et `next dev` ne suffisent pas. Seuls `flutter build` / `npm run build` / `dotnet test` disent la vérité — d'où la nouvelle section §0 du plan de test.
+**Leçon renforcée le 2026-08-12** : un build **jamais lancé** ne vaut pas mieux qu'un build vert supposé. Trois affirmations de ce tableau ont sauté d'un coup dès qu'on a tapé la commande. Corollaire à retenir : *« à reconfirmer par un vrai build »* dans une doc veut dire **que ce n'est pas confirmé**, et un point non confirmé ne doit pas être chiffré comme une demi-heure.
+
+⚠️ **Et un piège de vérification à connaître** : `flutter build apk … | tail` renvoie **le code de sortie du `tail`**, pas celui du build. Un échec y ressort en « exit code 0 ». Rediriger vers un fichier et lire `$?` séparément.
+
---
## 1ter. Chantier « migration Postgres v3 » — ordre de bataille (ajouté le 2026-08-07)
@@ -308,6 +312,20 @@ Les lots 1 et 2 sont terminés. **Le lot 3 et le lot médias sont tous les deux
**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** : six 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 2.0.10, jcenter mort), **puis 132 erreurs Dart** que le Gradle masquait depuis le début.
+
+**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).
+
+**Périmètre kiosk tranché le 2026-08-12** : `tablet-app` couvre 11 des 13 types. `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.
+
**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**.
@@ -332,6 +350,17 @@ Remplacé par `GetReferencedResourceIds()`, déjà implémentée sur les 13 sous
- **(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é.
+
+⚠️ **Deux dettes backend ouvertes par ce chantier, laissées ouvertes à dessein** (le périmètre était `manager-app` seul) :
+
+1. **Le plafond de 5 n'existe pas côté serveur.** `UserController.CreateUser` ne compte rien, pas de 422, et `SubscriptionPlan` ne porte aucun champ utilisateur. Ce qui est livré est un **garde-fou d'interface** : un POST direct sur l'API passe toujours. S'il devait varier par plan, c'est une colonne — donc un changement de schéma **après** le gel du lot B.
+2. **Aucune section n'est jamais journalisée.** `AuditedTypes.Contains(entry.Entity.GetType())` (`MyInfoMateDbContext.cs:126`) exige l'égalité **exacte** de type, or `Section` est **abstraite** (`Section.cs:17`) : le type runtime est toujours `SectionMap`, `SectionQuiz`… La ligne `typeof(Section)` d'`AuditedTypes` ne matche donc rien. Le journal couvre `Resource`, `Configuration`, `Device`, `User`, `Instance` — mais **pas le contenu**, précisément ce que l'usage cible voulait tracer. Correctif : `Any(t => t.IsInstanceOfType(entry.Entity))`. ⚠️ `EntityType` portera alors `SectionMap` et non `Section` : soit normaliser côté serveur, soit élargir la liste du filtre front aux 13 sous-types. Le filtre « Section » est **déjà dans l'écran** et ne rend aucune ligne d'ici là.
+
**Lot 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à.
@@ -351,6 +380,33 @@ Remplacé par `GetReferencedResourceIds()`, déjà implémentée sur les 13 sous
⚠️ **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.
diff --git a/kanban.html b/kanban.html
index 1a376d6..3dbbdb6 100644
--- a/kanban.html
+++ b/kanban.html
@@ -454,13 +454,13 @@
Échec Gradle sur flutter_tools/gradle/build.gradle.kts. Seul build encore cassé des cinq. Le dernier APK date d'avril. Peut-être déjà réglé par le commit 5a6701d (« drapeaux du migrateur Flutter ») — à reconfirmer par un vrai build, pas par le log.
Piste écartée le 11/08, à ne pas rouvrir : marker_view.dart:456 référence ContentGeoPoint, une classe absente de tout graphe de compilation — de quoi croire à un second défaut, côté Dart cette fois. Mais la ligne est dans un bloc commenté (/* ouvert en 418), comme sa jumelle de mymuseum-visitapp:367 (/* en 329). flutter analyze sur les deux fichiers : zéro erreur. Le problème de tablet-app est purement Gradle.
Le premier vrai build, le 12/08, a fait sauter le diagnostic de cette carte. « Un seul défaut, purement Gradle, peut-être réglé par 5a6701d » était doublement faux — il tenait parce que personne n'avait lancé la commande. L'outillage Android demandait six crans (Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 2.0.10, jcenter mort) : fait le 12/08. Derrière, le Gradle masquait 132 erreurs Dart.
3 causes seulement : ConfigurationDTO.roundedValue & co. ont migré vers AppConfigurationLinkDTO (73 err.), GeoPointDTO.latitude/longitude sont devenus GeometryDTO geometry avec PostGIS (40 err.), le reste en découle. Le modèle du portage est déjà écrit dans mymuseum-visitapp (currentAppConfigurationLink, Models/visitContext.dart) — copier, pas concevoir.
⚠️ Pourquoi ça bloque la bascule (L19) : 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.
+ v1-plan.md lot K · STATUS.md §1bis +tablet-app couvre 11 des 13 types. Ce n'est pas un retard : SectionEvent et SectionParcours sont nés avec Postgres v3, ils n'ont jamais existé dans Mongo.
Tranché le 12/08 — SectionParcours : exclu définitivement, un parcours guidé fait marcher le visiteur avec géodéclenchement, ça n'a pas de sens sur borne fixe. SectionEvent : à supporter, une borne d'accueil qui affiche le programme du jour et le plan est le cas d'usage kiosk par excellence (§19.13 cas E).
Plus le renommage SectionPuzzle → SectionGame et le nouveau SlidingPuzzle : reprendre mymuseum-visitapp/lib/Screens/Sections/Game/ (game_page.dart, sliding_puzzle_piece.dart), dont la version est moins buguée que le Puzzle/ de tablet-app. Récupération, pas réécriture.
Bucket en Europe = aucun quota gratuit, facturation dès le premier octet. L'alerte est la vraie protection contre la surprise.
- v2/media-storage-plan.md -Backend fait, front absent. Users : scoping backend fait, manque le plafond de 5 et le compteur dans l'UI.
- todo-features.md +Le front est livré (12/08), ces deux points ne le sont pas. (1) Aucune section n'est journalisée : AuditedTypes.Contains(entry.Entity.GetType()) exige l'égalité exacte, or Section est abstraite — le type runtime est toujours SectionMap, SectionQuiz… donc typeof(Section) ne matche jamais. Le journal couvre ressources, configurations, devices, users et instances, mais pas le contenu — précisément ce que l'écran devait tracer. Correctif : Any(t => t.IsInstanceOfType(…)), en décidant si EntityType porte alors SectionMap ou reste normalisé à Section. (2) Plafond de 5 utilisateurs : UserController.CreateUser ne compte rien, pas de 422 — le compteur livré est un garde-fou d'interface, un POST direct sur l'API passe toujours. Le retour d'erreur du POST est déjà branché côté front : le 422 sera visible sans retouche.
Donner l'IA à un client Pro se fait en SQL : AiTokensPerMonth n'est dans aucun DTO, IsAssistant est à poser deux fois (instance et ApplicationInstance), et le rattrapage d'indexation ne partant que depuis UpdateInstance en C#, il faut penser au bouton de relance. Exposer le quota dans InstanceDTO ramènerait ça à un formulaire. Reporté en V2 le 12/08 : la V1 n'a besoin d'aucun add-on — les 4 clients de la bascule sont affectés à leur plan, et l'activation manuelle reste un geste interne, fait une fois, sur une base qu'on contrôle. À faire d'un bloc avec l'endpoint de mise à jour d'ApplicationInstance (canaux activables) : c'est le même écran.
Bascule DNS/API, Mongo conservé en lecture seule quelques jours, et le plan de rollback écrit avant le jour J — un retour arrière qu'on improvise à 23 h n'est pas un retour arrière.
- STATUS.md §1quinquies — étapes 21-22 +Décidé le 12/08, remplace la bascule DNS. Un second serveur porte la nouvelle prod Postgres, les nouvelles versions des apps pointent vers lui, et l'ancienne prod Mongo continue de servir les apps déjà installées. Aucun DNS ne bouge, pas de fenêtre de maintenance, et un retour arrière qui consiste à ne rien faire.
+C'est la seule réponse au problème que L19 pose : on ne force pas la mise à jour du téléphone d'un visiteur.
+Ce qui rend la coexistence saine — une seule source d'écriture : dès que le back-office pointe sur la nouvelle prod, l'ancienne est figée de fait. Les vieilles apps voient un contenu gelé au jour J, ce qui est tenable quelques semaines, et les deux bases ne divergent jamais.
+⚠️ Ce qui ne marche pas : « recopier de la nouvelle prod vers l'ancienne ». Il n'existe pas de chemin Postgres → Mongo ; MigrationController va dans un seul sens et écrire l'inverse coûterait autant, pour alimenter une base qu'on éteint. L'ancienne prod se fige, elle ne se synchronise pas.
À tenir : une date d'extinction décidée d'avance (sinon l'ancienne prod survit trois ans), le coût d'un second serveur OVH le temps de la transition, et un plan de rollback réécrit — il ne s'agit plus de restaurer un dump mais de republier l'app qui pointe vers l'ancienne adresse, ce qui déplace le risque sur les délais de validation des stores.
+ v1-plan.md lot I — stratégie de coexistenceTrente-deux chantiers clos entre le 5 et le 11 août 2026.
+Trente-six chantiers clos entre le 5 et le 12 août 2026.
onVariants$default), suppression de android.bundle.enableUncompressedNativeLibs (retirée en AGP 8.1), AGP → 8.7.3 (minimum Flutter), AGP → 8.9.1 (réclamé par androidx.browser et core-ktx), et jcenter() → mavenCentral() (jcenter est arrêté depuis 2021). ⚠️ Un bloc buildscript entièrement commenté déclarait AGP 8.5.0/Kotlin 2.0.10 pendant que la vraie config vivait dans settings.gradle en 7.2.0/1.9.0 : il a fait conclure deux fois à une configuration qui n'était pas celle du build — supprimé, les versions ne sont plus déclarées qu'à un seul endroit. C'est le troisième piège « bloc commenté » du projet. Ce que ça débloque : compileSdkVersion 36, sans lequel plus aucune mise à jour n'est publiable sur le store. Reste le portage Dart — voir la carte du lot K.
+ manager-app seul. Écran « Activité » sur GET /api/Audit : filtres instance / type d'entité / utilisateur / plage de dates, pagination 50, clic sur une ligne → détail avant-après en table, entrée de menu réservée à role.value == 0 comme la policy de l'endpoint. Les ids sont résolus en noms, un id inconnu restant affiché tel quel — le journal doit rester lisible après la disparition de ce qu'il décrit. Compteur « X / 5 utilisateurs » et bouton d'ajout désactivé au plafond, SuperAdmin exclu : sa liste couvre toutes les instances, la compter contre un plafond par instance n'aurait aucun sens. manager_api_new n'a pas été touché — http.get direct au Bearer, comme le Guide IA : une lecture seule ne justifie pas d'étendre un client qui s'édite à la main. 40 clés i18n FR/EN/NL, flutter build web ✅. ⚠️ Trois pièges relevés en câblant : AuditController renvoie les entités brutes, pas un DTO ; invokeAPI ne lève pas sur code d'erreur et son résultat était ignoré, donc un e-mail déjà pris (409) ne produisait aucun message — corrigé ; et DropdownButtonFormField ne relit pas initialValue sur reconstruction, donc « Réinitialiser les filtres » vidait la requête sans vider l'affichage — remplacé par un DropdownButton piloté. Les deux dettes serveur qu'il ouvre sont sur leur propre carte, en Planifié.
+ BackfillInstanceAsync parcourait les sections d'une instance en boucle dans un seul job. Avec [AutomaticRetry(Attempts = 2)] posé sur la classe, un échec d'embedding à la 280ᵉ section faisait retenter le job entier par Hangfire — 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 qui avait causé le premier échec. Elle met désormais en file un job par section : l'unité de reprise devient la section, ReplaceAsync la rend idempotente, et le chemin de backfill cesse d'être un chemin à part — c'est celui de l'intercepteur, déjà éprouvé. Effet de bord utile : l'avancement se lit section par section dans /hangfire au lieu d'un job opaque. Complété par un backoff sur 429/503 dans GoogleEmbeddingService (3 tentatives, Retry-After s'il est fourni, exponentiel sinon), qui absorbe la rafale courte sans jamais remonter jusqu'à Hangfire. dotnet test 148/148. Découvert en préparant l'activation de l'IA sur MDLF et le Fort — deux instances Pro à doter à la main pour éprouver le post-filtrage HNSW sur 4 instances plutôt que 2.
+ switch qui collecte les ressources d'une configuration é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 — ni contenus d'articles, ni audios, ni icônes de carte, ni images de quiz. Remplacé par GetReferencedResourceIds(), qui existait déjà sur les 13 sous-types et n'attendait que d'être appelée. Côté import et côté client, la charge est enregistrée en une passe au lieu d'être redécouverte section par section : le serveur sait maintenant ce qu'il envoie, le client n'a plus à le deviner — et ça répare au passage la purge des fichiers obsolètes, pilotée par la même liste d'ids utilisés. 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, et c'est exactement ce qu'il ne faut plus pouvoir refaire. dotnet test 148/148. ⚠️ Le volet visiteur ne tient que sur l'analyse — l'APK ne compile pas — confirmation sur device au §21, carte « Visite hors ligne sur device » déjà en attente.
diff --git a/todo-features.md b/todo-features.md
index 5ee23bf..9dd6942 100644
--- a/todo-features.md
+++ b/todo-features.md
@@ -485,10 +485,11 @@
- ✅ `POST` : `InstanceId` **forcé** à l'instance de l'appelant (`l.133`), pas celui envoyé par le client
- ✅ Protection contre l'escalade de rôle : `PUT`/`DELETE` refusés si `user.Role < GetCallerRole()` (`l.210`, `l.266`)
- #### Ce qui reste réellement à faire
- - ❌ **Plafond de 5 utilisateurs par instance** — aucun contrôle de comptage dans `UserController`, pas de 422
- - ❌ **Manager-app** : compteur "X / 5 utilisateurs" + désactivation du bouton d'ajout au quota
- - ❓ À vérifier : le champ de rôle `SuperAdmin` est-il masqué dans le formulaire manager-app pour un `InstanceAdmin` ?
+ #### Ce qui reste réellement à faire — révisé le 2026-08-12
+ - ✅ **Manager-app** : compteur « X / 5 utilisateurs » + bouton d'ajout désactivé au quota — livré le 2026-08-12 (`kMaxUsersPerInstance` dans `constants.dart`, `users_screen.dart`). Le compteur et le plafond **ne s'appliquent pas au SuperAdmin** : son `GET /api/User` renvoie toutes les instances, compter cette liste contre un plafond *par instance* n'aurait aucun sens
+ - ✅ **Le rôle `SuperAdmin` est déjà masqué** pour un `InstanceAdmin` — la question était ouverte, elle est fermée : `_allowedRoles` filtre sur `r >= callerRole` (`users_screen.dart:40`), un appelant de rôle 1 ne se voit proposer que 1/2/3. C'était fait, jamais consigné
+ - ❌ **Plafond de 5 utilisateurs par instance, côté serveur** — aucun contrôle de comptage dans `UserController.CreateUser`, pas de 422. ⚠️ **Ce que manager-app vient de livrer est un garde-fou d'interface, pas une règle** : un `POST /api/User` direct sur l'API passe toujours, et rien n'empêche une instance d'avoir 12 utilisateurs. Le plafond n'est pas non plus dans `SubscriptionPlan`, qui ne porte que 5 champs (stockage, jetons IA, stats, rétention, stats avancées) — s'il devait varier par plan, c'est une colonne à ajouter, donc un changement de schéma **après** le gel du lot B
+ - ⚠️ **Retour d'erreur du POST, corrigé côté front dans la même passe** : `invokeAPI` ne lève pas sur un code d'erreur et son résultat était ignoré — un e-mail déjà utilisé (409) ou un rôle refusé (403) laissait la liste inchangée **sans aucun message**. Le statut est désormais testé et le corps affiché. C'est ce qui rendra le futur 422 visible sans retouche front
#### Comportement attendu (spec d'origine)
- Un `InstanceAdmin` peut voir, ajouter et supprimer des utilisateurs **de son instance uniquement**
@@ -516,13 +517,18 @@
- `SaveChanges()` (sync) et `SaveChangesAsync()` déclenchent tous les deux l'audit (avant : seul `SaveChangesAsync` l'alimentait, les controllers en sync passaient sous le radar)
- `DateCreation`/`DateUpdate` auto-remplis via `IAuditableEntity` + `ChangeTracker`, plus besoin de le faire à la main dans chaque mapper
-- [ ] ❌ **Manager-app — écran "Activité / Audit" (SuperAdmin uniquement)**
- - Nouveau modèle + service dans `manager_api_new/` en lien avec `AuditController` (`GET /api/Audit`) — rien n'existe encore côté client généré, à ajouter à la main (pas de régénération OpenAPI, cf. politique du projet)
- - Écran liste : date, instance (nom, pas juste l'ID), type d'entité, action (Create/Update/Delete), utilisateur — tri par date décroissante
- - Filtres : par instance (picker), par type d'entité, par utilisateur, par plage de dates
- - Tap sur une ligne → détail avant/après (`OldValues`/`NewValues` formatés, pas le JSON brut)
- - **Usage cible** : le SuperAdmin (Thomas) veut pouvoir répondre à "qu'est-ce que ce client a fait durant telle période ?" sans aller chercher en base
- - Accès : nouvelle entrée de menu visible uniquement si `role == SuperAdmin`
+- [x] ✅ **Manager-app — écran "Activité / Audit" (SuperAdmin uniquement)** — livré le 2026-08-12
+ - `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)
+ - Liste triée par date décroissante, filtres instance / type d'entité / utilisateur / plage de dates, pagination 50 par page, clic sur une ligne → détail avant/après en table champ / avant / après
+ - **`manager_api_new` n'a pas été touché.** Le plan initial disait « nouveau modèle + service dans `manager_api_new/` » ; l'écran passe par un `http.get` direct avec le 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 (`openApiTest.dart`) n'existe plus depuis le lot A
+ - Deux détails qui ne se devinent pas : le contrôleur renvoie les entités **brutes** (pas de DTO), donc les champs sont ceux d'`AuditLog` en camelCase ; et `to` est pris à **la fin de la journée** choisie, sinon un filtre « jusqu'au 12/08 » exclut tout le 12/08
+ - Les valeurs multilingues (`List