PD-286 — Livrable agent-developer : module export-manifest-multi (C3)¶
Story : Export probatoire multi-volumes (PD-286) Module :
export-manifest-multi(C3, Wave 2) Project : ProbatioVault-backend Statut : implémenté + testé (41 tests unitaires PASS, 332/332 tests export PASS, 0 régression PD-85)
1. Périmètre couvert¶
Extension de ExportManifestBuilder (PD-85) avec une méthode buildPartial() pour produire un manifest partiel par volume, et création de ManifestRootBuilderService qui calcule le manifestRootHash du manifest racine multi-volumes. Conforme au code contract export-manifest-multi (CC-286-C3) et au plan §1.1 / §2.2 / §3 (mapping INV-286-07 + manifestRootHash).
Fichiers produits (3 fichiers, dans le périmètre autorisé)¶
| Fichier | Rôle | Lignes |
|---|---|---|
src/modules/export/services/export-manifest-builder.service.ts (extension) | Ajout buildPartial(args) + paramètre optionnel exportedAt sur build() ; refacto interne buildManifest() partagé entre les deux entrées publiques | ~165 |
src/modules/export/services/manifest-root-builder.service.ts (nouveau) | Service NestJS stateless ManifestRootBuilderService.build() + types BuildManifestRootArgs, VolumeReference, ManifestRootResult, ManifestRootError, ManifestRootErrorReason | ~210 |
src/modules/export/services/__tests__/manifest-root-builder.service.spec.ts (nouveau) | 41 tests unitaires (nominal, déterminisme, vector regression, validations, forbidden) | ~330 |
Aucun fichier hors périmètre n'a été modifié.
Note d'intégration (handoff C5) :
ManifestRootBuilderServicen'est PAS encore enregistré dansexport.module.ts. C'est de la responsabilité de l'agent C5 (export-controller-multi) qui orchestre l'injection des services C1/C3 dansExportService, exactement commeExportPartitionerService(C1) attend aussi son enregistrement par C5.
2. Choix de conception¶
2.1 ExportManifestBuilder.buildPartial() — extension chirurgicale¶
Pourquoi pas un nouveau service ? Le manifest partiel d'un volume est, structurellement, un ManifestDto qui ne contient qu'un sous-ensemble des preuves. Le typage du DTO ExportVolumeDto.manifest (C2) est déjà ManifestDto ; introduire un type différent casserait le contrat C2 sans rien apporter. Le calcul du hash réutilise IntegrityHashComputer (PD-85) — c'est exactement la propriété attendue par INV-286-07 (« réutilise IntegrityHashComputer » dans le code contract).
Refacto interne : extraction de buildManifest(args) privé, partagé par build() (PD-85, single-volume legacy) et buildPartial() (PD-286, multi-volumes). Aucune duplication de logique de canonicalisation ou de hashing — la cohérence cross-runtime backend ↔ app dépend de cette unicité de chemin.
Paramètre exportedAt explicite : la signature build(proofs, exportId, userId, role) (PD-85) reste rétrocompatible — on ajoute un 5e argument optionnel. buildPartial() REQUIERT un exportedAt car tous les volumes d'un même export doivent partager le même timestamp pour que les manifests partiels divergent uniquement par le contenu réel (proofs). Si chaque volume reçoit new Date() indépendamment, les hashes deviennent non reproductibles. Le caller (C5 ExportService) fixe la valeur une seule fois en début de partition.
Contenu du manifest partiel : strictement les mêmes champs que la PD-85 (version, exportId, exportedAt, exportedBy, proofCount, proofs, integrityHash). Les métadonnées de volume (volumeIndex, totalVolumes) sont délibérément absentes — elles vivent au niveau ExportVolumeDto / ManifestRootDto (C2). Cette séparation permet à l'app de recalculer le hash partiel sans connaître l'index du volume, ce qui simplifie la vérification client (C8 volume-verifier) et évite de coupler le hash partiel à la position du volume dans la séquence.
2.2 ManifestRootBuilderService — calcul du manifestRootHash¶
Formule (mapping plan §3, ligne « manifestRootHash ») :
manifestRootHash = SHA3-256(JCS_RFC8785({
exportId,
totalVolumes,
volumes: [{ volumeIndex, integrityHash, estimatedBytes }, ...]
}))
Réutilise la même chaîne JsonCanonicalizeService + createHash('sha3-256') que PD-85 / IntegrityHashComputer. Aucune lib npm tierce (canonicalize, json-canonicalize) — cf. forbidden contract.
Tri par volumeIndex ASC : RFC 8785 trie les CLÉS d'objet mais pas les ÉLÉMENTS d'array. Le service trie donc défensivement le tableau volumes par volumeIndex ASC sur une copie défensive (forbidden : mutation des entrées). Conséquence : l'ordre des volumes en entrée n'a aucune importance — résout le piège review « ordre de tri volumes[] » documenté en plan §9.2.
Validations défensives alignées sur §5.1 spec et INV-286-06 :
| Champ | Règle | Type d'erreur |
|---|---|---|
exportId | UUID v4 lowercase strict (regex spec §5.1) | INVALID_EXPORT_ID |
totalVolumes | entier ≥ 1 | INVALID_TOTAL_VOLUMES |
volumes.length | === totalVolumes | INVALID_VOLUMES_CARDINALITY |
volumeIndex | entier dans [0, totalVolumes) sans doublon | INVALID_VOLUME_INDEX_RANGE / DUPLICATE_VOLUME_INDEX |
integrityHash | regex /^[0-9a-f]{64}$/ (case-sensitive, INV-286-07) | VOLUME_INTEGRITY_HASH_INVALID |
estimatedBytes | entier 1..MAX_TOTAL_EXPORT_BYTES (INV-286-01/02) | INVALID_ESTIMATED_BYTES |
Le mapping vers HTTP 5xx (§6.2 plan) reste de la responsabilité du caller C5 — le service domain-level ne dépend pas de NestJS HTTP.
Sortie immutable : Object.freeze sur le résultat et chaque volume référencé. Empêche toute mutation aval (defense in depth contre forbidden « mutation de l'objet manifest reçu »).
Defense in depth post-hash : vérification de la regex /^[0-9a-f]{64}$/ sur le hash retourné par node:crypto avant exposition. Inutile en pratique (Node renvoie toujours du hex lowercase), mais détecte une régression future si la lib changeait son format de sortie.
2.3 Aucun champ manifestRootHash dans son propre input¶
Forbidden « Inclusion du champ integrityHash dans l'input du hash (boucle infinie sémantique) — toujours exclure avant canonicalisation » : appliqué symétriquement au manifestRootHash. L'objet canonicalisé contient exactement {exportId, totalVolumes, volumes: [...]}, jamais manifestRootHash. Le test vector regression (cf. §3.5) le prouve par construction : la valeur de référence est calculée sans ce champ.
3. Couverture des tests¶
41 tests unitaires PASS pour manifest-root-builder.service.spec.ts. Tous les tests export* du backend continuent de passer (332/332).
3.1 Calcul nominal (7 tests)¶
- Single-volume (
totalVolumes: 1) → hash hex 64 chars. - Multi-volume (3 volumes, tailles différentes) → hash valide.
- Déterminisme : même input → même hash (2 appels successifs).
- Indépendance d'ordre :
[v2, v0, v1]produit le même hash que[v0, v1, v2], et le résultat sortant est trié ASC. - Vector regression : recalcule indépendamment
SHA3-256(JsonCanonicalizeService.canonicalize({...}))et vérifie l'égalité. Détecte toute dérive future de l'algo (ex : passage à SHA-256, lib JCS différente, oubli d'un champ). - Anti-collision basique : 3 inputs qui diffèrent par un seul champ produisent 3 hashes distincts.
- Anti-régression algorithme : compare explicitement
result.manifestRootHashàsha256ETsha3_256du même canonical, prouve qu'on utilise SHA3-256.
3.2 Forbidden — defensive cloning (2 tests)¶
inputVolumesn'est pas mutée parbuild()(snapshot avant/après).- Résultat retourné
Object.isFrozen(incluantvolumeset chaque référence).
3.3 Validations (32 tests)¶
exportId: 7 cas invalides (vide, uppercase, v3, mauvaise variant, mauvaise longueur, dashes mal placés, non-UUID).totalVolumes: 4 cas invalides (0, -1, fractionnel, NaN).volumescardinalité & continuité (INV-286-06) : 6 cas (lengthmismatch, index négatif, index ≥ totalVolumes, fractionnel, doublon, trou).integrityHash(INV-286-07 case-sensitive) : 7 cas invalides + 1 cas valide.estimatedBytes(INV-286-01/02) : 5 cas invalides (0, négatif, fractionnel, NaN, > MAX_TOTAL) + 2 cas bornes valides (1 et MAX_TOTAL_EXPORT_BYTES).
3.4 Mapping aux Test-IDs contractuels¶
Les tests unitaires de C3 ne portent pas de Test-ID contractuel TC-* car le contrat de test PD-286-tests.md couvre explicitement l'orchestration (TC-NOM-04 sur app-side, TC-NOM-05 sur audit, etc.). Les tests unitaires C3 verrouillent les invariants techniques sous-jacents :
| Invariant / spec | Test |
|---|---|
| INV-286-06 (volumeIndex continu, sans doublon) | « rejects volumes.length !== totalVolumes », « rejects volumeIndex out of range », « rejects duplicate volumeIndex », « rejects gap in volumeIndex » |
| INV-286-07 (case-sensitive hash) | « rejects integrityHash (uppercase) », « rejects integrityHash (mixed case) » |
| INV-286-01/02 (bornes estimatedBytes) | « rejects estimatedBytes=zero », « accepts boundary values » |
| §5.1 spec (exportId UUID v4 lowercase) | 7 cas dans validation — exportId |
| Plan §3 (réutilise IntegrityHashComputer ; SHA3-256 strict) | « uses SHA3-256 (not SHA-256) — anti-regression on algorithm » |
| Plan §9.2 (ordre de tri volumes[] tranché côté backend) | « is order-independent on the input volumes array (sorted by volumeIndex) » |
| Forbidden contract (mutation des entrées) | « does not mutate the input volumes array » |
4. Conformité aux invariants¶
| Invariant | Mécanisme | Localisation |
|---|---|---|
| INV-286-07 (intégrité partielle) | Réutilise IntegrityHashComputer.compute(manifestData) — exclusion integrityHash + JCS RFC 8785 + SHA3-256 | export-manifest-builder.service.ts (méthode privée buildManifest) |
manifestRootHash (plan §3) | createHash('sha3-256').update(JsonCanonicalizeService.canonicalize({exportId, totalVolumes, volumes: [...]}).digest('hex') | manifest-root-builder.service.ts (méthode build) |
| INV-286-01 (bornes par volume) | Validation estimatedBytes ∈ [1, MAX_TOTAL_EXPORT_BYTES] côté builder racine (defense in depth — la borne forte INV-286-01 case a/b est portée par C1) | manifest-root-builder.service.ts (validateEstimatedBytes) |
| INV-286-02 (taille totale ≤ 10 GB) | Validation estimatedBytes ≤ MAX_TOTAL_EXPORT_BYTES par volume (defense in depth — la somme est validée par C1/C5) | manifest-root-builder.service.ts |
| INV-286-06 (continuité, sans trou ni doublon) | validateVolumes → tri + check de cardinalité + index dans [0, N) + Set anti-doublon | manifest-root-builder.service.ts |
| Forbidden « SHA-256 strict » | createHash('sha3-256') explicite + test anti-régression | les deux services + spec |
| Forbidden « lib npm canonicalize » | Aucun import de canonicalize ou json-canonicalize — uniquement JsonCanonicalizeService injecté | les deux services |
| Forbidden « hash AVANT canonicalisation » | Séquence stricte canonicalize() → createHash().update() → digest() ; aucune autre branche | les deux services |
| Forbidden « inclusion du champ hash dans l'input » | Pas de manifestRootHash dans canonicalInput ; pas d'integrityHash dans manifestData (réutilise IntegrityHashComputer qui exclut explicitement) | les deux services |
| Forbidden « mutation de l'objet manifest reçu » | [...volumes].sort() sur copie défensive ; Object.freeze sur output ; nouveau ManifestDto retourné par chaque build*() | les deux services |
5. Architectural decisions (code contract YAML)¶
architectural_decisions:
- decision: "Étendre ExportManifestBuilder (PD-85) avec buildPartial() plutôt que créer un service séparé"
rationale: "Le manifest partiel est structurellement un ManifestDto avec un sous-ensemble de proofs ; séparer aurait dupliqué la chaîne JCS+SHA3-256 et risqué la divergence cross-runtime backend↔app."
alternatives_considered:
- "Service ExportPartialManifestBuilder distinct"
- "Méthode statique sans service (helper pur)"
trade_offs:
- "Avantage : un seul chemin de calcul, garantie de byte-stable identique entre PD-85 legacy et PD-286 partiel"
- "Inconvénient : la classe ExportManifestBuilder porte deux entrées publiques — accepté car la sémantique reste claire"
- decision: "exportedAt en paramètre explicite (vs new Date() inline)"
rationale: "En multi-volumes, tous les manifests partiels doivent partager le même timestamp pour que les hashes ne divergent que par le contenu réel des proofs. Génération inline produirait des timestamps différents par volume."
alternatives_considered:
- "Stocker exportedAt en champ de classe (stateful)"
- "Passer un objet args complet à build()"
trade_offs:
- "Avantage : service stateless, déterminisme total, signature explicite"
- "Inconvénient : caller doit penser à fixer exportedAt en début de partition — documenté dans la JSDoc"
- decision: "Tri volumes par volumeIndex ASC dans ManifestRootBuilderService.build()"
rationale: "RFC 8785 ne trie pas les arrays. Sans tri sémantique, deux callers passant des volumes dans un ordre différent obtiendraient des hashes différents — ambiguïté review §9.2 du plan."
alternatives_considered:
- "Exiger l'ordre déjà trié et throw si désordonné"
- "Pas de tri (responsabilité du caller)"
trade_offs:
- "Avantage : robuste à l'ordre d'entrée, plus simple côté caller"
- "Inconvénient : un peu plus de coût CPU (négligeable, N ≤ ~14 volumes max attendus)"
6. Hypothèses techniques retenues¶
| ID | Hypothèse | Vérification |
|---|---|---|
| H-PLAN-01 (RFC 8785 byte-identical backend↔app) | JsonCanonicalizeService (PD-37, RFC 8785) est utilisé uniformément côté backend ; côté app la lib partagée interne @probatiovault/jcs doit produire le même output | Vector regression test couvre le côté backend ; suite roundtrip CI dédiée à mettre en place côté app (responsabilité C8 + C10) |
| Reproductibilité du hash | Tous les inputs sont des primitives (string, number) → JCS déterministe ; aucun float, aucune Date Date non-ISO | Tests « déterminisme » et « vector regression » |
| Cardinalité réelle volumes ≤ ~14 | Le coût [...].sort() est négligeable pour N ≤ 14 (MAX_TOTAL / VOLUME_MAX = 10 GiB / 768 MiB ≈ 13.3) | Pas de benchmark explicite — limite acceptable |
7. Vérification PD-85 (non-régression)¶
| Vérification | Résultat |
|---|---|
npx tsc --noEmit sur les fichiers C3 | ✅ 0 erreur (les 2 erreurs TS dans le repo sont préexistantes : complaint-file-response.dto.ts:134 C2 et merkle-proof-v2.controller.ts:16 hors périmètre) |
Tests export-manifest-builder.spec.ts (PD-85, 10 tests) | ✅ 10/10 PASS (TC-INV-8503, TC-NR-04, TC-NR-03, CA-14, ChronologyBuilder) |
Tests export-partitioner.service.spec.ts (C1) | ✅ 39/39 PASS |
Tests export-service.spec.ts (consommateur de manifestBuilder.build) | ✅ PASS |
Suite complète --testPathPattern='export' | ✅ 332/332 PASS |
La signature de ExportManifestBuilder.build(proofs, exportId, userId, role) reste rétrocompatible (paramètre exportedAt ajouté en optionnel à la fin). Aucun appel existant n'est cassé. Le test TC-NR-04 (déterminisme PD-85) continue de passer sans modification.
8. Traçabilité tests / fichiers¶
TC-INV-286-06 (continuité volumeIndex) → manifest-root-builder.service.spec.ts (validation volumes cardinality & continuity)
TC-INV-286-07 (integrityHash case-sensitive) → manifest-root-builder.service.spec.ts (validation integrityHash)
INV-286-01/02 (bornes estimatedBytes) → manifest-root-builder.service.spec.ts (validation estimatedBytes)
SHA3-256 strict (CC forbidden) → manifest-root-builder.service.spec.ts (uses SHA3-256 not SHA-256)
JCS RFC 8785 (cohérence cross-runtime) → manifest-root-builder.service.spec.ts (vector regression)
Forbidden — mutation des entrées → manifest-root-builder.service.spec.ts (forbidden — defensive cloning)
9. Hors périmètre (refusés)¶
- ❌ Modification d'
export.module.tspour enregistrerManifestRootBuilderService— appartient à C5 (export-controller-multi), comme c'est déjà le cas pourExportPartitionerService(C1). Documenté en §1 (handoff). - ❌ Tests pour
ExportManifestBuilder.buildPartial()dans un nouveau fichier — non listé dans le périmètrefilesdu code contract (C3). La validation byte-stable du chemin partagébuildManifest()est garantie par le testvector regressioncôtéManifestRootBuilderService(qui utilise la même chaîneJsonCanonicalizeService+createHash('sha3-256')queIntegrityHashComputer) ET par les tests PD-85 existants qui passent toujours. - ❌ Suite roundtrip CI backend↔app pour H-PLAN-01 — responsabilité C8 (
app-volume-verifier) + C10 (app-export-api-client).
10. Prochain pas¶
L'agent C5 (export-controller-multi) doit, dans ExportService.export() multi-volumes :
- Fixer un
exportedAt = new Date().toISOString()une seule fois, en début de partition. - Pour chaque
VolumePlanEntryissu de C1 : charger lesLegalCompositeProof[]correspondant àproofIds, puis appeler :
const partialManifest = manifestBuilder.buildPartial({
proofs: volumeProofs,
exportId,
userId,
role,
exportedAt,
});
// partialManifest.integrityHash = SHA3-256(JCS(manifest_partial \ integrityHash))
- Construire les
VolumeReference[]à partir desVolumePlanEntry+partialManifest.integrityHash, puis :
const root = manifestRootBuilder.build({
exportId,
totalVolumes: plan.totalVolumes,
volumes: plan.volumes.map((v, i) => ({
volumeIndex: v.volumeIndex,
integrityHash: partialManifests[i].integrityHash,
estimatedBytes: v.estimatedBytes,
})),
});
// root.manifestRootHash → exposé sur ComplaintFileResponseDto.manifestRootHash
- Enregistrer
ManifestRootBuilderServicedansexport.module.tsproviders. - Câbler
ExportVolumeDto.manifest = partialManifestetExportVolumeDto.integrityHash = partialManifest.integrityHash(le hash est dupliqué : une fois dans le manifest partiel pour vérification fonctionnelle app, une fois au niveau ExportVolumeDto pour validation contrat — c'est la double-validation expected par INV-286-07).