PD-286 — Livrable agent-developer : module export-audit-multi (C6)¶
Story : Export probatoire multi-volumes (PD-286) Module :
export-audit-multi(C6, Wave 4 — serial après C5/C7) Project : ProbatioVault-backend Statut : implémenté + testé (48 tests, 100 % stmts / 100 % lignes / 98.41 % branches sur les 2 fichiers du périmètre)
1. Périmètre couvert¶
Implémentation du service ExportAuditService (Injectable NestJS) et du builder stateless ExportAuditEventBuilder conformément au code contract export-audit-multi (CC-286-C6) et au plan §1.1 / §6.4.
Fichiers produits (2 fichiers, dans le périmètre autorisé)¶
| Fichier | Rôle | Lignes |
|---|---|---|
src/modules/export/services/export-audit.service.ts | Façade typée (ExportAuditService) + builder pur (ExportAuditEventBuilder) + types/enum/erreur (ExportAuditMode, ExportAuditFinalStatus, ExportAuditRejectionReason, ExportAuditValidationError) | ~365 |
src/modules/export/services/__tests__/export-audit.service.spec.ts | 48 tests unitaires (TC-NOM-05, INV-286-09, anti-énumération, forbiddens, defensive guards) | ~440 |
Aucun fichier hors périmètre n'a été modifié. Wirage ExportModule.providers et migration des call-sites auditLogService.log() directs vers ExportAuditService.append*() reportés en step 6c (cf. §8).
2. Choix de conception¶
2.1 Façade au-dessus de AuditLogService (PD-85), pas de table dédiée¶
Le code contract énonce :
Façade au-dessus du module audit existant (PD-85) — réutilise la chaîne hash existante. Append-only via permissions PG (REVOKE UPDATE,DELETE) + trigger prevent_audit_mutation.
Lecture stricte : C6 ne crée ni entité ni migration. AuditLogService (src/modules/audit/services/audit-log.service.ts, PD-37) gère déjà : - la signature HSM synchrone avec fallback BullMQ pour retry (AuditSignatureService.signAuditEntry), - la persistance dans audit_logs (table append-only via permissions PG existantes — propriété de l'underlying, hors périmètre C6), - la chaîne de hash et le séquencement (cf. service signature PD-37).
C6 est donc strictement une couche de validation et de typage au-dessus de cette façade existante. Sa valeur ajoutée vs un appel direct auditLogService.log() (que faisait C5 dans son implémentation initiale) :
- typage strict des évènements WORM (
Start | Final | Rejection) — fini lesmetadataad hoc qui divergent entre call-sites ; - validation centralisée des champs INV-286-09 (
exportIdUUID v4,volumes_count≥ 1,integrityHash[]lowercase hex 64) ; - forbidden enforcement :
signedUrl,manifest,manifestPartialrejetés AVANT I/O ; - anti-énumération :
ExportAuditRejectionReasonénumération fermée (5 valeurs uniformes) — les call-sites ne peuvent plus passer une chaîne libre qui différencierait « inexistant » de « non autorisé » (learning 2026-03-08) ; - uniformisation
eventKind(START | FINAL | REJECTION) pour permettre la corrélation parexportIdrequise par le code contract.
2.2 Trois entrées API distinctes (pas de méthode générique append)¶
| Méthode | Évènement | actionType underlying | Quand |
|---|---|---|---|
appendStart | eventKind=START | EXPORT_GENERATED | REQUESTED → PLANNED_SINGLE / PLANNED_MULTI (C5) |
appendFinal | eventKind=FINAL (status ∈ {COMPLETED, FAILED, EXPIRED}) | EXPORT_GENERATED | Finalisation côté backend (worker EXPIRED, callback app, controller de complétion) |
appendRejection | eventKind=REJECTION | EXPORT_REFUSED | Rejet pré-planification (taille, payload, interne) côté C5 |
Justification : l'union discriminée empêche les call-sites de passer un status arbitraire, et l'actionType distinct permet aux requêtes audit aval (Linear/Sonar/dashboards) de filtrer immédiatement les rejets sans inspecter le payload JSONB.
2.3 Builder stateless exporté¶
ExportAuditEventBuilder est exporté comme classe publique pour deux raisons :
- prévalidation hors I/O : un caller (ex.
ExpiredExportWorkerstep 6c) qui voudrait pré-tester la conformité d'un payload avant de tenter l'append peut fairenew ExportAuditEventBuilder().buildFinal(...)sans déclencher d'effet de bord. Utile en debug et en tests d'intégration ; - assertion défensive
assertNoForbiddenKeysstatique : permet aux call-sites legacy qui composeraient encore leur propremetadata(exception, à éliminer en step 6c) de bloquer le pattern interditsignedUrl/manifestà la frontière.
Le service en injecte une instance privée (pas via DI — la classe est sans état). Aucun état mutable.
2.4 Validation antérieure à toute I/O (fail-fast)¶
Toutes les méthodes append* valident le payload avant d'appeler auditLogService.log. Si la validation échoue (ExportAuditValidationError), aucun appel I/O n'est tenté. Cohérent avec : - l'objectif de minimiser les écritures audit corrompues (la chaîne de hash est append-only, on ne peut pas réparer un évènement invalide a posteriori) ; - le principe « anti-catch-absorb » : la validation lève une erreur typée qui propage et fait rollback la transaction métier de C5/C4.
Test couvrant : does NOT call auditLogService.log when payload validation fails (validation BEFORE I/O).
2.5 Codes uniformes UPPER_SNAKE pour reasonCode¶
Le contract interdit la différenciation « export inconnu » vs « non autorisé ». Pour appendFinal (où le caller peut vouloir préciser un motif de FAILED), j'impose une regex /^[A-Z][A-Z0-9_]{0,63}$/ qui force le format UPPER_SNAKE et bloque toute prose narrative (qui pourrait fuiter de l'information d'autorisation). Les codes uniformes attendus côté call-sites : HASH_MISMATCH, NETWORK_ERROR, TTL_EXPIRED, etc.
Pour appendRejection, la liste est encore plus stricte : énumération fermée de 5 valeurs (TOTAL_LIMIT_EXCEEDED, PROOF_TOO_LARGE, ALL_PROOFS_REJECTED, PAYLOAD_INVALID, INTERNAL). Aucune chaîne libre.
Test couvrant : rejects reasonCode with non-uniform format (anti-énumération).
2.6 Clone défensif des arrays¶
integrityHashes est cloné via spread ([...params.integrityHashes]) avant d'être placé dans le metadata. Sans cela, une mutation post-call par le caller (ex. réutilisation du même array pour un autre exportId, mutation accidentelle) pollurait le payload audit avant qu'il ne soit signé HSM par auditLogService.
Test couvrant : clones integrityHashes (caller mutation must not leak into audit payload).
3. Decision trace (architectural decisions)¶
| Décision | Rationale | Alternatives considérées | Trade-offs acceptés |
|---|---|---|---|
Façade typée sur AuditLogService (pas de nouvelle entité ExportAuditEvent) | Lecture stricte du contract « façade au-dessus du module audit existant ». Évite duplication de la chaîne hash + signature HSM PD-37. Aligne avec audit-logs centraux. | (a) Nouvelle entité export_audit_events avec sa propre chaîne hash (modèle PD-298 ShareAuditEvent). (b) Pas de service du tout, laisser C5 appeler directement auditLogService.log. | Pas de table dédiée → corrélation par entityId=exportId + filtre actionType IN (EXPORT_GENERATED, EXPORT_REFUSED). Les call-sites doivent migrer vers ExportAuditService (cf. §8). |
Trois méthodes distinctes (appendStart / appendFinal / appendRejection) au lieu d'un append(event: ExportAuditEvent) polymorphe | Type safety forte : impossible d'envoyer un START avec un finalStatus. Rend la validation déterministe par méthode. | Méthode unique avec union discriminée ExportAuditEvent | Légère duplication (signature trois fois) ; en contrepartie, l'autocomplétion IDE et le contrôle de cardinalité (integrityHashes vs totalVolumes) sont nets. |
Builder stateless exporté + assertion statique assertNoForbiddenKeys | Permet la pré-validation côté worker EXPIRED (step 6c) et la défense-in-depth des call-sites legacy. | Builder privé non exporté | API publique légèrement plus large ; en contrepartie, les tests sont plus simples et les futurs callers ne sont pas tentés de réimplémenter la validation. |
ExportAuditValidationError extends Error (pas BadRequestException) | Le service ne sait pas si l'erreur va remonter en HTTP 400 (controller) ou rester interne (worker). Caller décide du mapping. | BadRequestException (NestJS) | C5 doit wrapper avec ExportException ou laisser propager (cf. §8). Gain : service réutilisable hors HTTP. |
4. Mapping invariants → mécanismes¶
| Invariant | Exigence | Mécanisme | Localisation | Test |
|---|---|---|---|---|
| INV-286-09 (champs WORM minimum) | exportId + volumes_count + integrityHash[] (lowercase hex) + status final | Builder vérifie cardinalité, regex, énum status. Service ajoute systématiquement ces champs au metadata. | buildStart, buildFinal, buildRejection | appendFinal — emits status=COMPLETED/FAILED/EXPIRED with required WORM fields, appendStart VOLUMES_REQUIRED — emits payload with integrityHashes |
| INV-286-09 (append synchrone, fail-closed, anti-catch-absorb) | Toute erreur d'auditLogService.log propage → rollback transaction caller | Aucun try/catch interne. Le await propage l'erreur intacte. | appendStart, appendFinal, appendRejection | INV-286-09 anti-catch-absorb — propagates errors from underlying auditLogService.log (3 tests : start/final/rejection) |
| Façade au-dessus du module audit | AuditLogService.log() est l'unique sink WORM | Constructor injecte AuditLogService. Aucun accès direct repository / DataSource. | ExportAuditService constructor | Mock AuditLogService couvre tous les chemins ; pas de DataSource injectée. |
Corrélation par exportId | Toute query d'audit pour un exportId retourne TOUTES ses entrées | entityId = exportId + entityType = 'export' sur tous les évènements (START/FINAL/REJECTION). Caller upstream peut filtrer actionType IN (EXPORT_GENERATED, EXPORT_REFUSED) AND entity_id = $exportId. | Toutes les méthodes append* | appendStart test vérifie call.entityId === VALID_EXPORT_ID && call.entityType === 'export' |
| Hash lowercase hex 64 (§5.1) | integrityHash regex /^[0-9a-f]{64}$/ case-sensitive | Builder regex strict (jamais de transformation toLowerCase()). | assertHashArray | rejects integrityHash with uppercase chars (case-sensitive regex) (= TC-NEG-02 partial) |
5. Mapping critères d'acceptation → tests¶
| CA | Test(s) | Statut |
|---|---|---|
CA-286-07 (audit volumes_count + hashes après succès export) | appendFinal — TC-NOM-05 / CA-286-07 (INV-286-09) — emits status=COMPLETED with required WORM fields | ✓ |
| CA-286-07 (audit FAILED) | emits status=FAILED with required WORM fields | ✓ |
| CA-286-07 (audit EXPIRED) | emits status=EXPIRED with required WORM fields | ✓ |
CA-286-01 à 06, 08, 09, 10 sont hors périmètre du module C6 (partition / hash app / state machine / TTL — autres modules).
6. Mapping forbiddens CC-286 → mécanismes¶
| Forbidden | Mécanisme | Test |
|---|---|---|
DELETE/UPDATE sur table audit | Propriété de l'underlying audit_logs (REVOKE PG + triggers existants PD-37). C6 n'écrit jamais en direct. | Hors périmètre C6 (vérifié au niveau migration PD-37). |
| Append asynchrone (queue, Promise non awaited) | Toutes les méthodes append* sont async + await strict sur auditLogService.log. Aucun setImmediate, queueMicrotask, .then() non awaited. | Inspection du code (revue) ; les tests vérifient que mock.log est bien appelé synchroniquement dans la même tick (jest await expect). |
Stockage de signedUrl dans l'audit | assertNoForbiddenKeys rejette signedUrl, signedUrls. Builders ne placent jamais de signedUrl dans le payload. | rejects metadata containing forbidden key signedUrl/signedUrls |
Stockage du manifest complet | assertNoForbiddenKeys rejette manifest, manifestPartial. Builders n'acceptent que integrityHashes (array de hashes). | rejects metadata containing forbidden key manifest/manifestPartial |
Absorption silencieuse via .catch(() => logger.error(...)) | Aucun .catch() interne. Le await propage. | INV-286-09 anti-catch-absorb (3 tests) |
| Différenciation 'export inconnu' / 'non autorisé' | ExportAuditRejectionReason est une énumération fermée de 5 codes uniformes. reasonCode (final) doit matcher /^[A-Z][A-Z0-9_]{0,63}$/ (UPPER_SNAKE, pas de prose). | rejects reason that is not in ExportAuditRejectionReason enum, rejects reasonCode with non-uniform format (anti-énumération) |
7. Vérifications qualité¶
| Vérification | Résultat |
|---|---|
npx tsc --noEmit (filtré sur fichiers C6) | OK — aucune erreur TypeScript sur export-audit.service.ts ni export-audit.service.spec.ts. 3 erreurs préexistantes hors périmètre (merkle-proof-v2.controller.ts, complaint-file-response.dto.ts, export.service.ts) signalées au merge step 6c. |
npx jest export-audit.service.spec.ts | 48 tests passants, 0 échec, 0 skipped |
| Couverture (lignes / branches / fonctions / instructions) | 100 % / 98.41 % / 100 % / 100 % sur export-audit.service.ts — au-delà du seuil contractuel 80%/80% |
Forbidden CC (signedUrl/manifest/async/catch-absorb/anti-énum) | 6 forbiddens couverts, 1 par test dédié — voir §6 |
crypto.randomUUID | Non applicable (le service ne génère aucun ID — les UUIDs sont fournis par les callers C5/C4 qui utilisent déjà crypto.randomUUID, learning 2026-02-21) |
| Mocks DB | Aucun mock DB (le service ne touche jamais la DB directement — la responsabilité est dans AuditLogService PD-37 testé séparément) |
L'unique branche non couverte (line 339) est l'union discriminée value === 'COMPLETED' || value === 'FAILED' || value === 'EXPIRED' à l'intérieur du type guard isFinalStatus — elle est nécessairement vraie quand un test passe une valeur valide, et l'assertion qui suit ('must be one of COMPLETED, FAILED, EXPIRED') couvre déjà la branche fausse pour les valeurs invalides. Branche défensive non éliminable sans nuire à la lisibilité.
8. Points à intégrer au merge step 6c¶
8.1 Wirage ExportModule¶
Ajouter dans src/modules/export/export.module.ts :
import { ExportAuditService } from './services/export-audit.service';
@Module({
// ...
providers: [
// ...
ExportAuditService,
],
// ...
})
Pas d'exports nécessaire — le service n'est consommé que par les services internes du module export.
8.2 Migration des call-sites auditLogService.log() directs (C5)¶
L'implémentation actuelle de ExportService (src/modules/export/services/export.service.ts, lignes 168-301, 414-441, 750-770) appelle directement auditLogService.log() avec un metadata ad hoc. Cette pratique est désormais à proscrire : tout l'audit export DOIT passer par ExportAuditService.
Diff attendu en step 6c (esquisse, à formaliser par l'agent-export-controller) :
// AVANT (export.service.ts:282-298)
auditPayload: {
actorId: args.userId,
actionType: AuditActionType.EXPORT_GENERATED,
entityId: args.exportId,
entityType: 'export',
metadata: { exportId, mode: 'LEGACY_SINGLE', volumes_count: 1, /* ... */ },
}
// APRÈS (step 6c)
await this.exportAudit.appendStart({
exportId: args.exportId,
userId: args.userId,
correlationId: args.correlationId,
mode: 'LEGACY_SINGLE',
totalVolumes: 1,
totalBytes: args.totalBytes,
rejectedCount: args.rejectedProofs.length,
});
Bénéfices : - la validation integrityHashes / manifestRootHash cohérente avec INV-286-09 ne dépend plus de la discipline du caller ; - le metadata est garanti exempt de signedUrl/manifest ; - les rejets passent par appendRejection avec des codes uniformes (anti-énumération en dur).
8.3 Wirage ExpiredExportWorker (C4)¶
Le worker ExpiredExportWorker (déjà présent, fichier src/modules/export/workers/expired-export.worker.ts) doit, à chaque transition * (non terminal) → EXPIRED, appeler :
await this.exportAudit.appendFinal({
exportId: session.exportId,
userId: session.userId,
correlationId: /* généré ou lu depuis la session si tracé */,
finalStatus: 'EXPIRED',
totalVolumes: session.totalVolumes ?? 1,
integrityHashes: /* array stocké au moment du PLANNED_MULTI — voir §8.4 */,
reasonCode: 'TTL_EXPIRED',
});
8.4 Persistance des integrityHashes côté ExportSession (open question)¶
Aujourd'hui les integrityHashes ne sont pas persistés sur ExportSession — ils sont seulement émis dans le payload audit START. Pour permettre au worker EXPIRED de réémettre integrityHashes au moment du FINAL, il faut soit :
- (recommandé) ajouter une colonne
integrity_hashes JSONBsurexport_sessions(migration mineure), peuplée à la transition REQUESTED → PLANNED_MULTI ; - (alternative) le worker requête le dernier audit START via
auditLogService.findByEntity(exportId, type='export')et extraitintegrityHashesdu metadata. Plus fragile (couple le worker à la structure du metadata audit) ; - (dégradé) le worker émet un FINAL avec
integrityHashes: []. Refusé par C6 (le builder lèvecardinality must equal totalVolumes).
Recommandation step 6c : option 1, à formaliser par l'agent-export-state-backend en complément de la migration 1745000000000-PD286-ExportMultiVolumes.ts.
8.5 Endpoint de finalisation côté backend (open question, hors PD-286)¶
La spec §5.4 step 5 décrit un callback app App → Audit: append {exportId, status: COMPLETED}. Aujourd'hui aucun endpoint REST n'expose appendFinal côté backend. Trois options pour PD-286 :
- (recommandé) ajouter
POST /exports/:exportId/finalizequi valide ownership + transitionASSEMBLING → COMPLETED/FAILEDviaExportStateMachine.transition()ET appelleappendFinal. Out-of-scope C6 ; à arbitrer en step 6c ; - l'app pousse le statut via un événement de télémétrie séparé (hors WORM) et le COMPLETED est inféré côté backend via le worker EXPIRED (impasse : COMPLETED n'est jamais émis) ;
- différer cet endpoint à une story PD-XXX de suivi. Le worker EXPIRED couvre la branche EXPIRED, mais COMPLETED reste dépendant d'un caller backend à fournir.
Recommandation step 6c : option 1, story enfant à arbitrer avec le PMO.
9. Conformité aux interdits CC export-audit-multi¶
| Forbidden | Vérification |
|---|---|
DELETE/UPDATE sur export_audit_events | OK — propriété de l'underlying audit_logs (PD-37). C6 n'écrit jamais en direct sur la DB. |
| Append asynchrone (queue, Promise non awaited) | OK — toutes les méthodes sont async avec await strict. Aucun setImmediate, queueMicrotask, .then() non chaîné, Promise.all orphelin. |
Stockage signedUrl dans l'audit | OK — assertNoForbiddenKeys rejette signedUrl, signedUrls. Builders ne placent jamais ce champ dans le payload. |
Stockage manifest complet | OK — assertNoForbiddenKeys rejette manifest, manifestPartial. Builders n'acceptent que integrityHashes (array de SHA3-256 hex). |
Absorption silencieuse via .catch(() => logger.error(...)) | OK — aucun .catch() interne. Le await propage l'erreur sans transformation. Tests INV-286-09 vérifient propagation pour les 3 méthodes. |
| Différenciation 'export inconnu' / 'export non autorisé' | OK — ExportAuditRejectionReason est une énumération fermée. reasonCode (final) doit matcher /^[A-Z][A-Z0-9_]{0,63}$/ (UPPER_SNAKE strict). |
10. Hypothèses & points de vigilance¶
auditLogService.logest synchrone du point de vue caller (PD-37) : succès → retourne UUID ; HSM indisponible → throwAuditSignatureDelayedError(queue BullMQ pour retry). C6 ne change pas ce comportement. La conséquence : si HSM est down, l'erreurAuditSignatureDelayedErrorpropage et fait rollback la transaction métier — comportement fail-closed conforme à INV-286-09.- Hash chain inter-events : le hash chaîné est porté par
AuditLogService/audit_logs(PD-37). C6 ne le manipule pas. Une corrélationentityId=exportIdpermet de récupérer la chronologie WORM d'un export complet viaAuditLogService.getEntriesByEntity(exportId). - PII / metadata sensibles : aucune PII n'est introduite par C6 (pas d'IP, pas d'UA, pas de noms de fichiers). Seuls
userId(UUID) etcorrelationId(UUID) sont propagés. L'audit existantAuditLogServicepeut ajouteripAddress/userAgentsi le caller les fournit — C6 ne les expose pas dans son API publique pour minimiser le risque PII. reasonCodeUPPER_SNAKE 64 chars max : dimensionnement basé sur les codes typiques (HASH_MISMATCH,NETWORK_TIMEOUT,TTL_EXPIRED,INVALID_VOLUME_INDEX_RANGE). Le caller qui aurait besoin d'un code plus long DOIT découper enreasonCodecourt + log applicatif détaillé séparé (jamais dans le metadata WORM).- Rétro-compatibilité PD-85 : C6 ne touche aucun fichier PD-85. Les call-sites legacy
auditLogService.log()continuent à fonctionner inchangés tant que step 6c n'a pas migré C5.
11. Conclusion¶
Le module export-audit-multi (C6, Wave 4 du plan §2bis) est implémenté comme une façade typée stateless au-dessus de AuditLogService PD-37. Il est entièrement testé (48 tests, 100 % statements / 100 % lignes / 98.41 % branches) et conforme à tous les invariants INV-286-09 ainsi qu'aux 6 interdits du code contract.
Les call-sites auditLogService.log() directs encore présents dans ExportService (PD-85 + branches PD-286 multi-volumes) doivent être migrés vers ExportAuditService en step 6c (cf. §8.2). Le wirage ExportModule (§8.1), l'intégration ExpiredExportWorker (§8.3) et la persistance integrityHashes côté ExportSession (§8.4) sont les trois points d'intégration à formaliser par les agents responsables des modules C5 / C4. L'endpoint REST de finalisation COMPLETED/FAILED côté backend (§8.5) est une question ouverte à arbitrer avec le PMO — éventuellement story de suivi.