# Runbook — bascule du serveur de production sur notre git (BACK#181, jalon L5)

> ⚠️ **DOCUMENT DE RÉFÉRENCE DE LA MISE EN PRODUCTION. Révisé le 23/08.**
> La **§0 ci-dessous fait foi** : le plan a changé de forme (serveur neuf monté à côté,
> puis bascule DNS — au lieu d'une conversion en place). Les §1 à §8 gardent leur valeur
> de **constat au 11/08**, mais leur **séquence d'exécution est caduque**.
>
> **Aucune action d'exécution n'a été menée sur un serveur** *au moment où ces lignes ont
> été écrites (23/08)* : tout ce qui suit a été établi en lecture seule.
>
> > ⛔ **AMENDÉ le 25/08.** Cet en-tête portait : « ⚠️ **Aucune session Claude ne peut
> > atteindre les serveurs** — tout geste SSH passe par Enguerran ou son associé (§0.5). »
> > **Cette prémisse est tombée** : le 25/08, une session a installé les deux machines
> > Hetzner et joué la session OVH par SSH. La règle qui s'applique désormais est la
> > **doctrine d'accès du §0.5** — dev et staging délégables **sur mandat explicite**,
> > **production vivante jamais à une session seule**. Les §1 à §8 restent des constats du
> > 11/08 établis en lecture seule ; c'est leur *séquence d'exécution* qui est à relire à la
> > lumière du §0.5.
>
> Séquence du 11/08, **désormais dépassée**, conservée pour mémoire :
> contre-recette globale L4+L4.5 → correctifs → merge `development → main` →
> #181 → déploiement prod. **Le merge `development → main` est FAIT** (23/08, §0.2).

Documents dont ce runbook dépend, à lire avant exécution :
`12_PROCEDURE_DEPLOIEMENT.md` (câblage, checklist 13 points) ·
`15_BASCULE_SERVEUR_VERS_GIT.md` (la même opération jouée sur le staging le 06/08) ·
`19_DIFF_SCHEMA_PROD.md` (état réel du schéma, établi le 08/08).

---

# 0. RÉVISION DU 23/08 — le plan a changé de forme

> ⚠️ **Lis cette section avant tout le reste.** Le corps de ce document (§1 à §8) a été
> écrit le 11/08 et décrit une bascule **en place** : modifier la production existante
> pendant une fenêtre de 90 minutes. **Cette approche est abandonnée.** Les mesures des
> §1 à §8 restent valables et précieuses — ce sont elles qui ont permis de décider — mais
> **la séquence des §3, §3bis et §4 est remplacée** par ce qui suit.
>
> Décision du fondateur, dimanche 23/08.

## 0.1 Ce qui a changé, et pourquoi

**Le plan du 11/08** : convertir la base de production en InnoDB, jouer les migrations,
créer 63 clés étrangères et remplacer 145 fichiers — **sur la machine vivante**, en 90
minutes, avec pour seul retour arrière la restauration d'un dump. Si quelque chose cloche,
on le découvre en ligne, sous pression.

**Le plan du 23/08** : monter un **serveur neuf**, y remonter la production **à côté** de
l'existante, la vérifier à froid pendant une semaine avec les vraies données, puis
**basculer le DNS**. Le retour arrière devient : re-changer le DNS.

Ce n'est pas une optimisation du même plan, c'est **une autre classe de risque**.

### Ce que ce changement dissout

Six blocants ouverts cessent d'être des obstacles — non pas parce qu'on les contourne,
mais parce que la nouvelle forme les rend sans objet :

| blocant | ce qu'il devient |
|---|---|
| **B2 / #136 / #157** — convertir MyISAM→InnoDB *en place*, avec des cascades dormantes qui s'activent sur la base vivante | **disparaît.** On *restaure* le dump dans une base MySQL 8 neuve, déjà InnoDB. Les cascades s'activent sur une copie qu'on inspecte **avant** qu'elle soit vivante. |
| **#181** — brancher le serveur sur notre git, `checkout` atomique de 145 fichiers | **disparaît.** Le nouveau serveur est un clone git dès la première minute. La branche-photo `prod/etat-<date>` du §3bis devient inutile. |
| **B12 / #305** — les sauvegardes vivent sur le serveur qu'elles protègent | **se conçoit correctement**, hors machine, dès l'installation. |
| **#316** — clé SMTP partagée staging+prod ; et prod+staging sur la **même machine** (`api.efektiv-academie.com` et `staging.efektiv-academie-dev.com` résolvent tous deux vers `57.129.1.122`) | **séparés par construction.** |
| **B6 / #303** — Composer 2.2.6 (2022) sur le serveur | **installation neuve**, versions courantes. |
| le 4ᵉ environnement OVH sans rapport avec le LMS | **reste sur OVH** (décision du 23/08) et cesse de polluer. |

### Ce que ça ne dissout PAS — et qui reste sur le chemin critique

- **#302 — SPF `-all` sans Brevo, DKIM absent** sur `efektiv-academie.com`. C'est du **DNS**,
  indépendant du serveur. ⚠️ **C'est le seul élément à délai incompressible** (propagation +
  vérification Brevo) : à lancer en premier, avant tout le reste.
- **#141 à #144** — les décisions de schéma et de données, visibles par les apprenants.
  **Elles sont adoucies, pas supprimées** : on joue les migrations sur le nouveau serveur,
  **on regarde le résultat réel**, et le fondateur tranche en connaissance de cause au lieu
  de trancher à l'aveugle avant la fenêtre. C'est un gain qui ne se voit pas au planning.
- **#190** — 4 comptes de test actifs en production, comptés dans les indicateurs.
- **#307** — qui déploie la production : le runbook ou la CI. La question survit au
  changement de serveur ; voir §0.5.

## 0.2 Le merge `development → main` — FAIT le 23/08

**B1 est levé.** Ce qui était le premier prérequis de #181 est exécuté sur les deux dépôts.

| | BACK | FRONT |
|---|---|---|
| PR | **#621** (`967076e`) | **FRONT#347** (`4e88522`) |
| Conflits arbitrés | 3 | 15 |
| CI | suite MySQL 8 verte — 1645 tests / 4997 assertions | verte — 132 fichiers, 1993 tests |

Contrôle après merge, rejoué indépendamment des fils qui ont préparé les PR :

```
BACK   arbre main = arbre development = 74eb060   ✅
FRONT  arbre main = arbre development = 0909f155  ✅
git diff origin/main origin/development           → VIDE sur les deux dépôts
« chaque commit est-il dans main ? »              → 0 absent / 719 (BACK), 0 / 554 (FRONT)
```

**`main` porte désormais, octet pour octet, la version recettée sur staging.**

⚠️ **Deux corrections au CA de #301**, dont la première montre pourquoi une mesure se
rejoue au lieu de se relire :
- « FRONT : ✅ aucun conflit », mesuré le 11/08 → **15 conflits** au 22/08. Onze jours
  d'écart suffisent à rendre une mesure fausse.
- « 15 bumps Dependabot sur `main` » côté FRONT → il n'y en avait **qu'un**.

**Dérogation tracée** : le merge n'est pas passé par `scripts/merge_train.sh` (le CA
l'imposait) mais par le connecteur GitHub. Motif et compensation : §0.6.

## 0.3 Le modèle de branches — la confusion développement / recette

**Le problème, formulé par le fondateur le 23/08** : « on confond le développement avec la
recette ; on n'a pas de copie stable de la version qui alimentera la prod parce qu'on en
rajoute en permanence — et c'est moi qui le demande en grande partie ».

C'est exact, et **ce n'est pas un problème d'infrastructure** : `development` sert à la
fois de branche de travail et de cible de recette. Dès qu'un fil merge, ce que les testeurs
sont en train de tester change sous eux.

### La cible

> ⚠️ **AMENDÉE le 25/08 — décision d'Enguerran** (issue #658, plan
> [`26_PLAN_MODELE_RELEASE.md`](./26_PLAN_MODELE_RELEASE.md) §2 bis/ter) : la branche
> de recette n'est PAS `release/AAAA-MM` datée mais **`staging` à NOM FIXE**,
> recréée depuis `development` à chaque campagne par la cérémonie scriptée
> `scripts/recreer_staging.sh` (deux verrous : tag d'archive avant tout reset,
> refus si correctif non reporté), jouée via l'enveloppe
> `scripts/ouvrir_campagne_staging.sh` qui ouvre et REFERME la fenêtre de
> force-push. Le tableau d'origine est conservé ci-dessous comme trace ; lire
> `release/AAAA-MM` → `staging`, et « coupe » → « cérémonie ».

| branche | rôle | déploie vers |
|---|---|---|
| `development` | le développement continue, les fils y mergent | **serveur de dev** (à créer, cf. §0.4) |
| ~~`release/AAAA-MM`~~ **`staging`** (25/08) | **figée** au démarrage d'une recette. Seuls les correctifs de recette y vont | **staging** = recette utilisateur |
| `main` | la version en production | **prod** — ⚠️ runbook MANUEL jusqu'après la bascule (décision ferme 25/08) |

### Ce que ça change concrètement

`deploy-staging.yml` se déclenche aujourd'hui sur `push: branches: [development]`. Il doit
se déclencher sur `push: branches: ['release/**']`. **Une ligne dans chacun des deux dépôts.**

### Deux règles sans lesquelles ça ne sert à rien

1. ⚠️ **Un correctif de recette va sur `release/`, PUIS est reporté dans `development`.**
   Jamais l'inverse, jamais seulement sur l'une des deux. Un correctif oublié dans le report
   revient comme régression au lot suivant.
2. ⚠️ **À partir de la coupure, un merge dans `development` n'arrive plus sur staging.**
   À annoncer à l'équipe **le jour même**, sinon quelqu'un merge un correctif, les testeurs
   ne le voient pas, et on perd une demi-journée à comprendre pourquoi.

### Impact sur la recette en cours : nul, à une condition

Si `release/2026-09` est coupée depuis `development` **à son état courant**, le contenu
déployé sur staging est identique à ce qui y tourne déjà. Les testeurs ne voient aucune
différence. **La condition est de couper la branche avant de basculer le workflow**, pas
l'inverse.

## 0.4 Le nouveau serveur — architecture et dimensionnement

### Principe : un serveur par usage, pas un gros serveur partagé

⚠️ **Ne pas mettre Microlearning sur la même machine que le LMS.** Les profils de charge
sont opposés : le LMS est un Laravel qui sert des pages (base de 1,5 Mo, vidéos chez Vimeo,
charge négligeable) ; Microlearning fait du **rendu vidéo** — Remotion + FFmpeg + appels
TTS/image — c'est du CPU saturé pendant des minutes. Un rendu en cours ferait ramer la
plateforme des apprenants. Décision du fondateur, 23/08 : **machines séparées**.

**Et le SaaS ?** Question posée le 23/08. Réponse : **serveur dédié, mais pas pour une
raison de puissance.** C'est une question de **rayon d'impact et d'isolation des données** —
le SaaS portera les données de plusieurs entreprises clientes, la prod Efektiv porte les
preuves QUALIOPI d'Efektiv. Et les rythmes sont opposés : un SaaS déploie souvent, une prod
de formation doit être ennuyeuse.

⚠️ **Mais ne pas le construire aujourd'hui** (#594, #519 sont ouvertes, le SaaS n'existe
pas). La décision de ce soir est d'adopter une architecture où le SaaS **pourra** être
ajouté comme machine séparée — pas une où il **devra** l'être.

### Dimensionnement recommandé — LMS de production

Hypothèses mesurées : base **1,46 Mo** / 58 tables / 442 colonnes, **301 utilisateurs**,
**2 128 inscriptions**, 54 cours, 313 leçons (relevé du 11/08, §1.4), vidéos **externalisées
chez Vimeo**, front = build statique servi par Apache.

> ⛔ **Correction du 23/08 (soir).** Cette ligne annonçait « 260 apprenants, 180 inscriptions ».
> **Les deux chiffres étaient faux** — et le bon était **dans ce document même**, au §1.4 :
> **301 utilisateurs, 2 128 inscriptions**, relevés en lecture seule sur `e-learning-prod`.
> « 260 apprenants » est un ordre de grandeur qui circule dans le dépôt (docs 08 et 10) ;
> « 180 inscriptions » ne vient de nulle part. **Le dimensionnement ne change pas** — 2 128
> inscriptions restent négligeables — mais la méthode, si : un chiffre se relève, il ne
> s'estime pas. Deux valeurs contradictoires vivaient dans un même fichier.
> ✅ **MESURÉ le 25/08 — le dernier chiffre manquant est tombé** (#156, bloc B, session SSH
> en lecture seule sur `57.129.1.122`).
>
> **Ce qu'on écrivait ici :** « ⚠️ Le seul chiffre manquant est le volume disque des fichiers
> (certificats PDF, avatars, images de cours, ressources) — commande de mesure en §0.7. Tant
> qu'il n'est pas connu, le dimensionnement ci-dessous suppose moins de 20 Go. »
>
> **Ce qui a été relevé** — et le point important est ce qu'il *dissocie* :
>
> | | mesuré |
> |---|---|
> | Machine OVH entière, `df -h /` | 467 Go, **32 Go utilisés** (8 %) |
> | dont `api.efektiv-academie.com` (API prod, storage inclus) | **580 Mo** |
> | dont `efektiv-academie.com` (front prod) | 420 Mo |
> | Base `e-learning-prod` (relevé du 11/08, §1.4) | **1,5 Mo** |
> | **Total LMS de production** | **≈ 1 Go** |
>
> Détail du storage : certificats **164 Mo** · avatars 66 Mo · ressources de cours 42 Mo ·
> fichiers de leçon 25 Mo · images de cours 24 Mo · le reste sous 10 Mo chacun.
>
> ⚠️ **Les 32 Go du serveur OVH ne sont PAS le LMS** — ce sont surtout les AUTRES projets
> hébergés sur la même machine (`api.formind-dev.io` 9,6 Go, staging opdo 3,3 Go, et
> 75 répertoires `staging.…-backup-<horodatage>/` accumulés sans rotation, ~660 Mo). C'est
> exactement le mélange que la séparation par usage vient défaire. **Lire « 32 Go » comme le
> besoin du LMS aurait sur-dimensionné la machine d'un facteur 30.**
>
> **Conséquence sur le seuil posé plus bas** : le seuil de bascule vers le cran supérieur
> était fixé à ~40 Go. On est à **2,5 % de ce seuil**. Le disque n'est plus un point ouvert.

#### Révision du 23/08 — le gabarit est descendu de CPX31 à CPX21

> ⚠️ **Les deux noms de ce titre sont périmés** (gamme Hetzner renouvelée) — lire
> **CPX22 / CPX32**, amendement du 25/08 en fin de section. **Le raisonnement qui suit,
> lui, tient entièrement** : c'est lui qui a fait descendre le gabarit, et la mesure du
> 25/08 l'a confirmé — l'hypothèse prudente était « moins de 20 Go », le réel est **≈ 1 Go**.

Question du fondateur : « on n'aura jamais un volume très important d'utilisateurs
supplémentaires sur cette version du LMS (contrairement à la version SaaS), on peut peut-être
revoir à la baisse ? »

**Oui — et pour une raison plus forte que la prémisse : aucun des postes qui dimensionnent
cette machine ne dépend du nombre d'apprenants.**

| poste | ce qui le fait varier | dépend des apprenants ? |
|---|---|---|
| Base MySQL | 1,5 Mo, tient entièrement en cache quoi qu'il arrive | non |
| Vidéos | externalisées chez **Vimeo** — ni disque, ni bande passante | non |
| Front | build statique servi par Apache, coût quasi nul | non |
| **Process PHP-FPM** | le nombre de requêtes **simultanées**, pas la population inscrite — et il se **règle** (`pm.max_children`) | **non** |

301 utilisateurs ne produisent pas 301 requêtes simultanées : ils produisent une pointe de
quelques dizaines pendant une session de formation. C'est cette pointe qui dimensionne, et
elle est déjà couverte par 4 Go.

**Ce qui reste dimensionnant, trois postes — un seul non mesuré :**

| poste | état |
|---|---|
| RAM | bornable : MySQL 8 résident ~400 Mo + N workers PHP à ~80 Mo. **4 Go = large.** |
| CPU en pointe | génération des certificats PDF (dompdf) — pic ponctuel, pas régime permanent |
| ~~**Disque**~~ | ~~❌ **NON MESURÉ.** C'est le seul chiffre qui peut encore changer la réponse (§0.7, étape 0)~~ → ✅ **mesuré le 25/08 : ≈ 1 Go** (#156). Les trois postes sont désormais bornés |

**La règle qui tranche — l'asymétrie entre les deux erreurs possibles :**

- se tromper sur **CPU/RAM** → un redémarrage de quelques minutes, **réversible** ;
- se tromper sur le **disque** → l'agrandissement est **définitif** chez la plupart des
  fournisseurs de VPS (on ne peut plus redescendre de gabarit ensuite).
  ~~⚠️ **Non revérifié dans cette session** — `docs.hetzner.com` est hors politique de sortie
  réseau. **À confirmer au moment de commander**, c'est le seul point de cette section qui
  n'est pas établi.~~
  ➡️ **Point refermé le 25/08 : les machines ont été commandées** (§ ci-dessous). Le
  raisonnement d'asymétrie tient toujours, mais il n'a plus de conséquence pratique ici —
  le besoin mesuré est à 1 % du disque retenu.

~~Donc : **descendre le CPU et la RAM sans hésiter, ne pas descendre le disque avant d'avoir
la mesure.**~~ ➡️ **La mesure est faite, et elle confirme la descente** (25/08).

**Condition levée** : la seule chose qui aurait imposé de rester à 8 Go, c'était un staging
ou une pré-production **co-résidents** (ils doublent MySQL et les workers PHP). Ce n'est pas
le cas : §0.2 acte que prod et staging sont **séparés par construction** (c'est précisément
ce que #316 reprochait à l'installation OVH). **La prod est seule sur sa machine.**

⚠️ **Ne pas descendre à 2 Go pour autant.** Pendant la bascule, la machine fait tourner en
même temps `composer install`, les migrations, la restauration du dump *et* sert déjà. 4 Go
est le plancher raisonnable ; 2 Go, c'est se créer un incident un samedi.

**Et l'argument qui emporte la décision n'est pas l'économie** — l'écart est de quelques
euros par mois. C'est qu'**une machine juste dimensionnée se surveille** : une dérive s'y
voit. Une machine trois fois trop grande masque tout — une fuite mémoire, une requête qui
part en vrille — jusqu'au jour où elle ne masque plus rien. C'est exactement ce qu'on vit
sur OVH : un serveur qui porte quatre environnements, et où plus personne ne sait ce qui
consomme quoi.

#### ✅ Amendement du 25/08 — la commande a eu lieu, la gamme avait changé de noms

> **Ce que ce tableau disait au 23/08 :** gabarit « 3 vCPU / 4 Go / 80 Go, classe Hetzner
> **CPX21** », cran supérieur « **CPX31**, 160 Go », PHP « **8.2** d'abord ».
>
> **Deux de ces trois lignes ne tiennent plus** — et la première pour une raison qu'aucun
> raisonnement ne pouvait produire : **la gamme Hetzner a été renouvelée.** `CPX21` et
> `CPX31` **n'existent plus au catalogue**. La leçon vaut au-delà du cas : un nom de
> référence commerciale est une donnée périssable, la **spec** ne l'est pas.

**La spec réelle, celle qu'il faut lire quand les noms changent :**

> **Planchers : 4 Go de RAM, 80 Go de disque. Le CPU n'a jamais été la contrainte** — aucun
> des postes du tableau ci-dessus n'en dépend, et le nombre de vCPU du gabarit retenu (2)
> est **inférieur** à celui qui était écrit ici (3) sans que cela change quoi que ce soit.

| | retenu le 25/08 | pourquoi |
|---|---|---|
| Gabarit | **CPX22** — 2 vCPU / 4 Go RAM / **80 Go** SSD | remplace `CPX21` (disparu). Les deux planchers sont tenus ; le disque mesuré (≈ 1 Go) est à **1 %** du volume retenu |
| Cran au-dessus, si un jour | **CPX32** (160 Go) | remplace `CPX31`. Seuil de bascule inchangé : ~40 Go de besoin réel — on en est à 2,5 % |
| Plancher à ne pas franchir | **4 Go de RAM** | la bascule elle-même consomme plus que le régime permanent |
| ~~Ancienne reco~~ | ~~4 vCPU / 8 Go / 160 Go (CPX31)~~ | **dépassée le 23/08** — dimensionnait sur une croissance qui n'aura pas lieu sur ce LMS |
| Localisation | **UE** — Falkenstein / Nuremberg (DE) ou Helsinki (FI) | données personnelles d'apprenants : RGPD. Ne pas prendre une région hors UE |
| OS | **Ubuntu 24.04 LTS, explicitement** — ⛔ **pas « la LTS la plus récente »** | voir `25_INSTALLATION_SERVEUR_CIBLE.md` §2 : la présélection de la console pousse une LTS qui livre MySQL 8.4 + PHP 8.5, **tous deux interdits ici**. L'écart a coûté une machine reconstruite le 25/08 |
| Base | **MySQL 8.0** — pas 8.4, pas 9.x | ⚠️ **INCHANGÉ, et désormais prouvé** : `8.0.46` installé sur les deux machines, répétition générale validée dessus (#156). C'est la version de la production actuelle ; changer de moteur ET de version en même temps, c'est deux variables |
| PHP | ~~**8.2** d'abord~~ → **8.4** | **amendé le 25/08, décision d'Enguerran** (#471) — voir l'encadré ci-dessous |
| Sauvegardes | **hors de la machine** — Storage Box, S3, ou équivalent | c'est #305 : une sauvegarde sur le serveur qu'elle protège ne protège de rien. ⚠️ **Toujours dû** : les sauvegardes Hetzner de la machine sont actives, le dépôt **hors machine** ne l'est pas |

**Machines effectivement commandées et installées le 25/08** : `lmsefektiv-dev` et
`lmsefektiv-prod`, deux CPX22, Ubuntu 24.04. La procédure jouée sur elles, avec ses écarts,
vit dans [`25_INSTALLATION_SERVEUR_CIBLE.md`](./25_INSTALLATION_SERVEUR_CIBLE.md) — passée
au statut **ÉPROUVÉE**. L'état d'avancement du run vit dans **#667**.

##### PHP : 8.2 → 8.4

> **Ce qu'on écrivait le 23/08 :** « PHP **8.2** d'abord — la prod est en 8.2 ; la montée en
> 8.4 est un chantier séparé (#471). »

La ligne était prudente sur le principe et fausse comme cible : elle faisait **naître une
machine neuve** sur une version dont le support sécurité s'éteint **fin décembre 2026**.
Tranché le 25/08 par Enguerran (#471), sur mesure et non sur intention :

- le plafond réel ne vient pas de Laravel mais **d'un seul paquet**,
  `phpoffice/phpspreadsheet` → `<8.5.0`. **8.4 est le maximum autorisé** ;
- `composer install` **vert en 8.4.24**, et **suite complète verte en 8.4** — résultat
  identique au run de référence en 8.2 ;
- une machine qui **naît** en 8.4 règle #471 sans migration ultérieure.

⚠️ **Suite non tranchée, à ne pas décider ici : la CI teste encore 8.2**
(`.github/workflows/tests.yml:114`). Tant que l'alignement n'est pas fait, le serveur tourne
sur une version que la CI ne joue pas. **C'est un point pour la conversation CI/CD — ce
document le SIGNALE, il ne le prescrit pas.**

⚠️ **Vérifier les tarifs courants au moment de la commande** — ils changent, et je ne les
garantis pas. **Ordre de grandeur constaté le 25/08** : ≈ **19 €/mois** par machine, tout
compris (gabarit + IPv4 ~0,60 € + sauvegardes +20 %). C'est un **constat daté, pas un
engagement** : il se re-vérifie à chaque commande.

### Docker : NON en production pour cette bascule

Question posée le 23/08 : « Docker, plus simple pour tester depuis n'importe quelle branche
ou session cloud ? »

**Deux questions distinctes, deux réponses opposées :**

- **Tester depuis n'importe quelle branche : déjà résolu, sans Docker de production.** La CI
  GitHub lance déjà la suite sur un MySQL 8 en conteneur, sur toute PR, quelle que soit sa
  base (depuis #578). C'est exactement le besoin, et ça marche.
- **Docker ne change rien à la capacité de déployer depuis une session cloud.** Le blocage
  est réseau et il est identique, conteneurisé ou non.
- **Docker en production : non, pour l'instant.** Il ajoute une couche à exploiter — images
  à reconstruire, volumes à sauvegarder, réseau à comprendre, un mode de panne de plus — et
  **personne n'exploite l'infra aujourd'hui** (§0.5). Sur Apache + PHP-FPM classique, quand
  ça casse, tout le monde sait lire un log.

**À la place** : un **script d'installation versionné** dans le dépôt, qui donne la
reproductibilité recherchée pour une fraction du coût d'exploitation. Docker plus tard,
quand la suite Formind justifiera plusieurs services **et** qu'il y aura quelqu'un pour
l'exploiter. Refs #573, #585 — à mettre à jour avec cette décision.

## 0.5 Qui fait quoi — établi le 23/08, **prémisse d'accès amendée le 25/08**

| | qui |
|---|---|
| Compte serveur, propriété des accès, zone DNS | **Enguerran et son associé** |
| Exécution sur les machines | **humain OU session, selon l'environnement — voir la doctrine ci-dessous** |
| Zone DNS (reste chez OVH) | **Enguerran** |
| Préparation des scripts, procédures, contrôles ; git, PR, issues | **le pilote (Claude)** |
| Exploitation courante après bascule | **à définir — trou identifié** |

### ⛔ La prémisse d'accès de ce document est tombée le 25/08

> **Ce que ce paragraphe affirmait, et qui a gouverné tout le document :**
>
> « ⚠️ **Fait structurant : aucune session Claude ne peut atteindre les serveurs.** Ni le
> serveur OVH actuel, ni le futur serveur — les hôtes sont hors de la politique de sortie
> réseau des sessions. Mesuré : `exit=56` sur `api.efektiv-academie.com`. **Tout ce qui est
> SSH passe par un humain.** Le pilote prépare et vérifie ; il n'exécute pas. »
>
> **La mesure était vraie ; la généralisation ne l'est pas.** Un `exit=56` mesuré depuis
> **une** session, sur **un** hôte, à **une** date, ne fonde pas un « aucune session, jamais,
> nulle part ». C'est la même faute de raisonnement que celle nommée au §0.6 à propos de la
> protection de branche : *un indice compatible avec deux états n'en démontre aucun.*

**Ce qui s'est réellement passé le 25/08** : la session serveur a **installé les deux
machines Hetzner** (`lmsefektiv-dev`, `lmsefektiv-prod`) et **joué la session OVH par SSH
depuis le poste d'Enguerran** — mesure disque, récupération du `.htaccess` de production, du
préfixe Stripe, du `.env`, comptage des orphelins, dump pour la répétition générale (#156,
tous blocs). Rien de tout cela n'était possible sous l'ancienne prémisse. **Ce document ne
peut plus être lu comme si elle tenait.**

### La doctrine — décision d'Enguerran du 24/08, confirmée à l'épreuve du 25/08

**Ce qui est acté, et rien de plus :**

| environnement | qui exécute |
|---|---|
| **Développement** (`lmsefektiv-dev`) | **délégable à une session, sur mandat explicite** |
| **Staging / recette** | **délégable à une session, sur mandat explicite** |
| ⛔ **Production VIVANTE** — celle qui sert des utilisateurs réels | **jamais une session seule. La session prépare, vérifie, consigne ; l'exécution est humaine.** |

**« Sur mandat explicite »** veut dire : un ordre donné pour ce geste-là, pas une autorisation
permanente. Le mandat ne se déduit ni d'un précédent, ni du fait que la session a déjà les
accès en main.

**La nuance, telle qu'elle a été jouée le 25/08 — et pas au-delà :** le **domaine provisoire
de la prod cible** (`lmsefektiv-prod`, fermé par mot de passe, aucun utilisateur, aucune
donnée servie) **a été monté par la session, sur mandat**. Ce n'est pas une exception à la
règle de production : cette machine n'est pas encore une production **vivante**, c'est une
machine de préparation qui porte le nom de la future prod. **Ce qui déclenche l'exigence
humaine, ce n'est pas le nom de la machine ni sa destination — c'est le fait qu'elle serve
des utilisateurs réels.** Le jour où le DNS bascule (§0.7, étape 4), `lmsefektiv-prod`
change de catégorie, et la règle de production s'applique à elle.

⚠️ **Ce qui n'est PAS tranché ici** — et ne doit pas être déduit de ce qui précède : quelle
session, dans quelles conditions techniques, atteint quels hôtes ; ni ce qui a changé entre
la mesure du 23/08 et l'exécution du 25/08. **On constate le fait, on pose la règle d'usage ;
on n'explique pas le mécanisme.**

> 📌 **Note de lecture — conséquence non traitée dans cet amendement.** Plusieurs procédures
> de ce runbook ont été écrites **sous l'ancienne prémisse**, c'est-à-dire « pour un humain
> parce que la session ne peut pas ». Elles ne sont pas fausses — elles sont **conçues plus
> étroitement que nécessaire**. Elles n'ont **pas** été réécrites ici : la liste des passages
> concernés est portée à l'annonce de cette PR, et leur reprise appartient à qui reprendra la
> séquence d'exécution. **Aucune procédure de ce document ne doit être élargie à une session
> sur la seule foi du présent §0.5** — chaque geste garde son mandat.

⚠️ **Et l'équipe de développement externe ne gère pas l'infra** (constat du fondateur,
23/08). La reprise n'est donc pas seulement un transfert d'accès : **il n'y a personne
derrière**. Un serveur neuf est le bon moment pour poser la question de l'exploitation
courante — sauvegardes vérifiées, renouvellement TLS, supervision, astreinte — mais elle
reste **ouverte**.

## 0.6 Le merge, la CI, et le sort de `merge_train.sh`

> ⚠️ **PRÉMISSE À NUANCER — mesuré le 25/08, sans réécrire ce qui suit.**
> Le constat ci-dessous énonce comme **universel** (« `gh` est absent des sessions »)
> ce qui est en réalité **dépendant du type de session**. Mesure du 25/08 depuis une
> session locale : `gh` **2.88.1 présent**, `api.github.com` **répond** (quota
> 4994/5000), `gh pr merge` accessible. Le 403 « GitHub access is not enabled for
> this session » mesuré par la session cloud était **vrai chez elle** — c'est une
> garde d'organisation qui frappe les sessions distantes, pas les sessions locales.
>
> **Ce que ça change pour le sort de `merge_train.sh`** (257 lignes, toujours au
> dépôt) : l'argument « il est inexécutable » ne tient plus tel quel — il est
> exécutable depuis une session locale. Mais **le §7 de `FIL_DE_CHANTIER.md`
> (25/08) a déplacé la question** : les trains sont commandés par la conversation
> chapeau, et un fil ne merge jamais. Trois options, **non tranchées ici** :
> (a) le script devient l'outil du **fil train** et son usage s'écrit dans le §7 ;
> (b) il est **retiré**, ses gardes utiles (« chaque commit de la PR est-il contenu
> dans la cible ? ») étant reprises en git pur ailleurs ; (c) il reste tel quel,
> avec sa dérogation tracée. ⚠️ **Instruit, pas arbitré** — cela relève d'Enguerran.

**Constat du 23/08.** `scripts/merge_train.sh` fait `gh pr merge`. Or `gh` est absent des
sessions, et `api.github.com` y répond **403** — « GitHub access is not enabled for this
session. An org admin must connect the Claude GitHub App for this organization ». Ce n'est
ni le proxy réseau (`recentRelayFailures` vide), ni GitHub, ni une question d'IP : c'est une
garde d'organisation, et elle s'applique même sans jeton.

**Conséquence** : le script est inexécutable depuis une session — **et avec lui, ses deux
gardes**, alors que la plus importante ne dépend pas de `gh`.

**Ce qui a été fait pour le merge du 23/08** : la garde « chaque commit de la PR est-il
contenu dans la cible ? » a été rejouée **en git pur** (`git merge-base --is-ancestor`,
sans `gh`, sans API), plus le contrôle d'**identité d'arbre** que le script ne fait pas et
qui est strictement plus fort. Résultats en §0.2.

⚠️ **La bonne correction n'est pas d'aménager le script — c'est de le rendre inutile.**
**Tant qu'il subsiste, il constitue une voie parallèle à la CI** — exactement ce qu'on
cherche à éviter. Objection du fondateur, 23/08, et elle est juste.

### ⛔ Et une prémisse partagée par tout le dépôt vient de tomber

`DEPLOIEMENT_STAGING.md` affirme que la protection de branche est « indisponible sur le plan
GitHub actuel (dépôts privés sur offre gratuite), ce qui signifie que *rien n'empêche
techniquement de merger une PR rouge* ». La décision **D4** du §7 en découle.

**C'est faux au 23/08.** La protection est **posée et active**.

> ## ⛔ CORRECTION DU 23/08 (soir) — ce paragraphe affirmait une asymétrie QUI N'EXISTE PAS
>
> La version précédente concluait : « la branche de TRAVAIL est protégée, la branche de
> PRODUCTION ne l'est pas ». **C'était faux, et l'erreur est de raisonnement, pas de mesure.**
>
> **Réglages lus par l'API** (conversation « structure du dépôt », jeton `AAZTEKDEV`,
> 23/08 ~18h30 — ⚠️ *mesure d'un autre fil, non reproductible depuis cette session, §0.6*) :
>
> | | contrôle requis | `enforce_admins` | force-push | suppression |
> |---|---|---|---|---|
> | BACK `main` | `Suite — vert ou rouge (MySQL 8)` | **true** | non | non |
> | BACK `development` | `Suite — vert ou rouge (MySQL 8)` | **true** | non | non |
> | FRONT `main` | `tests` | **true** | non | non |
> | FRONT `development` | `tests` | **true** | non | non |
>
> **Les quatre branches sont protégées à l'identique.** Aucun ruleset (`/rulesets` → `[]`).
>
> **Pourquoi les deux merges se sont comportés différemment** — ce n'est pas la branche qui
> diffère, c'est **quel contrôle était rouge** :
>
> | PR | cible | `Suite (MySQL 8)` — **requis** | `Pint` — **non requis** | résultat |
> |---|---|---|---|---|
> | #621 | `main` | ✅ **success** | ❌ failure | merge accepté — **cohérent** |
> | #636 | `development` | ⏳ *in progress* | ✅ success | merge refusé — **cohérent** |
>
> ⚠️ **La preuve était DANS CE DOCUMENT, au §0.2**, qui note « suite MySQL 8 verte — 1645
> tests / 4997 assertions » pour #621. Le commit de merge de #621 le dit aussi. J'ai écrit
> la conclusion inverse 230 lignes plus bas, sans rouvrir mon propre §0.2.
>
> **La faute de raisonnement, nommée** : « le merge est passé » est un indice qui **vaut dans
> les deux états** — « pas de protection » *et* « le seul contrôle requis était vert ». On ne
> peut rien en conclure. C'est la règle 15 de `CLAUDE.md`, appliquée à un fichier que
> j'avais écrit moi-même.

**Le vrai constat, lui, tient — et il est ailleurs :**

⚠️ **Le lint n'est requis NULLE PART.** Un `Pint` rouge (BACK) ou un `ESLint` rouge (FRONT)
passe sur **les quatre branches**, `main` comprise. **#621 en est la preuve vivante** : elle
est entrée sur `main` avec Pint rouge — et c'était **régulier**.

**Ce qui reste à faire :**
- [ ] ~~Étendre les contrôles requis à `main`~~ — **sans objet** : ils y sont déjà. « Réparer »
      une asymétrie inexistante aurait coûté du temps sans rien changer au vrai trou.
- [ ] Corriger `DEPLOIEMENT_STAGING.md`, dont la prémisse est fausse et qui sert de référence.
- [ ] Ré-instruire **D4** : la question n'est plus « faut-il un plan payant ? » ni « quelle
      protection sur quelle branche ? » mais **« le lint doit-il être bloquant, et où ? »** —
      à rapprocher de #467, qui porte déjà cet arbitrage. **Décision du fondateur.**
- [ ] **`merge_train.sh` n'a déjà plus de raison d'être** sur son volet « empêcher un merge
      rouge » : la protection le fait, sur les quatre branches. Reste à statuer sur ses
      contrôles annexes (rappel des migrations, lien profond post-déploiement).

## 0.7 La séquence de la semaine

> 📍 **L'état d'avancement du run vit dans l'issue #667** (étapes 0-2 faites au 25/08 :
> machines installées, répétition générale validée sur dump réel) — **le §0.7 reste la
> référence de la SÉQUENCE**, pas de l'avancement. Les cases ci-dessous ne sont pas tenues
> à jour ici : c'est #667 qui dit où en est la bascule.

Cible : **bascule fin de semaine du 25-30/08**. L'approche le permet parce qu'**elle ne
touche pas la production** : si le nouveau serveur n'est pas prêt, on ne bascule pas, et
rien n'a été cassé.

### Étape 0 — à lancer en premier, délai incompressible
- [ ] **#302 — authentifier `efektiv-academie.com` chez Brevo** (SPF `include`, DKIM) dans
      la zone OVH. **Tout le reste peut attendre, pas ça.**
- [ ] **Mesurer le volume disque** de la production :
```bash
ssh ubuntu@57.129.1.122 '
  echo "=== poids par application ==="
  sudo du -sh /var/www/*/ 2>/dev/null
  echo "=== stockage Laravel de la PROD, en détail ==="
  sudo du -sh /var/www/api.efektiv-academie.com/e-learning-api/storage/app/public/*/ 2>/dev/null
  echo "=== base ==="
  sudo du -sh /var/lib/mysql/* 2>/dev/null | sort -h | tail -5
  echo "=== place libre ==="
  df -h /
'
```
- [ ] **Abaisser le TTL DNS à 300 s** sur les enregistrements concernés — **plusieurs jours
      avant** la bascule, sinon on attend la propagation le jour J.

### Étape 1 — le serveur, à froid

> 📄 **Procédure d'exécution détaillée : [`25_INSTALLATION_SERVEUR_CIBLE.md`](./25_INSTALLATION_SERVEUR_CIBLE.md)**
> — durcissement, socle PHP 8.2/MySQL 8, vhosts, TLS, domaine provisoire fermé,
> sauvegardes hors machine, et 12 contrôles de fin d'installation.
> ⚠️ **Non éprouvée** tant que personne ne l'a jouée.
- [ ] Commande de la machine, OS, durcissement SSH (clés seulement), pare-feu.
- [ ] Apache + PHP 8.2 + MySQL 8.0 + Composer courant + Node 24.
- [ ] **Le tout consigné dans un script d'installation versionné**, pas dans un historique
      de shell. C'est ce qui remplace Docker (§0.4).
- [ ] Clone git de `main` — dès la première minute, pas de dépôt étranger.
- [ ] Nom de domaine **provisoire et dédié** pour la vérification (⚠️ à protéger de
      l'indexation : `robots.txt` + authentification HTTP, il portera des données réelles).

### Étape 2 — la base, en répétition
- [ ] `mysqldump` de la production (lecture seule, sans interruption).
- [ ] Restauration sur le nouveau serveur → **conversion InnoDB → migrations → 63 clés
      étrangères → balayage des orphelins.**
- [ ] ⚠️ **Rejouer la séquence complète au moins deux fois d'affilée sans intervention
      manuelle** — c'est le critère de #156, et il reste valable.
- [ ] **Montrer au fondateur le résultat réel des décisions #141-#144** et les faire trancher
      sur pièces.
- [ ] ⛔ **Le script de conversion InnoDB n'est toujours ni versionné ni testé** (B14). C'est
      le livrable bloquant de #156 et il reste à produire.

### Étape 3 — vérification en connecté
- [ ] Parcours apprenant complet, parcours manager, génération d'un certificat **ouvert et
      lu**, envoi d'un e-mail **reçu en boîte**.
- [ ] Contrôle des 13 points de `12_PROCEDURE_DEPLOIEMENT.md`.
- [ ] ⚠️ Vérifier enfin les deux questions en suspens : **le disque public est-il servi ?**
      (#618) et **quelle est la nature de la clé Stripe ?** (#600). Sur le nouveau serveur,
      ce sont des faits qu'on constate et qu'on corrige avant l'ouverture.

### Étape 4 — la bascule
- [ ] Gel des écritures (~30 min, un week-end — accepté par le fondateur).
- [ ] **Dump complet frais** → on rejoue **exactement** la séquence déjà répétée.
- [ ] ⚠️ **PAS de « merge des écarts ».** À 1,5 Mo, une restauration complète prend quelques
      secondes et se vérifie ; un merge de deltas est précisément là où l'on perd des
      données. **Question posée par le fondateur, tranchée ici.**
- [ ] `rsync` des fichiers déposés (certificats, avatars, ressources).
- [ ] Bascule DNS.
- [ ] Surveillance, puis **fermeture du serveur OVH une fois la période de garde écoulée** —
      pas avant.

**Retour arrière à toute étape** : le DNS pointe encore sur OVH, qui n'a pas été modifié.
C'est tout l'intérêt de cette forme.

## 0.8 Ce que cette révision n'a pas fait

- **Aucune commande exécutée sur un serveur**, ni OVH ni ailleurs.
- **Aucun chiffrage de coût** : le volume disque manque (§0.7, étape 0).
- **La procédure d'installation de l'étape 1 n'est pas encore écrite** — c'est le livrable
  suivant, et elle sera **non éprouvée** tant que personne ne l'aura jouée.
- **Les §1 à §8 n'ont pas été réécrits.** Leurs mesures restent vraies au 11/08 et gardent
  leur valeur de constat. ⚠️ **Mais leur séquence d'exécution est caduque** : là où ils
  décrivent une bascule en place, c'est le §0 qui fait foi.

---

## 1. Inventaire de la production — 11/08/2026

### 1.0 Méthode

Toutes les commandes ci-dessous ont été **exécutées** sur `ubuntu@57.129.1.122`, en
lecture seule. Aucune écriture, aucun redémarrage, aucune fenêtre de maintenance.
Elles sont reproduites telles quelles pour être rejouables.

| # | Commande | But |
|---|---|---|
| 1 | `git remote -v` · `git branch --show-current` · `git log -1` · `git status --porcelain` | dépôt, branche, commit, divergence locale |
| 2 | `ls -la` de la racine Laravel et du vhost parent | copies manuelles, fichiers hors git |
| 3 | `find … -exec sha256sum` sur l'arbre applicatif | **écart de contenu** avec notre git |
| 4 | `git hash-object` sur les 294 fichiers | quels contenus prod existent dans notre historique |
| 5 | `grep` nominatif sur `.env` (valeurs des secrets **masquées**) | paramètres de la checklist |
| 6 | `SELECT` / `information_schema` sur `e-learning-prod` | schéma, table `migrations`, orphelins |
| 7 | `cat` du `.htaccess` front · `ls /etc/apache2/mods-enabled/` | routage SPA, cache, `mod_headers` |
| 8 | `ls ~/.ssh` · `sudo -n true` · `df -h` · `php -v` · `composer --version` | prérequis d'exécution |

### 1.1 Dépôt git de la production

| Élément | Valeur constatée |
|---|---|
| Chemin | `/var/www/api.efektiv-academie.com/e-learning-api` |
| Remote `origin` | `https://github.com/Rigictech/e-learning-api.git` (dépôt de l'ancienne équipe) |
| Branche | `main` |
| Dernier commit | `166a8b2b` — **01/08/2025**, auteur `RupakRigic`, « Subscription and video calling storing » |
| Fichiers suivis modifiés | **111** |
| Fichiers non suivis | **116** |
| Branches locales | 7, toutes `rupak-*` |

Le dépôt de la production **a plus d'un an de retard sur son propre remote d'origine**,
et le code servi n'a plus rien à voir avec ce commit : 111 fichiers suivis divergent.
C'est la définition d'un déploiement sans traçabilité.

**Hors git, dans la racine Laravel** — non suivis, donc **préservés** par un `checkout`,
mais à connaître :

```
app--25-02-2026/   app-08-05-2026/   app-old-13-11/   app-old-16-10/   config-07-11-2025/
.env   .env.backup-avant-debug-20260806-102004   routes/api.php-old   routes/api.php-25-02-2026
app/Http/Requests/ChangePasswordRequest.php--20-05-2026
app/Http/Requests/UpdateUserRequest.php--20.05.2026
```

> **Règle de sûreté reconduite : ne JAMAIS lancer `git clean`.** `git reset` et
> `git checkout` ne touchent pas aux fichiers non suivis ; `git clean` supprimerait
> les copies ci-dessus **et** les fichiers téléversés (certificats, avatars,
> ressources de cours).

### 1.2 Écart entre le code déployé et notre `main` — le constat central

Comparaison **par contenu** (empreinte SHA-256 de chaque fichier), pas par commit :
l'historique des deux lignées est trop divergent pour qu'un `git log` dise quoi que ce
soit. Périmètre : `app/ config/ database/ routes/ resources/ lang/ public/ bootstrap/
tests/ artisan composer.json composer.lock phpunit.xml package.json` — soit
**294 fichiers en production**.

| | vs `origin/main` | vs `origin/development` |
|---|---|---|
| Fichiers identiques | 138 | 131 |
| **Contenu différent** | **145** | 152 |
| Présents en prod seulement | 11 | 11 |
| Présents dans git seulement | 2 | **244** |

**Ce que ces chiffres disent :**

1. **`main` n'est pas en avance sur la production, il est à côté.** Trois fichiers
   applicatifs déployés en production existent dans `development` mais **pas dans
   `main`** : `app/Http/Middleware/SanitizeInput.php`,
   `app/Http/Requests/Auth/{VerifyOtpRequest,VerifySignupOtpRequest,ResendOtpRequest}.php`,
   `app/OpenApi/OpenApiSpec.php`. Déployer `main` en l'état **remplacerait le contenu de
   145 fichiers** — non pas parce que `main` serait plus ancien au calendrier (son dernier
   commit de code date du même jour que le dernier dépôt en prod), mais parce qu'il s'agit
   d'une **branche latérale** qui n'a jamais reçu une partie du travail. Détail chiffré
   ci-dessous.
2. **`development` couvre la production.** Les 11 fichiers « prod seulement » face à
   `development` sont : 2 fichiers de sauvegarde datés, 2 fichiers `bootstrap/cache/`
   (générés), 2 copies de `routes/api.php`, et **5 fichiers de l'ancien flux de
   réinitialisation par code OTP** (`CheckPasswordAge`, `ResetPasswordRequest`,
   `SendResetPasswordCode`, `ResetPasswordCode`, `send-reset-password-otp.blade.php`) —
   délibérément supprimés par #243 au profit du lien signé. Aucun code vivant unique.
3. **283 des 294 contenus de la production existent déjà dans notre historique git.**
   Les 11 restants sont : les 2 `bootstrap/cache/`, `routes/api.php-old`,
   `composer.json`, `composer.lock`, `config/mail.php`, `config/services.php`,
   `routes/api.php`, et 3 fichiers applicatifs
   (`UpdateQuestion.php`, `Learner/CertificateResource.php`, `QuizResource.php`).
   Vérifiés un par un : ce sont des variantes **plus anciennes ou éditées à la main**
   (`_links` HATEOAS commentés, `mews/purifier` et `protonemedia/laravel-xss-protection`
   absents du `composer.json`, `laravel/pint` en `^1.13`). **Rien d'irremplaçable.**

#### Aucune référence de notre git ne correspond à ce qui tourne

Question posée par Enguerran, et elle mérite un chiffre plutôt qu'une impression : **`main`
est-il le git qui porte la version actuellement en production ?** Non — et `development` non
plus. Mesure : pour chaque commit de notre historique, nombre de fichiers dont le contenu est
**exactement** celui déployé (287 fichiers comparables, les générés et les sauvegardes datées
étant écartés).

| Référence | Correspondance avec la production |
|---|---|
| `origin/main` (sommet, 11/08) | **148 / 287 — 51 %** |
| `cfad019` — dernier commit de code de `main`, **08/05/2026** | 148 / 287 — 51 % |
| `93fb247` — divergence `main` / `development`, 07/05/2026 | 147 / 287 — 51 % |
| **`6ae14d9` — `development`, 08/05/2026 : meilleur point de tout l'historique** | **214 / 287 — 74 %** |
| `origin/development` (sommet, 11/08) | 135 / 287 — 47 % |

**Le meilleur point de notre historique laisse encore 73 fichiers différents.** La production
n'est l'état d'aucun commit : elle a été assemblée à la main pendant un an — dépôts de fichiers
issus de branches et de dates diverses, plus des éditions faites directement sur le serveur
(`composer.json`, `config/mail.php`, `config/services.php`, `routes/api.php` ont un contenu qui
n'existe nulle part chez nous).

#### D'où vient l'écart : la production n'a jamais été déployée depuis notre git

| Date | Fait |
|---|---|
| 07/11/2025 | `AAZTEKDEV/EFEKTIVACADEMIE-BACK` est **créé** (« Initial commit », NumediaDev). Ce n'est pas un fork. |
| 27/01/2026 | Commit « **Moved to client repo** » (RupakRigic) : l'équipe externe **recopie** son code chez nous. Une copie, pas une bascule — elle continue de travailler sur `Rigictech/e-learning-api`. |
| 07/05/2026 | `main` et `development` divergent (`93fb247`). |
| 08/05/2026 | Dernier dépôt de fichiers en production (`mtime` de `app/`). |
| 01/08/2025 → aujourd'hui | Le `origin` du serveur est **resté** `Rigictech/e-learning-api`, figé, avec ses branches `rupak-*`. |

Répartition des auteurs, qui dit à quoi sert chaque branche :

| Branche | Auteurs |
|---|---|
| `main` | 25 RupakRigic · 5 ronak-developer · 3 Tech · 3 AAZTEK · 1 NumediaDev → **la ligne de l'équipe externe**, arrêtée le 08/05 |
| `development` | **298 AAZTEK** · 41 RupakRigic · 27 Rigictech · 12 Tech · … → **notre ligne de travail** |

**Les déploiements manuels ne venaient donc pas de notre `main`.** Notre dépôt a été un miroir
du travail de l'équipe externe, jamais la source du déploiement. Ils déposaient les fichiers
depuis leur propre outillage, à des dates échelonnées (mars, mai), et éditaient certains
fichiers directement sur le serveur. C'est ce qui explique à la fois les 74 % de correspondance
maximale et les 26 % restants.

#### Y a-t-il quelque chose à récupérer de la production vers `main` ?

Question posée par Enguerran : plutôt que de déployer `main`, pourquoi ne pas **compléter `main`
avec ce que la prod a en plus** ? Vérifié — **il n'y a rien à récupérer.**

| Contrôle | Résultat |
|---|---|
| Surface d'API : routes déclarées | prod **152** · `main` **152** · `development` **195** |
| Routes présentes en prod et **absentes de `main`** | **0** |
| Routes présentes en prod et absentes de `development` | **3** — `password/otp/{resend,verify}`, `password/reset` : l'ancien reset par code OTP, **volontairement** remplacé par le lien signé (#243) |
| Contenus de fichiers prod existant déjà dans notre historique | **283 / 294** |
| Les 11 restants | 2 caches générés · 2 sauvegardes datées + `routes/api.php-old` (déchets) · 5 fichiers de config/route **édités à la main sur le serveur**, dont un `composer.json` **amputé** de `mews/purifier`, `protonemedia/laravel-xss-protection` et `darkaonline/l5-swagger`, et des `_links` HATEOAS commentés |

**Écrire ces divergences dans `main` importerait de la dérive, pas du travail** — un
`composer.json` amputé, du code commenté, des fichiers de sauvegarde — et obligerait ensuite à
arbitrer fichier par fichier, sur 145 fichiers, au moment du merge `development → main`, sur la
branche censée faire foi pour la production.

Ce qui manque à `main`, ce n'est pas la production : c'est `development`. Les trois fichiers que
la prod a et que `main` n'a pas (`SanitizeInput`, les `*OtpRequest`, `OpenApiSpec`) **sont déjà
dans `development`** — le merge #301 les récupère seul, sans reprise manuelle.

L'intuition reste juste sur un point, et c'est la **variante A** du §4 : garder une trace de
l'état déployé. La différence est *où* on l'écrit. Dans une branche `prod/etat-<date>` séparée,
on obtient l'ancre de retour arrière et la transparence **sans polluer `main`**.

#### Ce n'est pas une question d'ancienneté, mais de lignée

Le dernier commit de code de `main` (`cfad019`) date du **08/05/2026** — le jour même du dernier
dépôt en production (`mtime` de `app/`). `main` n'est donc pas « en retard » au calendrier.

`main` a divergé de `development` le **07/05/2026** (`93fb247`) ; depuis, `main` a **8** commits
en propre, `development` en a **357**. Surtout, trois lots ont été fusionnés dans `development`
**après** cette divergence alors qu'ils avaient été écrits avant — `Sanetization` (17/02),
Swagger (18-19/03). `main` ne les a donc jamais reçus, d'où les fichiers présents en production
et absents de `main`.

> **Conclusion opérationnelle — c'est le verrou du chantier.**
> Le `checkout` étant atomique, brancher la production sur `main` **déploie le contenu de
> `main`** : 145 fichiers changent. Ce n'est pas une bascule transparente, c'est une mise en
> production — et faite avant le merge `development → main`, elle emporterait du travail
> présent en prod. Le §3 en fait une porte, pas une recommandation.
>
> **Si l'objectif est une bascule réellement transparente, voir la variante A du §4, phase 3** :
> elle rebranche le serveur sans modifier un seul octet du code déployé.

**Note annexe** : `SanitizeInput.php` est présent sur le disque de la production mais
**n'est pas enregistré** dans son `bootstrap/app.php` (vérifié : aucune occurrence). Il
n'assainit donc rien aujourd'hui. `development` le branche bien
(`$middleware->appendToGroup('api', SanitizeInput::class)`) : la bascule **activera** ce
filtre. Ce n'est pas un effet de bord anodin — à recetter côté saisie riche (CKEditor).

### 1.3 `.env` de production

Lecture nominative, valeurs des secrets remplacées par leur longueur.

| Paramètre (checklist §2) | Valeur en production | Verdict |
|---|---|---|
| `APP_ENV` | `production` | ✅ |
| `APP_DEBUG` | `false` | ✅ (corrigé le 06/08) |
| `APP_LOCALE` / `APP_FALLBACK_LOCALE` | `fr` / `fr` | ✅ |
| `APP_URL` | `https://api.efektiv-academie.com` | ✅ |
| `APP_KEY` | posée (51 car.) | ✅ **ne jamais tourner sans plan** |
| `FRONT_URL` | `https://efektiv-academie.com` | ✅ (porte les liens des e-mails) |
| `DB_CONNECTION/HOST/PORT/DATABASE` | `mysql` / `127.0.0.1` / `3306` / **`e-learning-prod`** | ✅ |
| `DB_USERNAME` | `root` | ⚠️ compte applicatif dédié à prévoir (hors #181) |
| `QUEUE_CONNECTION` | **`sync`** | ⚠️ aucun worker, aucun cron (cf. doc 12, L5 n° 5) |
| `CACHE_STORE` / `SESSION_DRIVER` | `database` / `database` | ✅ |
| `MAIL_MAILER/HOST/PORT` | `smtp` / `smtp-relay.brevo.com` / `587` | ✅ |
| `MAIL_ENCRYPTION` | `tls` — `MAIL_SCHEME` **absent** | ⚠️ Laravel 12 lit `MAIL_SCHEME` |
| **`MAIL_FROM_ADDRESS`** | **`enguerran@voltairedigital.com`** | 🔴 **à changer — mais pas encore, voir §1.3.1** |
| `MAIL_FROM_NAME` | `"${APP_NAME}"` → « E-learning » | ⚠️ nom d'expéditeur à trancher |
| `SANCTUM_STATEFUL_DOMAINS` | **absent** | ✅ sans effet — l'API est en jetons, pas en mode SPA à cookie |
| `CORS_ALLOWED_ORIGINS` | **absent** | ✅ sans effet — `config/cors.php` fixe `allowed_origins => ['*']` en dur |
| `PUSHER_APP_ID` / `CLUSTER` | `1982744` / **`ap2`** | 🔴 **piège de build front, voir §1.5** |
| `STRIPE_KEY` / `SECRET` | posées (clés de test) | ✅ aucun paiement réel possible |
| `FILESYSTEM_DISK` | `local` | ✅ fichiers sur le disque du serveur |
| `LOG_LEVEL` | **`debug`** | ⚠️ verbeux en production |

#### 1.3.1 `MAIL_FROM_ADDRESS` : le changer aujourd'hui **aggraverait** la délivrabilité

L'objectif est bien `no-reply@efektiv-academie.com`. Mais le DNS du domaine cible n'est
pas prêt — vérifié au `dig` le 11/08 :

| Enregistrement | `efektiv-academie.com` | Effet |
|---|---|---|
| SPF | `v=spf1 include:mx.ovh.com **-all**` | **Brevo absent, et politique en échec strict** |
| DKIM Brevo (`brevo._domainkey`, `mail._domainkey`) | **aucune clé** | aucune signature alignée |
| DMARC | `v=DMARC1; p=none; rua=mailto:rua@dmarc.brevo.com` | authentification Brevo **commencée, jamais terminée** |

Le domaine actuellement utilisé (`voltairedigital.com`) est en `~all` — échec **souple**.
Le domaine cible est en `-all` — échec **dur**. **Basculer `MAIL_FROM_ADDRESS` sans
publier au préalable l'`include:spf.brevo.com` et la clé DKIM Brevo transformerait un
rejet silencieux en rejet certain**, sur tous les destinataires, pas seulement Gmail.

> C'est exactement le mécanisme de **#261**, mais avec un cran de gravité en plus.
> L'ordre est donc : (1) authentifier le domaine dans Brevo et publier les
> enregistrements chez OVH, (2) vérifier au `dig`, (3) **puis** changer
> `MAIL_FROM_ADDRESS`. Le point 12 de la checklist (un e-mail réel qui arrive **en
> boîte** sur un Gmail) est le seul contrôle qui fait foi.

### 1.4 Schéma MySQL de la production

Relevé le 11/08 (lecture seule). **Identique en tous points à l'état du 08/08** décrit
par `19_DIFF_SCHEMA_PROD.md` : rien n'a bougé, ce document reste la référence.

| Mesure | Valeur |
|---|---|
| MySQL | `8.0.46-0ubuntu0.22.04.3` |
| Base servie | `e-learning-prod` — 1,46 Mo, 58 tables, 442 colonnes |
| Moteur | **MyISAM : 58 / 58** |
| Clés étrangères | **0** (attendu : 64) |
| Table `migrations` | 62 lignes, `MAX(batch) = 5` (le recalage fautif du 06/08) |
| Volumétrie | 301 users · 54 cours · 2 128 inscriptions · 135 complétions · 313 leçons |

**Objets contrôlés un par un :**

| Objet | État | Conséquence |
|---|---|---|
| `courses.category_id` | **ABSENTE**, migration enregistrée en batch 1 | 🔴 filtre catalogue par catégorie → SQL 1054 → 500 **aujourd'hui, en prod** (#141) |
| `courses.slug`, `users.last_login_at`, `users.cta_url`, `enrollments.is_complete`, `lessons.support_type_id` | absentes, migrations **non enregistrées** | ✅ se comblent au prochain `migrate` |
| `login_events`, `lesson_views`, `quiz_attempts`, `media_progress`, `resource_downloads`, `support_types` | **6 tables absentes** | ✅ créées par `migrate` — **mais** : ce sont elles qui déclenchent l'errno 1824 (#157) |
| `courses.order_no`, `users.is_active` | **présentes**, migrations non enregistrées | ✅ gardes `hasColumn` désormais présentes dans `development` (vérifié) — le piège errno 1060 du §8 de la doc 19 est levé |

**Références pendouillantes** (elles font échouer la création des FK en errno 1452,
prérequis de #136) :

| Relation | Lignes orphelines | Traitée par |
|---|---|---|
| `subcategories → categories` | 1 | `2026_08_08_200001_ea136_reliquat_orphelins` |
| `lessons → sections` | 89 | `2026_08_08_150001_ea136_purge_supports_orphelins` |
| `lessons → courses` | **16** | ⚠️ **aucune migration dédiée** — mais recoupement fait : **les 16 sont intégralement inclus dans les 89**, donc purgés par ricochet. Vérifié : `0` leçon orpheline de cours survivrait à la purge. |
| `users → users (parent_id)` | 5 | `2026_08_09_130001_ea160_assainir_parent_id_orphelins` (détachement, pas suppression) |
| `enrollments → courses` / `→ users` | 0 / 0 | — |

> Le point `lessons → courses` a été soulevé puis **levé par la mesure**, pas par
> raisonnement. Il reste à confirmer lors de la répétition #156 : si l'ordre des
> migrations plaçait une création de FK **avant** la purge, les 16 redeviendraient
> bloquants.

### 1.5 Front de production et Apache

| Élément | État |
|---|---|
| Racine servie | `/var/www/efektiv-academie.com` |
| Bundle servi | `index-ivHaxGJI.js` — dépôt du **01/07/2026** |
| `.htaccess` | rewrite SPA **présent**, **aucune règle de cache** |
| `mod_headers` | **chargé** (`/etc/apache2/mods-enabled/headers.load`) — rien à activer |
| `mod_rewrite`, `mod_deflate` | chargés |

**Point 7 de la checklist** : il ne reste donc qu'à **ajouter le bloc de cache** au
`.htaccess` de la prod — le module est déjà là. Le bloc de référence est celui posé sur
le staging le 10/08 (`<Files "index.html">` en `no-cache, must-revalidate`,
`<FilesMatch>` des assets hachés en `immutable`).

**Ce que `rsync --delete` supprimerait** — inventorié : 14 images à la racine du front de
prod (`8600461.png`, `about-banner-min.png`, `about_1/2.png`, `banner-bg.png`,
`banner-girl.png`, `banner-img.png`, `grid-1..4.png`, `instructor-demo.png`,
`new-learning-logo.png`, `new-logo.png`) datant de décembre 2025. **Vérifié : aucune
n'est référencée par le code source du front** (`banner-img.png` remonte en `grep` mais
il s'agit de `/images/lesson-banner-img.png`, qui vit dans `public/images/` et sera bien
déployé). Ce sont des restes de l'ancienne vitrine. La sauvegarde du dossier servi les
couvre ; **`--exclude=.htaccess` reste obligatoire**.

🔴 **Piège de build front — `PUSHER_APP_CLUSTER`.** Le `.env.example` du dépôt front
propose `eu`. Le `.env` de production porte **`ap2`** (Mumbai — l'application Pusher de
l'ancienne équipe). Un build de prod avec `eu` produirait un chat silencieusement
inopérant : la garde de build (`vite.config.js`, FRONT#45) vérifie la **présence** des
5 variables, pas leur **exactitude**. Les 5 valeurs de PROD à utiliser :

```
VITE_APP_API=https://api.efektiv-academie.com/e-learning-api/public/api
VITE_APP_MEDIA_URL=https://api.efektiv-academie.com/e-learning-api/public/storage/
VITE_APP_PROFILE_URL=https://api.efektiv-academie.com/e-learning-api/public/storage/
VITE_APP_PUSHER_APP_KEY=<clé de l'app Pusher 1982744 — à lire dans le .env de la prod>
VITE_APP_PUSHER_APP_CLUSTER=ap2
```

### 1.6 Prérequis d'exécution sur le serveur

| Élément | État | Remarque |
|---|---|---|
| `sudo` sans mot de passe pour `ubuntu` | ✅ | |
| Espace disque `/` | 413 Go libres sur 467 | largement suffisant |
| `~/backups/` | 168 Mo, sauvegardes **staging uniquement** | aucune sauvegarde prod récente |
| Clé de déploiement existante | `~/.ssh/deploy_efektiv_back` + alias `github-efektiv-back` | ⚠️ c'est la clé du **staging** (déclarée sur GitHub le 06/08, lecture seule) |
| PHP | 8.2.30 | ✅ |
| **Composer** | **2.2.6 (février 2022)** | ⚠️ à vérifier pendant #156 — voir blocant B6 |
| git | 2.34.1 | ✅ |
| node | **absent** | ✅ conforme : le front se construit en local |
| Uptime | 431 jours | information, pas action |


### 1.7 Le staging : ni une copie de la prod, ni une copie de `main`

Question posée le 11/08. C'était **le staging de l'équipe externe**, déployé de la même manière
artisanale, sur son dépôt `Rigictech` et sur sa **propre base** `elearning-staging` (88 users /
43 cours — sans rapport avec les 301 / 54 de la production). Le 06/08, il a été repointé sur
notre dépôt (doc 15).

État vérifié le 11/08, en lecture seule :

```
origin  github-efektiv-back:AAZTEKDEV/EFEKTIVACADEMIE-BACK.git
branche development · commit 924ea6e (11/08) · fichiers suivis divergents : 0
DB_DATABASE=elearning-staging
```

**C'est la preuve que la manœuvre fonctionne** : le staging est passé de 318 fichiers divergents
à 0. La production, c'est le même geste — avec une base MyISAM non convertie et de vrais
clients, d'où les précautions du §4.

---

## 2. État de `main` — ce qui doit se passer avant la bascule

| | BACK | FRONT |
|---|---|---|
| `development` en avance sur `main` | **355 commits** | **218 commits** |
| `main` en avance sur `development` | 8 commits | 13 commits |
| Diff `main` ↔ `development` | 549 fichiers, +86 481 / −4 962 | 341 fichiers |
| **Essai de merge `development → main`** (à blanc, dépôt jetable) | ⚠️ **2 conflits** : `app/Http/Resources/QuizResource.php`, `app/Mail/StandaloneQuizAvailableMail.php` | ✅ **aucun conflit** |

Les commits que `main` porte seuls sont, côté BACK, les 3 merges Dependabot
(#283/#284/#285 — déjà réalignés sur `development` par la PR #295) plus 2 commits
« minor changes » de l'ancienne équipe ; côté FRONT, 13 commits de l'ancienne lignée
(`Illustrator-text-remove`, « Minor change », `Delete .env`…).

⚠️ **4 PR Dependabot ouvertes ciblent `main` sur le BACK** (#296 à #299 :
`laravel/framework`, `symfony/http-foundation`, `symfony/routing`, `dompdf`). Chacune
mergée creuse à nouveau l'écart. À traiter **avant** le merge `development → main`, ou à
re-cibler — pas à laisser dériver.

---

## 3. La porte — ordre non négociable

> ⚠️ **SECTION DÉPASSÉE — voir §0 (révision du 23/08).** Elle décrit la bascule *en
> place*, abandonnée. Conservée parce que ses mesures et son raisonnement restent utiles.

Chacune de ces conditions a été établie par la mesure, pas par prudence. **Aucune ne se
contourne.** L'ordre dépend de la variante retenue en phase 3.

### Variante A — rebrancher sans rien changer (recommandée)

```
   P0 · décision #307 : qui déploie la prod, le runbook ou la CI ?
        ↓
   P1 · commit d'état `prod/etat-<date>` constitué et poussé      (§4 phase 3, variante A)
        ↓
   ═══ REBRANCHEMENT (§4, phases 0-3A puis 8) ═══     ← aucun code ne change
        ↓
   … puis, dans une fenêtre distincte, le déploiement de `main` selon la variante B
```

La porte est courte parce que le geste est inoffensif : rien du code déployé n'est modifié.
#136, #157, #141-#144 et le DNS Brevo **ne conditionnent pas** ce rebranchement — ils
conditionnent le *déploiement*, qui vient après.

### Variante B — bascule et déploiement dans la même fenêtre

```
   contre-recette globale L4 + L4.5 sur staging            (Régime 2, doc 13)
        ↓
   correctifs de contre-recette mergés dans development
        ↓
   P0 · décision #307 : `deploy.yml` ne part pas sur `main` en l'état
        ↓
   P1 · merge development → main, sur LES DEUX dépôts      (#301 — sinon régression)
        ↓
   P2 · répétition #156 réussie 2x d'affilée sur copie     (sinon on improvise le jour J)
        + son SCRIPT de conversion InnoDB livré           (aujourd'hui inexistant, cf. 3 ter)
        ↓
   P3 · DNS Brevo publié et vérifié au dig                 (#302 — sinon MAIL_FROM aggrave)
        ↓
   P4 · décisions #141 / #142 / #143 / #144 tranchées      (comportement visible apprenant)
        ↓
   ═══ BASCULE + DÉPLOIEMENT (§4, phases 0-8) ═══
```

> **Pourquoi P1 d'abord et pas « on bascule puis on rattrape »** : en variante B, la bascule
> pose le code de `main` sur la production. Tant que `main` n'a pas reçu `development`, on
> remplace 145 fichiers par le contenu d'une branche latérale — dont l'API que sert le front
> déployé. Il n'y a pas de demi-mesure : le `checkout` est atomique.
>
> C'est exactement ce que la variante A évite, en rebranchant sur l'état déployé lui-même.

---

## 3 bis. Le plan en deux fenêtres (variante A retenue)

> ⚠️ **SECTION DÉPASSÉE — voir §0 (révision du 23/08).** Elle décrit la bascule *en
> place*, abandonnée. Conservée parce que ses mesures et son raisonnement restent utiles.

### Fenêtre 1 — rebrancher (≈ 30 min, aucun changement fonctionnel)

Phases 0, 1, 2, 3A puis 8. Les phases 4 à 7 sont sans objet : **aucun octet du code déployé
n'est modifié**. Prérequis : la seule décision **#307** (que `deploy.yml` ne s'active pas).
Ni #301, ni #136/#157, ni les décisions de schéma, ni le DNS Brevo — ils ne conditionnent que
le *déploiement*.

Ce qu'on gagne le jour même :

- le serveur est sur **notre** dépôt, avec une clé de déploiement en lecture seule à nous ;
- `git status` est **propre** — donc à partir de cet instant, **tout dépôt de fichier à la main
  sur le serveur devient visible**. C'est la fin de la dérive silencieuse ;
- on dispose d'une **ancre de retour arrière native** : `prod/etat-<date>` est, par construction,
  l'état qui fonctionnait.

### Fenêtre 2 — déployer (≈ 90 min, c'est la vraie mise en production)

C'est là que `main` arrive sur le serveur : `git checkout -f -B main origin/main`, puis phases
4 à 8 au complet. **Toute la porte du §3 variante B s'applique alors** — #301 (merge), #156
(répétition), #136/#157 (InnoDB avant `migrate`), #141-#144 (décisions de schéma), #302 (DNS
Brevo avant `MAIL_FROM`), plus la reconstruction et le dépôt du front.

Autrement dit : la fenêtre 1 ne supprime aucun blocant, elle les **déplace là où ils comptent**.
Elle sépare un geste sans risque (la plomberie) d'un geste qui en comporte (le changement de
version), au lieu de les jouer ensemble.

### Deux règles sur la branche d'état

> ⛔ **`prod/etat-<date>` ne se merge JAMAIS dans `main`.** Elle porte la dérive du serveur —
> `composer.json` amputé, `_links` commentés, éditions faites à la main. Sa fonction est d'être
> une **photo**, pas une ligne de développement. Un tag posé sur le même commit rend l'intention
> plus lisible encore.
>
> ⚠️ **Après la fenêtre 2, le retour arrière par git ne suffit plus.** `git checkout prod/etat-<date>`
> restaure le code, pas la base : les migrations et la conversion InnoDB, elles, ne se
> dé-jouent pas. Le dump de la phase 0 reste le seul retour arrière complet — c'est pourquoi la
> séquence de rollback du §4.9 traite le front, la base **et** le code, dans cet ordre.

### Et le front ?

Il n'y a **rien à rebrancher** côté front : le serveur n'en héberge pas le dépôt, seulement un
build déposé par `rsync`. Sa traçabilité vient d'ailleurs — le build est fait depuis un commit
connu, et le `.htaccess` doit être versionné (**FRONT#114**). Le front ne bouge donc qu'en
fenêtre 2.

---

## 3 ter. Ce qu'on répète, et ce qu'on prouve

Deux gestes, deux façons de s'assurer avant de toucher la production. Ils ne s'échangent pas :
on **répète** ce qui modifie la base, on **prouve** ce qui n'est censé rien modifier.

### La fenêtre 2 se répète — issue #156

La répétition générale sur copie de `e-learning-prod` est **ouverte, bloquante, et pas encore
jouée** au 11/08 (aucun compte rendu dans `docs/roadmap/`). Son principe : la production n'est
touchée qu'en **lecture** (`mysqldump`), tout le reste se passe sur une copie montée localement.
Risque nul.

Son critère d'acceptation est le bon et ne doit pas être assoupli : **la séquence complète passe
deux fois d'affilée**, la seconde en no-op, sans intervention manuelle, vérifications
fonctionnelles au vert. Tant que ce n'est pas atteint, la fenêtre 2 est reportée.

Trois précisions ajoutées à son protocole le 11/08, chacune capable d'invalider la répétition :

| # | Précision | Pourquoi |
|---|---|---|
| 1 | **Répéter sur `main` APRÈS le merge #301**, pas sur `development` | Ce qui partira en production, c'est `main`. Répéter sur `development` validerait un code qui n'est pas celui du jour J — 8 commits d'écart au minimum, et 2 conflits à résoudre entre les deux. |
| 2 | **`composer install` joué avec Composer 2.2.6**, la version du serveur | Une installation verte avec le Composer récent du poste de dev ne prouve rien sur une machine de 2022 face à 3 paquets jamais installés (#303). |
| 3 | **Contrôler l'ordre purge ↔ création de FK** sur `lessons.course_id` | Les 16 leçons orphelines de cours ne sont couvertes que **par ricochet** (incluses dans les 89 orphelines de section). Si une FK se crée avant la purge, elles redeviennent bloquantes en errno 1452. |

### ⛔ Le script de conversion InnoDB n'existe pas encore — livrable bloquant de #156

Vérifié le 11/08 : `development` porte **13 migrations** de purge d'orphelins et de rattrapage
(`ea136_*`, `ea137_*`, `ea141`-`ea144`, `ea152`, `ea160`), mais **aucune ne porte le
`ALTER TABLE … ENGINE=InnoDB`**. La conversion est prévue en SQL, hors migration.

Autrement dit, **la partie qui bloque tout (#136 / #157) est aujourd'hui la seule qui ne soit ni
versionnée, ni testée, ni rejouable.** C'est déjà un livrable explicite de #156 — « le script de
la séquence, idempotent, rejouable : le jour J, on exécute, on n'improvise pas ». Il faut le
traiter comme tel : **sans ce script, la fenêtre 2 se ferait à la main sur une base de
production.**

### Ce que la répétition ne couvrira pas

À savoir pour ne pas se croire couvert :

| Couvert par #156 | **Non** couvert — à vérifier autrement |
|---|---|
| Conversion InnoDB, purges, migrations, rattrapages | Le **build front** et ses 5 variables de prod (§1.5) |
| Chronométrage réel de chaque étape | `.htaccess`, `mod_headers`, cache SPA (point 7) |
| Diff de schéma avant / après (`scripts/schema/`) | La **délivrabilité e-mail** (#302) — seul un vrai Gmail juge (point 12) |
| Parcours apprenant et écrans manager sur la copie | Apache, permissions `www-data`, vhost |

### La fenêtre 1 ne se répète pas — elle se prouve

Elle ne touche ni la base, ni les dépendances, ni Apache : il n'y a rien à répéter. En revanche,
son caractère inoffensif se **démontre avant** de la jouer, ce qui vaut mieux qu'une répétition.

```bash
# 1. Re-relever l'empreinte de l'arbre déployé (lecture seule sur la prod)
ssh ubuntu@57.129.1.122 'cd /var/www/api.efektiv-academie.com/e-learning-api &&
  find app config database routes resources lang public bootstrap tests        artisan composer.json composer.lock phpunit.xml package.json -type f -print0   | sort -z | xargs -0 sha256sum' > /tmp/prod-manifeste.txt

# 2. Même empreinte sur le commit prod/etat-<date> qu'on s'apprête à publier
git archive prod/etat-<date> | tar -x -C /tmp/candidat
(cd /tmp/candidat && find … -type f -print0 | sort -z | xargs -0 shasum -a 256) > /tmp/candidat-manifeste.txt

# 3. Les deux fichiers doivent être IDENTIQUES
diff /tmp/prod-manifeste.txt /tmp/candidat-manifeste.txt && echo "checkout prouvé no-op"
```

Si le `diff` est vide, le `checkout` de la phase 3A **ne peut pas** modifier un fichier — ce
n'est plus une espérance, c'est une propriété. Sur le serveur, un `git status` vide après le
`checkout` le confirme une seconde fois, par un autre chemin.

> Cette preuve a une date de péremption : elle vaut pour l'arbre tel qu'il est **au moment du
> relevé**. Si un dépôt de fichier à la main survient entre le relevé et la fenêtre, elle ne
> vaut plus. La relancer juste avant la fenêtre coûte deux minutes — et c'est le dernier moment
> où une dérive silencieuse peut encore passer inaperçue : après la fenêtre 1, `git status` la
> montrera.

---

## 4. Runbook de bascule

> ⚠️ **SECTION DÉPASSÉE — voir §0 (révision du 23/08).** Elle décrit la bascule *en
> place*, abandonnée. Conservée parce que ses mesures et son raisonnement restent utiles.

> **Fenêtre** : créneau creux, environ **90 minutes** dont ~30 de marge. La conversion
> InnoDB elle-même se compte en secondes (1,46 Mo) — c'est l'ordre des opérations et les
> vérifications qui prennent le temps.
>
> **Rôle de chaque phase** : chaque phase se termine par sa condition de sortie. Si elle
> n'est pas remplie, on applique le retour arrière de la phase et **on arrête** — on ne
> passe pas à la suivante « pour voir ».

### Phase 0 — Sauvegardes (aucune modification)

```bash
ssh ubuntu@57.129.1.122
cd /var/www/api.efektiv-academie.com/e-learning-api
TS=$(date +%Y%m%d-%H%M%S); mkdir -p ~/backups

# 1. Base
export MYSQL_PWD=$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)
mysqldump -u root --single-transaction --quick --routines --triggers \
  'e-learning-prod' > ~/backups/prod-db-$TS.sql
tail -1 ~/backups/prod-db-$TS.sql        # DOIT contenir « Dump completed »
unset MYSQL_PWD

# 2. Code back (hors vendor / storage — storage = 289 Mo de fichiers téléversés)
tar czf ~/backups/prod-code-$TS.tar.gz --exclude=vendor --exclude=storage \
  --exclude=node_modules -C /var/www/api.efektiv-academie.com e-learning-api

# 3. .env  (contient TOUS les secrets — ne jamais sortir du serveur)
cp .env ~/backups/prod-env-$TS.backup

# 4. Front servi — fait aussi office de retour arrière immédiat
sudo cp -a /var/www/efektiv-academie.com /var/www/efektiv-academie.com-backup-$TS

# 5. Trace du remote actuel
git remote get-url origin > ~/backups/prod-ancien-remote-$TS.txt

echo "HORODATAGE DE LA FENETRE : $TS"     # à noter, tout le rollback s'y réfère
```

**Sortie** : `Dump completed` présent, les 5 artefacts existent, l'horodatage est noté.
**Rollback** : sans objet (rien n'a été modifié).

> ⚠️ **Sauvegarde hors serveur.** Ces exports vivent sur la machine qu'ils protègent.
> Un **snapshot OVH** pris juste avant est le seul filet couvrant une perte du serveur —
> et il exige l'accès au manager OVH (§7, décision D1).

### Phase 1 — Mise en maintenance

```bash
sudo -u www-data php artisan down --render="errors::503" --retry=60
curl -s -o /dev/null -w "%{http_code}\n" \
  https://api.efektiv-academie.com/e-learning-api/public/api/plans     # 503 attendu
```

**Sortie** : l'API répond 503.
**Rollback** : `sudo -u www-data php artisan up`.

### Phase 2 — Clé de déploiement **dédiée à la production**

La clé `deploy_efektiv_back` existante est celle du **staging**. Une clé par
environnement permet de révoquer l'un sans casser l'autre.

```bash
ssh-keygen -t ed25519 -f ~/.ssh/deploy_efektiv_prod -N "" \
  -C "deploy-efektiv-prod@$(hostname)"

cat >> ~/.ssh/config <<'CFG'

Host github-efektiv-prod
  HostName github.com
  User git
  IdentityFile ~/.ssh/deploy_efektiv_prod
  IdentitiesOnly yes
CFG
chmod 600 ~/.ssh/config
cat ~/.ssh/deploy_efektiv_prod.pub
```

Déclaration côté GitHub, **en lecture seule**, depuis le poste d'Enguerran :

```bash
gh api repos/AAZTEKDEV/EFEKTIVACADEMIE-BACK/keys -X POST \
  -f title="Serveur OVH ns3233390 — PRODUCTION (lecture seule)" \
  -f key="<contenu de deploy_efektiv_prod.pub>" -F read_only=true
```

```bash
ssh -T github-efektiv-prod        # « successfully authenticated » attendu
```

**Sortie** : GitHub authentifie la clé et refuse toute écriture.
**Rollback** : `gh api repos/AAZTEKDEV/EFEKTIVACADEMIE-BACK/keys/<id> -X DELETE`, puis
retirer le bloc du `~/.ssh/config` et les deux fichiers de clé.

> Le dépôt **front n'a besoin d'aucune clé** : le serveur ne le clone pas, le build est
> fait en local et déposé par `rsync`.

### Phase 3 — Bascule du remote

> **Deux variantes. Le choix appartient à Enguerran** — il change la nature de la fenêtre.
>
> | | Variante A — **rebrancher sans rien changer** | Variante B — rebrancher **et** déployer |
> |---|---|---|
> | Geste | pointer le serveur sur un commit qui **est** l'état déployé | `checkout main` |
> | Octets modifiés sur le disque | **zéro** | 145 fichiers |
> | Ce qu'on gagne tout de suite | traçabilité, `git status` propre, retour arrière par git | idem + le code de `main` |
> | Risque du jour J | quasi nul — rien ne change fonctionnellement | celui d'une mise en production complète |
> | Phases 4, 5, 6, 7 | **inutiles** — aucun code ne change | obligatoires |
> | Prérequis #301 (merge `development → main`) | **non** | **oui** |
>
> **Recommandation : A**, puis déployer `main` comme un déploiement normal, recetté, dans sa
> propre fenêtre. C'est ce qui répond à l'objectif « la bascule doit être transparente » : elle
> sépare le *rebranchement* (plomberie, sans effet visible) du *déploiement* (changement de
> version, qui se recette). La B les mêle, et y ajoute la conversion InnoDB et les migrations.

#### Variante A — rebrancher sur l'état réellement déployé

Préalable, **hors fenêtre**, depuis le poste d'Enguerran : constituer dans notre dépôt un commit
qui reproduit exactement l'arbre de la production.

```bash
# 1. Récupérer l'arbre déployé (lecture seule sur la prod, hors vendor/storage)
ssh ubuntu@57.129.1.122 \
  "tar czf - -C /var/www/api.efektiv-academie.com --exclude=vendor --exclude=storage \
   --exclude=.git --exclude='app-old-*' --exclude='app--*' --exclude='app-08-*' \
   --exclude='config-07-*' e-learning-api" > /tmp/prod-tree.tar.gz

# 2. En faire une branche de notre dépôt, à partir du meilleur point commun
git checkout -b prod/etat-2026-08-11 6ae14d9      # 74 % identique, réduit le diff du commit
# … déballer /tmp/prod-tree.tar.gz par-dessus, puis :
git add -A && git commit -m "chore(prod): état exact déployé au 11/08/2026, avant bascule"
git push -u origin prod/etat-2026-08-11
```

**Avant d'entrer en fenêtre — la preuve d'équivalence (§3 ter).** Elle se relance juste avant,
et elle est éliminatoire :

```bash
diff /tmp/prod-manifeste.txt /tmp/candidat-manifeste.txt && echo "checkout prouvé no-op"
```

Un `diff` non vide signifie que l'arbre déployé a bougé depuis la constitution du commit :
**on ne rentre pas en fenêtre**, on reconstitue le commit d'état et on recommence.

Puis, **en fenêtre**, sur le serveur :

```bash
cd /var/www/api.efektiv-academie.com/e-learning-api
git remote set-url origin github-efektiv-prod:AAZTEKDEV/EFEKTIVACADEMIE-BACK.git
git fetch origin --prune
git checkout -f -B prod/etat-2026-08-11 origin/prod/etat-2026-08-11
git status --porcelain          # DOIT être vide : aucun fichier n'a changé
```

**Sortie** : `git status` vide **et** l'API répond 200 sans qu'aucun cache n'ait été vidé — c'est
la preuve que rien n'a bougé. Les phases 4 à 7 sont alors sans objet ; on passe à la phase 8.

> **Si `git status` n'est PAS vide**, ne pas « corriger » fichier par fichier : c'est que la
> preuve d'équivalence a été jouée sur un arbre différent de celui déployé. Retour arrière
> immédiat (`git remote set-url` vers l'ancien remote, phase 3), et on refait le commit d'état.
> Le fait que la fenêtre 1 ne modifie rien est précisément ce qui rend ce retour en arrière
> gratuit.

> Le commit d'état porte la trace des éditions faites à la main sur le serveur. C'est
> précisément ce qu'on veut : la première chose que la traçabilité doit capturer, c'est ce qui
> tourne, pas ce qu'on aurait aimé qui tourne. Le rattrapage vers `main` se fait ensuite, en
> diff lisible.

#### Variante B — bascule et alignement sur `main`

⛔ **Exige #301 clos** (merge `development → main`). Sans lui, cette variante régresse.

```bash
cd /var/www/api.efektiv-academie.com/e-learning-api

# Contrôle de ce qui sera écrasé / préservé — à relire AVANT d'exécuter
git status --porcelain | grep -c '^ M'    # suivis, seront écrasés
git status --porcelain | grep -c '^??'    # non suivis, seront PRÉSERVÉS
git check-ignore .env && echo ".env protégé"

git remote set-url origin github-efektiv-prod:AAZTEKDEV/EFEKTIVACADEMIE-BACK.git
git fetch origin --prune
git checkout -f -B main origin/main
git log -1 --format='%H %ad %s' --date=iso
```

**Sortie** : le commit affiché est bien le sommet de **notre** `main` post-merge P1 ;
`git status --porcelain | grep -c '^ M'` renvoie **0**.
**Rollback** :

```bash
git remote set-url origin $(cat ~/backups/prod-ancien-remote-<TS>.txt)
cd /var/www/api.efektiv-academie.com
sudo tar xzf ~/backups/prod-code-<TS>.tar.gz
```

### Phase 4 — Dépendances

```bash
sudo -u www-data composer install --no-dev --optimize-autoloader --no-interaction
```

> Les commandes s'exécutent sous **`www-data`** : `storage/` et `bootstrap/cache/` lui
> appartiennent, et le hook `package:discover` de composer y écrit. Lancées sous
> `ubuntu`, elles échouent en « Permission denied » (constaté sur le staging le 09/08).
>
> ⚠️ **Composer 2.2.6 (2022)** est en place. `mews/purifier`,
> `protonemedia/laravel-xss-protection` et `darkaonline/l5-swagger` arrivent avec notre
> `composer.json` et n'ont jamais été installés ici. **Ce point doit être levé pendant
> la répétition #156**, pas découvert en fenêtre de maintenance (blocant B6).

**Sortie** : `composer install` sort en 0, `vendor/autoload.php` régénéré.
**Rollback** : restauration de l'archive (phase 3) puis `composer install` sur l'ancien
`composer.lock`.

### Phase 5 — ⛔ Base : conversion InnoDB **AVANT** toute migration

> **L'ordre « code puis `migrate` » est FAUX en production.** Les tables L1/L2
> (`login_events`…) naissent en InnoDB et déclarent une FK vers `users`, qui est en
> MyISAM : MySQL refuse (**errno 1824**), la migration s'arrête, **base à moitié
> migrée**. Dix migrations passent avant celle-là. C'est l'issue **#157**, mesurée le
> 08/08 sur une réplique fidèle.

Séquence obligatoire — **le script issu de la répétition #156 fait foi**, ce qui suit en
est le squelette :

| # | Opération | Sans elle |
|---|---|---|
| a | Purger les références pendouillantes (1 + 89 + 5 lignes, cf. §1.4) | création des FK en **errno 1452** |
| b | `ALTER TABLE … ENGINE=InnoDB` sur les **58** tables | `migrate` en **errno 1824** |
| c | Résorber D2 / D3 / D4 (`lessons.course_id` en `bigint unsigned`, `learning_paths.course_type_id`, `courses.subcategory_id`) | FK correspondantes impossibles |
| d | `sudo -u www-data php artisan migrate --force` | — |
| e | Migration de rattrapage `courses.category_id` (#141) + décisions #142/#143/#144 | schéma toujours divergent |
| f | Créer les 64 clés étrangères manquantes | intégrité toujours absente |
| g | **Diff outillé** `scripts/schema/` ref ↔ prod | **aucune preuve** que le schéma est correct |

**Sortie** : `migrate --pretend --force` répond « Nothing to migrate » **ET** le diff de
`scripts/schema/diff_schema.py` ne rapporte aucun écart hors FK attendues.

> ⚠️ **« Nothing to migrate » ne prouve rien** : cette commande ne lit que la table
> `migrations` — c'est exactement ce qu'affichait la production le 06/08 alors que
> `courses.category_id` manquait. **Seul le diff (g) fait preuve.**
>
> ⛔ **Ne pas recaler la table `migrations`.** La procédure de recalage
> (`15_BASCULE_SERVEUR_VERS_GIT.md` §5) n'est légitime **qu'après** avoir prouvé objet
> par objet que le schéma est conforme. Le recalage du 06/08 est l'incident **#137** :
> il a enregistré 23 migrations jamais exécutées et masqué 11 divergences.

**Rollback base** :

```bash
export MYSQL_PWD=$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)
mysql -u root 'e-learning-prod' < ~/backups/prod-db-<TS>.sql
unset MYSQL_PWD
```

> La restauration **écrase** la base : toute écriture applicative survenue depuis le
> dump est perdue. C'est la raison pour laquelle la phase 1 (maintenance) précède.

### Phase 6 — Caches et permissions back

```bash
sudo chown -R www-data:www-data storage bootstrap/cache
sudo -u www-data php artisan config:clear
sudo -u www-data php artisan route:clear
sudo -u www-data php artisan view:clear

# Langue de l'API — DOIT afficher fr
grep -E '^APP_LOCALE=' .env || echo 'APP_LOCALE ABSENT — corriger avant de continuer'
```

> `config:cache` / `route:cache` (#210) **ne font pas partie de cette bascule**. Les
> ajouter le jour J mêlerait deux changements dans la même fenêtre. À poser une fois la
> bascule stabilisée.

**Sortie** : les trois `clear` sortent en 0, `APP_LOCALE=fr`.
**Rollback** : rejouer les `clear` après restauration du code.

### Phase 7 — Front

Build **en local**, avec les 5 variables de PROD (§1.5) — la garde de `vite.config.js`
refuse un build incomplet, **ne pas la contourner** :

```bash
cd EFEKTIVACADEMIE-FRONT
git checkout main && git pull --ff-only
npm ci --legacy-peer-deps          # Node 24 obligatoire (engine-strict)
cp .env .env.staging.bak 2>/dev/null || true
# renseigner les 5 variables de PROD dans .env, puis :
npm run build
grep -o 'index-[^"]*\.js' dist/index.html      # noter ce hash : c'est le témoin du point 13
```

Dépôt — **via un répertoire relais**, `.htaccess` exclu :

```bash
rsync -avz dist/ ubuntu@57.129.1.122:/tmp/front-prod-$TS/
ssh ubuntu@57.129.1.122 \
  "sudo rsync -a --delete --exclude=.htaccess /tmp/front-prod-$TS/ /var/www/efektiv-academie.com/ \
   && sudo chown -R www-data:www-data /var/www/efektiv-academie.com \
   && rm -rf /tmp/front-prod-$TS"
```

Puis **compléter le `.htaccess`** de la prod avec le bloc de cache du staging
(`mod_headers` est déjà chargé, §1.5) — édition manuelle, le rewrite SPA existant est
conservé tel quel.

**Sortie** : le hash du bundle servi a **changé** (point 13).
**Rollback** :

```bash
sudo rsync -a --delete /var/www/efektiv-academie.com-backup-<TS>/ /var/www/efektiv-academie.com/
```

### Phase 8 — Sortie de maintenance et vérifications (les 13 points)

```bash
sudo -u www-data php artisan up
```

| # | Contrôle | Attendu |
|---|---|---|
| 1 | Fenêtre annoncée, sauvegardes présentes | phase 0 OK |
| 2 | `.env` back relu nominativement | §1.3 |
| 3 | 5 variables de build front aux valeurs de PROD | §1.5, cluster **`ap2`** |
| 4 | Back déployé sous `www-data` | phases 3-6 |
| 5 | Requêtes non triviales rejouées sur MySQL | règle SQLite ≠ MySQL |
| 6 | Front déposé, `.htaccess` préservé | phase 7 |
| 7 | `.htaccess` : rewrite **+ cache** | à ajouter, §1.5 |
| 8 | `curl …/api/plans` | **200** |
| 9 | `curl …/api/invitations/aaaa…` | message **en français** |
| 10 | `curl https://efektiv-academie.com/<lien profond>` | **200** |
| 11 | `Cache-Control` présent sur `/` et sur un asset | `no-cache` / `immutable` |
| 12 | E-mail d'invitation réel **en boîte** sur un Gmail | pas yopmail — Gmail juge |
| 13 | Hash du bundle servi **a changé** | comparaison avant/après |

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://api.efektiv-academie.com/e-learning-api/public/api/plans
curl -s https://api.efektiv-academie.com/e-learning-api/public/api/invitations/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
curl -s -o /dev/null -w "%{http_code}\n" https://efektiv-academie.com/<lien-profond>
curl -sI https://efektiv-academie.com/ | grep -i cache-control
curl -s https://efektiv-academie.com/ | grep -o 'index-[^"]*\.js'
```

**Sortie** : les 13 points au vert. Sinon → rollback complet (§4.9).

### 4.9 Retour arrière complet

Dans cet ordre, l'inverse du déploiement :

```bash
sudo -u www-data php artisan down
# 1. Front
sudo rsync -a --delete /var/www/efektiv-academie.com-backup-<TS>/ /var/www/efektiv-academie.com/
# 2. Base
export MYSQL_PWD=$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)
mysql -u root 'e-learning-prod' < ~/backups/prod-db-<TS>.sql; unset MYSQL_PWD
# 3. Code
cd /var/www/api.efektiv-academie.com && sudo tar xzf ~/backups/prod-code-<TS>.tar.gz
cd e-learning-api
git remote set-url origin $(cat ~/backups/prod-ancien-remote-<TS>.txt)
cp ~/backups/prod-env-<TS>.backup .env
# 4. Dépendances et caches
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo chown -R www-data:www-data storage bootstrap/cache
sudo -u www-data php artisan config:clear && sudo -u www-data php artisan route:clear
sudo -u www-data php artisan up
```

Puis rejouer les points 8 à 13. **Le retour arrière n'est terminé que lorsqu'ils sont
verts** — un rollback non vérifié est un second incident.

---

## 5. Protection de branche

### 5.1 Ce que dit le plan actuel

Mesuré le 11/08 par appel API sur les deux dépôts :

```
GET /repos/AAZTEKDEV/EFEKTIVACADEMIE-BACK/branches/main/protection
→ 403 « Upgrade to GitHub Pro or make this repository public to enable this feature. »
```

Même réponse sur `development`, sur le dépôt FRONT, et sur l'API `rulesets`.

| Constat | Valeur |
|---|---|
| Propriétaire | `AAZTEKDEV`, compte **personnel** (`type: User`) |
| Visibilité des 2 dépôts | **privés** |
| Protection de branche active | ~~**aucune** — indisponible~~ ⚠️ **FAUX depuis (erratum #655)** : mesuré le 24-25/08, **les quatre branches** (`main`, `development`, `staging`) sont protégées, `enforce_admins` compris. Le trou réel est ailleurs : **le lint n'est requis nulle part** |
| `scripts/merge_train.sh` | seul point d'application aujourd'hui |

### 5.2 Ce que la protection devrait demander

Sur `main` en priorité, `development` ensuite :

- [ ] PR obligatoire — pas de push direct
- [ ] au moins une revue approuvée avant merge
- [ ] branche à jour avec la base avant merge
- [ ] **interdiction du force-push** sur `main` et `development`
- [ ] contrôles CI requis — **voir la limite ci-dessous**

#### La CI back existe — sur une branche, pas encore intégrée

Une première version de ce document affirmait qu'aucun workflow n'existait sur le dépôt BACK.
**C'était faux** : le balayage des branches était tronqué. La CI est portée par
**`origin/pipeline`** (LSCHOTT), 8 workflows, **verte le 11/08** après une série de rouges les
07 et 10/08.

| Workflow | Rôle |
|---|---|
| `gitleaks.yml` | détection de secrets — bloque la suite |
| `sast.yml` | analyse statique de sécurité (CodeQL) |
| `lint.yml` · `test.yml` · `build.yml` | Pint · `artisan test --coverage --min=70` · build |
| `dev-pipeline.yml` | orchestrateur, sur push de n'importe quelle branche |
| `main-pipeline.yml` | orchestrateur, **sur push vers `main`**, se terminant par `deploy.yml` |

**Ce qui reste vrai pour la protection de branche** : `origin/pipeline` est basée sur la lignée
de **`main`** (merge-base `93fb247` du 07/05) — **10 commits en avance, 357 en retard** sur
`development`. Tant qu'elle n'est pas intégrée, aucun contrôle n'est disponible comme « check
requis ». Deux écarts à traiter à l'intégration : la CI tourne en **PHP 8.3** quand la
production est en **8.2.30**, et le seuil `--min=70` de couverture reste à confronter à la
couverture réelle de `development`. → **#304**.

> ⛔ **Point de collision avec ce chantier — issue #307.** `main-pipeline.yml` se déclenche sur
> push vers `main` et se termine par un `deploy.yml` qui fait, en SSH :
> `git pull origin main` → `docker compose up -d --build` → `migrate --force` → `optimize`.
> Or **la production n'est pas conteneurisée** (Apache + PHP sur l'hôte, aucun `docker-compose`
> nulle part), et `migrate --force` échouerait en **errno 1824** sur la base MyISAM (#157).
>
> Ce qui empêche l'accident aujourd'hui est **une omission** : les 4 secrets `SERVER_*` ne sont
> pas renseignés (vérifié — la liste des secrets du dépôt est vide), donc l'étape échoue au lieu
> de déployer. Le jour où quelqu'un les renseigne pour « finir la CI », le mécanisme devient
> actif — et **#301 (merge `development → main`) déclencherait alors un déploiement en
> production non recetté**.
>
> À trancher avant toute intégration : **qui déploie la production, le runbook manuel ou la
> CI ?** Les deux ne peuvent pas faire foi en même temps.

Le FRONT, lui, a `.github/workflows/tests.yml` sur `development`, déclenché sur `pull_request`
et sur push vers `development` — pas encore sur `main` ; il y arrivera avec le merge P1.
Dependabot est désormais actif sur les deux dépôts.

### 5.3 Chiffrage

| Voie | Prix | Ce qu'elle apporte | Remarque |
|---|---|---|---|
| **GitHub Pro** sur le compte `AAZTEKDEV` | **4 $/mois** (~48 $/an) | branches protégées, relecteurs requis, CODEOWNERS sur dépôts **privés** | Le plan est attaché au **compte**, pas au dépôt : **un seul abonnement couvre les deux dépôts**. C'est la voie la moins chère et la plus rapide. |
| **Organisation + GitHub Team** | **4 $/utilisateur/mois** | idem + gestion d'équipes, rôles, propriété au nom de la société | Rejoint la décision déjà posée de sortir les dépôts d'un compte personnel (cf. reprise d'infra du 06/08). Coût = 4 $ × nombre de membres. |
| Rendre les dépôts publics | 0 $ | idem | **Exclu** : code client. |

**Recommandation** : GitHub Pro **maintenant** (4 $/mois, effet immédiat, débloque #181),
et migration vers une organisation **au moment de la reprise contractuelle**, pas dans la
même fenêtre. Faire les deux ensemble mêlerait un changement de propriété des dépôts à
une mise en production.

> **Aucune configuration n'a été posée.** L'activation du plan et des règles attend le
> go d'Enguerran (décision D4, §7).

---

## 6. Blocants

Chaque ligne est portée par une issue. Le **CA de l'issue fait foi**, ce tableau n'en est
que l'index.

| # | Blocant | Gravité | Issue | État au 11/08 |
|---|---|---|---|---|
| **B1** | Aucune référence de notre git ne correspond à la prod : `main` 51 %, `development` 47 %, meilleur point de l'historique 74 %. `main` est une branche latérale à laquelle 3 lots de `development` n'ont jamais été fusionnés. Basculer sur `main` avant le merge = régression. | 🔴 bloquant en variante B · sans objet en variante A | **#301** | merge à blanc : **2 conflits** côté BACK, aucun côté FRONT |
| **B2** | Base en MyISAM 58/58, 0 FK : `migrate` échoue en errno 1824 sur `create_login_events_table` | 🔴 bloquant | **#136** / **#157** | inchangé ; migrations de purge des orphelins présentes dans `development` |
| **B3** | Table `migrations` recalée à tort le 06/08 (batch 5) ; `courses.category_id` enregistrée mais absente → **500 en production dès qu'un apprenant filtre le catalogue** | 🔴 bloquant | **#137** / **#141** | inchangé ; migration de rattrapage prête, **décision de backfill non tranchée** |
| **B4** | 10 divergences silencieuses de type / nullabilité / défaut, dont `quizzes.passing_percentage` (seuil de réussite) | 🟠 décision | **#144**, **#142**, **#143** | à trancher avant la fenêtre |
| **B5** | `MAIL_FROM_ADDRESS` : le domaine cible `efektiv-academie.com` est en SPF **`-all` sans Brevo**, sans DKIM Brevo. Changer maintenant **aggrave** la délivrabilité. | 🔴 bloquant | **#261** (à élargir au domaine cible) | DMARC posé, authentification Brevo **inachevée** |
| **B6** | Composer **2.2.6 (2022)** sur le serveur, face à un `composer.json` qui introduit 3 paquets jamais installés ici | 🟠 à lever | **à créer (L5)** | à valider pendant #156 |
| **B7** | La CI back existe (`origin/pipeline`, LSCHOTT, verte le 11/08) mais n'est ni sur `development` ni sur `main` → « CI verte requise » reste inconfigurable ; CI en PHP 8.3 contre 8.2 en prod | 🟠 à lever | **#304** | 10 commits en avance, **357 en retard** sur `development` |
| **B13** | `main-pipeline.yml` déclenche sur push vers `main` un `deploy.yml` qui fait `git pull` + `docker compose up` + `migrate --force` en SSH — sur une prod **ni conteneurisée ni convertie**. Inerte aujourd'hui **seulement** parce que les secrets `SERVER_*` ne sont pas renseignés. | 🔴 bloquant | **#307** | à trancher **avant** #301 : le merge deviendrait sinon un déploiement prod |
| **B8** | 4 comptes de test actifs en production, comptés dans tous les indicateurs | 🟠 à traiter | **#190** | inchangé |
| **B9** | Liste de blocage Brevo : 74 adresses, dont de vrais apprenants clients | 🟠 avant prod | **#292** | déjà au jalon L5 |
| **B10** | Répétition générale sur copie de `e-learning-prod` non réalisée | 🔴 bloquant | **#156** | ouverte, critère « 2 passages d'affilée » non atteint |
| **B14** | **Le script de conversion InnoDB n'est ni versionné ni testé** : 13 migrations couvrent purges et rattrapages, aucune ne porte le `ALTER … ENGINE=InnoDB`. La seule opération qui bloque tout est la seule qui ne soit pas rejouable. | 🔴 bloquant | **#156** (livrable) | à produire avant la fenêtre 2 |
| **B11** | `.htaccess` du front non versionné dans `public/` → le `rsync --delete` continuera de l'emporter à chaque déploiement | 🟠 à lever | **à créer (FRONT, L5)** | vérifié : aucun `.htaccess` suivi dans le dépôt front |
| **B12** | Sauvegardes stockées sur le serveur qu'elles protègent ; pas de snapshot OVH maîtrisé | 🟠 à lever | **à créer (L5)** | 413 Go libres, mais aucun export hors machine |

Points **soulevés puis levés par la mesure**, consignés pour ne pas être re-soulevés :

- `lessons → courses` : 16 orphelins, **tous inclus** dans les 89 `lessons → sections`
  déjà purgés (§1.4) ;
- `courses.order_no` et `users.is_active` : le piège errno 1060 du §8 de la doc 19 est
  **levé**, les gardes `hasColumn` sont dans `development` ;
- `CORS_ALLOWED_ORIGINS` et `SANCTUM_STATEFUL_DOMAINS` absents du `.env` : **sans effet**,
  aucune des deux n'est lue par le code ;
- 14 images à la racine du front de prod : **non référencées**, leur suppression par
  `rsync --delete` est sans conséquence.

---

## 7. Décisions et accès attendus d'Enguerran

| # | Sujet | Pourquoi c'est bloquant | Ce qui est demandé |
|---|---|---|---|
| **D1** | **Compte OVH** — à quel nom, et accès au manager | Le snapshot avant fenêtre est le seul filet couvrant une perte du serveur. Sans lui, on descend d'un niveau de sûreté. | Accès manager OVH, ou décision assumée de s'en passer |
| **D2** | **Brevo** — accès au tableau de bord + zone DNS `efektiv-academie.com` chez OVH | Sans `include:spf.brevo.com` et la clé DKIM, `no-reply@efektiv-academie.com` part en échec SPF **dur** (§1.3.1) | Go pour authentifier le domaine, et qui publie les enregistrements |
| **D3** | **Adresse et nom d'expéditeur définitifs** | `MAIL_FROM_NAME` vaut « E-learning » aujourd'hui | Confirmer `no-reply@efektiv-academie.com` + le nom affiché |
| **D4** | **Plan GitHub** — Pro (4 $/mois) maintenant, organisation plus tard ? | ~~Sans plan payant, aucune protection de branche n'est activable (§5)~~ ⚠️ **PRÉMISSE MORTE — 3ᵉ occurrence dans ce document.** La protection est **active et vérifiée** sur les trois branches (24-25/08, erratum #655). La question du plan reste ouverte pour **d'autres** motifs (organisation → *issue types*, indisponibles sur un compte User), pas pour la protection | Go pour l'abonnement Pro ; l'activation des règles suivra — **à ré-instruire sur la bonne prémisse** |
| **D5** | **Décisions de schéma #141 / #142 / #143 / #144** | Toutes visibles par les apprenants : catégories du catalogue, réponses de quiz de plus de 125 caractères, points de gamification, seuil de réussite | Un arbitrage par issue, avant la fenêtre |
| **D6** | **Application Pusher `1982744`, cluster `ap2`** | Elle appartient probablement à l'ancienne équipe. Sa perte casse le chat, en prod comme dans le build front | Décision : conserver, ou recréer une app Pusher à notre nom avant la bascule |
| **D7** | **Date de la fenêtre** | ~90 min, hors trafic, avec les phases 0 à 8 d'affilée | Un créneau, et qui est disponible pendant |
| **D0** | **Variante A ou B en phase 3** — rebrancher sans rien changer, puis déployer à part ; ou tout faire dans la même fenêtre | C'est le choix qui détermine la longueur de la porte du §3 et le risque du jour J | Trancher A ou B (recommandation : **A**) |
| **D9** | **Qui déploie la production** : le runbook manuel, ou la CI de `origin/pipeline` (#307) ? | Les deux ne peuvent pas faire foi en même temps ; `deploy.yml` s'activerait au merge `development → main` | Trancher, et neutraliser `deploy.yml` d'ici là |
| **D8** | **Sort des 4 comptes de test (#190)** et des copies manuelles `app-old-*` sur le serveur | Les premiers faussent les indicateurs, les secondes encombrent sans être supprimables par git | Marquer / désactiver ; archiver puis retirer les copies |

---

## 8. Ce que ce runbook n'a pas fait, volontairement

- **Aucune écriture sur la production** — ni fichier, ni base, ni service, ni `.env`.
- **Aucun redémarrage**, aucune mise en maintenance.
- **Aucune configuration GitHub** — pas d'abonnement, pas de règle, pas de clé posée.
- **Aucun correctif de code** : chaque écart découvert devient une issue L5, aucun n'a
  été corrigé au fil de l'eau.
- **Aucun merge** `development → main` : seulement un essai à blanc, dans un dépôt
  jetable, jamais poussé.
