PD-279 — Plan d'implémentation
1. Découpage en composants
| # | Composant | Responsabilité | Fichiers | Agent |
| C1 | Migration DDL | Extension enum document_status + colonnes restituted_at, restitution_deadline + contrainte CHECK + down migration avec précondition | src/database/migrations/1741300000000-PD279-AddRestitutedStatus.ts | agent-developer |
| C2 | DocumentStatus enum extension | Ajout de RESTITUTED au cycle de vie TypeScript | src/modules/documents/entities/document-secure.entity.ts | agent-developer |
| C3 | State machine extension | Ajout des transitions SEALED→RESTITUTED et RESTITUTED→SEALED dans la matrice + export RESTITUTED const | src/modules/destruction/services/document-state-machine.service.ts | agent-developer |
| C4 | RestitutionService | Logique métier de restitution et retour : gardes, atomicité, attestation lifecycle_log, calcul SLA | src/modules/documents/services/restitution.service.ts | agent-developer |
| C5 | RestitutionController | Endpoints REST POST /documents/:id/restitute et POST /documents/:id/return-from-restitution | src/modules/documents/controllers/restitution.controller.ts | agent-developer |
| C6 | RestitutionConfig | Configuration SLA (durée max, seuil alerte, seuil escalade) avec Joi validation | src/modules/documents/config/restitution.config.ts | agent-developer |
| C7 | RestitutionSlaScheduler | Job planifié BullMQ pour alerte 80%, escalade 100%, événement RESTITUTION_OVERDUE | src/modules/documents/services/restitution-sla.scheduler.ts | agent-developer |
| C8 | Destruction guard extension | Ajout vérification RESTITUTED dans l'eligibility service et l'exécution de destruction | src/modules/destruction/services/eligibility.service.ts, src/modules/destruction/services/destruction-execution.service.ts | agent-developer |
| C9 | Audit integration | Ajout AuditActionType pour restitution/retour/refus + émission via AuditLogService | src/modules/audit/types/audit-action.types.ts | agent-developer |
| C10 | JournalEventType extension | Ajout types RESTITUTION, RETURN_FROM_RESTITUTION, RESTITUTION_OVERDUE dans le journal d'intégrité | src/modules/integrity/enums/journal-event-type.enum.ts | agent-developer |
| C11 | Error codes | DTOs d'erreur + constantes error_code pour les erreurs 409 de restitution | src/modules/documents/dto/restitution-error.dto.ts | agent-developer |
| C12 | Tests unitaires + intégration | Suite de tests couvrant TC-NOM-01 à TC-NOM-07, TC-ERR-01 à TC-ERR-10, TC-INV-09A/B, TC-NR-01 à TC-NR-04, TC-NEG-01 à TC-NEG-05 | src/modules/documents/__tests__/, src/modules/destruction/__tests__/ | agent-qa-unit-integration |
| C13 | TLA+ model update | Extension du modèle formel AnchorAssume_States avec l'état RESTITUTED | ProbatioVault-doc/docs/normes/iso-14641/formal/ISO_14641.tla | agent-developer |
1b. Équivalence terminologique
| Terme spec (PD-279-specification.md) | Terme implémentation (backend) | Résolution |
lifecycle_log | integrity_journal_entries (table) via IntegrityJournalService.appendEntry() | Le terme spec lifecycle_log désigne le registre d'attestations de transitions d'état. L'implémentation backend matérialise ce registre dans la table vault_integrity.integrity_journal_entries via le service IntegrityJournalService (PD-251). Les deux termes sont équivalents : lifecycle_log = concept métier, integrity_journal_entries = implémentation technique. |
geo_copy_count | Colonne geo_copy_count sur documents (ajoutée par migration PD-279) | Colonne ajoutée avec DEFAULT 0 (fail-safe). Spec §5.1 définit le format ; spec §5.7 ne liste pas cette colonne explicitement car elle est un attribut de garde, pas un champ temporel. Ajout contractualisé dans ce plan (voir résolution HT-279-02). |
2. Flux techniques
2.1 Flux A — Restitution (POST /documents/:id/restitute)
Client → RestitutionController.restitute(id)
│
├─ 1. ParseUUIDPipe (validation format) → 400 si invalide
│
└─ RestitutionService.restitute(documentId, actorId)
│
├─ 2. Lecture document via DataSource (SELECT ... FOR UPDATE)
│ → 404 si inexistant
│
├─ 3. Vérification ownership (document.userId === actorId)
│ → 403 si non owner
│
├─ 4. Vérification état courant
│ ├─ Si déjà RESTITUTED → 200 idempotent (retour body actuel, pas d'attestation)
│ └─ Si != SEALED → 409 INVALID_SOURCE_STATE
│
├─ 5. Vérification legal_lock
│ → 409 DOCUMENT_UNDER_LEGAL_LOCK si true
│
├─ 6. Vérification geo_copy_count >= 2
│ → 409 INSUFFICIENT_GEO_COPIES si < 2
│
├─ 7. Calcul SLA
│ restituted_at = NOW() UTC
│ restitution_deadline = restituted_at + restitution_max_duration
│
├─ 8. Transaction ACID (QueryRunner)
│ ├─ UPDATE documents SET status=RESTITUTED, restituted_at, restitution_deadline
│ └─ INSERT lifecycle_log (type=RESTITUTION, actor_id, security_level, timestamp_utc)
│
├─ 9. Post-commit : AuditLogService.logAsync(document.restitute)
│
└─ 10. HTTP 200 + body document mis à jour
2.2 Flux B — Retour de restitution (POST /documents/:id/return-from-restitution)
Client → RestitutionController.returnFromRestitution(id)
│
├─ 1. ParseUUIDPipe → 400 si invalide
│
└─ RestitutionService.returnFromRestitution(documentId, actorId)
│
├─ 2. Lecture document (SELECT ... FOR UPDATE)
│ → 404 si inexistant
│
├─ 3. Vérification ownership → 403
│
├─ 4. Vérification état courant
│ ├─ Si déjà SEALED → 200 idempotent
│ └─ Si != RESTITUTED → 409 INVALID_SOURCE_STATE
│
├─ 5. Transaction ACID
│ ├─ UPDATE documents SET status=SEALED
│ └─ INSERT lifecycle_log (type=RETURN_FROM_RESTITUTION, actor_id, security_level, timestamp_utc)
│
├─ 6. Post-commit : AuditLogService.logAsync(document.return_from_restitution)
│
└─ 7. HTTP 200 + body document
2.3 Flux C — Contrôle destruction cross-module
Module Destruction (routes existantes — 3 routes per INV-279-10)
│
├─ Route 1 : POST /destruction/batches (inclusion)
│ EligibilityService.selectEligible() ou méthode d'inclusion
│ Ajouter : vérification explicite status !== 'RESTITUTED' AVANT inclusion
│ Si RESTITUTED → HTTP 409 DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN
│ (NB : le filtre SQL `status=EXPIRED` exclut RESTITUTED implicitement,
│ mais INV-279-10 exige un rejet EXPLICITE avec code erreur, pas un filtre silencieux)
│
├─ Route 2 : POST /destruction/batches/:id/execute (exécution)
│ EligibilityService.reVerifyEligibility()
│ Ajouter : if status === 'RESTITUTED' → HTTP 409 DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN
│ (double vérification anti-race-condition per INV-279-10)
│
└─ Route 3 : DELETE /documents/:id (soft-delete)
DocumentsController.delete() ou service associé
Ajouter : vérification status RESTITUTED → HTTP 409 DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN
2.4 Flux D — SLA Scheduler (async)
RestitutionSlaScheduler (BullMQ cron job)
│
├─ 1. SELECT documents WHERE status=RESTITUTED AND restitution_deadline IS NOT NULL
│
├─ 2. Pour chaque document :
│ ├─ elapsed_ratio = (NOW - restituted_at) / (restitution_deadline - restituted_at)
│ │
│ ├─ Si elapsed_ratio >= 0.80 et alerte non émise
│ │ → IntegrityJournalService.appendEntry(OPS_ALERT_SENT, {type: 'RESTITUTION_SLA_80'})
│ │ → AuditLogService.logAsync(document.restitution_sla_alert)
│ │
│ ├─ Si elapsed_ratio >= 1.00 et escalade non émise
│ │ → IntegrityJournalService.appendEntry(SLA_EXCEEDED, {type: 'RESTITUTION_SLA_100'})
│ │ → AuditLogService.logAsync(document.restitution_sla_escalation)
│ │
│ └─ Si NOW > restitution_deadline et RESTITUTION_OVERDUE non émis
│ → IntegrityJournalService.appendEntry(SLA_EXCEEDED, {type: 'RESTITUTION_OVERDUE'})
│ → AuditLogService.logAsync(document.restitution_overdue)
│
└─ 3. Idempotence : payload_digest inclut document_id + seuil → pas de doublon
2b. Diagrammes Mermaid
Graphe de dépendances des composants
graph TD
C1["C1 — Migration DDL"]
C2["C2 — DocumentStatus enum"]
C3["C3 — State machine extension"]
C4["C4 — RestitutionService"]
C5["C5 — RestitutionController"]
C6["C6 — RestitutionConfig"]
C7["C7 — RestitutionSlaScheduler"]
C8["C8 — Destruction guard extension"]
C9["C9 — Audit integration"]
C10["C10 — JournalEventType extension"]
C11["C11 — Error codes"]
C12["C12 — Tests"]
C13["C13 — TLA+ model update"]
C1 --> C2
C2 --> C3
C2 --> C4
C3 --> C4
C6 --> C4
C9 --> C4
C10 --> C4
C11 --> C4
C4 --> C5
C4 --> C7
C2 --> C8
C3 --> C8
C4 --> C12
C5 --> C12
C7 --> C12
C8 --> C12
C13 --> C12
Séquence — Flux A : Restitution (POST /documents/:id/restitute)
sequenceDiagram
participant Client
participant RestitutionController as C5 — RestitutionController
participant RestitutionService as C4 — RestitutionService
participant DB as PostgreSQL (QueryRunner)
participant IntegrityJournal as IntegrityJournalService
participant AuditLog as AuditLogService
Client->>RestitutionController: POST /documents/:id/restitute
RestitutionController->>RestitutionController: ParseUUIDPipe (400 si invalide)
RestitutionController->>RestitutionService: restitute(documentId, actorId)
RestitutionService->>DB: SELECT ... FOR UPDATE
alt Document inexistant
DB-->>RestitutionService: null
RestitutionService-->>Client: 404 DOCUMENT_NOT_FOUND
end
DB-->>RestitutionService: document
RestitutionService->>RestitutionService: Gardes (ownership, état, legal_lock, geo_copy_count)
alt Garde échouée
RestitutionService-->>Client: 403 / 409 (error_code)
end
alt Déjà RESTITUTED
RestitutionService-->>Client: 200 idempotent (pas d'attestation)
end
RestitutionService->>RestitutionService: Calcul SLA (restituted_at, deadline)
RestitutionService->>DB: BEGIN transaction
RestitutionService->>DB: UPDATE status=RESTITUTED, restituted_at, deadline
RestitutionService->>IntegrityJournal: appendEntry(RESTITUTION, actor_id, security_level)
RestitutionService->>DB: COMMIT
RestitutionService->>AuditLog: logAsync(document.restitute)
RestitutionService-->>Client: 200 + document mis à jour
Séquence — Flux B : Retour de restitution (POST /documents/:id/return-from-restitution)
sequenceDiagram
participant Client
participant RestitutionController as C5 — RestitutionController
participant RestitutionService as C4 — RestitutionService
participant DB as PostgreSQL (QueryRunner)
participant IntegrityJournal as IntegrityJournalService
participant AuditLog as AuditLogService
Client->>RestitutionController: POST /documents/:id/return-from-restitution
RestitutionController->>RestitutionController: ParseUUIDPipe (400 si invalide)
RestitutionController->>RestitutionService: returnFromRestitution(documentId, actorId)
RestitutionService->>DB: SELECT ... FOR UPDATE
DB-->>RestitutionService: document
RestitutionService->>RestitutionService: Gardes (ownership, état RESTITUTED)
alt Garde échouée
RestitutionService-->>Client: 403 / 409 (error_code)
end
alt Déjà SEALED
RestitutionService-->>Client: 200 idempotent (pas d'attestation)
end
RestitutionService->>DB: BEGIN transaction
RestitutionService->>DB: UPDATE status=SEALED
RestitutionService->>IntegrityJournal: appendEntry(RETURN_FROM_RESTITUTION, actor_id, security_level)
RestitutionService->>DB: COMMIT
RestitutionService->>AuditLog: logAsync(document.return_from_restitution)
RestitutionService-->>Client: 200 + document mis à jour
Séquence — Flux C : Contrôle destruction cross-module
sequenceDiagram
participant Client
participant DestructionController as DestructionController / DocumentsController
participant EligibilityService as C8 — EligibilityService
participant DB as PostgreSQL
Client->>DestructionController: POST /destruction/batches (ou execute, ou DELETE)
DestructionController->>EligibilityService: selectEligible() / reVerifyEligibility() / delete()
EligibilityService->>DB: SELECT status FROM documents WHERE id = :id
DB-->>EligibilityService: status
alt status = RESTITUTED
EligibilityService-->>Client: 409 DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN
else status != RESTITUTED
EligibilityService->>EligibilityService: Continuer flux destruction normal
end
Séquence — Flux D : SLA Scheduler (async)
sequenceDiagram
participant Cron as BullMQ Cron
participant Scheduler as C7 — RestitutionSlaScheduler
participant DB as PostgreSQL
participant IntegrityJournal as IntegrityJournalService
participant AuditLog as AuditLogService
Cron->>Scheduler: Déclenchement planifié
Scheduler->>DB: SELECT documents WHERE status=RESTITUTED AND deadline IS NOT NULL
DB-->>Scheduler: documents[]
loop Pour chaque document
Scheduler->>Scheduler: Calcul elapsed_ratio
alt elapsed_ratio >= 0.80 (alerte non émise)
Scheduler->>IntegrityJournal: appendEntry(OPS_ALERT_SENT, RESTITUTION_SLA_80)
Scheduler->>AuditLog: logAsync(restitution_sla_alert)
end
alt elapsed_ratio >= 1.00 (escalade non émise)
Scheduler->>IntegrityJournal: appendEntry(SLA_EXCEEDED, RESTITUTION_SLA_100)
Scheduler->>AuditLog: logAsync(restitution_sla_escalation)
end
alt NOW > deadline (OVERDUE non émis)
Scheduler->>IntegrityJournal: appendEntry(SLA_EXCEEDED, RESTITUTION_OVERDUE)
Scheduler->>AuditLog: logAsync(restitution_overdue)
end
end
3. Mapping invariants → mécanismes
| Invariant ID | Exigence | Mécanisme | Composant | Observable | Risque |
| INV-279-01-state-model | RESTITUTED dans DocumentStatus persistant | Extension enum TypeScript + migration ALTER TYPE PostgreSQL | C1, C2 | SELECT enum_range(NULL::vault_secure.document_status) contient RESTITUTED | Migration ALTER TYPE ADD VALUE ne peut pas être rollbackée dans une transaction PG — exécuter hors transaction |
| INV-279-02-restitute-guards | SEALED→RESTITUTED si owner, legal_lock=false, geo_copy_count>=2, état SEALED | Gardes séquentielles dans RestitutionService.restitute() avec ordre normatif §5.3 | C4 | Codes HTTP retournés selon la première garde violée (403, 409) | Race condition si deux appels concurrents — mitigé par SELECT FOR UPDATE |
| INV-279-03-return-guards | RESTITUTED→SEALED si owner et état RESTITUTED ; pas de check geo_copy_count | Gardes dans RestitutionService.returnFromRestitution() | C4 | HTTP 200 sans vérification geo_copy_count | Aucun risque spécifique |
| INV-279-04-traceability | Attestation lifecycle_log complète pour chaque transition | INSERT INTO vault_integrity.integrity_journal_entries dans la même transaction via IntegrityJournalService.appendEntry() | C4, C10 | Entrée avec event_type, actor_id, security_level, timestamp | Si HSM indisponible pour signature journal → fallback signature différée existant (PD-251) |
| INV-279-05-sla | restituted_at + restitution_deadline ; alerte 80%, escalade 100%, RESTITUTION_OVERDUE | Calcul au moment de la transition + RestitutionSlaScheduler pour les événements temporels | C4, C7 | Colonnes DB non-null après restitution ; 3 événements journal distincts | Dérive horloge — mitigé par UTC + NTP existant (PD-251) |
| INV-279-06-no-destruction-while-restituted | Rejet destruction HTTP 409 | Vérification status !== 'RESTITUTED' dans EligibilityService.reVerifyEligibility() + soft-delete guard | C8 | HTTP 409 + DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN | Chemin de contournement si un nouveau endpoint destruction est ajouté sans garde — mitigé par tests contractuels |
| INV-279-07-transition-matrix | Transitions explicitement autorisées/interdites | Extension de ALLOWED_TRANSITIONS dans DocumentStateMachineService | C3 | InvalidStateTransitionError levée pour toute transition non listée | Aucun risque — même pattern que PD-250 |
| INV-279-08-ddl-reversible | Migration up/down réversible | Migration TypeORM avec up() et down() ; précondition SELECT COUNT(*) WHERE status='RESTITUTED' dans down() | C1 | npm run typeorm migration:run puis migration:revert sans erreur | ALTER TYPE DROP VALUE n'existe pas en PG < 16 — utiliser stratégie rename+recreate |
| INV-279-09-atomicity | Transition + attestation atomiques ; async post-commit | QueryRunner unique pour UPDATE + INSERT lifecycle_log ; audit post-commit | C4 | Crash pré-commit → aucun artefact persisté ; crash post-commit → état DB valide + rattrapage async | Si QueryRunner abandonné sans rollback — garanti par NestJS TypeORM lifecycle |
| INV-279-10-cross-module-guard | Module destruction vérifie status avant destruction | Vérification dans EligibilityService.reVerifyEligibility() + DestructionExecutionService + DocumentsController.delete() | C8 | HTTP 409 sur 3 routes destruction | Couplage cross-module — mitigé par import direct de l'enum DocumentStatus |
| INV-279-11-idempotency | Idempotence des deux endpoints : POST restitute si déjà RESTITUTED → 200 sans attestation ; POST return si déjà SEALED → 200 sans attestation | Vérification état AVANT transaction. Si déjà dans l'état cible → retour immédiat 200 | C4 | Rejeu sans duplication d'attestation | Aucun — pattern simple |
Note : La double vérification destruction (inclusion + exécution) est couverte par INV-279-10 ci-dessus. L'état terminal EXPIRED (aucune transition sortante vers RESTITUTED) est couvert par INV-279-07 (matrice de transitions explicite).
4. Mapping critères d'acceptation → mécanismes
| Critère ID | Mécanisme(s) | Composant | Observable | Risque |
| CA-279-01 | Extension enum TS + migration PG ALTER TYPE ADD VALUE | C1, C2 | DocumentStatus.RESTITUTED compilable + persisté en DB | — |
| CA-279-02 | Migration up() ajoute enum + colonnes ; down() vérifie précondition puis retire | C1 | CI migration aller/retour sans erreur | PG < 16 : ALTER TYPE DROP VALUE impossible → stratégie alternative |
| CA-279-03 | RestitutionService.restitute() avec gardes séquentielles + transaction | C4, C5 | HTTP 200 + status=RESTITUTED en DB | — |
| CA-279-04 | Gardes ordonnées §5.3 dans RestitutionService.restitute() | C4, C11 | 409 INVALID_SOURCE_STATE, 409 DOCUMENT_UNDER_LEGAL_LOCK, 409 INSUFFICIENT_GEO_COPIES selon garde violée | — |
| CA-279-05 | RestitutionService.returnFromRestitution() avec gardes ownership + état | C4, C5 | HTTP 200 + status=SEALED en DB | — |
| CA-279-06 | Garde état dans returnFromRestitution() | C4, C11 | HTTP 409 INVALID_SOURCE_STATE | — |
| CA-279-07 | IntegrityJournalService.appendEntry() dans la transaction | C4, C10 | Entrée integrity_journal_entries avec tous champs (timestamp, actor_id, type, security_level) | — |
| CA-279-08 | Vérification dans EligibilityService (inclusion) + DestructionExecutionService (exécution) + DocumentsController.delete() | C8 | HTTP 409 + audit de refus sur les 3 routes | — |
| CA-279-09 | Calcul dans restitute() : deadline = restituted_at + config.restitutionMaxDuration | C4, C6 | Écart 0 seconde entre valeurs DB | — |
| CA-279-10 | RestitutionSlaScheduler avec 3 seuils + idempotence | C7 | 3 événements journal distincts (80%, 100%, OVERDUE) | — |
| CA-279-11 | Mise à jour modèle TLA+ avec RESTITUTED dans States | C13 | tlc ou apalache retourne PASS sur AnchorAssume_States | — |
| CA-279-12 | Garde ownership dans restitute() et returnFromRestitution() | C4 | HTTP 403 pour non-owner sur les deux endpoints | — |
| CA-279-13 | Détection d'état déjà atteint avant transaction : si RESTITUTED → 200 sans attestation ; si SEALED → 200 sans attestation (INV-279-11) | C4 | Rejeu sans duplication d'attestation | — |
5. Mapping tests (TC-*) → mécanismes + observables
| Test ID | Référence spec | Mécanisme(s) | Point(s) d'observation | Niveau de test visé |
| TC-NOM-01 | INV-279-01, INV-279-02, INV-279-05, CA-279-01/03/09 | RestitutionService.restitute() + transaction + calcul SLA | HTTP 200, status DB = RESTITUTED, deadline exacte, lifecycle_log créé | Unit + Integration |
| TC-NOM-02 | INV-279-03, CA-279-05 | RestitutionService.returnFromRestitution() + transaction | HTTP 200, status DB = SEALED, lifecycle_log RETURN_FROM_RESTITUTION | Unit + Integration |
| TC-NOM-03 | INV-279-05, CA-279-10 | RestitutionSlaScheduler + IntegrityJournalService | Alerte 80%, escalade 100%, OVERDUE — 3 événements distincts | Unit (horloge simulée) |
| TC-NOM-04 | INV-279-04, CA-279-07 | IntegrityJournalService.appendEntry() pour RESTITUTION + RETURN | 2 entrées lifecycle_log avec timestamp UTC, actor_id, type, security_level | Integration |
| TC-NOM-05 | INV-279-06, INV-279-10, CA-279-08 | EligibilityService.reVerifyEligibility() + DocumentsController.delete() | HTTP 409 + audit de refus | Unit + Integration |
| TC-NOM-06 | INV-279-08, CA-279-02 | Migration up/down via TypeORM | Commandes migration:run/revert sans erreur | Integration (CI) |
| TC-NOM-07 | CA-279-11 | Modèle TLA+ mis à jour | AnchorAssume_States PASS | Formel (TLC) |
| TC-ERR-01 | §6 (400) | ParseUUIDPipe validation | HTTP 400, aucune mutation DB | Unit |
| TC-ERR-02 | §6 (404) | Requête DB retourne null | HTTP 404, aucune attestation | Unit |
| TC-ERR-03 | INV-279-02, CA-279-12 | Garde ownership dans restitute() | HTTP 403, status inchangé | Unit |
| TC-ERR-04 | INV-279-03, CA-279-12 | Garde ownership dans returnFromRestitution() | HTTP 403, status inchangé | Unit |
| TC-ERR-05 | INV-279-02, INV-279-07, CA-279-04 | Garde état != SEALED | HTTP 409 INVALID_SOURCE_STATE | Unit |
| TC-ERR-06 | INV-279-02, CA-279-04 | Garde geo_copy_count < 2 | HTTP 409 INSUFFICIENT_GEO_COPIES | Unit |
| TC-ERR-07 | INV-279-02, CA-279-04 | Garde legal_lock = true | HTTP 409 DOCUMENT_UNDER_LEGAL_LOCK | Unit |
| TC-ERR-08 | INV-279-03, INV-279-07, CA-279-06 | Garde état != RESTITUTED pour retour | HTTP 409 INVALID_SOURCE_STATE | Unit |
| TC-ERR-09 | INV-279-05 | Joi validation dans RestitutionConfig | Rejet config au démarrage si hors bornes [1,30] | Unit |
| TC-ERR-10 | INV-279-09 | Erreur forcée dans transaction (mock QueryRunner) | HTTP 500, rollback total, aucune attestation | Unit |
| TC-INV-09A | INV-279-09 | Transaction interrompue avant commit | Ni status ni lifecycle_log persistés | Unit |
| TC-INV-09B | INV-279-09 | Scheduler SLA redémarré après interruption | Événements manquants émis, doublons évités | Unit |
| TC-NR-01 | Non-régression | Lecture status PENDING/SEALED/EXPIRED | Aucune erreur désérialisation | Integration |
| TC-NR-02 | Non-régression | Flux SEALED→EXPIRED inchangé | Comportement identique baseline | Integration |
| TC-NR-03 | Non-régression | Triggers immutabilité existants | Écriture restitution possible | Integration |
| TC-NR-04 | Non-régression | Destruction pour statuts non-RESTITUTED | Politique inchangée | Integration |
| TC-NEG-01 | Adversarial | Deux appels concurrents restitute | 1 seul succès, SELECT FOR UPDATE sérialise | Integration |
| TC-NEG-02 | Adversarial, INV-279-11 | Rejeu return après retour effectué | Idempotent : 200 sans attestation | Unit |
| TC-IDEMP-01 | INV-279-11, CA-279-13 | Rejeu restitute sur document déjà RESTITUTED | Idempotent : 200 sans nouvelle attestation lifecycle_log | Unit |
| TC-IDEMP-02 | INV-279-11, CA-279-13 | Rejeu return sur document déjà SEALED | Idempotent : 200 sans nouvelle attestation lifecycle_log | Unit |
| TC-NEG-03 | Adversarial | UUID casse mixte | Traitement normal | Unit |
| TC-NEG-04 | Adversarial | security_level invalide | Rejet transactionnel | Unit |
| TC-NEG-05 | Adversarial | Crash post-commit | État DB cohérent, rattrapage async | Integration |
6. Gestion des erreurs
| Code HTTP | error_code | Condition | Composant | Observable |
| 400 | INVALID_DOCUMENT_ID | UUID non conforme v4 | C5 (ParseUUIDPipe) | Body JSON avec message validation |
| 401 | — | Token JWT absent/expiré | Guard Auth existant | — |
| 403 | NOT_DOCUMENT_OWNER | document.userId !== actorId | C4 | Body JSON avec error_code |
| 404 | DOCUMENT_NOT_FOUND | Document inexistant | C4 | Body JSON |
| 409 | INVALID_SOURCE_STATE | État incompatible (non-SEALED pour restitute, non-RESTITUTED pour return) | C4, C11 | Body JSON avec current_state |
| 409 | INSUFFICIENT_GEO_COPIES | geo_copy_count < 2 lors restitution | C4, C11 | Body JSON avec geo_copy_count actuel |
| 409 | DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN | Destruction d'un document RESTITUTED | C8, C11 | Body JSON + audit refus |
| 409 | DOCUMENT_UNDER_LEGAL_LOCK | legal_lock=true lors restitution | C4, C11 | Body JSON |
| 422 | INVALID_SLA_CONFIG | Bornes SLA hors [1j, 30j] au chargement config | C6 | Joi.ValidationError au démarrage |
| 500 | — | Échec transactionnel non-métier | C4 | Rollback total, log error |
Stratégie de propagation : Les erreurs métier sont des exceptions NestJS (ConflictException pour 409, ForbiddenException pour 403, NotFoundException pour 404) avec payload structuré { error_code, message, details? }. Un ExceptionFilter spécifique normalise le format de réponse.
7. Impacts sécurité
| Risque | Mitigation | Composant |
| Élévation de privilèges : non-owner pourrait restituer | Garde ownership obligatoire avec SELECT FOR UPDATE vérifiant user_id | C4 |
| Contournement destruction : document RESTITUTED détruit | Triple vérification cross-module (inclusion batch, exécution batch, soft-delete) | C8 |
| Race condition : appels concurrents restitute/return | SELECT ... FOR UPDATE sur la ligne document dans le QueryRunner | C4 |
Injection SQL : paramètres document_id | ParseUUIDPipe NestJS (validation UUID v4 format) + requêtes paramétrées TypeORM | C5 |
| Déni de service SLA : configuration altérée | Joi validation stricte au démarrage avec rejet complet si hors bornes | C6 |
| Audit trail incomplet : transition sans attestation | Atomicité ACID — attestation lifecycle_log dans la même transaction que la MAJ status | C4 |
| Fuite information : error_code révèle l'état | Les codes d'erreur sont génériques et ne révèlent pas de données sensibles (pas de PII) | C11 |
| Journalisation : toutes les transitions et refus sont audités | AuditLogService.logAsync() post-commit pour chaque opération (succès et refus) | C9 |
8. Hypothèses techniques
| ID | Hypothèse | Validation | Impact si faux |
| HT-279-01 | document_id est UUID v4 (contrat API existant, validé par ParseUUIDPipe) | Vérifié : ParseUUIDPipe utilisé dans tous les controllers documents | Adapter regex + tests |
| HT-279-02 | geo_copy_count est un champ existant accessible sur l'entité DocumentSecure | NON VÉRIFIÉ : ce champ n'existe pas actuellement dans l'entité. Sera implémenté comme colonne ou résolu via service externe | Si absent : ajouter colonne dans la migration C1 ou créer un service de résolution |
| HT-279-03 | security_level est disponible dans le contexte documentaire au moment de la transition | PARTIELLEMENT VÉRIFIÉ : ce concept existe dans le module d'intégrité PD-251 mais pas directement sur l'entité document | Si absent : utiliser une valeur par défaut ou récupérer via service |
| HT-279-04 | Le module destruction PD-250 est déployé et fonctionnel | Vérifié : code et migrations présents dans le backend | — |
| HT-279-05 | PostgreSQL >= 14 (pour les features enum utilisées) | À vérifier : la stratégie de down() migration dépend de la version PG | Si PG < 14 : stratégie rename+recreate enum |
| HT-279-06 | IntegrityJournalService accepte de nouveaux JournalEventType sans modification de signature | Vérifié : appendEntry(archiveId, eventType, payload) prend un string enum | — |
| HT-279-07 | Les jobs planifiés SLA sont idempotents via payload_digest dans le journal d'intégrité | Vérifié : IntegrityJournalEntry.payloadDigest permet la déduplication | — |
| HT-279-08 | EXPIRED est déjà terminal dans la matrice de transitions pour le contexte restitution | Vérifié partiellement : EXPIRED→DESTROYED et EXPIRED→RECONCILIATION_FAILED existent dans PD-250 ; PD-279 ne modifie pas ces transitions | — |
| HT-279-09 | Le projet backend utilise CommonJS (CJS) comme système de modules (NestJS convention) | Vérifié : tsconfig.json utilise "module": "commonjs". Tous les imports PD-279 suivent ce pattern (pas d'ESM import.meta). | Si migration ESM future : adapter les imports, mais hors périmètre PD-279 |
| HT-279-10 | Le framework de test est Jest (convention NestJS backend) | Vérifié : package.json contient jest + @nestjs/testing. Les fichiers *.spec.ts suivent les conventions Jest (describe, it, expect). | Si migration Vitest : adapter la config, mais hors périmètre PD-279 |
Résolution HT-279-02 (geo_copy_count)
Le champ geo_copy_count n'existe pas dans l'entité DocumentSecure. Deux approches :
- Ajout d'une colonne
geo_copy_count integer NOT NULL DEFAULT 0 dans la migration PD-279 — simple mais couple le concept de réplication géo au document. - Service de résolution qui compte les copies géo via un service externe (OVH Object Storage multi-région) — plus propre architecturalement mais ajoute une dépendance réseau dans le flux de garde.
Décision : Approche 1 (colonne) car la spécification traite geo_copy_count comme un attribut du document. Un trigger ou un job de synchronisation met à jour cette valeur en amont (hors périmètre PD-279). La migration PD-279 ajoute la colonne avec DEFAULT 0, ce qui signifie que les gardes de restitution refuseront par défaut (geo_copy_count < 2) tant que le mécanisme de comptage amont n'a pas initialisé la valeur — comportement fail-safe conforme à l'esprit ISO 14641.
Résolution HT-279-03 (security_level)
Le champ security_level sera passé en paramètre à la méthode restitute()/returnFromRestitution() depuis le contexte d'authentification (JWT claims ou profil utilisateur). Si non disponible, utiliser la valeur par défaut du document (niveau de sécurité du coffre-fort associé). Le service récupère cette information depuis le contexte existant @CurrentUser() decorator.
9. Points de vigilance (risques, dette, pièges)
| # | Point | Risque | Mitigation |
| V1 | ALTER TYPE ADD VALUE non-transactionnelle | PostgreSQL ne permet pas ALTER TYPE ... ADD VALUE dans une transaction. La migration doit exécuter cette commande hors transaction. | Pattern PD-250 : séparer l'ajout de valeur enum dans un queryRunner.query() direct, pas dans un bloc BEGIN/COMMIT |
| V2 | Down migration enum | ALTER TYPE ... DROP VALUE n'existe pas en PG < 16. | Stratégie : (1) vérifier précondition COUNT(*)=0 WHERE status='RESTITUTED', (2) recréer le type enum avec les anciennes valeurs, (3) ALTER TABLE ALTER COLUMN pour utiliser le nouveau type |
| V3 | geo_copy_count inexistant | Le champ n'existe pas dans l'entité actuelle. Sa sémantique (qui l'initialise, qui le met à jour) est hors périmètre PD-279. | Ajouter la colonne en migration PD-279 avec DEFAULT 0 (fail-safe). Documenter le STUB de mise à jour comme // STUB: PD-XXX — mécanisme de comptage copies géo |
| V4 | Couplage module destruction | PD-279 modifie des fichiers du module destruction (eligibility, execution). | Limiter les modifications au strict minimum (ajout d'un if de vérification status). Tests de non-régression obligatoires sur la destruction |
| V5 | BullMQ queue pour SLA scheduler | Nouvelle queue pv-jobs-restitution-sla à enregistrer dans le module NestJS. | Suivre le pattern PD-250 : constante de nom de queue, enregistrement dans BullModule.forRoot() |
| V6 | Idempotence des endpoints REST | Le rejeu d'un POST restitute sur un document déjà RESTITUTED ne doit pas créer de nouvelle attestation. | Vérifier l'état AVANT la transaction. Si déjà dans l'état cible → retour immédiat sans mutation |
| V7 | Spec review PD-279 — legal_lock HTTP 409 | La spec v2 utilise HTTP 409 Conflict avec error_code: DOCUMENT_UNDER_LEGAL_LOCK pour legal_lock=true (§5.3 point 7, §6). | Respecter la spec v2 : utiliser ConflictException (409) avec DOCUMENT_UNDER_LEGAL_LOCK. Note : PD-63 utilise 423 pour un contexte différent (verrou HSM), ne pas confondre. |
9b. Dépendances inter-PD
| PD | Statut | Dépendance | Impact PD-279 |
| PD-250 | DONE | Module destruction (batch, execution, state machine) | PD-279 étend la state machine et les gardes destruction. Pas de risque : PD-250 est déployé. |
| PD-251 | DONE | Module intégrité (IntegrityJournalService, JournalEventType) | PD-279 ajoute des event types. Service existant, signature compatible. |
| PD-63 | DONE | Legal lock, HSM labels | PD-279 réutilise le concept legal_lock (champ existant). Pas de conflit : PD-63 utilise 423 pour HSM, PD-279 utilise 409 pour restitution. |
| PD-280 | STUB | État DISPOSED | PD-279 interdit RESTITUTED→DISPOSED. STUB documenté dans hors périmètre. |
| PD-278 | STUB | NF Z42-013 DIP | Hors périmètre PD-279. |
| PD-XXX | STUB | Mécanisme comptage geo_copy_count | Colonne ajoutée avec DEFAULT 0 (fail-safe). // STUB: PD-XXX — mécanisme de comptage copies géo |
10. Hors périmètre
- État
DISPOSED : STUB planifié en PD-280, transition RESTITUTED→DISPOSED interdite dans PD-279. - NF Z42-013 DIP : Couvert par PD-278.
- Restitution multi-destinataire : Non spécifié, non implémenté.
- Frontend/mobile : Aucune hypothèse UI dans cette story.
- Mécanisme de comptage
geo_copy_count : Seule la colonne et la garde sont implémentées. Le mécanisme de mise à jour du compteur est hors périmètre (STUB). - Notification utilisateur : Les événements SLA sont des alertes opérationnelles, pas des notifications end-user.
11. Mécanismes cross-module
| Élément | Détail |
| Routes de l'autre module à protéger | POST /destruction/batches (inclusion), POST /destruction/batches/:id/execute (exécution), DELETE /documents/:id (soft-delete) |
| Controller et méthode (inclusion) | EligibilityService.selectEligible() — filtre déjà status=EXPIRED, exclut implicitement RESTITUTED |
| Controller et méthode (exécution) | EligibilityService.reVerifyEligibility() — ajout check status === 'RESTITUTED' |
| Controller et méthode (soft-delete) | DocumentsController.delete() ou service associé — ajout check status === 'RESTITUTED' |
| Effet du guard | HTTP 409 DOCUMENT_RESTITUTED_DESTRUCTION_FORBIDDEN + audit de refus |
| Mécanisme de jointure cross-schéma | vault_secure.documents.status lu directement via DataSource — même schéma, pas de FK cross-schéma |
| Scope d'enregistrement | Vérification au niveau service du module destruction (pas de guard NestJS global) |
| Exceptions d'accès | Aucune : même rôle ADMIN ne peut détruire un RESTITUTED |
12. Périmètre de test
| Niveau de test | In scope | Hors scope (justification) |
| Unitaire | Tous les composants C1-C11 : RestitutionService, RestitutionSlaScheduler, RestitutionConfig, state machine, guards, DTOs, error handling | — |
| Intégration | Interactions RestitutionService ↔ IntegrityJournalService ↔ AuditLogService ; cross-module destruction ↔ restitution ; migration up/down | — |
| E2E | Flux HTTP complet restitute/return via supertest | — |
| Formel | Vérification TLA+ AnchorAssume_States | — |
| Performance | Hors scope : volume de restitutions prévu faible (< 10/jour), pas de benchmark requis | Volume insuffisant pour justifier un test de charge dédié |
Tous les niveaux de test sont couverts sauf performance (justification : volumétrie faible). Aucune exclusion de composant.
13. Ordonnancement des composants
Phase 1 — Fondations (C1, C2, C3, C6, C9, C10, C11)
Migration + enum extension + state machine + config + types audit/journal + error DTOs
Phase 2 — Core métier (C4, C5)
RestitutionService + RestitutionController
Phase 3 — Cross-module (C8)
Extension destruction guard
Phase 4 — SLA async (C7)
RestitutionSlaScheduler
Phase 5 — Vérification formelle (C13)
TLA+ model update
Phase 6 — Tests (C12)
Suite complète unitaire + intégration + E2E + formel