Aller au contenu

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) : ManifestRootBuilderService n'est PAS encore enregistré dans export.module.ts. C'est de la responsabilité de l'agent C5 (export-controller-multi) qui orchestre l'injection des services C1/C3 dans ExportService, exactement comme ExportPartitionerService (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 à sha256 ET sha3_256 du même canonical, prouve qu'on utilise SHA3-256.

3.2 Forbidden — defensive cloning (2 tests)

  • inputVolumes n'est pas mutée par build() (snapshot avant/après).
  • Résultat retourné Object.isFrozen (incluant volumes et 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).
  • volumes cardinalité & continuité (INV-286-06) : 6 cas (length mismatch, 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.ts pour enregistrer ManifestRootBuilderService — appartient à C5 (export-controller-multi), comme c'est déjà le cas pour ExportPartitionerService (C1). Documenté en §1 (handoff).
  • ❌ Tests pour ExportManifestBuilder.buildPartial() dans un nouveau fichier — non listé dans le périmètre files du code contract (C3). La validation byte-stable du chemin partagé buildManifest() est garantie par le test vector regression côté ManifestRootBuilderService (qui utilise la même chaîne JsonCanonicalizeService + createHash('sha3-256') que IntegrityHashComputer) 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 :

  1. Fixer un exportedAt = new Date().toISOString() une seule fois, en début de partition.
  2. Pour chaque VolumePlanEntry issu de C1 : charger les LegalCompositeProof[] correspondant à proofIds, puis appeler :
const partialManifest = manifestBuilder.buildPartial({
  proofs: volumeProofs,
  exportId,
  userId,
  role,
  exportedAt,
});
// partialManifest.integrityHash = SHA3-256(JCS(manifest_partial \ integrityHash))
  1. Construire les VolumeReference[] à partir des VolumePlanEntry + 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
  1. Enregistrer ManifestRootBuilderService dans export.module.ts providers.
  2. Câbler ExportVolumeDto.manifest = partialManifest et ExportVolumeDto.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).