# Infrastructure cloud — document d'écarts cible ↔ existant

> Instruction menée le **21/08/2026**, en lecture seule, en application de
> [`INFRA_CLOUD.md`](./INFRA_CLOUD.md) (cadrage du même jour) et du skill
> `instruction-sujet`. **Rien n'a été développé.** Ce document mesure, chiffre les
> écarts, et formule l'arbitrage qui appartient au produit.

## Mandat

Aucun document de cadrage d'orchestration n'existe (`docs/process/cadrage/` :
absent). Le mandat de cette instruction est donc celui donné en clair le 21/08 :
**instruire, produire un document d'écarts et une issue par écart, ne rien
développer avant arbitrage.** Le périmètre est celui des trois questions de
`INFRA_CLOUD.md` : stack technique, secrets, accès serveur.

## Méthode et portée des mesures

Toute affirmation ci-dessous est adossée à une mesure datée du 21/08, rejouable.
Les dépôts mesurés sont `EFEKTIVACADEMIE-BACK` et `EFEKTIVACADEMIE-FRONT`, sur
`development`. **Aucune valeur de secret n'a été lue ni transcrite** : les
mesures portent sur la présence, la longueur et la classe des valeurs, jamais sur
leur contenu.

---

## Le résultat en une phrase — et sa borne

**L'inventaire demandé par le cadrage (« ordre proposé », étape 1) est fait : la
surface de secrets à exposer est nulle _pour ce qu'un fil fait en session_** —
coder, jouer les suites, jouer le lint, pousser. L'arbitrage de gouvernance
annoncé comme bloquant (étape 3) porte donc, en l'état, sur un ensemble vide.

### ⚠️ La borne, à ne pas perdre

**« Nulle pour ce qu'un fil fait en session » ne veut pas dire « le projet n'a pas
de secrets ».** Il en a, ils sont réels, et ils vivent **sur les serveurs** — pas
sur le poste de développement, pas dans le dépôt, pas dans la CI. Mesuré sur
staging le 21/08 (noms de variables uniquement, **aucune valeur lue**) :

| Variable | Poste local | **Staging** |
|---|---|---|
| `DB_PASSWORD` | vide | **renseigné** |
| `MAIL_USERNAME` | vide | **renseigné** |
| `MAIL_PASSWORD` | vide | **renseigné — 90 caractères, la clé SMTP Brevo** |

Cette mesure **renforce** la conclusion au lieu de l'affaiblir : elle montre que
les vrais secrets ont déjà un endroit où vivre, que cet endroit n'est pas celui
où travaille un fil, et donc qu'**ils n'ont aucune raison d'en sortir**.

⚠️ **Précision acquise depuis** (voir §« Le risque a déjà été payé ») : cette clé
`MAIL_PASSWORD` de staging est **la même que celle de la production** — les deux
`.env` portent des identifiants identiques. Ce n'est donc pas « la clé de
staging », c'est la clé de production, déjà partagée. `BACK#316` porte la
correction. La
frontière est nette et elle tient toute seule : le serveur détient les
identifiants de service ; la session détient du code et des tests.

Toute reprise de ce document doit conserver la borne. Sans elle, la phrase
devient dans six mois « on n'a pas de secrets à protéger » — ce qui est **faux**,
et dangereux : la clé SMTP Brevo est partagée entre plusieurs marques.

---

## Axe 1 — Le stack technique

### É1 · Aucun script d'installation d'environnement n'existe

| | |
|---|---|
| **Cible** | Un script d'installation posé au démarrage de chaque session cloud : PHP 8.2 + extensions, composer, Node 24, MySQL. |
| **Existant mesuré** | `.devcontainer/` **absent des deux dépôts**. Aucun script d'amorçage. Le stack n'est décrit nulle part sous forme exécutable — il n'existe que dans les workflows de CI et dans `CLAUDE.md`. |
| **Écart** | Total. C'est le premier verrou : sans lui, une session cloud n'a ni PHP, ni Node, ni base. |
| **Ce qu'il coûte** | Faible, et **la matière existe déjà** : les deux `tests.yml` décrivent le stack exact, extensions comprises. Le script est une transposition, pas une conception. |

**Le stack, tel que la CI le fixe aujourd'hui** — mesuré sur `origin/development`
**après BACK#578** (21/08), qui a changé le moteur de base :

| | BACK | FRONT |
|---|---|---|
| Runtime | PHP **8.2 exactement** | Node **24 exactement** |
| Extensions | `mbstring, dom, curl, xml, bcmath, gd, zip, intl, `**`pdo_mysql`** | — |
| Base | **MySQL 8 — obligatoire, moteur unique** (`efektiv_testing`) | — |
| Gestionnaire | composer v2 | npm (`npm ci`) |
| Amorçage `.env` | `cp .env.example .env` + `key:generate` + `DB_HOST`/`PORT`/`USERNAME=root`/`PASSWORD=` (vide) | **aucun** |

> **Révision du 21/08 (BACK#578).** Ce tableau portait `pdo_sqlite, sqlite3` et
> aucune base. La suite tournait sur SQLite ; elle tourne désormais sur MySQL 8,
> **sans moteur de repli**. Le script d'installation doit donc poser MySQL 8
> **d'emblée**, et non « à la demande » comme ce document le proposait d'abord.
> Voir É2, réécrit.

Trois bornes à ne pas rater, toutes déjà payées une fois :

- **Node 24, pas 25** — `package.json` déclare `engines: node >=20 <25` ; Node 25
  produit **645 faux échecs** `localStorage` (FRONT#32).
- **PHP 8.2, pas la version par défaut** — le poste local tourne en **PHP 8.5.4**
  ; `php@8.2` est installé via Homebrew mais n'est pas le PHP par défaut. Le
  script cloud doit **épingler 8.2**, comme la CI, et ne pas hériter du PHP du
  système.
- **MySQL 8 et pas SQLite** — depuis BACK#578, `phpunit.xml` force
  `DB_CONNECTION=mysql`. Une session sans MySQL ne joue **aucun** test BACK. Le
  nom de base `efektiv_testing` est forcé lui aussi, et c'est une **sécurité** :
  `RefreshDatabase` fait un `migrate:fresh`, sans ce forçage la suite viderait la
  base de développement.

### É2 · MySQL — ~~à poser à la demande~~ → **obligatoire d'emblée** (écart tranché par BACK#578)

> **Cet écart a été résolu pendant l'instruction, et dans le sens inverse de celui
> que ce document proposait.** La version d'origine est conservée ci-dessous
> barrée : la corriger en silence effacerait la trace d'un raisonnement juste sur
> ses mesures mais dépassé par une décision mieux fondée.

**Ce que ce document proposait (21/08, avant #578)** : ~~les suites n'ont pas
besoin de MySQL — `phpunit.xml` forçait `sqlite` / `:memory:` et la CI ne posait
que `pdo_sqlite`. MySQL pouvait donc être installé « à la demande », pour le seul
besoin de vérifier les requêtes non portables.~~

**Ce que BACK#578 a tranché, et pourquoi c'est mieux** : la suite tourne
désormais sur **MySQL 8, moteur unique, sans repli**. Le motif est la **parité
avec la production** — et il est plus fort que mon argument de coût :

- **14 migrations du dépôt portent déjà un aiguillage explicite**
  `getDriverName() === 'mysql'`. Sous SQLite, ces branches n'étaient jamais
  jouées : la suite verte ne prouvait rien sur le code réellement exécuté en prod.
- Les `enum`, la casse, le mode strict et **l'erreur 1093** sont **invisibles
  sous SQLite**. C'est exactement le piège « SQLite ≠ MySQL » déjà vécu.
- Mon comptage de **75 sites de SQL brut** dans `app/` (27 `DB::raw`/`statement`/
  `select`, 48 `whereRaw`/`orderByRaw`/`selectRaw`) mesurait bien le risque —
  mais j'en tirais la mauvaise conclusion. Une zone de risque de cette taille ne
  se vérifie pas « à la demande » : elle se couvre par défaut.

| | |
|---|---|
| **Cible révisée** | MySQL 8 posé **au démarrage de chaque session**, sans quoi aucun test BACK ne tourne. |
| **Existant mesuré (post-#578)** | `phpunit.xml` force `DB_CONNECTION=mysql` et `DB_DATABASE=efektiv_testing`. La CI provisionne un service `mysql:8.0`, attend sa disponibilité, et installe `pdo_mysql`. **Aucun moteur de repli.** |
| **Écart restant** | **Nul sur le principe** — la décision est prise et appliquée. Il ne reste qu'à la **répercuter dans le script d'installation** (É1), qui doit poser MySQL 8 d'emblée. |

**Effet sur le reste du document** : É2 ne porte plus d'écart propre. Son contenu
utile a été versé dans **É1**, dont les critères d'acceptation sont corrigés en
conséquence. **L'issue #574 est fermée** comme dépassée, avec ce motif.

**Ce que #578 ne change pas** : la suite reste **sans aucun secret**.
Le service MySQL de la CI tourne en `root` **sans mot de passe**
(`MYSQL_ALLOW_EMPTY_PASSWORD`), sur `127.0.0.1`, et le workflow écrit
`DB_PASSWORD=` (vide) dans le `.env`. É4 tient donc intégralement.

### É11 · Docker — le chantier existe déjà, instruit le 06/08, et il croise É1 de plein fouet

**Ce document avait manqué une décision déjà écrite.** `docs/roadmap/16_NOTE_DOCKER_CI_LILIAN.md`
(06/08) instruit Docker pour ce dépôt et **recommande Docker pour le local et la
CI, production hors périmètre jusqu'à mi-septembre**. Ne pas la relire aurait
conduit à écrire un script d'installation natif à côté d'un chantier Docker déjà
cadré — exactement le doublon que l'instruction doit éviter.

**État mesuré le 21/08 :**

| | Mesure |
|---|---|
| `laravel/sail` | **Déjà en dépendance** (`composer.json`, `^1.41`) — mais **jamais initialisé** : aucun `docker-compose.yml` au dépôt. `php artisan sail:install` le génère. |
| Fichiers Docker | **Aucun** dans les deux dépôts (ni `Dockerfile`, ni `compose.yml`, ni `.dockerignore`). |
| Docker sur le poste | **Absent du `PATH`.** |
| Côté FRONT | Ni sail, ni Docker, ni équivalent. |

**Ce que la note du 06/08 disait, et ce qu'il en reste** — trois de ses cinq
points sont **périmés, en bien** :

| Point de la note | État au 21/08 |
|---|---|
| §1 « Merger la PR #95 avant Docker » — `migrate:fresh` échouait sur MySQL | ✅ **Levé.** #95 mergée le 07/08 ; la clé étrangère a été déplacée dans `2026_08_05_000001_add_courses_subcategory_foreign_key.php`. |
| §3 « Les tests tournent sur SQLite, les basculer révélera des choses » | ✅ **Fait**, par BACK#578 — et la suite est **verte** sur MySQL 8. La note prévoyait des surprises : il n'y en a pas eu. |
| §4 « 5 échecs `cta_url` antérieurs, référence à connaître » | ✅ **Éteints.** La suite est verte, référence = **0 échec**. |
| §5 « PHP 8.2 obligatoire, à épingler dans l'image » | ⚠️ **Toujours valide** — cohérent avec É1. |
| §7 « Production hors périmètre : 289 Mo de fichiers téléversés, racine Apache atypique, serveur mutualisé » | ⚠️ **Toujours valide**, et hors du périmètre de ce document. |

**Le croisement avec É1.** Ce document proposait un « script d'installation ».
Docker est **un moyen de le faire**, pas un sujet parallèle : `sail:install`
produirait PHP 8.2, MySQL 8 et les extensions en un seul artefact, versionné,
identique en local, en CI et en session cloud. Il rendrait aussi caduque une
partie de É2 (poser MySQL à la main).

### ✅ RÉPONDUE le 22/08 — « oui, mais pas jouable aujourd'hui »

**Mesuré dans deux sessions cloud distinctes (06:56 et 07:03), reproductible au
détail près.** Environnement vérifié avant toute conclusion : `Linux … x86_64`,
Ubuntu 24.04.4, `uid=0(root)`.

**La réponse à la question pivot est OUI** — et elle n'est pas inférée d'un
numéro de version, elle est prouvée par exécution :

| Preuve | Résultat |
|---|---|
| `docker build` d'une image `FROM scratch`, hors réseau | ✅ `DONE`, exit 0 |
| `docker run` de cette image | ✅ **`HELLO DEPUIS UN CONTENEUR DOCKER`** |
| `docker pull` réel depuis le réseau (`mcr.microsoft.com`) | ✅ `Downloaded newer image` |
| `docker run` sur l'image tirée du réseau | ✅ `Hello from Docker!` |

`runc` exécute, `containerd` stocke, `buildx` construit. Docker Compose v5.1.1 et
buildx v0.31.1 sont présents.

**Trois réserves, toutes confirmées par la réplication :**

1. **Le démon n'est pas lancé au démarrage** — `docker run` échoue d'abord sur
   `dial unix /var/run/docker.sock: no such file or directory`. Mais `dockerd`
   est présent (`/usr/bin/dockerd`) et l'on est `root` : `nohup dockerd &` le
   lève **en 2 secondes**. Le script d'installation doit donc le démarrer
   explicitement.
2. **Cgroup v1**, pas v2 — et c'est le **profil de l'environnement**, pas un
   accident d'instance (identique dans les deux sessions). Les images récentes
   qui présupposent cgroup v2 sont à écarter.
3. **⛔ L'allow-list réseau interdit tout registre utile** — voir ci-dessous.
   C'est la réserve qui bloque, et elle est **plus contraignante qu'elle n'en
   avait l'air**.

### ⛔ Le mur réel : les blobs, pas les registres

Une première mesure avait conclu que `ghcr.io` et `public.ecr.aws` « passent ».
**Cette conclusion était fausse, et pour une raison instructive** : elle
s'appuyait sur une sonde `/v2/`, c'est-à-dire sur l'atteignabilité du
**manifeste**. C'est exactement l'erreur contre laquelle ce document met en garde
à propos de `docker --version` — *la sonde d'entrée n'est pas le service*. La
réplication a tiré des images pour de vrai :

| Registre | Manifeste | **Blobs** | Pull réel |
|---|---|---|---|
| Docker Hub | atteignable | `production.cloudfront.docker.com` → **refusé** | ❌ |
| `public.ecr.aws` | atteignable | `d2glxqk2uabbnd.cloudfront.net` → **refusé** | ❌ |
| `ghcr.io` | atteignable | `pkg-containers.githubusercontent.com` → **refusé** | ❌ |
| **`mcr.microsoft.com`** | atteignable | **sert ses blobs lui-même** | ✅ |

**Le mécanisme est le même partout** : le registre sert le manifeste depuis son
propre domaine et **délègue les blobs à un CDN tiers**, absent de l'allow-list.

**Conséquence** : `mcr.microsoft.com` est le seul registre réellement utilisable,
et **il ne publie ni `mysql:8` ni les images `php` officielles** dont Sail a
besoin. **Il n'existe donc aucun contournement par mirror.** La branche « oui »
n'est pas jouable tant que l'allow-list n'est pas élargie.

> **La demande à formuler est plus précise qu'« autoriser Docker Hub »** : elle
> porte sur les **hôtes de blobs**, `production.cloudfront.docker.com` en tête.
> Autoriser `registry-1.docker.io` seul ne changerait rien — il passe déjà — et
> `sail:up` échouerait quand même, avec un message qui se lit comme une panne de
> Docker sans en être une.

### Ce que ça donne, concrètement, pour É1

**Docker est le bon artefact cible, mais il n'est pas disponible aujourd'hui.**
Deux conséquences qui ne demandent aucun arbitrage :

- **À court terme**, É1 doit être un **script d'installation natif** — c'est la
  seule voie qui fonctionne en session cloud maintenant.
- **À terme**, si l'allow-list s'élargit, cet artefact bascule vers Docker et
  rejoint le chantier du 06/08.

#### ⚠️ Correction du 22/08 — « hors des bornes de la CI » ≠ « hors des bornes du projet »

Ce document écrivait que le stack de la session était hors bornes sur trois
points. **C'était une lecture trop rapide** : la CI **épingle** des versions
exactes pour la reproductibilité, ce qui n'est pas la même chose que les
contraintes que le projet **exige**. Vérifié dans `composer.lock` et
`package.json` :

| Outil | Session cloud | Épinglé en CI | **Contrainte réelle du projet** | Verdict |
|---|---|---|---|---|
| PHP | **8.4.19** | 8.2 | `^8.2`, et `phpspreadsheet 1.30.6` exige `>=7.4.0 <8.5.0` | ✅ **satisfait** — 8.4.19 < 8.5.0 |
| Node | **22.22.2** | 24 | `engines: >=20 <25` | ✅ **satisfait** |
| composer | 2.8.12 | v2 | v2 | ✅ satisfait |
| **`mysqld`** | **absent** | MySQL 8 | **obligatoire** depuis #578 | ⛔ **le seul vrai manque** |
| `sudo` | sans mot de passe, `uid=0` | — | — | ✅ favorable |

**Conséquence : il n'y a qu'une seule chose à installer, MySQL 8.** PHP et Node
de la session conviennent tels quels. C'est l'inverse de ce que ce document
laissait craindre.

*(Le poste local, lui, est bien hors bornes : PHP **8.5**.4 dépasse le
`<8.5.0` de phpspreadsheet et casse `composer install` — c'est le §5 de la note
du 06/08. Node 26.7 dépasse le `<25`. Les deux environnements n'ont pas du tout
le même problème.)*

**La contrepartie, à assumer par écrit** : travailler en 8.4/Node 22 alors que la
CI épingle 8.2/Node 24 crée un écart de version entre ce qu'un fil constate et ce
que la CI juge. C'est acceptable — les contraintes sont respectées des deux
côtés — mais un rouge de CI que la session ne reproduit pas devra faire penser
**à la version avant de faire penser au code**.

#### ⛔ La question qui reste ouverte, et c'est la même que pour Docker

**`apt-get` fonctionne-t-il derrière le proxy de la session ?** Non mesuré.

C'est le seul point qui décide si le script natif est réalisable. Et le risque
est **exactement celui qui a tué la branche Docker** : une allow-list qui laisse
passer la porte d'entrée mais pas le service.

- **Si `apt-get` passe** : Ubuntu 24.04 publie `mysql-server` (8.0) dans ses
  propres dépôts. Une seule commande, aucun dépôt tiers, et É1 est fait.
- **Si `apt-get` est bloqué** : le script natif meurt du même mal que Docker, et
  la demande d'élargissement d'allow-list devient **le préalable des deux
  branches** — pour les hôtes `archive.ubuntu.com`/`security.ubuntu.com` au lieu
  des hôtes de blobs.

⚠️ **Ne pas conclure depuis une sonde.** La leçon du mirror s'applique
intégralement : `curl archive.ubuntu.com` qui répond ne prouve rien. Il faut
jouer un **`apt-get install` réel**, jusqu'au bout.

Second point à mesurer dans la foulée : **MySQL démarre-t-il sans `systemd` ?**
La session n'en a pas (`/proc/1/cgroup` en v1, pas d'init systemd exploitable) —
`service mysql start` peut échouer là où `mysqld_safe` ou `mysqld` lancé
directement fonctionne.

### 📌 Deux erreurs de la séquence de vérification, corrigées

Elles étaient dans le bloc que ce document proposait. Les taire aurait laissé le
piège en place pour la prochaine mesure.

- **`ps aux | grep -i [d]ockerd`** — l'astuce des crochets suppose que seul le
  `grep` porte le motif. Passée à `bash -c`, **la commande entière apparaît dans
  `ps` et le grep se matche lui-même** : il sort en succès, le `|| echo` ne se
  déclenche jamais, et l'on lit un pavé qui ressemble à un démon actif alors
  qu'il n'y en a aucun. **Correctif : `pgrep -a dockerd || echo "aucun demon"`.**
- **Les deux appels à `api.ipify.org`** — ils devaient mesurer la stabilité de
  l'IP de sortie. Ils ne mesurent rien : **le domaine est hors allow-list** et
  reçoit `CONNECT tunnel failed, response 403`. Un refus de proxy n'est pas une
  IP instable. Le test était mal conçu ; la question Brevo trouve sa réponse
  ailleurs (ci-dessous).

### ~~L'inconnue qui décide, et qu'aucune mesure locale ne peut lever~~ — RÉPONDUE

> **Docker est-il disponible à l'intérieur d'une session cloud ?**

Je ne peux pas le mesurer d'ici : il faut une session cloud pour le savoir. Or
tout en dépend :

- **si oui** → Docker/Sail devient le meilleur moyen de É1 : un seul artefact
  pour le local, la CI et le cloud, et le chantier du 06/08 se poursuit tel quel ;
- **si non** → É1 reste un script d'installation natif pour le cloud, et Docker
  garde sa valeur pour le local et la CI, mais **les deux coexistent** — deux
  définitions du même stack, donc deux occasions de diverger.

**Conséquence sur l'ordre** : cette vérification devient le **tout premier geste**
de É10, avant même d'écrire quoi que ce soit dans É1. Elle coûte quelques minutes
et elle décide de la forme de tout le reste.

#### Tentative de mesure du 21/08 — ÉCHOUÉE, et pourquoi c'est utile de le dire

Un agent a été lancé en isolation « distante » pour répondre à la question. **Il
s'est exécuté sur la machine macOS locale, pas dans un environnement cloud** — il
l'a détecté lui-même (`uname` → `Darwin … arm64`, utilisateur local, absence de
`/proc`, de `systemctl` et d'`apt-get`). **La question reste donc entièrement
ouverte** : cette mesure ne dit rien de Docker en session cloud.

Le noter plutôt que de le taire évite qu'on croie la question traitée. Ce qu'elle
a tout de même établi, sur le **poste local** :

| Mesure | Valeur | Lecture |
|---|---|---|
| `docker`, `podman`, `nerdctl` | tous absents du `PATH` | Docker n'est pas installé sur le poste, confirmé. |
| `php` | **8.5.4** | Conforme à É1 : ce n'est **pas** le 8.2 exigé. |
| `node` | **26.7.0** | ⚠️ **Au-delà de la borne.** `engines` exige `>=20 <25` et la CI épingle 24 : le poste, tel quel, produirait les 645 faux échecs `localStorage` de FRONT#32. |
| `mysql` / `mysqld` | 9.6.0 | Présent, mais la CI tourne sur **MySQL 8**. |
| `sudo` sans mot de passe | non | Un script d'installation ne peut pas présumer des droits. |

**Ce que ça confirme accessoirement** : le poste local n'est pas aligné sur la
CI (PHP 8.5 vs 8.2, Node 26 vs 24, MySQL 9.6 vs 8). C'est précisément le genre de
divergence qu'un artefact unique — Docker — supprimerait, et un argument de plus
pour É11 **indépendant** de la réponse sur le cloud.

**Comment y répondre pour de bon** : ouvrir une session cloud jetable et y jouer
la séquence de vérification listée dans l'issue #585. Aucun outil local ne peut
s'y substituer.

### É3 · Playwright n'est pas tracé comme dépendance

| | |
|---|---|
| **Cible** | Le banc de recette visuelle doit tourner en session cloud (règle « recette visuelle avant d'annoncer »). |
| **Existant mesuré** | Le banc `tools/recette-visuelle/` (FRONT) utilise Playwright, mais **Playwright n'est pas dans `package.json`** — il est installé ad hoc. Un `npm ci` sur une machine neuve ne le pose donc pas, et `npx playwright install chromium` n'est décrit nulle part. |
| **Écart** | Le banc est **inopérant sur une session cloud neuve**, en silence : la commande échouera au premier lancement, pas au moment de l'installation. |
| **Bonne nouvelle mesurée** | Le banc est **auto-portant** : `npm run build` → `vitest run` → `python3 -m http.server 8899`. Il ne parle **ni à staging, ni au back, ni à aucun service tiers**. Il ne demande donc **aucun secret ni aucun accès réseau sortant** — la réserve du cadrage sur le niveau d'accès réseau ne le concerne pas. |

---

## Axe 2 — Les secrets

C'est l'axe où la mesure contredit l'intuition. **Quatre constats, tous
vérifiés** — dont un, la mesure de staging (§É5), qui **borne** les trois autres
et les rend utilisables sans contresens.

### É4 · Jouer les tests ne demande aucun secret — c'est déjà prouvé en production

| | |
|---|---|
| **Cible** | Savoir ce dont un fil a besoin pour jouer les tests. |
| **Existant mesuré** | Les deux suites tournent **avec zéro secret GitHub**, sur chaque PR, depuis le 20/08. `tests.yml` (BACK) ne référence **aucun** `secrets.*` : il fait `cp .env.example .env` + `key:generate`, puis — depuis BACK#578 — ajoute `DB_HOST`/`DB_PORT`/`DB_USERNAME=root`/`DB_PASSWORD=` **vide**, pour un service MySQL local au run. `tests.yml` (FRONT) ne référence **aucun** `secrets.*` et **aucune** variable `VITE_*`. |
| **Preuve — post-#578, sur MySQL 8** | La PR de ce document même (#572) : job **« Suite — vert ou rouge (MySQL 8) » vert en 2 min 59 s**, run `32508772579`, 21/08 — **sans qu'aucun secret ne lui soit fourni**. C'est la preuve la plus fraîche et la plus pertinente : elle porte sur le moteur réellement utilisé depuis #578, pas sur l'ère SQLite. |
| **Preuve — antérieure, pour mémoire** | Six runs consécutifs verts relevés le 21/08 avant #578 (le plus récent : run `32500808798`). ≈1 458 tests BACK, ≈1 299 tests FRONT, dette éteinte depuis le 11/08. |
| **Pourquoi MySQL ne rompt rien** | Le service de test tourne en `root` **sans mot de passe** (`MYSQL_ALLOW_EMPTY_PASSWORD`), sur `127.0.0.1`, éphémère au run. C'est une base **provisionnée**, pas une base **à identifiants** : il n'y a rien à confier à personne. |
| **Écart** | **Nul.** Un fil qui code, joue les suites, joue le lint et pousse n'a besoin d'aucun secret d'application. |

`.env.example` (BACK) a été contrôlé : les champs sensibles y sont **vides ou
`null`** (`APP_KEY`, `MAIL_USERNAME`, `MAIL_PASSWORD`, `AWS_ACCESS_KEY_ID`,
`AWS_SECRET_ACCESS_KEY`, `REDIS_PASSWORD`). Il ne porte aucune fuite.

### É5 · Faire tourner l'application **sous le profil de développement** n'en demande pas davantage — et les vrais secrets vivent ailleurs

C'est le point que le cadrage demandait d'instruire (« jouer les tests demande
beaucoup moins que faire tourner l'application »). **La mesure dit que l'écart
entre les deux est, en pratique, presque nul.**

Profil réellement en vigueur dans le `.env` local du BACK (67 lignes) :

| Variable | Valeur mesurée | Ce que ça implique |
|---|---|---|
| `MAIL_MAILER` | `log` | Aucun mail ne part. **Aucun identifiant SMTP utilisé.** |
| `MAIL_HOST` | `127.0.0.1` | Local, non secret. `MAIL_USERNAME`/`MAIL_PASSWORD` : **vides**. |
| `FILESYSTEM_DISK` | `local` | Disque local. `AWS_ACCESS_KEY_ID`/`SECRET`/`BUCKET` : **tous vides**. |
| `BROADCAST_CONNECTION` | `log` | Aucun Pusher côté serveur. |
| `CACHE_STORE`, `QUEUE_CONNECTION`, `SESSION_DRIVER` | `database` | **Aucun Redis.** `REDIS_PASSWORD` : vide. |
| `DB_CONNECTION` | `mysql` | Base **locale**. `DB_PASSWORD` **vide**, `DB_USERNAME` 4 car. — identifiants choisis, reproductibles, non secrets. |
| `APP_KEY` | renseignée (51 car.) | **Régénérable** : `php artisan key:generate`. Ce n'est pas un secret à transporter. |

**Écart : nul — pour le profil de développement.** Faire tourner l'application en
session **avec ce profil** ne demande **aucun identifiant tiers** — il n'y en a aucun de renseigné aujourd'hui. Le `.env` du
BACK est intégralement **reconstructible** à partir de `.env.example`, de valeurs
choisies localement, et d'une commande.

**Et voici la borne du constat.** Ce `.env` est celui d'un **poste de
développement**. Le même fichier, **sur staging**, porte des valeurs réelles
(mesuré le 21/08, noms de variables uniquement, aucune valeur lue) :
`DB_PASSWORD` renseigné, `MAIL_USERNAME` renseigné, `MAIL_PASSWORD` de
**90 caractères** — la clé SMTP Brevo.

Deux profils, donc, pour un même fichier :

| | Profil **développement** (poste, CI, session cloud) | Profil **service** (staging, production) |
|---|---|---|
| Mail | `log` / `array` — rien ne part | SMTP réel, **identifiants renseignés** |
| Fichiers | disque local | (à confirmer côté serveur) |
| Base | locale, mot de passe vide | **mot de passe renseigné** |
| Qui le détient | reconstruit à la volée | **le serveur, et lui seul** |

**C'est cette séparation qui rend la conclusion solide**, et non l'absence de
secrets dans le projet. Un fil en session travaille sous le profil
développement ; les identifiants du profil service ne lui sont jamais
nécessaires. Ils n'ont donc pas à quitter les serveurs — et la clé SMTP Brevo
moins que les autres, puisqu'elle est **partagée entre plusieurs marques** : sa
fuite déborderait le périmètre du LMS.

### É6 · Le `.env` du FRONT ne contient aucun secret — prouvé par le bundle livré

| | |
|---|---|
| **Existant mesuré** | 7 variables, toutes `VITE_*` : `VITE_APP_API`, `VITE_APP_MEDIA_URL`, `VITE_APP_PROFILE_URL`, `VITE_APP_PUSHER_APP_KEY`, `VITE_APP_PUSHER_APP_CLUSTER`, `VITE_SENTRY_DSN`, `VITE_SENTRY_ENVIRONMENT`. |
| **Preuve** | Vite n'expose au client que les variables préfixées `VITE_`. Vérifié en dur : **la clé Pusher du `.env` se retrouve telle quelle dans `dist/`**, le bundle livré à chaque visiteur. |
| **Écart** | **Nul, et par construction.** Ces valeurs sont publiques : trois URL, une clé d'application Pusher (publique par conception — le *secret* Pusher est côté serveur), un DSN Sentry (publique par conception), un nom d'environnement. Les déposer dans une configuration hébergée n'expose rien qui ne soit déjà servi à tout visiteur. |

---

## ⚖️ L'arbitrage — à rendre, non tranché

> **Question** : accepte-t-on que des identifiants d'API vivent dans une
> configuration hébergée par un tiers (les variables d'environnement de
> l'environnement cloud) ?

**Ce que la mesure change à la question.** Le cadrage posait cet arbitrage comme
un préalable bloquant. L'inventaire montre qu'il porte **aujourd'hui sur un
ensemble vide** : aucun identifiant tiers n'est renseigné dans le `.env` du BACK,
et le `.env` du FRONT ne contient que du public. La question n'est donc plus
« accepte-t-on de déposer ces clés », mais **« quelle règle pose-t-on pour le
jour où il y en aura »**.

**Le précédent, à verser au dossier** : la décision est déjà prise une fois, dans
l'autre sens. `DEPLOY_SSH_KEY`, `DEPLOY_USER`, `DEPLOY_HOST` et
`FRONT_ENV_STAGING` **vivent déjà dans les secrets GitHub** depuis le 20/08, et
la clé SSH de déploiement est l'élément le plus sensible du dispositif. Refuser
le dépôt côté environnement cloud tout en l'acceptant côté GitHub demanderait un
motif explicite — les deux sont des configurations hébergées par un tiers.

**Les options réelles :**

| | Option | Ce que ça donne | Ce que ça coûte |
|---|---|---|---|
| **A** | **Aucun secret tiers, jamais.** Le profil de session reste `log`/`local`/`database`, comme aujourd'hui. | La question ne se pose plus. Rien à arbitrer, rien à faire tourner. | Ce qui exige un service réel n'est pas vérifiable en session : envoi de mail réel (invitations), dépôt S3 réel. Ces vérifications restent locales ou en recette staging. |
| **B** | **Secrets tiers autorisés, au cas par cas, sur décision.** Chaque ajout est arbitré et inscrit. | Permet un jour de vérifier un mail d'invitation de bout en bout depuis une session. | Ouvre une surface qui n'existe pas aujourd'hui. Demande une règle écrite et une revue — sinon la porte reste ouverte par défaut. |
| **C** | **Alignement explicite sur le précédent GitHub** : ce qui est acceptable dans les secrets GitHub l'est dans l'environnement cloud, et réciproquement. | Une seule règle, cohérente, déjà à moitié appliquée. | Demande d'assumer que la clé SSH de production/staging est, elle aussi, déjà chez un tiers. |

**L'enjeu, concrètement** : la réponse ne change **rien** au démarrage du
chantier — É1, É2, É3, É7 et É8 avancent à l'identique quelle que soit l'option.
Elle change **ce qu'on écrit dans le script d'installation** (un profil dégradé
figé, ou un profil paramétrable) et **ce qu'un fil pourra vérifier lui-même** un
jour. Rien ne bloque tant qu'elle n'est pas rendue.

### 📌 Le risque n'est pas à estimer : il a déjà été payé, le 11/08

**Mesure décisive, trouvée le 21/08 dans `BACK#316` (ouverte) et dans la fiche du
compte Brevo.** Elle change la nature de la question posée plus haut.

**Fait 1 — la clé de staging EST la clé de production.** `MAIL_USERNAME` et
`MAIL_PASSWORD` sont **identiques dans les deux `.env`**. La valeur de 90
caractères mesurée sur staging n'est donc pas « une clé de staging » : c'est la
clé de production, déjà descendue d'un cran.

**Fait 2 — le risque s'est réalisé.** Le 11/08 entre 21 h 15 et 21 h 20, une clé
SMTP a été supprimée par mégarde dans Brevo. Comme les deux environnements
partageaient les mêmes identifiants, l'authentification a échoué **partout à la
fois** : plus aucun e-mail ne partait, **production servant de vrais clients
comprise**. Détection immédiate, rétablissement en ~5 minutes.

**Fait 3 — la décision est déjà prise, elle n'est pas appliquée.** `BACK#316`
porte exactement la cible : *une clé SMTP par environnement, nommée
explicitement, chacune dans son `.env`*, avec pour critère d'acceptation
« **révoquer la clé de staging n'affecte pas la production** ». L'issue est
**ouverte**.

**Fait 4 — une clé dédiée ne coûte presque rien.** Brevo délivre des clés SMTP
nommées à volonté : le 11/08, deux clés neuves ont été émises et posées en
**cinq minutes**, sous incident.

#### Ce que ça change pour les options A / B / C

| | Relecture à la lumière du 11/08 |
|---|---|
| **B — copier la clé du serveur** | Bien pire que ce que ce document laissait entendre. Il ne s'agit pas de « copier une clé de staging » : **c'est la clé de production**. Une session cloud de plus, c'est un endroit de plus où sa suppression ou sa fuite coupe les envois de vrais clients — l'incident du 11/08, avec une surface élargie. |
| **C — une clé dédiée** | Ce n'est pas une option nouvelle : c'est **`BACK#316` étendu d'un environnement**. La même décision, déjà rendue pour staging/prod, appliquée aussi au développement. |
| **A — rien ne sort** | Reste valable et sans risque, au prix d'un aller-retour humain à chaque vérification d'envoi réel. |

#### ✅ La réserve sur l'IP est levée — et elle tranche la question

**Vérifié le 21/08 dans la documentation Brevo. Le filtre d'IP couvre le SMTP,
pas seulement l'API.** Il possède même son propre code d'erreur SMTP :

> **`525 5.7.1 Unauthorized IP address`** — « signifie que vous avez activé le
> blocage des adresses IP inconnues **pour les clés SMTP** de votre compte, et
> que l'adresse utilisée ne figure pas sur la liste autorisée ».

Trois conséquences, dans l'ordre :

1. **La liste d'IP autorisées est commune aux clés API et aux clés SMTP.** Ce
   n'est pas deux réglages, c'en est un seul. Le piège relevé le 11/08 sur l'API
   vaut donc aussi pour l'envoi de mail.
2. **Le blocage est actif sur notre compte** — c'est l'un des trois pièges
   constatés le 11/08, et l'IP du serveur a dû y être déclarée pour que les
   envois repartent.
3. **Brevo documente explicitement notre cas.** Sa page d'aide avertit que dans
   un environnement **conteneurisé ou cloud**, l'IP de sortie diffère de l'IP
   publique du serveur : la connexion part « d'une IP d'overlay privée ou d'une
   passerelle NAT partagée » plutôt que de l'adresse déclarée.

**Ce que ça fait aux options B et C : elles ne fonctionnent ni l'une ni l'autre
depuis une session cloud**, quelle que soit la clé fournie, tant que l'IP de
sortie de la session n'est pas stable *et* déclarée dans Brevo. Une clé dédiée ne
change rien à ce mur : le blocage porte sur l'IP, pas sur la clé.

> **A n'est donc plus un choix de prudence : c'est le seul qui marche.** Un fil
> en session cloud vérifie que le mail est correctement *fabriqué* ; l'envoi réel
> se constate sur staging, par un humain ou par un workflow déclenché depuis le
> serveur — dont l'IP, elle, est déclarée.

Et cette contrainte a une vertu : **elle rend la décision insensible à
l'arbitrage**. Il n'y a plus à peser un risque contre un confort, puisque le
confort n'est pas disponible.

#### ⚠️ Ce que cela ne dissout PAS

**`BACK#316` reste entièrement valide et nécessaire.** Elle porte sur la
séparation **staging ↔ production**, où les deux IP *sont* déclarées et où le
partage de clé a déjà coupé la production. Rien de ce qui précède ne l'affaiblit :
seule la branche « étendre #316 au développement » tombe, faute d'utilité.

Reste enfin le contexte du compte, inchangé : **le compte Brevo est mutualisé**
entre cinq marques (Efektiv Académie, Efektiv Group, ACS Services, ACS Solutions,
Voltaire Digital). Le rayon d'une fuite de la clé partagée dépasse le LMS — un
argument de plus pour #316, indépendant de la question du cloud.

**Sources** : [Brevo — Authorize and block IP addresses for API and SMTP
security](https://help.brevo.com/hc/en-us/articles/5740111683858-Authorize-and-block-IP-addresses-for-API-and-SMTP-security),
[Brevo — Troubleshooting SMTP](https://help.brevo.com/hc/en-us/articles/115000188150-Troubleshooting-Issues-with-Brevo-SMTP).

#### 🔒 Confirmation indépendante, par un tout autre mécanisme (22/08)

La mesure en session cloud apporte une **seconde raison, sans rapport avec la
première**, qui aboutit à la même conclusion :

```
-- test SMTP sortant Brevo (port 587) --
tcp/587 brevo: BLOQUE ou injoignable
```

**Le port SMTP ne sort pas de la session.** Il n'y a donc même pas de socket à
autoriser : la question de l'IA autorisée ne se pose qu'à condition de pouvoir
ouvrir la connexion, ce qui n'est pas le cas.

Deux verrous indépendants ferment donc la même porte : **le filtre d'IP de Brevo**
(côté fournisseur) et **le blocage du port 587** (côté environnement). Il faudrait
lever les deux pour que B ou C redeviennent envisageables.

> **La conclusion « A est le seul qui marche » est donc doublement établie**, et
> ne repose plus sur une seule source.

⚠️ **Correction d'une erreur de conception de ce document** : il proposait de
trancher la question en comparant deux appels à `api.ipify.org` pour juger de la
stabilité de l'IP de sortie. **Ce test ne mesurait rien** — le domaine est hors
allow-list et renvoie `CONNECT tunnel failed, response 403`. Un refus de proxy
n'est pas une IP instable. La réponse vient du blocage de `tcp/587`, pas de là.

> **Conséquence sur l'arbitrage** : la question « quel risque au juste ? » est
> **close par la mesure**. Ce qui reste à trancher n'est plus l'écart de risque
> entre B et C — il est établi et documenté — mais **si l'on étend `BACK#316` au
> développement maintenant, ou si l'on s'en tient à A**. Et cela dépend d'abord
> de la réserve sur l'IP, qui est une **vérification**, pas une décision.

### Pourquoi la règle gagne à être écrite **maintenant**

Le jour où la question se posera vraiment n'est pas hypothétique : **il arrive
mécaniquement** dès qu'un fil devra faire tourner l'application contre une vraie
base ou envoyer un mail réel. À ce moment-là, la décision se prendra sous
pression, sur un cas particulier, avec un fil bloqué qui attend — les pires
conditions pour arbitrer une question de gouvernance.

Aujourd'hui l'ensemble est **vide** : personne ne défend un besoin, aucun fil
n'attend, et la règle peut se poser à froid. **C'est le moment le moins cher pour
la décider, et le seul où elle ne coûte rien à personne.**

*Recommandation, à titre consultatif et sans valeur de décision* : l'option A
correspond à l'état mesuré et ne coûte rien à tenir aujourd'hui ; l'option C
serait le choix cohérent si l'on veut trancher une fois pour toutes. Ce qui
paraît à éviter est l'absence de règle, qui laisse le sujet se re-débattre au
premier besoin — et le premier besoin arrivera avec un fil bloqué derrière lui.

**La mesure de staging éclaire l'arbitrage**, et l'incident du 11/08 le tranche
à moitié. La question n'est ni « faut-il des secrets » ni même « peut-on en
descendre un » — c'est, très concrètement :

> **Quand un fil aura besoin d'envoyer un vrai e-mail pour vérifier son
> correctif, lui donne-t-on (A) rien, et un humain confirme sur staging ?
> (B) la clé du serveur — qui est la clé de production ? ou (C) une clé Brevo
> dédiée, nommée, révocable sans toucher à la production ?**

Formulée ainsi, elle est décidable en une minute, et la mesure du 11/08 fournit
le chiffre qui manquait : le coût de B a déjà été constaté — **tous les envois
coupés, production comprise**.

---

## Axe 3 — L'accès au serveur

### É7 · Jouer une migration exige encore une connexion interactive

| | |
|---|---|
| **Cible** | Aucun geste courant ne doit exiger de connexion SSH interactive. |
| **Existant mesuré** | `deploy-staging.yml` fait, sur staging : `git pull` → `php artisan optimize:clear` → contrôle de santé → retour arrière si mauvaise santé. **Il ne joue aucune migration** — vérifié sur les cinq étapes du workflow. |
| **Écart** | **Toute PR portant une migration exige aujourd'hui un `ssh` à la main.** C'est le dernier geste qui interdit strictement le pilotage ordinateur fermé — et c'est un geste fréquent, pas un cas limite. |
| **Ce qu'il coûte** | Faible. Le dispositif d'appui est **déjà en place** : `deploy-staging.yml` porte déjà `workflow_dispatch`, la clé, le contrôle de santé et le retour arrière. Le geste manquant est une étape de migration déclenchable et tracée. |
| **Gain de côté** | Un déclenchement par workflow est **tracé et auditable**, là où une connexion interactive ne laisse rien. C'est un gain de gouvernance, pas seulement de confort. |

### É8 · Lire les journaux du serveur reste sans solution

| | |
|---|---|
| **Existant mesuré** | Aucun accès aux journaux hors connexion SSH. Aucun workflow ne les expose. |
| **Écart** | Réel, et **assumé comme tel par le cadrage** (« usage rare, à traiter au cas par cas »). |
| **Ce qu'il coûte** | À ne pas traiter dans ce lot. Sentry couvre déjà les erreurs applicatives, qui sont l'essentiel du besoin. |

### É9 · Vérifier l'état déployé — écart nul, à acter

Déjà couvert : le contrôle de santé du déploiement continu interroge l'API et
exige un `422` ; un `500` ou un délai dépassé déclenchent le retour arrière
automatique. **Aucune action.**

---

## Axe 4 — Le critère de réussite

### É10 · Le dispositif n'a jamais été éprouvé de bout en bout

| | |
|---|---|
| **Cible** | « Depuis un mobile, ordinateur fermé : trier un signalement, lancer un fil, évaluer son annonce, passer le train, vérifier le déploiement — sans aucune connexion à la machine locale. » |
| **Existant mesuré** | Aucune session cloud jetable n'a été lancée. Le critère n'est pas testé, et **une hypothèse structurante du cadrage ne l'est pas non plus** : qu'un fil lancé depuis une session de pilotage s'exécute bien dans l'environnement de cette session, stack complet compris. |
| **Écart** | Total, et **c'est l'écart qui valide tous les autres**. Tant qu'il n'est pas franchi, É1 à É3 restent des transpositions non vérifiées. |

---

## Ce qui reste local — acté, sans écart

La **production vidéo** du microlearning ne migre pas : modules et fichiers
lourds hors dépôt, secrets de rendu, chaîne coûteuse. Confirmé par la règle
existante « `projects/` et `*.mp4` hors dépôt ». **Aucune action, et c'est le bon
choix.**

---

## Tableau de bord des écarts

| # | Écart | Axe | Coût | Bloque le critère ? |
|---|---|---|---|---|
| **É11** | **Docker — chantier déjà instruit (06/08), croise É1 ; disponibilité en session cloud INCONNUE** | Stack | Faible | **Décide la forme de É1** |
| É1 | Aucun script d'installation | Stack | Faible | **Oui** |
| ~~É2~~ | ~~MySQL : usage non distingué~~ → **tranché par BACK#578**, versé dans É1 | Stack | — | Non |
| É3 | Playwright non tracé | Stack | Très faible | Non |
| É4 | Secrets pour les tests | Secrets | **Nul — acté** | Non |
| É5 | Secrets pour l'application | Secrets | **Nul — acté** | Non |
| É6 | Secrets du FRONT | Secrets | **Nul — acté** | Non |
| É7 | Migration sans SSH | Serveur | Faible | **Oui** |
| É8 | Journaux serveur | Serveur | Hors lot | Non |
| É9 | État déployé | Serveur | **Nul — acté** | Non |
| É10 | Épreuve de bout en bout | Réussite | Moyen | **Oui** |

**Chemin critique** : **É11 (l'inconnue Docker)** → É1 → É10, avec É7 en
parallèle. É2 a été tranché entre-temps par BACK#578 sans passer par ce document.

⚠️ **É11 n'ajoute pas une étape, il en déplace une** : savoir si Docker tourne en
session cloud coûte quelques minutes et **décide de la forme de É1**. L'écrire
avant de le savoir, c'est risquer de l'écrire deux fois.

## Ordre proposé — révisé par la mesure

L'ordre du cadrage plaçait l'arbitrage de gouvernance en étape 3, avant la
construction. **La mesure permet de le sortir du chemin critique** : il ne
conditionne aucun des trois écarts bloquants.

0. **É11 — la question qui décide, en premier.** Ouvrir une session cloud
   jetable et répondre à une seule chose : **Docker y tourne-t-il ?** Quelques
   minutes. Si oui, É1 se fait via `sail:install` et rejoint le chantier du
   06/08 ; si non, É1 est un script natif et Docker reste au local + CI.
1. **É1** — le script d'installation, transposé des deux `tests.yml` **dans leur
   version post-#578** : MySQL 8 d'emblée, `pdo_mysql`, pas de SQLite. Sa
   **forme** dépend de l'étape 0.
2. **É3** — Playwright tracé, dans la foulée de É1.
3. **É10** — l'éprouver sur une session jetable : les deux suites passent-elles ?
4. **É7** — le workflow de migration, qui lève le dernier besoin de SSH.
5. ~~**É2** — MySQL, dimensionné au vu de l'épreuve.~~ **Tranché par BACK#578**,
   versé dans É1 : MySQL 8 est obligatoire, il n'y a plus rien à dimensionner.
6. **L'arbitrage** — à rendre **maintenant**, pendant que l'ensemble est vide et
   que la décision est facile. Il ne bloque toujours aucune issue, mais l'écrire
   à froid vaut mieux que le subir à chaud (cf. §« Pourquoi la règle gagne à être
   écrite maintenant »).

## Suite — les issues ouvertes

Une issue par écart actionnable, toutes passées au vérificateur de gabarit
(`node .github/scripts/verifier-gabarit.mjs`) : **sept sur sept conformes**.

| Écart | Issue | Dépôt |
|---|---|---|
| **É11 · Docker — la question pivot** | **[#585](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/585)** | BACK |
| É1 · Script d'installation | [#573](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/573) — **forme décidée par #585** | BACK |
| ~~É2 · MySQL, usage distingué~~ | [#574](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/574) — **fermée**, tranchée par BACK#578 | BACK |
| É3 · Playwright tracé | [#327](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-FRONT/issues/327) | **FRONT** |
| É7 · Migration sans ssh | [#575](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/575) | BACK |
| É10 · Épreuve de bout en bout | [#576](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/576) | BACK |
| É8 · Journaux serveur (hors lot) | [#577](https://github.com/AAZTEKDEV/EFEKTIVACADEMIE-BACK/issues/577) | BACK |

Les écarts nuls (É4, É5, É6, É9) sont **actés ici et ne donnent pas lieu à
issue** : il n'y a rien à combler.

L'arbitrage de gouvernance des secrets **n'est pas un écart** et ne donne pas
lieu à issue — c'est une décision produit, formulée plus haut avec ses trois
options et son enjeu, en attente. Elle ne bloque aucune des six issues.

**Rien ne part avant arbitrage de l'ordre proposé.**

---

## Journal des révisions

| Date | Révision |
|---|---|
| 21/08 | Version initiale : dix écarts, six issues. |
| 21/08 | **Révision post-BACK#578.** É2 tranché en sens inverse (MySQL 8 obligatoire, moteur unique, parité prod) → issue #574 fermée, contenu versé dans É1 dont les critères sont corrigés. Stack de É1 mis à jour (`pdo_mysql`, service MySQL 8). |
| 21/08 | **Borne ajoutée à la conclusion sur les secrets.** Mesure de staging versée au document (`DB_PASSWORD`, `MAIL_USERNAME`, `MAIL_PASSWORD` 90 car. = clé SMTP Brevo, renseignés). « Surface nulle » est désormais borné à « nulle pour ce qu'un fil fait en session ». |
| 21/08 | Argument ajouté en faveur d'**écrire la règle de gouvernance maintenant**, pendant que l'ensemble est vide. |
| **22/08** | **✅ QUESTION PIVOT RÉPONDUE — « oui, mais ».** Deux sessions cloud distinctes (06:56 et 07:03), résultat reproductible. **Docker fonctionne** : image construite `FROM scratch` puis **exécutée**, image tirée du réseau puis exécutée. Trois réserves : démon à lever à la main (`nohup dockerd &`, 2 s), **cgroup v1**, et surtout **l'allow-list réseau**. |
| **22/08** | **⛔ Le mirror n'est pas une porte de sortie.** Une première mesure concluait que `ghcr.io`/`public.ecr.aws` « passaient » — conclusion tirée d'une sonde `/v2/`, donc du **manifeste seul**, exactement l'erreur que ce document dénonce sur `docker --version`. Pull réels : les deux échouent, leurs **blobs** étant délégués à des CDN tiers refusés (`pkg-containers.githubusercontent.com`, `d2glxqk2uabbnd.cloudfront.net`), comme Docker Hub (`production.cloudfront.docker.com`). Seul `mcr.microsoft.com` sert ses blobs lui-même — mais ne publie ni `mysql:8` ni les images `php`. **Conséquence : É1 doit être un script NATIF à court terme.** |
| **22/08** | **Deux erreurs de la séquence de vérification corrigées** : `ps aux \| grep -i [d]ockerd` se matche lui-même sous `bash -c` (→ `pgrep -a dockerd`), et le double appel à `api.ipify.org` ne mesurait rien (domaine hors allow-list). |
| **22/08** | **Conclusion Brevo confirmée par un second mécanisme indépendant** : `tcp/587` ne sort pas de la session cloud. Deux verrous distincts — filtre d'IP côté Brevo, port bloqué côté environnement. |
| 21/08 | **Réserve sur l'IP Brevo LEVÉE, et elle tranche.** Le filtre d'IP couvre le **SMTP** et pas seulement l'API — code d'erreur dédié `525 5.7.1 Unauthorized IP address`, liste d'IP commune aux clés API et SMTP. Brevo documente explicitement le cas des environnements conteneurisés/cloud (IP de sortie via NAT partagé). **Les options B et C ne fonctionnent donc ni l'une ni l'autre depuis une session cloud** : A n'est plus un choix de prudence, c'est le seul qui marche. `BACK#316` (staging ↔ prod) reste entièrement valide. |
| 21/08 | **Tentative de mesure Docker en session cloud : ÉCHOUÉE.** L'agent « distant » s'est exécuté sur le poste macOS local et l'a détecté. La question de #585 reste **entièrement ouverte**. Effet de bord utile : le poste local est mesuré **hors des bornes de la CI** (PHP 8.5.4 vs 8.2, Node 26.7 vs 24, MySQL 9.6 vs 8) — argument supplémentaire pour un artefact unique, indépendant de la réponse cloud. |
| 21/08 | **Le risque « clé de prod vs clé dédiée » cesse d'être une estimation.** Mesuré : la clé de staging **est** la clé de production (identiques dans les deux `.env`), le risque s'est **réalisé le 11/08** (suppression accidentelle → tous les envois coupés, prod comprise), et la décision est **déjà rendue dans `BACK#316`** (une clé par environnement), simplement non appliquée. Deux réserves ajoutées : restriction d'IP Brevo, et compte mutualisé entre cinq marques. |
| 21/08 | **Docker versé au document (É11).** La note `docs/roadmap/16_NOTE_DOCKER_CI_LILIAN.md` (06/08) instruisait déjà le sujet et avait été manquée. Trois de ses cinq points sont périmés en bien (PR #95 mergée, MySQL fait, `cta_url` éteints) ; PHP 8.2 et les réserves de production restent valides. `laravel/sail` est déjà en dépendance mais jamais initialisé. **Inconnue pivot ajoutée** : Docker tourne-t-il en session cloud ? Elle décide de la forme de É1 et passe en tête de l'ordre. |
| 21/08 | **É4 re-prouvé sur MySQL 8.** La preuve citait des runs de l'ère SQLite. Elle cite désormais le run vert de la PR de ce document (`32508772579`, 2 min 59 s), qui vaut sur le moteur réellement en service — et confirme que le passage à MySQL n'introduit aucun secret. |
