chore: sync the frontend template from 4dc033c to 8157ad4

The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 12:04:58 +09:00
co-authored by Claude Opus 5
parent 002ba3624e
commit 4bff9ca151
142 changed files with 23010 additions and 1544 deletions
@@ -0,0 +1,405 @@
# 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<BrowserDataResult<Uint8Array>>;
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<void>`를 추가한다. `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와 `<picture>` 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<T>(
task: Promise<T>,
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 완료로 오인하지 않는다.