Files
clean-architecture-frontend…/docs/architecture/browser-file-and-origin-storage.md
DongHyeonkaandClaude Opus 5 618da9abf5 fix: preserve OPFS recovery authority during cleanup
Repair the compensating half of the OPFS put saga.

The coordinator now owns a single abortPreparedPut() driven by a
composition-owned bounded signal instead of the caller's already aborted one,
and the worker client no longer issues a duplicate fire-and-forget abort.
Journal rows and budget reservations are released only after the physical
effect is confirmed CLEANED or ALREADY_CLEAN; a timeout, malformed response or
EFFECT_UNKNOWN keeps PREPARING/FILES_READY and returns OBJECT_RECONCILE.

New writes carry a transaction-unique physicalGenerationId through the staging
receipt, manifest path and prepared object, so a late compensation deletes only
its own transaction's directory even when a newer transaction legitimately
reuses the same logical generation. v1 paths, receipts and prepared objects stay
readable through the rollback window.

Abort and cleanup hold the origin mutation lease through physical deletion and
staging removal. A transaction that never reached staging returns ALREADY_CLEAN
without waiting for the lease, which would otherwise deadlock against the BEGIN
it is cancelling.

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

1204 lines
63 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Browser file and origin-storage platform
이 문서는 File, Blob, 파일 선택기, 다운로드, IndexedDB, OPFS, Cache Storage를
프로덕션에 도입할 때의 경계, 프로토콜, 실패·복구 정책과 promotion evidence를
정의한다. 최초 결정 기준일은 2026-07-27이며 lifecycle/migration 설계는
2026-07-28에 갱신했다.
현재 skeleton에는 요청된 browser API를 직접 호출하는 **정책 주입형 reference
runtime**이 `AVAILABLE_NOT_COMPOSED` 상태로 들어 있다. catalog exposure의
recipe availability는 `RECIPE_AVAILABLE`이지만 이는 primary status나 product
selection 값이 아니다. 제품 capability는 아직 선택·조합하지 않았고
bootstrap/installed feature에는 연결하지 않았다.
따라서 production build에는 포함되지 않고, 실제 제품 요구·dataset owner와
해당 capability가 server 경계를 넘는 경우 backend protocol까지 정한 프로젝트가
필요한 adapter만 조립한다. Service Worker 등록,
제품 데이터 schema와 owner-specific migration policy는 아직 조합하지 않았다.
이 상태는 catalog의 `productionComposition: false`만 믿지 않는다. build가
생성하는 `artifacts/quality/vite-module-inventory.json`
`check:optional-recipes`가 아래 runtime source root가 production chunk에 없음을
검증한다. IndexedDB reference runtime 자체에는 additive DDL planner,
codecVersion 기반 resumable maintenance, dataset budget, retention lifecycle과
receipt retention/prune가 구현되어 있으며 bootstrap에서만 분리되어 있다.
실행 가능한 계약·runtime·evidence는 다음 위치에 있다.
- `src/application/ports/browser-file-storage/`
- `src/adapters/browser-files/`
- `src/adapters/storage/indexeddb/`
- `src/adapters/storage/opfs/`
- `src/adapters/cache-storage/`
- `src/adapters/browser-file-storage/storage-manager-adapter.ts`
- `tests/browser-capabilities/`
- `recipes/frontend-capabilities/browser-file-storage-contracts.ts`
- `recipes/frontend-capabilities/browser-file-storage-fakes.ts`
- `tests/recipes/browser-file-storage-contracts.test.ts`
- `config/recipes/frontend-capability-recipes.json`
- `artifacts/quality/vite-module-inventory.json` (build-generated evidence)
- `scripts/test-browser-file-storage-runtime-removal.ts`
## 0. 후속 lifecycle/migration 결정과 현재 delta
origin storage를 제품에 composition하기 전 남은 공통 lifecycle 설계는
[VD-15: Origin storage lifecycle, migration, and optional file capabilities](./decisions/VD-15-origin-storage-lifecycle-and-migration.md)에
고정한다. VD-15가 Accepted됐다는 사실은 해당 runtime이 구현 또는 조립됐다는
뜻이 아니다.
현재와 목표를 구분하면 다음과 같다.
| 항목 | 현재 reference runtime | VD-15 목표 |
| --- | --- | --- |
| StorageManager | `estimate/persisted/persist`와 pressure 분류 구현 | IDB·OPFS·Cache write admission, hysteresis, bounded GC와 quota failure 뒤 exact 1회 retry coordinator |
| eviction | OPFS logical/physical 및 Cache pointer/candidate의 partial mismatch 감지 | dataset sentinel/backend epoch 연계; origin 전체 marker 소실은 first install과 완전 구분할 수 없음을 유지 |
| IndexedDB migration | additive DDL, codecVersion, bounded resumable maintenance 구현 | origin coordinator와 N-1 promotion/rollback evidence 연계 |
| OPFS migration | journal/physical manifest v1, reconciliation 구현 | copy-on-write physical/journal forward migration과 historical fixture |
| OPFS readiness | API property와 required primitive 확인, 실제 operation은 native browser test에서 검증 | composition readiness에서 worker/lock/journal/small write-read-delete-cleanup real preflight |
| Cache Storage | public static release stage/verify/activate, previous retain, owned cleanup 구현 | cursor/count/deadline이 있는 bounded inspect/cleanup과 control-schema migration |
| Service Worker | 미조립·미구현 | 제품이 PWA를 선택할 때만 별도 update/client-drain controller |
| local preview | byte/signature/media/active-content/object URL lease 구현 | object URL 발급 전 pixel/decoded-byte/animation/real-decode safety probe |
| directory/persistent handle/drop | 미구현, transient file picker만 제공 | 제품 workspace/consent 요구가 있을 때 별도 optional capability |
| Range resumable download | 현재 download/public cache에 없음 | `DESIGNED_NOT_IMPLEMENTED`; VD-14의 별도 runtime/provider 계약 |
| private/sparse Range cache | public cache에서 명시적으로 거부 | 제품이 별도 선택하기 전 `NOT_SELECTED` |
| app-managed background download | 현재 미선택 | `NOT_SELECTED`; 지원 browser용 별도 optional capability |
| cross-browser app-managed background download guarantee | 대상 browser 전체에서 지속 실행을 보장할 수 없음 | `PLATFORM_LIMITED`; browser-managed handoff와 명시적 progressive enhancement |
따라서 이 문서 아래에서 “해야 한다”로 표현된 항목 중 위 표의 VD-15 목표는
아직 executable implementation이 아니다. primary current-status literal은
`COMPOSED`, `AVAILABLE_NOT_COMPOSED`, `DESIGNED_NOT_IMPLEMENTED`, `NOT_SELECTED`,
`PLATFORM_LIMITED` 다섯 값만 사용하며 선형 maturity로 해석하지 않는다. 현재 기존
native runtime은 `AVAILABLE_NOT_COMPOSED`, 새 coordinator/migrator/probe와 Range
resumable download는 `DESIGNED_NOT_IMPLEMENTED`,
directory/persistent handle/Service Worker/private·sparse Range cache와
app-managed background download capability는 `NOT_SELECTED`, 그 cross-browser
guarantee는 `PLATFORM_LIMITED`다. selection, traffic admission, runtime health와
promotion evidence는 이 primary status와 별도 축이다.
축의 canonical 이름과 literal은 completion ledger의 `Selection`,
`TrafficAdmission`, `RuntimeHealth`, `PromotionEvidence`를 그대로 사용한다.
contract/provider/browser/operations는 별도 축 이름을 만들지 않고
`PromotionEvidence`를 계산하는 component gate다. projection과 evidence expiry도
ledger 규칙을 따른다.
## 1. 변경할 수 없는 경계 결정
### 1.1 기술별 소유권
| 기술 | 소유하는 것 | 소유하지 않는 것 |
| --- | --- | --- |
| `File`/`Blob` | 현재 사용자가 선택했거나 현재 작업이 생성한 transient bytes | 영구 ID, 권한, 신뢰 가능한 MIME, persistence |
| native file input/picker | user activation 안의 선택 의도와 transient handle | application/domain model, boot permission |
| download/save | 검증된 artifact를 브라우저 또는 사용자가 고른 파일에 전달 | server authorization, 파일 처리 완료를 가장한 anchor click |
| IndexedDB | 구조화 record, index, revision, idempotency, OPFS journal; 명시적으로 선택된 feature command queue의 storage mechanism이 될 수 있음 | command/sync protocol authority, 큰 immutable payload, HTTP response cache, credential |
| OPFS | 큰 opaque immutable bytes, chunk, integrity manifest, staging | query/index, domain metadata authority, 사용자에게 보이는 파일 경로 |
| Cache Storage | 정책이 승인한 public HTTP `Request`/`Response` representation | domain repository, TanStack Query cache, 인증/개인 API response |
| HTTP cache | 서버 header가 소유하는 일반 freshness/revalidation | application offline database |
| 기존 `StoragePort` | 작은 동기식 public preference | IndexedDB, OPFS, Cache Storage |
이 표가 가장 중요한 설계 규칙이다. `StorageAdapter`, `FileManager`,
`BrowserPersistence` 같은 범용 mega-port 하나로 합치지 않는다. 각 API는
transaction, 수명, quota, 일관성, 복구 방식이 다르다.
### 1.2 source of truth
- 서버는 명시적으로 승인된 offline-first 동기화 protocol을 제외하면 업무
entity와 authorization의 source of truth다.
- IndexedDB record는 server state 전체 복제가 아니라 offline projection,
unsynced command, local-only product state 중 승인된 하나다.
- OPFS의 physical manifest는 integrity/recovery 자료다. 논리적 object의 commit
authority는 IndexedDB journal과 logical row다.
- Cache Storage hit는 authorization proof가 아니다. 저장 대상 자체를 anonymous
public representation으로 제한한다.
- `File.name`, `File.type`, extension, `lastModified`와 picker permission은
authorization 또는 content safety의 근거가 아니다.
### 1.3 clean architecture 배치
실제 capability를 선택한 프로젝트의 권장 배치는 다음과 같다.
```text
src/features/<feature>/
application/
<aggregate>-offline-repository.ts
file-intake.ts
file-delivery.ts
adapters/
indexeddb/
codec.ts
schema.ts
migrations.ts
repository.ts
file/
browser-file-vault.ts
download-delivery-adapter.ts
upload-example/
quarantined-upload-http-adapter.ts
src/adapters/storage/opfs/
opfs-object-store.ts
opfs.worker.ts
worker-protocol.ts
journal-repository.ts
src/platform/offline/
cache-policy.ts
cache-storage-adapter.ts
service-worker-runtime.ts
```
공통 reference runtime은 domain-neutral한 low-level policy port로 제공한다.
제품 feature는 이를 직접 UI에 노출하지 않고, feature-specific repository/use
case가 schema, codec, query plan, authority와 retention policy를 주입해 더 좁은
domain contract로 감싼다. application/domain에는 `IDBDatabase`, transaction
callback, store/index 이름, `File`, `Blob`, `FileSystemHandle`, `Request`,
`Response`, `Cache`가 보이면 안 된다.
### 1.4 정책과 도메인의 분리
브라우저 API의 안전한 실행 규칙은 도메인과 무관한 공통 정책이다. 예를 들면
transaction-complete 이후 성공 처리, picker user activation, stream chunk 상한,
object URL lease, blocked/versionchange 처리, OPFS staging/journal 순서, Cache
Storage의 exact match와 fail-closed 검증이 여기에 속한다.
반대로 모든 값을 하나의 전역 정책으로 고정할 수는 없다. 어떤 record를 저장할지,
어떤 index/query가 필요한지, server와 local 중 누가 authority인지, data
classification·retention·quota 우선순위, 허용 MIME과 공개 cache 대상은
dataset/use-case owner가 결정한다. 코드에서는 전자는 공통 adapter에, 후자는
주입되는 `*Policy`, codec, query planner와 feature facade에 둔다.
따라서 reference runtime이 제공하는 것은 transaction 완료 판정, bounded stream,
scope/policy binding, integrity와 recovery 같은 **공통 메커니즘**이다. 제품
composition root는 dataset별 owner, opaque scope, `BrowserStoragePolicy`,
schema/codec/query planner, lifecycle authority와 backend protocol을 명시적으로
주입한다. runtime은 이 구성 객체를 검증한 뒤 깊은 snapshot/freeze하고 persisted
binding과 대조한다. 서로 다른 dataset을 하나의 편의상 전역 repository나
가변 policy 객체로 합치지 않는다.
## 2. 공통 데이터 정책
browser persistence를 선택하기 전에 dataset별로 아래 catalog를 채운다. 한
항목이라도 비어 있으면 runtime을 설치하지 않는다.
| 필드 | 필수 결정 |
| --- | --- |
| owner | product owner와 operational owner |
| namespace | 고정된 application/feature/dataset ID |
| classification | public, internal, personal, confidential; credential은 금지 |
| authority | server, local-first, reconstructable 중 하나 |
| retention | session, TTL, until-synced, explicit delete |
| soft/hard budget | origin 전체와 dataset별 bytes/entries/object limit |
| persistence | best-effort 허용 여부와 `persist()` 요청 조건 |
| eviction priority | reconstructable → synced copy → user-authored |
| account scope | opaque partition key, logout/account deletion 처리 |
| failure UX | online-only, read-only, export-required 중 하나 |
| recovery | re-fetch, replay, quarantine, operator/user action |
| observability | allowlisted event와 bucket attribute |
| rollback | N-1 reader, destructive migration 보류 기간 |
credential, session token, password, signing key, raw authorization header는
IndexedDB, OPFS, Cache Storage 모두에서 금지한다. 동일 origin JavaScript가
암·복호화 key를 사용할 수 있는 client-side encryption은 XSS에 대한 권한
경계가 아니다.
`navigator.storage.estimate()`는 IndexedDB, OPFS, Cache Storage 등이 공유하는
origin storage의 거친 추정치다. free-space 예약이나 기술별 정확한 사용량으로
사용하지 않는다. 기본 pressure policy의 출발점은 다음과 같고 실제 field
evidence로 조정한다.
| 추정 사용률 | 상태 | 동작 |
| --- | --- | --- |
| `< 70%` | normal | 승인된 write 계속 |
| `7085%` | pressure | expired/reconstructable data bounded GC |
| `>= 85%` | critical | nonessential cache write 중지, sync/export 유도 |
| `QuotaExceededError` | authoritative failure | transaction rollback, GC, idempotent operation만 최대 1회 재시도 |
추정치는 hysteresis trigger일 뿐이다. user-authored committed data를 quota 대응
명목으로 자동 삭제하거나 DB 전체를 자동 `deleteDatabase()`하지 않는다.
현재 `storage-manager-adapter.ts`는 이 표의 한 시점 pressure 분류와 명시적
`persist()` 요청만 제공한다. threshold 하향 hysteresis, 기술 간 write admission,
GC ordering과 `QuotaExceededError` 뒤 idempotent operation의 최대 1회 retry는
아직 구현되지 않았고 VD-15 coordinator의 목표다. estimate 결과만으로 native
free space를 예약했다고 간주해서는 안 된다.
## 3. File, Blob, 파일 선택기
### 3.1 native type의 수명
`File``Blob`에 이름과 수정 시각 metadata가 추가된 browser object다.
application에는 native object 대신 opaque `LocalFileRef`와 정규화된 metadata를
전달한다. adapter의 transient vault가 ref에서 native object를 찾고 bounded
inspection/range read를 수행한다.
```text
click/user activation
-> native input 또는 enhanced picker
-> transient browser file vault
-> opaque LocalFileRef + untrusted metadata
-> bounded inspection
-> preview 또는 download 또는 별도 선택한 backend workflow
-> ref release
```
`File`, `FileList`, `Blob`, `FileSystemFileHandle`, 로컬 경로와 object URL은
domain, application state, query cache, global store, diagnostics에 넣지 않는다.
같은 파일을 다시 선택할 수 있도록 input value reset 책임도 picker adapter가
소유한다.
### 3.2 picker baseline과 enhancement
- 접근 가능한 `<input type="file">`가 모든 browser의 canonical baseline이다.
- `showOpenFilePicker()``showSaveFilePicker()`는 runtime method별 feature
detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다.
- picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에
config fetch, analytics flush, permission query를 `await`하면 transient user
activation을 잃을 수 있다.
- boot, route mount 또는 background effect에서 permission을 요청하지 않는다.
- 선택/저장 대화상자 취소는 정상적인 `DISMISSED` outcome이다. error toast,
retry alert, error telemetry로 기록하지 않는다.
- native input의 `cancel` event는 즉시 authoritative dismissal로 처리한다.
`window.focus` 기반 fallback만 사용하는 engine은 focus 복귀 뒤 기본 1초 동안
늦은 `change`/`FileList` 반영을 기다린 다음 dismissal로 확정한다.
- permission denied, unsupported, user dismissal, active transfer abort를 서로 다른
outcome으로 다룬다.
- enhanced picker가 실패한 같은 click에서 native picker를 자동으로 다시 열면
activation이 소진될 수 있다. fallback 버튼을 보여 다음 명시적 사용자 동작에서
baseline을 실행한다.
- directory handle이나 persistent file handle은 별도 제품 capability와 별도
consent/retention decision 없이는 저장하지 않는다.
### 3.3 intake policy와 검증
선택 전에 composition root가 `BrowserFilePolicyProfile`에 다음을 고정한다.
application port의 caller는 raw selection/inspection/preview/download 정책을
전달하지 않는다. composition에서 발급·등록한 정확한 `FilePolicyReference`
객체와 더 작은 count/byte limit만 전달한다. registry는 배열, signature pattern,
MIME allowlist까지 깊게 snapshot하며 동일한 `policyKey`/`intention` 문자열로 새
reference를 만들어도 권한으로 인정하지 않는다. 따라서 presentation이 다른
feature의 전략, MIME, extension, integrity 또는 byte ceiling을 선택할 수 없다.
- purpose/policy version
- single/multiple, 최대 file count
- 개별 byte limit과 누적 byte limit
- zero-byte 허용 여부
- allowlisted extension과 reported MIME pair
- bounded inspection bytes와 content signature rule
- active content/archive 처리 방식
- classification, retention, telemetry redaction
inspection 성공 receipt는 `policyId` 문자열이 아니라 정확한 registered profile과
file snapshot에 묶인다. 같은 inspection rule ID를 공유하는 다른 preview
profile로 receipt를 replay하면 `POLICY_REJECTED`다. picker/save dialog를 기다리는
동안 caller request, handle array, byte-source method 또는 composition dependency가
바뀌어도 이미 snapshot/bind한 ref, signal, limits, handle loader와 stream만
사용한다.
`accept`, extension, `File.type`과 magic byte는 모두 client-side 조기 UX다.
선택·preview·client-generated download만 도입한 feature에는 backend upload가
필수가 아니다. 업로드를 별도 선택했다면 최종 backend는 authorization과 함께
다음을 다시 검증한다.
- 실제 byte length, MIME sniffing, allowlisted parser
- image dimensions/pixel budget, PDF active content
- archive entry count, nesting, expanded size/ratio, path traversal와 symlink
- malware scan/CDR/quarantine
- polyglot/malformed input과 decompression bomb
- resource owner와 upload session scope
그 별도 upload workflow에서 서버 검증이 끝나기 전 resource 상태는
`QUARANTINED`다. public URL이나 active preview를 발급하지 않는다.
### 3.4 Blob과 memory budget
다음 API는 전체 payload를 메모리에 올릴 수 있으므로 제품별 hard cap 아래에서만
허용한다.
- `Blob.arrayBuffer()`, `Blob.text()`, `Blob.bytes()`
- `Response.arrayBuffer()`, `Response.blob()`, `Response.text()`
- base64/Data URL conversion
- 대형 `Uint8Array` 하나로 합치기
대용량 경로는 `Blob.stream()`/`Response.body`와 bounded chunk 또는 multipart를
사용한다. application 계약에는 `readRange` 또는 닫힌
`AsyncIterable<BrowserDataResult<Uint8Array>>`만 노출하며 adapter가 browser
`ReadableStream`/`WritableStream`과 연결한다. recipe contract의 동일 경계는
`CapabilityResult<Uint8Array>`를 쓴다. 각 chunk는 성공 또는 allowlisted failure고,
consumer는 첫 실패에서 소비를 중지한다. native `DOMException`, rejection 또는
부분 byte 뒤의 raw throw가 application 경계를 통과해서는 안 된다.
memory upper bound는 적어도 다음 식으로 review한다.
```text
part size × concurrent parts × implementation copy factor
+ preview decode surface
+ framework/network buffering
<= approved foreground memory budget
```
progress의 total은 `null`일 수 있다. unknown total을 억지 percent로 표시하지
않고 indeterminate UI를 사용한다. progress event는 animation frame 또는
410Hz 정도로 throttle하고 항상 단조 증가해야 한다.
Web Crypto `digest()`는 incremental stream hash가 아니다. 대용량 whole-file을
한 번에 넣지 않는다. 업로드는 bounded part SHA-256, OPFS는 명시적인 chunk-tree
digest 또는 별도 검토된 streaming hash adapter를 사용한다.
### 3.5 object URL lease
`URL.createObjectURL()`은 호출마다 새 URL과 Blob 수명 lease를 만든다.
`TransientPreviewPort` 한 곳만 생성 권한을 갖고 `{url, release}`를 반환한다.
- preview 대상 교체, load error, unmount에서 정확히 한 번 revoke한다.
- one-shot 작은 download는 click handoff 뒤 cross-engine scheduling을 위해 기본
30초 grace를 두고 revoke한다. feature/runtime dispose는 grace를 기다리지 않고
모든 active lease를 즉시 revoke한다.
- URL을 global state, IndexedDB, query cache, log, analytics에 보관하지 않는다.
- Service Worker에서 object URL을 사용하지 않는다.
- SVG, HTML, untrusted PDF 같은 active content를 same-origin object URL iframe에
직접 preview하지 않는다. 별도 격리 origin 또는 안전한 decode/re-encode 결과,
아니면 attachment-only 정책을 사용한다.
현재 reference preview는 encoded byte/signature/media allowlist와 위
active-content denylist까지만 runtime에서 강제한다. static raster의 intrinsic
width/height, pixel 수, decoded surface, animation과 실제 decode 성공은 아직
검증하지 않는다. preview를 제품에 조립하기 전 VD-15의 bounded header parser와
decode probe를 구현해야 하며, 그 전에는 byte cap을 decode-memory 안전성으로
해석하지 않는다.
runtime factory는 `hardMaxPreviewBytes`, `hardMaxObjectUrlBytes`,
`hardMaxTransferBytes` absolute ceiling을 필수로 소유한다. registered profile과
caller의 optional preview/buffer/transfer reduction은 이 ceiling을 낮출 수만
있으며 초과 값은 native picker, stream 또는 object URL side effect 전에
fail-closed한다.
## 4. 별도 backend upload workflow 예시
이 절은 File/Blob/picker/download runtime의 dependency가 아니다.
`BrowserFileComposition`에는 picker, content, preview, download만 들어간다.
제품이 backend upload를 따로 선택하고 아래 서버 책임을 구현할 때에만 recipe의
`ExampleQuarantinedUploadPort`를 feature-local 계약으로 복사한다. 단순 request가
실제 size/latency 요구를 만족하면 bounded upload를 쓰고, 재개·대용량이 필요하면
이 예시를 제품 protocol에 맞게 좁힌다.
1. backend가 purpose, detected type, declared bytes와 authorization을 검증해
short-lived session을 만든다.
2. backend가 part size, concurrency upper bound, expiry와 checksum algorithm을
반환한다.
3. client는 `FileContentPort.readRange()`로 bounded part만 읽는다.
4. 각 part는 part number, offset, checksum, idempotency key로 전송한다.
5. complete는 ordered part receipt와 checksum을 검증한다.
6. 성공 resource는 scan 완료 전 `QUARANTINED`다.
7. user abort는 현재 request의 `AbortSignal`뿐 아니라 server session의 explicit
`abort()`도 호출한다. unload cleanup은 신뢰하지 않으므로 backend TTL job이
orphan session을 최종 정리한다.
object key, storage bucket, signed URL과 long-lived credential을 client가 결정하거나
log하지 않는다. session 생성, part, complete 단계마다 authorization을 다시
확인한다. retry는 같은 idempotency key와 같은 part checksum에만 허용한다.
이 workflow의 설치, kill switch, 관측성, runbook과 제거는 browser-file runtime과
독립적이어야 한다.
## 5. 다운로드와 저장
### 5.1 전략 선택
| 상황 | 기본 전략 | 의미 |
| --- | --- | --- |
| server artifact, 모든 browser | authorized endpoint를 실제 anchor/navigation으로 전달 | `BROWSER_HANDOFF`; 완료를 관측했다고 주장하지 않음 |
| 큰 artifact + save picker 지원 | picker를 먼저 열고 response stream을 writable에 전달 | close/integrity 이후 `SAVED` |
| 작은 client-generated artifact | hard cap 이내 Blob + object URL | `SAVED`가 아니라 browser handoff일 수 있음 |
| 큰 client-generated + picker 미지원 | server-side artifact 생성 후 direct download | 전체 memory Blob fallback 금지 |
`<a download>` click은 browser에게 전달했음을 의미할 뿐 disk write 완료가 아니다.
따라서 `DownloadOutcome``BROWSER_HANDOFF``SAVED`를 분리한다. 이미 close/
commit된 뒤 늦게 AbortSignal이 발생하면 성공을 취소로 거짓 보고하지 않는다.
stream-to-file에서 integrity를 close 전에 확인해야 하면 writable을 열린 상태로
유지하고, digest가 맞을 때만 close한다. mismatch/abort/partial write면 abort하고
`INTEGRITY_FAILED` 또는 partial-save failure를 반환한다.
전략, media type, safe extension, integrity mode와 최대 bytes는 composition
registry가 소유한다. caller는 composition이 등록한 정확한
`FilePolicyReference` 객체, 아래 source union의 최소 입력, filename과 limit
reduction만 전달한다. `policyKey`/`intention`이 같은 새 객체나 raw 전략을
제출할 수 없다. source는 다음처럼 분리한다.
- `BROWSER_MANAGED_RESOURCE`: resource ID와 서버 발급 capability receipt
- `AUTHORIZED_STREAM_RESOURCE`: 별도 authorization adapter가 여는 stream
- `GENERATED`: runtime이 snapshot한 closed-Result byte source
`BROWSER_MANAGED_RESOURCE`는 user action 전에 준비된 synchronous resolver로만
처리한다. caller의 branded `BrowserManagedDownloadCapabilityReceipt`와 resolver가
돌려주는 receipt는 정확히 같아야 한다. capability는 그 receipt, resource ID,
media type, safe extension, server-enforced max bytes, optional SHA-256와 expiry를
정확히 묶어야 한다. 하나라도 registered profile/source와 다르거나 expired이면
navigation handoff를 하지 않는다. async signed-URL fetch를 click 뒤 기다리거나
단순 `resourceId -> href` 함수를 authorization으로 간주하지 않는다.
### 5.2 filename과 response contract
filename은 advisory metadata다. client와 server 모두 다음을 처리한다.
- `/`, `\`, path segment, NUL/control, bidi override 제거
- leading/trailing space와 dot 제거
- `.`, `..`, Windows device name 거절
- Unicode NFC 정규화와 byte-length limit
- content type과 맞지 않는 executable/double extension 교체
- 안전한 fallback filename
서버가 download를 소유하면 `Content-Disposition: attachment`와 UTF-8
`filename*`, ASCII `filename` fallback을 함께 제공한다. cross-origin download는
`download` attribute만 믿지 않는다. JS가 header를 읽어야 하면 CORS
`Access-Control-Expose-Headers`도 계약에 넣는다.
민감 artifact의 권장 header 시작점은 다음과 같다. 실제 caching 정책은 제품
분류에 맞춰 backend가 결정한다.
```http
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf";
filename*=UTF-8''report.pdf
X-Content-Type-Options: nosniff
Cache-Control: private, no-store
Cross-Origin-Resource-Policy: same-origin
```
## 6. IndexedDB
### 6.1 port와 wire model
IndexedDB는 기존 동기식 `StoragePort` backend가 아니다. feature application은
업무 intent형 async repository를 소유하고 adapter만 IndexedDB를 안다.
`StructuredOfflineStore<T>`는 복사 후 feature query와 mutation으로 더 좁힐
recipe다.
reference runtime의 실제 wire layout은 하나의 conceptual envelope가 아니라
분리된 store record다. store 이름은 composition-owned safe identifier이며 다음
shape와 역할을 유지한다.
```ts
type StoredRecord = Readonly<{
key: string;
codecVersion: number;
revision: number;
payload: unknown;
}>;
type StoredRetentionRecord = Readonly<{
recordKey: string;
writtenAtEpochMs: number;
synchronization: "PENDING" | "CONFIRMED" | "NONE";
measuredBytes: number;
eligibleAtEpochMs?: number;
}>;
type StoredIdempotencyReceipt = Readonly<{
idempotencyKey: string;
operation: "PUT" | "DELETE";
recordKey: string;
expectedRevision: number | null;
fingerprint: string;
synchronization: "PENDING" | "CONFIRMED" | "NONE";
revision: number;
expiresAtEpochMs: number;
}>;
type StoredDatasetBudget = Readonly<{
bindingKey: "dataset-budget";
budgetVersion: 1;
usedBytes: number;
receiptCount: number;
}>;
```
- `recordStore`: `StoredRecord`
- `retentionStore`: `StoredRetentionRecord`와 eligibility index
- `idempotencyStore`: `StoredIdempotencyReceipt`와 expiry index
- `governanceStore`: immutable `dataset-binding``StoredDatasetBudget`
- `lifecycleMetadataStores`: migration checkpoint처럼 partition/session purge 때
함께 비워야 하는 adapter-owned metadata. immutable governance binding은 이
목록에 넣지 않는다.
`StoredRecord`는 reference runtime의 실제 adapter-private wire shape다.
recipe의 feature-facing `StoredRecord<T>` view model과 혼동하지 않는다.
`writtenAtEpochMs`, synchronization, `measuredBytes`, `eligibleAtEpochMs`는 record
envelope에 중복 저장하지 않고 `retentionStore` sidecar에 둔다.
- structured clone 성공을 업무 schema 검증으로 간주하지 않는다.
- 모든 read는 `unknown`에서 runtime codec으로 envelope와 payload version을
검증한 뒤 domain value로 mapping한다.
- class instance와 암묵적 Date 직렬화를 피하고 primitive wire object와 명시적
epoch/ISO 값을 사용한다.
- database DDL schema version과 record codec version을 분리한다.
- `IndexedDbDatasetScope``authorityToken`, `namespaceToken`,
`partitionToken`은 registry-issued opaque token이다. readable namespace,
account/tenant/user ID 또는 domain key를 token에 인코딩하지 않는다.
- physical DB명은 오직 세 opaque token으로
`ca-idb-v1:<authority>.<namespace>.<partition>` 형태로 파생한다. caller가 주는
database name은 생성 입력이 아니라 exact assertion으로만 허용한다.
- account scope와 storage policy의 `accountScope`가 일치해야 하며, 모든
query/index는 해당 dataset scope 안에서만 실행한다.
- offset pagination 대신 deterministic compound key와 tie-breaker가 있는
keyset cursor를 사용한다.
- 실제 query가 없는 index와 민감 원문 index는 만들지 않는다.
runtime은 composition 때 scope와 전체 `BrowserStoragePolicy`를 검증하고 깊은
snapshot/freeze한다. governance store의 immutable `dataset-binding`은 scope와
정책 전체를 보존한다. 최초 versionchange transaction에서 생성·검증하고,
일반 open과 maintenance 진입 때도 다시 검증한다. 기존 DB에 binding이 없거나 한
필드라도 달라지면 임의 보정하거나 다른 dataset으로 열지 않고
`POLICY_REJECTED`로 fail-closed한다.
### 6.2 open, versionchange, blocked 상태
connection manager는 다음 상태를 명시적으로 갖는다.
```text
opening -> ready -> draining -> closed
\-> upgrade-blocked -> ready | online-only
\-> future-schema -> read-only | online-only
```
- connection을 만든 즉시 `versionchange`와 forced `close` handler를 등록한다.
- `versionchange`에서는 신규 operation 접수를 중지하고 connection을 즉시
닫는다. upgrade callback에서 UI state를 저장하려 하지 않는다.
- upgrader는 versioned prepare hint를 broadcast한 뒤 open한다.
- `blocked`면 다른 탭을 닫거나 reload하도록 접근 가능한 UI를 보여 준다.
원격 탭 강제 종료, 무한 reload, timeout 후 자동 DB 삭제는 금지한다.
- `BroadcastChannel`은 upgrade/invalidation hint다. message loss와 partial
ordering을 전제로 항상 DB를 다시 읽는다.
- old bundle이 future schema를 만나면 write를 시도하거나 version을 내리지 않고
read-only/online-only fallback으로 기동한다.
- native open request는 일반 AbortSignal로 실제 취소할 수 없다. caller가 취소한
뒤 늦게 열린 connection은 즉시 close하고 stale 결과를 폐기한다.
### 6.3 schema와 data migration
`onupgradeneeded`에서는 deterministic structural change와 작은 metadata request를
동기적으로 queue한다. 다음은 금지한다.
- network/fetch
- timer
- crypto/hash
- user callback
- unrelated Promise/`await`
- analytics와 외부 side effect
- 대량 row 변환
큰 migration은 expand/migrate/contract로 나눈다.
1. **expand:** additive store/index와 migration state만 추가한다.
2. **fence:** rollout/session authority가 N-1 old-codec writer를 drain하고
migration/contract window 동안 다시 쓰지 못함을 보장한다. 단순
`BroadcastChannel` 알림이나 탭 열거는 이 fence가 아니다.
3. **migrate:** post-open maintenance invocation 또는 worker가 bounded batch를
변환한다.
4. 각 batch는 row update와
`{migrationId, targetCodecVersion, lastKey, state}` checkpoint를 같은
transaction에 commit하고 aggregate progress count만 caller에 반환한다.
5. row별 codec version으로 retry가 idempotent해야 한다.
6. 모든 active release와 rollback window가 지난 뒤 별도 schema version에서
old store/index 제거를 검토한다. 공통 reference planner는 제품 owner 없는
destructive DDL을 허용하지 않으므로, 제거는 별도 승인된 product migration
구현과 rollback evidence가 있어야 한다.
old writer drain은 keyset checkpoint의 correctness 전제다. drain되지 않은 N-1이
현재 `lastKey`보다 앞선 key로 old-codec row를 새로 쓰면 이후 batch가 그 row를 다시
보지 못한다. 따라서 reference maintenance는 주입된
`isOldWriterDrainConfirmed`가 참이 아니면 `BLOCKED`로 중단하며, drain 보장은
migration 완료 후 contract/rollback window까지 유지한다. 새 runtime의 정상 write는
항상 target codec version을 기록한다.
각 invocation은 caller가 주는 `maxRows``maxDurationMs` 중 더 작은 budget을
지키며 reference runtime의 절대 상한은 500 rows, 30,000ms다. async storage
operation 사이마다 monotonic deadline을 검사하고 소진 시
`state: "MORE", budgetExhausted: true`와 keyset checkpoint를 반환한다. migration
transaction은 revision fence, migrated row, retention sidecar, dataset budget와
checkpoint를 함께 commit한다.
지원하는 모든 historical schema snapshot을 fixture로 보존하고 fresh upgrade,
중간 crash, resume, 재실행과 N-1 rollback을 실제 browser에서 검증한다.
### 6.4 transaction 정확성
- raw transaction callback을 port 밖으로 내보내지 않는다.
- 한 repository method가 하나의 업무 transaction scope다.
- transaction 안에서 network, timer, UI 또는 unrelated `await`를 수행하지 않는다.
- request `success`가 아니라 transaction `complete` 이후에만 mutation 성공을
반환한다.
- signal이 시작 전에 abort됐으면 transaction을 만들지 않는다.
- 진행 중 취소는 안전한 경우 `tx.abort()`로 업무 단위 전체를 rollback한다.
- complete와 abort race는 settle latch 하나로 결정하며 committed write를
`ABORTED`로 거짓 보고하지 않는다.
- read-modify-write는 하나의 readwrite transaction과 `RevisionGuard` CAS를 쓴다.
- command attempt는 unique idempotency key로 중복을 막는다.
- codec의 `measureStoredBytes()`와 보수적인 envelope reservation으로 logical
stored bytes를 계산한다. record retention sidecar의 `measuredBytes`
`dataset-budget.usedBytes`는 CAS, remove, lifecycle deletion, codec migration과
같은 transaction에서 원자적으로 갱신한다. 이는 native physical byte 측정치가
아니며, configured `hardBudgetBytes` 초과는 `LIMIT_EXCEEDED`로 rollback한다.
- idempotency receipt에는 canonical wire value의 lowercase SHA-256 hex만
fingerprint로 저장한다. label/ID/PII와 reversible encoding은 거부한다.
- receipt replay window는 주입된 clock/retention으로 고정하고 expiry index 기반
bounded maintenance로만 정리한다. 아직 replay 가능한 receipt는 삭제하지 않으며
prune 성공도 transaction `complete` 이후에만 반환한다. reference runtime의
receipt retention 절대 상한은 31일이다. configured
`maxIdempotencyReceipts``dataset-budget.receiptCount`도 write/prune/purge
transaction에서 원자적으로 강제하며 구현 절대 상한은 1,000,000개다. 제품은
이 두 상한 안에서 더 짧고 작은 retry window를 선택한다.
- write 때 retention sidecar의 `eligibleAtEpochMs`를 계산한다. TTL record는 sweep
전에도 read에서 `EXPIRED_RESOURCE`, query에서 skip 처리한다.
`UNTIL_SYNCED`는 caller가 `CONFIRMED`를 명시한 record만 lifecycle deletion
대상이 된다.
- `SESSION_END`, `LOGOUT`, `ACCOUNT_DELETION`, `RETENTION_SWEEP` batch는 최대
500 rows/30,000ms의 bounded work다. 매 deleting invocation은 composition이
필수 주입한 `authorizeLifecycle({action, scope, storagePolicy})`의 opaque
short-lived proof를 받아 형식 검증한 뒤 즉시 폐기한다. proof를 저장하거나
telemetry에 내보내거나 caller가 직접 제공하게 하지 않는다.
- full partition purge는 record와 retention sidecar/idempotency receipt뿐 아니라
등록된 모든 `lifecycleMetadataStores`를 같은 bounded transaction에서 비우고
dataset budget counter도 원자적으로 맞춘다. governance binding 자체는 dataset
identity 검증을 위해 유지한다.
- 외부 cursor/range key는 native 변환 전에 depth, total element와 total byte
절대 상한을 검사하고 초과·순환·비정상 key를 `INVALID_INPUT`으로 거부한다.
- network sync는 `TX1 snapshot/lease -> network outside TX -> TX2 revision와
fencing token 재검증` 순서다.
- Web Locks는 maintenance/leader 최적화일 뿐 correctness를 대체하지 않는다.
### 6.5 corruption과 recovery
runtime codec이 실패한 record와 `NotReadableError`/forced close 같은 database
failure를 분리한다.
- reconstructable record: safe issue bucket만 기록하고 purge/re-fetch 가능
- synced copy: revision 확인 후 server rehydrate
- unsynced/user-authored: 자동 삭제 금지, read-only 격리, export/sync/runbook
- migration/open failure: bounded reopen 한 번 후 online-only
- full DB deletion: classification owner와 recovery/export 확인 후 명시적 runbook
action만 허용
raw key, value, account ID, exception message/stack을 telemetry로 보내지 않는다.
## 7. OPFS
### 7.1 역할과 layout
OPFS는 사용자가 탐색하는 파일 시스템이 아니라 origin-private storage다.
사용자 filename, email, tenant/user ID를 path에 넣지 않는다.
default physical layout은 구현과 동일하게 다음과 같다.
```text
/ca-frontend-opfs-v1/
authorities/<authorityToken>/<namespaceToken>/<partitionToken>/
objects/<object-id-prefix>/<opaque-object-id>/<generation>/manifest.json # physical v1 (read-only)
objects/<object-id-prefix>/<opaque-object-id>/g<generation>-<token>/manifest.json # physical v2 (new writes)
chunks/sha256/<digest-prefix>/<digest>.bin
staging/<transaction-id>/receipt.json
```
physical v2는 STO-01 수정의 일부다. logical `generation`은 설계상 transaction 간에
재사용되므로, 늦게 도착한 T1 보상이 같은 logical generation을 쓰는 T2의 디렉터리를
지울 수 있었다. v2는 transaction-unique `physicalGenerationId` fencing token을
경로, staging receipt, prepared object에 함께 기록해 보상이 자기 transaction의
디렉터리만 삭제하도록 만든다. expand 단계에서는 v1 경로/receipt/prepared object를
계속 읽고 새 write만 v2로 쓴다. rollback window가 끝나기 전에 v1 physical
generation을 일괄 삭제하지 않는다.
구조화 metadata, query, revision, refcount와 operation journal은 IndexedDB가
소유한다. OPFS에는 immutable chunk와 bounded runtime-schema-validated manifest만
둔다. readable `scope.namespace`는 경로에 쓰지 않는다.
IndexedDB journal DB는 authority token으로 격리되고, 첫 mutation에서 두 binding을
같은 transaction에 기록한다. physical
`authorityToken|namespaceToken|partitionToken -> readable namespace + full policy
fingerprint`와 logical
`authorityToken|namespace|partitionToken -> physical scope key`를 양방향으로
검증한다. 둘 중 하나만 없거나 기존 값이 다르면 `CORRUPT_DATA` 또는
`POLICY_REJECTED`로 중단한다. runtime과 byte store도 composition 시 scope/policy를
snapshot하고 namespace 일치를 확인하므로 caller의 사후 mutation으로 다른
dataset 경계를 열 수 없다.
sync access handle은 DedicatedWorker 안에서만 소유하고 `write/truncate -> verify
-> flush -> close`를 `finally`까지 보장한다. main thread에서 사용하거나
committed file을 in-place overwrite하지 않는다. portable exclusive mode를
기본으로 하고 `readwrite-unsafe`는 금지한다.
### 7.2 IndexedDB journal saga
IndexedDB와 OPFS 사이에는 cross-API atomic transaction이 없다. 다음 journal이
“committed logical row가 partial file을 가리키지 않음”을 보장한다.
```text
PREPARING
IDB: operation ID, object ID, expected/target generation, size, expiry
|
| OPFS immutable chunks + physical manifest write/flush/verify
v
FILES_READY
|
| one IDB transaction: generation CAS + logical object row + journal
v
COMMITTED <- 사용자에게 보이는 유일한 commit point
|
| staging/old physical object bounded cleanup
v
CLEANED -> journal 제거
```
보상(compensation)은 saga의 반쪽이며 다음 규칙을 따른다.
- journal row와 budget reservation은 physical cleanup effect가
`CLEANED` 또는 `ALREADY_CLEAN`으로 확인된 뒤에만 해제한다. timeout, crash,
malformed response, `EFFECT_UNKNOWN`은 성공이 아니며 `PREPARING`/`FILES_READY`를
그대로 남기고 `OBJECT_RECONCILE`로 반환한다.
- coordinator가 `abortPreparedPut()` 하나만 소유한다. worker client는 prepare 실패
시 별도의 fire-and-forget abort를 발행하지 않는다. 중복 보상은 아직 남아 있어야
할 journal row를 조기에 지우는 경로였다.
- 보상은 caller signal을 상속하지 않는다. composition이 소유한 bounded
`compensationSignal`을 사용하므로 이미 abort된 caller가 cleanup RPC 자체를
시작조차 못 하게 만들 수 없다.
- abort/cleanup은 origin mutation Web Lock을 physical 삭제와 staging 제거가 끝날
때까지 계속 보유한다. lease를 먼저 release하지 않는다. 단, staging이 아직 없는
transaction은 삭제할 것이 없으므로 lock을 기다리지 않고 `ALREADY_CLEAN`을
반환한다. 이는 자기 자신이 취소하는 BEGIN과의 deadlock을 막는다.
- `PREPARING` crash: partial staging을 검증 후 resume하거나 purge한다.
- `FILES_READY` crash: expected generation과 digest가 맞으면 idempotent logical
commit, 아니면 quarantine한다.
- `COMMITTED`인데 file이 없거나 digest가 다름: `CORRUPT_DATA`/
`STORAGE_EVICTED`; reconstructable만 rehydrate한다.
- delete는 logical row tombstone을 먼저 commit해 신규 open을 막고 physical bytes를
나중에 정리한다.
- operation ID로 replay를 deduplicate한다.
`Web Locks`를 쓰면 고정된 origin mutation lock과 AbortSignal/timeout을 사용한다.
nested lock을 금지하고 lock ordering을 문서화한다. lock은 multi-file transaction
또는 crash recovery를 대신하지 않는다.
### 7.3 민감한 policy maintenance authority
`DurableObjectMaintenancePort.enforcePolicies()` caller는 reason, 더 작은
time/object budget과 signal만 전달한다. application caller가 proof를 만들거나
전달하는 필드는 없다.
`LOGOUT`, `UNTIL_SYNCED`, `ACCOUNT_DELETION`처럼 제품 authority가 필요한 삭제를
composition하려면 `requestMaintenanceAuthority` provider와
`consumeMaintenanceAuthority` consumer를 **둘 다** 주입한다. 하나라도 없으면
runtime은 삭제 전 `POLICY_REJECTED`와 `READ_ONLY` recovery로 fail-closed한다.
- provider는 매 invocation의 exact reason, frozen scope, frozen
`BrowserStoragePolicy`에 묶인 새 opaque proof와 expiry를 발급한다.
- runtime은 proof 형식과 현재 시각 기준 양수 expiry를 검증하고 수명을 최대
5분으로 제한한다.
- consumer는 같은 reason/scope/policy/expiry를 다시 확인하고 proof를 원자적으로
consume한다. replay, scope/action/policy mismatch와 expiry를 거절해야 한다.
- proof는 provider에서 consumer로만 전달한다. application port로 반환하거나
저장·telemetry·diagnostic에 기록하지 않는다.
`TTL`, `SESSION_END`, `PRESSURE` maintenance에는 이 proof protocol을 적용하지
않지만 frozen policy eligibility와 bounded budget은 그대로 강제한다.
### 7.4 integrity, GC와 fallback
큰 object에 Web Crypto whole-file digest를 사용하지 않는다. 고정된 bounded
chunk마다 SHA-256을 계산하고 아래처럼 algorithm ID가 포함된 tree root를 쓴다.
```text
SHA-256-TREE-V1(
canonical(totalBytes, chunkSize, ordered chunk byteLength + digest)
)
```
server whole-file SHA-256과 같은 값이라고 주장하지 않는다. server protocol이
whole-file hash를 요구하면 vetted incremental implementation을 별도 공급망/
bundle review로 선택한다.
GC는 exclusive mutation lock 아래 mark/sweep을 bounded batch로 수행한다.
1. stale staging
2. expired reconstructable object
3. unreferenced chunk에 grace period
4. tombstoned physical object
5. synced copy
committed user-authored object는 자동 quota GC 대상이 아니다. max entries/bytes/time,
cursor, 다음 실행 시점과 visibility/battery 정책을 둔다.
제품 composition의 목표 capability detection은 API property 확인만으로 끝내지
않는다. secure context, DedicatedWorker round-trip, small write/read/delete probe,
IndexedDB journal과 필요한 lock을 확인한다. 현재
`inspectBrowserOpfsSupport()`는 property/primitive 확인까지만 수행하고 실제
small-operation preflight는 구현하지 않았다. VD-15가 probe scope, deadline,
cleanup과 readiness mapping을 정의한다. 목표 fallback 순서는 다음과 같다.
```text
OPFS + IndexedDB journal
-> product-approved, size-capped IndexedDB Blob
-> online-only/unavailable
```
silent in-memory “durable” fallback은 금지한다.
## 8. Cache Storage
### 8.1 의미
Cache Storage는 browser HTTP cache와 분리된 script-managed
`Request`/`Response` map이다.
- 자동 TTL, freshness, revalidation, eviction ordering을 제공하지 않는다.
- Service Worker script update나 unregister가 cache를 자동 삭제하지 않는다.
- Window/Worker에서도 사용할 수 있어 Service Worker 설치가 필수는 아니다.
- offline fetch interception을 한다면 한 Service Worker runtime만 owner다.
- TanStack Query entity cache나 application repository로 사용하지 않는다.
raw `Cache`, `Request`, `Response`는 platform-local adapter 안에 둔다. application
port에서 generic `cache.get/set`을 제공하지 않는다.
### 8.2 허용·금지 정책
기본 허용:
- build manifest에 등록된 same-origin content-hashed JS/CSS/font/image
- HTTP 200
- expected media type, byte length, integrity가 모두 일치
- request credentials `omit`
- public data classification
- 선택형 runtime cache는 anonymous public GET, explicit URL class, TTL/entry/byte
limit과 owner가 있을 때만
기본 금지:
- `/config.json`, `/release-manifest.json`
- bootstrap HTML; 별도 static offline document만 예외 review
- auth/API/user/tenant URL
- Authorization, cookie-dependent request, credentials include
- private, personal, confidential representation
- `Cache-Control: no-store`, `private`, 보수적으로 `no-cache`
- `Set-Cookie`, `Vary: Cookie`, `Vary: Authorization`, `Vary: *`
- opaque/opaqueredirect/error/redirect response
- 206 partial content, non-GET
- 검증할 수 없는 content type/size/integrity
표준 Cache API가 위 정책을 자동 강제한다고 가정하지 않는다. adapter가 put 전에
검증한다. opaque response는 body/header/integrity를 검사할 수 없으므로 기본
거절한다.
`PublicCacheAsset.expectedContentType`은 선택 metadata가 아니라 manifest binding의
일부다. adapter는 media type을 정규화하고 canonical manifest의
`URL + request headers + expectedByteLength + expectedContentType + SHA-256`에
포함해 `manifestDigestHex`를 검증한다. fetch response의 정규화된
`Content-Type`이 이 값과 정확히 다르면 body digest가 맞아도
`INTEGRITY_FAILED`이고 candidate를 활성화하지 않는다.
default match의 query와 `Vary` 의미를 보존한다. fragment는 cache key에서
제외하지만 query는 유지한다. `ignoreSearch`, `ignoreMethod`, `ignoreVary`는
production policy에서 금지한다. 전체 `CacheStorage.match()` 대신 정확한 owned
cache name을 열어 match한다.
response를 network consumer와 cache 양쪽에 전달하면 body를 소비하기 전에
`clone()`한다. clone fan-out과 backpressure 때문에 cache entry byte upper bound를
강제한다.
### 8.3 release candidate와 activation
cache에는 rename API가 없으므로 name만 바꿔 atomic release라고 주장하지 않는다.
```text
build-produced manifest
-> candidate cache 생성
-> exact URLs fetch(credentials omit)
-> type/size/integrity 검증 후 put
-> verified marker + manifest digest
-> composition owner의 explicit activation
-> active release pointer
-> current + previous grace retain
-> incomplete candidate와 오래된 owned cache cleanup
```
한 entry라도 실패하면 candidate는 incomplete이며 active로 전환하지 않는다.
static Cache-only 조합은 waiting worker, `skipWaiting()`, `clients.claim()` 또는
controlled-client drain을 activation 전제나 성공 증거로 사용하지 않는다.
composition owner가 exact release ID와 manifest digest로
`activateRelease(releaseRegistryId, manifestDigestHex)`를 호출하고, current +
previous 또는 승인된 grace window를 유지한다. rollback은 verified previous
release만 대상으로 한다.
Service Worker를 별도 선택한 조합만 VD-15 section 9.2의 waiting worker/page update
controller protocol을 추가한다. 이 branch에서 page controller가 dirty form,
active transfer와 compatibility를 확인하고 versioned activation을 승인하며, old
controlled client가 drain되기 전에는 그 client가 사용할 release를 cleanup
eligible로 만들지 않는다.
cleanup caller는 retain cache name, release registry ID 또는 retain list를
제출하지 않는다. adapter가 검증된 active pointer와 composition의
`retainedPreviousReleaseCount`에서 보존 집합을 계산하고 owned-prefix 안의
나머지만 삭제한다. active pointer와 release marker 같은 control JSON도
`Response.json()`/`text()`로 무제한 materialize하지 않는다. content-length
preflight와 정확히 2 MiB(2,097,152 bytes) cap의 stream reader로 완전히 읽은 뒤
strict UTF-8와 runtime schema를 검증하며, cap을 넘으면 reader를 cancel하고
`CORRUPT_DATA`로 fail-closed한다.
현재 `cleanupOwned()`와 `inspect()`는 위 ownership/integrity 경계를 지키지만
cache count에 대한 cursor, per-invocation max count와 deadline은 제공하지 않는다.
따라서 cache 수에 비례한 unbounded maintenance를 production boot path에
연결하지 않는다. VD-15의 목표 runtime은 active-pointer epoch와 policy에 묶인
opaque cursor, 기본 100 caches/5초와 절대 500 caches/30초 상한을 적용한다.
현재 static release runtime의 previous retain과 기존
`activateRelease(releaseRegistryId, manifestDigestHex)`는 검증된 이전 release를
다시 활성화할 수 있는 primitive다. 다만 Service Worker waiting/activation,
old controlled-client drain, `skipWaiting`/`clients.claim` 승인과 실제 rollback
controller는 구현·조립하지 않았다. Cache Storage runtime을 사용한다는 이유만으로
Service Worker lifecycle이 설치됐다고 표현하지 않는다.
다음 fetch 전략은 Service Worker 또는 명시적인 fetch owner를 별도 선택·조합한
경우의 목표다. static Cache-only runtime은 navigation이나 global fetch를
intercept하지 않고 verified release의 explicit lookup/activation primitive만
제공한다. 특히 navigation 전략에는 Service Worker 선택이 필요하다.
fetch 전략은 route class별로 고정한다.
| request class | 전략 |
| --- | --- |
| immutable hashed asset | exact active cache-first |
| navigation | network-first + 명시적 static offline fallback |
| runtime config/release manifest/auth/API | network-only |
| approved public media | bounded TTL metadata가 있는 경우에만 stale-while-revalidate |
Cache API 자체는 TTL을 계산하지 않으므로 runtime-public entry metadata와 prune
owner가 따로 있어야 한다.
Service Worker를 선택한 조합에서 unregister는 cache removal이 아니다. worker
unregister와 parsed owned-prefix cleanup migration을 함께 배포하고, 기존
controlled client가 drain될 때까지 old worker가 동작할 수 있음을 고려한다.
static Cache-only 조합은 worker 절차 없이 verified pointer/retention에서 계산한
owned-prefix cleanup만 수행한다. 어느 branch도 `caches.keys()` 전체를 삭제하지
않는다.
## 9. 닫힌 failure vocabulary
recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기 위한
경계다.
| code | 대표 원인 | 기본 복구 |
| --- | --- | --- |
| `ABORTED` | pre-commit user/caller cancellation | 조용히 종료 또는 명시적 retry |
| `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback |
| `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 |
| `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 |
| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI |
| `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read |
| `MIGRATION_FAILED` | schema/data migration | read-only/online-only |
| `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry |
| `CORRUPT_DATA` | codec/manifest invalid | 분류별 quarantine/re-fetch/export |
| `NOT_READABLE` | I/O/forced close | bounded reopen 후 degrade |
| `INTEGRITY_FAILED` | byte length/digest/signature mismatch | commit 금지, quarantine |
| `STORAGE_EVICTED` | origin data loss | sentinel 확인, reconstructable rehydrate |
| `EXPIRED_RESOURCE` | upload/download session expiry | authorization 후 새 session |
| `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback |
| `UNSUPPORTED` | capability absence | baseline/online-only |
user dismissal은 failure가 아니라 outcome이다. browser DOMException name은
adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded
diagnostic에도 기본 저장하지 않는다.
byte stream도 이 vocabulary 밖의 예외 통로를 만들지 않는다. File/OPFS/Cache와
download recipe의 stream은 chunk마다 closed Result를 내보내며, failure chunk
하나를 보낸 뒤 종료한다. raw native error를 throw하거나 실패 뒤 추가 byte를
내보내는 adapter는 contract 위반이다.
## 10. 관측성
허용 event 예:
- `file.selection.rejected`
- `file.download.handed_off`
- `file.download.completed`
- `file.transfer.failed`
- `storage.database.opened`
- `storage.upgrade.blocked`
- `storage.migration.failed`
- `storage.quota.pressure`
- `storage.fallback.entered`
- `opfs.recovery.completed`
- `cache.release.staged`
- `cache.operation.failed`
- `service_worker.update.state`
별도 backend upload example을 설치한 feature만 자기 allowlist에
`file.upload.completed`와 quarantine/session event를 추가한다.
허용 attribute:
- operation/phase/outcome/failure kind
- strategy/backend/data class/cache role
- size, duration, retry, entry count, pressure ratio의 **bucket**
- registry-owned database/store/migration/release ID
- total known 여부, persistence mode
금지 attribute:
- filename, local path, relative path
- object URL, download/signed URL, query string
- resource/object/record key, hash/digest
- raw MIME와 header/body
- account, tenant, email 또는 user ID
- exact usage/quota와 raw DOMException message/stack
integrity mismatch, unauthorized/private response cache 시도, committed user data
corruption은 한 건도 canary promotion을 중지한다. 일반 latency/error rate는
production-like baseline과 minimum sample size가 정해진 뒤 threshold를 승인한다.
## 11. 테스트와 promotion evidence
fake contract test만으로 native adapter를 production-ready로 선언하지 않는다.
### 11.1 unit/property
- filename path/control/bidi/Unicode/reserved-name sanitizer
- count/size/total/zero/unsafe integer 경계
- MIME-extension-signature 모든 불일치
- chunk boundary, closed Result failure termination, checksum, idempotency,
progress monotonicity
- codec version encode/decode, hostile/oversized/cyclic input
- migration planner 연속성, pressure hysteresis
- cache URL/query/Vary/header/classification policy
- DOMException mapping과 diagnostic redaction
### 11.2 deterministic contract/fault injection
- 별도 upload example을 선택했다면 part conflict, complete/abort race, session
expiry
- delayed/truncated stream, wrong/missing declared length
- IndexedDB request success 뒤 transaction commit failure
- atomic multi-record abort, CAS 경쟁, quota injection
- upgrade/data migration 각 checkpoint crash와 resume
- OPFS `PREPARING`, `FILES_READY`, `COMMITTED` 직후 worker termination
- partial chunk, corrupt/truncated manifest, missing committed file
- Cache candidate 한 entry 실패와 active release 불변
- persistence denied, storage clear/eviction sentinel mismatch
### 11.3 실제 browser
Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한다.
`test:browser-capabilities`의 JUnit을
`verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성,
양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다.
현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2,
IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2,
presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다.
promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두
실행되어야 한다. 이 host의
WebKit은 필수 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도
Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를
충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한
system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야
한다.
- native input keyboard/focus/same-file reselection/multiple/dismissal
- Chromium conditional picker/save enhancement와 다른 engine fallback
- download event와 ASCII/UTF-8 filename
- 큰 synthetic stream의 queued-byte high-water mark
- IndexedDB fresh/upgrade/versionchange/blocked를 same-origin 두 page로 검증
- concurrent CAS와 BroadcastChannel loss 후 authoritative re-read
- native BroadcastChannel delivery와 localStorage-event fallback cleanup
- 실제 OPFS create/open/read/remove, worker handle close
- release A active → B complete/incomplete candidate → offline → 승인 activation →
old/new client → rollback/cleanup
- fixed BFF capability 발급 → verified streaming download → writable close
- native IndexedDB/Web Locks와 cross-origin fetch를 통한 3-part resumable upload
- allowlisted Image CDN response의 static header metadata 검사와 실제 bitmap decode
- private mode/WebView/unsupported path의 explicit degraded state
quota를 실제로 가득 채우는 flaky test는 merge gate의 유일한 근거로 쓰지 않는다.
deterministic injection을 merge gate로, 실제 pressure/clear를 staging/manual drill로
유지한다. picker/OS UI와 storage eviction은 실제 device manual evidence도
필요하다.
### 11.4 negative static gate
`check:browser-file-storage-boundaries`는 다음 source 위반을 거절한다.
- application/presentation의 raw `indexedDB`, `caches`,
`navigator.storage.getDirectory()`, `FileSystem*`
- production `download(): Uint8Array`와 unbounded Blob/arrayBuffer/text
- preview facade 밖 `URL.createObjectURL`
- `File.type`/`accept`만으로 valid 판정
- transaction 안의 fetch/timer/unrelated await
- sync access handle의 main-thread 또는 `readwrite-unsafe` 사용
- cache의 auth/private/no-store/opaque/ignoreVary 허용
- raw filename/path/key/URL/hash/value telemetry
- credential persistence
- recipe source의 production direct import
### 11.5 production bundle과 removability evidence
Vite plugin은 emitted chunk마다 실제 source module ID를
`.vite/module-inventory.json`에 기록하고 release artifact로
`artifacts/quality/vite-module-inventory.json`을 보존한다.
`check:optional-recipes`는 `productionComposition: false`인 동안 File/Blob,
IndexedDB, OPFS, Cache Storage reference runtime source prefix가 inventory에 한
개라도 있으면 실패한다. minified marker 검색은 보조 방어이고 module inventory가
권위 있는 bundle 증거다.
`test:browser-file-storage-removal`은 runtime source root, 전용 browser test/gate,
catalog의 세 `referenceRuntime` metadata를 격리 copy에서 제거한 뒤 typecheck,
lint, architecture, 전체 test, build, optional catalog와 CI contract를 다시
실행한다. 이 removal gate와 production module inventory가 모두 통과해야
optional runtime이 skeleton core에 결합되지 않았다고 판단한다.
## 12. rollout과 rollback
실제 도입은 한 번에 모든 기술을 켜지 않는다.
1. 데이터 catalog, owner와 fallback 승인; upload를 선택한 경우에만 backend
protocol 별도 승인
2. feature-specific port와 codec/schema 구현
3. fake contract + historical fixture + real-browser adapter test
4. runtime default off, capability probe와 online-only fallback
5. internal cohort에서 read-only/shadow write 검증
6. 작은 cohort write, quota/recovery/rollback drill
7. N-1 reader와 migration compatibility 확인 후 확대
8. product value가 확인된 user action에서만 persistence 설명/요청
kill switch는 좁게 분리한다.
- enhanced picker/save off → native input/direct authorized download
- preview off
- 별도 upload workflow를 설치한 경우 resumable upload off → bounded simple upload
- offline read-write → read-only → online-only
- OPFS writes off → approved size-capped fallback 또는 online-only
- Cache Storage/SW off → network-only
- new worker activation off → verified current/previous 유지
schema version은 내리지 않는다. rollback bundle은 future schema를 감지해
read-only/online-only로 살아나야 한다. destructive DDL, old store/cache 삭제는
승인된 rollback window가 지난 뒤 별도 release에서 실행한다.
운영 절차는 `docs/operations/browser-file-storage-recovery.md`, decision은
`docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md`를 따른다.
## 13. 공식 기준
- [W3C File API](https://w3c.github.io/FileAPI/)
- [WHATWG HTML file upload state](https://html.spec.whatwg.org/multipage/input.html#file-upload-state-(type=file))
- [WHATWG HTML downloading resources](https://html.spec.whatwg.org/multipage/links.html#downloading-resources)
- [WICG File System Access](https://wicg.github.io/file-system-access/)
- [WHATWG File System](https://fs.spec.whatwg.org/)
- [WHATWG Streams](https://streams.spec.whatwg.org/)
- [WHATWG MIME Sniffing](https://mimesniff.spec.whatwg.org/)
- [RFC 6266 Content-Disposition](https://www.rfc-editor.org/rfc/rfc6266.html)
- [W3C Indexed Database API 3.0](https://w3c.github.io/IndexedDB/)
- [WHATWG Storage](https://storage.spec.whatwg.org/)
- [W3C Service Workers and Cache](https://w3c.github.io/ServiceWorker/)
- [RFC 9111 HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111.html)
- [W3C Web Locks](https://w3c.github.io/web-locks/)
- [W3C Web Cryptography](https://w3c.github.io/webcrypto/)
File System Access와 일부 IndexedDB 3.0 기능은 evolving specification이다.
cross-browser baseline은 실제 Chromium/Firefox/WebKit evidence로 고정하고, 새 API
존재만으로 제품 fallback을 제거하지 않는다.