Aller au contenu

PD-286 — Livrable agent-developer — Module C2 export-multi-dto

Story : PD-286 — Export probatoire multi-volumes (jusqu'à 10 GB) Module : C2 export-multi-dto Périmètre fichiers : src/modules/export/dto/{export-volume,manifest-root,complaint-file-response,index}.ts Wave plan : Wave 1 (Foundations, parallèle avec C4) Stack : NestJS + class-validator 0.14 + class-transformer 0.5 + @nestjs/swagger 7

1. Contexte

Le module C2 fournit la couche DTO contractuelle pour l'export multi-volumes :

  • ExportVolumeDto : volume unitaire dans la réponse (volumeIndex, integrityHash, estimatedBytes, signedUrl, manifest partiel typé ManifestDto).
  • ManifestRootDto + VolumeReferenceDto : manifest racine multi-volumes (consommé par C3 export-manifest-multi).
  • Extension de ComplaintFileResponseDto (PD-85) avec trois champs optionnels : volumes?, totalVolumes?, manifestRootHash?.
  • Validation cross-field INV-286-06 inline (IsValidVolumesShape).

Aucune logique métier (ni partition, ni hash, ni I/O). C2 est purement déclaratif (DTO + validation) et est consommé par C5 (controller/service) et C3 (manifest builder) en aval.

2. Fichiers produits

Fichier Statut Rôle
src/modules/export/dto/manifest-root.dto.ts nouveau ManifestRootDto, VolumeReferenceDto, constantes regex partagées
src/modules/export/dto/export-volume.dto.ts nouveau ExportVolumeDto, constante MAX_SIGNED_URL_LENGTH
src/modules/export/dto/complaint-file-response.dto.ts étendu ajout volumes?, totalVolumes?, manifestRootHash? + validateur custom IsValidVolumesShape ; @ApiPropertyOptional sur minorEvidence?
src/modules/export/dto/index.ts étendu export des nouveaux types et constantes

Aucun fichier hors périmètre n'a été modifié.

3. Couverture des invariants

Invariant Mécanisme Observable
INV-286-05 (legacy sérialisation sans volumes/totalVolumes/manifestRootHash, omis pas null) Champs déclarés ?: (TypeScript optional) sans valeur d'initialisation. Restent undefined en mode legacy. JSON.stringify les omet naturellement (pas null). Aucun class-transformer @Expose n'a été utilisé : la sérialisation par défaut + l'absence d'init suffit. TC-NR-01 (snapshot byte-stable PD-85). À vérifier en C11 que instanceToPlain(legacyResponse) ne contient pas les clés.
INV-286-06 (volumeIndex >=0 et < totalVolumes, sans trou ni doublon, cardinalité = totalVolumes) Validateur custom IsValidVolumesShape enregistré via registerDecorator. Vérifie : volumes.length === totalVolumes, chaque volumeIndex ∈ [0, totalVolumes) entier, unicité, continuité. Bornes @Min(0) aussi appliquées au champ volumeIndex lui-même dans ExportVolumeDto. TC-NEG-05, TC-ERR-06, TC-INV-10/11 (suite C11).
§5.1 integrityHash (regex /^[0-9a-f]{64}$/ case-sensitive, refus normalisation) @Matches(HEX_64_LOWER_REGEX) strictement lowercase. Aucun @Transform (interdit par forbidden YAML). TC-NEG-02 (uppercase rejeté), TC-ERR-03.
§5.1 signedUrl (HTTPS only, ≤ 4096 chars) @IsUrl({ protocols: ['https'], require_protocol: true, require_valid_protocol: true }) + @MaxLength(4096). TC-NEG-03.
§5.1 exportId (UUID v4 strict, normalisé lowercase à l'écriture, case-insensitive en lecture) Sur ManifestRootDto.exportId : @Transform(({value}) => value.toLowerCase()) puis @Matches(UUID_V4_LOWER_REGEX). La regex stricte s'applique post-normalisation. TC-NEG-01.
§5.1 manifestRootHash (regex /^[0-9a-f]{64}$/) @IsString() @Matches(HEX_64_LOWER_REGEX) à la fois sur ManifestRootDto et ComplaintFileResponseDto. C11 unit tests.
§5.1 estimatedBytes (entier >=1, <=10_737_418_240) @IsInt() @Min(1) @Max(MAX_TOTAL_EXPORT_BYTES) sur ExportVolumeDto et VolumeReferenceDto. C11 unit tests bornes.
§5.1 totalVolumes (entier >=1) @IsInt() @Min(1). Pas de borne max contractuelle (cf. plan §9 vigilance). TC-NEG-04.

4. Couverture des forbidden

Forbidden Mécanisme de défense
Sérialisation null/undefined pour volumes/totalVolumes/manifestRootHash en legacy Champs ?: sans init → restent undefined natif → omis par JSON.stringify. Le code consommateur (C5) ne doit JAMAIS faire dto.volumes = null ; doc dans le commentaire de tête du DTO.
Regex case-insensitive sur hashes Constante unique HEX_64_LOWER_REGEX = /^[0-9a-f]{64}$/ (lowercase only), réutilisée dans les 3 fichiers. Pas de flag i.
Transformation/normalisation côté DTO d'un integrityHash reçu Aucun @Transform sur integrityHash (ni dans ExportVolumeDto, ni dans VolumeReferenceDto).
Record<string, unknown> pour manifest manifest!: ManifestDto (typé explicite). Validation @ValidateNested() @Type(() => ManifestDto).
Champ optionnel non documenté @ApiProperty/@ApiPropertyOptional Tous les champs ?: portent @ApiPropertyOptional : volumes?, totalVolumes?, manifestRootHash?, minorEvidence?.

5. Décisions architecturales

Format conforme au schéma architectural_decisions du code contract (PD-XX-code-contracts.yaml).

Décision D1 — Sérialisation conditionnelle via undefined natif (pas class-transformer @Expose)

  • Décision : Utiliser des champs ?: TypeScript sans valeur d'initialisation, sans @Exclude/@Expose ni groupe class-transformer.
  • Rationale : JSON.stringify omet nativement les clés undefined ; la solution est byte-stable, sans dépendance à un mécanisme de groupes class-transformer qui ajouterait du couplage avec la couche service. Le ClassSerializerInterceptor global (déjà actif via main.ts) ne touche pas aux champs sans décorateur particulier.
  • Alternatives considérées :
  • @Expose({ groups: ['multi'] }) + activation conditionnelle dans le service via instanceToPlain(dto, { groups: ['multi'] }).
  • Deux DTO distincts LegacyComplaintFileResponseDto vs MultiVolumeComplaintFileResponseDto en union type.
  • Trade-offs :
  • Avantages : zéro changement requis dans C5 (le service peut produire l'instance sans configurer de groupes), TC-NR-01 byte-stable garanti, pas de risque d'oubli de groupe.
  • Inconvénients : repose sur la discipline du service (ne pas faire dto.volumes = null ni dto.volumes = [] en legacy). Le validateur IsValidVolumesShape rattrape null/[] mais pas un assignement explicite à null côté sérialisation.

Décision D2 — Validateur cross-field IsValidVolumesShape inline (pas de fichier validators/ séparé)

  • Décision : Implémenter le validateur custom directement dans complaint-file-response.dto.ts via @ValidatorConstraint + registerDecorator.
  • Rationale : Le validateur est spécifique à ce DTO (couplage fort avec le champ frère totalVolumes). Le sortir dans dto/validators/ ajouterait un import circulaire et ne serait pas réutilisé ailleurs.
  • Alternatives considérées :
  • Validateur séparé dans dto/validators/is-valid-volumes-shape.validator.ts.
  • Validation déléguée intégralement à C1 ExportPartitioner (pas de validation au niveau DTO).
  • Trade-offs :
  • Avantages : auto-suffisance du fichier DTO, pas de fragmentation. Defense-in-depth (C1 valide aussi en runtime, mais le DTO bloque dès la sérialisation).
  • Inconvénients : complaint-file-response.dto.ts croît un peu (~160 lignes au total).

Décision D3 — Réutilisation de ManifestDto (PD-85) pour le champ manifest du volume

  • Décision : ExportVolumeDto.manifest!: ManifestDto, sans créer de type VolumeManifestDto distinct.
  • Rationale : Un manifest partiel a la même structure qu'un manifest complet — c'est un manifest sur un sous-ensemble de preuves. Créer un alias n'apporterait pas de garantie supplémentaire et complexifierait C3.
  • Alternatives considérées :
  • type VolumeManifestDto = ManifestDto (alias).
  • Sous-ensemble strict (sans proofCount).
  • Trade-offs :
  • Avantages : cohérence avec PD-85, pas de duplication de schéma, IntegrityHashComputer (PD-85) réutilisable tel quel par C3.
  • Inconvénients : import croisé complaint-file-response.dto.tsexport-volume.dto.ts (cf. §6 Vigilance).

Décision D4 — Constantes regex/limites partagées dans manifest-root.dto.ts

  • Décision : HEX_64_LOWER_REGEX, UUID_V4_LOWER_REGEX, UUID_V4_ANY_CASE_REGEX, MAX_TOTAL_EXPORT_BYTES exportés depuis manifest-root.dto.ts et réutilisés par les autres fichiers.
  • Rationale : Source unique de vérité pour les bornes contractuelles §5.1. Évite toute divergence entre fichiers.
  • Alternatives considérées : Constantes dans export.constants.ts. Rejeté car ce fichier appartient à la couche métier (PD-85) et ne devrait pas porter des regex DTO.
  • Trade-offs : Léger couplage des DTO entre eux ; acceptable car ils appartiennent au même module.

Décision D5 — Pas de @Transform sur integrityHash/manifestRootHash

  • Décision : Aucune transformation appliquée à la lecture des hashes.
  • Rationale : Forbidden YAML explicite ("Transformation/normalisation côté DTO d'un integrityHash reçu (toLowerCase, trim) — refuser stricto sensu"). Une normalisation masquerait un bug d'émission backend ou une corruption en transit.
  • Trade-offs : Pas de tolérance à la casse. Toute valeur uppercase entrante est rejetée par @Matches.

6. Points de vigilance / dette technique

# Sujet État Action attendue
V1 Import croisé complaint-file-response.dto.tsexport-volume.dto.ts Présent. Tous les usages sont dans des thunks lazy (@Type(() => ManifestDto), type: () => ExportVolumeDto). Les annotations TypeScript pures sont effacées au runtime. Aucune action immédiate. Si C11 détecte des undefined au runtime sur @Type(), factoriser ManifestDto dans son propre fichier.
V2 Pas de borne max sur totalVolumes (cf. plan §9 vigilance) Conforme spec §5.1. C5/C1 doivent appliquer une borne dérivée (MAX_TOTAL_EXPORT_BYTES / 1 au pire). C2 ne pose pas de borne arbitraire pour rester aligné avec le contrat.
V3 Validateur cross-field non actif sur instanceToPlain sortant IsValidVolumesShape agit lors de validate() (entrée). En sortie, c'est la responsabilité du service C5 de produire un DTO conforme. C5 doit invoquer validate(dto) (ou ValidationPipe côté contract test) avant JSON.stringify. Couvert par le test d'intégration C11.
V4 Manifest partiel ≠ manifest racine : ManifestDto.exportId peut être un sous-export id ou l'export id global. La spec §5 ne tranche pas explicitement. Reporté à C3. C3 doit poser sa convention (probablement : même exportId pour tous les manifests partiels d'un export, le partitionnement étant interne à un export unique).
V5 @IsUrl accepte les IDN/punycode (config par défaut de validator.js) Acceptable car signedUrl est généré par le storage signer (pas d'entrée utilisateur). Si C5 ouvre signedUrl à du remplissage utilisateur (peu probable), durcir avec une regex stricte.
V6 Cardinalité assembled_from[] (INV-286-08) non validée par C2 Hors scope C2 (manifest racine côté backend ; assembled_from côté app). C9 (app pvproof-assembler) doit garantir assembled_from.length === totalVolumes.

7. Vérification TypeScript

cd ProbatioVault-backend
npx tsc --noEmit 2>&1 | grep "src/modules/export/dto"
# (vide — 0 erreur sur le périmètre C2)

L'unique erreur résiduelle remontée par tsc --noEmit (src/modules/merkle/v2/controllers/merkle-proof-v2.controller.ts(16,48)) est pré-existante, hors du périmètre C2, et n'est pas affectée par cette PR.

8. Tests à produire en C11 (référence pour le module tests)

C2 ne livre pas de tests (hors scope). Les tests doivent être écrits par C11.

Test ID (spec §3-§8 PD-286-tests.md) Couvre Suggéré
TC-NEG-01 exportId non UUID v4 → erreur 400 validate(plainToInstance(ManifestRootDto, { exportId: 'not-a-uuid', ... })) retourne erreur sur exportId
TC-NEG-02 integrityHash uppercase ou longueur != 64 → erreur 422 uppercase rejeté, 63 chars rejeté, 65 chars rejeté
TC-NEG-03 signedUrl non HTTPS ou >4096 → erreur 422 http://... rejeté, chaîne 4097 chars rejetée
TC-NEG-04 totalVolumes=0 → erreur 500 validate(plainToInstance(ComplaintFileResponseDto, {... totalVolumes: 0, volumes: []})) rejette
TC-NEG-05 volumeIndex<0 → erreur 500 rejet sur @Min(0)
TC-ERR-06 / TC-INV-10 volumeIndex trou/doublon/hors plage IsValidVolumesShape rejette
TC-NR-01 sérialisation legacy byte-stable (sans volumes/totalVolumes/manifestRootHash) snapshot test : JSON.stringify(legacyDto) ne contient pas les nouvelles clés
TC-INV-03 union des preuves cross-volumes hors scope DTO (C1)

9. Interfaces consommées en aval

Consommateur Usage
C5 export-controller-multi Construit ComplaintFileResponseDto ; pose volumes/totalVolumes/manifestRootHash UNIQUEMENT en mode multi (laisse undefined en legacy).
C3 export-manifest-multi Produit les ManifestDto partiels, calcule integrityHash par volume et manifestRootHash global ; consomme ManifestRootDto pour structurer le manifest racine.
C10 app-export-api-client Doit reproduire les mêmes regex (HEX_64_LOWER_REGEX, UUID_V4_LOWER_REGEX) en Zod côté React Native. Source de vérité = ce fichier. Une éventuelle divergence est un défaut bloquant.
C8 app-volume-verifier Reçoit manifest typé ManifestDto (côté app, type miroir Zod) ; recalcule SHA3-256(JCS(manifest)).
C11 tests Suite class-validator + snapshot serialization.

10. Conformité aux learnings injectés

Learning Application
crypto.randomUUID() obligatoire (2026-02-21) N/A pour C2 (pas de génération d'ID dans les DTO). À enforcer en C5.
Anti-catch-absorb (2026-03-08) N/A (pas de catch dans les DTO).
Branded types pour UUID semantiquement distincts (2026-03-04) Non appliqué : exportId est un seul concept dans ce module. Si une story future introduit parentExportId/childExportId, brand obligatoire.
Zod allowlist stricte pour metadata telemetry (2026-04-23) N/A (pas de telemetry dans les DTO).

11. Notes pour le merge

  • 0 modification sur les fichiers hors src/modules/export/dto/.
  • 0 régression attendue sur PD-85 : les types existants (ManifestDto, ChronologyDto, SignedUrlDto, etc.) sont inchangés dans leur structure ; seul minorEvidence? reçoit @ApiPropertyOptional (purement déclaratif Swagger, pas d'effet runtime).
  • Convention de versioning JSON : la réponse legacy reste byte-identical (TC-NR-01) à condition que C5 ne pose JAMAIS null sur volumes/totalVolumes/manifestRootHash.
  • Migration DB / config : aucune. C2 est purement déclaratif TypeScript.