- README: status bump to sprint 3, test counts refreshed (164/86/105), IPv6 note for the e2e runner - CHANGELOG: sprint 3 entry under [Unreleased] (multi-tech model + matrix endpoint + auto-save UI); sprint 2 moved to its own [Sprint 2] section (merged 2026-05-27) - tasks/lessons.md: 6 lessons captured (2-pass spec-review, inline summary scoping, "test in brief means test in commit" discipline, SQLite batch_alter_table, real migration round-trip, modal Apply 0 disambiguation) - tasks/todo.md: status flipped to 🟢 SPRINT COMPLET, execution sequence ticks updated with commit hashes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
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
Simulationn'a plusmitre_technique_idnimitre_technique_name(scalaires). Remplacés partechniques(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_idnon null →techniques = [{id, name}], sinontechniques = [] - drop les deux anciennes colonnes
- migration réversible (downgrade : prendre le premier élément, ré-injecter dans les scalaires, drop
techniques)
- ajoute la colonne
- AC-13.3 : sérialisation simulation expose
techniques: [{id, name, tactics: [...]}]— le backend enrichit chaque entrée avec sestacticsdepuis le service MITRE au moment du serialize (snapshot d'id+nameen DB, tactics dérivées au runtime depuis le bundle). - AC-13.4 :
PATCH /api/simulations/<sid>accepte{technique_ids: ["T1059", "T1078"]}(liste d'IDs string). Backend valide chaque ID contre le bundle MITRE, résoutname, écrit[{id, name}]en DB. ID inconnu → 400{error: "unknown technique id: T9999"}. - AC-13.5 : la règle d'auto-transition
pending → in_progresss'applique aussi àtechnique_idsquand 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 seulMitreTechniquePickerdu sprint 2, un composantMitreTechniquesFieldaffiche :- 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."
- Liste des techniques sélectionnées sous forme de chips/tags (id + name, ex :
- 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 +2si 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-softquand sélectionnée, indentpl-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-labelledbysur 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
MitreTechniquePickerest conservé dans la base de code MAIS sa signature passe en clean rewrite (onSelect({id, name})au lieu deonChange(id, name)), wrappé parMitreTechniquesFielden 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 :techniques: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list) - Stockage :
[{"id": "T1059", "name": "Command and Scripting Interpreter"}, ...]. Pas detacticsen DB (dérivé au serialize).
Service workflow (backend/app/services/simulation_workflow.py) — mise à jour RBAC field-level OBLIGATOIRE
- Dans le
REDTEAM_FIELDSfrozenset 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 pourtechnique_idsrepose intégralement sur ce frozenset. - Le
SOC_FIELDSfrozenset reste inchangé. - Tester explicitement :
test_simulations_techniques.pydoit inclure "SOC PATCH technique_ids → 403" (cf. liste de tests plus bas).
Migration Alembic 0003_simulation_techniques_array.py
- Upgrade :
- Ajouter colonne
techniques(JSON, nullable=True temporaire, default'[]') —op.add_columndirect OK. - Data migration : pour chaque ligne, si
mitre_technique_idIS NOT NULL → settechniques = '[{"id":"<id>","name":"<name>"}]', sinon'[]'. - ALTER column
techniques→ nullable=False — OBLIGATOIRE viaop.batch_alter_table('simulations', ...)car SQLite ne supporte pas ALTER COLUMN nativement. - Drop columns
mitre_technique_id,mitre_technique_name— OBLIGATOIRE viaop.batch_alter_table('simulations', ...)(même raison : SQLite ne supporte pas DROP COLUMN hors batch mode).
- Ajouter colonne
- 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
techniquessi non vide, droptechniques. - 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
tacticsdepuismitre_svc.get_tactics(id). Si la technique a été retirée du bundle MITRE entre-temps,tactics = [](gracieux). commandsreste tel quel (text brut, inchangé sprint 2).
- Avant retour, enrichir chaque tag avec
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]:Sub-techniques embarquées sous chaque parent (relation STIX[ {"tactic_id": "TA0001", "tactic_name": "Initial Access", "techniques": [ {"id": "T1078", "name": "Valid Accounts", "subtechniques": [{"id": "T1078.001", "name": "Default Accounts"}, ...]}, ... ]}, ... ]subtechnique-ofdans le bundle). Si la technique n'en a pas,subtechniques: []. Ordre des tactiques : canonical MITRE Enterprise order (12 tactics). Lecture depuis les objets STIXx-mitre-tacticordonnés parx_mitre_shortnamenatif OU constante module-level hardcodée si plus simple. Ordre des techniques au sein d'une tactique : alphabétique parname(déterministe, lisible).
API (backend/app/api/simulations.py)
GET /api/mitre/matrix— nouvel endpoint, 200 + tree, 503 si bundle absent.PATCH /api/simulations/<sid>: le payload accepte maintenanttechnique_ids: list[str]à la place demitre_technique_id+mitre_technique_name. Validation : tous les IDs doivent exister dans le bundle (400 sinon),namesnapshot servi parlookup_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_idsen 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_idsnon vide (≥1 élément) compte comme redteam-side filled, déclenchepending → 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 exposetechniquesavectactics. - PATCH avec ID inconnu → 400.
- Auto-transition sur
technique_idsnon 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é (T1059en premier).
- Création + PATCH
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 touchaitmitre_technique_id/mitre_technique_name.- Migration : test que les anciennes simulations en
pendingavec un id mono-tech sont upgradées entechniques: [{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, ajoutertacticssi manquant).- Ajouter
MitreTactic:{tactic_id: string, tactic_name: string, techniques: MitreMatrixTechnique[]}avecMitreMatrixTechnique = {id: string, name: string, subtechniques: {id: string, name: string}[]}. Simulation.techniques: MitreTechnique[]à la place demitre_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'avecmake 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, dansfrontend/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 deonChangedu parent — le composant fait son propre PATCH viauseUpdateSimulation.) - UI : liste de
<MitreTechniqueTag>+ 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
useUpdateSimulationavec{technique_ids: [...]}. Toast succès'Techniques updated', toast erreur sinon. Pendant le PATCH : disable l'interaction (les × deviennent grisés, les boutons disabled).
- Props :
-
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
initialSelectionmutée par les toggles, (b)expandedTechniques: Set<string>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 setexpandedTechniques), 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 parenttext-[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, backdropbg-ink/60, containerbg-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) :
useEffectau mount →searchInputRef.current?.focus().onKeyDownau niveau du container modale :Tabsans shift sur le dernier élément focusable →preventDefault()+ focus le premier.Shift+Tabsur le premier →preventDefault()+ focus le dernier.- Récupérer la liste des focusables via
container.querySelectorAll('a, button, input, [tabindex]:not([tabindex="-1"])'), ignorer ceuxdisabledouhidden.
- Pas de focus restoration ni de live region — out of scope V1.
- Pas de dépendance npm.
- Escape → onCancel. Click backdrop → onCancel.
- Props :
-
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/techniqueNameen entrée (le picker est désormais un sélecteur "one-shot" qui se réinitialise après chaque sélection).
- Avant :
Pages
-
SimulationFormPage.tsx: remplacer le<MitreTechniquePicker>standalone par un<MitreTechniquesField simulationId={sim.id}>. Le statert.techniquesdisparait 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 (disabledprop). -
SimulationList.tsx: colonne MITRE — affichertechniques[0]?.id + (techniques.length > 1 ?+${techniques.length - 1}: ''). Sitechniquesest 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 signatureonSelect. - Adapter
SimulationFormPage.test.tsx(sprint 2) — assertions surtechniquesarray 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/matrixest 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)
- Storage multi-tech : colonne JSON
[{id, name}](KISS, patterncommandssprint 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.
- API shape :
PATCHreç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. - Rétrocompat : migration backfill
[{id, name}]depuis les scalaires. Pas de rétrocompat API. - MitreTechniquePicker : clean rewrite de la signature (
onSelect({id, name})). - Matrix layout : colonnes par tactique, 220px fixe, scroll horizontal global.
- Apply de la modale matrice : auto-save immédiat (PATCH déclenché par
MitreTechniquesFieldquand le modal renvoie sa liste viaonApply). Add/remove via tag × ou Quick Search aussi auto-save. - Sprint 4 framing (anticipation, NE PAS implémenter dans sprint 3) : Dark mode (toggle + tokens dark + persistence) + Hygiène process UI (
design-revieweragent + 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 lessonscreenshots mandatoryest déjà appliquée en sprint 3 dans le brief frontend (§3).
7. Plan d'exécution
- ✅ User a validé les 8 décisions §6 (2026-05-27).
- ✅ Team-lead a mis à jour SPEC.md (§0).
- ✅ Spec-reviewer : APPROVED WITH NOTES après 2 passes (5 items au total, tous traités).
- ✅ Backend-builder : commits
b5ea292+673b25e(model + migration + matrix endpoint + 503 unloaded, 162 passing). - ✅ Frontend-builder : commit
771483f(MitreTechniquesField + MitreMatrixModal + tags + auto-save + screenshots, 84 passing). - ✅ Code-reviewer : APPROVED WITH NITS (2 MINORs + 4 NITs).
- ✅ Post-review fixes :
4596f09+393b6edbackend (164 passing) +39f4076frontend (86 passing). - ✅ Test-verifier : commit
df8a6b6(105/106 sprint 3 e2e verts, 1 pré-existant sprint 1 — DB pollution, non-régression). - 🟡 Team-lead : récap + PR en cours.
- 🔵 Backend-builder : modèle + migration + endpoints + tests.
- 🔵 Frontend-builder : composants + page update + tests Vitest. Screenshots obligatoires avant "done".
- 🔵 Code-reviewer : LSP-first.
- 🔵 Test-verifier : e2e US-13 → US-16 + adaptation sprint 2.
- 🟢 Team-lead : PR + récap.