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>
31 KiB
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 항목이 세 개 있다.
- presigned download는
open()에서 이미 fetch와 timeout을 시작하지만 반환된 source에는close()가 없다. 호출자가 stream을 늦게 열거나 열지 않으면 정상 API 사용만으로 body/timeout 자원이 방치된다 (BT-PRE-01). - IndexedDB checkpoint partition 삭제는
BLOCKED를 반환한 뒤에도 nativedeleteDatabase()가 늦게 commit될 수 있다. 반환 결과가 실제 effect certainty를 표현하지 못한다 (BT-UP-03). - 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를 복구할 수도 없다.
- 호출자가 source를 받은 뒤 stream 시작을 늦추면, 실제 consumer deadline이 아니라
결정: lazy, single-start lease로 변경한다. open()은 policy/vault 검증과 capability consume까지만 수행하고 fetch는 첫 stream(signal) 진입 시 시작한다. source에 close(): void를 추가해 미사용 lease도 명시적으로 폐기한다. close()와 stream의 first-start는 하나의 state machine을 공유한다.
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";
구현 규칙:
READY -> STREAMING만 fetch를 시작한다.READY -> CLOSED는 network I/O 없이 끝낸다.STREAMING -> CLOSED는 composed signal abort, reader/body cancel, timer/listener release를 한 번만 수행한다.- 두 번째
stream()은 기존처럼CONFLICT / REISSUE_CAPABILITY다. - digest 성공 전 chunk는 현재의
VERIFIED_ON_SUCCESSFUL_EXHAUSTION의미를 유지한다. consumer는 최종 success 전 파일을 commit하면 안 된다. - 첫
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 streamingcloses an unused source without issuing a requeststarts the transfer deadline at first stream consumptionclose during a pending read cancels the reader and releases listeners oncestream 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, retryablefalse, recoveryREISSUE_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, executorpresigned-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/clearTimeoutthrow 시 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를 적용한다.
createPresignedCapabilityVault()는{ issuer: PresignedCapabilityIssuer; consumer: PresignedCapabilityConsumer }를 반환한다. provider option에는 issuer만, executor option에는 consumer만 전달한다. root barrel에는 factory와 consumer-facing type만 export하고 issuer type은 provider의 구조적 parameter로 숨긴다.- issuer 등록 직전 공통
validatePresignedCapabilityRegistration()으로 method/binding/URL/header/status/bytes/digest/expiry를 다시 검증한다. - 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%252edouble 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은 같은 capturednow를 사용한다. - 테스트: 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을 다음처럼 명시한다.
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 생성을 막고 lateonsuccess/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.ts2,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 |
추출 순서:
- 기존
tests/unit/resumable-upload-runtime.test.ts에 observable call-order characterization를 추가한다. - 순수 transition 함수와 table test를 먼저 만든다.
- retry executor, reconciler, part scheduler, abort saga 순서로 한 모듈씩 이동한다.
- 각 이동 뒤 기존 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한 뒤 boundedallSettled후 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; runtimeimage-cdn-runtime.ts:518-527 - 현재 동작: optional signal을 허용하고
PRIMARY_REQUIREDpreset은 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.ts1,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:
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를 유지한다.
실행 순서와 의존성
BT-UP-03,BT-PRE-01,BT-PRE-02를 각각 독립 PR로 해결한다.BT-X-01utility golden test를 만들고BT-PRE-03,BT-UP-01,BT-UP-02를 이관한다.BT-UP-04,BT-IMG-01,BT-IMG-02,BT-PRE-04/05를 작은 contract-hardening PR로 처리한다.- behavior suite가 모두 green인 뒤
BT-UP-05/06,BT-IMG-03구조 분리를 수행한다. - 실제 product 선택이 있을 때만
BT-UP-07,BT-IMG-04를 composition plan으로 연다.
각 PR 공통 gate:
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 전에 실행한다.
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 완료로 오인하지 않는다.