diff --git a/docs/superpowers/specs/2026-04-22-eartag-series-parsing-design.md b/docs/superpowers/specs/2026-04-22-eartag-series-parsing-design.md
new file mode 100644
index 0000000..c182964
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-22-eartag-series-parsing-design.md
@@ -0,0 +1,138 @@
+# Parsing de `SerieBoucles` → `EarTagSeriesDto` typé
+
+## Contexte
+
+`EarTagSeriesDto` a été introduit en Phase 1 comme wrapper minimal (`$rawNode: object`) autour du noeud `SerieBoucles` retourné par `IpBGetInventaire` quand le consommateur passe `includeEarTagStock: true`. Le docblock actuel dit que le XSD `typeSerieBoucles` est « riche » et qu'on différera le parsing — c'est **faux**. Le XSD (`resources/ednotif-ws/IpBNotif_v1.xsd:1181-1197`) ne contient que 3 champs :
+
+```xml
+
+
+
+
+
+
+
+```
+
+Sémantique : « j'ai `Quantite` boucles consécutives à partir du numéro `DebutSerie` dans le pays `CodePays` ». Une série = une plage de boucles physiques non utilisées, en stock chez l'éleveur.
+
+## But
+
+Remplacer le wrapper `rawNode` par un DTO typé à 3 champs, plus un helper calculé `endNumber()` qui rend l'usage UI immédiat (afficher « 50 boucles de 0012345678 à 0012345727 »).
+
+## Scope
+
+### Inclus
+
+- `EarTagSeriesDto` plat : `countryCode: string`, `startNumber: string`, `quantity: int`.
+- Une méthode `endNumber(): string` qui renvoie `startNumber + quantity - 1` avec zéro-padding à 10 chiffres.
+- Adaptation du `InventoryMapper` : instancier le DTO avec les 3 champs lus depuis `$serieNode->CodePays`/`DebutSerie`/`Quantite` au lieu de passer le noeud brut.
+- Tests unitaires sur le DTO (helper) et sur le mapper (les 2 tests existants pour `SerieBoucles` — full inventory + list — passent à travers les nouveaux champs).
+- Mise à jour du docblock sur `EarTagSeriesDto` (le texte Phase 1 est à refaire).
+
+### Exclus (YAGNI)
+
+- `contains(string $tagNumber): bool` et `iterableNumbers(): Generator` — à ajouter le jour où `IpBCreateRebouclage` ou une UI de sélection de boucle en aura vraiment besoin.
+- Validation métier (vérifier que `startNumber` matche le pattern XSD `0[1-9][0-9]{8}|[1-9][0-9]{9}`) — EDNOTIF garantit déjà la forme, on n'ajoute pas une seconde ligne de défense.
+- Changement du type de `$earTagSeries` dans `InventoryDto` — la liste reste `list`.
+
+## Changement non-rétro-compatible assumé
+
+Le remplacement de `$rawNode` par 3 champs casse l'API publique de `EarTagSeriesDto`. Comme cette DTO n'a **aucun consommateur connu à ce jour** (Ferme n'utilise pas `includeEarTagStock: true`), on fait le changement direct, sans transition. Si un consommateur tiers existait, il faudrait un cycle de dépréciation ; ce n'est pas le cas.
+
+## Architecture
+
+### DTO
+
+```php
+final readonly class EarTagSeriesDto
+{
+ public function __construct(
+ public string $countryCode,
+ public string $startNumber,
+ public int $quantity,
+ ) {}
+
+ public function endNumber(): string
+ {
+ return str_pad(
+ (string) ((int) $this->startNumber + $this->quantity - 1),
+ 10,
+ '0',
+ STR_PAD_LEFT,
+ );
+ }
+}
+```
+
+Décisions à documenter dans le code :
+
+- **`startNumber` en string**, pas int : le XSD impose 10 chiffres avec zéro-padding possible. Convertir en int ferait perdre le format (`'0012345678'` → `12345678`). Laisser en string préserve le format d'affichage.
+- **`endNumber()` renvoie aussi une string** zéro-paddée à 10 chiffres, via `str_pad`. Cohérent avec `startNumber`.
+- **`quantity` en int** : xsd:unsignedInt se mappe naturellement sur int PHP (les 10 milliards de boucles possibles tiennent dans int64 sans problème).
+
+### Mapper
+
+Dans `InventoryMapper::map()`, remplacer :
+
+```php
+$earTagSeries[] = new EarTagSeriesDto(rawNode: $serieNode);
+```
+
+par :
+
+```php
+$earTagSeries[] = new EarTagSeriesDto(
+ countryCode: $this->toNullableString($serieNode->CodePays ?? null) ?? '',
+ startNumber: $this->toNullableString($serieNode->DebutSerie ?? null) ?? '',
+ quantity: $this->toNullableInt($serieNode->Quantite ?? null) ?? 0,
+);
+```
+
+Valeurs par défaut (`''`, `0`) pour les cas où EDNOTIF renverrait un noeud partiel — plus simple qu'une DTO avec des nullable partout, et `quantity = 0` / `startNumber = ''` sont des valeurs sentinelles immédiatement repérables en debug.
+
+### Docblock actualisé
+
+Remplacer le texte Phase 1 sur `EarTagSeriesDto` par une description factuelle de la structure XSD et de la sémantique (plage de boucles en stock).
+
+## Edge cases
+
+- **Noeud `SerieBoucles` partiel** (un des 3 champs manque) : le DTO est construit avec les valeurs sentinelles `''` ou `0`. Pas de crash, pas d'exception. Responsabilité du consommateur de filtrer si besoin.
+- **`quantity = 0`** : `endNumber()` renvoie `startNumber - 1` (zéro-padded). Techniquement absurde côté métier mais techniquement déterministe. Ne vaut pas la peine de documenter un `throw`.
+- **`startNumber` vide** : `(int) '' === 0`, `endNumber()` renvoie `str_pad((string)(-1), ...)` = `'00000000-1'`. Déjà en terrain « shouldn't happen ». Pas de garde défensive ajoutée.
+- **`CodePays` vide** : `endNumber()` ne dépend pas du pays, aucun impact.
+
+## Tests
+
+### DTO
+
+Un fichier `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php` :
+
+- `testEndNumberComputesStartPlusQuantityMinusOne` — cas standard, ex : start `0012345678`, quantity 50, end `0012345727`.
+- `testEndNumberPreservesLeadingZeroPadding` — cas avec leading zero, ex : start `0000000001`, quantity 5, end `0000000005`.
+- `testEndNumberWithQuantityOne` — une seule boucle dans la série, end === start.
+
+3 tests, ~15 assertions.
+
+### Mapper
+
+Les 2 tests existants (`testMapFullInventory` et `testMapInventoryWithSerieBouclesAsListPreservesAllEntries`) sont **modifiés** pour :
+
+- Peupler `CodePays`, `DebutSerie`, `Quantite` dans les fixtures à la place de `NumeroSerieDebut`.
+- Asserter sur `$inventory->earTagSeries[0]->startNumber` et `$inventory->earTagSeries[0]->quantity` au lieu de `rawNode->NumeroSerieDebut`.
+
+Aucun nouveau test sur le mapper — la structure est déjà couverte.
+
+## Impact sur les autres fichiers
+
+- `src/Bovin/Dto/EarTagSeriesDto.php` — réécrit.
+- `src/Bovin/Mapper/InventoryMapper.php` — un bloc modifié (instanciation de la DTO).
+- `tests/Unit/Bovin/Mapper/InventoryMapperTest.php` — fixtures `SerieBoucles` adaptées (2 tests impactés).
+- Nouveau : `tests/Unit/Bovin/Dto/EarTagSeriesDtoTest.php`.
+
+Aucun autre fichier touché. `InventoryDto`, `BovinApi`, `BovinApiInterface`, `config/services.php` : inchangés.
+
+## Comptage de tests attendu
+
+Avant : 53 tests.
+Après : 53 + 3 nouveaux sur le DTO = 56 tests (les 2 du mapper sont modifiés, pas ajoutés).