Aller au contenu

PD-286 — Livrable agent agent-developer-app-export-api-client

Story : PD-286 (Export probatoire multi-volumes) Module : app-export-api-client (projet ProbatioVault-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 callExportApiV2 plutôt que muter callExportApi.
  • 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 callExportApi en 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é volumeIndex afficherait manifest required). Inacceptable pour le mapping d'erreur précis demandé par la spec §6.
  • Trade-offs : duplication ~30 lignes du flux HTTP entre callExportApi et callExportApiV2. 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 : volumeIndex et totalVolumes sont 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ètres f(volumeIndex, totalVolumes). Le branding force as VolumeIndex au 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 à string en 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_CONTINUITY de ARTIFACT_CORRUPT dans la télémétrie), un code d'erreur supplémentaire devra être ajouté à ExportErrorCode dans src/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 callExportApicallExportApiV2 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.