iOS Envelope Encryption Integration Plan¶
Historical document. Implementation shipped on
main(PR #8, 2026-08-18). Upload uses API-mediated envelope (POST /veteran/me/documents/imaging/upload); prod/staging API uses R940 MinIO (IMAGING_STORAGE_MODE=minio_envelope). Kept for design archaeology only. Current behavior:docs/ARCHITECTURE.md.
Session: 1 (design + scoping only)
Status: Completed — superseded by docs/ARCHITECTURE.md
Date: 2026-08-17
Backend reference: lastvet-gates/docs/security/IMAGING_ENVELOPE_ENCRYPTION.md (canonical), implemented in last.vet-api PR #32
Blocking milestone: Production IMAGING_STORAGE_MODE=minio_envelope cutover requires this iOS work; 10 external TestFlight veterans waiting
Executive summary¶
LastVet imaging moves from client-direct S3 presigned multipart to API-mediated envelope encryption (Pattern B). The iOS app sends plaintext over TLS; the API encrypts with AES-256-GCM and stores wrapped DEKs in Postgres. iOS never sees KEK or DEK.
Recommendation: Clean cutover to envelope upload. Keep legacy presigned download handling only for existing documents with storage_backend: s3 until those rows are migrated or aged out. Remove presigned upload code paths after envelope ships.
Download is mostly ready today. downloadDocument already handles inline binary streams when the response is not JSON. Envelope downloads use that path.
Upload is the main gap. iOS must replace init → presigned part PUTs → complete with a single POST /veteran/me/documents/imaging/upload multipart request.
Confidence: Medium (see Risks and open questions).
1. Current state analysis¶
1.1 Networking — LastVetAPIClient.swift¶
| Area | Current behavior |
|---|---|
| Imaging upload | uploadImagingDocument() calls POST .../imaging/upload/init, uploads each part via presigned S3 URLs (ImagingMultipartUpload), then POST .../imaging/upload/:documentId/complete |
| Non-imaging upload | uploadDocument() uses postMultipart to POST /veteran/me/documents — this is the pattern envelope upload should mirror |
| Imaging download | GET /veteran/me/documents/:id/download with Accept: application/json, application/octet-stream. If JSON + downloadUrl, fetches presigned URL. Otherwise treats body as inline binary |
| Pending upload abandon | DELETE .../imaging/upload/:documentId |
| Auth | Bearer JWT via attachAuth; withAuthRetry on API calls |
| Retry | performWithRetry (3 attempts, exponential backoff) on API requests; separate per-part retry in ImagingMultipartUpload (3× per part) |
| 413 handling | Maps all 413 to LastVetAPIError.storageCapExceeded (does not distinguish imaging_object_too_large) |
| 410 / 503 | Fall through to generic serverError(statusCode) |
Upload flow (presigned, today):
Wi‑Fi guard (>50 MB)
→ POST /imaging/upload/init (JSON)
→ for each part: PUT presigned URL (ImagingMultipartUpload, 3× retry)
→ POST /imaging/upload/:id/complete (JSON)
→ return UploadedDocument
Download flow (today, already dual-mode):
GET /documents/:id/download
→ if Content-Type JSON + downloadUrl → GET presigned URL → Data
→ else → inline Data from response body
1.2 ImagingMultipartUpload.swift¶
Purpose-built for presigned S3 multipart:
- 8 MiB default part size (S3 5 MiB minimum except last part)
- Per-part retry with backoff (1s / 2s / 4s)
LastVetAPIError.imagingPartUploadFailed(partNumber:reason:)- Test hook
uploadPartHookfor unit tests
Not needed for envelope v1 (single POST, no client-side parts).
1.3 UI — imaging trigger and display¶
| File | Role |
|---|---|
UploadDocumentView.swift |
Imaging picker (Photos + Files: PDF, ZIP, DICOM). Calls uploadImagingDocument when documentType == .imaging. Progress bar driven by multipart part fractions (0.05–1.0) |
RecordView.swift |
interruptedImagingUploadBanner for pending uploads ("Upload didn't finish") |
HealthRecordViewModel.swift |
Loads pendingImagingUploads from documents list; abandonPendingImagingUpload |
DocumentViewerView (in UploadDocumentView.swift) |
Downloads on open; renders PDF via PDFKit or raster via UIImage; DICOM/ZIP show "Unsupported document format" (pre-existing gap) |
1.4 Connectivity guard — UploadConnectivity.swift¶
NWPathMonitortracks Wi‑Fi or Ethernet- Files > 50 MB blocked on cellular (
LastVetAPIError.wifiRequiredForLargeUpload) - Applies before upload in
uploadImagingDocument— keep for envelope
1.5 Models / DTOs¶
Public:
UploadedDocument— nometadataorstorage_backendfield decoded todayPendingImagingUpload— id, title, document_type, file_size, created_atDownloadedDocument— data, mimeType, fileName
Private (presigned-only):
ImagingUploadInitRequest/Response,ImagingUploadCompleteRequest/Response,ImagingDownloadURLResponse
1.6 Tests¶
| File | Coverage |
|---|---|
ImagingMultipartUploadTests.swift |
Part slicing, per-part retry exhaustion — presigned only |
| No tests | LastVetAPIClient.uploadImagingDocument, downloadDocument, envelope paths |
1.7 Info.plist and entitlements¶
| Item | Value | Envelope impact |
|---|---|---|
ITSAppUsesNonExemptEncryption |
false |
Still correct — iOS performs no non-exempt crypto; server encrypts |
LastVet.entitlements |
APNs, associated domains | No change expected |
| Deployment target | iOS 17.0 (project.yml) |
No iOS 18+ APIs required for v1 |
| Background modes | remote-notification only |
No background upload entitlement today |
1.8 Memory and progress characteristics (important)¶
Both upload and download load entire file into Data in memory:
- Upload:
selectedFileData→multipartBody(duplicate buffer) →URLSession.data - Download:
URLSession.shared.data(for:)loads full response
This existed for presigned uploads but envelope increases peak memory on upload (single multipart body ≈ file size + overhead). At the 512 MiB cap, peak RAM can exceed 1 GB on device. Worth explicit device testing.
No URLSessionUploadTask / delegate progress today for multipart POST — progress UI will need rework (indeterminate spinner or new upload-task delegate).
2. Backend contract summary¶
Canonical spec: lastvet-gates/docs/security/IMAGING_ENVELOPE_ENCRYPTION.md
Implementation: last.vet-api/src/routes/imaging-uploads.ts, src/lib/imagingDownload.ts, migration 061_imaging_envelope.sql
2.1 Storage mode switch¶
| Variable | Values | Scope |
|---|---|---|
IMAGING_STORAGE_MODE |
s3_presigned (legacy default) | minio_envelope |
Server-wide env var — not per-request |
When minio_envelope:
- Envelope upload route enabled
- Presigned
init,complete,DELETE .../imaging/upload/:docIdreturn 410 Gone (imaging_presigned_upload_retired) - Envelope upload route returns 503 when mode is NOT
minio_envelope(imaging_envelope_upload_not_enabled)
iOS does not receive mode in a dedicated config endpoint. Mode is inferred from API responses (410 on init = envelope era) or known at deploy time.
2.2 Envelope upload¶
Endpoint: POST /api/veteran/me/documents/imaging/upload
Auth: Bearer JWT (veteran session)
Content-Type: multipart/form-data
| Field | Required | Notes |
|---|---|---|
file |
Yes | Binary payload |
title |
Yes | 1–255 chars |
description |
No | max 2000 |
documentType |
Yes | imaging_dicom | imaging_pdf |
documentDate |
No | ISO date string |
MIME rules: Must match document type (application/pdf for PDF; DICOM/ZIP/octet-stream for dicom type per backend IMAGING_MIME_BY_TYPE).
Success: 201 Created
{
"documentId": "uuid",
"document": { /* UploadedDocument row */ },
"storageBackend": "minio_envelope",
"ciphertextBytes": 12345
}
Limits:
- 512 MiB max per file (
IMAGING_ENVELOPE_MAX_BYTES) - Veteran storage cap enforced server-side (
storage_cap_exceeded, 413)
Error responses (selected):
| HTTP | error |
Notes |
|---|---|---|
| 400 | No file uploaded, validation errors |
|
| 401 | Missing veteran identity | |
| 413 | imaging_object_too_large |
Includes message, maxBytes |
| 413 | storage_cap_exceeded |
Veteran cap |
| 503 | imaging_envelope_upload_not_enabled |
Wrong mode |
| 503 | imaging_upload_not_configured |
MinIO/KEK not configured |
| 500 | Failed to upload imaging study |
2.3 Envelope download¶
Endpoint: GET /api/veteran/me/documents/:docId/download (unchanged path)
Auth: Bearer JWT
Routing: Server inspects document metadata:
minio_envelope→ stream decrypted plaintext (no presigned URL)s3(legacy) → JSON{ downloadUrl, expiresInSeconds, documentId }- Other → local filesystem bytes
Envelope success headers:
Content-Type: document mime (e.g.application/pdf)Content-Disposition:inline; filename="..."Cache-Control:no-store, private- Body: decrypted plaintext stream
Envelope errors:
| HTTP | error |
|---|---|
| 404 | imaging_envelope_not_found |
| 403 | consent_denied (incl. mid-stream revocation for provider path) |
| 500 | Failed to decrypt imaging study |
| 503 | imaging_download_not_configured |
2.4 imaging_envelope Postgres table¶
Server-only crypto metadata. iOS does not read or write this table. Relevant columns: wrapped_dek, data_nonce, data_auth_tag, object_key, ciphertext_bytes, envelope_format. Document row carries metadata.storage_backend = minio_envelope and upload_status = completed after envelope upload.
2.5 Rate limits¶
No imaging-specific rate limit surfaced in route handlers beyond general API middleware. 512 MiB cap is the hard size limit documented in spec and Muster memory.
2.6 Legacy presigned endpoints (retired in envelope mode)¶
| Endpoint | Envelope mode |
|---|---|
POST .../imaging/upload/init |
410 |
POST .../imaging/upload/:id/complete |
410 |
DELETE .../imaging/upload/:id |
410 |
GET /veteran/me/documents still returns pendingImagingUploads for rows stuck in pending state from the presigned era.
3. Gap analysis¶
3.1 API contract changes¶
| Current iOS call | Envelope era | Gap |
|---|---|---|
POST .../imaging/upload/init |
410 Gone | Replace with envelope upload POST |
| Presigned S3 PUT parts | N/A | Remove |
POST .../imaging/upload/:id/complete |
410 Gone | Remove |
DELETE .../imaging/upload/:id |
410 Gone | Abandon flow broken for cleanup; need client-side dismiss strategy |
GET .../:id/download |
Works for envelope (inline stream) | Minor: error mapping, large-file memory |
GET .../documents |
Unchanged | Pending banner may show stale presigned-era rows |
3.2 New endpoints iOS must hit¶
POST /veteran/me/documents/imaging/upload— only new upload endpoint
No new download endpoint.
3.3 Data structure changes¶
| Change | Detail |
|---|---|
Add ImagingEnvelopeUploadResponse |
Decode documentId, document, storageBackend, ciphertextBytes |
| Remove presigned private DTOs | After cutover: init/complete/part structs |
Optional UploadedDocument extension |
Decode metadata if we need client-side storage_backend for telemetry — not required for v1 download (server routes by DB) |
| Error body parsing | Distinguish imaging_object_too_large vs storage_cap_exceeded vs imaging_presigned_upload_retired |
3.4 New error scenarios¶
| Scenario | iOS today | Needed handling |
|---|---|---|
| 410 on init (envelope mode, old app) | Generic server error | N/A post-cutover; TestFlight must ship before prod flip |
| 503 envelope not enabled (staging mismatch) | Generic server error | User-friendly "imaging upload unavailable" |
413 imaging_object_too_large |
Shown as storage cap message | Wrong copy — use server message (512 MB limit) |
404 imaging_envelope_not_found on download |
Generic not found | "Document unavailable" |
| 500 decrypt failure | Generic server error | "Could not load this document" (no crypto detail) |
| Upload timeout mid-POST | Partial state: no pending row (atomic) | User retries full upload — simpler than presigned |
Stale pendingImagingUploads |
Retry opens upload sheet; abandon calls DELETE | Abandon returns 410 — banner stuck unless client dismisses locally |
3.5 UX considerations¶
| Topic | Impact |
|---|---|
| Upload progress | Single long request replaces per-part progress; use indeterminate progress or URLSessionUploadTask delegate |
| Upload duration | Server encrypts + writes MinIO before 201; large studies may sit at ~95% longer |
| Download duration | Server decrypts while streaming; first byte may be slower than presigned redirect |
| Pending upload banner | Obsolete for new uploads; may linger for migration — simplify or auto-dismiss on 410 abandon |
| Wi‑Fi guard | Unchanged (>50 MB) |
| DICOM viewing | Pre-existing gap — out of envelope scope unless Ryan wants parity |
4. Design proposal¶
4.1 Approach: clean cutover (recommended)¶
| Layer | Decision |
|---|---|
| Upload | Envelope only — delete presigned init/parts/complete path |
| Download | Dual path — keep presigned JSON branch for legacy s3 documents only; envelope uses existing inline binary branch |
| Feature flag | Not required if TestFlight validates before prod IMAGING_STORAGE_MODE flip; optional compile-time guard only for emergency rollback builds |
| iOS version | Stay on 17.0 |
Rationale: AWS S3 retired; presigned upload against prod is dead end once backend flips. Legacy download branch is low cost and protects veterans with old imaging rows.
4.2 File-by-file changes (Session 2)¶
LastVet/Core/Networking/LastVetAPIClient.swift¶
- Rewrite
uploadImagingDocument: - Keep Wi‑Fi guard and
ImagingDocumentKindmime resolution - Build multipart body (same fields as backend
envelopeUploadMetaSchema+file) postMultipartto/veteran/me/documents/imaging/upload- Decode
ImagingEnvelopeUploadResponse; returndocument -
Progress: start 0.1 → optional upload-task fraction → 1.0 on 201
-
Extend
validateHTTPResponse(or imaging-specific validator): - 410 +
imaging_presigned_upload_retired→ dedicated error (diagnostics) - 413 +
imaging_object_too_large→ new error with servermessage -
503 +
imaging_envelope_upload_not_enabled/imaging_upload_not_configured→ configuration error -
Download (
downloadDocument): - Keep presigned JSON branch for legacy docs
- Ensure envelope binary path handles large responses (consider
downloadTaskto temp file in v1.1 — optional stretch) -
Map 404/500 imaging errors to viewer-friendly strings
-
Remove private presigned DTOs and init/complete logic after cutover
-
abandonPendingImagingUpload: - On 410, treat as success (local dismiss) — pending rows are orphaned server-side in envelope mode
LastVet/Core/Networking/ImagingMultipartUpload.swift¶
- Delete (or move to
Legacy/only if Ryan wants a rollback branch artifact)
LastVet/Core/Networking/UploadConnectivity.swift¶
- No functional change
- Consider documenting 512 MiB server cap in UI copy (separate from 50 MiB Wi‑Fi threshold)
LastVet/Features/HealthRecord/UploadDocumentView.swift¶
- Update error catches: remove
imagingPartUploadFailed; addimagingObjectTooLarge, configuration errors - Progress UI: indeterminate
ProgressViewduring envelope upload OR wire upload-task progress - Optional copy tweak: "Large files need Wi‑Fi · Max 512 MB"
LastVet/Features/HealthRecord/RecordView.swift + HealthRecordViewModel.swift¶
- Pending banner: hide when list empty; on abandon 410, remove locally without surfacing error
- Long-term: remove pending banner entirely once prod has no pending rows (follow-up cleanup)
LastVetTests/NetworkingTests/ImagingMultipartUploadTests.swift¶
- Replace with
ImagingEnvelopeUploadTests.swift: - Mock
URLProtocolfor 201 success - 413
imaging_object_too_largemessage propagation - 503 not enabled
- Wi‑Fi guard still enforced (unit test
UploadConnectivitywith injected path)
Optional new file: LastVetTests/NetworkingTests/ImagingDownloadTests.swift¶
- Mock inline PDF stream vs JSON presigned branch
LastVet/Resources/Info.plist¶
- No change to
ITSAppUsesNonExemptEncryption(remainfalse)
project.yml¶
- No deployment target bump
4.3 Refactoring opportunities (same PR, bounded scope)¶
- Extract shared
buildImagingMultipartBody(title:description:documentType:documentDate:fileName:mimeType:data:)used by envelope upload - Consolidate imaging error parsing into
ImagingAPIErrorenum mapped fromAPIErrorBody - Add
URLSessioninjection toLastVetAPIClientfor testability (if not already present)
Do not expand into DICOM viewer, background uploads, or chunked client upload (v2 backend).
4.4 Backward compatibility¶
| Scenario | Strategy |
|---|---|
| Prod backend flips before iOS ships | Broken imaging upload (410 on init) — must not happen |
| iOS ships before backend flip | Envelope POST returns 503 — veterans cannot upload imaging until backend enables mode |
| Legacy S3 documents in DB | Download presigned JSON path retained |
| Pending presigned uploads | Stale banner; abandon 410 → local dismiss; optional backend cleanup script (Ryan/ops) |
Recommend: Staging validates iOS against minio_envelope first, then TestFlight, then prod backend flip.
5. Effort estimate¶
Realistic hours for one engineer familiar with the codebase, including typical friction:
| Workstream | Hours |
|---|---|
| Upload rewrite + error mapping + abandon 410 handling | 10–14 |
| Download hardening + error copy | 2–4 |
| Remove presigned multipart code + DTO cleanup | 2–3 |
| UI progress + error surfacing | 3–5 |
| New unit tests (URLProtocol mocks) | 8–12 |
| Manual device testing (small PDF, ~100 MB, Wi‑Fi/cellular guard, download round-trip) | 8–12 |
| TestFlight iteration (external veteran feedback, large DICOM/ZIP edge cases) | 8–16 |
| Total | 41–66 hours (~1–1.5 weeks calendar) |
Central estimate for planning: ~50 hours.
Backward-compat presigned download branch: included above (2–4h). No iOS presigned upload fallback recommended.
6. Rollout plan¶
6.1 Branch strategy¶
| Phase | Branch | Notes |
|---|---|---|
| Session 2 implementation | feature/ios-imaging-envelope off main |
Single focused PR |
| This document | docs/envelope-encryption-plan |
Review only; merge doc after Ryan approval |
6.2 Environment sequencing¶
sequenceDiagram
participant iOS as iOS TestFlight
participant Stg as Staging API (minio_envelope)
participant TF as External veterans
participant Prod as Prod API
iOS->>Stg: E2E upload/download QA
iOS->>TF: Build N+1 (envelope client)
TF->>Stg: Soak against staging envelope
Note over Prod: Do NOT flip until TF validated
Prod->>Prod: IMAGING_STORAGE_MODE=minio_envelope
TF->>Prod: Cohort upload/download verify
- Enable
minio_envelopeon staging (if not already) - Merge iOS feature branch → TestFlight build (increment build number)
- Internal + 10 external veterans test against staging API (or prod after flip — staging strongly preferred)
- After soak (recommend ≥48h, ≥5 successful imaging uploads including one >50 MB on Wi‑Fi): flip production
IMAGING_STORAGE_MODE - Monitor API logs:
imaging_envelope_upload_stored, decrypt failures, 413 rate
6.3 TestFlight approach¶
- Replace current TestFlight build with envelope-capable build (no separate flag required)
- Bump
CURRENT_PROJECT_VERSIONinproject.yml - Release notes: "Imaging upload security upgrade — re-test imaging upload if you use that feature"
6.4 Pre-TestFlight gate (local + Xcode)¶
- [ ] All unit tests pass (
ImagingEnvelopeUploadTests, existing suite) - [ ] Manual: upload PDF imaging <5 MB → appears in record → download → PDF renders
- [ ] Manual: upload >50 MB on cellular → Wi‑Fi message
- [ ] Manual: upload >50 MB on Wi‑Fi → success (staging)
- [ ] Manual: download legacy S3 doc still works (if test row exists)
- [ ] Manual: abandon/dismiss stale pending banner without crash
6.5 Rollback plan¶
| Failure mode | Rollback |
|---|---|
| Envelope upload broken in prod | Backend: set IMAGING_STORAGE_MODE=s3_presigned — but S3 is retired, so this is not viable for prod |
| iOS envelope client broken | Ship previous TestFlight build + keep backend on s3_presigned until fix — only if prod not yet flipped |
| Decrypt failures server-side | Backend/crypto ops issue — iOS rollback does not help |
Critical: Prod backend flip is one-way given AWS retirement. iOS must be validated on staging before prod flip. Backend s3_presigned mode is a config rollback lever only if MinIO path fails before cutover — not a long-term option.
6.6 Cutover coordination¶
| Event | Owner | When |
|---|---|---|
Staging minio_envelope verified |
Ryan/ops | Before iOS Session 2 merge |
| iOS TestFlight with envelope upload | iOS | Before prod flip |
| External cohort soak complete | Ryan | Gate for prod flip |
Prod IMAGING_STORAGE_MODE=minio_envelope |
Ryan/ops | After TestFlight validation |
| Optional: cleanup pending presigned rows | Backend/ops | Post-cutover |
7. Risks and open questions¶
7.1 Cryptographic / compliance¶
| Item | Assessment |
|---|---|
| Client-side crypto | None in v1 — ITSAppUsesNonExemptEncryption=false remains valid |
| PHI in transit | TLS to API; plaintext in API memory during upload (Pattern B by design) |
| Export compliance | No change expected; confirm in App Store Connect on next submission |
7.2 iOS-specific edge cases¶
| Risk | Severity | Mitigation |
|---|---|---|
| Peak memory ~2× file size on upload | High for 400+ MB studies | Device test near cap; document in known limitation; v2 streaming upload if needed |
Full Data download of large studies |
High | Same as today; consider temp-file download follow-up |
| Upload timeout (nginx/ALB idle) | Medium | Confirm ingress timeout ≥ largest expected upload duration on R940 path |
| No upload resume | Low | Envelope is atomic — acceptable vs presigned multipart resume |
| DICOM/ZIP not viewable in app | Low (pre-existing) | Out of scope unless Ryan prioritizes |
7.3 Backend contract questions for Ryan¶
- Is staging already on
IMAGING_STORAGE_MODE=minio_envelope? Session 2 needs a live target for E2E. - Pending presigned rows: Run a one-time DB cleanup on cutover, or rely on iOS local dismiss only?
- Ingress timeout: What is the current Caddy/ALB
read_timeoutforapi.last.vetrelative to 512 MiB uploads on typical veteran uplink? - Provider iOS app: Does
last.vet-providerhave imaging download paths that also need envelope work? (Out of scope for this doc unless shared client exists.)
7.4 Test coverage gaps¶
- No integration test against real staging MinIO from iOS CI (expected — manual + TestFlight)
- No load test for concurrent veteran imaging uploads
- Legacy S3 download path may lack test fixtures once AWS is gone
7.5 Decisions required before Session 2¶
| # | Question | Options | Recommendation |
|---|---|---|---|
| 1 | Drop pending-upload banner entirely in envelope build? | Remove vs keep with 410 dismiss | Keep briefly with 410-local-dismiss; remove in follow-up |
| 2 | Upload progress UX | Indeterminate vs URLSessionUploadTask progress |
Upload task progress if ≤4h extra; else indeterminate |
| 3 | Prod flip gate | TestFlight soak duration | ≥48h + 5 successful uploads |
| 4 | Legacy S3 download support duration | Time-box removal | Keep until no storage_backend:s3 imaging rows in prod query |
| 5 | DICOM in-app viewing | Defer vs scope in | Defer (pre-existing gap) |
Appendix A — Reference code anchors (current iOS)¶
Presigned upload entry point:
```161:225:LastVet/Core/Networking/LastVetAPIClient.swift func uploadImagingDocument( _ input: UploadDocumentInput, fileData: Data, ... let initResponse: ImagingUploadInitResponse = try await post( "/veteran/me/documents/imaging/upload/init", body: initBody ) ... let completeResponse: ImagingUploadCompleteResponse = try await post( "/veteran/me/documents/imaging/upload/(initResponse.documentId)/complete", body: completeBody )
Download dual-path (envelope-compatible inline branch):
```227:262:LastVet/Core/Networking/LastVetAPIClient.swift
func downloadDocument(id: String, preferredFileName: String? = nil) async throws -> DownloadedDocument {
...
if contentType.contains("application/json"),
let presigned = try? JSONDecoder().decode(ImagingDownloadURLResponse.self, from: data),
!presigned.downloadUrl.isEmpty {
...
}
...
return DownloadedDocument(data: data, mimeType: mimeType, fileName: fileName)
Existing multipart upload pattern to mirror:
128:158:LastVet/Core/Networking/LastVetAPIClient.swift
func uploadDocument(...) async throws -> UploadedDocument {
let boundary = "Boundary-\(UUID().uuidString)"
let body = multipartBody(boundary: boundary) { form in
...
form.addFileField(name: "file", fileName: fileName, mimeType: mimeType, data: fileData)
}
let response: DocumentUploadResponseDTO = try await postMultipart(
"/veteran/me/documents",
body: body,
boundary: boundary
)
Appendix B — Session 2 definition of done¶
- [ ] Imaging upload uses envelope POST only
- [ ] Presigned upload code removed
- [ ] Download works for envelope + legacy S3
- [ ] Error messages correct for 413/503/410
- [ ] Unit tests cover upload + error paths
- [ ] Staging E2E verified on device
- [ ] TestFlight build published
- [ ] Ryan sign-off on prod
IMAGING_STORAGE_MODEflip timing
End of Session 1 design document.