Aller au contenu

PD-253 — Review Plan Gate 5 v1

Reviewer : Claude (Gate 5 — AMBIGUITY) Date : 2026-03-12 Documents examinés : - PD-253-specification.md (v2) - PD-253-tests.md (v2) - PD-253-plan.md (v1.0)


Scores

Critère Score
feasibility 9/10
coverage 9/10
risk_mitigation 8/10
coherence 9/10

Moyenne : 8.75/10 — seuil RESERVE (>= 7) atteint, seuil GO (>= 8 partout) atteint sur ¾ critères.


Réserves Gate 3 — vérification dans le plan

  • ECT-01 : RESOLU. Section §2/ECT-01 contractualise l'endpoint POST /bulk-exports/:id/confirm-download avec garde OIDC, précondition READY_FOR_DOWNLOAD, idempotence si DOWNLOADED, code 409 EXPORT_NOT_DOWNLOADABLE sinon. CC-253-05 et CC-253-12 couvrent l'implémentation. TC-NOM-15 est annoncé en complément des tests v2.

  • ECT-02 : RESOLU. Section §2/ECT-02 fournit le schéma TypeScript complet DestructionLogEntry (6 champs : document_id, destroyed_at, destruction_act_hash, destruction_batch_id, destruction_reason, bordereau_id) + l'objet DestructionLog enveloppant. Encodage UTF-8 sans BOM, chemin metadata/destruction-log.json précisé. CC-253-10 couvre le service.

  • ECT-03 : RESOLU. Section §2/ECT-03 définit BulkExportFailureReason enum 6 valeurs (PROOF_ENVELOPE_INCOMPLETE, PACKAGE_SIZE_EXCEEDED, STORAGE_UPLOAD_FAILED, HSM_SIGNATURE_FAILED, SCOPE_RESOLUTION_FAILED, UNKNOWN_ASSEMBLY_ERROR). Modélisée en VARCHAR(50) avec contrainte CHECK PostgreSQL dans la migration (§3.1). Exposée nullable dans BulkExportResponseDto.

  • ECT-05 : RESOLU (avec réserve maintenue ouverte). Section §2/ECT-05 tranche explicitement : rétention uniforme indépendante du téléchargement, purge anticipée hors périmètre PD-253. Hypothèse H-253-07 documentée. La réserve PC-253-02 de la spec reste en points ouverts mais le plan fixe un comportement contractuel déterministe — acceptable.

  • ECT-06 : RESOLU. Section §2/ECT-06 explicite le mécanisme : la définition d'"export actif" est status IN ('REQUESTED','ASSEMBLING','READY_FOR_DOWNLOAD'). La libération du quota est implicite dès la transition vers tout état terminal. ExportQuotaService.hasActiveExport() effectue un COUNT synchrone dans la transaction de création. Reprise FAILED_TIMEOUT = manuelle uniquement, motivée (risque boucle infinie).


Écarts identifiés

E-01 [MINEUR] — INV-253-11 : chiffrement des fichiers temporaires non contractualisé dans CC-253-06/11

L'invariant INV-253-11 exige que tout artefact cryptographique temporaire soit chiffré au repos. Le plan §7.4 précise que le répertoire /tmp/bulk-export-{exportId}/ contient les fichiers assemblés. CC-253-06 (processor) mentionne l'invariant dans sa liste INV couverts mais ne décrit pas le mécanisme concret de chiffrement du répertoire temporaire local : est-ce un tmpfs chiffré, un chiffrement applicatif fichier par fichier, ou une hypothèse sur le chiffrement disque de l'hôte ?

Le contrat TC-INV-11 attend une inspection des artefacts temporaires persistants, mais sans mécanisme précisé côté plan, le test n'a pas de contrat vérifiable. Le package content.enc copié depuis S3 (binaire chiffré WORM) est déjà chiffré — mais les fichiers metadata.json, destruction-log.json et les manifests temporaires contiennent des données de traçabilité dont la sensibilité doit être qualifiée.

Action attendue : préciser dans CC-253-06 et CC-253-11 le mécanisme de protection des fichiers temporaires (chiffrement disque hôte assumé, ou chiffrement applicatif explicite). Si chiffrement disque hôte assumé, documenter comme hypothèse H-253-10.


E-02 [MINEUR] — ECT-05 résolu mais transition DOWNLOADED → EXPIRED absente de la machine à états du plan

La spec §5.4 définit DOWNLOADED comme état terminal (DOWNLOADED -> * : INTERDITE). Le plan §2/ECT-05 décrit que l'ExportExpiryScheduler passe DOWNLOADED → EXPIRED à expiration du TTL de rétention. C'est une contradiction : un état terminal ne peut avoir de transition sortante par définition de la spec.

Le plan §2/ECT-05 introduit implicitement une transition DOWNLOADED → EXPIRED non autorisée par la spec v2. La spec distingue READY_FOR_DOWNLOAD → EXPIRED (non téléchargé avant TTL) de DOWNLOADED (terminal). Si un export DOWNLOADED doit aussi expirer, la spec doit être amendée pour autoriser cette transition — ou le plan doit confirmer que les exports DOWNLOADED ne passent jamais en EXPIRED (l'objet S3 est purgé mais le statut reste DOWNLOADED).

Action attendue : clarifier dans le plan si DOWNLOADED peut transiter vers EXPIRED. Si oui, demander un amendement de la spec §5.4 (ajout DOWNLOADED → EXPIRED comme transition autorisée). Si non, le scheduler ne doit traiter que les exports READY_FOR_DOWNLOAD.


E-03 [MINEUR] — Validation fonctionnelle plaintext_hash / ciphertext_hash : mécanisme de recalcul non précisé

Le plan §7.7 (learning PD-283) déclare que plaintext_hash et ciphertext_hash font l'objet d'une validation fonctionnelle ("vérification que le hash est utilisé, référencé dans le manifeste Merkle pour ciphertext_hash"). La validation format est bien couverte (regex ^[a-f0-9]{64}$ dans CC-253-08).

Cependant, le plan ne précise pas si le worker recalcule le hash ciphertext_hash à partir du binaire content.enc téléchargé depuis S3 pour comparer à la valeur stockée dans ProofEnvelope. Sans ce recalcul, la validation fonctionnelle se limite à vérifier que le champ est référencé dans le Merkle — ce qui est nécessaire mais ne détecte pas une ProofEnvelope falsifiée avec un hash cohérent en interne mais inexact par rapport au contenu.

Nuance : PD-253 est déclaré consommateur (pas producteur) des ProofEnvelopes, et la spec indique que H-253-01 assume leur validité. Le recalcul peut donc être hors périmètre. Mais l'ambiguïté entre "validation fonctionnelle" (learning PD-283) et "consommation de contrat amont" (H-253-01) doit être levée explicitement dans le plan.

Action attendue : préciser dans CC-253-08 si la validation fonctionnelle de ciphertext_hash inclut ou non un recalcul depuis content.enc. Si non, documenter comme hypothèse H-253-01 étendue (confiance totale en ProofEnvelope amont, recalcul hors périmètre).


E-04 [MINEUR] — Pattern audit dans le processor : finally { await audit() } insuffisant pour fail-closed strict

Le plan §CC-253-06 indique : "Audit à chaque transition : finally { await audit(transition) } pour garantir trace même en cas d'erreur partielle." Le finally garantit l'émission de l'événement mais ne garantit pas le fail-closed : si auditService.log() lève une exception dans le finally, cette exception remplace l'exception originale et le comportement devient imprévisible.

Le learning anti-catch-absorb (CLAUDE.md) précise : Option A finally { await audit() } — mais cela suppose que l'audit dans le worker est best-effort (log ERROR si fail) plutôt que fail-closed strict. Cette distinction doit être explicitée : dans le worker asynchrone, un échec d'audit en cours d'assemblage peut être log-ERROR (non bloquant) tandis que l'audit à la création (CC-253-05) reste fail-closed strict. Le plan mélange les deux contextes.

Action attendue : préciser dans CC-253-06 que les audits du processor sont best-effort (échec logué, non bloquant), distincts du fail-closed strict de CC-253-05 (création). Documenter explicitement l'exception à INV-253-10 dans le contexte worker.


E-05 [MINEUR] — ExportExpiryScheduler : fréquence CRON 15 min non contractualisée dans les SLA

Le plan §CC-253-11 définit un scheduler CRON "toutes les 15 min" pour la détection des exports expirés. Cette fréquence n'est pas contractualisée dans la spec (§5.3 ne définit que package_retention_ttl minimum 24h). La granularité de 15 min est cohérente avec une rétention min de 24h (delta d'expiration maximale = 15 min, soit 0.01% de la rétention minimale). Aucune SLA d'expiration à la seconde près n'est requise.

Cependant, la fréquence n'est pas exposée comme paramètre configurable. Pour des raisons de charge DB en production (table bulk_exports potentiellement volumineuse), un CRON_EXPORT_EXPIRY_INTERVAL configurable serait prudent.

Action attendue : optionnel — envisager de rendre la fréquence CRON configurable via BulkExportConfig. Non bloquant pour Gate 5.


Analyse par critère

Feasibility (9/10)

La stack NestJS / BullMQ v5 / PostgreSQL / S3 est correctement adressée : - VARCHAR + CHECK au lieu d'ALTER TYPE (learning PD-282, PD-279) : correctement appliqué §3.1 et §7.2. - BullMQ v5 API : getJobSchedulers() mentionné §7.1, deprecation évitée. - Nom de queue sans ':' : conforme §7.3. - crypto.randomUUID() : §7.5 correctement prescrit. - Branded types BulkExportId / UserId : §3.2 et CC-253-01 correctement intégrés. - S3 multipart pour 100 GB : non explicitement contractualisé. L'upload d'un package de 100 GB via S3Service.putObject(key, stream) suppose un streaming multipart. Si S3Service existant ne supporte pas le multipart, c'est un risque d'implémentation non adressé.

Déduction -1 : le plan assume que S3Service existant supporte le streaming / multipart pour 100 GB sans le vérifier explicitement.

Coverage (9/10)

Les 14 invariants (INV-253-01 à INV-253-14) sont tous couverts par au moins un CC et une tâche (§8 mapping complet). Les 5 réserves Gate 3 (ECT-01 à ECT-06) sont toutes résolues avec décision explicite. La table §8 est exhaustive et correctement croisée avec les tâches T-253-01 à T-253-24.

Déduction -1 : INV-253-11 (chiffrement temporaires) est listé dans CC-253-06 mais sans mécanisme précisé (cf. E-01). La couverture est nominale mais pas vérifiable.

Risk_mitigation (8/10)

Risques correctement traités : - BullMQ async : post-commit explicite (INV-253-14, CC-253-05 pattern queryRunner), idempotence processor décrite. - HSM optionnel : skip si indisponible, niveau standard maintenu, CC-253-13 optionnel. - Quota concurrence : COUNT synchrone dans transaction, libération implicite (ECT-06). - Taille 100 GB : H-253-02 documentée, rejet 413 précoce avant assemblage (TC-ERR-05). - purgeStale() au démarrage : §7.4, CC-253-06, CC-253-11 — learning PD-283 correctement appliqué. - Anti-catch-absorb : §7.6 et CC-253-05 pattern explicite.

Risque insuffisamment traité : - S3 multipart / streaming pour 100 GB (cf. feasibility) : non adressé. - Reprise idempotente du processor si crash entre ASSEMBLING → READY_FOR_DOWNLOAD et upload S3 : le plan décrit purgeStale() mais ne détaille pas la reprise d'un job BullMQ qui redémarre après upload partiel (l'objet S3 peut exister partiellement). Ce cas de retry n'est pas couvert dans CC-253-06.

Déduction -2 : S3 multipart non adressé + reprise crash post-upload partiel S3 non contractualisée.

Coherence (9/10)

Le plan est très cohérent avec la spec v2 : - Structure BagIt (§CC-253-08) conforme aux formats contractuels spec §5.1. - Machine à états 8 états conforme spec §5.4 — sauf l'ambiguïté DOWNLOADED → EXPIRED (E-02). - Paramètres numériques spec §5.2 reproduits dans BulkExportConfig CC-253-02 avec bornes Joi identiques. - ZA-02 (504 HTTP incohérent async) résolu proprement. - ZA-03 (snapshot sémantique) documenté H-253-08. - ZA-04 (SELECTION invalide) décision documentée.

Déduction -1 : contradiction DOWNLOADED terminal vs transition DOWNLOADED → EXPIRED introduite par le plan (E-02) sans amendement de spec.


Verdict

RESERVE

Le plan est solide, structuré et répond aux réserves Gate 3. Les scores sont au-dessus du seuil RESERVE (>= 7 en moyenne : 8.75/10). La non-atteinte du GO (>= 8 partout) est due au critère risk_mitigation à 8/10, tiré par deux points ouverts : streaming S3 multipart 100 GB et reprise crash post-upload partiel.

Conditions de levée pour GO Gate 5 :

  1. (E-02 — BLOQUANT pour cohérence spec) Clarifier si DOWNLOADED → EXPIRED est une transition autorisée ou non. Si oui : demander amendement spec §5.4. Si non : limiter le scheduler aux exports READY_FOR_DOWNLOAD uniquement.

  2. (E-01 — MAJEUR pour INV-253-11) Préciser le mécanisme de protection des fichiers temporaires dans /tmp/bulk-export-{exportId}/ (chiffrement disque hôte assumé ou applicatif). Documenter comme hypothèse ou invariant vérifiable.

  3. (Risque S3 multipart — MAJEUR pour feasibility 100 GB) Vérifier que S3Service existant supporte le multipart upload / streaming pour des objets jusqu'à 100 GB. Si non, documenter l'extension requise dans CC-253-11 ou une dépendance à une story infra.

  4. (E-03 — MINEUR) Clarifier le périmètre de la validation fonctionnelle de ciphertext_hash (recalcul vs confiance contrat amont).

  5. (E-04 — MINEUR) Distinguer explicitement audit fail-closed (création) vs audit best-effort (processor transitions) dans CC-253-06.

Points 1, 2 et 3 sont requis avant ouverture de l'étape 6. Points 4 et 5 peuvent être adressés en note de bas de contrat sans bloquer.