Files
clean-architecture-frontend…/docs/reviews/adapters/03-storage-and-browser-files.md
T
DongHyeonkaandClaude Opus 5 4e87bacdf3 fix: enforce installed HTTP auth profiles
Install the REST auth profile registry once at composition and make it the
single transport authority for V3. Contract composition now rejects an
unregistered authProfileId, so the executor never resolves a profile at
runtime.

The credential collaborator contributes proof headers only: Fetch credentials
come from the resolved profile, transport-owned and forbidden headers are
rejected, headers outside the profile's allowed set are rejected, and a missing
required header fails closed as AUTH_INTEGRATION_FAILURE with zero fetch calls.
The final invariant re-proves credentials mode and the exact header sets.

Demo mode satisfies the strict bearer profile with a fixed non-secret marker
instead of weakening REFERENCE_EXTERNAL_BEARER. Credential owners now receive
the operation lifetime through AuthOperationContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 22:56:44 +09:00

566 lines
52 KiB
Markdown

# Storage / browser-file adapters 구현 준비 코드 리뷰
검토 저장소: `/home/donghyeon/workspace/desktop-server-git/clean-architecture-frontend-template`
검토 범위: `src/adapters/storage/**`, `src/adapters/browser-files/**`, `src/adapters/browser-file-storage/**`, `src/adapters/cache-storage/**` 및 직접 연결된 application port, contract, bootstrap, test, architecture/operations 문서
검토 방식: 구현 파일을 수정하지 않은 read-only 리뷰. 아래 line은 현재 worktree 기준이다.
## 0. 결론과 우선순위
| ID | 판정 | 심각도 | 확신도 | 요약 |
| --- | --- | --- | --- | --- |
| STO-01 | 확정 결함 | **Critical** | 높음 | OPFS pre-commit 보상 cleanup 실패/취소를 무시하고 journal을 rollback한다. 늦게 도착한 generation-only cleanup이 후속 write의 같은 logical generation을 삭제할 수 있고, 그렇지 않아도 복구 근거와 quota를 잃는다. |
| STO-02 | 확정 결함 | **High** | 높음 | browser-managed download는 `baseOrigin`으로 상대 URL을 검증하지만 원문 `href``document.baseURI`로 실행한다. `<base>`가 있으면 검증한 origin과 실제 navigation origin이 달라진다. |
| STO-03 | 확정 결함 | **Medium** | 높음 | public cache policy가 `allowedVaryHeaderNames`를 허용하면서 response allowlist에서 `vary`를 제거하는 모순을 허용한다. stage는 성공할 수 있지만 저장 variant가 충돌하고 activation이 실패한다. |
| STO-04 | 확정 결함 | **Medium** | 높음 | 동일 manifest 재-stage가 marker와 count만 신뢰한다. marker 작성 뒤 browser eviction/부분 손상된 candidate를 성공으로 재사용하여 self-heal하지 못한다. activation은 fail-closed지만 staging success 의미가 약해진다. |
| STO-05 | 확정 결함 | **Medium** | 높음 | cache `activateRelease`/`cleanupOwned`가 네트워크 fetch를 쓰지 않는데도 공통 availability guard가 `fetcher`를 필수로 요구한다. offline activation/rollback/cleanup이 불필요하게 `UNSUPPORTED`가 된다. |
| STO-06 | 확정 계약 위반 | **Medium** | 높음 | IndexedDB codec migration은 commit transaction 내부의 연속 native operation 사이에 monotonic deadline을 재확인하지 않는다. 문서/port의 cooperative duration contract보다 오래 실행될 수 있다. |
| STO-07 | hardening 후보 | **Medium** | 높음 | OPFS worker envelope에 protocol version/response kind가 없고 client response parser가 `{requestId, ok}`만 검사한다. page/worker release 불일치와 malformed response를 `INCOMPATIBLE`로 닫을 수 없다. |
| STO-08 | 브라우저 검증 필요 | **Low** | 중간 | enhanced open/save picker 함수를 `Window`가 아니라 options 객체에 bind한다. Web IDL brand check가 있는 engine에서는 `Illegal invocation` 가능성이 있으나 현재 unit fake는 이를 검증하지 않는다. 실제 browser test로 먼저 확정한다. |
| GAP-01 | 문서화된 미구현 | **High readiness gap** | 높음 | preview pixel/decoded-byte/frame/decode probe가 없다. VD-15가 이미 `DESIGNED_NOT_IMPLEMENTED`로 명시했으므로 regression으로 오인하지 말고, untrusted image preview 조립의 promotion blocker로 취급한다. |
| GAP-02 | 문서화된 미구현 | **High readiness gap** | 높음 | Cache inspect/cleanup은 cursor/count/deadline 없이 전체 owned namespace를 순회한다. VD-15가 정확히 현 상태를 기록한다. |
| GAP-03 | 문서화된 미구현 | **High readiness gap** | 높음 | origin-wide pressure/write-admission/GC, OPFS/Cache forward migration, real OPFS preflight가 아직 없다. 기존 per-store primitive를 완성 증거로 삼지 않는다. |
즉시 순서는 **STO-01 write 차단/수정 → STO-02 canonical URL 실행 → STO-03~05 cache 불변식 → STO-06/07 hardening**이다. GAP 항목은 해당 capability를 제품에 선택·조립하기 전에 별도 promotion gate로 구현한다.
## 1. 누락 없는 범위 inventory: 책임과 의존성
### 1.1 `browser-file-storage`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/browser-file-storage/index.ts` | browser data 공통 Result와 StorageManager adapter barrel export | 내부 두 모듈만 export. 경계가 작고 유지 대상. |
| `src/adapters/browser-file-storage/result.ts` | native 예외를 closed `BrowserDataFailure`로 정규화하고 안전한 observation 제공 | `application/ports/browser-file-storage/shared.ts`; raw path/name/message 비노출, observer 예외 격리가 좋다. |
| `src/adapters/browser-file-storage/storage-manager-adapter.ts` | `estimate/persisted/persist` snapshot, pressure bucket, user-activation-bound persistence 요청 | storage durability port/result. estimate를 예약량으로 오인하지 않고 irreversible `persist()` truth를 보존한다. origin coordinator는 의도적으로 없음(GAP-03). |
### 1.2 `browser-files`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/browser-files/browser-file-picker.ts` | native input baseline 및 enhanced system picker, activation/abort/dismissal, vault capture | file port, vault, policy registry. baseline/enhancement 분리가 좋다. `showOpenFilePicker.bind(options)`는 STO-08. |
| `src/adapters/browser-files/browser-file-policy-registry.ts` | composition-owned selection/inspection/preview/download policy 등록·identity 확인·hard-cap reduction | file contracts, `file-policy.ts`. `WeakSet`/identity binding과 frozen snapshot을 유지한다. |
| `src/adapters/browser-files/browser-file-vault.ts` | transient native File/handle 보관, opaque ref, inspection receipt, bounded range/source | file port/shared/result/policy registry. File이 application 경계를 넘지 않고 receipt가 exact file/profile에 묶이는 설계가 좋다. |
| `src/adapters/browser-files/create-browser-file-runtime.ts` | vault/picker/preview/download를 선택적으로 조립하고 일괄 dispose | 위 adapters 및 application contracts. optional capability를 제품 선택 없이 bootstrap에 암묵 조립하지 않는 점을 유지. preview 조립 전 GAP-01 gate 필요. |
| `src/adapters/browser-files/download-delivery-adapter.ts` | browser handoff, foreground save stream, bounded object URL download, integrity/progress/cancellation | file/authorized-download ports, policy registry, object URL lease, Result. STO-02와 STO-08; stream close truth/backpressure는 유지. |
| `src/adapters/browser-files/file-observer.ts` | file-safe observation DTO를 공통 browser observation으로 변환 | shared port/result. raw filename/ref 비노출 유지. |
| `src/adapters/browser-files/file-policy.ts` | policy input validation, MIME/extension/signature/hard byte caps, immutable resolved policy | file/shared contracts. closed allowlist 및 absolute ceiling을 유지. |
| `src/adapters/browser-files/index.ts` | browser-file public exports | 위 모듈. native implementation detail export 확장을 피한다. |
| `src/adapters/browser-files/object-url-lease.ts` | 중앙 object URL lease cap/registry, transient preview, idempotent revoke/dispose | file/shared contracts, vault, policy registry. URL lifecycle은 좋으나 `create()` 256-303은 decode probe 없이 URL을 발급(GAP-01). |
### 1.3 `cache-storage`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/cache-storage/index.ts` | public cache policy/adapter barrel | optional public static cache만 export; private/range cache로 일반화하지 않는다. |
| `src/adapters/cache-storage/public-cache-policy.ts` | same-origin/public-only release 정책, URL/header/query/size/retention hard limits | cache ports/shared. STO-03 policy cross-field invariant 누락. 기본 policy에는 `vary`가 있어 기본-path 테스트는 통과한다. |
| `src/adapters/cache-storage/public-response-cache-adapter.ts` | manifest canonicalization/digest, anonymous fetch, bounded body 검증, candidate marker-last staging, explicit activation, exact lookup/reverify, owned cleanup/inspect | cache ports/result/policy, CacheStorage/fetch/Crypto/Web Lock snapshot. STO-03~05 및 GAP-02. private/auth/opaque/206 거절과 current+previous 보존은 유지. |
### 1.4 `storage` root / IndexedDB
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/storage/browser-storage-adapter.ts` | registry key별 local/session/memory 저장, TTL, failure overlay/tombstone, quota fallback | `storage-keys`, `StoragePort`, codec, diagnostics. strict registry와 stale persistent suppression을 유지. adjacent physical-key migration/sweep는 문서상 미구현. |
| `src/adapters/storage/browser-storage-codec.ts` | bounded exact JSON envelope, exotic/accessor/unsafe-key/cycle/depth/node 거절 | 독립 codec. prototype pollution/JSON silent coercion 방어가 좋다. |
| `src/adapters/storage/indexeddb/index.ts` | IndexedDB runtime/maintenance/governance/migration export | native IDB type을 application port 밖으로 내보내지 않는 구조 유지. |
| `src/adapters/storage/indexeddb/indexeddb-failure.ts` | IDB/DOM failure를 closed browser failure로 변환 | common Result. raw native detail 비노출 유지. |
| `src/adapters/storage/indexeddb/indexeddb-governance.ts` | opaque dataset scope/physical DB identity 및 frozen policy binding | indexeddb/shared ports. account/business ID를 physical name에 쓰지 않는 양방향 binding 유지. |
| `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | post-open codec migration 및 idempotency receipt prune, keyset checkpoint, budget/revision fencing | IndexedDB port/types/failure/governance. async transform outside tx, row+sidecar+budget+checkpoint atomic commit은 좋다. STO-06 및 temporal drain lease 개선 후보. |
| `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | additive-only contiguous DDL planner/validator | indexeddb types. destructive DDL 거절 유지. |
| `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | generic repository open/read/query/CAS/delete, idempotency, retention, lifecycle purge, connection lifecycle | indexeddb ports/types/governance/failure/migrations. transaction `complete` truth, versionchange close, shared open/abort isolation, exact budgets 유지. lifecycle proof는 현재 문서 계약(형식 검증 후 폐기)과 일치하므로 결함으로 분류하지 않았다. |
| `src/adapters/storage/indexeddb/indexeddb-types.ts` | adapter-local codec/query/schema/dependency contracts | application indexeddb/shared ports. `isOldWriterDrainConfirmed()` boolean은 provider가 전체 window를 보장한다는 문서 전제; lease형으로 강화 권고. |
### 1.5 `storage/opfs`
| 파일 | 책임 | 주요 의존성 / 리뷰 결과 |
| --- | --- | --- |
| `src/adapters/storage/opfs/browser-opfs-runtime.ts` | OPFS support inspection 및 journal/worker/byte-store composition | OPFS ports, journal, byte-store, policy, worker client. property probe를 real readiness로 주장하지 않음(GAP-03). |
| `src/adapters/storage/opfs/index.ts` | OPFS runtime/journal/policy/protocol/client exports | optional capability barrel. |
| `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | logical object/journal/budget/chunk refcount의 IDB authority; begin/files-ready/commit/rollback/reconcile pages | OPFS ports, IDB failure, policy. journal+object+budget CAS atomicity가 좋다. STO-01 수정에서 incomplete row를 cleanup 확인 전 삭제하지 않아야 한다. |
| `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | logical journal과 physical worker를 saga로 조정, put/open/remove, reconcile/policy maintenance | OPFS/shared ports, journal, worker gateway, policy. STO-01의 journal/physical compensation ordering 결함 위치. |
| `src/adapters/storage/opfs/opfs-policy.ts` | root/lock/chunk/object/RPC/reconcile/GC hard limits 및 scope validation | shared/opfs ports. opaque physical path와 absolute caps 유지. |
| `src/adapters/storage/opfs/opfs-worker-client.ts` | request correlation/timeout/abort/transferable chunking, worker gateway, streamed reads | protocol/policy/shared Result. STO-01의 untracked abort cleanup 및 STO-07의 shallow response parse. |
| `src/adapters/storage/opfs/opfs-worker-protocol.ts` | page↔DedicatedWorker request/response union 및 gateway contract | OPFS/shared ports. STO-07; protocol version/kind/effect certainty 추가 필요. |
| `src/adapters/storage/opfs/opfs-worker-runtime.ts` | DedicatedWorker OPFS physical layout, lock lease, immutable chunk, manifest/staging receipt, abort/finalize/remove/GC | protocol/policy/OPFS ports/Web Lock/Crypto. STO-01의 generation-only cleanup과 lease release 순서. sync handle `finally close` 등은 유지. |
### 1.6 직접 연결 경계와 조립
- `src/application/ports/browser-file-storage/{shared,file,indexeddb-port,opfs-ports,cache-storage-ports,storage-durability-port}.ts`와 barrel을 읽었다. native `File/Blob/Cache/IDB*/Response/ReadableStream`을 application으로 노출하지 않는 포트 방향은 올바르다.
- `src/application/ports/storage-port.ts`, `src/contracts/storage-keys.ts`를 대조했다. Web Storage는 registry-owned typed key만 허용한다.
- `src/bootstrap/runtime-adapters.ts:17,260-266`은 Web Storage만 기본 조립한다. file/IndexedDB/OPFS/Cache가 없는 것은 문서의 `AVAILABLE_NOT_COMPOSED`와 일치하며 결함이 아니다.
## 2. 구체적 findings와 구현 방법
### STO-01 — OPFS 보상 cleanup이 journal보다 늦게 완료되거나 실패할 때 후속 generation 삭제 가능
**근거와 실패 연쇄**
1. 새 logical generation은 현재 committed generation+1로 재사용된다: `src/adapters/storage/opfs/opfs-byte-store-adapter.ts:158-195`(특히 183-195).
2. `preparePut` 또는 `markFilesReady` 실패 시 `rollbackBestEffort`를 호출한다: 같은 파일 `222-242`.
3. `rollbackBestEffort``worker.cleanupTransaction(..., callerSignal)``BrowserDataResult`를 검사하지 않고, 곧바로 `journal.rollback`을 호출한다: `833-845`. caller signal이 이미 abort되었으면 cleanup RPC는 시작조차 못 한다.
4. worker client도 prepare 단계 실패/timeout 때 별도의 un-signaled `ABORT_PUT`을 보내지만 timeout/실패를 삼키며 “journal reconciliation이 반복한다”고 가정한다: `src/adapters/storage/opfs/opfs-worker-client.ts:190-198,229-307`. 그런데 3번이 journal row를 삭제한다.
5. physical cleanup은 staging receipt에서 `(scope, objectId, generation)`만 읽어 해당 generation 디렉터리를 삭제한다: `src/adapters/storage/opfs/opfs-worker-runtime.ts:584-607,717-761`. manifest/path에 transaction-unique physical generation identity가 없다.
6. `abortPut`은 mutation lease를 먼저 release한 뒤 generation 삭제를 수행한다: 같은 파일 `501-529`(특히 519-527). `cleanupTransaction` 자체도 mutation lease를 얻지 않는다.
따라서 T1 cleanup RPC가 timeout 뒤 worker에서 계속되거나 T1 `ABORT_PUT`이 늦게 실행되는 동안 coordinator가 T1 journal을 rollback하면 T2가 같은 object의 동일 logical generation을 다시 시작할 수 있다. 늦은 T1 cleanup은 T2의 물리 디렉터리를 삭제할 수 있다. 삭제까지 겹치지 않아도 journal 부재로 stale staging/immutable chunks가 영구 잔존해 quota pressure를 만든다.
**패턴과 수정**
- cross-API ACID를 주장하지 말고 **durable saga + transactional outbox/compensation state**를 유지한다.
- “physical cleanup confirmed” 전에는 PREPARING/FILES_READY journal row와 budget reservation을 rollback하지 않는다. cleanup은 caller signal과 분리한 composition-owned bounded signal을 사용한다.
- worker client 내부에서 fire-and-forget abort를 중복 발행하지 않는다. coordinator가 `abortPreparedPut()` 한 번을 소유하고 결과가 `CLEANED|ALREADY_CLEAN`일 때만 journal rollback한다. timeout/crash는 `EFFECT_UNKNOWN`으로 남겨 reconcile한다.
- 장기적으로 **transaction-unique physical generation/fencing token**을 path, receipt, manifest, journal에 저장한다. stale T1 cleanup은 T1 token 경로만 삭제하고 T2를 건드릴 수 없어야 한다.
- cleanup/abort는 같은 origin mutation Web Lock을 physical 삭제 완료까지 보유한다. lease를 먼저 release하지 않는다.
**권장 새/변경 signature**
```ts
declare const opfsPhysicalGenerationBrand: unique symbol;
export type OpfsPhysicalGenerationId = string & {
readonly [opfsPhysicalGenerationBrand]: "OpfsPhysicalGenerationId";
};
export type OpfsPreparedObjectV2 = Readonly<{
physicalSchemaVersion: 2;
physicalGenerationId: OpfsPhysicalGenerationId;
descriptor: DurableObjectDescriptor; // logical generation은 그대로 유지
chunks: readonly OpfsChunkReference[];
}>;
export type OpfsCleanupEffect =
| Readonly<{ kind: "CLEANED" | "ALREADY_CLEAN" }>
| Readonly<{ kind: "EFFECT_UNKNOWN" }>;
export interface OpfsWorkerGateway {
abortPreparedPut(request: Readonly<{
scope: OpfsStorageScope;
transactionId: string;
physicalGenerationId: OpfsPhysicalGenerationId;
signal?: AbortSignal; // coordinator-owned compensation signal만 전달
}>): Promise<BrowserDataResult<OpfsCleanupEffect>>;
}
```
P0에서는 v1 read를 유지하면서 새 write만 v2/token path로 쓴다. `EFFECT_UNKNOWN`은 성공 Result로 취급하지 말고 journal 유지 + `OBJECT_RECONCILE`를 반환한다.
**기존 테스트와 false-positive 방지**
- `tests/unit/opfs-byte-store.test.ts:504-540`의 “keeps a committed journal row for reconciliation when cleanup fails”는 logical commit 뒤 finalize 실패만 검증한다. PREPARING/FILES_READY 보상 실패를 다루지 않는다.
- `tests/unit/opfs-worker-runtime.test.ts:232-408`은 BEGIN cancel/APPEND-vs-ABORT serialization/authority isolation을 검증하지만, journal rollback 뒤 다른 worker/context가 재사용한 generation에 대한 늦은 cleanup을 만들지 않는다.
- `indexeddb-opfs-journal.ts:997-1008`의 unique `logicalKey` index는 **journal row가 남아 있는 동안** T2를 막는다. 바로 그 row를 조기에 삭제하는 것이 문제이므로 이 index가 반증이 아니다.
### STO-02 — 검증 URL과 실제 download navigation URL의 base가 다름
**근거**
- `safeBrowserManagedTarget``new URL(href, new URL(baseOrigin))`으로 protocol/origin/query/hash를 검증한다: `src/adapters/browser-files/download-delivery-adapter.ts:1248-1269`.
- 성공 후 canonical `URL.href`가 아니라 원문 문자열을 host로 넘긴다: `435-459`.
- 실제 anchor는 `anchor.href = href`라서 document의 current `baseURI`를 기준으로 해석한다: `47-68`.
예: configured `baseOrigin=https://app.example`, capability `href="downloads/report"`, document에 `<base href="https://evil.example/">`가 있으면 검증은 app origin을 통과하지만 실제 anchor는 evil origin으로 향한다. capability receipt의 server binding이 있더라도 adapter의 same-origin 정책 주장이 깨진다.
**패턴과 수정**
- **Parse once / canonicalize then execute** 패턴을 적용한다. validator가 boolean이 아니라 canonical absolute URL을 반환하고 정확히 그 값을 handoff한다.
- cross-origin을 허용하는 별도 policy에서도 username/password/hash/query 규칙을 적용한 canonical string만 실행한다.
```ts
type ResolvedBrowserManagedTarget = Readonly<{ absoluteHref: string }>;
function resolveBrowserManagedTarget(
href: string,
baseOrigin: string,
policy: Readonly<{ allowCrossOrigin: boolean; allowQuery: boolean }>,
): BrowserDataResult<ResolvedBrowserManagedTarget>;
```
`context.options.host.handoff(target.value.absoluteHref, fileName)`로 변경한다. 더 엄격한 선택은 capability resolver가 absolute `https:` URL만 발행하게 하고 상대 URL을 거절하는 것이다.
**기존 테스트 대조**
- `tests/unit/browser-file-download.test.ts:222-255`는 raw 상대 path가 host에 그대로 전달된다고 고정한다. 이 기대값을 canonical `https://app.example/downloads/artifact-1`로 바꿔야 한다.
- `257-285`는 이미 absolute evil/query URL 거절만 검증해 `<base>` 불일치를 잡지 못한다.
### STO-03 — Vary 허용/보존 policy가 모순될 수 있음
**근거**
- policy validation은 vary name이 request allowlist에 포함되는지만 본다: `src/adapters/cache-storage/public-cache-policy.ts:110-141`, 특히 `132-134`. response allowlist에 `vary`가 있는지는 확인하지 않는다.
- network response의 Vary는 exact request headers와 검증한다: `src/adapters/cache-storage/public-response-cache-adapter.ts:1140,1228-1262`.
- 이후 `unknownResponseHeaderAction="STRIP"`이면 response allowlist에 없는 `vary`를 제거하고(`1264-1280`), 제거된 headers로 Cache에 put한다(`472-479`). 동일 URL variant가 충돌한다.
- activation은 모든 entry를 다시 digest/type/Vary 검증하므로 `592-617`에서 fail-closed한다. 따라서 현재 증거로 private-data disclosure를 주장하면 과장이다. 실제 영향은 impossible candidate에 대한 stage 성공, variant loss, activation/rollback availability 저하다.
**수정**
```ts
if (
policy.allowedVaryHeaderNames.length > 0 &&
!policy.allowedResponseHeaderNames.includes("vary")
) throw new TypeError("Vary must be preserved when variants are enabled.");
```
방어를 겹치려면 `sanitizedResponseHeaders`가 검증된 `Vary`를 generic strip과 무관하게 반드시 보존하도록 한다. **Policy cross-field invariant + fail-fast composition** 패턴이다.
`tests/unit/public-response-cache.test.ts:1030-1155`는 default response allowlist가 이미 `vary`를 포함(`public-cache-policy.ts:54-63`)하므로 이 custom-policy 조합을 놓친다.
### STO-04 — existing cache marker만 확인하는 stage idempotence
`src/adapters/cache-storage/public-response-cache-adapter.ts:424-442`는 cache name이 있고 marker의 release ID/digest/count가 맞으면 모든 cached response의 존재/내용을 보지 않고 stage 성공을 반환한다. marker-last는 첫 stage crash에는 강하지만 marker 이후 browser pressure eviction, manual deletion, partial corruption에는 충분하지 않다. activation이 `592-617`에서 재검증하므로 unsafe publish는 막지만, 같은 manifest로 restage해도 손상 candidate를 복구하지 못한다.
**수정:** `verifyReleaseCandidate(cache, normalized, policy, crypto, signal)`를 factor하고 stage fast path와 activation이 공유한다. 기존 candidate가 missing/mismatch면 owned candidate만 삭제하고 network restage한다. verification 중 abort/unknown error면 active pointer는 건드리지 않고 candidate를 유지 또는 정책대로 삭제하되 성공을 반환하지 않는다. 이는 **idempotent repair, marker as claim not evidence** 패턴이다.
### STO-05 — cache mutation availability가 fetcher에 과결합
`mutationAvailability`는 storage+fetcher+lock 모두를 요구한다: `public-response-cache-adapter.ts:1643-1651`. stage 호출 `392-396`에는 맞지만, fetch하지 않는 activate `538-542`와 cleanup `695-699`에도 같은 guard를 쓴다. 이미 검증된 release를 offline에서 활성화/rollback하거나 quota recovery cleanup하는 기능을 차단한다.
**수정:** operation별 capability guard로 분리한다.
```ts
function stageAvailability(d: Dependencies): BrowserFailureResult | null;
// cacheStorage + mutationLock + fetcher
function localMutationAvailability(
d: Dependencies,
operation: "CACHE_ACTIVATE" | "CACHE_DELETE",
): BrowserFailureResult | null;
// cacheStorage + mutationLock
```
**Dependency segregation**을 적용하고 recovery도 `ONLINE_ONLY`가 아니라 실제 operation에 맞는 `RETRY/REHYDRATE`로 유지한다.
### STO-06 — IndexedDB migration commit 중 duration budget 재확인 없음
- port는 async storage operation 사이 cooperative duration budget을 명시한다: `src/application/ports/browser-file-storage/indexeddb-port.ts:81-89`.
- docs도 각 native operation 사이 monotonic deadline 확인을 요구한다: `docs/architecture/browser-file-and-origin-storage.md:611-615`.
- transform phase는 clock을 확인한다: `src/adapters/storage/indexeddb/indexeddb-maintenance.ts:940-966`.
- 그러나 `commitPrepared`의 read/write/budget/sidecar/checkpoint chain은 `969-1233` 동안 clock을 호출하지 않는다. 최대 500 rows의 IDB callbacks가 invocation deadline 이후에도 계속될 수 있다.
**수정:** transaction을 시작하기 전 composition-owned `minimumCommitReserveMs`를 확인하고, prepared row 수를 budget에 맞춰 더 작게 제한한다. transaction을 연 뒤에는 각 record 시작 시 monotonic deadline을 확인하여 아직 어떤 write도 시작하지 않은 다음 record에서 transaction을 정상 종료하고 last-safe checkpoint까지만 commit한다. 이미 시작한 record의 row/sidecar/budget은 원자 완료하거나 tx 전체 abort해야 하며 부분 truth를 반환하면 안 된다. clock failure는 transaction abort + `UNAVAILABLE`다.
`tests/unit/indexeddb-maintenance.test.ts:562-597`은 transform 시작 전 budget exhaustion만 검증하므로 commit callback 중 clock advance 케이스를 추가한다.
### STO-07 — OPFS worker protocol version/strict response correlation 부재
- request/response envelope에 `protocolVersion`과 echoed `kind`가 없다: `src/adapters/storage/opfs/opfs-worker-protocol.ts:15-137`.
- worker는 requestId+known kind만 1차 검사한다: `opfs-worker-runtime.ts:1643-1669`.
- client는 `{requestId:string, ok:boolean}`만 검사한다: `opfs-worker-client.ts:666-675`. 실패 object/failure code/kind를 strict validate하지 않고 `response.failure.code`를 사용(`171-184`)한다.
- VD-15는 real preflight에서 protocol/schema mismatch를 `INCOMPATIBLE`로 닫으라고 한다: `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md:476-484`.
**수정:** `OPFS_WORKER_PROTOCOL_VERSION = 2 as const`; 모든 request/response에 version과 kind를 넣고 pending request가 expected kind를 보관한다. closed failure-code set과 per-kind value parser를 적용한다. 먼저 `HELLO/CAPABILITIES` handshake에서 supported physical schema와 protocol version을 교환하고 mismatch면 write/read를 금지한다. generic cancel은 초기 correctness 필수가 아니다. PUT은 effect certainty가 필요한 명시적 `ABORT_PUT`; read/verify RPC는 client-side abandon으로 충분하며, 자원 최적화가 필요할 때만 `CANCEL_REQUEST { targetRequestId }`를 추가한다.
### STO-08 — picker function receiver binding은 browser test로 먼저 확정
- open: `src/adapters/browser-files/browser-file-picker.ts:431-436`
- save/open-authorized callbacks: `src/adapters/browser-files/download-delivery-adapter.ts:201-235`
platform `Window.showOpenFilePicker/showSaveFilePicker`를 options object에 bind할 이유가 없고 Web IDL receiver brand check 가능성이 있다. 다만 현재 코드가 host facade 콜백을 의도했을 수도 있어 확정 전 browser matrix가 필요하다. 우선 실제 `window.showOpenFilePicker`를 전달한 capability test를 추가한다. 실패가 재현되면 API를 `SystemPickerHost { open; save? }`로 만들고 composition에서 올바른 owner에 bind한 host만 주입한다. arbitrary callback(`openAuthorizedSource`, integrity factory)은 bind하지 않고 함수 snapshot 그대로 호출한다.
## 3. 명시적 architecture 결정
### Transaction / crash recovery
- IndexedDB 한 domain mutation은 한 native transaction으로 row, retention sidecar, budget, idempotency receipt/checkpoint를 commit한다. request success가 아니라 transaction `complete`가 성공 truth다.
- IDB와 OPFS/Cache 사이에는 atomic transaction이 없다. OPFS는 journal-authoritative durable saga다. phase는 monotonic이고 physical side effect가 불명확하면 incomplete journal을 유지한다.
- compensation은 원 caller abort와 분리된 bounded signal로 실행한다. cleanup success가 확인될 때만 journal/budget rollback; unknown이면 reconcile owner에게 넘긴다.
- committed object를 in-place repair하지 않는다. 새 physical token/generation에 copy/verify 후 logical CAS publish한다.
### Migration / rollback
- 독립 version 축(IDB DDL, record codec, OPFS journal, OPFS physical, Cache control/release)을 합치지 않는다.
- 공통 순서는 expand → old-writer drain lease → bounded migrate/copy → atomic publish → N-1 observe/rollback window → 별도 contract release다.
- schema downgrade, whole DB/root/cache delete, read-time unbounded rewrite는 금지한다.
- IDB `isOldWriterDrainConfirmed()`는 현재 provider가 전체 migration/contract window를 보장한다는 문서 전제라 현 결함은 아니다. 다음 interface로 temporal guarantee를 실행 가능하게 강화한다:
```ts
export interface OldWriterDrainLease {
readonly leaseId: string;
readonly validUntilEpochMs: number;
assertValid(signal?: AbortSignal): Promise<BrowserDataResult<void>>;
release(): Promise<void>;
}
export interface IndexedDbDataMigrationPolicy<WireValue> {
acquireOldWriterDrainLease(input: Readonly<{
migrationId: string;
targetCodecVersion: number;
scope: IndexedDbDatasetScope;
signal?: AbortSignal;
}>): Promise<BrowserDataResult<OldWriterDrainLease>>;
// migrate/measure 기존 계약 유지
}
```
lease는 batch commit 직전 재검증하고, product rollout owner는 migration 완료 후 rollback/contract window까지 global fence를 유지한다.
### Quota / pressure / eviction
- StorageManager estimate는 rough signal일 뿐 free-space reservation이 아니다. 실제 `QuotaExceededError`가 authority다.
- per-dataset hard budget은 그대로 유지하고, origin coordinator는 Web Lock leader 한 개가 hysteresis(`70/85%`, 하향 `65/80%` 2회)를 적용한다.
- GC 순서: incomplete candidate/stale staging → expired reconstructable → grace 지난 unreferenced chunk → inactive public release → confirmed synced copy → 중지. user-authored/unsynced는 자동 삭제 금지.
- 기본 invocation 100 items/5s, 절대 500/30s. cursor는 owner/policy/release epoch에 binding한다.
- quota retry는 실제 quota rollback, 동일 idempotency/revision/digest, external publish 없음, GC가 실제 제거/pressure 하향, 새 admission token 조건을 모두 만족할 때 정확히 1회만 허용한다.
### Lease / destructive authority
- OPFS mutation Web Lock은 physical delete/cleanup 완료까지 보유한다. transaction-unique physical token이 stale cleanup fencing이다.
- object URL은 registry lease로만 만들고 persistence/log/analytics/global cache에 넣지 않는다. release/dispose는 idempotent다.
- IndexedDB lifecycle authority는 현재 문서대로 composition callback의 short-lived proof를 형식 검증 후 즉시 폐기한다. OPFS와 동일한 replay 방지가 제품 threat model에 필요하면 provider+atomic consumer의 one-shot lease로 별도 강화하되 application caller에게 token을 노출하지 않는다.
### Object URL / preview
- 현재 encoded size/signature/media/active-content denylist는 유지한다.
- 제품 untrusted image preview를 선택하기 전 object URL 발급 **앞**에 bounded header parser + native decode probe를 둔다. static JPEG/PNG/WebP/AVIF 등 명시 allowlist만; SVG/PDF/HTML/XML과 animated image는 별도 격리/re-encode capability가 없으면 attachment-only다.
```ts
export interface PreviewSafetyProbe {
inspect(input: Readonly<{
file: File; // adapter-local only
mediaType: string;
maxEncodedBytes: number;
maxPixels: number;
maxDecodedBytes: number;
maxFrames: number;
deadlineMs: number;
signal: AbortSignal;
}>): Promise<BrowserDataResult<Readonly<{
width: number;
height: number;
frameCount: number;
decodedBytes: number;
}>>>;
}
```
parser 산술은 overflow-safe여야 하고 native `createImageBitmap` 결과는 항상 `close()`. timeout/abort/failure면 `createObjectURL`을 호출하지 않는다.
### Stream / cancellation
- application boundary는 `ByteSource.stream(signal): AsyncIterable<BrowserDataResult<Uint8Array>>`를 유지한다. 첫 failure에서 producer/reader/writer를 모두 닫고 raw DOMException/EOF 성공으로 바꾸지 않는다.
- save stream은 backpressure를 따르고 `writer.close()` 완료 truth가 늦은 abort보다 우선한다. partial destination append/resume로 주장하지 않는다.
- Blob/object URL buffer는 hard cap 아래 fallback에서만 허용한다. public cache는 exact length/digest 검증 때문에 bounded buffer를 유지하되 cap을 넘으면 reader cancel.
- pre-start abort는 side effect 0. IDB 중간 abort는 tx abort. irreversible prompt/persist/close가 완료된 뒤에는 platform truth가 이긴다.
- worker mutation timeout은 effect unknown이지 rollback 확인이 아니다. read RPC는 응답을 버릴 수 있지만 mutation은 journal/explicit abort protocol로 종결한다.
### Worker protocol
- versioned handshake, request kind echo, requestId+kind correlation, strict discriminated parser, closed error set을 채택한다.
- wrong version/schema는 `INCOMPATIBLE` health로 write/read 금지. 이를 failure surface에 노출할 필요가 있으면 `BrowserDataFailureCode``INCOMPATIBLE`을 추가하고 모든 exhaustive mapper/fixture를 함께 갱신한다. 단순 `UNAVAILABLE` retry loop로 숨기지 않는다.
- generic `CANCEL_REQUEST`는 read CPU/resource 최적화로 후순위. PUT correctness는 transaction-scoped `ABORT_PUT`과 durable journal이 담당한다.
### Cache security / eviction
- anonymous same-origin public GET, credentials omit, exact query/request headers/Vary/type/length/digest만 cache한다. auth/private/no-store/no-cache/opaque/redirect/206/range는 계속 금지한다.
- verified marker는 모든 entries 이후 마지막에 쓰되 marker만 증거로 믿지 않는다. stage reuse와 activation/lookup에서 response를 재검증한다.
- current+verified previous release를 유지하고 rollback도 동일 activation validation을 다시 통과한다.
- partial eviction/miss는 `STORAGE_EVICTED` 또는 integrity failure로 fail-closed하고 network rehydrate한다. owned prefix 밖 cache나 user data는 절대 삭제하지 않는다.
## 4. 정확한 파일 변경 계획
### Phase 0 — 즉시 correctness/security fix
**수정**
- `src/application/ports/browser-file-storage/opfs-ports.ts`: v1|v2 prepared object read union, `OpfsPhysicalGenerationId`, journal row physical identity.
- `src/adapters/storage/opfs/opfs-worker-protocol.ts`: explicit abort/cleanup effect, protocol v2 envelope/kind correlation.
- `src/adapters/storage/opfs/opfs-worker-client.ts`: fire-and-forget duplicate abort 제거, strict response parser, coordinator-owned confirmed abort.
- `src/adapters/storage/opfs/opfs-worker-runtime.ts`: tokenized physical path/receipt/manifest, cleanup lock 보유, exact token delete.
- `src/adapters/storage/opfs/opfs-byte-store-adapter.ts`: cleanup result 확인 전 journal rollback 금지; independent compensation deadline; unknown effect reconcile.
- `src/adapters/storage/opfs/indexeddb-opfs-journal.ts`: v2 prepared/journal validation, incomplete row 유지 및 migration metadata.
- `tests/unit/opfs-byte-store.test.ts`, `tests/unit/opfs-worker-runtime.test.ts`, `tests/unit/indexeddb-opfs-journal.test.ts`: 아래 race/crash tests.
- `src/adapters/browser-files/download-delivery-adapter.ts`: boolean validator를 canonical resolver로 변경; absolute URL 실행.
- `tests/unit/browser-file-download.test.ts`: canonical URL 및 hostile base regression.
- `src/adapters/cache-storage/public-cache-policy.ts`: Vary preservation cross-field invariant.
- `src/adapters/cache-storage/public-response-cache-adapter.ts`: stage candidate full verify/self-repair, availability 분리.
- `tests/unit/public-response-cache.test.ts`: custom Vary, damaged candidate, no-fetcher activate/cleanup.
### Phase 1 — bounded lifecycle / protocol / preview promotion
**생성**
- `src/application/ports/browser-file-storage/origin-storage-lifecycle-port.ts`
- `src/adapters/storage/origin-storage-lifecycle-coordinator.ts`
- `tests/unit/origin-storage-lifecycle-coordinator.test.ts`
- `src/adapters/browser-files/browser-image-preview-probe.ts`
- `tests/unit/browser-image-preview-probe.test.ts`
- `src/adapters/storage/opfs/opfs-physical-migration.ts`
- `tests/unit/opfs-physical-migration.test.ts`
- `tests/fixtures/origin-storage/opfs-v1-populated.ts`
- `tests/fixtures/origin-storage/cache-v1-populated.ts`
**수정**
- `src/application/ports/browser-file-storage/index.ts`: 새 lifecycle port export.
- `src/application/ports/browser-file-storage/file.ts`: preview safety policy/result를 native-free 형태로 추가하거나 probe를 adapter-internal dependency로 유지.
- `src/application/ports/browser-file-storage/cache-storage-ports.ts`: bounded maintenance page/cursor input.
- `src/application/ports/browser-file-storage/indexeddb-port.ts`, `src/adapters/storage/indexeddb/indexeddb-types.ts`: drain lease contract.
- `src/adapters/storage/indexeddb/indexeddb-maintenance.ts`: commit reserve/deadline checks 및 lease revalidation.
- `src/adapters/browser-files/object-url-lease.ts`, `src/adapters/browser-files/create-browser-file-runtime.ts`: probe success 전 URL 생성 금지.
- `src/adapters/cache-storage/public-response-cache-adapter.ts`: cursor/deadline bounded inspect/cleanup.
- `src/adapters/storage/opfs/browser-opfs-runtime.ts`: real worker/lock/journal/write-read-delete-cleanup preflight 조립 hook.
- `tests/browser-capabilities/{browser-files,opfs-runtime,public-cache-storage,indexeddb-runtime}.spec.ts``opfs-test.worker.ts`: real engine evidence.
- `docs/architecture/browser-file-and-origin-storage.md`, `docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md`, `docs/operations/browser-file-storage-recovery.md`, `docs/operations/client-cache-and-storage-recovery.md`: 상태를 구현 후에만 `AVAILABLE_NOT_COMPOSED`로 승격.
**삭제/이동**: 없음. v1 reader/fixtures와 old cache prefix는 rollback window 종료 전 삭제하지 않는다. barrel 재배치도 불필요하다.
### Cache bounded port signature
```ts
declare const publicCacheCursorBrand: unique symbol;
export type PublicCacheMaintenanceCursor = string & {
readonly [publicCacheCursorBrand]: "PublicCacheMaintenanceCursor";
};
export type PublicCacheMaintenanceInput = Readonly<{
maxCaches?: number; // default 100, absolute 500
maxDurationMs?: number; // default 5_000, absolute 30_000
cursor?: PublicCacheMaintenanceCursor;
signal?: AbortSignal;
}>;
export type PublicCacheMaintenancePage = Readonly<{
inspectedCaches: number;
deletedCaches: number;
retainedCaches: number;
unreadableCaches: number;
nextCursor: PublicCacheMaintenanceCursor | null;
moreAvailable: boolean;
deadlineReached: boolean;
}>;
cleanupOwned(input?: PublicCacheMaintenanceInput):
Promise<BrowserDataResult<PublicCacheMaintenancePage>>;
inspectOwned(input?: PublicCacheMaintenanceInput):
Promise<BrowserDataResult<PublicCacheMaintenancePage>>;
```
cursor는 caller-readable cache name이 아니며 owned prefix, active pointer epoch, policy fingerprint에 서명/opaque binding한다. stale cursor는 `STALE_RESULT`.
## 5. TDD 계획: 이름, 입력, 기대 결과
| 테스트 이름 | 핵심 입력/fixture | 기대 결과 |
| --- | --- | --- |
| `keeps PREPARING journal when compensating cleanup is aborted or unavailable` | `preparePut` failure; caller signal aborted; worker cleanup `ABORTED/UNAVAILABLE` | `journal.rollback` 미호출, reservation/journal 유지, `OBJECT_RECONCILE` recovery; 후속 same object begin conflict |
| `delayed stale cleanup cannot delete a reused logical generation` | T1 generation 1 abort RPC 지연; T2 generation 1 v2 token으로 commit; T1 cleanup resume | T1 token path만 제거; T2 verify/open bytes 성공; T2 manifest/chunks 유지 |
| `holds the OPFS mutation lease until exact physical cleanup completes` | cleanup delete promise를 gate하고 concurrent begin 시도 | delete 완료 전 T2 lease 미획득; release 후 진행 |
| `does not roll back journal after an unknown worker mutation effect` | cleanup RPC timeout 후 worker operation pending | incomplete journal 유지; reconcile가 exact transaction을 종결 |
| `rejects mismatched OPFS worker protocol and response kind` | v1 response 또는 requestId는 같지만 wrong kind/malformed failure | `INCOMPATIBLE`/closed failure; pending request success로 resolve하지 않음; write side effect 0 |
| `hands off the canonical URL validated against baseOrigin` | `href="downloads/a"`, baseOrigin app, document base evil | host receives `https://app.example/downloads/a`; evil URL never assigned |
| `rejects a policy that enables variants but strips Vary` | allowed vary `accept-language`, allowed response headers without `vary`, STRIP | composition `TypeError`, Cache/fetch side effect 0 |
| `preserves Vary for every stored custom variant` | en/ko same URL with exact request header | stage+activate+both exact match succeed; stored response has Vary |
| `restages an evicted entry even when the release marker remains` | successful stage 후 one asset delete, same manifest stage again | missing asset re-fetch; all entries reverify; success only after repair |
| `activates and cleans a prestaged cache without a fetcher` | seeded valid cache/pointer, cacheStorage+lock, no fetcher | activate/cleanup success; no network call |
| `stops codec migration commit at the cooperative deadline` | fake clock advances during IDB record callbacks, prepared N rows | only last atomically safe prefix+checkpoint commit; `MORE`, `budgetExhausted`; no orphan sidecar/budget delta |
| `requires an old-writer drain lease to remain valid before batch commit` | lease valid at acquire, expires before commit | tx write 0/abort; `BLOCKED`; checkpoint unchanged |
| `rejects oversized raster dimensions before object URL creation` | small encoded PNG with huge width/height or overflow dimensions | `LIMIT_EXCEEDED/POLICY_REJECTED`; `createObjectURL` 0 calls |
| `closes a decoded bitmap on preview abort and failure` | probe aborts after native decode begins | bitmap `close` once, URL 0, closed `ABORTED` |
| `rejects animated and truncated preview containers` | animated WebP/GIF, truncated PNG/JPEG | fail before URL, no leaked decoder resource |
| `pages cache cleanup by count deadline and opaque cursor` | 700 owned caches + foreign caches; max 100/5s | <=100 inspected, foreign untouched, `moreAvailable`, bound cursor; repeated pages converge |
| `rejects cache maintenance cursor after active pointer epoch changes` | page1 cursor 후 activation | `STALE_RESULT`, delete 0 |
| `retries quota failure exactly once only after productive GC` | reconstructable write quota fail, GC deleted >0, same idempotency/digest | attempt 2 최대 한 번; second fail no third; user-authored untouched |
| `uses the real Window receiver for enhanced system pickers` | actual browser `window.showOpenFilePicker/showSaveFilePicker` facade (feature-gated) | supported engine에서 illegal invocation 없음; dismissal closed outcome |
### 실행 명령
```bash
# 가장 빠른 red/green loop
corepack pnpm exec vitest run \
tests/unit/opfs-byte-store.test.ts \
tests/unit/opfs-worker-runtime.test.ts \
tests/unit/indexeddb-opfs-journal.test.ts \
tests/unit/browser-file-download.test.ts \
tests/unit/public-response-cache.test.ts \
tests/unit/indexeddb-maintenance.test.ts \
tests/unit/browser-image-preview-probe.test.ts \
tests/unit/origin-storage-lifecycle-coordinator.test.ts
# 정적 경계
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm check:browser-file-storage-boundaries
corepack pnpm lint
# 실제 browser/storage semantics
corepack pnpm exec playwright test --config playwright.capabilities.config.ts \
tests/browser-capabilities/browser-files.spec.ts \
tests/browser-capabilities/indexeddb-runtime.spec.ts \
tests/browser-capabilities/opfs-runtime.spec.ts \
tests/browser-capabilities/public-cache-storage.spec.ts \
tests/browser-capabilities/storage-manager.spec.ts
# 전체 회귀
corepack pnpm test:unit
corepack pnpm test:browser-file-storage-removal
corepack pnpm verify:documentation
```
## 6. 데이터 호환성, migration, deployment, rollback 순서
1. **즉시 containment:** 제품에 OPFS v1 write가 조립돼 있다면 kill switch로 신규 write를 read-only/export-required로 전환한다. read/export와 journal reconcile는 유지한다. file/IDB/OPFS/cache가 template bootstrap 기본 조립이 아니라는 사실은 영향 범위를 줄이지만 product-specific composition을 확인해야 한다.
2. **N expand release:** journal DDL을 additive upgrade하고 v1+v2 `OpfsPreparedObject` reader를 배포한다. worker protocol v2 handshake를 먼저 넣되 v1 data read는 지원한다. v2 physical path는 unique token을 포함하고 새 write만 v2로 쓴다.
3. **old writer drain:** 모든 N-1 page/worker가 write를 중단했다는 release/lease evidence를 확인한다. BroadcastChannel hint만으로 판단하지 않는다. v2 write traffic은 SHADOW/canary부터 연다.
4. **resume/reconcile:** PREPARING/FILES_READY v1 journal을 bounded하게 처리한다. cleanup effect가 불명확하면 row를 삭제하지 않는다. logical committed v1은 authority이며 in-place 수정하지 않는다.
5. **copy-on-write migration:** v1 committed object → v2 staging/token path → bounded chunk read/copy → manifest/tree digest verify → journal generation/fencing CAS publish. publish 전 crash는 v1, publish 후 crash는 v2가 authority다.
6. **Cache migration:** old active verified release를 byte rewrite하지 말고 새 prefix/control schema에 network restage → full verify → explicit activation. current+previous와 old prefix를 rollback/grace window 동안 유지한다.
7. **Web Storage:** current keys는 registry `DISCARD` semantics를 유지한다. adjacent migration이 제품에 필요할 때만 exact owned old physical key를 read-once/validate/write-current/delete-old한다. 전체 localStorage sweep 금지.
8. **Canary observation:** multi-tab/worker timeout, crash between every phase, partial eviction, quota fault, N-1 read-only/online-only fixture를 통과한다. user-authored bytes export/sync path도 확인한다.
9. **Rollback:** traffic admission과 새 writer부터 끈다. schema/database version을 내리지 않는다. compatible N reader 또는 N-1 online-only/read-only bundle로 전환하고, OPFS는 publish authority에 따라 v1/v2 source를 선택한다. Cache는 검증된 previous release로 같은 activate protocol을 실행한다.
10. **Contract release:** 모든 active/rollback clients drain, grace/authority evidence, historical fixtures 후에만 v1 physical generation/old cache prefix를 bounded cursor cleanup한다. DB/root/cache blanket delete는 하지 않는다.
## 7. 유지해야 할 좋은 설계
- closed `BrowserDataResult`, safe recovery vocabulary, observer exception 격리 및 PII/path/name 비노출.
- File policy가 composition-owned immutable identity이고 selection/inspection/preview/download receipt가 exact file/profile에 binding되는 구조.
- native input baseline과 optional enhanced picker 분리, user activation 전에 await하지 않는 규칙, dismissal과 failure 구분.
- 중앙 object URL lease cap, idempotent revoke/dispose, typed Blob, active-content denylist.
- `ByteSource` chunk별 Result/cancellation, download backpressure, close 완료 truth, bounded object URL fallback.
- Web Storage의 typed registry, physical key versioning, strict exact JSON codec, TTL, quota memory overlay와 tombstone.
- IndexedDB의 opaque physical identity/governance binding, additive-only planner, transaction-complete semantics, CAS/idempotency/retention/budget atomicity, versionchange late-close.
- OPFS의 IDB logical authority, phase journal, immutable digest chunks/refcount, hard budget reservation, fail-closed staging GC, no user-readable physical paths.
- Cache의 anonymous public-only same-origin policy, exact query/header/Vary/type/length/digest, marker-last candidate, explicit activation, current+previous retention, owned-prefix-only cleanup, read/activate 재검증.
- optional adapters를 bootstrap에서 자동 조립하지 않고 `AVAILABLE_NOT_COMPOSED`로 남긴 현재 composition posture.
## 8. 기존 테스트·문서 대조와 false-positive 경계
### 실행한 기존 검증
다음 명령을 이 리뷰 중 실행했고 **7 files / 105 tests 전부 통과**했다.
```bash
corepack pnpm exec vitest run \
tests/unit/opfs-byte-store.test.ts \
tests/unit/opfs-worker-runtime.test.ts \
tests/unit/public-response-cache.test.ts \
tests/unit/browser-file-download.test.ts \
tests/unit/indexeddb-maintenance.test.ts \
tests/unit/indexeddb-runtime.test.ts \
tests/unit/storage-registry.test.ts --reporter=default
```
이는 finding이 현재 green suite가 보호하지 않는 interleaving/custom-policy/browser-base case임을 뜻하며, 기존 behavior가 전반적으로 깨졌다는 뜻은 아니다.
### 반증/과장 방지 표
| 의심 항목 | 기존 증거 | 최종 판단 |
| --- | --- | --- |
| OPFS commit 뒤 finalize cleanup 실패 | `opfs-byte-store.test.ts:504-540`가 COMMITTED row 보존 검증 | 보호됨. STO-01은 **commit 전 cleanup 실패/늦은 RPC + generation reuse**로 좁힘. |
| OPFS concurrent operations | `opfs-worker-runtime.test.ts:232-408`가 lock wait cancel, APPEND/ABORT, authority isolation 검증 | 같은 worker의 active put 일부는 보호됨. journal 조기 rollback 후 cross-context late cleanup은 미검증. |
| Cache Vary가 곧 private leak | activation/lookup이 response를 재검증(`public-response-cache-adapter.ts:592-617,341-365`) | 직접 disclosure 주장은 철회. stage success/variant loss/activation availability 결함으로 Medium. |
| Cache 기본 policy Vary | default response allowlist에 `vary` 포함(`public-cache-policy.ts:54-63`), unit `1030-1155` green | 기본은 보호됨. custom policy cross-field invariant만 결함. |
| damaged cache가 active로 publish | activation full reverify | publish는 fail-closed. STO-04는 idempotent stage/self-repair contract. |
| Web Storage schema mismatch | `storage-registry.test.ts:231-244`가 current physical key의 old envelope discard 검증 | 보호됨. old **physical key** sweep/adjacent migration은 문서상 미구현이며 현재 작은 preference의 readiness gap. |
| IndexedDB transaction success/abort | `indexeddb-runtime.test.ts:319-380`가 commit failure rollback과 abort 검증 | 보호됨. STO-06은 migration commit-loop duration budget에 한정. |
| IndexedDB old-writer drain이 전혀 없음 | maintenance test `269-301`, docs `604-609`가 provider confirmation을 전제 | 현 계약상 provider 책임이므로 결함으로 세지 않음. temporal lease는 enforceability 강화. |
| preview decode safety가 몰래 누락 | `browser-file-and-origin-storage.md:360-365`, VD-15 `19-31,574+`, runbook `96-108`가 미구현을 명시 | regression 아님. 제품 preview promotion blocker(GAP-01). |
| Cache unbounded cleanup이 발견되지 않은 bug | VD-15 `486-515`, runbook `382-429`가 정확히 명시 | known `DESIGNED_NOT_IMPLEMENTED` readiness gap(GAP-02). |
| origin pressure/migration coordinator 부재 | VD-15 `19-31,90-103`, `browser-file-storage-recovery.md:10-14` | known gap. 기존 per-store maintenance를 coordinator로 오인하지 않는다. |
| optional adapters가 bootstrap에 없음 | `runtime-adapters.ts:260-266`; docs status `AVAILABLE_NOT_COMPOSED` | 의도된 skeleton posture, 결함 아님. |
| picker receiver | unit tests가 모두 arrow/fake callback을 사용 | 확정 증거 부족. STO-08은 browser test 선행의 낮은 심각도 hypothesis로 격리. |
## 9. 리뷰 범위 밖으로 확장하지 않은 항목
- Service Worker lifecycle, private/range cache, persistent directory/file handles, Range resumable download는 문서상 별도 `NOT_SELECTED`/`DESIGNED_NOT_IMPLEMENTED` capability다. public cache/file adapter에 섞어 고치지 않는다.
- application/product dataset, schema, rollout authority가 없으므로 optional IndexedDB/OPFS/Cache를 현재 default bootstrap에 새로 조립하지 않는다.
- 전체 origin eviction은 모든 IndexedDB/OPFS/Cache metadata가 함께 사라질 수 있어 client-only로 완전 판별할 수 없다. server rehydrate/export UX와 generation/session authority가 필요하다.
---
최종 권고: STO-01은 production composition이 하나라도 있으면 release blocker로 취급한다. STO-02는 작은 canonicalization patch로 즉시 닫을 수 있다. Cache 세 항목은 동일 변경 묶음으로 TDD하고, VD-15 gap들은 상태 문서를 먼저 바꾸지 말고 executable unit+browser evidence와 rollback fixture가 생긴 후에만 승격한다.