PD-103 — Agent Developer — Module M4 : capture-upload¶
1. Identite agent¶
- Agent : agent-developer (Agent B — Claude)
- Story : PD-103
- Module : M4 — capture-upload
- Wave : 2 (depend de M2 crypto-pipeline)
- Date : 2026-04-03
2. Resume¶
Module M4 implemente le service d'upload capture probatoire : upload single (< 10 MB) et multipart (>= 10 MB) vers S3 via URLs pre-signees, avec retry borne, controle d'integrite ciphertext (x-amz-content-sha256), annulation cooperative, reprise differee avec conservation des ETags, et POST backend /documents/capture.
Fichiers crees : - src/capture/upload-service.ts — Service principal : presigned URL, upload single, POST backend, retry - src/capture/multipart-upload.ts — Orchestration upload multipart : init, parts, completion, reprise
Fichiers existants reutilises : - src/capture/types.ts — Branded types, constantes, interfaces (DTOs, PresignedUrlResponse, etc.) - src/crypto/utils.ts — bytesToHex pour header x-amz-content-sha256 - @noble/hashes/sha256 — SHA-256 pour controle integrite S3 (INV-103-06) - src/config/index.ts — config.apiUrl pour base URL backend - Pattern : src/services/upload/uploadNetwork.ts (fetchWithTimeout, abort) - Pattern : src/services/upload/uploadRetry.ts (backoff exponentiel + jitter)
3. Artefacts livres¶
| Fichier | Role | Lignes |
|---|---|---|
src/capture/upload-service.ts | Service upload single + presigned URL + POST backend + retry | ~420 |
src/capture/multipart-upload.ts | Upload multipart : init, parts, completion, abort, reprise | ~440 |
4. Architecture¶
4.1 Decisions architecturales¶
architectural_decisions:
- decision: "x-amz-content-sha256 au lieu de Content-MD5 pour l'integrite S3"
rationale: "@noble/hashes/sha256 est deja present dans le projet ; evite d'ajouter une dependance MD5 (crypto-js/md5 ou autre). SHA-256 est plus fort et supporte nativement par S3."
alternatives_considered:
- "Content-MD5 via crypto-js"
- "Double header Content-MD5 + x-amz-content-sha256"
trade_offs: "SHA-256 est legerement plus couteux que MD5 mais negligeable sur des chunks de 5 MB."
- decision: "Separation CaptureUploadService (single + orchestration) et CaptureMultipartUpload (multipart specifique)"
rationale: "Respecte le plan (upload-service.ts + multipart-upload.ts). Le service principal decide du mode (single/multipart) selon le seuil 10 MB et delegue au module multipart si necessaire."
alternatives_considered:
- "Classe unique avec modes internes"
- "Fonctions pures sans classe"
trade_offs: "Deux fichiers mais chacun reste sous 500 lignes ; separation claire des responsabilites."
4.2 Pattern utilise¶
Retry borne avec backoff exponentiel + jitter (pattern src/services/upload/uploadRetry.ts) : - Jitter CSPRNG obligatoire (crypto.getRandomValues, pas Math.random) - Annulation cooperative via AbortSignal - Erreurs non-retryable propagees immediatement (4xx sauf 500+)
Multipart avec reprise partielle (§5.5bis) : - Init multipart → upload parts avec ETags → CompleteMultipartUpload - En cas d'echec part : MultipartUploadError porte les completedParts et uploadId - L'orchestrateur (M6) peut persister ces donnees dans DeferredCapturePayload.multipartState - A la reprise : URLs fraiches demandees, parts deja completees skippees
Progression granulaire : - Single : 0% → 100% - Multipart : progression part par part avec currentPart / totalParts
4.3 Dependances¶
M4 (capture-upload)
├── M2 (capture-crypto) — consomme CryptoCapturePipelineResult (ciphertext, nonce, tag, dek)
├── types.ts — PresignedUrlResponse, MultipartInitResponse, CaptureIngestPayload, constantes
├── @noble/hashes/sha256 — x-amz-content-sha256 integrite S3
├── src/crypto/utils.ts — bytesToHex
└── src/config — apiUrl
4.4 Flux detaille¶
upload-service.ts multipart-upload.ts
┌──────────────────┐ ┌──────────────────────┐
│ CaptureUploadSvc │ │ CaptureMultipartUpld │
│ │ │ │
│ upload() │ │ execute() │
│ 1. presignURL │ │ 1. initMultipart │
│ 2. if < 10MB: │ │ 2. for each part: │
│ uploadSingle │ │ uploadPartRetry │
│ 3. if >= 10MB: ─┼────────────────────>│ 3. completeMpart │
│ delegate │ │ │
│ 4. POST /cap │ │ abort() │
│ │ │ AbortMultipartUpld │
│ resumeDeferred() │ │ │
│ = upload() │ │ _refreshPartUrls() │
│ │ │ URLs fraiches │
│ cancelUpload() │ └──────────────────────┘
│ abort best-eff │
└──────────────────┘
5. Mapping invariants¶
| Invariant | Mecanisme | Observable |
|---|---|---|
| INV-103-06 | Header x-amz-content-sha256 sur chaque PUT S3 (single + chaque part multipart) | Header present dans requete |
| INV-103-22 | upload() retourne seulement apres S3 OK + POST backend 202/200 | Aucun resultat partiel |
| INV-103-23 | Exception propagee apres retries epuises → orchestrateur transite UPLOAD_DEFERRED | CaptureUploadError / MultipartUploadError |
| INV-103-24 | resumeDeferred() redemande systematiquement une URL pre-signee fraiche | Nouvelle URL avant PUT |
| INV-103-29 | cancelUpload() + MultipartUpload.abort() : AbortMultipartUpload best-effort | Appel backend abort |
| INV-103-31 | capture_id passe tel quel depuis metadata (deja lowercase par asCaptureId) | Payload POST lowercase |
6. Mapping tests contractuels¶
| Test ID | Reference spec | Mecanisme | Niveau |
|---|---|---|---|
| TC-NOM-01 | INV-103-06, 22, 30 | upload single + POST avec dek_wrapped_b64 + kek_id + x-amz-content-sha256 | Integration |
| TC-NOM-05 | INV-103-23 | Retries epuises → exception propagee (orchestrateur gere UPLOAD_DEFERRED) | Unit |
| TC-NOM-06 | INV-103-24 | resumeDeferred() redemande presigned URL | Integration |
| TC-NOM-13 | §5.5bis, INV-103-22 | Multipart : echec part → reprise partielle avec ETags conserves | Integration |
| TC-NOM-14 | INV-103-29 | cancelUpload() appelle abort multipart | Unit |
| TC-ERR-01 | INV-103-23 | Retry borne epuise → exception | Unit |
| TC-ERR-06 | §5.1 | POST backend avec champ invalide → HTTP 400 | Unit |
| TC-ERR-14 | INV-103-06 | Checksum mismatch → rejet + retry | Unit |
| TC-ERR-16 | INV-103-34 | POST backend → 422 UNWRAP_DEK_FAILED | Unit |
| TC-ERR-17 | ER-103-17 | POST backend → 503 KEY_SERVICE_UNAVAILABLE | Unit |
| TC-INV-09 | INV-103-29 | Annulation signal → abort multipart + purge | Integration |
7. API publique¶
CaptureUploadService¶
class CaptureUploadService {
constructor(apiBaseUrl?: string);
/** Upload complet : presigned URL → S3 PUT → POST /documents/capture */
upload(
ciphertext: Uint8Array,
metadata: CaptureMetadata,
hashSha3_256: Sha3_256Hash,
cryptoArtifacts: CaptureUploadCryptoArtifacts,
jwtToken: string,
ocr?: OcrResult,
options?: CaptureUploadOptions,
): Promise<CaptureUploadResult>;
/** Reprise differee (nouvelle URL pre-signee + meme flux) */
resumeDeferred(/* memes params */): Promise<CaptureUploadResult>;
/** Annulation S3 : abort multipart best-effort */
cancelUpload(captureId: CaptureId, uploadId: string | null, jwtToken: string): Promise<void>;
}
CaptureMultipartUpload¶
class CaptureMultipartUpload {
constructor(apiBaseUrl: string);
/** Upload multipart complet ou en reprise partielle */
execute(
ciphertext: Uint8Array,
metadata: CaptureMetadata,
jwtToken: string,
options?: MultipartUploadOptions,
): Promise<MultipartUploadResult>;
/** Abort multipart S3 best-effort (INV-103-29) */
abort(captureId: string, uploadId: string, jwtToken: string): Promise<void>;
}
Types export¶
/** Erreur upload avec code metier + httpStatus + isRetryable */
CaptureUploadError
/** Erreur multipart avec resumeState (completedParts, uploadId) pour reprise */
MultipartUploadError
toResumeState(): MultipartResumeState | null
/** Options upload : retries, backoff, signal, onProgress */
CaptureUploadOptions
/** Resultat upload : uploadObjectKey + ingestResponse */
CaptureUploadResult
/** Artefacts crypto passes au service (nonce, tag, dek_wrapped, kek_id) */
CaptureUploadCryptoArtifacts
8. Gestion des erreurs¶
| Situation | Code | HTTP | Retryable | Action orchestrateur |
|---|---|---|---|---|
| Reseau indisponible | UPLOAD_FAILED | — | Oui | Retry → UPLOAD_DEFERRED |
| Timeout PUT S3 | UPLOAD_FAILED | — | Oui | Retry → UPLOAD_DEFERRED |
| S3 500+ | UPLOAD_FAILED | 5xx | Oui | Retry → UPLOAD_DEFERRED |
| Part multipart echoue | UPLOAD_FAILED | — | — | UPLOAD_DEFERRED + ETags |
| Annulation signal | UPLOAD_FAILED | — | Non | CANCELLED + abort S3 |
| Payload invalide | 400 code metier | 400 | Non | CANCELLED |
| Conflict idempotence | UPLOAD_FAILED | 409 | Non | Log + traiter selon politique |
| Unwrap DEK echoue | UNWRAP_DEK_FAILED | 422 | Non | CANCELLED |
| Rate-limit | UPLOAD_FAILED | 429 | Non | Retry apres delai |
| HSM indisponible | KEY_SERVICE_UNAVAILABLE | 503 | Oui | Retry backend |
9. Hypotheses¶
| ID | Hypothese | Impact si faux |
|---|---|---|
| HU-01 | Le backend expose POST /documents/capture/presign pour obtenir URL pre-signee | Adapter endpoint |
| HU-02 | Le backend expose POST /documents/capture/multipart-init et /multipart-complete | Adapter endpoints multipart |
| HU-03 | S3 retourne un header ETag sur chaque PUT part reussi | Fallback ETag synthetique |
| HU-04 | Le JWT est passe en parametre par l'orchestrateur (pas de refresh automatique dans M4) | Le refresh JWT est la responsabilite de M6 |
10. Points hors perimetre M4¶
- Transition FSM : responsabilite de M1 (state-machine) via M6 (orchestrator)
- Persistance locale ciphertext : responsabilite de M7 (purge-manager)
- Stockage deferred dans Zustand : responsabilite de M5 (capture-store)
- Refresh JWT : responsabilite de M6 (orchestrator) ou module auth existant
- Purge locale post-UPLOADED : responsabilite de M7
11. Matrice de couverture tests¶
| Test-ID | Fichiers de test |
|---|---|
| TC-NOM-01 | src/__tests__/capture/upload-service.test.ts (a creer) |
| TC-NOM-05 | src/__tests__/capture/upload-service.test.ts |
| TC-NOM-06 | src/__tests__/capture/upload-service.test.ts |
| TC-NOM-13 | src/__tests__/capture/multipart-upload.test.ts (a creer) |
| TC-NOM-14 | src/__tests__/capture/upload-service.test.ts |
| TC-ERR-01 | src/__tests__/capture/upload-service.test.ts |
| TC-ERR-14 | src/__tests__/capture/multipart-upload.test.ts |
| TC-ERR-16 | src/__tests__/capture/upload-service.test.ts |
| TC-ERR-17 | src/__tests__/capture/upload-service.test.ts |
| TC-INV-09 | src/__tests__/capture/multipart-upload.test.ts |
Note : Les fichiers de test ne sont pas dans le perimetre de cet agent (Wave 2). Ils seront crees par l'agent de test ou lors de Wave 4 (M14 capture-tests-backend).