# Adapter Review — TechLog Asset Multipart Upload > 검토 기준: `feature/techlog-backend-alignment` (2026-08-18, Task 7) > > 범위: `src/features/tech-log/adapters/http/asset-upload-transport.ts` 1개 파일과 그 wiring — `create-tech-log-feature-input.ts`, `installed-feature-adapters.ts`, `bootstrap/runtime-adapters.ts`의 `attachCredentials`/`techLogCsrf`. `src/adapters/**` 전수 리뷰([INVENTORY](./INVENTORY.md))와는 별도 트랙이다: 이 파일은 `src/features/tech-log/adapters/**` 아래에 있고, TechLog는 자체 canonical HTTP 계약을 갖는 product feature이지 템플릿의 범용 adapter 계층이 아니다. ## 결론 TechLog Studio는 canonical 계약상 19개 operation을 갖는다. 그중 18개는 `external-contract-runtime.ts`가 표현할 수 있는 `requestBody: "NONE" | "JSON"` 범위 안에 있고, 플랫폼의 V3 실행기(`http-execution-v3.ts`) · 저수준 client(`client.ts`) · `attachCredentials` credential seam을 그대로 통과한다. 나머지 1개, `uploadStudioAsset`(`POST /api/v1/studio/assets`)만 `multipart/form-data`를 요구한다. 이 요구를 플랫폼이 표현할 수 없으므로, 이 operation 하나만 별도의 좁은 transport(`asset-upload-transport.ts`)로 분리했다. 이 seam은 플랫폼을 대체하지 않는다. CSRF, `Idempotency-Key`, canonical 오류 코드 매핑, timeout, credentials는 동일한 provider·동일한 오류 taxonomy로 다시 구현해 대칭을 유지한다. 포기하는 것은 플랫폼이 대신 강제해 주던 부분 — 계약 실행기의 byte 상한, retry policy, V3 진단 계측 — 뿐이며 이는 아래에 명시적으로 기록한다. 조립 지점은 `createTechLogFeatureInstalledInput`이 무조건(HTTP/MOCK 무관) `createHttpStudioAssetGateway`를 구성하고, 그 안에 이 transport를 주입하는 한 곳뿐이다. ## 우회 대상과 이유 - `src/contracts/external-contract-runtime.ts`의 `requestBody` union은 `"NONE" | "JSON"` 두 값만 갖는다. `multipart/form-data`를 표현할 세 번째 값이 없다. - `src/adapters/http/client.ts:719` 부근의 저수준 client는 본문이 있는 모든 request를 `JSON.stringify(input)`으로 직렬화해 고정 `content-type: application/json`으로 보낸다. `File`을 이 경로에 태우면 파일 바이트 대신 그 JSON 표현(빈 객체거나 오류)이 전송된다. - 두 제약 모두 이번 task의 global constraint로 수정 금지 대상이다(`external-contract-runtime.ts`, `client.ts`, `http-execution-v3.ts`, `mutation-intent.ts`, 생성된 계약 산출물, `studio-gateway.ts` 포트). 계약 실행기 자체를 바꾸는 대신, `uploadStudioAsset` 한 operation만 포트 경계 뒤에서 다른 구현으로 우회한다. ## 우회 범위 canonical Studio API 전체는 19개 operation이다. `tech-log-studio-contract-contribution.ts`는 그중 18개만 등록한다 — `uploadStudioAsset`은 계약 실행기가 표현할 수 없으므로 애초에 그 파일에 없다(`studio-contract-contribution.test.ts`의 "declares every canonical operation except the multipart upload"가 18을 고정한다). | 분류 | operation | 경로 | | --- | --- | --- | | JSON, 계약 실행기 경유 | `getStudioSession`, `getStudioDashboard`, `listStudioDocuments`, `createStudioDocument`, `getStudioDocument`, `saveStudioDocument`, `validateStudioDocument`, `getCurrentStudioPreview`, `createStudioPreview`, `publishStudioDocument`, `listStudioPublications`, `unpublishStudioPublication`, `getStudioPublicationSnapshot`, `listStudioCatalog`, `listStudioAssets`, `getStudioAsset`, `updateStudioAsset`, `deleteStudioAsset` (18개) | `contractHttp.execute()` → `client.ts` → `attachCredentials` | | multipart, 플랫폼 우회 | `uploadStudioAsset` (1개) | `asset-upload-transport.ts`의 직접 `fetch()` | 18개는 `contractOperations.execute(operationId, input, { routeId, intent? })`를 통해 나가며, 그중 17개(`getStudioSession` 제외)에 `attachCredentials`가 매 요청 `x-csrf-token`을 싣는다 — `getStudioSession`은 그 토큰을 발급하는 operation 자신이라 CSRF 헤더를 요구하지 않는 `TECH_LOG_STUDIO_BOOTSTRAP` auth profile을 쓴다(자세한 내용은 아래 "유지되는 보증"의 CSRF 행). 1개(`uploadStudioAsset`)만 이 경로를 완전히 벗어나 `createAssetUploadTransport`가 직접 `fetch()`한다. `StudioAssetGateway.uploadAsset()`이 이 transport를 호출하는 유일한 지점이며, 포트 시그니처(`Promise`)는 나머지 4개 asset operation과 동일해 호출자는 어느 경로인지 알 필요가 없다. ## 유지되는 보증 플랫폼이 18개 JSON operation에 자동으로 제공하는 것을, 이 transport는 같은 provider·같은 값으로 손으로 다시 만든다. | 보증 | JSON 경로 | multipart 경로 | | --- | --- | --- | | CSRF | `attachCredentials`가 `techLogCsrf.token()`/`headerName()`으로 얻은 값을 그 이름 그대로 요청 헤더에 싣는다 | `StudioAssetGateway.uploadAsset()`이 **같은** `techLogCsrf` provider에서 `token()`/`headerName()`을 읽어 transport에 넘긴다 — provider가 composition root에 하나뿐이므로 세션당 토큰도 하나다. `getStudioSession` 자신은 이 provider를 거치지 않는 `TECH_LOG_STUDIO_BOOTSTRAP` auth profile을 쓴다: 그 provider가 토큰을 얻으려고 호출하는 operation이 같은 provider의 토큰을 요구하면 순환이 되기 때문이다(`docs`가 아니라 코드로 고정: `studio-csrf-composition.test.ts`) | | Idempotency-Key | 실행 intent(`mutationIntent()`)에서 나와 client가 헤더로 싣는다 | 호출자(`uploadAsset` options)의 `idempotencyKey`를 gateway가 그대로 헤더로 전달한다 | | canonical 오류 코드 매핑 | `toStudioGatewayError()`가 `STUDIO_ERROR_CODES`에 있는 `problem.code`만 `StudioGatewayError`로 승격하고, 계약 밖 코드는 `STUDIO_UNAVAILABLE`로 접는다 | transport가 동일한 `STUDIO_ERROR_CODES` 집합을 재사용해 같은 규칙으로 매핑한다. 서버가 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`처럼 이 목록에 있는 코드를 보내면 그대로 `StudioGatewayError`가 되고, 계약에 없는 코드나 파싱 불가능한 본문은 도메인 코드를 지어내지 않고 `STUDIO_UNAVAILABLE`로 접는다 | | timeout | 계약의 `requestDeadlineCeilingMs` | `AbortSignal.timeout(deps.timeoutMs)`를 호출자 signal과 `AbortSignal.any()`로 합성한다 | | credentials | `TECH_LOG_STUDIO_SESSION` 프로필의 `credentials: "include"` | `fetch()` 호출에 동일하게 `credentials: "include"`를 명시한다 | ## 포기하는 보증 - **byte 상한**: 계약 실행기의 bounded body reader/writer가 응답 크기를 강제하는 것과 달리, 이 transport의 요청 body(`FormData`)와 응답 JSON 파싱에는 별도 상한이 없다. 서버가 `413 PAYLOAD_TOO_LARGE`로 거절하는 것에 의존한다. - **retry policy**: 계약 실행기의 `retry-policy.ts`는 이 operation에 적용되지 않는다. `uploadStudioAsset`은 애초에 계약에서 `retrySemantics`를 선언할 수 없는 경로 밖에 있으므로, 재시도는 호출자(향후 Task 11의 Asset Library UI)가 명시적으로 다시 `uploadAsset()`을 호출하는 형태로만 존재한다. - **V3 진단 계측**: `createHttpObservationProjector`가 만드는 `api.request.*` diagnostics/telemetry 이벤트는 `contractHttp.execute()` 내부에서만 발생한다. 이 transport는 그 관찰 경계 밖에서 직접 `fetch()`하므로 업로드 성공/실패는 diagnostics 스트림에 나타나지 않는다. `routeId: "TECH_LOG_STUDIO_ASSETS"`는 나머지 4개 asset JSON operation에는 여전히 붙지만, `uploadStudioAsset` 자체에는 대응하는 diagnostics 레코드가 없다. 이 세 항목 모두 이번 task 범위에서 새로 만들지 않는다 — 다시 만들려면 플랫폼과 동일한 bounded reader/retry/observation을 복제해야 하고, 그것은 계약 실행기를 다시 짓는 것과 다르지 않다. 대신 아래 교체 계획으로 닫는다. ## `ROUTE_ID` 검토 (Task 6 리뷰 인계 항목) `http-studio-asset-gateway.ts`의 `const ROUTE_ID = "TECH_LOG_STUDIO_ASSETS"`는 diagnostics/telemetry 버킷을 나누는 low-cardinality routing 메타데이터이지, 등록된 route 경로가 아니다. 이제 gateway가 실제로 배선되어 나머지 4개 asset JSON operation(`listAssets`, `getAsset`, `updateAssetMetadata`, `deleteAsset`)이 이 값으로 나가는 시점에서 다시 확인한 결과, **그대로 유지한다.** 문서/편집 operation의 `TECH_LOG_STUDIO`와 별개 값을 쓰는 것은 두 가지를 갖는다. 1. Asset 수명주기(업로드·목록·삭제)는 문서 편집과 실패 특성이 다르다 — 예를 들어 `PAYLOAD_TOO_LARGE`/`UNSUPPORTED_MEDIA_TYPE`/`ASSET_QUARANTINED`는 asset 쪽에만 있다. 별도 routeId는 이 실패를 diagnostics에서 문서 편집 트래픽과 섞지 않는다. 2. `uploadStudioAsset` 자체는 계약 실행기를 우회해 이 routeId를 진단에 보고하지 않지만, 같은 이름을 4개 JSON operation에 유지해 두면 향후 업로드가 presigned/resumable로 옮겨가거나 플랫폼에 MULTIPART 모드가 생겨 계약 경로로 복귀할 때, 같은 routeId 아래 asset 트래픽 전체가 이미 일관되게 모여 있다. 값을 바꿀 이유(예: 기존 registry 충돌, 명명 규칙 위반)는 없었다. ## 교체 계획 1. **presigned/resumable 업로드로 이전.** `src/adapters/browser-transfer/`에 이미 presigned capability와 resumable checkpoint 인프라가 있다(별도 리뷰: [04 — Browser transfer](./04-browser-transfer.md)). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다. `POST /api/v1/studio/assets`의 multipart 자체가 없어진다. 2. **플랫폼에 `requestBody: "MULTIPART"` 모드가 생기는 경우.** `external-contract-runtime.ts`와 `client.ts`가 `FormData` 본문을 표현할 수 있게 확장되면, `uploadStudioAsset`을 이미 등록된 다른 18개 operation과 함께 `tech-log-studio-contract-contribution.ts`에 등록하고 `createHttpStudioAssetGateway`의 `upload` 의존성을 제거한다. `StudioAssetGateway` 포트 시그니처(`uploadAsset(form, options): Promise`)는 바뀌지 않는다 — 교체는 이 파일과 `create-tech-log-feature-input.ts`의 배선 한 줄에서 끝난다. 두 경로 모두 `StudioAssetUploadTransport`/`StudioAssetGateway` 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다. ## 검증 ``` corepack pnpm exec vitest run tests/features/tech-log/asset-upload-transport.test.ts corepack pnpm exec vitest run tests/features/tech-log/studio-asset-gateway.test.ts corepack pnpm exec vitest run tests/features/tech-log/runtime-composition.test.ts corepack pnpm exec vitest run tests/features/tech-log/studio-csrf-composition.test.ts corepack pnpm exec vitest run tests/features/tech-log/studio-session-csrf.test.ts corepack pnpm check:types corepack pnpm test:tech-log ``` `studio-csrf-composition.test.ts`는 fix round 1에서 추가됐다 — 실 `createContractHttpExecutor` · `createCsrfTokenProvider` · `attachStudioSessionCredentials`를 composition root와 같은 방식으로 조립해 `getStudioSession`이 정확히 한 번만 나가고 그 토큰이 JSON operation과 업로드 양쪽에 모두 실리는지 검증한다. `studio-session-csrf.test.ts`는 provider의 재진입 가드를 단독으로 고정한다. fix round 2에서 같은 파일에 "JSON operation의 403이 캐시된 토큰을 무효화해 다음 operation이 세션을 다시 가져온다"는 테스트를 더했다 — `invalidateTechLogCsrfOnOutcome`(`studio-session-credentials.ts`)를 composition root와 동일하게 호출한다. `asset-upload-transport.test.ts`에는 업로드 transport가 계약 밖 상태 코드의 실제 HTTP status를 그대로 통과시키는지, 그리고 계약 밖 401 본문도 게이트웨이의 토큰 무효화를 실제로 촉발하는지 검증하는 테스트를 더했다. `studio-contract-contribution.test.ts`에는 `getStudioSession`이 bootstrap profile의 유일한 사용자인지와 `assertExactlyOneTechLogStudioBootstrapOperation`이 0개·2개 위반을 거절하는지 고정하는 테스트를 더했다. 정확한 실행 결과는 `.superpowers/sdd/2026-08-17-techlog-backend-alignment/task-7-report.md`에 있다.