---
issueRef: "#130"
version: 6
updatedBy: {"kind":"user"}
updatedAt: 1785243026193
---

## #130 — Cadrage technique Architect (2026-07-27)

**Root cause identifiée dans le code réel** — bug critique de non-idempotence, confirmé et reproductible par construction (pas besoin du détail score 20/25 pour l'expliquer, mais cohérent avec le scénario rapporté).

Erreur SQLite 2067 = violation de contrainte `UNIQUE`. La table `active_set_results` (`lib/infrastructure/local/tables.dart:473-513`) porte :
```sql
UNIQUE (active_workout_session_id, program_index, exercise_index, set_index)
```

`ActiveWorkoutSessionUseCases.recordCurrentSetResult` (`lib/application/use_cases.dart:2451-2511`), appelée par le bouton "Terminer la série" (`workout_execution_screen.dart:1459`, via `_recordAndAdvance`), construit **toujours** un `ActiveSetResult` avec une **metadata/id neuve** (`_newMetadata(ids, originDeviceId, now)`, ligne 2485) puis appelle `sessionRepository.saveSetResult` → `insertOnConflictUpdate` (`drift_repositories.dart:1130-1140`). Drift résout ce conflit sur la **clé primaire** (`id`), pas sur la contrainte `UNIQUE` métier — donc si une ligne existe déjà pour `(session, program, exercise, set)` avec un autre `id`, `insertOnConflictUpdate` ne trouve pas de conflit sur `id`, tente un `INSERT`, et percute l'`UNIQUE` → 2067.

**Comparer avec le code correct existant** : `upsertSetResultAtPosition` (ligne 2513-2588, utilisée pour l'édition a posteriori d'une série passée) fait ça bien — elle **cherche d'abord** la ligne existante via `listSetResults` (ligne 2546-2555) et réutilise son `metadata`/`id` (`existing.metadata.touch(now)`) si trouvée, sinon en crée une neuve. C'est le pattern à répliquer dans `recordCurrentSetResult`.

**Deux points d'entrée indépendants écrivent sur la même clé sans coordination**, ce qui rend la collision non hypothétique :
1. Le bouton téléphone (`_recordAndAdvance` → `recordCurrentSetResult`).
2. Le handler de commande montre `_finishCurrentSet` (`use_cases.dart:3546`, déclenché par `WatchCommandType.finishCurrentSet`/`skipCurrentSet`, ligne 3472-3476) qui appelle **aussi** `recordCurrentSetResult` (ligne 3574) — un chemin d'écriture séparé pour la même position logique.

Toute situation où une ligne existe déjà pour cette position au moment de l'appel (retry après un premier appel resté en vol, double-tap sans garde métier au niveau du use case, ou commande montre + tap téléphone proches dans le temps) déclenche l'erreur — pas seulement le cas particulier score/reps décrit dans le ticket, mais c'est cohérent avec un scénario où l'utilisateur a interagi via les deux surfaces (téléphone + montre) pour la même série.

### Correctif (contrat clair pour DevBackend)
Faire de `recordCurrentSetResult` un **véritable upsert sur la clé logique**, à l'identique du pattern déjà en place dans `upsertSetResultAtPosition` :
```dart
final existingResults = await sessionRepository.listSetResults(sessionId);
final existing = existingResults.firstWhereOrNull((r) =>
    r.programIndex == programIndex &&
    r.exerciseIndex == exerciseIndex &&
    r.setIndex == setIndex);
final result = ActiveSetResult(
  metadata: existing == null
      ? _newMetadata(ids, originDeviceId, now)
      : existing.metadata.touch(now),
  // ... reste inchangé
);
```
Ce correctif rend `recordCurrentSetResult` idempotent et sûr vis-à-vis des deux points d'entrée (téléphone/montre) et de tout retry, sans changer sa signature publique ni le contrat vu par l'UI/la montre.

### Tests
- Use case : appeler `recordCurrentSetResult` deux fois de suite pour la même position (simulant un retry ou une double soumission téléphone/montre) → doit réussir les deux fois sans exception, avec la seconde valeur qui gagne (dernière écriture) et un seul enregistrement en base.
- Cas croisé : `recordCurrentSetResult` puis vérifier `listSetResults` ne retourne qu'une ligne pour cette position.
- Régression : vérifier que `upsertSetResultAtPosition` reste inchangée et continue de passer ses tests existants.

**Prêt pour implémentation directe, aucun arbitrage supplémentaire nécessaire. Priorité critique confirmée** (bloque la fin de série dans un cas déjà survenu en usage réel).
