PD-286 — Livrable agent-developer — Module C2 export-multi-dto¶
Story : PD-286 — Export probatoire multi-volumes (jusqu'à 10 GB) Module : C2
export-multi-dtoPérimètre fichiers :src/modules/export/dto/{export-volume,manifest-root,complaint-file-response,index}.tsWave 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 C3export-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_decisionsdu 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/@Exposeni groupe class-transformer. - Rationale :
JSON.stringifyomet nativement les clésundefined; la solution est byte-stable, sans dépendance à un mécanisme de groupes class-transformer qui ajouterait du couplage avec la couche service. LeClassSerializerInterceptorglobal (déjà actif viamain.ts) ne touche pas aux champs sans décorateur particulier. - Alternatives considérées :
@Expose({ groups: ['multi'] })+ activation conditionnelle dans le service viainstanceToPlain(dto, { groups: ['multi'] }).- Deux DTO distincts
LegacyComplaintFileResponseDtovsMultiVolumeComplaintFileResponseDtoen 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 = nullnidto.volumes = []en legacy). Le validateurIsValidVolumesShaperattrapenull/[]mais pas un assignement explicite ànullcô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.tsvia@ValidatorConstraint+registerDecorator. - Rationale : Le validateur est spécifique à ce DTO (couplage fort avec le champ frère
totalVolumes). Le sortir dansdto/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.tscroî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 typeVolumeManifestDtodistinct. - 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.ts↔export-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_BYTESexportés depuismanifest-root.dto.tset 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.ts ↔ export-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 ; seulminorEvidence?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
nullsurvolumes/totalVolumes/manifestRootHash. - Migration DB / config : aucune. C2 est purement déclaratif TypeScript.