Aller au contenu

PD-103 — Agent Developer — Module M6 capture-orchestrator

Date : 2026-04-03 Agent : Agent Developer (Claude) Story : PD-103 — Capture probatoire d'écran et scellement automatique Module : M6 capture-orchestrator Spec version : v3

1. Résumé

Module M6 implémenté : orchestrateur séquentiel coordonnant les modules M1 (state-machine), M2 (crypto-pipeline), M3 (ocr-service), M4 (upload-service), M5 (useCaptureStore) et M7 (purge-manager).

Le flux complet suit la séquence contractuelle : purgeStale → capture → hash → encrypt → OCR → upload → purge locale

2. Fichiers créés

Fichier Lignes Rôle
src/capture/orchestrator.ts ~730 Orchestrateur principal
src/__tests__/capture/orchestrator.test.ts ~480 Tests unitaires + contractuels

3. Fichiers existants non modifiés

Aucun fichier existant n'a été modifié. L'orchestrateur consomme les APIs publiques des modules M1–M5 et M7 sans altération.

4. API publique

CaptureOrchestrator

class CaptureOrchestrator {
  constructor(deps: CaptureOrchestratorDeps, config?: CaptureOrchestratorConfig)

  // Flux nominal : capture → hash → encrypt → OCR → upload → UPLOADED
  async execute(
    imageBytes: Uint8Array,
    metadata: CaptureMetadata,
    jwtToken: string,
    ocrEnabled?: boolean,
    signal?: AbortSignal,
    onProgress?: (progress: CaptureUploadProgress) => void,
  ): Promise<CaptureOrchestratorResult>

  // Reprise différée : UPLOAD_DEFERRED → UPLOADING → UPLOADED
  async resumeDeferred(
    captureId: CaptureId,
    jwtToken: string,
    signal?: AbortSignal,
    onProgress?: (progress: CaptureUploadProgress) => void,
  ): Promise<CaptureOrchestratorResult>

  // Annulation explicite (CAPTURED ou UPLOADING → CANCELLED)
  async cancel(jwtToken?: string): Promise<CaptureOrchestratorResult | null>

  // Watchdog TTL : expire les captures différées au-delà du TTL
  async expireDeferredCaptures(): Promise<CaptureId[]>
}

CaptureOrchestratorDeps

interface CaptureOrchestratorDeps {
  readonly kekProvider: KekProvider;      // M2 — wrapping DEK
  readonly ocrService: OcrService;        // M3 — OCR local
  readonly uploadService?: CaptureUploadService;  // M4 — upload S3 + POST
  readonly purgeManager?: CapturePurgeManager;    // M7 — purge artefacts
}

CaptureOrchestratorConfig

interface CaptureOrchestratorConfig {
  readonly apiBaseUrl?: string;    // URL API backend
  readonly maxRetries?: number;    // 0..5, défaut 3
  readonly backoffBaseMs?: number; // 1000..10000, défaut 1000
}

5. Couverture des invariants

Invariant Mécanisme dans M6 Vérifié par test
INV-103-01-fidelity imageBytes passé directement au pipeline crypto, aucune transformation TC-NOM-01
INV-103-03-hash-local-first CryptoCapturePipeline.execute() calcule le hash avant tout upload TC-NOM-01, TC-INV-02
INV-103-04-ocr-local-only OcrService.extract() appelé avec try/catch absorbant TC-NOM-03
INV-103-06-encryption-file-level Pipeline crypto produit ciphertext AES-256-GCM TC-NOM-01
INV-103-07-purge-startup purgeManager.purgeStale() appelé en étape 1 TC-NOM-04
INV-103-20 Garde hashComputed + encryptionDone avant CAPTURED → UPLOADING TC-INV-02
INV-103-21 cancel() depuis CAPTURED avec userCancelled: true TC-ERR-02
INV-103-22 Transition UPLOADING → UPLOADED avec s3UploadConfirmed + backendAck TC-NOM-01
INV-103-23 Upload échoué → transition UPLOADING → UPLOAD_DEFERRED TC-NOM-05, TC-ERR-01
INV-103-24 resumeDeferred() vérifie JWT + redemande URL pré-signée TC-NOM-06
INV-103-29 Annulation en UPLOADING → abort S3 + purge + CANCELLED TC-NOM-14
INV-103-30 dek_wrapped_b64 + kek_id transmis via crypto pipeline → upload TC-NOM-01
INV-103-31 asCaptureId() normalise en lowercase TC capture_id lowercase
INV-103-32 Délégué à CryptoCapturePipeline (zeroizeDEK dans finally) — (testé dans M2)
INV-103-38 Purge locale immédiate après UPLOADED + expiration TTL différé TC-NOM-17, TC-ERR-11

6. Matrice de couverture tests (TC-* → fichiers)

Test-ID Fichier de test Description
TC-NOM-01 src/__tests__/capture/orchestrator.test.ts (lignes 168-191) Flux nominal complet CAPTURED → UPLOADED
TC-NOM-01 src/__tests__/capture/orchestrator.test.ts (lignes 193-207) Hash SHA3-256 calculé avant upload
TC-NOM-02 src/__tests__/capture/orchestrator.test.ts (lignes 213-227) OCR désactivé, UPLOADED sans champs OCR
TC-NOM-03 src/__tests__/capture/orchestrator.test.ts (lignes 233-241) OCR en erreur, flux continue
TC-NOM-04 src/__tests__/capture/orchestrator.test.ts (lignes 247-261) purgeStale() avant toute opération
TC-NOM-05 src/__tests__/capture/orchestrator.test.ts (lignes 267-283) Upload différé après échec réseau
TC-NOM-06 src/__tests__/capture/orchestrator.test.ts (lignes 289-319) Reprise différée avec succès
TC-NOM-14 src/__tests__/capture/orchestrator.test.ts (lignes 325-349) Annulation explicite en UPLOADING
TC-NOM-17 src/__tests__/capture/orchestrator.test.ts (lignes 355-377) Purge locale immédiate après UPLOADED
TC-ERR-01 src/__tests__/capture/orchestrator.test.ts (lignes 383-399) Retries épuisés → UPLOAD_DEFERRED
TC-ERR-02 src/__tests__/capture/orchestrator.test.ts (lignes 405-420) Annulation utilisateur pré-upload
TC-ERR-05 src/__tests__/capture/orchestrator.test.ts (lignes 426-434) Image vide → CANCELLED
TC-INV-02 src/__tests__/capture/orchestrator.test.ts (lignes 440-450) Garde CAPTURED → UPLOADING valide
TC-ERR-11 src/__tests__/capture/orchestrator.test.ts (lignes 456-478) TTL différé expiré → CANCELLED + purge

Tests additionnels (NON-CONTRACTUAL)

Test Description
cancel sans capture active Retourne null si aucune capture active
capture_id lowercase (INV-103-31) Normalisation lowercase du capture_id
reprise capture inexistante CANCELLED si capture différée non trouvée

7. Résultats des tests

PASS src/__tests__/capture/orchestrator.test.ts
  CaptureOrchestrator
    TC-NOM-01: Flux nominal complet
      ✓ devrait executer le flux capture → hash → encrypt → upload → UPLOADED (3 ms)
      ✓ devrait calculer le hash SHA3-256 localement avant upload (INV-103-03) (1 ms)
    TC-NOM-02: OCR desactive
      ✓ devrait atteindre UPLOADED sans champs OCR
    TC-NOM-03: OCR en erreur
      ✓ devrait continuer sans OCR si extraction echoue (1 ms)
    TC-NOM-04: purgeStale au demarrage
      ✓ devrait appeler purgeStale() avant toute operation
    TC-NOM-05: Upload deferred sur echec reseau
      ✓ devrait transitionner vers UPLOAD_DEFERRED apres epuisement des retries (1 ms)
    TC-NOM-06: Reprise differee
      ✓ devrait reprendre un upload differe avec succes (1 ms)
    TC-NOM-14: Annulation explicite en UPLOADING
      ✓ devrait annuler l'upload et transitionner vers CANCELLED (12 ms)
    TC-NOM-17: Purge locale immediate apres UPLOADED
      ✓ devrait supprimer l'artefact local apres ACK backend (1 ms)
    TC-ERR-01: Retries epuises
      ✓ devrait transitionner vers UPLOAD_DEFERRED apres echec upload (1 ms)
    TC-ERR-02: Annulation utilisateur pre-upload
      ✓ devrait annuler depuis CAPTURED via cancel()
    TC-ERR-05: Image vide provoque erreur hash
      ✓ devrait rejeter avec CANCELLED si image_bytes est vide (1 ms)
    TC-INV-02: Garde CAPTURED → UPLOADING
      ✓ devrait reussir la transition si hash et chiffrement sont valides
    TC-ERR-11: TTL differe expire
      ✓ devrait expirer les captures differees au-dela du TTL
    NON-CONTRACTUAL: cancel sans capture active
      ✓ devrait retourner null si aucune capture active
    NON-CONTRACTUAL: capture_id lowercase (INV-103-31)
      ✓ devrait normaliser capture_id en lowercase
    NON-CONTRACTUAL: reprise capture inexistante
      ✓ devrait retourner CANCELLED si capture differee non trouvee

Test Suites: 1 passed, 1 total
Tests:       17 passed, 17 total

Non-régression

Tous les tests existants des modules M1, M3, M5, M7 passent toujours (199 tests au total, 0 échec).

8. Décisions architecturales

architectural_decisions:
  - decision: "Injection de dépendances via interface CaptureOrchestratorDeps"
    rationale: "Permet de mocker chaque module (M2, M3, M4, M7) indépendamment pour les tests unitaires, conformément au pattern existant (KekProvider, OcrEngine)."
    alternatives_considered:
      - "Instanciation directe des modules dans le constructeur"
      - "Singleton global"
    trade_offs: "Légère verbosité à l'instanciation, mais testabilité maximale et découplage strict."

  - decision: "Persistance du ciphertext en base64 via expo-file-system"
    rationale: "Hermes ne supporte pas Blob/File natif. Base64 est le seul encodage supporté par FileSystem.writeAsStringAsync. Le ciphertext est déjà chiffré (AES-256-GCM), pas de risque de fuite en clair."
    alternatives_considered:
      - "Stockage binaire via react-native-fs"
      - "Stockage en AsyncStorage (limité en taille)"
    trade_offs: "Overhead base64 (~33% taille) acceptable car le ciphertext est temporaire et purgé après UPLOADED."

9. Hypothèses

ID Hypothèse Impact si faux
HO-01 btoa/atob sont disponibles dans le runtime Hermes cible (Expo SDK 54+). Implémenter un polyfill base64 (pattern existant dans src/crypto/utils.ts).
HO-02 useCaptureStore.getState() est appelable hors composant React (dans un service). Pattern validé par les stores Zustand existants (useAuthStore, useSealStore).
HO-03 Le pipeline scellement backend (PD-56/PD-55/PD-41) accepte les captures ingérées via POST /documents/capture. Les transitions UPLOADED → PENDING_SEAL → SEALED → ANCHOR_CONFIRMED restent hors scope M6 (backend).

10. Limites et dette technique

  1. Base64 dans filesystem — Le stockage du ciphertext en base64 sur le filesystem local introduit un overhead de ~33%. Pour des images > 100 MB, cela pourrait impacter le stockage temporaire. Acceptable car la purge est immédiate post-UPLOADED et le TTL différé est borné à 24h.

  2. btoa/atob en Hermes — Les fonctions btoa/atob sont utilisées pour la conversion base64 du ciphertext local. Si le runtime Hermes cible ne les supporte pas, un fallback via bytesToBase64 de src/crypto/utils.ts sera nécessaire.

  3. Pas de retry sur la reprise différéeresumeDeferred() tente un seul cycle upload. Si l'upload échoue à nouveau, la capture reste en UPLOAD_DEFERRED. Le watchdog TTL gérera l'expiration. Un mécanisme de retry automatique au niveau de l'app (via AppState ou NetInfo) est la responsabilité de M8 (capture-ui).

  4. Annulation best-effort — L'abort S3 (cancelUpload) est best-effort. Si l'appel échoue, les lifecycle rules S3 (M15) et le GC orphelins (M12) nettoieront les objets résiduels.