PD-286 — Livrable agent agent-developer-app-export-api-client¶
Story : PD-286 (Export probatoire multi-volumes) Module :
app-export-api-client(projetProbatioVault-app) Agent :agent-developer(B — Claude) Wave : 2 (Backend services / parallèle, dépend uniquement du contrat) Date : 2026-04-25
1. Synthèse exécutive¶
Implémentation de l'extension ExportApiClient côté ProbatioVault-app pour supporter le second contrat de réponse multi-volumes (PD-286) tout en préservant la rétrocompatibilité du contrat single-volume legacy (PD-283 / INV-286-05).
Le module est la frontière API côté app : il valide strictement le payload reçu via Zod avant tout téléchargement de volume. La validation Zod est la seule source de vérité pour la conformité du contrat (aucune validation manuelle ad hoc).
2. Périmètre livré¶
| Fichier | Type | Lignes ajoutées | Statut |
|---|---|---|---|
src/export/schemas.ts | extension | +152 | livré |
src/export/api-client.ts | extension | +127 | livré |
src/export/__tests__/api-client.test.ts | extension | +259 | livré |
Aucun fichier hors périmètre du contrat n'a été modifié. Les fichiers src/export/types.ts, orchestrator.ts, pvproof-assembler.ts, etc., sont intacts (la migration de l'orchestrateur vers callExportApiV2 est la responsabilité de l'agent C7 app-export-orchestrator-multi).
3. Conformité au code contract¶
3.1 Interfaces livrées¶
| Interface contractuelle | Symbole exporté | Fichier |
|---|---|---|
ExportApiClient (extension) | callExportApiV2(proofIds, authToken) | api-client.ts |
ExportResponseSchemas (Zod) | multiVolumeExportApiResponseSchema, exportVolumeDtoSchema, parseExportApiResponse | schemas.ts |
3.2 Invariants — couverture¶
| Invariant contract | Mécanisme livré | Test associé |
|---|---|---|
Validation Zod stricte à la frontière API : refus si volumeIndex hors [0, totalVolumes-1], trous/doublons (ERR-286-06) | multiVolumeExportApiResponseSchema.superRefine (cardinalité, unicité, continuité, range) | 4 tests TC-ERR-06 (hole, duplicate, out of range, length != totalVolumes) |
integrityHash regex /^[0-9a-f]{64}$/ — case-sensitive, jamais de transformation | integrityHashSchema (réutilisée pour les volumes et manifestRootHash) | TC-NEG-02 (uppercase), wrong length, manifestRootHash uppercase |
signedUrl HTTPS-only, length ≤ 4096 (ERR-286-08) | multiVolumeSignedUrlSchema (max(4096) + .startsWith("https://")) | rejects non-HTTPS, rejects > 4096 chars, INVALID_URL_SCHEME |
exportId UUID v4 regex stricte (lowercase) | exportIdV4Schema (UUID_V4_LOWERCASE_REGEX) | TC-ERR-08 (not a UUID, uppercase UUID) |
Si validation Zod fail → Result.err(InvalidContractError) AVANT tout téléchargement | parseExportApiResponse lance ZodError, mappé en ExportError typé par mapZodErrorToExportError. Aucun fetch supplémentaire (le pipeline orchestrateur n'est pas atteint) | does NOT retry on Zod validation error (assert mockFetch appelé 1 fois exactement) |
| Schéma unique source de vérité côté app | multiVolumeExportApiResponseSchema exporté ; orchestrateur (C7) et verifier (C8) consomment les types ExportVolumeDto / ValidatedMultiVolumeExportApiResponse inférés | typage TS partagé |
Refined types : volumeIndex, totalVolumes branded | volumeIndexSchema.brand<"VolumeIndex">(), totalVolumesSchema.brand<"TotalVolumes">() | tests dédiés branded types |
3.3 Interdictions — respect¶
| Interdit contract | Vérification |
|---|---|
| Validation manuelle ad hoc (if/else sur structure) en dehors de Zod | Seule discrimination structurelle : isMultiVolumePayload (présence clé volumes) — c'est un router de schéma, pas une validation de champ. Toute validation de format/borne passe par Zod. |
| Import de DTO backend directement | Aucun import depuis ProbatioVault-backend (vérifié via grep). Les types sont inférés depuis les schémas Zod locaux. |
Tolérance integrityHash majuscules | Regex /^[0-9a-f]{64}$/ strict (sans flag i) — vérifié par TC-NEG-02 |
Type string pour volumeIndex/totalVolumes | z.number().int().brand<"…">() — types branded VolumeIndex / TotalVolumes exportés |
Désactivation Zod via .passthrough() ou .catchall() | z.strictObject partout sur l'enveloppe + DTO. Test rejects unknown extra fields (strictObject) et rejects unknown top-level fields couvrent. |
| Retry sur erreur de validation Zod | Aucun retry implémenté ; test explicite does NOT retry on Zod validation error (mockFetch appelé 1 seule fois). Les retries existants legacy ne s'appliquent qu'aux erreurs réseau. |
4. Décisions architecturales¶
Décision 1 — Coexistence legacy + V2 (additive, pas de refactor)¶
- Décision : ajouter
callExportApiV2plutôt que mutercallExportApi. - Rationale : la fonction legacy
callExportApi(PD-283) reste utilisée par les tests d'intégration et l'orchestrateur actuel. Une mutation aurait nécessité de changer la signature de retour, cassant tous les consommateurs en cascade. La voie additive permet à l'agent C7 (orchestrator) de migrer indépendamment et donne une période de cohabitation pour les tests d'intégration. - Alternatives considérées :
- Refactor
callExportApien wrapper de V2 : risque de régression sur les tests PD-283, gain marginal. - Discriminated union
z.union([…]): Zod renvoie alors le message d'erreur du dernier schéma essayé, ce qui masque la cause réelle (ex : un défaut de continuitévolumeIndexafficheraitmanifest required). Inacceptable pour le mapping d'erreur précis demandé par la spec §6. - Trade-offs : duplication ~30 lignes du flux HTTP entre
callExportApietcallExportApiV2. Volontairement accepté pour préserver la stabilité du contrat legacy. Le facteur commun pourra être extrait quand l'orchestrateur aura migré.
Décision 2 — Branded types via z.brand au lieu d'un type alias nu¶
- Décision :
volumeIndexettotalVolumessont des branded numbers (number & {__brand: "VolumeIndex"}). - Rationale : cf. learning 2026-03-04 sur les UUID. Les deux valeurs sont des entiers sémantiquement distincts (un index ∈
[0, N-1]vs un total≥ 1). Sans branding, TypeScript n'empêche pas un appelant d'inverser les paramètresf(volumeIndex, totalVolumes). Le branding forceas VolumeIndexau site d'appel et évite la classe d'erreurs "j'ai swappé deux paramètres". - Alternatives considérées :
- Type alias
type VolumeIndex = number: aucune protection à la compilation, équivalent àstringen termes de risque. - Wrapper class
class VolumeIndex { value: number }: surcoût runtime, casse la sérialisation JSON directe. - Trade-offs : cast explicite requis dans le
superRefine(v.volumeIndex as unknown as number) pour les comparaisons numériques. Acceptable et localisé dans le schéma lui-même.
5. Mapping tests → invariants / critères¶
| Test ID (jest) | Référence spec | Invariant / Critère / Erreur |
|---|---|---|
accepts a valid 2-volume response | TC-NOM-02 | INV-286-01, INV-286-06, CA-286-01 |
CA-286-03: accepts a single volume dédié exceptionnel | TC-NOM-06 | INV-286-01 (volume dédié), CA-286-03 |
rejects volumes[] with hole in volumeIndex | TC-ERR-06 | INV-286-06, ERR-286-06 |
rejects volumes[] with duplicate volumeIndex | TC-ERR-06 | INV-286-06, ERR-286-06 |
rejects volumeIndex >= totalVolumes | TC-ERR-06 | INV-286-06, ERR-286-06 |
rejects volumes.length != totalVolumes | TC-ERR-06 | INV-286-06 (cardinalité) |
rejects empty volumes[] when totalVolumes >= 1 | TC-ERR-06 | INV-286-06 |
rejects exportId that is not UUID v4 lowercase | TC-ERR-08 | spec §5.1, ERR-286-08 |
rejects UUID v4 with uppercase letters | TC-NEG-02 | spec §5.1 (case-sensitivity) |
rejects uppercase integrityHash | TC-NEG-02 | INV-286-07, ERR-286-03 |
rejects integrityHash with wrong length | TC-NEG-05 | INV-286-07 |
rejects manifestRootHash uppercase | TC-NEG-02 (analogue) | spec §5.1 (manifestRootHash strict) |
rejects non-HTTPS signedUrl | TC-NEG-03 | ERR-286-08, spec §5.1 |
rejects signedUrl > 4096 chars | TC-NEG-03 (analogue) | spec §5.1 (max 4096) |
rejects estimatedBytes > MAX_TOTAL_EXPORT_BYTES | INV-286-01 borne haute | INV-286-01 |
rejects estimatedBytes = 0 | INV-286-01 borne basse | INV-286-01 (> 0) |
rejects empty manifest | spec §5.1 | INV-286-07 (manifest non vide indispensable au recalcul de hash) |
rejects unknown extra fields (strictObject) | spec / contract forbidden | INV-286-08 (pas de pollution payload) |
rejects unknown top-level fields | contract forbidden | idem |
volumeIndexSchema rejects negative integer | TC-NEG-05 | spec §5.1 |
volumeIndexSchema rejects non-integer | spec §5.1 | spec §5.1 |
totalVolumesSchema rejects 0 | TC-NEG-04 | spec §5.1 |
totalVolumesSchema accepts 1 | spec §5.1 | spec §5.1 (min=1) |
parseExportApiResponse: classes multi-volume | discrimination | routing schéma |
parseExportApiResponse: classes single-volume | INV-286-05 | rétrocompatibilité legacy |
parseExportApiResponse: throws ZodError on malformed | fail-fast | ERR-286-03/06/08 |
callExportApiV2: returns multi-volume kind | TC-NOM-02 | flow nominal |
callExportApiV2: returns single-volume kind for legacy | INV-286-05 | rétrocompatibilité |
callExportApiV2: maps volumeIndex hole to ARTIFACT_CORRUPT | ERR-286-06 | mapping erreur |
callExportApiV2: maps invalid exportId UUID v4 | ERR-286-08 | mapping erreur |
callExportApiV2: maps non-HTTPS signedUrl | ERR-286-08 | mapping erreur |
callExportApiV2: maps uppercase integrityHash | ERR-286-03 | mapping erreur |
callExportApiV2: does NOT retry on Zod validation error | contract forbidden (Retry sur erreur de validation Zod) | preuve 1 fetch unique |
callExportApiV2: propagates HTTP 403 as EXPORT_FORBIDDEN | propagation legacy | non-régression |
callExportApiV2: propagates HTTP 422 as EXPORT_ALL_REJECTED | propagation legacy | non-régression |
Synthèse couverture :
- 14 tests legacy (PD-283) — non-régression : 14/14 PASS
- 37 tests nouveaux (PD-286) — multi-volumes : 37/37 PASS
- Total fichier : 51/51 PASS (jest)
- Test suite
schemas-validation.test.ts(PD-283) : 19/19 PASS (non-régression)
6. Mapping erreurs¶
callExportApiV2 mappe les défauts de contrat Zod vers ExportError :
| Défaut Zod | Code ExportError | Justification |
|---|---|---|
path = "exportId" | INVALID_EXPORT_ID | code existant PD-283, sémantique préservée |
path contient signedUrl ou url + message HTTPS | INVALID_URL_SCHEME | code existant PD-283 |
path contient integrityHash ou manifestRootHash | ARTIFACT_CORRUPT | hash invalide = corruption d'artefact (cohérent INV-286-07) |
path commence par volumes + message continuité (Duplicate, Missing, out of range, must equal) | ARTIFACT_CORRUPT (avec préfixe message Multi-volume contract violation) | INV-286-06 ; pas de code dédié dans ExportErrorCode (types.ts hors périmètre) |
| autres défauts de contrat | ARTIFACT_CORRUPT | fallback sécurisé |
Note d'agent — pour C7/C8 : si une distinction sémantique fine est requise (par ex. séparer
INVALID_VOLUME_CONTINUITYdeARTIFACT_CORRUPTdans la télémétrie), un code d'erreur supplémentaire devra être ajouté àExportErrorCodedanssrc/export/types.ts. Ce fichier est hors périmètre de cet agent ; je signale le besoin sans l'implémenter.
7. Vérifications effectuées¶
| Vérification | Résultat | Commande |
|---|---|---|
TypeScript --noEmit sur src/export/ | 0 erreur introduite | npx tsc --noEmit \| grep src/export/ |
Jest api-client.test.ts | 51/51 PASS (14 legacy + 37 nouveau) | npx jest src/export/__tests__/api-client.test.ts |
Jest schemas-validation.test.ts (non-régression) | 19/19 PASS | npx jest src/export/__tests__/schemas-validation.test.ts |
| Imports croisés interdits (DTO backend) | aucun | grep -r "from .*backend" src/export/ |
Le repo a des erreurs TypeScript préexistantes dans des modules sans rapport (auth, biometric, navigation, hooks). Aucune n'est liée à PD-286 ni à
src/export/. Conformément à l'Art. VI, je signale ce gap mais le périmètre agent est strictement limité aux fichiers du contract.
8. Hypothèses & limites¶
| ID | Hypothèse | Validation |
|---|---|---|
| H-PD286-API-01 | Le backend (C5) émet exportId en UUID v4 lowercase (spec §5.1 normalisé) | À confirmer en intégration ; le schéma rejette les majuscules. Si le backend émet en majuscules, la validation échoue côté app — comportement voulu (case-sensitive selon spec). |
| H-PD286-API-02 | Le payload multi-volume ne contient aucun champ legacy (manifest, signedUrls, etc.) — l'agent C5 utilise @Expose conditionnel pour les omettre (cf. plan H-PD286-PLAN-03) | z.strictObject rejette tout champ extra, donc une fuite de champ legacy ferait échouer la validation. Test rejects unknown top-level fields couvre. Si C5 ne respecte pas cette discipline, il y aura un échec de contrat clair côté app — c'est le comportement attendu. |
| H-PD286-API-03 | parseExportApiResponse discrimine sur la présence de la clé volumes (pas sur sa validité). Un payload malformé contenant volumes: null est routé vers le schéma multi-volume et échoue Zod — voulu (fail-loud). | Test parseExportApiResponse: throws ZodError on malformed multi-volume payload. |
9. Points laissés à C7 (orchestrator) et C8 (verifier)¶
| Point | Destinataire | Action attendue |
|---|---|---|
Migration de l'orchestrateur de callExportApi → callExportApiV2 | C7 | Brancher la pipeline multi-volume séquentielle quand result.kind === "multi-volume" |
| Recalcul SHA3-256(JCS(manifest)) | C8 | Utiliser le type ExportVolumeDto exporté pour typer la fonction verify(volume, expectedHash) |
Code d'erreur dédié INVALID_VOLUME_CONTINUITY (optionnel) | propriétaire de types.ts | Ajouter à ExportErrorCode si la télémétrie distingue ces cas |
Validation runtime backend que volumes[] est trié par volumeIndex ASC (plan §9.2) | C5 | Plan §9.2 impose backend trié + app re-trie défensivement. Mon schéma autorise n'importe quel ordre tant que la continuité est respectée. Le re-tri défensif est responsabilité C7. |
10. Annexe — diff résumé¶
src/export/schemas.ts | +152 (constantes + 9 schémas + 2 types branded + 1 type union + 1 fonction discriminante)
src/export/api-client.ts | +127 (1 type union + 2 fonctions privées + 1 fonction publique callExportApiV2)
src/export/__tests__/api-client.test.ts | +259 (37 nouveaux cas, 5 describe blocks PD-286)
Aucun export public legacy n'a été supprimé ou renommé. Le module est strictement additif vis-à-vis de PD-283.