# Adapter Review — Browser Transfer > 검토 기준: `develop` / `4dc033c` (2026-08-13) > > 범위: `src/adapters/browser-transfer/**`, 직접 연결된 application port, unit test, `docs/architecture/presigned-transfer-and-image-cdn.md` ## 결론 브라우저 전송 계열은 URL·header·subscription material을 application/presentation에서 차단하고, identity capability와 strict decoder를 사용하는 방향이 좋다. 특히 presigned single-use vault, multipart checkpoint CAS, image preset registry와 private descriptor 서명 검증은 유지해야 한다. 다만 실제 조합 전에 해결해야 할 P1 항목이 세 개 있다. 1. presigned download는 `open()`에서 이미 fetch와 timeout을 시작하지만 반환된 source에는 `close()`가 없다. 호출자가 stream을 늦게 열거나 열지 않으면 정상 API 사용만으로 body/timeout 자원이 방치된다 (`BT-PRE-01`). 2. IndexedDB checkpoint partition 삭제는 `BLOCKED`를 반환한 뒤에도 native `deleteDatabase()`가 늦게 commit될 수 있다. 반환 결과가 실제 effect certainty를 표현하지 못한다 (`BT-UP-03`). 3. presigned capability wire envelope에는 top-level protocol literal이 없다. 이미 아키텍처 문서가 요구한 `PRESIGNED_TRANSFER_V1`을 실제 request/response decoder가 아직 강제하지 않는다 (`BT-PRE-02`). 파일 크기만을 이유로 나누면 안 되지만, `resumable-upload-runtime.ts` 2,196줄과 `image-cdn-runtime.ts` 1,340줄은 각각 state transition, I/O orchestration, retry, persistence, presentation projection을 동시에 소유한다. characterization test를 먼저 고정한 뒤 State Machine·Saga·Strategy 경계로 분리하는 것이 안전하다. ## 판정 기준 | 표기 | 의미 | | --- | --- | | P1 | 조합 또는 배포 전에 수정. 결과 거짓 보고, 보안/정합성, 자원 수명주기 결함 | | P2 | 다음 리팩터링 묶음에서 수정. 계약 모호성, 실패 격리, 유지보수 위험 | | P3 | 동작을 고정한 뒤 정리. 테스트 seam, 중복, 가독성 | | `VERIFIED_DEFECT` | 현재 코드 경로만으로 재현 가능한 결함 | | `CONTRACT_GAP` | provider/consumer 간 의미가 타입이나 decoder에 충분히 고정되지 않음 | | `REFACTOR` | 현재 외부 동작은 보존하면서 내부 책임을 재배치 | | `PLANNED_GAP` | 기존 아키텍처 문서가 이미 미구현으로 선언한 항목. 현재 구현의 회귀로 계산하지 않음 | | `KEEP` | 의도와 테스트가 일치하므로 변경하지 않음 | ## 전체 파일 판정 | 모듈 | 현재 역할 | 판정 | 후속 항목 | | --- | --- | --- | --- | | `browser-transfer/index.ts` | 하위 capability export | KEEP | public export 증가는 각 capability 계획에서만 수행 | | `presigned/index.ts` | presigned public surface | KEEP | `BT-PRE-04`에서 vault issuer 노출만 축소 검토 | | `presigned/incremental-sha256.ts` | streaming SHA-256 | KEEP | WebCrypto `digest()`로 바꾸면 전체 buffering이 되므로 교체 금지 | | `presigned/presigned-capability-http-provider.ts` | BFF capability 발급, strict decode | CONTRACT_GAP | `BT-PRE-02`, `BT-PRE-03`, `BT-X-01` | | `presigned/presigned-capability-vault.ts` | identity capability 보관/폐기 | REFACTOR | `BT-PRE-04` | | `presigned/presigned-transfer-executor.ts` | GET stream/PUT part 실행 | VERIFIED_DEFECT | `BT-PRE-01`, `BT-PRE-03`, `BT-X-01` | | `resumable-upload/checkpoint-schema.ts` | durable schema guard | KEEP | schema V1 golden fixture 유지 | | `resumable-upload/fetch-json-transport.ts` | bounded JSON control transport | VERIFIED_DEFECT | `BT-UP-01`, `BT-UP-02`, `BT-X-01` | | `resumable-upload/http-control-plane-adapter.ts` | operation별 wire decoder | KEEP/REFACTOR | runtime 분리 뒤 decoder만 남김 | | `resumable-upload/index.ts` | resumable public surface | KEEP | facade 호환 유지 | | `resumable-upload/indexeddb-checkpoint-store.ts` | scope-bound CAS store/admin | VERIFIED_DEFECT | `BT-UP-03` | | `resumable-upload/presigned-upload-part-executor.ts` | multipart와 presigned bridge | VERIFIED_DEFECT | `BT-UP-04` | | `resumable-upload/resumable-upload-runtime.ts` | session state/retry/part scheduling/commit | REFACTOR | `BT-UP-05`, `BT-UP-06` | | `resumable-upload/runtime-policy.ts` | hard bound snapshot | KEEP | 값 변경은 contract migration으로만 수행 | | `resumable-upload/upload-byte-source.ts` | stream/range source snapshot과 hashing | KEEP/REFACTOR | runtime에서 source preparation Strategy로 주입 | | `resumable-upload/upload-cancellation-channel.ts` | best-effort cross-context cancel hint | KEEP | backend/CAS가 authority라는 주석과 동작 유지 | | `resumable-upload/upload-mutation-lock.ts` | Web Lock exclusive mutation | PLANNED_GAP | `BT-UP-07` | | `image-cdn/README.md` | 안전한 composition 예제 | KEEP | resolve signal 결정 반영 필요 | | `image-cdn/browser-image-probe.ts` | bounded fetch/header/static decode probe | VERIFIED_DEFECT | `BT-IMG-02` | | `image-cdn/image-cdn-policy.ts` | origin/preset/hard-limit registry | KEEP | composition-owned identity reference 유지 | | `image-cdn/image-cdn-runtime.ts` | asset acceptance, signature, URL/projection | REFACTOR | `BT-IMG-01`, `BT-IMG-03` | | `image-cdn/image-header-metadata.ts` | PNG/JPEG/WebP/AVIF static header parser | KEEP | 별도 fuzz/golden corpus로 보호; 작은 parser로 임의 분해 금지 | | `image-cdn/p256-image-capability-verifier.ts` | P-256 P1363 verifier | KEEP | key overlap contract 유지 | | `image-cdn/index.ts` | image public surface | KEEP | descriptor provider가 생길 때만 export 확장 | 직접 연결 경계도 다음과 같이 대조했다: `src/application/ports/browser-transfer/authorized-download.ts`, `src/application/ports/browser-transfer/image-cdn.ts`, `src/application/ports/browser-transfer/presigned-transfer.ts`, `src/application/ports/browser-transfer/resumable-upload.ts`, barrel `src/application/ports/browser-transfer/index.ts`, 그리고 presigned source의 직접 consumer `src/adapters/browser-files/download-delivery-adapter.ts`. native URL/header/File/Response를 application port로 올리지 않는 방향은 유지하며, `BT-PRE-01`의 `close()` migration은 이 consumer까지 포함한다. ## Presigned transfer 상세 ### BT-PRE-01 — `open()`이 반환되기 전에 download lease가 시작됨 - 우선순위/분류: **P1 / VERIFIED_DEFECT** - 근거: `presigned-transfer-executor.ts:114-192`, `:408-631` - 현재 동작: - `openDownload()`이 capability를 claim/consume한 뒤 즉시 `fetch()`를 수행한다. - timeout scope도 `open()` 안에서 시작한다. - response body와 scope는 반환된 `PresignedDownloadByteSource.stream()`을 완주하거나 실패해야만 해제된다. - source port에는 `close()`/`dispose()`가 없다. - 영향: - 호출자가 source를 받은 뒤 stream 시작을 늦추면, 실제 consumer deadline이 아니라 `open()` 시점의 timeout으로 실패한다. - 호출자가 stream을 열지 않으면 body cancellation과 listener/timer cleanup을 명시적으로 수행할 방법이 없다. - capability는 이미 single-use로 소비되므로 동일 source를 복구할 수도 없다. 결정: **lazy, single-start lease로 변경한다.** `open()`은 policy/vault 검증과 capability consume까지만 수행하고 fetch는 첫 `stream(signal)` 진입 시 시작한다. source에 `close(): void`를 추가해 미사용 lease도 명시적으로 폐기한다. `close()`와 stream의 first-start는 하나의 state machine을 공유한다. ```ts type PresignedDownloadByteSource = Readonly<{ byteLength: number; capability: PresignedDownloadCapability; integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION"; stream(signal: AbortSignal): AsyncIterable>; close(): void; }>; type DownloadLeaseState = "READY" | "STREAMING" | "CLOSED"; ``` 구현 규칙: 1. `READY -> STREAMING`만 fetch를 시작한다. 2. `READY -> CLOSED`는 network I/O 없이 끝낸다. 3. `STREAMING -> CLOSED`는 composed signal abort, reader/body cancel, timer/listener release를 한 번만 수행한다. 4. 두 번째 `stream()`은 기존처럼 `CONFLICT / REISSUE_CAPABILITY`다. 5. digest 성공 전 chunk는 현재의 `VERIFIED_ON_SUCCESSFUL_EXHAUSTION` 의미를 유지한다. consumer는 최종 success 전 파일을 commit하면 안 된다. 6. 첫 `stream()` 직전에 capability expiry와 minimum remaining lifetime을 다시 확인하고, `open()` 때 받은 outer signal과 stream signal을 함께 적용한다. 오래 보관되어 만료된 source는 fetch를 시작하지 않는다. 테스트 추가 (`tests/unit/presigned-transfer.test.ts`): - `does not fetch until the returned download source starts streaming` - `closes an unused source without issuing a request` - `starts the transfer deadline at first stream consumption` - `close during a pending read cancels the reader and releases listeners once` - `stream after close returns one terminal conflict without fetching` 마이그레이션: port에 `close()`를 추가한 뒤 직접 consumer인 `src/adapters/browser-files/download-delivery-adapter.ts`를 포함한 모든 consumer를 source 획득 직후 `try/finally { source.close(); }`로 감싼다. size reject, `createWritable()`/prompt 실패, object-URL strategy의 stream 전 실패도 `tests/unit/browser-file-download.test.ts`로 고정한다. 그 다음 fetch를 lazy로 옮긴다. rollback은 eager fetch 구현으로 되돌릴 수 있지만 `close()` API는 유지한다. 완료 조건: 위 테스트와 기존 presigned suite가 통과하고, source를 생성만 한 테스트에서 fetch 호출 수와 active timer가 모두 0이다. ### BT-PRE-02 — capability wire envelope의 protocol version 부재 - 우선순위/분류: **P1 / CONTRACT_GAP**, 기존 문서의 미완료 항목 - 근거: `presigned-capability-http-provider.ts:173-210`, `:436-462`; `docs/architecture/presigned-transfer-and-image-cdn.md:93-108` - 현재 동작: request body와 strict response key set에 top-level transfer protocol이 없다. multipart binding 내부 protocol만으로 전체 capability envelope version을 식별한다. - 영향: 서버가 필드를 추가/재해석할 때 old/new client가 같은 shape를 서로 다른 의미로 받아들일 수 있다. strict decoder라서 단순 필드 추가도 곧바로 장애가 되지만, 장애가 version mismatch로 분류되지 않는다. 결정: - request와 response에 `protocol: "PRESIGNED_TRANSFER_V1"`을 필수로 추가한다. - missing/unknown protocol은 현재 closed taxonomy의 `POLICY_REJECTED`, retryable `false`, recovery `REISSUE_CAPABILITY`로 닫는다. 이 변경에서 새 failure code를 만들지 않는다. - multipart의 `PRESIGNED_MULTIPART_V1`은 하위 binding protocol로 그대로 유지한다. - protocol은 `PresignedTransferCapability`, `PresignedCapabilityRegistration/Binding`, vault snapshot, executor common-binding validator까지 전파해 request → registration → consumption exact parity를 보장한다. - server는 request shape를 협상해 legacy request에는 legacy response, V1 request에는 V1 response를 반환한다. strict legacy decoder를 깨뜨리므로 legacy response에 V1 field를 먼저 emit하거나 한 response에 dual fields를 넣지 않는다. 테스트 추가: - request body exact-key snapshot과 protocol literal - missing, V0, V2 protocol response 거절 - V1 download와 V1 multipart capability 수락 - protocol mismatch가 vault `register()` 전에 종료됨 배포 순서: request-shape negotiated provider 배포 → V1 client 배포 → old-client drain 기간 관찰 → provider legacy request/response 제거. rollback 시 provider는 두 request shape를 계속 수락하되 각각 matching exact response를 반환한다. ### BT-PRE-03 — timeout이 non-cooperative fetch를 실제로 bound하지 못함 - 우선순위/분류: **P2 / VERIFIED_DEFECT** - 근거: capability provider `:186-214`, executor `presigned-transfer-executor.ts:157-190`, 각 파일의 `createAbortScope()` - 현재 동작: timer는 AbortController만 abort한다. injected fetcher 또는 host가 signal을 무시하면 `await fetcher(...)` 자체는 끝나지 않는다. - 영향: API가 선언한 timeout이 hard bound가 아니며, teardown도 fetch settlement에 묶인다. 결정: 공통 `AbortableOperationScope`의 `race(task, onLateValue)`를 사용한다 (`BT-X-01`). deadline/caller abort가 먼저 끝나면 즉시 typed failure를 반환하고, 늦게 온 `Response`는 body를 취소한다. timer 생성 실패 시 이미 붙인 external listener를 즉시 제거한다. 테스트 추가: - signal을 무시하는 fetch Promise가 timeout 뒤에도 pending인 fixture - timeout 결과가 정시에 반환되고 late response body가 취소되는지 검증 - scheduler `setTimeout`/`clearTimeout` throw 시 listener 누수와 public rejection이 없는지 검증 ### BT-PRE-04 — vault가 스스로 registration invariant를 소유하지 않음 - 우선순위/분류: **P2 / REFACTOR** - 근거: `presigned-capability-vault.ts:112-174` - 현재 동작: HTTP provider가 URL, header, expiry, byte/digest를 검사하지만 exported vault의 `register()`는 전달받은 registration을 그대로 snapshot한다. - 영향: 다른 issuer adapter가 추가되거나 테스트/조합 코드가 vault를 직접 사용하면 동일한 capability 타입에 더 약한 invariant가 들어갈 수 있다. 결정: issuer/consumer 권한을 wiring 단계에서 분리하고 공통 invariant validator를 적용한다. 1. `createPresignedCapabilityVault()`는 `{ issuer: PresignedCapabilityIssuer; consumer: PresignedCapabilityConsumer }`를 반환한다. provider option에는 issuer만, executor option에는 consumer만 전달한다. root barrel에는 factory와 consumer-facing type만 export하고 issuer type은 provider의 구조적 parameter로 숨긴다. 2. issuer 등록 직전 공통 `validatePresignedCapabilityRegistration()`으로 method/binding/URL/header/status/bytes/digest/expiry를 다시 검증한다. 3. HTTP decoder는 wire-specific shape를 검사하고, vault validator는 runtime invariant만 검사한다. decoder 로직을 통째로 중복하지 않는다. 테스트: malformed registration을 직접 issuer seam에 넣는 table test와, HTTP provider의 valid 결과가 동일 snapshot으로 등록되는 parity test를 추가한다. ### BT-PRE-05 — encoded path의 provider 해석 차이 - 우선순위/분류: **P2 / SECURITY_HARDENING** - 근거: `presigned-capability-http-provider.ts:517-533`, `:1078-1097` - 현재 동작: literal `.`/`..`와 backslash는 거절하지만 `%2f`, `%5c`, `%25...` 같은 encoded separator가 object-store/CDN에서 한 번 더 decode되는지 계약이 없다. - 결정: raw `URL.pathname`의 각 segment를 strict UTF-8 percent-decode한다. decoded segment에서 `/`, backslash, NUL, `.`/`..`, 그리고 literal `%` 뒤 두 hex digit을 거절한 뒤, 대문자 percent-hex canonical encoder 결과와 raw segment를 비교한다. 이 규칙은 `%252e%252e` double encoding을 닫고 valid opaque UTF-8 segment는 허용한다. CDN/provider conformance fixture가 같은 canonicalizer를 사용한다. - 테스트: `%2F`, `%5C`, `%252e%252e`, mixed-case encoding, valid UTF-8 opaque segment를 포함한다. ## Resumable upload 상세 ### BT-UP-01 — AbortSignal 구조 검증과 cleanup 사용이 불일치 - 우선순위/분류: **P2 / VERIFIED_DEFECT** - 근거: `fetch-json-transport.ts:548-582`, `:670-677` - 현재 동작: `isAbortSignal()`은 `aborted`와 `addEventListener`만 검사하지만 `FetchAttempt.release()`는 `removeEventListener()`를 무조건 호출한다. - 영향: 구조적으로 허용된 signal이 finally에서 throw하여 typed result 대신 Promise rejection을 만든다. - 수정: native getter 기반 또는 최소한 `removeEventListener`까지 포함한 공통 guard를 사용하고, release cleanup은 terminal result를 덮지 않도록 catch한다. - 테스트: remove가 없는 structural fake는 입력에서 `INVALID_INPUT`; remove가 cleanup 중 throw하는 hostile facade는 typed terminal result를 보존. ### BT-UP-02 — transport clock/scheduler가 전역에 고정됨 - 우선순위/분류: **P3 / REFACTOR** - 근거: `fetch-json-transport.ts:571-580`, `:626-636` - 현재 동작: request timeout은 global timer, HTTP-date `Retry-After`는 `Date.now()`를 직접 사용한다. - 결정: dependencies에 `clock.now()`와 `scheduler`를 추가하고 snapshot/validate한다. delta-seconds와 HTTP-date parsing은 같은 captured `now`를 사용한다. - 테스트: fake clock으로 경계값, clock rollback, invalid date, max clamp를 결정론적으로 검증. ### BT-UP-03 — `deleteDatabase()` timeout 뒤 late delete effect - 우선순위/분류: **P1 / VERIFIED_DEFECT** - 근거: `indexeddb-checkpoint-store.ts:375-439` - 현재 동작: `deletePartition()`은 `onblocked` 후 timer가 끝나면 `BLOCKED`를 반환한다. 그러나 IndexedDB delete request는 취소할 수 없고, 다른 tab이 닫히면 반환 이후 `onsuccess`로 실제 DB가 삭제될 수 있다. - 영향: caller가 `BLOCKED`를 `NOT_APPLIED`로 해석할 수 있지만 native request는 나중에 성공/실패할 수 있어 반환값과 effect certainty가 모순된다. 다른 realm의 open/delete ordering까지 현재 증거 없이 단정하지 않는다. 결정: delete dispatch 이후에는 failure certainty를 `NOT_APPLIED`로 표현하지 않는다. port outcome을 다음처럼 명시한다. ```ts type PartitionDeleteOutcome = | { state: "DELETED"; effect: "APPLIED" } | { state: "PENDING"; effect: "UNKNOWN"; reason: "BLOCKED_DEADLINE" }; ``` - pre-dispatch invalid/aborted/unsupported만 기존 failure다. - `PENDING`을 받은 runtime은 해당 store instance를 terminal closed로 유지한다. 같은 JS realm에서는 `(IDBFactory identity, databaseName)` pending-deletion registry가 새 factory 생성을 막고 late `onsuccess/onerror`에서 해제한다. 다른 realm은 native IndexedDB blocked ordering과 명시적 recovery UX로 처리하며 client-only global registry를 주장하지 않는다. - late `onsuccess`/`onerror`는 observer에 기록한다. 다시 확인하려면 별도 `inspectPartitionDeletion()` 또는 새 page generation에서 DB 목록/open 결과를 사용한다. - 단순히 timer를 제거해 무한 대기시키지는 않는다. 테스트 추가 (`tests/unit/resumable-upload-checkpoint.test.ts`): blocked deadline → PENDING → late success, blocked deadline → late error, PENDING 뒤 store method가 UNAVAILABLE, caller abort before dispatch, concurrent new runtime 금지. ### BT-UP-04 — bridge clock의 non-finite 값이 expiry 검사를 통과함 - 우선순위/분류: **P2 / VERIFIED_DEFECT** - 근거: `presigned-upload-part-executor.ts:36-70` - 현재 동작: `now()`가 `NaN` 또는 음수이면 expiry 비교를 우회한다. `+Infinity`는 현재도 expiry 비교에서 거절되지만 dependency failure가 capability policy failure로 잘못 분류된다. - 수정: `Number.isSafeInteger(nowEpochMs) && nowEpochMs >= 0`을 먼저 검사하고 실패 시 `UNAVAILABLE / RESUME`을 반환한다. - 테스트: NaN/음수의 현재 bypass, +Infinity의 현재 rejection, 수정 후 모든 non-finite/negative clock의 `UNAVAILABLE / RESUME`, throw, 만료 경계 `expiresAt === now`, 유효 `now + 1`. ### BT-UP-05 — runtime의 상태 전이와 side effect가 한 파일에 결합됨 - 우선순위/분류: **P2 / REFACTOR** - 근거: `resumable-upload-runtime.ts` 2,196줄; session resolution, retry, hashing, scheduler, CAS, abort saga, validation과 telemetry를 함께 소유 - 외부 facade는 유지하고 다음 내부 경계만 추출한다. | 새 내부 모듈 | 책임 | 적용 패턴 | | --- | --- | --- | | `upload-session-state-machine.ts` | ACTIVE/ABORT_PENDING/completed transition의 순수 함수 | State Machine | | `upload-session-reconciler.ts` | local checkpoint와 server status 수렴 | Reconciler | | `upload-part-scheduler.ts` | memory/server/client concurrency와 receipt serialization | Bounded Work Queue | | `upload-retry-executor.ts` | retry budget, Retry-After, jitter, attempt deadline | Policy + Template Method | | `upload-abort-saga.ts` | local tombstone → backend abort → checkpoint removal | Saga/Compensation | | `resumable-upload-runtime.ts` | public facade, lifecycle, mutation lock orchestration만 | Facade | 추출 순서: 1. 기존 `tests/unit/resumable-upload-runtime.test.ts`에 observable call-order characterization를 추가한다. 2. 순수 transition 함수와 table test를 먼저 만든다. 3. retry executor, reconciler, part scheduler, abort saga 순서로 한 모듈씩 이동한다. 4. 각 이동 뒤 기존 suite 전체를 그대로 실행한다. fixture expected 값을 리팩터링에 맞춰 바꾸지 않는다. 변경 금지: - part idempotency key derivation - server-authoritative status reconciliation - accepted receipt의 순차 CAS persistence - checkpoint에 URL/credential을 저장하지 않는 규칙 - first part failure 뒤 이미 시작한 sibling의 확정 receipt를 기다려 저장하는 현재 정책. 이를 즉시 cancel하면 remote success가 ambiguous해질 수 있으므로 별도 behavior change로 다룬다. ### BT-UP-06 — sync `close()`가 drain 완료를 증명하지 못함 - 우선순위/분류: **P2 / LIFECYCLE_REFACTOR** - 근거: `resumable-upload-runtime.ts:280-287` - 현재 동작: lifetime abort 직후 checkpoint store를 닫고 반환한다. native fetch/IDB가 signal에 반응해 정리될 것으로 기대하지만 caller는 active operation의 terminal settlement를 기다릴 수 없다. - 결정: application `ResumableUploadPort`의 `close(): void`는 admission을 닫고 같은 single-flight drain을 시작하는 호환 facade로 유지한다. adapter runtime lifecycle surface에 향후 composition owner가 `await`할 `dispose(): Promise`를 추가한다. `dispose()`는 이미 시작된 drain promise를 공유하고 active operation registry를 abort한 뒤 bounded `allSettled` 후 store/channel을 닫는다. 현재 production bootstrap consumer가 있다고 가정하지 않는다. - 테스트: close 중 신규 admission 거절, active fetch/IDB abort, 중복 dispose single-flight, cleanup deadline, late provider success가 checkpoint를 다시 쓰지 못함. ### BT-UP-07 — Web Locks 비지원 정책이 composition 결과로 표현되지 않음 - 우선순위/분류: **P1 before composition / PLANNED_GAP** - 근거: `upload-mutation-lock.ts:19-58`; 아키텍처 completion ledger의 optional capability decision - 현재 동작: factory는 LockManager가 없으면 throw한다. multi-tab 안전성을 희생하는 in-memory fallback은 없다. - 결정: silent fallback은 추가하지 않는다. composition이 Web Locks 미지원 시 resumable upload capability를 `UNSUPPORTED`로 명시하고 일반 foreground upload 또는 재선택 UX로 degrade한다. 실제 지원 browser matrix가 확정되기 전 default composition에는 설치하지 않는다. ## Image CDN 상세 ### BT-IMG-01 — resolve signal을 일관되게 필수화할지에 대한 API 단순화 - 우선순위/분류: **P3 / API CONSISTENCY DECISION**, 현재 동작 결함 아님 - 근거: application port `image-cdn.ts:167-172`; runtime `image-cdn-runtime.ts:518-527` - 현재 동작: optional signal을 허용하고 `PRIMARY_REQUIRED` preset은 signal 부재를 명시적 `UNSUPPORTED`로 표현한다. 문서가 signal 없는 probe 성공을 약속하지 않으므로 defect는 아니다. - 결정: hidden preset precondition을 줄이기 위해 다음 major contract 정리에서 `resolve()` signal을 필수화한다. 이는 runtime correctness fix가 아니라 API consistency 개선이다. - 마이그레이션: `tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts`와 대응 typecheck script를 먼저 추가하고 모든 caller/README에 lifecycle signal을 전달한 뒤 port와 optional 분기를 바꾼다. P1/P2와 같은 PR에 섞지 않는다. ### BT-IMG-02 — Cache-Control quoted value parser가 malformed 값을 수락 - 우선순위/분류: **P2 / VERIFIED_DEFECT** - 근거: `browser-image-probe.ts:272-373` - 현재 동작: `rawValue.replace(/^"|"$/gu, "")`는 한쪽 quote만 있는 `max-age="60` 또는 `max-age=60"`도 숫자 `60`으로 만들 수 있다. - 영향: probe가 malformed cache policy를 immutable public response로 승인할 수 있다. - 수정: - comma split 전에 quote/escape-aware tokenizer를 사용해 quoted extension의 comma를 directive 경계로 취급하지 않는다. - quoted-string은 시작/종료 quote가 모두 있고 escape/control 문자가 유효할 때만 unquote한다. - numeric directives는 unquoted digits 또는 완전한 quoted digits만 허용한다. - private response는 `no-store`가 필수이며 `public`, `private`, `immutable`, `max-age`, `s-maxage`, `no-cache`, `must-revalidate`, `proxy-revalidate`가 함께 있으면 fail-closed한다. 문법상 유효한 unknown extension만 무시한다. - parser는 중복 directive를 계속 거절한다. - 테스트: unmatched quote, escaped quote, duplicate, comma-in-quoted extension, contradictory public/private directives, valid quoted max-age. ### BT-IMG-03 — acceptance, verification, URL projection의 응집도 분리 - 우선순위/분류: **P3 / REFACTOR** - 근거: `image-cdn-runtime.ts` 1,340줄 - facade와 WeakMap capability identity는 유지하고 다음 내부 모듈만 추출한다. | 새 내부 모듈 | 책임 | | --- | --- | | `image-asset-decoder.ts` | public/private exact shape snapshot | | `image-capability-verification.ts` | canonical payload, digest, key verifier deadline | | `image-presentation-projector.ts` | candidate URL/srcset/descriptor 생성 | | `image-cdn-runtime.ts` | issued reference WeakMap, close, facade orchestration | `image-header-metadata.ts`는 format parser라는 단일 책임을 이미 가진다. LOC만 보고 더 쪼개지 말고 fuzz corpus와 malformed container table을 보강한다. ### BT-IMG-04 — descriptor provider/refresh는 아직 구현 대상 - 우선순위/분류: **P1 before composition / PLANNED_GAP** - 근거: `docs/architecture/presigned-transfer-and-image-cdn.md:574-610` - 현재 상태: signature 검증/runtime/probe는 있으나 BFF에서 descriptor를 가져오고 single-flight refresh하는 provider와 `` renderer는 없다. - 결정: 현재 runtime을 직접 product composition에 노출하지 않는다. 향후 provider는 `protocol: "IMAGE_CDN_DESCRIPTOR_V1"`, exact authority/request binding, minimum remaining TTL, single-flight refresh, close-generation fence를 필수로 한다. renderer는 descriptor 필드만 투영하고 alt/error/placeholder 정책은 feature 소유로 둔다. ## 공통 개선 ### BT-X-01 — abort/deadline/late-result mechanics 통합 - 우선순위: **P2 / REFACTOR** - 중복 근거: presigned provider/executor, image probe/runtime, resumable runtime, browser files, HTTP, Web Push에 `createAbortScope`, `combineAbortSignals`, `awaitWithAbort`, `readWithSignal` 변형이 반복된다. - 결정: result taxonomy는 각 adapter에 남기고 **mechanics만** `src/adapters/platform/abortable-operation.ts`로 추출한다. 필수 API: ```ts type AbortableOperationScope = Readonly<{ signal: AbortSignal; terminal(): "OPEN" | "CALLER_ABORT" | "DEADLINE" | "CLOSED"; race( task: Promise, onLateValue?: (value: T) => void, ): Promise< | { kind: "VALUE"; value: T } | { kind: "TERMINAL"; terminal: "CALLER_ABORT" | "DEADLINE" | "CLOSED" } >; close(): void; }>; ``` 불변식: - caller abort와 deadline 중 최초 하나만 terminal authority다. - `close()`는 idempotent하고 timer/listener cleanup throw를 삼킨다. - late rejection은 항상 관찰되어 unhandled rejection이 되지 않는다. - late `Response`/`ImageBitmap`/native handle은 caller가 제공한 compensator로 닫고 값을 버린다. 각 subsystem adapter가 `TERMINAL`을 자기 Result taxonomy로 변환한다. - 이 utility는 `BrowserDataResult`, `WebPushResult`, HTTP outcome을 import하지 않는다. 적용 순서: 새 utility golden test → presigned → image → resumable transport → 다른 adapter. 한 PR에서 모든 subsystem을 동시에 바꾸지 않는다. ## 유지해야 할 설계 - raw presigned URL/header가 application port를 통과하지 않고 identity capability vault 안에만 존재한다. - capability는 exact WeakMap identity이며 single-use claim 후 vault에서 제거된다. - upload byte는 hash/network await 전에 snapshot한다. - multipart checkpoint에는 URL, credential, capability material을 저장하지 않는다. - multipart receipt는 revision CAS로 순차 commit하고 server status가 복구 authority다. - cross-context cancellation은 hint일 뿐 backend idempotency/Web Lock/CAS를 대체하지 않는다. - public image는 revision rollover, private image는 signed expiry/revocation으로 구분한다. - private image는 exact signed URL, credential omit, no-store, static container와 decode budget을 확인한다. - composition hard limit은 adapter implementation ceiling보다 느슨해질 수 없다. - P-256 key overlap set과 terminal `close()` generation fence를 유지한다. ## 실행 순서와 의존성 1. `BT-UP-03`, `BT-PRE-01`, `BT-PRE-02`를 각각 독립 PR로 해결한다. 2. `BT-X-01` utility golden test를 만들고 `BT-PRE-03`, `BT-UP-01`, `BT-UP-02`를 이관한다. 3. `BT-UP-04`, `BT-IMG-01`, `BT-IMG-02`, `BT-PRE-04/05`를 작은 contract-hardening PR로 처리한다. 4. behavior suite가 모두 green인 뒤 `BT-UP-05/06`, `BT-IMG-03` 구조 분리를 수행한다. 5. 실제 product 선택이 있을 때만 `BT-UP-07`, `BT-IMG-04`를 composition plan으로 연다. 각 PR 공통 gate: ```bash corepack pnpm exec vitest run tests/unit/presigned-transfer.test.ts \ tests/unit/resumable-upload-checkpoint.test.ts \ tests/unit/resumable-upload-fetch-transport.test.ts \ tests/unit/resumable-upload-http-control-plane.test.ts \ tests/unit/resumable-upload-runtime.test.ts \ tests/unit/image-cdn-runtime.test.ts corepack pnpm check:types corepack pnpm check:architecture corepack pnpm lint git diff --check ``` 실제 browser gate도 capability promotion 전에 실행한다. ```bash corepack pnpm test:browser-capabilities -- \ tests/browser-capabilities/presigned-streaming.spec.ts \ tests/browser-capabilities/resumable-upload.spec.ts \ tests/browser-capabilities/image-cdn.spec.ts ``` 해당 browser/provider 환경이 없으면 이 gate는 `UNVERIFIED`로 남기며 capability availability를 승격하지 않는다. ## 구현 완료 정의 - 모든 P1 항목에 failing-before/fixed-after test가 있다. - wire version과 migration 순서가 provider fixture에 반영된다. - 어떤 timeout 경로도 non-cooperative Promise 때문에 public API를 무한 대기시키지 않는다. - delete partition 결과가 late native commit 가능성을 숨기지 않는다. - runtime facade의 public capability identity, failure taxonomy, persisted V1 schema는 명시된 migration 외에는 바뀌지 않는다. - 기존 문서의 `AVAILABLE_NOT_COMPOSED`/`PLANNED_GAP` 상태를 code defect 완료로 오인하지 않는다.