---
issueRef: "#98"
version: 5
updatedBy: {"kind":"user"}
updatedAt: 1784983186248
---
# Carnet #98 — Contrat figé (approche **B2**)

> Cadrage ARCHITECT figé et gelé. **Source de vérité pour DevBackend et QA.** Ce carnet remplace la phase d'arbitrage ; les options A/B1/C sont closes (voir §2 pour le rejet motivé).

---

## 1. Cause racine affinée

Le modèle est perdu **au spawn**, pas à la sauvegarde (persistance vérifiée correcte bout en bout, `profiles.json` conserve bien `opencodeProvider.model`). Deux facteurs composés :

1. **Pas de bloc `models`** pour les providers catalogue connus — `opencode_provider_config_json` (`lifecycle.rs:2774-2844` + jumeau `assistant/mod.rs:428-503`) n'émet `npm`/`baseURL`/`models` **que** pour le chemin `custom`. Pour un provider catalogue (zai), seul `provider.<id>.options.apiKey` est écrit. Test fige ce manque : `lifecycle.rs:4439-4440`.
2. **Cache models.dev isolé et vide** — IdeA passe `XDG_CACHE_HOME=<run_dir>/.opencode/cache` vide (`lifecycle.rs:2386,2405` ; `assistant/mod.rs:272,282`) + `HOME` isolé. Or `zai` n'est connu d'OpenCode **que** via ce cache (catalogue lu côté IdeA depuis le vrai `~/.cache/opencode/models.json`, `provider_catalogue.rs:79-84`).

→ OpenCode ne connaît plus `zai`, ne résout pas `zai/<modèle>` → **fallback silencieux `glm-5.2`** (fallback interne CLI OpenCode, absent du code IdeA).

Asymétrie confirmée : local llama.cpp et cloud custom marchent car ils émettent un bloc `models` (`assistant/mod.rs:371-379`, `:457-460`).

## 2. Approche retenue : **B2** (seed best-effort du cache hôte)

Copier best-effort le cache models.dev hôte (`opencode_models_cache_path()`) vers `<xdg_cache>/opencode/models.json` dans le cache isolé, juste après le `create_dir_all` du cache dans `apply_mcp_config` (branche commune opencode/opencodeProvider), aux **DEUX** sites.

**Rejet motivé des alternatives :**
- **A (émettre bloc `models` pour providers connus)** : rejeté car `provider_catalogue.rs` ne persiste que `name`+`models` et **ignore `npm`+`baseURL`**. Un bloc `models` seul laisse OpenCode incapable de *joindre* `zai` — il faudrait en plus étendre le domaine (`display_name`/`npm`/`base_url`) → scope bien plus large et risque régression. A n'est viable qu'en révision A-étendue, écartée pour ce lot.
- **B1 (= B sans factorisation, deux sites copiés)** : rejeté pour dette hexagonale — le writer `opencode_provider_config_json` est déjà dupliqué `lifecycle.rs` vs `assistant/mod.rs`. Dupliquer aussi le seed amplifierait la dette et rendrait toute divergence un bug silencieux. B2 impose une factorisation unique.
- **C (hybride : pointer `XDG_CACHE_HOME` vers le vrai cache)** : rejeté — casse l'invariant d'isolation du run (un profil pourrait lire/écrire le cache hôte ou un cache d'un autre run), et OpenCode pourrait y écrire (logs, refresh) → pollution mutuelle.

**Pourquoi B2 :** donne à OpenCode sa connaissance registry complète (npm, baseURL, modèles) sans toucher au domaine ni casser l'isolation écriture — c'est de la donnée publique read-only copiée **dans** l'espace isolé.

## 3. Contrat figé — ports / fichiers / invariants

### 3.1 Fonctions à implémenter (factorisation)

- **Rendre `opencode_models_cache_path()` publique** — source de vérité unique du chemin hôte, **ne pas re-dériver** le chemin dans le seed.
- **`seed_opencode_models_cache(fs, isolated_cache_dir)`** (fonction partagée) : lit le cache hôte via `opencode_models_cache_path()`, copie best-effort vers `<isolated_cache_dir>/opencode/models.json`. Appelée aux **deux** sites après le `create_dir_all` du cache dans `apply_mcp_config` (branche commune `opencode`/`opencodeProvider`).
- **`seed_from_bytes(fs, dest, src_bytes)`** (partie pure testable) : extrait la logique d'écriture du fichier destination à partir de bytes source. C'est le seam de test unitaire.

### 3.2 Sites d'appel (les DEUX)

1. `crates/application/src/agent/lifecycle.rs` — après `create_dir_all` cache dans `apply_mcp_config`.
2. `crates/infrastructure/src/assistant/mod.rs` — après `create_dir_all` cache dans `apply_mcp_config`.

### 3.3 Invariants (à respecter, à tester)

- **Isolation préservée en écriture** : le seed ne fait que copier une donnée read-only **vers** le cache isolé. Jamais de `XDG_CACHE_HOME` pointé vers l'hôte.
- **Best-effort** : le seed **n'échoue jamais le launch**. Toute erreur (fichier hôte absent, IO) est tracée (warn/log) et ignorée — on retombe sur le comportement actuel (fallback glm-5.2), pas sur un crash.
- **Pas de régression** : profils local (llama.cpp), custom et built-ins (anthropic/openai/openrouter) doivent continuer à fonctionner byte-identique au rendu `opencode.json` actuel.
- **Exclusion mutuelle #97 non touchée** : le seed s'exécute sur la branche commune `opencode`/`opencodeProvider`, sans affecter la logique d'exclusion.
- **Cohérence picker↔spawn** : le catalogue vu côté UI (picker) et celui seedé au spawn proviennent du même fichier hôte → l'utilisateur ne peut pas picker un modèle qu'OpenCode ne connaîtra pas au spawn.

### 3.4 Hors périmètre (explicitement exclu)

- Remote SSH/WSL (le seed ne concerne que le spawn local).
- Déduplication du rendu lifecycle↔infra au-delà du seed (la dette du writer `opencode_provider_config_json` reste ouverte — autre lot).
- UI wizard (aucun changement surface).

## 4. Périmètre QA — 2 couches

### Couche 1 — Unitaire pure (obligatoire, rapide, déterministe)
- **`seed_from_bytes`** : assert écriture byte-identique du contenu source vers `dest`, gestion erreurs (fs en échec → pas de panic), idempotence.
- **Non-régression rendu `opencode.json`** : les tests existants `lifecycle.rs:4439-4440` et équivalents infra doivent rester **byte-identiques** pour local/custom/built-ins (le seed ne change pas le rendu config). Mettre à jour l'assert si et seulement si B2 modifie réellement le rendu — sinon la conserver telle quelle.

### Couche 2 — Intégration gated (obligatoire avant fermeture du lot)
- **Spawn réel OpenCode** sur profil `zai` + modèle X choisi.
- **Assert** : OpenCode démarre sur le modèle X (pas de fallback `glm-5.2`).
- Vérifier dans les background-tasks/IO qu'aucune trace `glm-5.2` n'apparaît comme modèle actif.
- **Gate obligatoire à lever** : OpenCode **refresh/écrase-t-il le cache `models.json` au démarrage** ? → si **oui**, le seed est inutile (écrasé avant lecture) et **il faut escalader vers A-étendue** (persistir npm/baseURL/models côté domaine). Consigner le verdict dans le rapport QA.

## 5. Risque résiduel — refresh models.dev par OpenCode

**Risque ouvert, à trancher en QA couche 2.** Si OpenCode rafraîchit/écrase `<xdg_cache>/opencode/models.json` au démarrage (network call ou réécriture locale), le seed B2 est potentiellement **inopérant** :
- Meilleur cas : OpenCode lit le cache avant tout refresh → B2 fonctionne.
- Cas dégradé : OpenCode refresh en premier, écrase le seed, et comme le profil est isolé (pas d'accès réseau garanti / HOME isolé), le refresh peut échouer ou produire un cache incomplet → bug persiste.
- **Plan de contournement si échec** : escalader vers A-étendue (ajouter `npm`+`base_url`+`models` persistés côté `OpenCodeProviderConfig` + émettre le bloc complet au spawn). Ce plan est documenté mais **hors scope B2** — il ferait l'objet d'un lot suivant si QA le confirme nécessaire.

---

## Contexte de mise à jour

- Cadrage figé par Main sur validation Architect (approche B2).
- Version précédente (v1) : phase d'arbitrage A/B/C — clos.
- Prochaine étape cycle : Git (branche) → DevBackend (implémentation 2 sites + factorisation) → QA (2 couches, gate refresh).
