Files
IdeA/.ideai/briefs/orchestration-v3-cadrage.md
Blomios 37e72747d3 feat(agent): orchestration v3 — surface MCP model-agnostic (M0→M4) — §14.3
Expose l'orchestration IdeA comme serveur MCP par-dessus le même
OrchestratorService::dispatch, avec repli fichier .ideai/requests pour les
CLI sans MCP. v3 réduite à la surface MCP : la messagerie inter-agents et la
corrélation requête↔réponse étaient déjà résolues par §17 (send_blocking).

- M0 capacité MCP sur le profil (McpCapability/McpConfigStrategy/McpTransport)
- M1 injection conf MCP au LaunchAgent + prose adaptée selon la surface
- M2 serveur/adapter MCP (JSON-RPC 2.0 maison ; outils idea_*) + ListAgents
- M3 câblage par projet (registre mcp_servers jumeau du watcher)
- M4 observabilité UI : OrchestratorRequestProcessed.source = file|mcp + badge

Trois portes d'entrée (fichier, MCP, UI) → un seul dispatch ; aucun nouveau
port applicatif ; MCP confiné à l'adapter infra. Tous lots verts (cycle §3).
Cadrage : .ideai/briefs/orchestration-v3-cadrage.md ; ARCHITECTURE.md §14.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 12:53:31 +02:00

281 lines
22 KiB
Markdown

# Cadrage Architecture — Orchestration v3 : invocation native d'agents (surface MCP)
> Produit par **Architect** en réponse au brief `orchestration-v3-invocation-native.md`.
> Cadrage **avant tout code** (méthode §3). Livrable : ce document + mise à jour `ARCHITECTURE.md` §14.3.
> Aucun code de production ici.
---
## 0. État réel du terrain — ce qui est DÉJÀ résolu (lu dans le code, pas présumé)
Le brief décrit trois faiblesses (conscience = prose, pas de discussion inter-agents,
fire-and-forget). **Deux des trois sont déjà comblées par le pivot §17** (livré, lots D0→D7).
Il faut le constater honnêtement pour ne **pas re-cadrer** ce qui existe :
| Faiblesse du brief | Statut réel | Référence code |
|---|---|---|
| Pas de discussion inter-agents (`agent.message` « future », `task` ignoré pour agent vivant) | ✅ **RÉSOLU** | `OrchestratorCommand::AskAgent` (`domain/src/orchestrator.rs`), `OrchestratorService::ask_agent` (`application/src/orchestrator/service.rs`) |
| Fire-and-forget, pas de corrélation requête↔réponse | ✅ **RÉSOLU sans outbox** : le rendez-vous synchrone est **intrinsèque** à `AgentSession::send()` (flux → `Final` déterministe). Le `Final` *est* la fin de tour. | `application/src/agent/structured.rs` (`send_blocking`), `domain/src/ports.rs` (`ReplyEvent::Final`) |
| Pas de réveil du demandeur / event de réponse | ✅ **RÉSOLU** | `DomainEvent::AgentReplied`, `OrchestratorResponse.reply` (`infrastructure/src/orchestrator/mod.rs`) |
| Conscience = soft prompt (l'agent doit deviner le schéma JSON) | ⚠️ **PARTIEL** : la prose `# Orchestration IdeA` est injectée (`compose_convention_file`), mais **aucun outil typé natif** n'est exposé. | `application/src/agent/lifecycle.rs` |
| Interdiction des subagents natifs **+** alternative native | ⚠️ **PARTIEL** : interdiction présente (prose) ; l'alternative native (outils `idea_*`) **manque encore**. | idem |
| Capacité MCP sur le profil | ❌ **ABSENT** | — |
| Serveur MCP / config MCP par CLI | ❌ **ABSENT** | — |
**Conclusion de cadrage** : l'orchestration v3 **n'est plus** « combler la messagerie inter-agents »
(c'est fait). Elle se réduit à **un seul chantier net** : **exposer l'orchestration IdeA comme
serveur MCP model-agnostic**, en tant qu'**adapter entrant supplémentaire** par-dessus le **même**
`OrchestratorService::dispatch`, avec **repli homogène** sur le protocole fichier `.ideai/requests`
(§14.3) + prose (`compose_convention_file`) pour les CLI sans MCP. C'est ce que cadre la suite.
> **Principe directeur (zéro régression, §9/§17.3)** : MCP est un **confort de conscience native**
> (outils typés, plus de schéma à deviner). Il **n'invente aucune sémantique** : tout outil MCP se
> ramène à un `OrchestratorCommand` déjà existant. La voie principale du *retour de valeur* reste
> §17 (`send_blocking`) ; MCP ne fait que **déclencher** `dispatch`, jamais re-router la réponse.
---
## 1. Décisions tranchées (les 4 points durs du brief)
### Décision 1 — Capacité MCP par runtime = champ optionnel `mcp` sur `AgentProfile` (Open/Closed)
**Tranché** : on ajoute un champ **optionnel** `mcp: Option<McpCapability>` sur `AgentProfile`,
exactement comme `session: Option<SessionStrategy>` et `structured_adapter: Option<StructuredAdapter>`
le sont déjà. `None` (défaut) ⇒ **repli fichier + prose** (comportement actuel, zéro régression).
`Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et l'agent voit les outils `idea_*`.
```rust
// domain/src/profile.rs — capacité MCP déclarative (pur, validé par constructeur, comme SessionStrategy)
/// Stratégie de matérialisation de la config MCP propre à UNE CLI : chaque CLI
/// déclare son serveur MCP différemment (fichier `.mcp.json` pour Claude Code,
/// flag de lancement, ou variable d'env). Déclaratif = donnée, pas code (§9).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "strategy")]
pub enum McpConfigStrategy {
/// Écrire un fichier de conf MCP au chemin (relatif au run dir isolé §14.1)
/// attendu par la CLI, au format JSON propre à cette CLI (ex. `.mcp.json`).
ConfigFile { target: String }, // relative_safe(target) — pas de `..`, pas d'absolu
/// Passer le serveur via un flag de lancement (ex. `--mcp-config {path}`).
Flag { flag: String }, // non_empty(flag)
/// Passer via une variable d'environnement.
Env { var: String }, // valid_env_var(var)
}
/// Capacité MCP d'un profil : COMMENT déclarer le serveur MCP IdeA à cette CLI,
/// et QUEL transport. `None` sur le profil ⇒ repli fichier `.ideai/requests` + prose.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCapability {
/// Comment matérialiser la config MCP au lancement (relatif au run dir).
pub config: McpConfigStrategy,
/// Transport du serveur MCP IdeA (détail invisible au domaine ; voir D3).
/// `stdio` = défaut robuste cross-OS ; `socket` = optimisation (point ouvert).
#[serde(default)]
pub transport: McpTransport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum McpTransport { #[default] Stdio, Socket }
```
Sur `AgentProfile`, additif et **non cassant** (sérialisation inchangée pour les profils sans MCP) :
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
```
Builder additif (comme `with_structured_adapter`) : `AgentProfile::new(...).with_mcp(cap)` ; la
signature de `AgentProfile::new` reste **inchangée** ⇒ tous les appels du catalogue/tests restent verts.
**Justification** : cohérence §9 (« ajouter une IA = donnée, pas code »), symétrie avec les deux autres
capacités optionnelles déjà sur le profil, `skip_serializing_if = None`**zéro régression** de
sérialisation. Le **prédicat de surface** est `profile.mcp.is_some()` — un seul point de vérité.
> **Modèle en couches (exigé par le brief §4.1)** : la surface effective d'un agent est
> `surface(agent) = if profile.mcp.is_some() { Mcp } else { FileProtocol }`. Les deux couches
> produisent le **même** `OrchestratorCommand`. Aucun agent n'est jamais bloqué : sans MCP, la prose
> `# Orchestration IdeA` + `.ideai/requests` reste pleinement fonctionnelle.
---
### Décision 2 — Retour synchrone d'`ask` = AUCUN nouveau modèle de corrélation : on réutilise `send_blocking`
**Tranché (et c'est la décision la plus importante)** : l'outil MCP `idea_ask_agent` **ne crée
aucune corrélation requête↔réponse, aucun outbox, aucun event de réveil neufs**. Il appelle le
**même** `OrchestratorService::dispatch(AskAgent { target, task })` que le watcher fichier, qui
**retourne déjà** `OrchestratorOutcome { reply: Some(content) }` via `send_blocking`. L'adapter MCP
**renvoie ce `content` inline** comme valeur de retour de l'outil. Fin.
Le brief (rédigé avant le pivot §17) supposait qu'`ask` était un point dur à résoudre via outbox +
corrélation fichier. **Le pivot §17 l'a déjà tranché autrement** : le `Final` du `ReplyStream` *est*
la fin de tour déterministe ; pas besoin de deviner, pas d'outbox, pas de `request_id`. On **n'y
revient pas**. Le tableau ci-dessous fige la sémantique, déjà implémentée :
| Aspect | Décision (déjà en place) | Code |
|---|---|---|
| **Corrélation** | Aucune : `dispatch` est un appel synchrone `async` ; la réponse est la valeur de retour. Le transport MCP (JSON-RPC) porte nativement la corrélation requête/réponse. | `service.rs::ask_agent` |
| **Outbox** | **Supprimé de la voie principale** (§17.4). Pas réintroduit. | — |
| **Event `AgentReplied`** | **Observabilité UI uniquement** (« Architect a répondu à Main »), best-effort, ne porte pas la valeur. | `reply_outcome` |
| **Timeout** | Borné (`ASK_AGENT_TIMEOUT = 300 s`). À l'expiration : `AgentSessionError::Timeout` → la cible **reste vivante** (non tuée), l'outil MCP renvoie une **erreur typée** ; l'appelant décide. | `send_blocking` |
| **Cible a déjà une session vivante** (one-live-session-per-agent) | `ask` **réutilise** la session structurée vivante (`session_for_agent`) — rendez-vous direct, pas de respawn. Si la cible est vivante en **PTY brut** (profil sans `structured_adapter`) ⇒ erreur typée explicite (**jamais** un ACK trompeur). | `service.rs::ask_agent` étapes 1→3 |
| **Cible morte** | `LaunchAgent` en mode structuré (background) puis `send_blocking`. Garde d'unicité sur **les deux** registres. | idem |
**Justification** : DRY radical (une seule logique de rendez-vous, partagée par UI chat, watcher
fichier et MCP) ; frontière nette (le domaine ne connaît qu'un `prompt` et un `Final`, jamais un
transcript ni un id de corrélation) ; universalité (marche pour toute CLI structurée Claude/Codex).
**Le seul travail v3 ici est de brancher l'outil MCP sur `dispatch` — pas de re-cadrer le rendez-vous.**
> **Conséquence produit** : `idea_ask_agent` cible **toujours un agent structuré** (Claude/Codex),
> cohérent avec le menu restreint §17.3/§17.6. Un agent **demandeur** peut être n'importe quelle CLI
> MCP (Claude, Codex, Gemini…) ; un agent **cible** d'un `ask` doit être structuré. C'est déjà
> l'invariant en vigueur — MCP ne le change pas.
---
### Décision 3 — MCP vs subagents natifs : on garde l'interdiction ET on offre l'alternative native, config injectée par CLI au lancement
**Tranché** : l'interdiction des subagents natifs (prose `# Orchestration IdeA`) **reste** — elle
protège l'identité/mémoire/observabilité IdeA. Mais on offre désormais la **vraie alternative
native** : les outils `idea_*` apparaissent dans la liste d'outils de la CLI. La prose est **adaptée
selon la surface** :
- agent **MCP** (`profile.mcp.is_some()`) : la prose pointe vers les outils `idea_ask_agent` /
`idea_launch_agent` / `idea_list_agents` (au lieu d'« écris un JSON dans `.ideai/requests` »).
- agent **fichier** (`mcp == None`) : prose `.ideai/requests` actuelle, **inchangée**.
**Injection de la config MCP par CLI = au `LaunchAgent`, dans le run dir isolé (§14.1), via la
`McpConfigStrategy`** — exactement le même point et la même mécanique que le convention file :
```
LaunchAgent::execute (après apply_injection, avant spawn/factory.start) :
if let Some(mcp) = &profile.mcp:
// IdeA matérialise SA config MCP au format de CETTE CLI dans <run_dir>/...
apply_mcp_config(mcp, &run_dir, &spec) // ConfigFile→write ; Flag→spec.args ; Env→spec.env
```
- `ConfigFile { target }` : écrit `<run_dir>/<target>` (ex. `.mcp.json`) avec la déclaration du
serveur MCP IdeA (commande/transport). Non-clobbering, best-effort, **comme le seed de permissions**.
- `Flag { flag }` : ajoute le flag + chemin au `SpawnSpec.args`.
- `Env { var }` : ajoute la variable au `SpawnSpec.env`.
Le **serveur MCP lui-même** est démarré **par projet ouvert**, à côté du `FsOrchestratorWatcher`,
dans le **même hook** `ensure_orchestrator_watch` (`app-tauri/src/state.rs`). Une CLI qui se lance
avec la config injectée se connecte à ce serveur (stdio : IdeA spawn un pont par session ; socket :
adresse partagée — détail d'adapter, point ouvert S-MCP).
**Justification** : symétrie totale avec le convention file et le seed de permissions (même run dir,
même best-effort non-clobbering, même moment) ⇒ aucune nouvelle plomberie de cycle de vie. La config
MCP est **donnée déclarative par profil**, donc « ajouter une CLI MCP = donnée, pas code ».
---
### Décision 4 — Frontières hexagonales : le serveur MCP est un adapter entrant d'infrastructure ; AUCUN nouveau port applicatif
**Tranché** : le serveur MCP est un **driving adapter d'infrastructure**
(`infrastructure/src/orchestrator/mcp/`), **pair** du `FsOrchestratorWatcher`. Il appelle le **même**
`OrchestratorService::dispatch` (application) et **ne duplique rien**. Trois portes d'entrée
substituables se ramènent au même `OrchestratorCommand` :
```
┌─────────────────────────────────────────────┐
Agent MCP ───▶│ Serveur MCP (infra/orchestrator/mcp) │──┐
└─────────────────────────────────────────────┘ │
┌─────────────────────────────────────────────┐ │ OrchestratorCommand
Fichier ───▶│ FsOrchestratorWatcher (infra/orchestrator) │──┼──▶ OrchestratorService::dispatch
(.ideai/ └─────────────────────────────────────────────┘ │ (application — INCHANGÉ)
requests) ┌─────────────────────────────────────────────┐ │ │
UI ───▶│ Commandes Tauri (app-tauri) │──┘ ▼
└─────────────────────────────────────────────┘ use cases agent/terminal
```
- **Où vit le serveur MCP** : `infrastructure/src/orchestrator/mcp/`. Il **traduit** un appel d'outil
MCP (`idea_ask_agent`, `idea_launch_agent`, `idea_list_agents`, et par parité `idea_update_context`,
`idea_create_skill`, `idea_stop_agent`) en `OrchestratorCommand`, appelle `dispatch`, et renvoie
`OrchestratorOutcome` (`reply`/`detail`) inline comme résultat d'outil. JSON-RPC, stdio/socket,
le crate MCP : **tout reste dans cet adapter**. Le domaine/application ignorent MCP.
- **Quel port côté domaine/application** : **aucun nouveau**. `OrchestratorService::dispatch`
(application) est déjà l'unique seam. `idea_list_agents` réutilise `ListAgents`. La validation
(`OrchestratorRequest::validate`) reste le point unique « parse, don't validate » — l'adapter MCP
construit un `OrchestratorCommand` (directement, ou via `OrchestratorRequest` pour réutiliser la
validation, au choix d'implémentation).
- **Réutilisation de `OrchestratorService` plutôt que duplication** : le serveur MCP reçoit
`Arc<OrchestratorService>` au composition root (`state.rs`), exactement comme le watcher. Une seule
logique applicative ; les adapters ne portent que leur techno d'entrée.
**Justification** : DRY + règle de dépendance hexagonale. Cible, identité, mémoire, observabilité UI
passent **toujours** par le seul chemin applicatif. Les spikes MCP (transport, crate) sont **confinés**
à l'adapter infra et ne touchent ni le domaine ni l'application.
---
## 2. Modèle de messages corrélés — état figé (rien de neuf)
La « corrélation requête↔réponse » du brief est portée **nativement par le transport** :
- **MCP** : JSON-RPC corrèle requête/réponse par `id` de message ⇒ rien à modéliser côté IdeA.
- **Fichier** : `<file>.json``<file>.json.response.json` (sibling), déjà en place.
- **Valeur de retour** : `OrchestratorOutcome { detail, reply }` (application) → `OrchestratorResponse
{ ok, action, detail, error, reply }` (infra fichier) **ou** résultat d'outil MCP. **Structs déjà
définies**, réutilisées telles quelles.
Aucun `CorrelationId`, aucun `AgentReply`, aucun port `AgentReplyChannel`, aucun outbox : **abandonnés
par le pivot §17** et **non réintroduits** par v3. C'est la simplification clé.
---
## 3. Découpage en LOTS testables (méthode §3) — MCP uniquement
> Chaque lot = binôme dev+test, vert avant le suivant. Backend/frontend séparés.
> Les terminaux non-IA et le chemin fichier `.ideai/requests` restent verts à chaque lot.
> **Spike S-MCP** (crate MCP Rust + transport stdio/socket + format de conf par CLI) est **confiné au
> lot M2** et n'invalide pas l'ossature (le contrat d'entrée reste `OrchestratorCommand`).
| Lot | Côté | Périmètre | Crates/dossiers | Contrats | Tests attendus |
|---|---|---|---|---|---|
| **M0 (capacité profil)** | back | `McpCapability` + `McpConfigStrategy` + `McpTransport` (domaine, validés) ; champ `AgentProfile.mcp: Option<McpCapability>` (+ builder `with_mcp`, `new` inchangé) ; catalogue Claude/Codex annotés (ex. `ConfigFile { target: ".mcp.json" }`). | `domain/src/profile.rs`, `application/src/agent/catalogue.rs` | enum + struct + champ optionnel sérialisé | unit purs : `mcp = None` round-trip **identique à avant** (zéro régression sérialisation) ; `Some(_)` round-trip ; constructeurs valident (`relative_safe` target, `non_empty` flag, `valid_env_var`) ; catalogue annoté. |
| **M1 (injection conf MCP au lancement)** | back | `LaunchAgent` matérialise la conf MCP selon `McpConfigStrategy` dans le run dir isolé (après `apply_injection`, avant spawn/`factory.start`) : `ConfigFile`→write non-clobbering, `Flag`→`args`, `Env`→`env`. Prose `compose_convention_file` adaptée selon `mcp.is_some()`. | `application/src/agent/lifecycle.rs` | `LaunchAgent` (chemin MCP additif) | unit (fakes) : profil `mcp=None` ⇒ **aucun** write/flag/env MCP (chemin actuel inchangé) ; `ConfigFile` ⇒ fichier écrit au bon chemin, non-clobbering ; `Flag`/`Env` ⇒ `spec` enrichi ; prose contient les outils `idea_*` si MCP, sinon `.ideai/requests`. |
| **M2 (serveur/adapter MCP)** | back | `infrastructure/src/orchestrator/mcp/` : serveur MCP exposant `idea_ask_agent`/`idea_launch_agent`/`idea_list_agents` (+ parité `idea_update_context`/`idea_create_skill`/`idea_stop_agent`) → `OrchestratorCommand` → `dispatch` → résultat inline. **Spike S-MCP** (crate, transport) isolé ici. | `infrastructure/src/orchestrator/mcp/` | mapping outil→commande ; `Arc<OrchestratorService>` injecté | unit (fakes + `OrchestratorService` à use cases fakes) : chaque outil mappe la bonne commande ; `idea_ask_agent` renvoie `reply` inline ; timeout → erreur typée, cible non tuée ; `idea_list_agents` liste ; JSON-RPC malformé → erreur, jamais panic. Hors-réseau (transport en mémoire/pipe scriptable). |
| **M3 (câblage par projet)** | back | Démarrer le serveur MCP par projet ouvert dans `ensure_orchestrator_watch` (à côté du watcher) ; registre `mcp_servers` jumeau de `orchestrator_watchers` ; arrêt à la fermeture du projet. | `app-tauri/src/state.rs`, `commands.rs` | hook `ensure_orchestrator_watch` étendu | app-tauri : un serveur MCP par projet, idempotent ; fermeture du projet ⇒ arrêt ; coexiste avec le watcher fichier (les deux portes vivantes). |
| **M4 (observabilité UI — optionnel)** | front | Surfacer dans l'UI Agents qu'une délégation est passée par MCP vs fichier (badge/source sur l'event `OrchestratorRequestProcessed` / `AgentReplied`). Non bloquant. | `frontend/src/features/agents` | DTO d'event enrichi (`source: "mcp"|"file"`) | Vitest : badge source affiché ; absence d'event ⇒ pas de régression. |
**Ordre conseillé** : **M0 → M1 → M2 → M3** (→ M4 optionnel). M0 débloque tout (donnée pure) ;
M1 injecte la conf (testable sans serveur) ; M2 livre l'adapter derrière un transport scriptable
(spike confiné) ; M3 le câble par projet. M4 est du confort d'observabilité.
---
## 4. Conformité hexagonale & SOLID (rappel)
- **Règle de dépendance** : `McpCapability`/`McpConfigStrategy` sont **domaine** (purs, validés).
Le serveur MCP, JSON-RPC, stdio/socket, le crate MCP sont **exclusivement** infra. Le domaine et
l'application **ignorent** MCP (l'application ne voit que `OrchestratorCommand`/`dispatch`).
- **S** : le serveur MCP = une seule techno d'entrée (MCP→commande). `OrchestratorService` garde sa
responsabilité (commande→use cases). `LaunchAgent` gagne une étape d'injection homogène, pas une
responsabilité nouvelle.
- **O** : ajouter une CLI MCP = un bloc `mcp` sur le profil (**donnée**). Aucun cœur touché.
- **L** : les trois portes d'entrée (fichier, MCP, UI) sont substituables — même `dispatch`, même
résultat. Repli fichier ≡ MCP du point de vue de la réponse.
- **I** : le serveur MCP ne reçoit que `Arc<OrchestratorService>` (pas les use cases en détail).
- **D** : tout injecté au composition root (`state.rs`) ; aucun `new` d'adapter MCP ailleurs.
---
## 5. Chantiers adjacents (situés, NON cadrés ici)
- **Hot-swap de l'AI profile** (chantier A, §15.1) : **LIVRÉ** (`ChangeAgentProfile`). Interaction
avec v3 : un swap vers/depuis un profil MCP change la surface (`mcp.is_some()`) ⇒ au relance,
`LaunchAgent` (ré)injecte ou retire la conf MCP automatiquement. **Rien à cadrer** : la surface suit
le profil courant, point de vérité unique.
- **Reprise auto des sessions au redémarrage** (chantier B, §15.2) : **LIVRÉ** (`ListResumableAgents`,
`conversation_id` persisté sur la cellule). Interaction avec v3 : à la reprise, `LaunchAgent`
ré-matérialise la conf MCP comme à tout lancement (M1). **Rien à cadrer**.
Ces deux chantiers **ne sont pas un prérequis** de v3/MCP et n'en bloquent aucun lot.
---
## 6. Synthèse des décisions
1. **Capacité MCP = `Option<McpCapability>` sur le profil** (Open/Closed, `None` ⇒ repli fichier+prose, zéro régression sérialisation).
2. **Retour synchrone d'`ask` : RIEN de neuf** — réutilise `send_blocking`/`AskAgent`/`AgentReplied`/`OrchestratorOutcome.reply` déjà livrés (§17). Pas d'outbox, pas de corrélation fichier, pas de `CorrelationId`. Le transport MCP corrèle nativement.
3. **Interdiction subagents natifs conservée + alternative native** : prose adaptée selon surface ; conf MCP injectée **par CLI** au `LaunchAgent` dans le run dir isolé (`McpConfigStrategy` : ConfigFile/Flag/Env), symétrique au convention file et au seed permissions.
4. **Frontières** : serveur MCP = **adapter entrant infra** (`infrastructure/src/orchestrator/mcp/`), pair du watcher fichier, appelant le **même** `OrchestratorService::dispatch`. **Aucun nouveau port** applicatif/domaine.
5. **Lots** : M0 (capacité profil) → M1 (injection conf) → M2 (adapter/serveur MCP, spike confiné) → M3 (câblage par projet) → M4 (observabilité, optionnel).