Aller au contenu

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) :

  1. typage strict des évènements WORM (Start | Final | Rejection) — fini les metadata ad hoc qui divergent entre call-sites ;
  2. validation centralisée des champs INV-286-09 (exportId UUID v4, volumes_count ≥ 1, integrityHash[] lowercase hex 64) ;
  3. forbidden enforcement : signedUrl, manifest, manifestPartial rejetés AVANT I/O ;
  4. 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) ;
  5. uniformisation eventKind (START | FINAL | REJECTION) pour permettre la corrélation par exportId requise 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. ExpiredExportWorker step 6c) qui voudrait pré-tester la conformité d'un payload avant de tenter l'append peut faire new ExportAuditEventBuilder().buildFinal(...) sans déclencher d'effet de bord. Utile en debug et en tests d'intégration ;
  • assertion défensive assertNoForbiddenKeys statique : permet aux call-sites legacy qui composeraient encore leur propre metadata (exception, à éliminer en step 6c) de bloquer le pattern interdit signedUrl/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 :

  1. (recommandé) ajouter une colonne integrity_hashes JSONB sur export_sessions (migration mineure), peuplée à la transition REQUESTED → PLANNED_MULTI ;
  2. (alternative) le worker requête le dernier audit START via auditLogService.findByEntity(exportId, type='export') et extrait integrityHashes du metadata. Plus fragile (couple le worker à la structure du metadata audit) ;
  3. (dégradé) le worker émet un FINAL avec integrityHashes: []. Refusé par C6 (le builder lève cardinality 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 :

  1. (recommandé) ajouter POST /exports/:exportId/finalize qui valide ownership + transition ASSEMBLING → COMPLETED/FAILED via ExportStateMachine.transition() ET appelle appendFinal. Out-of-scope C6 ; à arbitrer en step 6c ;
  2. 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) ;
  3. 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.log est synchrone du point de vue caller (PD-37) : succès → retourne UUID ; HSM indisponible → throw AuditSignatureDelayedError (queue BullMQ pour retry). C6 ne change pas ce comportement. La conséquence : si HSM est down, l'erreur AuditSignatureDelayedError propage 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élation entityId=exportId permet de récupérer la chronologie WORM d'un export complet via AuditLogService.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) et correlationId (UUID) sont propagés. L'audit existant AuditLogService peut ajouter ipAddress/userAgent si le caller les fournit — C6 ne les expose pas dans son API publique pour minimiser le risque PII.
  • reasonCode UPPER_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 en reasonCode court + 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.