# Sprint 3 — MITRE matrix modal + multi-technique simulations **Branche** : `sprint/3-mitre-matrix` **Statut** : 🟢 SPRINT COMPLET — 105/105 sprint 3 e2e verts, code-review traité, PR prête **Base** : `main` @ `e1d9738` **Objectif** : remplacer la sélection MITRE mono-technique de sprint 2 par une sélection multi-techniques avec deux modes complémentaires : autocomplete (rapide) et matrice cliquable (exploration). Les techniques choisies s'affichent comme tags sur la simulation. --- ## 0. Évolution SPEC.md à acter en début de sprint SPEC.md § Simulation dit aujourd'hui "Type d'attaque MITRE correspondant (peut être une liste de référence)" au singulier. Le team-lead met à jour cette ligne en début de sprint (pas de PR séparée) pour refléter le scope multi-techniques. Texte cible : > Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. L'évolution est tracée dans CHANGELOG.md § Changed du sprint 3. --- ## 1. User stories ### US-13 — En tant que redteam, je sélectionne plusieurs techniques MITRE par simulation **Pourquoi** : un test couvre souvent plusieurs TTPs (ex : Initial Access → Discovery → Execution). Mono-technique limite la description réelle d'un test. **Critères d'acceptation** - [ ] AC-13.1 : modèle `Simulation` n'a plus `mitre_technique_id` ni `mitre_technique_name` (scalaires). Remplacés par `techniques` (colonne JSON, liste d'objets `{id: str, name: str}`, défaut `[]`). - [ ] AC-13.2 : migration Alembic `0003_simulation_techniques_array.py` : - ajoute la colonne `techniques` (JSON) - backfill les simulations existantes : si `mitre_technique_id` non null → `techniques = [{id, name}]`, sinon `techniques = []` - drop les deux anciennes colonnes - migration réversible (downgrade : prendre le premier élément, ré-injecter dans les scalaires, drop `techniques`) - [ ] AC-13.3 : sérialisation simulation expose `techniques: [{id, name, tactics: [...]}]` — le backend enrichit chaque entrée avec ses `tactics` depuis le service MITRE au moment du serialize (snapshot d'`id`+`name` en DB, tactics dérivées au runtime depuis le bundle). - [ ] AC-13.4 : `PATCH /api/simulations/` accepte `{technique_ids: ["T1059", "T1078"]}` (liste d'IDs string). Backend valide chaque ID contre le bundle MITRE, résout `name`, écrit `[{id, name}]` en DB. ID inconnu → 400 `{error: "unknown technique id: T9999"}`. - [ ] AC-13.5 : la règle d'auto-transition `pending → in_progress` s'applique aussi à `technique_ids` quand la liste reçue est non vide. ### US-14 — En tant que redteam, je vois et retire les techniques d'une simulation sous forme de tags **Pourquoi** : visualiser rapidement la couverture TTP d'un test. **Critères d'acceptation** - [ ] AC-14.1 : sur `SimulationFormPage`, à la place du seul `MitreTechniquePicker` du sprint 2, un composant `MitreTechniquesField` affiche : - Liste des techniques sélectionnées sous forme de chips/tags (id + name, ex : `T1059 — Command and Scripting Interpreter`), avec un `×` cliquable pour retirer chaque technique. - Bouton "Add technique" qui ouvre la modale matrice (US-15). - Bouton "Quick search" qui ouvre l'autocomplete existant (réutilisation du `MitreTechniquePicker`) en mode "ajoute à la liste" (sélection = append, pas replace). - État vide : message "No techniques selected — use the matrix or the quick search to add." - [ ] AC-14.2 : retirer un tag (× sur le chip) déclenche un PATCH immédiat (auto-save) avec la liste mise à jour. La modale matrice (US-15) auto-save aussi via "Apply". Le picker Quick Search auto-save chaque sélection. Toast `'Techniques updated'` sur succès, toast erreur sinon. Pas de bouton Save manuel pour les techniques. - [ ] AC-14.3 : sur `SimulationList` (table dans EngagementDetailPage), la colonne "MITRE" affiche un compteur + premier tag (ex : `T1059 +2` si 3 techniques sélectionnées). Si la liste est vide, afficher `—`. - [ ] AC-14.4 : ordre des tags dans la simulation préservé entre lecture et écriture (pas de tri imposé côté serveur). - [ ] AC-14.5 : tags affichés avec les couleurs/spacing DESIGN.md (`bg-primary-soft`, `text-primary-deep`, `rounded-full`, `px-md py-xxs`). ### US-15 — En tant que redteam, j'ouvre la matrice MITRE ATT&CK pour explorer et sélectionner des techniques **Pourquoi** : l'autocomplete est efficace si on sait ce qu'on cherche ; la matrice est nécessaire pour "voir ce qui existe" et combiner par tactique. **Critères d'acceptation** - [ ] AC-15.1 : nouvel endpoint `GET /api/mitre/matrix` (auth, tous rôles) → tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Chaque technique top-level embarque ses sub-techniques (`T1059` → `[T1059.001, T1059.002, ...]`). Ordre des tactiques = ordre canonique MITRE Enterprise (Initial Access → Execution → Persistence → ... → Exfiltration → Impact). 503 si bundle non chargé. - [ ] AC-15.2 : composant `MitreMatrixModal` : - Modal large (≥ 1100px), scroll vertical interne. - Layout horizontal en colonnes : 1 colonne par tactique. Header de colonne = nom de la tactique + compteur de techniques sélectionnées dans cette tactique (sub-techniques incluses). - Chaque technique top-level = bouton/cellule cliquable. État sélectionné visible (`bg-primary` + texte blanc). - Si la technique a des sub-techniques (`subtechniques.length > 0`), un chevron (▸/▾) précède le nom. Click sur le chevron = expand/collapse (n'affecte PAS la sélection). Click sur le label = toggle sélection de la technique top-level. - Sub-techniques affichées en cascade indentée sous leur parent quand expand. Cliquables individuellement (toggle de la sub). État visuel distinct : `bg-primary-soft` quand sélectionnée, indent `pl-md`, font-size légèrement plus petit. - Sélectionner une sub-technique ne sélectionne PAS le parent (les deux sont indépendants côté data). Mais le compteur de tactique somme parent + subs sélectionnées. - Champ de recherche en haut du modal qui filtre les techniques affichées (case-insensitive sur id ET name). Quand le filtre matche une sub-technique, son parent est automatiquement expand pour la rendre visible. - Boutons en footer : "Cancel" (ferme sans appliquer), "Apply N techniques" (compteur = total parents + subs sélectionnés). - [ ] AC-15.3 : la modale est ouverte depuis le bouton "Add technique" de US-14. Elle reçoit en input la liste actuelle de techniques sélectionnées et travaille sur une copie locale ; "Apply" déclenche directement le PATCH (auto-save, cf AC-14.2) et ferme la modale ; "Cancel" jette le diff local. - [ ] AC-15.4 : Escape ferme la modale (= Cancel). Click sur le backdrop = Cancel. - [ ] AC-15.5 : a11y V1 — **scope minimal explicite** : (1) focus initial sur le champ recherche à l'ouverture, (2) Tab cycle entre les éléments focusables de la modale (wrap : dernier élément → premier), (3) Escape ferme = onCancel, (4) ARIA `role="dialog"` + `aria-labelledby` sur le titre. Full WAI-ARIA dialog conformance (live regions, focus restoration au close, screen reader announcements détaillés) **out of scope V1** — c'est une dette assumée à reprendre dans un sprint a11y dédié. ### US-16 — En tant que user (tous rôles), j'utilise les autres fonctionnalités sans régression **Critères d'acceptation** (régression) - [ ] AC-16.1 : workflow sprint 2 (auto-transition, transitions manuelles, RBAC SOC) inchangé — tous les ACs sprint 2 (US-7 → US-12) continuent de passer. - [ ] AC-16.2 : l'ancien `MitreTechniquePicker` est conservé dans la base de code MAIS sa signature passe en clean rewrite (`onSelect({id, name})` au lieu de `onChange(id, name)`), wrappé par `MitreTechniquesField` en mode append. - [ ] AC-16.3 : aucune e2e sprint 1/sprint 2 ne casse. Quelques assertions sprint 2 (US-8 et US-10) qui validaient le mono-technique sont mises à jour pour refléter la liste. --- ## 2. Brief technique — Backend Builder **Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs (team-lead). ### Livrables **Modèle `Simulation`** (`backend/app/models/simulation.py`) - Remplacer `mitre_technique_id`, `mitre_technique_name` (str nullable) par : ```python techniques: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list) ``` - Stockage : `[{"id": "T1059", "name": "Command and Scripting Interpreter"}, ...]`. Pas de `tactics` en DB (dérivé au serialize). **Service workflow** (`backend/app/services/simulation_workflow.py`) — **mise à jour RBAC field-level OBLIGATOIRE** - Dans le `REDTEAM_FIELDS` frozenset existant : **retirer** `"mitre_technique_id"` et `"mitre_technique_name"`, **ajouter** `"technique_ids"`. - Sans ce changement : un user soc qui PATCH avec `{technique_ids: [...]}` reçoit un silent no-op (champ ignoré) au lieu du 403 attendu. La gate field-level RBAC pour `technique_ids` repose intégralement sur ce frozenset. - Le `SOC_FIELDS` frozenset reste inchangé. - Tester explicitement : `test_simulations_techniques.py` doit inclure "SOC PATCH technique_ids → 403" (cf. liste de tests plus bas). **Migration Alembic `0003_simulation_techniques_array.py`** - Upgrade : 1. Ajouter colonne `techniques` (JSON, nullable=True temporaire, default `'[]'`) — `op.add_column` direct OK. 2. Data migration : pour chaque ligne, si `mitre_technique_id` IS NOT NULL → set `techniques = '[{"id":"","name":""}]'`, sinon `'[]'`. 3. ALTER column `techniques` → nullable=False — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** car SQLite ne supporte pas ALTER COLUMN nativement. 4. Drop columns `mitre_technique_id`, `mitre_technique_name` — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** (même raison : SQLite ne supporte pas DROP COLUMN hors batch mode). - Downgrade : symétrique avec les MÊMES guards batch_alter_table pour les étapes ALTER/DROP. Recrée les 2 colonnes, prend le premier élément de `techniques` si non vide, drop `techniques`. - Pattern à suivre : la migration `0002_add_simulations.py` (sprint 2) — vérifier le style batch_alter_table déjà en place. **Serializer** (`backend/app/serializers.py`) - `serialize_simulation(sim)` : - Avant retour, enrichir chaque tag avec `tactics` depuis `mitre_svc.get_tactics(id)`. Si la technique a été retirée du bundle MITRE entre-temps, `tactics = []` (gracieux). - `commands` reste tel quel (text brut, inchangé sprint 2). **Service MITRE** (`backend/app/services/mitre.py`) - Étendre l'index avec un dict `tactics_by_technique: dict[str, list[str]]` pour lookup O(1) au serialize. - Nouvelle fonction `get_tactics(technique_id: str) -> list[str]`. - Nouvelle fonction `lookup_name(technique_id: str) -> str | None` — utilisée par l'endpoint PATCH pour résoudre le name côté serveur (le client n'envoie que les IDs). - Nouvelle fonction `get_matrix() -> list[dict]` : ```json [ {"tactic_id": "TA0001", "tactic_name": "Initial Access", "techniques": [ {"id": "T1078", "name": "Valid Accounts", "subtechniques": [{"id": "T1078.001", "name": "Default Accounts"}, ...]}, ... ]}, ... ] ``` Sub-techniques embarquées sous chaque parent (relation STIX `subtechnique-of` dans le bundle). Si la technique n'en a pas, `subtechniques: []`. Ordre des tactiques : canonical MITRE Enterprise order (12 tactics). Lecture depuis les objets STIX `x-mitre-tactic` ordonnés par `x_mitre_shortname` natif OU constante module-level hardcodée si plus simple. Ordre des techniques au sein d'une tactique : alphabétique par `name` (déterministe, lisible). **API** (`backend/app/api/simulations.py`) - `GET /api/mitre/matrix` — nouvel endpoint, 200 + tree, 503 si bundle absent. - `PATCH /api/simulations/` : le payload accepte maintenant `technique_ids: list[str]` à la place de `mitre_technique_id` + `mitre_technique_name`. Validation : tous les IDs doivent exister dans le bundle (400 sinon), `name` snapshot servi par `lookup_name`. Pas de rétrocompat avec les anciens champs scalaires (clean break — pas d'utilisateur externe). - **Dedup serveur** : avant écriture en DB, dédupliquer la liste `technique_ids` en préservant l'ordre (`list(dict.fromkeys(technique_ids))`). Le client peut envoyer accidentellement des doublons (race UI ou bug), le serveur ne doit jamais persister deux fois la même technique. - Auto-transition (AC-13.5) : un `technique_ids` non vide (≥1 élément) compte comme redteam-side filled, déclenche `pending → in_progress`. Liste vide = pas de trigger. **Tests pytest** - `test_simulations_techniques.py` (nouveau) : - Création + PATCH `technique_ids` → simulation a la bonne liste, sérialisation expose `techniques` avec `tactics`. - PATCH avec ID inconnu → 400. - Auto-transition sur `technique_ids` non vide. - Retirer toutes les techniques (`technique_ids: []`) → pas de trigger d'auto-transition (cohérent avec règle "valeur vide"). - **Dedup** : PATCH avec `technique_ids: ["T1059", "T1078", "T1059"]` → DB ne stocke que 2 entrées, ordre préservé (`T1059` en premier). - `test_mitre.py` (existant) — ajouter : - `get_matrix()` renvoie les bonnes tactiques dans le bon ordre. - `lookup_name(unknown)` → None. - `get_tactics(known)` → liste correcte (≥1 tactique). - `test_simulations_crud.py` + `test_simulations_patch.py` + `test_simulations_workflow.py` (existants) — adapter toute assertion qui touchait `mitre_technique_id` / `mitre_technique_name`. - Migration : test que les anciennes simulations en `pending` avec un id mono-tech sont upgradées en `techniques: [{id, name}]` (fixture inline ou test direct sur Alembic). **Quality bar** : ruff + mypy clean, tous les tests existants + nouveaux verts. --- ## 3. Brief technique — Frontend Builder **Scope strict** : `frontend/` uniquement. **Note process (lesson learned sprint 2)** : avant de marquer la tâche terminée, lance le dev server et screenshot (a) la matrice modale ouverte avec ≥3 techniques sélectionnées et (b) la simulation form avec ≥2 tags affichés. Joins-les à ton summary final. ### Livrables **Types** (`frontend/src/api/types.ts`) - `MitreTechnique`: `{id: string, name: string, tactics: string[]}` (déjà existant pour le picker — réutiliser, ajouter `tactics` si manquant). - Ajouter `MitreTactic`: `{tactic_id: string, tactic_name: string, techniques: MitreMatrixTechnique[]}` avec `MitreMatrixTechnique = {id: string, name: string, subtechniques: {id: string, name: string}[]}`. - `Simulation.techniques: MitreTechnique[]` à la place de `mitre_technique_id` + `mitre_technique_name`. PATCH payload : `{technique_ids: string[]}`. **API client** (`frontend/src/api/mitre.ts`) - `searchMitreTechniques(q)` — existant, garder. - `getMitreMatrix()` — nouveau, GET `/api/mitre/matrix`. **Hooks** (`frontend/src/hooks/useMitre.ts`) - `useMitreSearch(q, enabled)` — existant, garder. - `useMitreMatrix(enabled)` — nouveau hook TanStack Query, `staleTime: Infinity` (la matrice ne change qu'avec `make update-mitre` + redémarrage). **Composants** - **`MitreTechniqueTag.tsx`** (nouveau) : chip affichant `{id} — {name}` avec un bouton `×`. Props : `technique: MitreTechnique`, `onRemove: () => void`, `disabled?: boolean`. - **`MitreTechniquesField.tsx`** (nouveau, dans `frontend/src/components/`) : conteneur qui orchestre la sélection multi-tech avec **auto-save** (PATCH déclenché par chaque add/remove/Apply). - Props : `value: MitreTechnique[]`, `simulationId: number`, `disabled?: boolean`. (Pas de `onChange` du parent — le composant fait son propre PATCH via `useUpdateSimulation`.) - UI : liste de `` + 2 boutons "Add technique" (ouvre matrix) et "Quick search" (ouvre/toggle picker autocomplete inline). - Dédup : si l'utilisateur essaye d'ajouter une technique déjà présente, no-op silencieux (pas de PATCH non plus). - Auto-save : chaque mutation (× sur tag, Apply matrice, sélection Quick Search) déclenche `useUpdateSimulation` avec `{technique_ids: [...]}`. Toast succès `'Techniques updated'`, toast erreur sinon. Pendant le PATCH : disable l'interaction (les × deviennent grisés, les boutons disabled). - **`MitreMatrixModal.tsx`** (nouveau) : modale matrice avec sub-techniques expand/collapse. - Props : `isOpen: boolean`, `initialSelection: MitreTechnique[]`, `onApply: (selection: MitreTechnique[]) => void`, `onCancel: () => void`. - État local : (a) copie de `initialSelection` mutée par les toggles, (b) `expandedTechniques: Set` pour les IDs parents dépliés. - Layout : flex horizontal scrollable, 1 colonne par tactique. Largeur fixe 220px par colonne pour cohérence visuelle. - Chevron `▸/▾` à gauche du nom des techniques qui ont des sub-techniques (`subtechniques.length > 0`). Click chevron = toggle expand (mute le set `expandedTechniques`), ne modifie PAS la sélection. - Click sur le label d'une technique top-level = toggle sa sélection (le chevron ne se déclenche pas dans ce cas — séparer les zones cliquables). - Sub-techniques rendues en cascade indentée sous leur parent quand expand : `pl-md text-[12px] bg-cloud rounded` (vs parent `text-[14px]`). Cliquables individuellement, sélection indépendante du parent. - Compteur header de tactique = nombre de techniques **parents + subs** sélectionnées dans cette tactique. - Champ recherche en haut : filtre case-insensitive sur id ET name. Une sub-technique matchée force l'expand de son parent (modifie automatiquement `expandedTechniques`). - Modale : `position: fixed`, backdrop `bg-ink/60`, container `bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden`. - Footer : "Cancel" (jette les changements locaux + ferme), "Apply N techniques" (compteur total ; click → onApply renvoie la liste complète, parent fait le PATCH auto-save US-14.2). - Focus trap (scope minimal V1, cf AC-15.5) : - `useEffect` au mount → `searchInputRef.current?.focus()`. - `onKeyDown` au niveau du container modale : - `Tab` sans shift sur le dernier élément focusable → `preventDefault()` + focus le premier. - `Shift+Tab` sur le premier → `preventDefault()` + focus le dernier. - Récupérer la liste des focusables via `container.querySelectorAll('a, button, input, [tabindex]:not([tabindex="-1"])')`, ignorer ceux `disabled` ou `hidden`. - Pas de focus restoration ni de live region — out of scope V1. - Pas de dépendance npm. - Escape → onCancel. Click backdrop → onCancel. - **`MitreTechniquePicker.tsx`** (existant, sprint 2) : clean rewrite de la signature. - Avant : `onChange(id: string | null, name: string | null)` qui remplaçait la valeur. - Après : `onSelect({id, name})` — un seul match sélectionné, le parent (MitreTechniquesField) gère l'append + le dédup. - Plus de prop `techniqueId`/`techniqueName` en entrée (le picker est désormais un sélecteur "one-shot" qui se réinitialise après chaque sélection). **Pages** - **`SimulationFormPage.tsx`** : remplacer le `` standalone par un ``. Le state `rt.techniques` disparait du form (les techniques ont leur propre cycle de save via le champ lui-même — auto-save). Le bouton "Save Red Team" continue de batcher tous les AUTRES champs (name, description, commands, etc.) mais ne touche pas aux techniques. Affichage read-only (rôle SOC) : afficher les tags sans `×`, boutons Add/Quick Search masqués (`disabled` prop). - **`SimulationList.tsx`** : colonne MITRE — afficher `techniques[0]?.id + (techniques.length > 1 ? ` +${techniques.length - 1}` : '')`. Si `techniques` est vide, afficher `—`. **Tests Vitest** - `MitreTechniqueTag.test.tsx` — render id+name, click × appelle onRemove. - `MitreTechniquesField.test.tsx` — affiche tags, "Add technique" ouvre le modal matrix, "Quick search" ouvre le picker, dédup silencieuse, remove via × appelle onChange avec liste mise à jour. - `MitreMatrixModal.test.tsx` — render colonnes par tactique, click toggle sélection, Apply renvoie liste, Cancel jette, Escape ferme, search filtre. - Adapter `MitreTechniquePicker.test.tsx` (sprint 2) à la nouvelle signature `onSelect`. - Adapter `SimulationFormPage.test.tsx` (sprint 2) — assertions sur `techniques` array au lieu de scalaire. **Quality bar** : typecheck + lint + vitest clean. ### Règles - Lit le summary backend EN PREMIER. - Pas d'invention d'endpoints — `GET /api/mitre/matrix` est le seul nouveau, déjà spec'd. - Réutiliser `LoadingState`, `ErrorState`, `ConfirmDialog`, `useToast`, action bar pattern (sprint 2) existants. - Respect DESIGN.md tokens (palette + spacing). Tags = `bg-primary-soft text-primary-deep rounded-full px-md py-xxs gap-xxs text-[14px]`. - Pas de nouvelle dépendance npm sans escalade au team-lead. --- ## 4. Brief — Test verifier E2e Playwright. Un fichier par US : - `us13-multi-techniques.spec.ts` — AC-13.1 → AC-13.5 (focus API + données) - `us14-techniques-tags.spec.ts` — AC-14.1 → AC-14.5 (UI tags + remove) - `us15-mitre-matrix-modal.spec.ts` — AC-15.1 → AC-15.5 (modal interaction + a11y) - `us16-regression-sprint2.spec.ts` — re-exécuter les ACs critiques sprint 2 (auto-transition US-8, workflow US-11, SOC restrictions US-9) avec le nouveau modèle. Mettre à jour les e2e sprint 2 qui assertaient `mitre_technique_id` / `mitre_technique_name` scalaires (US-8, US-10 selon le grep). --- ## 5. Definition of Done — Sprint 3 - [ ] Tous les AC US-13 → US-16 passent. - [ ] Backend tests verts (`pytest -q`). Ruff + mypy clean. - [ ] Frontend tests verts (`npm run test -- --run`). Typecheck + lint clean. - [ ] E2e Playwright suite verte (sprint 1 + 2 + 3). - [ ] Migration Alembic testée upgrade + downgrade. - [ ] SPEC.md mis à jour (multi-techniques acté). - [ ] README.md mis à jour (mention matrice + multi-tech dans la description workflow). - [ ] CHANGELOG.md sprint 3 entry sous [Unreleased]. - [ ] Code-reviewer sans BLOCKER. - [ ] **Frontend-builder a screenshot la matrice modale + la simulation form avec tags AVANT de marquer la tâche terminée (lesson learned sprint 2).** - [ ] PR ouverte + récap synthétique team-lead. --- ## 6. Décisions arrêtées (utilisateur 2026-05-27) 1. **Storage multi-tech** : colonne JSON `[{id, name}]` (KISS, pattern `commands` sprint 2). 2. **Sub-techniques dans la matrice** : OUI, affichées avec expand/collapse par technique parent. Sub-techniques sont aussi accessibles via Quick Search en plus. 3. **API shape** : `PATCH` reçoit `{technique_ids: ["T1059", "T1059.001", ...]}` — IDs uniquement (parents et subs au même niveau dans la liste). Backend résout names depuis le bundle. 4. **Rétrocompat** : migration backfill `[{id, name}]` depuis les scalaires. Pas de rétrocompat API. 5. **MitreTechniquePicker** : clean rewrite de la signature (`onSelect({id, name})`). 6. **Matrix layout** : colonnes par tactique, 220px fixe, scroll horizontal global. 7. **Apply de la modale matrice** : auto-save immédiat (PATCH déclenché par `MitreTechniquesField` quand le modal renvoie sa liste via `onApply`). Add/remove via tag × ou Quick Search aussi auto-save. 8. **Sprint 4 framing** (anticipation, NE PAS implémenter dans sprint 3) : Dark mode (toggle + tokens dark + persistence) + Hygiène process UI (`design-reviewer` agent + screenshot mandatory dans brief frontend-builder). Connecteur C2 reporté au-delà. Les builders sprint 3 N'ajoutent PAS de tokens dark, N'invoquent PAS le design-reviewer (qui n'existe pas encore). Seule la lesson `screenshots mandatory` est déjà appliquée en sprint 3 dans le brief frontend (§3). --- ## 7. Plan d'exécution 1. ✅ User a validé les 8 décisions §6 (2026-05-27). 2. ✅ Team-lead a mis à jour SPEC.md (§0). 3. ✅ Spec-reviewer : APPROVED WITH NOTES après 2 passes (5 items au total, tous traités). 4. ✅ Backend-builder : commits `b5ea292` + `673b25e` (model + migration + matrix endpoint + 503 unloaded, 162 passing). 5. ✅ Frontend-builder : commit `771483f` (MitreTechniquesField + MitreMatrixModal + tags + auto-save + screenshots, 84 passing). 6. ✅ Code-reviewer : APPROVED WITH NITS (2 MINORs + 4 NITs). 7. ✅ Post-review fixes : `4596f09` + `393b6ed` backend (164 passing) + `39f4076` frontend (86 passing). 8. ✅ Test-verifier : commit `df8a6b6` (105/106 sprint 3 e2e verts, 1 pré-existant sprint 1 — DB pollution, non-régression). 9. 🟡 Team-lead : récap + PR en cours. 4. 🔵 Backend-builder : modèle + migration + endpoints + tests. 5. 🔵 Frontend-builder : composants + page update + tests Vitest. Screenshots obligatoires avant "done". 6. 🔵 Code-reviewer : LSP-first. 7. 🔵 Test-verifier : e2e US-13 → US-16 + adaptation sprint 2. 8. 🟢 Team-lead : PR + récap.