PD-286 — Livrable agent-developer : module export-partitioner (C1)¶
Story : Export probatoire multi-volumes (PD-286) Module :
export-partitioner(C1, Wave 2) Project : ProbatioVault-backend Statut : implémenté + testé (39 tests, 100 % branches/lignes)
1. Périmètre couvert¶
Implémentation du service ExportPartitionerService et des types VolumePlan conformément au code contract export-partitioner (CC-286-C1) et au plan §1.1 / §2.2.
Fichiers produits (3 fichiers, dans le périmètre autorisé)¶
| Fichier | Rôle | Lignes |
|---|---|---|
src/modules/export/types/volume-plan.type.ts | Contrat d'I/O : PartitionInputProof, VolumePlanEntry, VolumePlan, PartitionError, PartitionErrorReason | ~75 |
src/modules/export/services/export-partitioner.service.ts | Service NestJS stateless ExportPartitionerService.partition() + assertions runtime | ~250 |
src/modules/export/services/__tests__/export-partitioner.service.spec.ts | 39 tests unitaires (TC-NOM, TC-INV, TC-ERR, déterminisme, gardes défensifs, NR PD-85) | ~430 |
Aucun fichier hors périmètre n'a été modifié.
2. Choix de conception¶
2.1 Algorithme — First-Fit Decreasing déterministe¶
Tri : bytes DESC, tiebreak proofId ASC (localeCompare). Aucune source non déterministe (pas de Math.random(), Date.now(), crypto.randomUUID()) — conforme au forbidden « Math.random() ou source non déterministe » du CC et au learning 2026-02-21.
Bin-packing :
- Pour chaque preuve dans l'ordre trié :
- Si
bytes > VOLUME_MAX_BYTES(768 MiB) → ouverture immédiate d'un volume dédié exceptionnel (1 seule preuve, INV-286-01 case b + INV-286-04). - Sinon → First-Fit dans les volumes standards (saute les dédiés, les volumes dédiés ne sont jamais réouverts pour ajouter une preuve).
- Si aucun volume standard n'a la capacité → ouverture d'un nouveau volume standard.
- Assignation
volumeIndex = 0..N-1après le packing. - Gel des structures (
Object.freeze) pour interdire la mutation côté caller.
Pourquoi FFD plutôt que Next-Fit Decreasing : FFD donne un meilleur facteur d'approximation (11/9·OPT au pire) et peut remplir les volumes ouverts par des petites preuves arrivant après une grande, ce qui réduit le nombre de volumes facturés au transfert mobile. La complexité reste O(N²) acceptable sur ≤ 500 preuves (DEFAULT_MAX_PROOF_IDS PD-85).
2.2 Service stateless pur¶
- Aucune injection : pas de
Repository,S3PresignService,ConfigService,Logger. Le service prend unereadonly PartitionInputProof[]en entrée et retourne unVolumePlanimmuable. Le caller (C5ExportService) est responsable de fournir desbytesexacts depuis la metadata DB. - Aucun effet de bord : pas d'I/O, pas de DB, pas de log (forbidden CC). Cela respecte aussi le principe d'isolation step 6b (CLAUDE.md).
- Constantes via export.constants.ts :
VOLUME_MAX_BYTESetMAX_TOTAL_EXPORT_BYTESimportés exclusivement depuis../export.constants(forbidden CC : pas de magic number).
2.3 Types et erreurs métier découplées de NestJS¶
Le service lève une PartitionError (extends Error, pas HttpException) avec un champ reason: PartitionErrorReason discriminé :
reason | Sémantique | Mapping HTTP attendu (côté C5) |
|---|---|---|
EXPORT_TOTAL_LIMIT_EXCEEDED | ERR-286-01 | 413 EXPORT_TOTAL_LIMIT_EXCEEDED |
PROOF_TOO_LARGE | ERR-286-02 / CA-286-10 | 413 PROOF_TOO_LARGE |
EMPTY_INPUT | input vide (caller doit pré-filtrer) | 422 (ALL_PROOFS_REJECTED côté C5) |
INVALID_PROOF_BYTES | bytes ≤ 0, non-entier, NaN, Infinity | 500 (data-integrity, défaut metadata DB) |
DUPLICATE_PROOF_ID | doublon dans l'entrée | 500 (bug caller) |
Cela maintient la pureté du service (forbidden : effets I/O) tout en laissant C5 décider du code HTTP final.
2.4 Assertions runtime obligatoires (defense in depth)¶
Le contract impose une « assertion runtime obligatoire en fin de partition ». La méthode privée assertInvariants(volumes, inputProofs) vérifie systématiquement :
- INV-286-06 :
volumes[i].volumeIndex === ipour touti ∈ [0, N). - INV-286-01 :
estimatedBytes > 0;dedicated ⇒ estimatedBytes ≤ MAX_TOTAL;!dedicated ⇒ estimatedBytes ≤ VOLUME_MAX. - INV-286-04 :
dedicated ⇒ proofIds.length === 1; aucunproofIdn'apparaît dans deux volumes. - INV-286-03 :
set(union(volumes.proofIds)) === set(input.proofIds)(cardinalité + appartenance).
Tous ces gardes sont testés directement (8 tests dédiés via cast as unknown). Si une régression de l'algorithme casse un invariant, le service throw immédiatement plutôt que de retourner un plan corrompu — pattern fail-closed conforme CONSTITUTIONAL Art. IV.
3. Decision trace (architectural decisions)¶
| Décision | Rationale | Alternatives considérées | Trade-offs acceptés |
|---|---|---|---|
partition() synchrone (pas async) | Algorithme pur sans I/O — async ajouterait un overhead inutile | async (cohérence avec autres services PD-85) | Cohérence légèrement réduite, gain en lisibilité et en performance |
Erreurs PartitionError extends Error (et non HttpException) | Forbidden CC : pas d'I/O / framework coupling. Caller C5 mappe vers HTTP. | ExportException (PD-85, étendre ExportErrorCode) | Caller C5 doit ajouter un translator try/catch ; le service reste réutilisable hors HTTP (worker, batch) |
Type PartitionInputProof ≠ entité LegalCompositeProof | Découplage — le partitionneur n'a besoin que de proofId + bytes. Permet de tester sans fixture DB. | Accepter LegalCompositeProof[] (cohérence avec ChronologyBuilder) | Caller C5 doit faire un mapping trivial ; gain en testabilité unitaire et en pureté |
4. Mapping invariants → mécanismes (récap)¶
| Invariant | Mécanisme | Localisation | Test |
|---|---|---|---|
| INV-286-01 | Borne par construction (FFD respecte ≤ VOLUME_MAX, dédié ≤ MAX_TOTAL) + assertInvariants | binPack + assertInvariants | TC-INV-01, TC-NOM-06, gardes défensifs |
| INV-286-02 | Check explicite totalBytes > MAX_TOTAL → throw EXPORT_TOTAL_LIMIT_EXCEEDED | partition() ligne 1 du flow | TC-ERR-01, boundary MAX_TOTAL exact |
| INV-286-03 | Itération unique sur l'entrée + assertInvariants (set comparison) | binPack + assertInvariants | TC-INV-03, gardes union manquante / preuve fantôme |
| INV-286-04 | Volume dédié = 1 preuve par construction ; seen.add(pid) + check duplicates | binPack + assertInvariants | TC-INV-04, TC-NOM-06, garde double appartenance |
| INV-286-06 | volumes.map((v,i) => ({...,volumeIndex:i})) + assertInvariants | partition() post-binPack + assertInvariants | TC-INV-06, garde gap |
5. Mapping critères d'acceptation → tests¶
| CA | Test(s) | Statut |
|---|---|---|
| CA-286-01 (export 2GB → multi-volumes conformes) | TC-NOM-02 (4×600MB, FFD efficient) | ✓ |
| CA-286-02 (single-volume legacy ≤ 768MB) | TC-NR-01 (350MB en 1 volume non dédié) | ✓ |
| CA-286-03 (preuve 900MB → volume dédié exceptionnel) | TC-NOM-06 (single + mixed) | ✓ |
| CA-286-10 (preuve > 10GB → 413 PROOF_TOO_LARGE) | TC-ERR-09 (11GB) | ✓ |
CA-286-04/05/06/07/08/09 sont hors périmètre du module C1 (assemblage app, hash app, audit, state machine, TTL — autres modules).
6. Vérifications qualité¶
| Vérification | Résultat |
|---|---|
npx tsc --noEmit (filtré sur fichiers C1) | OK — aucune erreur TypeScript sur les nouveaux fichiers (2 erreurs préexistantes sur complaint-file-response.dto.ts et merkle-proof-v2.controller.ts hors périmètre signalées au merge) |
npx jest export-partitioner.service.spec.ts | 39 tests passants, 0 échec |
| Couverture (lignes / branches / fonctions / instructions) | 100 % / 100 % / 100 % / 100 % sur les deux fichiers C1 |
Forbidden CC (Math.random, VOLUME_MAX_BYTES inline, I/O, DB) | Aucun — vérifié par lecture |
crypto.randomUUID requis ? | Non applicable (le service ne génère aucun ID) |
7. Hypothèses & décisions hors-périmètre¶
PartitionInputProof.bytesdoit refléter exactement la taille du fichier chiffré téléchargeable. Plan §8 H-PD286-PLAN-02 : C5 doit assertirproof.bytes === actual_file_sizeà la lecture metadata. Ce module ne le vérifie pas (forbidden : I/O).- Ordre de tri stable cross-runtime :
localeCompareest ICU-dépendant. Pour desproofIdUUID hex (charset[0-9a-f-]), l'ordre est byte-stable. Si un futur changement introduit des proofId non-ASCII, prévoir unlocaleCompare(_, 'en', { sensitivity: 'variant' })ou un compare binaire. - Atomicité I/O : la limite supérieure
bytes ≤ MAX_TOTAL_EXPORT_BYTESau niveau d'une preuve unique permet bien le volume dédié exceptionnel ; le rejet se fait surbytes > MAX_TOTAL(strict), conforme à ERR-286-02 et CA-286-10. - Pas d'horaire / pas de TTL : C1 ne touche pas aux SLA
SIGNED_URL_TTLniEXPORT_SESSION_TTL(relevant de C4 + worker EXPIRED).
8. Points à intégrer au merge step 6c (C5 / agent-export-controller-multi)¶
Le caller C5 (ExportService.execute()) doit, après refactor PD-85 :
- Calculer
totalSizeà partir deproof.bytesréels (et non plus du fallback 1 MiB deestimateTotalSize, plan §1.1 C5). - Mapper le résultat de
partitioner.partition(proofs)vers les DTOExportVolumeDto[](C2) : pour chaqueVolumePlanEntry, builder le manifest partiel viaExportManifestBuilder(C3) puis calculerintegrityHashviaIntegrityHashComputer(PD-85 réutilisé). -
Wrapper l'appel
partitioner.partition(...)avec un translator d'erreurs :⚠️ Anti-catch-absorb (learning 2026-03-08) : touttry { const plan = this.partitioner.partition(proofs.map(p => ({ proofId: p.proofId, bytes: p.bytes }))); } catch (err) { if (err instanceof PartitionError) { switch (err.reason) { case 'EXPORT_TOTAL_LIMIT_EXCEEDED': throw new ExportException('EXPORT_SIZE_EXCEEDED', err.message, err.details); case 'PROOF_TOO_LARGE': throw new ExportException('EXPORT_SIZE_EXCEEDED', err.message, err.details); // ... autres reasons → 500 INTERNAL_ERROR } } throw err; }catchqui appelle l'audit DOIT propager l'erreur. Ne JAMAIS absorberPartitionError. -
Étendre
ExportErrorCode(PD-85) avecEXPORT_TOTAL_LIMIT_EXCEEDEDetPROOF_TOO_LARGE(deux codes distincts au lieu de réutiliserEXPORT_SIZE_EXCEEDED) pour respecter la nomenclature spec §6 et l'observabilité métriquesexport_rejected_total{reason="…"}(plan §3, INV-286-02 / CA-286-10). -
Enregistrer le service dans
ExportModule.providers(export.module.ts).
Ces points dépassent le périmètre du module C1 (forbidden : modifier d'autres fichiers) — signalés ici pour qu'ils soient pris en compte par C5 / step 6c.
9. Conformité aux interdits CC export-partitioner¶
| Forbidden | Vérification |
|---|---|
| Modification d'une preuve (split/merge/recompression) | OK — le service ne touche jamais aux bytes des fichiers, il manipule uniquement proofId (string) et bytes (number) |
| Lecture des bytes du fichier preuve | OK — aucune lecture de fichier, aucun import fs, aws-sdk, etc. |
Constante VOLUME_MAX_BYTES inline (number magique) | OK — toujours importée depuis ../export.constants |
Math.random() ou source non déterministe | OK — uniquement localeCompare et arithmétique pure |
| Effets de bord I/O ou DB | OK — pas d'injection (Repository, ConfigService, Logger), aucun await |
10. Conclusion¶
Le module export-partitioner (C1, Wave 2 du plan §2bis) est implémenté, entièrement testé (39 tests, 100 % branch coverage) et conforme à tous les invariants INV-286-01/02/03/04/06 ainsi qu'aux interdits du code contract. Il est prêt à être consommé par C5 (ExportService étendu) en step 6c — voir §8 pour les points d'intégration à respecter côté caller.