2 Commits

Author SHA1 Message Date
55e8839613 docs(beta): plan d'implémentation 1a — socle sûreté & chaîne prod GUI V6
Plan bite-sized TDD pour les chantiers A/B/E1 : fail-close PII (P0-1), URL portail
(P0-2), binding licence souple (P0-6), log fichier V6 (E1), flag frozen ONNX (P0-5),
instance unique + mutex installeur (P0-7). 6 tâches, code complet, tests unitaires.
Plans 1b (gating cœur), 1c (UI), 2 (diagnostics), 3 (build/release) à suivre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:49:01 +02:00
6554a6d590 docs(beta): audit prêt-bêta GUI V6 + design de mise en prod bêta testeur
Audit prêt-pour-bêta de GUI V6 (3 axes : parcours/UX/PII, moteurs/OnnxTR/frozen,
licence/télémétrie/diffusion) → punch-list P0=7 / P1=11 / P2=6, findings vérifiés.

Design validé Dom : périmètre « A + chaîne prod », P0 + P1 honnêteté UI, toggles
« Données à détecter » câblés au moteur (gating par type + couplage rescan, pas de
plancher dur), licence souple + binding poste (D-20.4), −2 Go via excludes PyInstaller,
Linux-first TDD + un seul build Windows. Plus 3 exigences prod intégrées : diagnostics
logs auto-upload scrubbé (liste-blanche), mise à jour propre (installeur), modèles
embarqués hors-ligne. 6 chantiers (A-F), 2 repos. Diffusion = gate Dom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:43:04 +02:00
3 changed files with 971 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
# Audit prêt-pour-bêta — GUI V6 (HEAD `4b7c8db`, 2026-06-25)
Périmètre : GUI V6 (`gui_v6/`) + chaîne prod (licence, télémétrie, diffusion) pour
une mise en bêta testeur. Cible : un testeur installe l'EXE Windows → active sa
licence → traite des PDF/images médicaux → ses stats remontent au portail.
Audit en 3 axes (parcours/UX/PII, moteurs/OnnxTR/frozen, licence/télémétrie/diffusion).
Findings vérifiés dans le code (lignes citées) ; les 2 plus lourds recontrôlés par Claude.
**À FAIRE par Dom : valider / élaguer cette liste avant tout code (cocher / barrer).**
---
## P0 — Bloqueurs bêta (à corriger avant toute diffusion)
### P0-1 — Fuite PII silencieuse : la GUI ne fait pas fail-close si le NER obligatoire échoue
- `gui_v6/engine_bridge.py:222-231` appelle `ensure_loaded()` sans vérifier l'état ;
si CamemBERT-bio ne charge pas (`:173-175``UNAVAILABLE`, `camembert=None`), le
traitement continue en **regex + gazetteers seuls** et le document sort compté
« Réussi ». Le rescan résiduel ne couvre PAS les noms de personnes.
- Le CLI de prod fait l'inverse : `scripts/anonymize_cli.py:184-198` fail-close (code 3,
aucune sortie) si le modèle obligatoire manque.
- **Impact** : modèle ONNX corrompu/absent (antivirus, ressource manquante) → PDF qui
paraît traité, statut OK, mais noms patients potentiellement en clair. **Risque #1.**
- **Fix** : dans `process_fn`, si `use_local_ner` et état `UNAVAILABLE` → lever une
exception explicite (refus de traitement), le runner la remonte en échec. Aligne la
GUI sur la garantie code-3 du CLI.
### P0-2 — Chaîne prod cassée : la GUI pointe `http://localhost`, jamais le portail réel
- `Pseudonymisation_Gui_V6.py:57``AnonymisationApp()` sans client → `gui_v6/app.py:41`
`LicenseClient("http://localhost")`. La télémétrie réutilise cette URL (`app.py:199`).
Aucune env var / config / injection build ne fixe l'URL portail (grep exhaustif = 0).
- **Impact** : sur un poste testeur, activation licence ET télémétrie POSTent sur
`localhost` → rien n'écoute → **activation impossible, stats jamais remontées**. Toute
la « chaîne prod » demandée est non fonctionnelle. Le portail tourne sur
`app.aivanov.eu` (vérifié live, HTTPS 200, artefact servi).
- **Fix** : injecter l'URL réelle (`DEFAULT_PORTAL_URL="https://app.aivanov.eu"` ou env
var), `localhost` devient le secours dev. **Impose un rebuild** (l'EXE déjà publié est
inutilisable pour l'activation).
### P0-3 — Gain 2 Go non réalisé : `optimum` réintroduit torch en dur
- `requirements.txt:3` = `optimum[onnxruntime]>=2.0.0` ; optimum déclare `torch>=1.11`
en dépendance **cœur** (vérifié). Le build installe requirements → torch dans le venv →
PyInstaller le bundle malgré le retrait des hiddenimports (`4b7c8db`). EXE ~+2 Go.
- `optimum` n'est importé que par `ner_manager_onnx.py` (legacy ONNX **non câblé** à la
GUI), `Pseudonymisation_Gui_V5.py` (V5 legacy) et un script de finetune — **jamais par
`gui_v6/`** (le NER GUI = `camembert_ner_manager` en onnxruntime brut).
- **Fix** : retirer `optimum` (et hiddenimports `optimum*`) du build frozen GUI ;
valider « torch-free » par la **taille EXE réelle** + grep de l'arbo PyInstaller, pas
par le diff du spec.
### P0-4 — Build non reproductible : pas de précache des poids OnnxTR
- `anonymisation_gui_v6_onefile.spec:50-67` **raise** `FileNotFoundError` si les poids
`db_resnet50` + `crnn_vgg16_bn` absents du cache ; `scripts/build_windows_oneclick.ps1`
ne télécharge jamais ces poids. Le build « marche » seulement grâce au cache résiduel
de la machine 192.168.1.11.
- **Fix** : étape pré-PyInstaller : `python -c "from onnxtr.models import ocr_predictor;
ocr_predictor(det_arch='db_resnet50', reco_arch='crnn_vgg16_bn')"` + doc.
### P0-5 — Crash frozen ONNX probable : le hotfix CLI `6c6f653` non reporté au launcher GUI
- Le launcher V6 ne pose pas `ANON_SKIP_LEGACY_ONNX_MANAGER=1` (lu à l'import du core,
`anonymizer_core_refactored_onnx.py:211`) et ne pré-charge pas CamemBERT — alors que le
hotfix CLI (`scripts/anonymize_cli.py:87`) le fait pour éviter « cannot load module more
than once per process » en frozen Windows.
- **Fix** : poser le flag tout en haut de `Pseudonymisation_Gui_V6.py` + pré-charger
CamemBERT avant le 1er import du core. **À confirmer sur l'EXE** (non reproductible en dev).
### P0-6 — Contrôle de licence purement décoratif (décision Dom requise)
- Enforcement nul : `tab_usage.py:205` active « Lancer » selon le nombre de documents
seulement ; aucune gate sur le statut licence (lu pour affichage uniquement).
- Pas de vérif locale : `license_client.py:208-213` lit `license.json` **sans vérifier
signature ni `machine_id`** → fichier copiable sur N postes = N licences « actives ».
- `machine_id` = MAC seule 12 chars (`machine_id.py:12-14`) = clonable, change en VM.
- D-20.1 (fingerprint multi-composants) et D-20.4 (vérif locale machine_id) **non
implémentés**. La bêta est une fenêtre idéale (aucune activation prod existante à migrer).
- **Décision Dom** : niveau d'enforcement bêta (souple / dur / différé) — voir question.
### P0-7 — Pas de protection multi-instance (lock perdu vs V5)
- `Pseudonymisation_Gui_V6.py:50-59` lance `mainloop()` sans file-lock `msvcrt.locking`
(présent en V5). Double-clic testeur → 2 instances, 2 chargements ONNX, 2 écritures dans
le même dossier sortie.
- **Fix** : réintroduire le lock V5 + message « application déjà ouverte ».
---
## P1 — Forte friction testeur / écarts (à arbitrer pour la bêta)
- **P1-1 — Dropzone décorative** (`tab_usage.py:103-115`) : zone « déposez vos fichiers »
sans glisser-déposer réel. Confusion dès la 1re étape. → câbler DnD (tkinterdnd2) ou
retirer la métaphore.
- **P1-2 — 7 toggles « Données à détecter » non câblés** (`tab_config.py:351-357`) : les
interrupteurs (Noms, DDN, Établissements…) n'ont ni variable ni callback → aucun effet.
Promesse UI non tenue. → câbler au moteur ou rendre informatifs (lecture seule).
- **P1-3 — Import/Export config désactivés « à venir »** (`tab_config.py:817-840`) alors
que le Partage est mis en avant. Régression vs workflow email V5. → câbler ou retirer.
- **P1-4 — `config_path` jamais transmis** (`app.py:155-162`, `tab_usage.py:53,176`) : le
`dictionnaires.yml` externe éditable n'est pas garanti chargé en frozen (pas de copie au
1er lancement). Personnalisations établissement potentiellement ignorées.
- **P1-5 — Erreurs/quarantaine sans pointeur** (`tab_usage.py:268,282`) : « échec » /
« quarantaine » sans indiquer où est le doc non livré (`_*_failed/`). → afficher le
chemin sortie + bouton « ouvrir le dossier ».
- **P1-6 — Pas de validation d'inscriptibilité du dossier de sortie** (`processing_runner.py:214`) :
dossier en lecture seule → tout le lot échoue par doc avec message cryptique. → test
d'écriture en amont.
- **P1-7 — Version app incohérente** : `gui_v6/__init__.py:11` = `6.0.0-g1` vs artefact/note
bêta `2026.06.18.1203` (remonte aussi en télémétrie). → aligner sur le schéma de release.
- **P1-8 — Runbook non exécutable tel quel** : republier impose un rebuild après P0-2 (le
SHA de `note-beta-client.md` deviendra caduc). → ajouter étape « vérifier URL portail
embarquée » au runbook.
- **P1-9 — Clé de signature licence = clé dev auto-générée** (`app_aivanov/app/signing.py`) :
KEY_ID « dev », paire RSA régénérée si volume recréé → licences invalidées. → provisionner
une clé prod stable avant d'activer la vérif de signature côté client.
- **P1-10 — Politiques VM (D-20.2) / dossiers partagés (D-20.3) non implémentées** (décision
Dom, marquées « DECISION REQUIS » dans D-20). Acceptable de différer en bêta interne — à acter.
- **P1-11 — Audit JSONL `original="docTR"`** (`anonymizer_core_refactored_onnx.py:5356`) :
trace de provenance périmée (OCR = OnnxTR) visible dans le livrable d'audit. → `"OnnxTR"`.
---
## P2 — Polish (best-effort)
- **P2-1 — Étapes de progression statiques** (`tab_usage.py:144-147`) : pastilles
Extraction/Détection/Masquage/PDF décoratives ; pas de feedback intra-document.
- **P2-2 — Cartes format de sortie décoratives** (`tab_usage.py:118-127`) : suggèrent un
choix alors que la sortie est toujours PDF+TXT. → présenter comme « sorties produites ».
- **P2-3 — Commentaires/docstrings docTR périmés** (`anonymizer_core_refactored_onnx.py:55,102`) :
cosmétique, comportement OK.
- **P2-4 — `os_info` non envoyé à l'activation** (`license_client.py:161-164`) : perte d'info
diagnostique mineure.
- **P2-5 — Contact / canal de retour vide** dans `docs/beta/note-beta-client.md:36`.
- **P2-6 — `/api/v1/version` ne déclenche pas de download** (`tab_about.py:164-178`) : le
testeur va manuellement sur le portail. Acceptable bêta.
---
## Verdicts positifs (ne pas re-traiter)
- **Télémétrie RGPD : irréprochable.** Double filtrage liste-blanche (`usage_telemetry.py:72`,
`_ALLOWED_DOC_KEYS`) + recalcul serveur Pydantic strict. Aucun nom/chemin/texte ne peut
fuiter. Non bloquante (thread daemon, timeout 4 s, spool best-effort). App utilisable
hors-ligne.
- **Pipeline cœur fail-closed correct** : quarantaine moteur → échec, `SEUIL_RESCAN_RESIDUEL=0`,
`redact_pdf_raster` pas dans un `except: pass`. La fuite P0-1 vient du **wiring GUI**, pas du cœur.
- **engine_bridge / affichage moteurs** : logique « n'afficher que les moteurs embarqués »
saine et cohérente avec OnnxTR (OCR) + CamemBERT-bio (NER onnxruntime). 0 référence docTR
dans les chaînes affichées à l'utilisateur.
- **Câblage `ONNXTR_CACHE_DIR` frozen** : chemins concordants, fail-closed si OnnxTR absent.
- **Portail déployé et fonctionnel** : `app.aivanov.eu` HTTPS, artefact servi, download
authentifié (licence active requise). Le serveur n'est pas le problème — le client n'y est
pas connecté.
---
## Décompte : P0 = 7 · P1 = 11 · P2 = 6
## Synthèse pour décision
Le « complet pour prod bêta » est **plus que rebuild + ship** : la chaîne prod est
aujourd'hui **non fonctionnelle bout-en-bout** (P0-2 localhost, P0-6 licence décorative),
il y a un **risque PII #1** (P0-1 fail-open), et le **2 Go n'est pas acquis** (P0-3 optimum).
Rien de rédhibitoire : findings nets, fixes ciblés. C'est un vrai chantier de finition +
câblage, pas une réécriture.

View File

@@ -0,0 +1,649 @@
# GUI V6 bêta — Plan 1a : socle sûreté & chaîne prod
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rendre la GUI V6 sûre (fail-close PII) et réellement connectée à la chaîne de prod (portail, binding licence, stabilité frozen, log fichier) — sans toucher au cœur de détection ni à l'UI.
**Architecture:** 6 corrections ciblées, toutes dans `gui_v6/` + l'entrée frozen `Pseudonymisation_Gui_V6.py`. Chaque fix est testable en `pytest` sur Linux (sauf confirmation frozen de P0-5/P0-7 au smoke EXE, hors de ce plan). On suit le contrat existant : factories injectables, sessions HTTP injectables, aucun appel réseau en test.
**Tech Stack:** Python 3.10-3.12, pytest, `logging` stdlib + loguru (best-effort), `ctypes`/`msvcrt` (Windows), `fcntl` (POSIX).
**Référence spec :** `docs/superpowers/specs/2026-06-25-gui-v6-beta-prod-design.md` (chantiers A, B, E1).
---
### Task 1 : URL portail réelle (P0-2)
**Files:**
- Modify: `gui_v6/app.py:12-24` (imports), `:41` (défaut client), `:199` (fallback télémétrie)
- Test: `tests/unit/test_gui_v6_portal_url.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/unit/test_gui_v6_portal_url.py
import pytest
def test_default_portal_url_is_prod(monkeypatch):
monkeypatch.delenv("ANON_PORTAL_URL", raising=False)
from gui_v6.app import DEFAULT_PORTAL_URL, resolve_portal_url
assert DEFAULT_PORTAL_URL == "https://app.aivanov.eu"
assert resolve_portal_url() == "https://app.aivanov.eu"
def test_portal_url_env_override(monkeypatch):
monkeypatch.setenv("ANON_PORTAL_URL", "http://localhost:8088")
from gui_v6.app import resolve_portal_url
assert resolve_portal_url() == "http://localhost:8088"
```
- [ ] **Step 2 : Lancer le test pour le voir échouer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v`
Expected: FAIL — `ImportError: cannot import name 'DEFAULT_PORTAL_URL'`.
- [ ] **Step 3 : Implémenter**
Dans `gui_v6/app.py`, ajouter `import os` en tête (sous `from __future__ import annotations`), puis après les imports (avant `_TABS`) :
```python
DEFAULT_PORTAL_URL = "https://app.aivanov.eu"
def resolve_portal_url() -> str:
"""URL du portail : env ``ANON_PORTAL_URL`` sinon défaut prod."""
return os.environ.get("ANON_PORTAL_URL", DEFAULT_PORTAL_URL)
```
Remplacer la ligne 41 :
```python
self._license_client = license_client or LicenseClient(resolve_portal_url())
```
Remplacer le fallback de la ligne 199 :
```python
base_url = getattr(self._license_client, "_base_url", "") or resolve_portal_url()
```
- [ ] **Step 4 : Lancer le test pour le voir passer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_portal_url.py -v`
Expected: PASS (2 tests).
- [ ] **Step 5 : Commit**
```bash
git add gui_v6/app.py tests/unit/test_gui_v6_portal_url.py
git commit -m "fix(gui): connecter la GUI V6 au portail prod (P0-2, plus localhost)"
```
---
### Task 2 : Fail-close si le NER obligatoire est indisponible (P0-1)
**Files:**
- Modify: `gui_v6/engine_bridge.py:36-41` (nouvelle exception), `:222-231` (`process_fn`)
- Test: `tests/unit/test_gui_v6_engine_failclose.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/unit/test_gui_v6_engine_failclose.py
from pathlib import Path
import pytest
from gui_v6.engine_bridge import (
EngineSettings,
EngineUnavailableError,
NerManagers,
make_process_fn,
)
def _managers_with_broken_camembert(settings):
def boom():
raise RuntimeError("model.onnx absent")
return NerManagers(
settings,
factories={"camembert": boom, "eds": boom, "gliner": boom},
caps_provider=lambda: {},
)
def test_process_fn_raises_when_mandatory_ner_unavailable():
settings = EngineSettings(use_local_ner=True)
managers = _managers_with_broken_camembert(settings)
called = {"engine": False}
def fake_engine(*a, **k):
called["engine"] = True
return {"pdf": "x"}
fn = make_process_fn(settings, managers=managers, engine=fake_engine)
with pytest.raises(EngineUnavailableError):
fn(Path("doc.pdf"), Path("/tmp/out"))
# Le moteur ne doit JAMAIS être appelé → aucune sortie possible.
assert called["engine"] is False
def test_process_fn_runs_when_ner_ok():
settings = EngineSettings(use_local_ner=True)
managers = NerManagers(
settings,
factories={"camembert": lambda: object(), "eds": lambda: None, "gliner": lambda: None},
caps_provider=lambda: {},
)
fn = make_process_fn(settings, managers=managers, engine=lambda *a, **k: {"pdf": "ok"})
assert fn(Path("d.pdf"), Path("/tmp/out")) == {"pdf": "ok"}
```
- [ ] **Step 2 : Lancer le test pour le voir échouer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v`
Expected: FAIL — `ImportError: cannot import name 'EngineUnavailableError'`.
- [ ] **Step 3 : Implémenter**
Dans `gui_v6/engine_bridge.py`, après la classe `ManagerState` (l.41) :
```python
class EngineUnavailableError(RuntimeError):
"""Levée quand un moteur de détection OBLIGATOIRE n'a pas pu être chargé.
Garantit le fail-close : on refuse de produire une sortie plutôt que de
livrer un document potentiellement non anonymisé (aligné sur le code 3 du CLI).
"""
```
Remplacer le corps de `process_fn` (l.222-231) :
```python
def process_fn(doc_path: Path, out_dir: Path) -> dict:
if settings.use_local_ner:
state = managers.ensure_loaded()
if state == ManagerState.UNAVAILABLE:
raise EngineUnavailableError(
"Modèle de détection obligatoire (CamemBERT-bio) indisponible — "
"traitement refusé pour éviter une anonymisation incomplète."
)
kwargs = build_engine_kwargs(settings, managers)
run = engine
if run is None:
from anonymizer_core_refactored_onnx import process_document
run = process_document
return run(doc_path, out_dir, **kwargs)
```
- [ ] **Step 4 : Lancer le test pour le voir passer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_engine_failclose.py -v`
Expected: PASS (2 tests). Le runner (`processing_runner._run_impl:221`) attrape déjà toute `Exception` → le doc est compté **échec** et non livré.
- [ ] **Step 5 : Vérifier la non-régression du runner**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_processing_runner.py -v`
Expected: PASS (inchangé).
- [ ] **Step 6 : Commit**
```bash
git add gui_v6/engine_bridge.py tests/unit/test_gui_v6_engine_failclose.py
git commit -m "fix(gui): fail-close si CamemBERT-bio indisponible (P0-1, anti-fuite PII)"
```
---
### Task 3 : Binding licence ↔ poste (souple, affichage) (P0-6)
**Files:**
- Modify: `gui_v6/app.py` (imports + `_safe_local_status`), nouvelle fonction `bound_local_status`
- Test: `tests/unit/test_gui_v6_license_binding.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/unit/test_gui_v6_license_binding.py
from gui_v6.app import bound_local_status
from gui_v6.license_client import LicenseStatus
def test_binding_flags_other_machine():
st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111")
out = bound_local_status(st, "BBBB2222")
assert out.valid is False
assert out.status == "autre_poste"
assert "autre poste" in out.message.lower()
def test_binding_ok_same_machine():
st = LicenseStatus(valid=True, status="active", machine_id="AAAA1111")
out = bound_local_status(st, "AAAA1111")
assert out.valid is True
assert out.status == "active"
def test_binding_noop_without_machine_id():
# licence locale sans machine_id (ancien payload) → inchangée, pas de blocage.
st = LicenseStatus(valid=True, status="active", machine_id=None)
assert bound_local_status(st, "AAAA1111").valid is True
```
- [ ] **Step 2 : Lancer le test pour le voir échouer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v`
Expected: FAIL — `ImportError: cannot import name 'bound_local_status'`.
- [ ] **Step 3 : Implémenter**
Dans `gui_v6/app.py`, ajouter en tête l'import `from gui_v6.machine_id import default_machine_id` (sous l'import de `license_client`). Ajouter au niveau module (après `resolve_portal_url`) :
```python
def bound_local_status(status: LicenseStatus, local_machine_id: str) -> LicenseStatus:
"""Annoter le statut licence selon le binding poste.
Souple (décision D1) : on N'EMPÊCHE PAS le traitement. Si la licence locale
est valide mais liée à un autre ``machine_id`` que le poste courant (ex.
``license.json`` copié), on le **signale** par un statut non valide d'affichage.
"""
if status.valid and status.machine_id and status.machine_id != local_machine_id:
return LicenseStatus(
valid=False,
status="autre_poste",
message="Licence liée à un autre poste",
expires_at=status.expires_at,
grace_days=status.grace_days,
machine_id=status.machine_id,
license_ref=status.license_ref,
)
return status
```
Remplacer `_safe_local_status` (l.81-85) :
```python
def _safe_local_status(self) -> LicenseStatus:
try:
status = self._license_client.local_status()
return bound_local_status(status, default_machine_id())
except Exception:
return LicenseStatus.unavailable()
```
- [ ] **Step 4 : Lancer le test pour le voir passer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_license_binding.py -v`
Expected: PASS (3 tests).
- [ ] **Step 5 : Commit**
```bash
git add gui_v6/app.py tests/unit/test_gui_v6_license_binding.py
git commit -m "feat(gui): binding licence-poste souple (P0-6/D-20.4, affichage sans blocage)"
```
---
### Task 4 : Log fichier V6 à chemin connu (E1)
**Files:**
- Create: `gui_v6/logging_setup.py`
- Test: `tests/unit/test_gui_v6_logging_setup.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/unit/test_gui_v6_logging_setup.py
import logging
def test_setup_file_logging_writes_to_known_path(tmp_path, monkeypatch):
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
from gui_v6.logging_setup import setup_file_logging
log_path = setup_file_logging()
assert log_path.parent.exists()
logging.getLogger("test.e1").warning("ligne-temoin-42")
for h in logging.getLogger().handlers:
h.flush()
assert "ligne-temoin-42" in log_path.read_text(encoding="utf-8")
```
- [ ] **Step 2 : Lancer le test pour le voir échouer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'gui_v6.logging_setup'`.
- [ ] **Step 3 : Implémenter**
```python
# gui_v6/logging_setup.py
"""Configuration du log fichier de la GUI V6 (E1).
Sans ceci, la GUI frozen fenêtrée (sans console) perd ses logs de diagnostic.
Le log est posé dans le même répertoire applicatif que la licence
(``%LOCALAPPDATA%/Aivanov/Anonymisation``) pour faciliter sa récupération (E2/E3).
"""
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
_CONFIGURED = False
def _app_data_dir() -> Path:
base = os.environ.get("LOCALAPPDATA")
if base:
root = Path(base)
else: # Linux/dev
root = Path.home() / ".local" / "share"
return root / "Aivanov" / "Anonymisation"
def log_file_path() -> Path:
return _app_data_dir() / "logs" / "anonymisation.log"
def setup_file_logging() -> Path:
"""Configure un handler fichier rotatif sur le logger racine. Idempotent."""
global _CONFIGURED
path = log_file_path()
if _CONFIGURED:
return path
path.parent.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
str(path), maxBytes=2_000_000, backupCount=3, encoding="utf-8"
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.addHandler(handler)
# Best-effort : si le cœur utilise loguru, on ajoute aussi un sink fichier.
try:
from loguru import logger as _loguru
_loguru.add(str(path), rotation="2 MB", retention=3, encoding="utf-8")
except Exception:
pass
_CONFIGURED = True
return path
```
- [ ] **Step 4 : Lancer le test pour le voir passer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_logging_setup.py -v`
Expected: PASS.
- [ ] **Step 5 : Commit**
```bash
git add gui_v6/logging_setup.py tests/unit/test_gui_v6_logging_setup.py
git commit -m "feat(gui): log fichier rotatif V6 à chemin connu (E1)"
```
---
### Task 5 : Stabilité frozen — flag legacy ONNX au plus tôt (P0-5)
**Files:**
- Modify: `Pseudonymisation_Gui_V6.py:12-15` (en-tête) + `main` (appel logging)
- Test: `tests/unit/test_gui_v6_entry_frozen_flag.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/unit/test_gui_v6_entry_frozen_flag.py
import os
import importlib
def test_entry_sets_legacy_onnx_flag_on_import(monkeypatch):
monkeypatch.delenv("ANON_SKIP_LEGACY_ONNX_MANAGER", raising=False)
import Pseudonymisation_Gui_V6 as entry
importlib.reload(entry)
assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "1"
def test_entry_does_not_override_explicit_flag(monkeypatch):
monkeypatch.setenv("ANON_SKIP_LEGACY_ONNX_MANAGER", "0")
import Pseudonymisation_Gui_V6 as entry
importlib.reload(entry)
assert os.environ.get("ANON_SKIP_LEGACY_ONNX_MANAGER") == "0"
```
- [ ] **Step 2 : Lancer le test pour le voir échouer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v`
Expected: FAIL — le flag n'est pas posé.
- [ ] **Step 3 : Implémenter**
Dans `Pseudonymisation_Gui_V6.py`, juste après `import sys` (l.14), **avant toute autre logique** :
```python
import os
# Frozen Windows : désactiver le manager ONNX legacy AVANT tout import du cœur,
# pour éviter « cannot load module more than once per process » (hotfix CLI 6c6f653).
os.environ.setdefault("ANON_SKIP_LEGACY_ONNX_MANAGER", "1")
```
Dans `main()` (avant `from gui_v6.app import AnonymisationApp`, l.55), initialiser le log fichier :
```python
from gui_v6.logging_setup import setup_file_logging
setup_file_logging()
```
- [ ] **Step 4 : Lancer le test pour le voir passer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_entry_frozen_flag.py -v`
Expected: PASS (2 tests).
- [ ] **Step 5 : Vérifier le self-test**
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test`
Expected: `GUI V6 self-test OK`, exit 0.
- [ ] **Step 6 : Commit**
```bash
git add Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_entry_frozen_flag.py
git commit -m "fix(gui): flag legacy ONNX + log fichier dès l'entrée frozen (P0-5/E1)"
```
---
### Task 6 : Instance unique + mutex partagé installeur (P0-7)
**Files:**
- Create: `gui_v6/single_instance.py`
- Modify: `Pseudonymisation_Gui_V6.py:main`
- Test: `tests/unit/test_gui_v6_single_instance.py`
- [ ] **Step 1 : Écrire le test qui échoue**
```python
# tests/unit/test_gui_v6_single_instance.py
import pytest
from gui_v6.single_instance import (
APP_MUTEX_NAME,
AlreadyRunningError,
SingleInstance,
)
def test_mutex_name_is_stable():
# Nom partagé avec l'installeur (Inno AppMutex). Ne pas changer sans MAJ .iss.
assert APP_MUTEX_NAME == "AivanonymAnonymisationV6"
def test_second_instance_is_rejected(tmp_path, monkeypatch):
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
first = SingleInstance()
first.acquire()
try:
with pytest.raises(AlreadyRunningError):
SingleInstance().acquire()
finally:
first.release()
def test_release_allows_reacquire(tmp_path, monkeypatch):
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
a = SingleInstance()
a.acquire()
a.release()
b = SingleInstance()
b.acquire() # ne lève pas
b.release()
```
- [ ] **Step 2 : Lancer le test pour le voir échouer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'gui_v6.single_instance'`.
- [ ] **Step 3 : Implémenter**
```python
# gui_v6/single_instance.py
"""Protection multi-instance de la GUI V6 (P0-7).
- Windows (frozen) : mutex nommé kernel via ctypes — c'est CE nom que l'installeur
Inno détecte (``AppMutex``) pour fermer l'app avant une mise à jour (D8).
- POSIX (dev/test) : verrou ``fcntl`` exclusif sur un fichier dans le dossier app.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Nom partagé avec installer/Anonymisation.iss (AppMutex). NE PAS modifier seul.
APP_MUTEX_NAME = "AivanonymAnonymisationV6"
class AlreadyRunningError(RuntimeError):
"""Une autre instance de l'application est déjà en cours d'exécution."""
def _lock_dir() -> Path:
base = os.environ.get("LOCALAPPDATA")
root = Path(base) if base else Path.home() / ".local" / "share"
d = root / "Aivanov" / "Anonymisation"
d.mkdir(parents=True, exist_ok=True)
return d
class SingleInstance:
def __init__(self) -> None:
self._handle = None # mutex Windows
self._fh = None # file handle POSIX
def acquire(self) -> None:
if sys.platform.startswith("win"):
self._acquire_windows()
else:
self._acquire_posix()
def _acquire_windows(self) -> None: # pragma: no cover (exécuté sur Windows)
import ctypes
ERROR_ALREADY_EXISTS = 183
handle = ctypes.windll.kernel32.CreateMutexW(None, False, APP_MUTEX_NAME)
if not handle or ctypes.windll.kernel32.GetLastError() == ERROR_ALREADY_EXISTS:
raise AlreadyRunningError("L'application est déjà ouverte.")
self._handle = handle
def _acquire_posix(self) -> None:
import fcntl
path = _lock_dir() / "instance.lock"
fh = open(path, "w")
try:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
fh.close()
raise AlreadyRunningError("L'application est déjà ouverte.")
self._fh = fh
def release(self) -> None:
if self._handle is not None: # pragma: no cover
import ctypes
ctypes.windll.kernel32.CloseHandle(self._handle)
self._handle = None
if self._fh is not None:
self._fh.close()
self._fh = None
```
- [ ] **Step 4 : Lancer le test pour le voir passer**
Run: `.venv/bin/pytest tests/unit/test_gui_v6_single_instance.py -v`
Expected: PASS (3 tests).
- [ ] **Step 5 : Câbler dans l'entrée**
Dans `Pseudonymisation_Gui_V6.py:main`, après `setup_file_logging()` et avant `AnonymisationApp()` :
```python
from gui_v6.single_instance import AlreadyRunningError, SingleInstance
guard = SingleInstance()
try:
guard.acquire()
except AlreadyRunningError:
try:
import tkinter.messagebox as mb
mb.showinfo("Anonymisation", "L'application est déjà ouverte.")
except Exception:
print("L'application est déjà ouverte.")
return 0
try:
application = AnonymisationApp()
application.mainloop()
finally:
guard.release()
return 0
```
(Remplace les lignes `application = AnonymisationApp()` / `application.mainloop()` / `return 0` existantes.)
- [ ] **Step 6 : Vérifier self-test + suite GUI V6**
Run: `.venv/bin/python Pseudonymisation_Gui_V6.py --self-test && .venv/bin/pytest tests/unit/ -k gui_v6 -q`
Expected: `GUI V6 self-test OK` + suite verte.
- [ ] **Step 7 : Commit**
```bash
git add gui_v6/single_instance.py Pseudonymisation_Gui_V6.py tests/unit/test_gui_v6_single_instance.py
git commit -m "feat(gui): instance unique + mutex partagé installeur (P0-7)"
```
---
## Self-review (couverture spec chantiers A/B/E1)
- P0-1 fail-close → Task 2 ✓ · P0-2 URL → Task 1 ✓ · P0-5 frozen flag → Task 5 ✓ ·
P0-6 binding → Task 3 ✓ · P0-7 lock+mutex → Task 6 ✓ · E1 log fichier → Task 4 (+ câblage Task 5) ✓.
- Hors de ce plan (par décision de découpe) : P1-2 (Plan 1b), P1-1/3/4/5 + P2 (Plan 1c), diagnostics
E2-E4 (Plan 2), build/release C+F (Plan 3).
- Cohérence types : `EngineUnavailableError`, `ManagerState.UNAVAILABLE`, `LicenseStatus(machine_id=…)`,
`default_machine_id()`, `setup_file_logging()`, `SingleInstance/APP_MUTEX_NAME` — tous définis ici et
réutilisés de façon cohérente.
- À confirmer au smoke EXE (Plan 3) : P0-5 (crash frozen réel) et P0-7 (mutex Windows).

View File

@@ -0,0 +1,161 @@
# GUI V6 → prod bêta testeur — design
**Date** : 2026-06-25 · **Branche** : `feature/q1-quarantine-mvp` · **HEAD de départ** : `4b7c8db`
**Statut** : design validé Dom (cadrage + décisions ci-dessous), prêt pour plan d'implémentation.
## 1. Objectif
Amener **GUI V6** (`gui_v6/`) à une **mise en prod bêta testeur** complète : un testeur
non-technique installe l'EXE Windows → **active sa licence** → traite des PDF/images
médicaux → ses **statistiques d'usage remontent au portail** `app.aivanov.eu`, et l'équipe
peut **récupérer ses logs de diagnostic** (scrubbés) pour analyse. Le CLI est mis de côté
(pas abandonné) ; il partage le même core, donc les fixes cœur lui profitent.
## 2. Périmètre
**Dans le périmètre (décisions Dom) :**
- App finie : correction des 7 P0 + des P1 qui trompent le testeur + UI honnête de bout en bout.
- Rebuild Windows OnnxTR / **torch-free** (gain ~2 Go) + smoke.
- Chaîne prod : activation licence bout-en-bout + télémétrie qui remonte réellement + diffusion testée.
- **Diagnostics** : log fichier V6 + scrubbing PII (philosophie liste-blanche) + **auto-upload portail**.
- **Mise à jour propre** : version réelle injectée au build, upgrade en place, app fermée, config préservée.
- **Modèles embarqués dans l'EXE** : présents automatiquement à l'install, hors-ligne (déjà le cas, à fiabiliser).
**Hors périmètre (différé, acté) :**
- Enforcement licence **dur** (blocage), anti-VM (D-20.2/3), fingerprint multi-composants
(D-20.1), vérification de signature RSA côté client + clé prod stable (P1-9).
- **MAJ in-app auto-download** (l'app télécharge+lance l'installeur) — la MAJ bêta se fait par
téléchargement manuel de l'installeur depuis le portail.
- Onboarding profond : install validée sur site testeur, boucle de retour structurée.
- DnD réel = **inclus** (voir D) ; tout le reste P1 non listé + P2 non listés = itération ultérieure.
## 3. Décisions clés (et pourquoi)
| # | Décision | Justification |
|---|----------|---------------|
| D1 | **Licence souple + binding poste** : activation câblée, statut affiché, vérif locale `machine_id` (D-20.4), **pas de blocage** du traitement. | Valide la chaîne sans bloquer un testeur hors-ligne/mal activé. Durcissement post-bêta. |
| D2 | **Toggles « Données à détecter » câblés pour de vrai**, défaut tous ON. | Un contrôle visible non fonctionnel **fausse le test humain**. |
| D3 | **Pas de plancher dur** : décocher une catégorie relâche masquage **et** filet de sécurité pour cette catégorie ; l'utilisateur assume. | Sinon le toggle reste faussé (quarantaine systématique). `EMAIL/IBAN/IPP` (sans toggle) restent toujours masqués = plancher conservé. |
| D4 | **2 Go via `excludes` PyInstaller** ; validation par **taille EXE réelle**. | `optimum[onnxruntime]` tire `torch` en dép cœur ; legacy non câblé (import déjà sous try/except). |
| D5 | **Linux-first TDD, un seul build Windows à la fin** ; P0-5 porte à l'identique le hotfix CLI `6c6f653`. | Minimise les cycles de build chers. |
| D6 | **Rebuild = autonome** (build interne + smoke) ; **diffusion (republish portail) = gate Dom**. | Cadrage d'autonomie. |
| D7 | **Logs auto-upload scrubbé** : log fichier V6 rotatif + diagnostics **structurés liste-blanche** (type d'exception, étape, code) + redaction ciblée des identifiants de doc connus, envoyés au portail (endpoint `/diagnostics`, auth licence, non bloquant). | Récupération auto pour analyse sans déranger le testeur ; RGPD garanti par la **même philosophie que la télémétrie** (whitelist), pas par un scrub a posteriori d'un log brut. |
| D8 | **MAJ = installeur propre** : version réelle injectée au build, AppId fixe (upgrade en place), `CloseApplications`, config/licence préservées (hors dossier install). | Suffit en bêta ; MAJ in-app auto-download différée. |
## 4. Chantiers
### A. Sûreté & robustesse cœur
- **P0-1 — Fail-close GUI sur NER indisponible.** `gui_v6/engine_bridge.py:make_process_fn`
(210-233) : capturer l'état de `ensure_loaded()` ; si `use_local_ner` et état `UNAVAILABLE`
(CamemBERT-bio non chargé, `:173-175`) → lever `EngineUnavailableError`. Le runner la remonte
en **échec** (doc non livré). Réplique la garantie code-3 du CLI (`scripts/anonymize_cli.py:184-198`).
*Test* : factory CamemBERT qui throw → 0 fichier en sortie + statut échec.
- **P0-5 — Frozen ONNX.** `Pseudonymisation_Gui_V6.py:main` (50-58, entrée frozen confirmée
`anonymisation_gui_v6_onefile.spec:145`) pose `os.environ.setdefault("ANON_SKIP_LEGACY_ONNX_MANAGER","1")`
**avant** `from gui_v6.app import AnonymisationApp` (l.55), et pré-charge CamemBERT. *Test* : ordre
d'init unitaire ; **confirmation au smoke EXE** (non reproductible en dev Linux).
- **P0-7 — Lock multi-instance.** File-lock `msvcrt.locking` (Windows) au début de `main()`,
message « application déjà ouverte ». Le nom du mutex est **partagé avec l'installeur** (D8/AppMutex).
*Test* : unit mock.
### B. Chaîne prod — licence + télémétrie
- **P0-2 — URL portail.** `DEFAULT_PORTAL_URL = "https://app.aivanov.eu"` injectée à l'entrypoint
(override `ANON_PORTAL_URL`) ; `gui_v6/app.py:41` `localhost` devient secours dev. Télémétrie **et
diagnostics** héritent de l'URL (`app.py:199`). *Test* : entrypoint construit le client sur l'URL prod.
- **P0-6 — Licence souple + binding (D1).** Activation câblée bout-en-bout. Au démarrage :
`local_status()` + **comparer `payload.machine_id` au `machine_id` local** ; si mismatch → statut
« licence liée à un autre poste » **affiché**, traitement **non bloqué**. *Test* : `license.json`
copié (machine_id ≠ local) → statut invalide, traitement fonctionne.
### C. Build torch-free reproductible + modèles embarqués
- **P0-3 — 2 Go (D4).** `excludes=['torch','torchvision','optimum','optimum.onnxruntime','doctr']`
aux 3 specs ; `optimum[onnxruntime]` en optionnel commenté dans `requirements.txt`. **Validation =
taille EXE réelle + grep arbo PyInstaller pour `torch`**.
- **P0-4 — Précache OnnxTR + modèles embarqués (#2).** Étape avant PyInstaller dans
`scripts/build_windows_oneclick.ps1` : `ocr_predictor(det_arch='db_resnet50', reco_arch='crnn_vgg16_bn')`
(télécharge les 2 poids). Confirme que le spec embarque bien CamemBERT-bio ONNX + poids OnnxTR
(`anonymisation_gui_v6_onefile.spec` `datas`) → **modèles présents à l'install, hors-ligne, sans
téléchargement** (aucun chemin de download runtime dans `gui_v6/` — vérifié). Doc build mise à jour.
### D. Honnêteté UI (anti-faussé)
- **P1-2 — Toggles « Données à détecter » câblés (D2/D3).** 7 booléens (Noms→`NOM`,
DDN→`DATE_NAISSANCE`, Établissements→`ETAB`, Adresses→`ADRESSE`, NIR→`NIR`, Téléphones→`TEL`,
N° adhérent→`ADHERENT`), défaut tous ON, fil : `ConfigState``EngineSettings` → kwargs →
`process_document``cfg` (core:2934/5678). Le moteur **gate le masquage par type** ; une catégorie
décochée n'est plus masquée. **Couplage sécurité (D3)** : pour les catégories décochées du rescan
résiduel (`CRITICAL_PII_KEYS`, core:610 — NIR, TEL, DATE_NAISSANCE), le **rescan est relâché**
(pas de fausse quarantaine). `EMAIL/IBAN/IPP` restent toujours masqués. *Tests* : (a) décocher `ETAB`
→ établissement non masqué, autres masqués ; (b) décocher `NIR` → NIR en clair + pas de quarantaine ;
(c) tous ON → non-régression. **Revue Qwen obligatoire (code sécurité).**
- **P1-1 — Glisser-déposer réel** via `tkinterdnd2` (`tab_usage.py:103-115`) + boutons conservés.
- **P1-3 — Import/Export config câblés** (`tab_config.py:817-840`), logique merge V5 (`scripts/merge_params.py`).
- **P1-4 — `config_path` frozen** : résoudre `dictionnaires.yml` à côté de `sys.executable`, copie au
1er lancement, passé en `config_path`.
- **P1-5 — Erreurs/quarantaine lisibles** : chemin sortie + mention `_*_failed/` + bouton « ouvrir le dossier ».
- **P2-1 / P2-2 (bonus)** : étapes de progression et cartes format **honnêtes** (fonctionnelles ou
visiblement non-cliquables).
### E. Diagnostics & logs (#1, D7)
- **E1 — Log fichier V6.** Configurer un log loguru rotatif à chemin connu
(`%LOCALAPPDATA%/Aivanov/Anonymisation/logs/anonymisation.log`) dans `Pseudonymisation_Gui_V6.py`
**avant** tout import (combiné à l'ordre P0-5). Aujourd'hui V6 n'a **aucun** log fichier → en frozen
fenêtré les diagnostics sont perdus. *Test* : le log est créé au chemin attendu.
- **E2 — Diagnostics structurés liste-blanche (RGPD).** Un module `diagnostics` qui produit des
**événements structurés** (type d'exception, étape moteur, code, durée, version, `run_id`,
`machine_id`) — **jamais** de texte de document ni de contenu d'erreur libre. Les identifiants de
documents (noms/chemins connus en entrée) sont **redactés** (→ ordinal/hash) partout. Même
philosophie que `usage_telemetry._ALLOWED_*`. *Tests* : un run avec noms de fichiers PII → 0
nom/chemin/texte dans la charge utile diag.
- **E3 — Upload auto.** Client diagnostic (réutilise l'auth licence/`machine_id` comme la télémétrie),
`POST /api/v1/diagnostics/report`, **non bloquant** (thread daemon, spool best-effort, timeout court).
Hérite de l'URL portail (P0-2). *Test* : échec réseau → traitement non figé, spool écrit.
- **E4 — Portail (`app_aivanov`, repo séparé).** Endpoint `/api/v1/diagnostics/report` (Pydantic strict,
auth licence/seat, recalcul/validation serveur), stockage, vue admin « Diagnostics par client ».
Migration alembic. *Tests web.* **Revue Qwen RGPD.**
### F. Release, mise à jour & diffusion
- **P1-7 + D8 — Version & installeur.** Injecter la vraie version release (schéma `2026.MM.DD.HHMM`,
depuis `build_info`) dans `gui_v6/__init__.py` **et** dans `installer/Anonymisation.iss` (`AppVersion`,
`VersionInfoVersion`) — aujourd'hui codée en dur `"1.0.0"` / `"6.0.0-g1"` (3 schémas incohérents).
- **D8 — MAJ propre.** `Anonymisation.iss` : AppId fixe déjà présent (upgrade en place) ; ajouter
`CloseApplications=yes` + `AppMutex=<nom partagé avec P0-7>` pour fermer l'app avant remplacement de
l'EXE. Licence/config en `%LOCALAPPDATA%/Aivanov/Anonymisation/` (hors `{app}`) → **préservées**
(vérifié). *Test/checklist* : installer N+1 sur N préserve licence+config, version affichée correcte.
- Rebuild Windows GUI V6 (192.168.1.11 via ssh-windows) ; **smoke** : `--self-test` ; 1 PDF natif ;
1 PDF scanné (`ocr_used=True`) ; **fail-close vérifié** ; **taille EXE mesurée** (2 Go ?) ; cycle
MAJ N→N+1 testé.
- **Runbook** `docs/beta/runbook-portail-beta.md` finalisé (+ étape « vérifier URL portail embarquée ») ;
**republish portail + MAJ SHA dans `note-beta-client.md` = gate Dom**.
## 5. Stratégie de tests & non-régression
- TDD sur tout le testable Linux : nouveaux `tests/unit/test_gui_v6_*` + extension core (gating par type)
+ tests diagnostics/scrubbing. Non-régression suite existante (222+ tests) + gate `synthetic_regression`.
- Revue **Qwen** sur les fixes sécurité (P0-1, P0-6, P1-2 gating+rescan) **et RGPD** (F2/F4 diagnostics).
- Smoke EXE pour les concerns frozen-only (P0-4, P0-5, taille, cycle MAJ).
## 6. Critères d'acceptation (definition of done)
1. Aucune sortie produite si le NER obligatoire échoue (fail-close GUI = CLI).
2. Activation licence fonctionne contre `app.aivanov.eu` ; binding correct ; **non bloquant**.
3. Télémétrie d'un run réel **visible au portail** (RGPD : compteurs only).
4. Les 7 toggles modifient **réellement** la sortie ; tous ON = non-régression ; décoché = relâché.
5. Import/export, DnD, messages d'erreur : **fonctionnels** (zéro contrôle factice).
6. **Diagnostic** d'un run **visible au portail**, contenant **0 nom/chemin/texte de document** (RGPD vérifié).
7. **Modèles** présents dans l'EXE : OCR + NER fonctionnent **hors-ligne** post-install, sans téléchargement.
8. **MAJ N→N+1** : upgrade propre, licence + config préservées, app fermée pendant la MAJ, version correcte.
9. EXE rebuildé : self-test OK, OCR scanné OK, **taille ~2 Go confirmée**, 0 « cannot load module ».
10. Suite verte, 0 régression, revue Qwen GO (sécurité + RGPD).
11. Diffusion portail = **sur GO Dom** uniquement.
## 7. Risques & points différés
- **R1** — P1-2 touche le cœur sécurité : risque de régression de masquage. Mitigation : TDD + gate
`synthetic_regression` + revue Qwen ; défaut tous-ON identique à l'existant.
- **R2** — Binding `machine_id` MAC-seule **faible** (contournable, fragile en VM). Accepté en souple ;
durcissement (D-20.1) différé post-bêta — **signalé**.
- **R3** — P0-5 (crash frozen) non confirmable en dev : **dépend du smoke EXE**. Mitigation : portage
identique du hotfix CLI connu-bon.
- **R4** — Sans signature vérifiée côté client (différé), le binding `machine_id` est éditable à la main :
protège de la copie naïve, pas d'un utilisateur déterminé. Acceptable bêta.
- **R5** — **RGPD diagnostics** : un log technique peut fuiter de la PII. Mitigation = événements
**structurés liste-blanche** (pas de texte libre) + redaction des identifiants de doc + revue Qwen RGPD +
défaut conservateur (dans le doute, on ne remonte pas la ligne). C'est le risque RGPD principal du lot.
- **Différé** : MAJ in-app auto-download, enforcement dur, anti-VM, signature client, reste P1/P2.