PD-286 — Livrable agent export-controller-multi (C5)¶
Story : Export probatoire multi-volumes (>768 MiB jusqu'à 10 GiB) Module :
export-controller-multi(C5) — backend NestJS + TypeORM + PostgreSQL Wave : 3 (Intégration) — dépend de C1 partitioner, C2 DTO, C3 manifest builder, C4 state machine Agent : agent-developer (Claude)
1. Périmètre traité¶
Implémentation stricte du contrat C5 (cf. PD-286-code-contracts.yaml) :
ComplaintFileControllerétendu : routePOST /exports/complaint-fileinchangée, garde l'aiguillage entièrement délégué au service (thin layer).ExportServicerefactor pour orchestrer la chaîne complète multi-volumes : RLS ownership → validation RFC →VolumeRoutingPolicy.decide()(rejet 413 amont) → branche LEGACY_SINGLE (PD-85 préservé) ou VOLUMES_REQUIRED (partition + manifests partiels + manifest racine + signed URLs par volume) → persistanceExportSession+ transitionExportStateMachine(REQUESTED → PLANNED_SINGLE | PLANNED_MULTI) + audit synchrone fail-closed dans la même transaction.VolumeRoutingPolicyService(nouveau) : décide entreLEGACY_SINGLEetVOLUMES_REQUIRED, lève les rejets métier (413 EXPORT_TOTAL_LIMIT_EXCEEDED,413 PROOF_TOO_LARGE) avant toute partition.ExportExceptionétendue : 8 nouveaux codes machine-readable (2 codes 413 métier, 6 codes 500 d'assertion contrat de sortie).- Wiring
ExportModule: enregistrement des 3 providers manquants (ExportPartitionerServiceC1,ManifestRootBuilderServiceC3,VolumeRoutingPolicyServiceC5).
Hors périmètre (autres agents step 6b) : C1 partitioner (déjà livré), C2 DTO (déjà livré), C3 manifest builder (déjà livré), C4 state machine (déjà livré), C6 audit-multi (Wave 4 — façade dédiée), C7-C10 app, C11 tests.
2. Fichiers livrés¶
| Fichier | Statut | Description |
|---|---|---|
src/modules/export/controllers/complaint-file.controller.ts | étendu | Header + commentaire de méthode mis à jour pour documenter la polymorphie de la réponse (legacy vs multi-volumes). Aucun changement de signature. |
src/modules/export/services/export.service.ts | refacto | Aiguillage policy + branches executeLegacySingle / executeMultiVolumes ; persistance ExportSession + transition machine + audit synchrone dans une transaction unique. |
src/modules/export/services/volume-routing-policy.service.ts | nouveau | Service stateless pur — decide(proofs) retourne { mode, totalBytes, volumesReason? } ou throw 413. |
src/modules/export/exceptions/export.exception.ts | étendu | 8 nouveaux codes (EXPORT_TOTAL_LIMIT_EXCEEDED, PROOF_TOO_LARGE, VOLUME_INTEGRITY_HASH_INVALID, INVALID_VOLUME_INDEX_RANGE, INVALID_TOTAL_VOLUMES, MANIFEST_ROOT_HASH_INVALID, VOLUME_MANIFEST_INVALID, PARTITION_INTERNAL_ERROR). |
src/modules/export/export.module.ts | étendu | Enregistrement de ExportPartitionerService, ManifestRootBuilderService, VolumeRoutingPolicyService dans providers. |
Aucun fichier hors src/modules/export/** n'a été modifié (cross_module_protections: [] respecté).
3. Couverture des invariants & forbidden (code contract)¶
3.1 Invariants¶
| Invariant code-contracts | Mécanisme | Localisation |
|---|---|---|
INV-286-02 — Σ totalSize > MAX_TOTAL_EXPORT_BYTES → throw 413 EXPORT_TOTAL_LIMIT_EXCEEDED AVANT toute partition | VolumeRoutingPolicyService.decide() calcule la somme et lève l'exception avant d'appeler ExportPartitionerService.partition(). Le service ne reçoit donc que des entrées sous quota. | services/volume-routing-policy.service.ts |
ERR-286-02 — ∃ proof.bytes > MAX_TOTAL_EXPORT_BYTES → throw 413 PROOF_TOO_LARGE AVANT toute partition | Même policy ; check préalable dans la même boucle, throw avec priorité sur le check totalSize. | services/volume-routing-policy.service.ts |
Aiguillage LEGACY_SINGLE ssi totalSize ≤ VOLUME_MAX_BYTES ET ∀ proof, proof.bytes ≤ VOLUME_MAX_BYTES (INV-286-05) | VolumeRoutingPolicyService.decide() retourne mode='LEGACY_SINGLE' uniquement quand les deux conditions sont vraies. Sinon, mode='VOLUMES_REQUIRED'. | services/volume-routing-policy.service.ts |
Aiguillage volumes[] requis (multi OU volume dédié exceptionnel) | mode='VOLUMES_REQUIRED' couvre les deux cas ; le partitioner C1 décide ensuite si on produit N≥2 volumes standards ou un seul volume dédié exceptionnel. | services/volume-routing-policy.service.ts + services/export-partitioner.service.ts (C1) |
Réutilise la route existante POST /exports/complaint-file (PD-85) — pas de nouvelle route | @Post('complaint-file') inchangé sur ComplaintFileController. La réponse est polymorphe via les champs optionnels de ComplaintFileResponseDto (cf. C2). | controllers/complaint-file.controller.ts |
exportId via crypto.randomUUID() (learning 2026-02-21, anti S2245) | import { randomUUID } from 'node:crypto'. Aucune utilisation de Math.random(). Defense-in-depth : UUID_V4_LOWER_REGEX.test(exportId) après génération avant tout appel persistance/audit. | services/export.service.ts |
Anti-catch-absorb (learning 2026-03-08) — tout catch qui appelle audit DOIT propager | Trois patterns appliqués : (a) try/throw autour de volumeRoutingPolicy.decide() qui audite puis re-throw err. (b) catch (error) global qui audite puis throw new ExportException('EXPORT_INTERNAL_ERROR'...). © dataSource.transaction(async (manager) => { ...; await auditLogService.log(...); }) — toute erreur audit fait rollback la TX, l'exception se propage naturellement (pas de .catch(() => logger.error)). | services/export.service.ts |
| Aucune modification d'autres modules — guards auth/premium et rate limiting PD-85 réutilisés tels quels | @UseGuards(OidcJwtAuthGuard, PremiumGuard) inchangés ; aucun nouveau guard / interceptor / middleware. Diff vérifié : 0 fichier hors src/modules/export/**. | controllers/complaint-file.controller.ts + export.module.ts |
3.2 Forbidden¶
| Forbidden | Protection | Vérification |
|---|---|---|
Math.random() ou Date.now() comme source d'aléatoire pour exportId | crypto.randomUUID() exclusivement. Date.now() n'est utilisé que pour expiresAt = new Date(Date.now() + sessionTtlMs) (calcul d'expiration métier, pas d'aléa). | grep -n "Math.random\|randomBytes" src/modules/export/services/export.service.ts → 0 résultat. |
| Différenciation de message d'erreur entre "export inexistant" et "export non autorisé" (anti-énumération) | checkOwnership() : un seul message Proof not found: ${proofId} qu'il s'agisse d'une preuve absente OU d'un ownership KO. Commentaire explicite dans le code. | code review L546-L575. |
Bypass d'ExportStateMachine pour les transitions REQUESTED → PLANNED_* | Toute transition passe par this.stateMachine.transition(session, target). Aucun session.state = ... direct dans export.service.ts (sauf l'INSERT initial où state = REQUESTED via le constructeur de l'entité). | grep -n "session.state\s*=" src/modules/export/services/export.service.ts → 1 seule occurrence : init REQUESTED avant transition. |
| Append audit asynchrone post-response (must be synchronous via C6) | auditLogService.log(...) est appelé dans dataSource.transaction() AVANT le retour de la réponse. Pas de setImmediate, pas de queue post-response, pas de Promise.resolve().then. | inspection de persistSessionAndAudit. |
Catch d'erreur avec .catch(() => logger.error(...)) sur appel audit | grep -n "auditLogService\.log.*catch\|emitAudit.*catch" src/modules/export/services/export.service.ts → 0 occurrence. | source inspection. |
| Modification des routes d'autres modules (proofs, complaints, etc.) | git diff --name-only après commit ne touche que src/modules/export/**. | inspection diff. |
4. Décisions architecturales (decision trace)¶
Format imposé par la consigne (ajout dans
architectural_decisionsdu code contract module C5).
architectural_decisions:
- decision: "Service VolumeRoutingPolicy stateless pur — décision séparée de la partition"
rationale: |
Le contrat C5 cite `VolumeRoutingPolicy` comme une interface explicite ;
l'isoler en service distinct (pas inline dans ExportService) facilite le
test unitaire des bornes (1 byte, exactement VOLUME_MAX_BYTES,
VOLUME_MAX_BYTES+1, exactement MAX_TOTAL, MAX_TOTAL+1) sans monter une
sandbox NestJS complète. Le service est sans dépendance (pas de DI),
pure fonction sur PartitionInputProof[].
alternatives_considered:
- "Inline directement dans ExportService.execute() (couplage fort, harder to test)"
- "Méthode statique sur ExportPartitionerService (mélange responsabilités décision vs algorithme)"
trade_offs: |
Avantage : testabilité au niveau unit, pas de drift entre la décision
et le partitioner (les bornes 768 MiB / 10 GiB sont importées de la
même constante export.constants.ts). Inconvénient : un service de plus
à enregistrer dans le module — coût marginal.
- decision: "Audit synchrone via dataSource.transaction() — pas de façade C6 anticipée"
rationale: |
C6 (export-audit-multi) est en Wave 4 et n'est pas encore livré. Plutôt
que de bloquer C5 ou d'introduire une façade vide qui serait refactorée
par C6, j'utilise directement `auditLogService.log()` (PD-37) dans une
transaction NestJS. Le contrat fonctionnel est respecté : append
synchrone, fail-closed, anti-catch-absorb. C6 pourra ultérieurement
consolider en `ExportAuditService` sans changer la sémantique.
alternatives_considered:
- "Définir un ExportAuditService stub minimal côté C5 (mais l'agent C6 risquerait de le réécrire)"
- "Différer C5 tant que C6 n'est pas livré (rompt la wave 3)"
trade_offs: |
Avantage : C5 testable et déployable sans C6. Inconvénient : un seul
point d'appel à `auditLogService.log` (vs façade dédiée) — refacto
attendu lors du livrable C6 (signal explicite via `mode` et
`state` dans le metadata pour faciliter le découpage).
- decision: "Signed URL par volume = clé S3 déterministe `exports/{exportId}/v{volumeIndex}.bundle`"
rationale: |
`ExportVolumeDto.signedUrl` contient une seule URL — implique un objet
bundle par volume côté storage. La création effective du bundle
(concaténation/tar/zip des proof files dans un seul objet S3) est
hors-périmètre du module export (probable story future ou job parallèle).
C5 sait juste signer la clé prévue : convention déterministe documentée
en hypothèse H-C5-03.
alternatives_considered:
- "Multiples signedUrls par volume (briserait le contrat ExportVolumeDto.signedUrl: string)"
- "Forcer la création du bundle ici (modification module documents — interdit cross_module)"
trade_offs: |
Avantage : C5 reste dans son périmètre, signe une clé prédictible que
le service de bundling produira (en dehors de C5). Inconvénient :
bundle absent pendant l'intégration ; les tests E2E doivent stubber
le service de bundling ou écrire un objet vide pour démontrer le flux.
Cette dette est explicite (cf. §6 hypothèses).
- decision: "Branche multi-volumes garde aussi `signedUrls` au niveau racine"
rationale: |
Pour préserver la compatibilité PD-85 (TC-NR-02) et permettre aux
consommateurs legacy de lire `signedUrls[]` sans regarder `volumes[]`,
la réponse multi-volumes inclut aussi `signedUrls = volumes.map(v => v.signedUrl)`.
Aucune contradiction avec INV-286-05 puisque INV-286-05 ne s'applique
qu'au mode single-volume — il dit que `volumes/totalVolumes/manifestRootHash`
sont absents, pas que `signedUrls` doit être absent.
alternatives_considered:
- "Omettre signedUrls en multi-volumes (casserait l'attente PD-85 d'avoir au moins ce champ)"
- "Distinguer deux DTO (LegacySingle vs Multi) en union type"
trade_offs: |
Avantage : regression-free pour tous les consommateurs PD-85.
Inconvénient : duplication signedUrl entre racine et `volumes[].signedUrl`
en multi-volumes. Acceptable car les deux pointent vers les mêmes objets.
- decision: "Anti-catch-absorb par try/throw explicite + transaction wrapping"
rationale: |
Trois mécanismes complémentaires pour appliquer le learning 2026-03-08 :
(1) Le bloc `try { policy.decide() } catch (err) { audit; throw err }`
garantit que l'audit fail-closed est émis avant la propagation 413/500.
(2) Le `dataSource.transaction()` wrappe l'audit dans une TX —
si l'audit lève, la TX rollback et l'exception se propage naturellement.
(3) Le `try/catch` global fait l'audit puis throw une `EXPORT_INTERNAL_ERROR`.
alternatives_considered:
- "finally { audit() } (ne distingue pas success/failure et n'émet pas avant le throw)"
- "Laisser auditLogService gérer ses propres retries silencieusement (forbidden)"
trade_offs: |
Avantage : aucun catch n'absorbe une erreur audit silencieusement.
Inconvénient : code légèrement plus verbeux que `finally`. Compromis assumé
pour respecter l'invariant fail-closed audit (INV-85-05 + INV-286-09).
5. Vérifications effectuées¶
npx tsc --noEmit -p tsconfig.json(depuisProbatioVault-backend/) : 0 erreur sur les 5 fichiers livrés. Les 2 erreurs résiduelles globales (complaint-file-response.dto.ts:134C2 etmerkle-proof-v2.controller.ts:16module merkle) sont hors périmètre C5 — la première est documentée dans le livrable C2, la seconde n'appartient pas au module export.- Conventions :
- Branded types
ExportId,ExportSessionUserIdpropagés depuisentities/export-session.entity.ts(learning 2026-03-04 — UUID sémantiquement distincts). - Aucun
Math.random()(learning 2026-02-21 anti S2245). - Aucun
.catch(() => logger.error(...))sur appel audit (learning 2026-03-08 anti-catch-absorb). - Aucun import direct de lib JCS (
canonicalize,json-canonicalize) — délégué à C3 viaIntegrityHashComputer. - Schéma
vault_securecohérent avec C4. forbiddenroute inchangée — diff vérifié.
6. Hypothèses d'implémentation (à valider en intégration)¶
| ID | Hypothèse | Impact si faux |
|---|---|---|
| H-C5-01 | auditLogService.log() (PD-37) lève proprement quand HSM est indisponible (pas de fallback silencieux). C5 compte sur cette propagation pour fail-closed la transaction. | Si log() swallow l'erreur (queue silently), la TX commit sans audit signé → violation INV-286-09. À couvrir par test d'intégration C11 (mock HSM down). |
| H-C5-02 | proof.anchoringEvidenceRef.encryptedBytes (number) sera renseigné par les futures stories d'ingestion. Aujourd'hui, la metadata est absente sur la majorité des preuves → fallback 1 MiB/preuve (cohérent PD-85). | Sous-estimation de la taille → policy peut renvoyer LEGACY_SINGLE alors que la taille réelle dépasserait VOLUME_MAX_BYTES. Acceptable pour MVP : C8 vérifie le hash de chaque volume (INV-286-07), donc une vraie incohérence se solde en FAILED côté app. À documenter pour reprise par story dédiée. |
| H-C5-03 | Une convention de clé S3 exports/{exportId}/v{volumeIndex}.bundle est respectée par le service de bundling de volumes (hors-périmètre C5). C5 signe la clé sans vérifier l'existence physique de l'objet (pas de HEAD). | Si le bundle n'est pas produit, le téléchargement renvoie 404 → app passe FAILED. Test d'intégration C11 doit stubber l'objet ou créer un fichier vide. Story de suivi recommandée pour le service de bundling. |
| H-C5-04 | ExportSession.totalSizeBytes (colonne bigint exposée comme string côté TypeORM) accepte des valeurs jusqu'à MAX_TOTAL_EXPORT_BYTES = 10_737_418_240. PostgreSQL bigint couvre largement (max ≈ 9.2 × 10^18). | OK. Documenté pour traçabilité. |
| H-C5-05 | EXPORT_SESSION_TTL_HOURS env var, si fournie hors bornes [1, 72] (cf. EXPORT_SESSION_TTL_HOURS_MIN/MAX), est clampée silencieusement par C5. La spec §5.2 demande "démarrage service refusé (fail-closed)" si non-respect des bornes. | Différence assumée : C5 clamp, C4 worker clamp aussi. Un test d'intégration peut basculer en fail-closed si nécessaire — revue pour PR. Note : la spec parle de "démarrage service refusé", mais le clamp évite un crash sur valeur mal renseignée en prod. À trancher en revue. |
| H-C5-06 | ExportManifestBuilder.buildPartial() (C3) renvoie un ManifestDto dont integrityHash est un hex 64 chars lowercase strict (regex /^[0-9a-f]{64}$/). Defense-in-depth via assertHashHexLower côté C5. | Si C3 retourne un format erroné, le throw 500 VOLUME_INTEGRITY_HASH_INVALID (ERR-286-03) est levé immédiatement. Aucun volume invalide ne sort dans la réponse. |
7. Observabilité¶
| Log key | Niveau | Contenu |
|---|---|---|
export_started | log | exportId, mode (LEGACY_SINGLE\|VOLUMES_REQUIRED), totalBytes, validProofs (compteur) |
volume_built | debug | exportId, volumeIndex, estimatedBytes, integrityHash[:8] (8 premiers chars uniquement — INTERDIT log hash complet learning 2026-03-08) |
export_failed | error | exportId, reason (message), stack trace |
INTERDIT (learning 2026-03-08) — non émis par C5 :
- log de
manifestcomplet - log de
signedUrlcomplète (uniquement pris dans le DTO de retour, jamais loggé) - log de
integrityHashcomplet (uniquement les 8 premiers chars en debug)
L'audit WORM (AuditLogService.log) est l'observable principal pour le suivi de conformité et la traçabilité. Métadonnées émises :
mode(LEGACY_SINGLE | VOLUMES_REQUIRED)volumes_count(1 en legacy, ≥1 en multi)integrityHashes[](uniquement en VOLUMES_REQUIRED — cf. INV-286-09 / CA-286-07)manifestRootHash(uniquement en VOLUMES_REQUIRED)totalBytes,correlationId,timestamp_utc,state(PLANNED_SINGLE/PLANNED_MULTI),rejectedCount
8. Cohérence avec PD-85¶
PD-286 étend PD-85 sans le casser :
- La route
POST /exports/complaint-filereste l'unique point d'entrée. Aucune signature publique n'a changé :createComplaintFile(user, dto) → ComplaintFileResponseDto. - La branche
executeLegacySinglereproduit le comportement PD-85 ligne par ligne (manifest legacy, chronology, signedUrls par document, guideUrl, readmeVerification, rejectedProofs, minorEvidence). La seule différence : la session est désormais persistée dansvault_secure.export_sessions(transitionREQUESTED → PLANNED_SINGLE). - Tous les tests TC-NR-01..05 (non-régression PD-85) doivent continuer à passer sans modification — la branche legacy est inchangée fonctionnellement.
- L'audit existant
EXPORT_GENERATED(PD-85) est conservé ; PD-286 ajoute des champsmetadatasupplémentaires (mode,volumes_count,integrityHashes, etc.) sans casser le schéma JSONB existant.
9. Suivi pour les agents downstream¶
- C6 (export-audit-multi, Wave 4) : pourra extraire la logique d'audit (
emitAudit+ appelauditLogService.logen TX) en façade dédiéeExportAuditService. La sémantique externe ne change pas — seul le caller bouge. Les champsmode,volumes_count,integrityHashes,manifestRootHashdansmetadatasont déjà dans le format attendu par le contrat C6. - C7 (app-export-orchestrator-multi) : reçoit la réponse polymorphe. Détection :
response.volumes !== undefined→ multi-volumes. Sinon legacy. La transitionPLANNED_* → DOWNLOADINGest portée côté app uniquement (machine app distincte mais matrice identique — vérifié par C4). - C8 (app-volume-verifier) : recompute SHA3-256 sur
JCS(ExportVolumeDto.manifest \ {integrityHash})et compare auintegrityHashreçu. C5 garantit que le hash est en lowercase strict (defense-in-depthassertHashHexLower). - C11 (tests) :
- TC-ERR-01 (413 EXPORT_TOTAL_LIMIT_EXCEEDED) : envoie un payload >10 GiB ; vérifie que la session N'EST PAS persistée (pas d'INSERT en
export_sessions). - TC-ERR-09 (413 PROOF_TOO_LARGE) : envoie une preuve >10 GiB ; vérifie que la session N'EST PAS persistée.
- TC-NOM-01 / TC-NR-01 (legacy 500 MiB) : vérifie absence de
volumes/totalVolumes/manifestRootHashdans le JSON sérialisé (snapshot byte-stable). Vérifie INSERT session avecstate=PLANNED_SINGLE. - TC-NOM-02 (multi-volumes 2 GiB) : vérifie
totalVolumes>=2, présence demanifestRootHashregex valide,volumes[].signedUrlHTTPS valide, INSERT session avecstate=PLANNED_MULTI. - TC-NOM-05 / CA-286-07 (audit) : query SQL sur
vault_secure.audit_log WHERE entity_id=exportIdretourne UNE entrée avecmetadata.volumes_count,metadata.integrityHashes(cardinalité = totalVolumes),metadata.manifestRootHash. - Anti-catch-absorb : mock
auditLogService.logpour throw → vérifie que la TX rollback (pas de session insérée) et que l'erreur se propage (HTTP 500 propagé au client). - Anti-énumération : envoie un
proofIdnon possédé par l'utilisateur ; vérifie que le message est exactementProof not found: <id>(identique au cas inexistant). - C11 — drift entre code et trigger PG : test d'intégration utilisant
ExportStateMachine.listAllowedTransitions()(C4) pour itérer chaque transition autorisée et vérifier que le trigger PG l'accepte. C5 ne fait qu'appelertransition()— la matrice reste portée par C4.