`createMockStudioGateway`'s default `dependencyRevision.current()` returned a literal constant and never consulted `dependencies.assets`, so the staleness guards in `createStudioPreview` and `publishStudioDocument` could not fire for an asset-store mutation between validate and preview. The live case: a document references an evidence key with `alt=""` and the store holds only a `decorative: true` Asset for it, so validation is correctly VALID with zero issues. A newer `decorative: false` Asset then wins that key. Preview succeeds, the figure resolves to `decorative: false, alt: ""`, and publish snapshots it verbatim -- a meaningful image with no accessible name, validated clean, with nothing anywhere reporting an error. The default now folds the Asset store into the revision. Each Asset is reduced to the fields the mock's own validation and projection read -- identity and resolution order (`id`, `assetKey`, `updatedAt`), resolvability (`managementStatus`, `publicPath`), the alt rule (`decorative`, `altText`), and what the published `ResolvedAsset` carries (`mediaType`, `width`, `height`) -- canonicalized with `stableStringify`, sorted, and folded into a 128-bit FNV-1a digest. Sorting the canonical strings is what makes it order-independent, which this mock's reproducibility across the suite depends on. It is a projection rather than the whole record because the excluded fields cost sensitivity without buying any. `usageCount` is the clearest: it counts referencing documents, so on a real backend publishing any document that uses an Asset would invalidate every other author's in-flight validation, while changing nothing the validator or renderer reads. An empty store still reports the bare catalog constant -- that is the world the seeded fixtures were validated against, and `fixtures.ts` now shares the one definition rather than retyping the literal. `findResolvableAsset` is untouched: it is a single-point-in-time predicate and is correct as it stands. Seeing a change *between* two points is the revision's job. A caller-supplied `dependencyRevision` still wins outright. The asset-picker test that reached `failureOf`'s `ContentFormatError` branch did so only because the revision could not move; it now pins its own revision to keep reaching the projection, and asserts the problem detail so the two 409 paths cannot be confused. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 KiB
Adapter Review — TechLog Asset Multipart Upload
검토 기준:
feature/techlog-backend-alignment(2026-08-18, Task 7)범위:
src/features/tech-log/adapters/http/asset-upload-transport.ts1개 파일과 그 wiring —create-tech-log-feature-input.ts,installed-feature-adapters.ts,bootstrap/runtime-adapters.ts의attachCredentials/techLogCsrf.src/adapters/**전수 리뷰(INVENTORY)와는 별도 트랙이다: 이 파일은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의requestBodyunion은"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<Asset>)는 나머지 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와 별개 값을 쓰는 것은 두 가지를 갖는다.
- Asset 수명주기(업로드·목록·삭제)는 문서 편집과 실패 특성이 다르다 — 예를 들어
PAYLOAD_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE/ASSET_QUARANTINED는 asset 쪽에만 있다. 별도 routeId는 이 실패를 diagnostics에서 문서 편집 트래픽과 섞지 않는다. uploadStudioAsset자체는 계약 실행기를 우회해 이 routeId를 진단에 보고하지 않지만, 같은 이름을 4개 JSON operation에 유지해 두면 향후 업로드가 presigned/resumable로 옮겨가거나 플랫폼에 MULTIPART 모드가 생겨 계약 경로로 복귀할 때, 같은 routeId 아래 asset 트래픽 전체가 이미 일관되게 모여 있다.
값을 바꿀 이유(예: 기존 registry 충돌, 명명 규칙 위반)는 없었다.
교체 계획
- presigned/resumable 업로드로 이전.
src/adapters/browser-transfer/에 이미 presigned capability와 resumable checkpoint 인프라가 있다(별도 리뷰: 04 — Browser transfer). Studio asset 업로드가 그쪽으로 옮겨가면, 이 transport는 presigned URL 발급을 위한 작은 JSON operation(계약 실행기 경유 가능)과 실제 바이트 전송을 위한 presigned executor 호출로 나뉜다.POST /api/v1/studio/assets의 multipart 자체가 없어진다. - 플랫폼에
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<Asset>)는 바뀌지 않는다 — 교체는 이 파일과create-tech-log-feature-input.ts의 배선 한 줄에서 끝난다.
두 경로 모두 StudioAssetUploadTransport/StudioAssetGateway 포트 경계 뒤에서 일어나므로, presentation 계층(Task 11의 Asset Library UI)은 재작성하지 않는다.
MOCK 의존성 리비전은 계약의 요구가 아니라 mock의 구현이다
createMockStudioGateway의 기본 dependencyRevision.current()가 무엇을 관찰하는지 — 그리고 그것이 계약이 요구하는 계산이 아니라는 점 — 을 여기에 남긴다. 나중에 mock의 구현을 계약의 요구로 오독하지 않기 위해서다.
실제 백엔드
DependencyRevision(studio-api.openapi.yaml / generated.ts)은 값의 형식만 계약이다: 불투명한 문자열. 계약이 요구하는 것은 값이 아니라 규칙 하나뿐이다 — 검증에 사용한 dependency set을 publish 시 다시 계산해 값이 다르면 VALIDATION_STALE로 거절한다. 무엇을 dependency set에 넣을지(Topic/Project 존재, relation target 상태, Asset READY/QUARANTINED 상태, slug/route ownership, catalog revision, 필요 시 renderer/content-format version), 그리고 그것을 어떻게 정규화·hash할지는 서버가 스스로 정한다. 프론트엔드는 이 값을 생성하지도, 해석하지도, 비교하지도 않는다. ValidationReport.dependencyRevision을 받아 그대로 되돌려 보내고, 서버가 내린 VALIDATION_STALE 판정을 표시할 뿐이다. HTTP gateway(http-studio-gateway.ts)에는 리비전을 계산하는 코드가 없다 — 있어서도 안 된다.
MOCK
MOCK studioSource에는 그 서버가 없으므로, mock이 같은 규칙을 스스로 만족시켜야 한다. 기본 리비전은 dependency-revision.ts의 mockDependencyRevision이 계산한다.
- catalog 성분:
MOCK_CATALOG_REVISION("catalog-2026-08-14") 상수.createMockStudioState가 고정 fixture catalog 하나를 싣고 변경하지 않으므로 catalog의 기여는 실제로 상수다.fixtures.ts의 seed validation/preview도 같은 정의를 import해 쓴다 — 두 값이 갈라지면 seed된 문서가 전부 조용히 stale이 된다. - asset 성분: Asset store를 정규화해 만든 128비트 digest. 각 Asset을 투영(projection) 으로 줄이고(
id,assetKey,managementStatus,publicPath,updatedAt,decorative,altText,mediaType,width,height),stableStringify로 정규 문자열을 만든 뒤 정렬해 접는다. 따라서Map삽입 순서와 무관하게 같은 논리적 Asset 집합은 항상 같은 리비전을 낸다 — 이 mock의 재현성은 저장소 전체 테스트가 의존하는 성질이다. - 빈 store는 성분을 더하지 않아
MOCK_CATALOG_REVISION그대로다. seed fixture가 Asset이 없는 세계에서 만들어졌고 그 문자열을 그대로 싣기 때문이다.
레코드 전체가 아니라 투영을 hash하는 이유: usageCount는 그 Asset을 참조하는 문서 수라 실제 백엔드였다면 아무 문서나 publish할 때마다 다른 저자의 진행 중인 검증이 전부 무효가 된다 — 검증기도 렌더러도 읽지 않는 필드인데도. version은 이 mock에서 updatedAt과 함께 움직여 신호를 더하지 않고, kind·originalFilename·byteSize·createdAt은 검증에도 render model에도 도달하지 않는다.
이 기본값이 닫는 구멍
기본 리비전이 리터럴 상수였을 때, createStudioPreview/publishStudioDocument의 staleness guard는 Asset store를 전혀 관찰하지 못했다. 그래서 validate와 preview 사이의 Asset 변경이 guard에게 보이지 않았다. 구체적으로: 어떤 evidence key의 Asset이 decorative: true뿐이면 alt=""인 directive는 정당하게 VALID다(장식용 이미지는 대체 텍스트가 없어도 된다). 그 사이에 같은 key에 decorative: false인 더 새로운 Asset이 도착하면, createStudioPreview는 성공하고 figure는 decorative: false, alt: ""로 해석되며 publishDocument가 그 render model을 그대로 snapshot한다. 의미 있는 이미지가 접근 가능한 이름 없이, 검증은 깨끗한 채로, 아무도 오류를 보고하지 않은 채 공개된다. 이제 그 변경이 리비전을 움직여 guard가 VALIDATION_STALE을 내고, 저자가 재검증하면 EVIDENCE_ALT_REQUIRED로 진짜 문제를 듣는다.
수정은 findResolvableAsset이 아니라 리비전에 있다. findResolvableAsset은 한 시점의 술어이고 그 자체로는 옳다 — 두 시점 사이의 변화를 보는 것은 리비전의 일이다.
주의: 위 필드 목록은 이 mock이 스스로 무엇을 읽는지에 대한 서술이지, 서버가 무엇을 dependency set에 넣어야 하는지에 대한 요구가 아니다. 서버는 프론트엔드가 볼 수 없는 것(예: relation target의 게시 상태, route ownership)까지 포함할 수 있고 그래야 한다. 이 mock을 계약의 참조 구현으로 삼지 말 것.
고정 테스트: tests/features/tech-log/mock-dependency-revision.test.ts(보고된 시나리오 end-to-end, 순서 무관 결정성, 무변경 authoring loop 안정성).
검증
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 exec vitest run tests/features/tech-log/mock-dependency-revision.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에 있다.