Aller au contenu

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 :

  1. Pour chaque preuve dans l'ordre trié :
  2. 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).
  3. 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).
  4. Si aucun volume standard n'a la capacité → ouverture d'un nouveau volume standard.
  5. Assignation volumeIndex = 0..N-1 après le packing.
  6. 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 une readonly PartitionInputProof[] en entrée et retourne un VolumePlan immuable. Le caller (C5 ExportService) est responsable de fournir des bytes exacts 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_BYTES et MAX_TOTAL_EXPORT_BYTES importé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 === i pour tout i ∈ [0, N).
  • INV-286-01 : estimatedBytes > 0 ; dedicated ⇒ estimatedBytes ≤ MAX_TOTAL ; !dedicated ⇒ estimatedBytes ≤ VOLUME_MAX.
  • INV-286-04 : dedicated ⇒ proofIds.length === 1 ; aucun proofId n'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.bytes doit refléter exactement la taille du fichier chiffré téléchargeable. Plan §8 H-PD286-PLAN-02 : C5 doit assertir proof.bytes === actual_file_size à la lecture metadata. Ce module ne le vérifie pas (forbidden : I/O).
  • Ordre de tri stable cross-runtime : localeCompare est ICU-dépendant. Pour des proofId UUID hex (charset [0-9a-f-]), l'ordre est byte-stable. Si un futur changement introduit des proofId non-ASCII, prévoir un localeCompare(_, 'en', { sensitivity: 'variant' }) ou un compare binaire.
  • Atomicité I/O : la limite supérieure bytes ≤ MAX_TOTAL_EXPORT_BYTES au niveau d'une preuve unique permet bien le volume dédié exceptionnel ; le rejet se fait sur bytes > 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_TTL ni EXPORT_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 :

  1. Calculer totalSize à partir de proof.bytes réels (et non plus du fallback 1 MiB de estimateTotalSize, plan §1.1 C5).
  2. Mapper le résultat de partitioner.partition(proofs) vers les DTO ExportVolumeDto[] (C2) : pour chaque VolumePlanEntry, builder le manifest partiel via ExportManifestBuilder (C3) puis calculer integrityHash via IntegrityHashComputer (PD-85 réutilisé).
  3. Wrapper l'appel partitioner.partition(...) avec un translator d'erreurs :

    try {
      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;
    }
    
    ⚠️ Anti-catch-absorb (learning 2026-03-08) : tout catch qui appelle l'audit DOIT propager l'erreur. Ne JAMAIS absorber PartitionError.

  4. Étendre ExportErrorCode (PD-85) avec EXPORT_TOTAL_LIMIT_EXCEEDED et PROOF_TOO_LARGE (deux codes distincts au lieu de réutiliser EXPORT_SIZE_EXCEEDED) pour respecter la nomenclature spec §6 et l'observabilité métriques export_rejected_total{reason="…"} (plan §3, INV-286-02 / CA-286-10).

  5. 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.