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-orchestratorSpec 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¶
-
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.
-
btoa/atoben Hermes — Les fonctionsbtoa/atobsont utilisées pour la conversion base64 du ciphertext local. Si le runtime Hermes cible ne les supporte pas, un fallback viabytesToBase64desrc/crypto/utils.tssera nécessaire. -
Pas de retry sur la reprise différée —
resumeDeferred()tente un seul cycle upload. Si l'upload échoue à nouveau, la capture reste enUPLOAD_DEFERRED. Le watchdog TTL gérera l'expiration. Un mécanisme de retry automatique au niveau de l'app (viaAppStateouNetInfo) est la responsabilité de M8 (capture-ui). -
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.