chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
@@ -0,0 +1,64 @@
# VD-01: TypeScript 7과 ESLint 10의 점진적 전환 도구
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-typescript-tooling-foundation`
## 배경
저장소는 TypeScript `7.0.2`와 ESLint `10.8.0`을 고정하고 있다. 첫 전환
브랜치는 compiler를 변경하거나 production source를 일괄 변환하지 않고
JS/JSX/TS/TSX가 같은 품질 게이트를 통과하게 해야 한다.
결정 시점의 package peer contract는 다음과 같다.
- `typescript-eslint@8.65.0`과 canary는 TypeScript `<6.1.0`을 요구한다.
- `eslint-plugin-jsx-a11y@6.10.2`는 ESLint `<=9`를 요구한다.
- `eslint-plugin-react-hooks@7.1.1`은 ESLint 10을 지원한다.
- Babel 8 ESLint parser는 ESLint 10을 지원하고 Node `>=24.11.0`을 요구한다.
호환되지 않는 peer dependency를 강제 설치하면 lockfile 검증은 통과하더라도
지원되지 않는 parser와 rule 조합을 플랫폼 계약으로 만들게 된다.
## 결정
1. TypeScript `7.0.2`와 ESLint `10.8.0`을 유지한다.
2. TypeScript/TSX의 ESLint syntax parsing에는
`@babel/eslint-parser`와 TypeScript/JSX syntax plugin을 사용한다.
3. TypeScript의 이름 해석, unused 진단과 type semantics는 `tsc`가 소유한다.
Babel parser가 TypeScript scope manager를 제공하지 않으므로 TS 파일의
core `no-undef``no-unused-vars`는 끄고 분리된 app/node/test TypeScript
project를 필수 게이트로 실행한다.
4. React Hook 규칙은 호환되는 `eslint-plugin-react-hooks`로 즉시 적용한다.
5. JSX 접근성은 현재의 semantic component contract, Testing Library,
axe 기반 cross-browser gate와 수동 검토 계약이 계속 담당한다. 호환되지 않는
`eslint-plugin-jsx-a11y`는 설치하지 않는다.
6. Babel 8의 지원 범위에 맞춰 Node engine 하한을 `24.11.0`으로 명시한다.
7. production source의 대량 rename은 이 결정에 포함하지 않는다.
## 적용 후 상태 (2026-07-27)
후속 migration에서 production source, 비-fixture tests, Node scripts와 지원되는
tool config를 모두 TS/TSX로 전환했다. `allowJs`는 껐고 runtime/source 영역의
JavaScript 재유입은 architecture gate가 거절한다. Node scripts는 pinned Node
24에서 `.ts`로 직접 실행되며 NodeNext, `verbatimModuleSyntax`
`erasableSyntaxOnly`로 별도 typecheck한다. `tests/fixtures/**`도 TS/TSX
architecture/security/type negative input으로 전환했다. 이는 7번 결정의 범위를
변경한 것이 아니라 그 기반 위에서 완료한 별도 후속 작업이다.
## 검증
- `check:types`는 app, Node scripts/config, tests project를 모두 검사한다.
- TS invalid-call, invalid port, discriminated-union fixture는 실패해야 한다.
- ESLint와 dependency-cruiser는 TS/TSX architecture fixture를 검사한다.
- registry scanner는 TS registry의 required field, uniqueness와 reference를
검증한다.
- browser security gate는 TSX의 금지된 raw HTML fixture를 거절한다.
## 후속 검토와 제거
`typescript-eslint`가 TypeScript 7을, JSX 접근성 plugin이 ESLint 10을 공식
지원하면 별도 dependency 브랜치에서 peer metadata와 전체 negative fixture를
재검증한다. 교체할 때는 Babel parser package와 TS 전용 ESLint override를
함께 제거한다. compiler downgrade나 `--force` 설치는 이 ADR의 rollback
방법이 아니다.
@@ -0,0 +1,58 @@
# VD-03: React Router Data Mode와 서버 상태 소유권
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-routing-release-recovery-runtime`
## 배경
기존 라우터는 `BrowserRouter`와 수동 JSX route 목록을 사용했다. 직렬화 가능한
route registry에 params/search schema, loading/error surface, access, title,
navigation과 chunk ID가 있었지만 실행 route tree와 독립적이어서 선언과 행동이
어긋날 수 있었다.
이 저장소는 client-only SPA이며 서버 상태는 application input과 TanStack Query가
소유한다. Framework Mode의 loader/action 중심 데이터 소유권이나 SSR을 도입하지
않으면서 route object, 오류 경계와 navigation lifecycle은 중앙에서 조립할
필요가 있다.
## 결정
1. 고정된 React Router `7.18.1``createBrowserRouter``RouterProvider`
사용하는 Data Mode를 기본값으로 채택한다.
2. 직렬화 가능한 route contract와 React component/codec runtime map을 분리한다.
3. 모든 executable route object와 navigation은 registry에서 생성한다. JSX에서
route 목록을 다시 열거하지 않는다.
4. params/search는 route 경계의 Zod codec으로 parse하고 같은 codec으로 canonical
URL을 생성한다.
5. loader/action은 같은 서버 데이터를 직접 다시 요청하지 않는다. 필요하면
application input 또는 query adapter 한 경로를 호출한다.
6. 서버 상태, retry, cache와 mutation lifecycle은 application input과 TanStack
Query가 계속 소유한다.
7. lazy chunk rejection만 release recovery input으로 보내며 일반 render error는
route/feature boundary가 소유한다.
8. Framework Mode, SSR, static generation과 router version upgrade는 별도
dependency/architecture 브랜치에서 결정한다.
## 검증
- route contract/runtime map의 누락과 orphan은 TypeScript negative fixture와
registry gate가 모두 거절한다.
- duplicate ID/path, unknown codec/surface/chunk와 참조 불일치를 negative registry
fixture로 검증한다.
- params/search parse/build round-trip, canonical redirect, 최대 redirect hop,
access rejection, title/focus와 boundary reset을 unit/component test로 검증한다.
- Vite dynamic entry와 release route chunk map, runtime config JSON Schema를
build/release 검증기가 확인한다.
- chunk failure는 no-store manifest refetch 후 build/release 쌍마다 한 번만
reload하며 offline, malformed manifest와 storage 실패는 fail-closed한다.
## 결과와 rollback
Data Router는 navigation lifecycle의 조립 경계이며 서버 데이터 계층이 아니다.
이 구분을 지키면 React Router를 교체해도 application input과 output port는
유지된다.
rollback은 RP-04 merge를 되돌려 이전 수동 router와 generic route failure
surface로 복구한다. URL shape와 application API는 유지하고, 이미 배포된 asset
cache의 purge는 저장소 rollback 범위에 포함하지 않는다.
@@ -0,0 +1,55 @@
# VD-04: Native form controller와 local facade
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-form-page-platform`
## 배경
플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
field orchestration까지 플랫폼 기본값으로 고정하게 된다.
## 결정
1. RP-06은 React native form event와 controlled value를 사용하는 local
`useAppForm` facade를 기본 엔진으로 채택한다.
2. Zod presentation schema, application command mapper와 domain invariant는 서로
다른 소유물로 유지한다.
3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
`mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
4. 422 details는 승인된 `path``code`만 HTTP 경계에서 투영한다. backend
message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
error로 이동한다.
5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
dirty 상태를 해제한다.
7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`
layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
## React Hook Form 도입 조건
다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
React Hook Form과 Zod resolver를 평가한다.
- 동적 field array와 중첩 object를 함께 다루는 복합 form
- field 단위 비동기 validation 취소와 의존 validation
- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
- uncontrolled input 또는 vendor extension이 필요한 경우
도입하더라도 이 문서의 public API와 component/application tests를 유지해야
한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
## 검증과 rollback
- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
reset, dirty guard와 focus를 component test로 검증한다.
- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
- architecture gate가 template의 application/HTTP/query vendor import를
거절한다.
- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
application input과 outbound gateway 계약은 유지되며, form facade와 template
commit은 독립적으로 되돌릴 수 있다.
@@ -0,0 +1,57 @@
# VD-05: Semantic icon facade와 native-first interaction
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-design-system-platform`
- 재검토: native 계약으로 충족할 수 없는 widget 요구가 확인될 때
## 배경
앱 셸과 공통 UI는 문자 glyph, raw button/select와 페이지별 focus 처리를
사용했다. 아이콘 공급자와 복합 interaction을 제품 코드에 직접 노출하면 번들,
접근성, vendor type과 교체 비용이 모든 feature로 전파된다. 반대로 실제 요구가
없는 두 개의 headless vendor를 기본 설치하면 skeleton 소비자가 제거해야 할
의존성과 중복 interaction 모델이 생긴다.
## 결정
1. 아이콘 공급자는 lockfile 최소 게시 유예를 통과한 `lucide-react@1.25.0`으로
고정한다.
2. `lucide-react`의 static named import는
`design-system/icons/vendors/lucide.tsx` 한 파일에서만 허용한다.
3. public API는 `MenuIcon`, `CloseIcon`, `WarningIcon` 같은 의미 이름만
노출한다. vendor component type, icon name, stroke API와 dynamic icon
registry는 노출하지 않는다.
4. 장식 아이콘은 accessibility tree에서 제외한다. 정보를 단독 전달하는
아이콘은 `label`, icon-only action은 필수 `accessibleName`을 사용한다.
5. 현재 복합 control은 native `dialog`, form control, `details`와 local
TypeScript state model로 구현한다. Menu는 roving focus/typeahead/Escape,
Tabs는 manual/automatic activation, Drawer는 modal/background
비활성화/focus restore 계약을 가진다.
6. React Aria와 Radix는 기본 dependency로 추가하지 않는다. native platform이
collision, nested overlay, virtualized collection 또는 복합 select 요구를
충족하지 못한다는 재현 가능한 요구가 생길 때 prototype과 ADR로 다시
평가한다.
7. Storybook과 pinned visual baseline은 VD-08/RP-10에서 도입한다. RP-07의
runtime gallery와 browser interaction test는 해당 workshop을 대체한다고
주장하지 않는다.
## 경계와 검증
- 제품 코드는 `presentation/design-system/index`만 import한다.
- design-system 검사기는 direct icon/headless import, deep import, raw palette,
undefined token과 tooltip-only required information fixture를 거절한다.
- type negative fixture는 accessible name 없는 `IconButton`을 거절한다.
- component test는 decorative icon, form control, Menu, Tabs, Drawer와 Toast를
검증한다.
- Chromium/Firefox E2E는 compact Drawer의 native modal 상태, Escape, focus
restore, gallery keyboard interaction과 axe를 검증한다.
- 로컬 WebKit 실행은 host `libevent-2.1.so.7` 부재로 환경 검증이 남아 있으며
공급자 선택이나 product behavior의 PASS로 숨기지 않는다.
## Rollback
기존 `presentation/components/ui/*` 경로는 canonical TypeScript primitive를
재수출하므로 소비 코드를 즉시 되돌릴 수 있다. Lucide 제거 시 vendor facade와
semantic icon 구현만 교체하고 제품 API는 유지한다. headless vendor를 나중에
도입해도 public props와 interaction test를 유지한다.
@@ -0,0 +1,96 @@
# VD-06: Intl과 typed local message catalog
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-i18n-message-formatting-contract`
- 재검토: 승인 locale·복수형 문법·번역 추출 workflow가 local catalog 범위를 넘을 때
## 배경
공통 셸, route surface, async/form 상태와 디자인 시스템 기본 문구가 JSX와
JavaScript에 분산돼 있었다. 날짜는 일부 application mapper에서 고정 locale로
가공되어 presentation이 locale을 바꿀 수 없었고, direction·누락 key·보간 실패
정책도 없었다. 반면 현재 skeleton에는 번역 관리 서비스, 실제 번역 승인 절차,
복잡한 ICU 문법이라는 제품 요구가 아직 없다. 이 단계에서 i18n vendor를 기본
번들에 넣으면 소비 프로젝트가 제거하거나 다시 감싸야 할 의존성만 늘어난다.
## 결정
1. 표준 `Intl.DateTimeFormat`, `NumberFormat`, `RelativeTimeFormat`,
`ListFormat`, `PluralRules`와 typed local catalog를 기본 엔진으로 사용한다.
2. `MessageKey`는 한국어 canonical catalog에서 도출하며 영어와 RTL smoke
catalog는 `satisfies Record<MessageKey, string>`으로 compile-time parity를
강제한다.
3. 보간이 필요한 key는 `MessageParameters`에 key별 parameter object를
선언한다. 잘못된 key, 누락·초과 parameter는 TypeScript negative fixture가
거절한다.
4. 기본 locale은 `ko-KR`, fallback locale도 `ko-KR`이다. 알려지지 않은 locale은
language fallback 후 `ko-KR`로 정규화한다. 알려지지 않은 key와 누락 보간은
raw key나 외부 값을 출력하지 않고 안전한 공통 fallback을 반환한다.
5. `en-XA`는 영어 문구를 확장·accent 처리하는 pseudo locale이고 `ar-EG`
RTL 동작 smoke locale이다. 이 두 locale은 실제 제품 번역 완료를 의미하지
않는다.
6. 날짜 formatter의 기본 timezone은 테스트와 SSR/브라우저 결과가 흔들리지
않도록 `UTC`다. 제품 timezone이 필요하면 호출자가 명시한다. invalid
date/number/timezone은 `—`를 반환하고 throw하지 않는다.
7. locale state는 React inbound concern이다. `LocaleProvider`가 copy,
formatter와 `<html lang/dir>`을 제공하며 application/domain은 미리 번역된
문자열 대신 의미 값과 timestamp를 반환한다.
8. backend `message`, raw HTML, stack과 내부 key를 catalog 입력으로 신뢰하지
않는다. transport/application failure kind를 등록된 사용자 message key로
매핑한 뒤 presentation이 해석한다.
9. key rename은 즉시 제거하지 않고 `MESSAGE_KEY_ALIASES`에 compatibility alias를
둔다. alias는 새 호출의 타입에 포함하지 않아 신규 코드는 canonical key만
사용한다.
10. extraction, ICU rich message, 번역 SaaS 또는 framework adapter가 필요해지면
`presentation/i18n` public API 뒤에서 교체한다. vendor type은 feature와
design-system public prop으로 노출하지 않는다.
## 실행 경계
```text
route/form/failure 의미 값
-> presentation message key
-> LocaleProvider
-> typed catalog / Intl formatter
-> text node와 accessible name
```
- canonical catalog: `src/presentation/i18n/catalog.ts`
- feature contribution: `src/features/*/contracts/*-message-catalog.ts`
`src/features/installed-feature-messages.ts`에서 조립
- key/보간/fallback/alias: `message-contract.ts`
- locale-safe value formatting: `formatters.ts`
- React composition과 document metadata: `locale-provider.tsx`
- public entry: `src/presentation/i18n/index.ts`
## 검증
- `check:i18n`은 catalog key와 placeholder parity, common UI의 한국어 literal,
backend message JSX 렌더링과 raw HTML 사용을 검사한다.
- `check:i18n:fixture`는 세 금지 사례를 실제로 거절해야 성공으로 인정된다.
- type negative fixture는 unknown key와 잘못된 parameter shape를 거절한다.
- unit test는 fallback, alias, pseudo 확장, direction과 timezone/number/relative/
list/plural/select의 결정성을 검증한다.
- component test는 document `lang/dir`, RTL Tabs와 direction-aware pagination,
Drawer semantics를 검증한다.
- Playwright는 320px pseudo reflow와 RTL compact shell/Drawer/focus restore를
Chromium, Firefox, WebKit project에서 실행한다.
## 한계와 재검토 조건
현재 catalog는 실제 번역 승인, ICU rich text, locale별 plural 문장 전체 조합,
메시지 추출/번역 메모리와 서버 locale negotiation을 제공하지 않는다. 다음 중
하나가 확인되면 별도 ADR로 엔진을 재평가한다.
- 세 개 이상의 실제 승인 locale과 번역 담당 workflow
- 복수형·성별·select가 한 문장 안에서 중첩되는 제품 copy
- server/client extraction, namespace lazy-loading 또는 번역 SaaS 연동
- SSR locale negotiation과 hydration 일치가 필요한 rendering mode
## Rollback
`ko-KR` catalog가 기존 기본 문구를 보존하므로 provider를 고정 locale adapter로
되돌려도 기본 UX를 유지한다. formatter/vendor 교체 시 public `message`,
`date`, `number`, `relativeTime`, `list`, `plural`, `select` 계약과 negative
fixture는 유지한다. alias는 migration window 종료 근거 없이 제거하지 않는다.
@@ -0,0 +1,97 @@
# VD-07: Diagnostics와 telemetry exporter 경계
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-diagnostics-telemetry-runtime`
- 재검토: 실제 운영 sink, consent가 필요한 analytics 또는 분산 tracing provider가
선정될 때
## 배경
기존 telemetry registry와 best-effort HTTP queue는 있었지만 운영 진단 record와
semantic event의 책임이 하나의 telemetry port에 섞여 있었다. boot, HTTP,
cache, storage, route와 release failure의 선언도 실제 production producer와
완전히 연결되지 않았다. 이 상태에서는 retry attempt마다 같은 사건을 발행하거나
raw URL, query, request body와 오류 객체가 queue에 들어갈 위험이 있다.
반면 skeleton 단계에는 실제 관측 vendor, endpoint의 운영 보안 정책, analytics
consent와 보존 기간이 결정되지 않았다. 특정 SDK를 기본 번들에 설치하는 것은
vendor 결정 전에는 안전한 기본값이 아니다.
## 결정
1. level 기반 운영 진단은 `DiagnosticsPort`, registry 기반 semantic event는
`TelemetryPort`로 분리한다. application은 두 port의 concrete adapter나
exporter SDK를 알지 못한다.
2. diagnostics의 level, event ID와 context key는 닫힌 registry/allowlist다.
telemetry도 event별 required/optional attribute와 value policy를 적용한다.
등록되지 않은 event·context·고카디널리티 값은 전송하지 않는다.
3. 기본 diagnostics adapter는 bounded in-memory evidence이고 telemetry는
설정이 없으면 true no-op이다. endpoint가 있을 때만 bounded oldest-drop
queue와 best-effort HTTP sink를 사용한다.
4. raw path/URL/query/body/response/storage value, credential, cookie, email,
stack과 오류 객체 전체는 context에 넣지 않는다. route ID, operation ID,
correlation ID, release ID, error kind, status/attempt/duration bucket만
허용한다.
5. HTTP logical execution은 success, retry recovery, terminal failure 또는
abort마다 `http.request.completed` diagnostics를 정확히 한 번 남긴다.
`api.request.failed` telemetry는 retry가 끝난 terminal non-abort failure에만
정확히 한 번 발행한다.
6. `app.boot.failed`, `ui.render.failed`, `release.mismatch.detected`,
`telemetry.delivery.dropped`를 production path에 연결한다. cache와 storage
실패는 diagnostics로 기록하되 raw key/value를 기록하지 않는다.
7. queue full, invalid event/context, serialization과 sink failure는 제한된
reason bucket으로 집계한다. drop observer의 failure는 다시 telemetry를
발행하지 않는 nonrecursive 경계다.
8. diagnostics와 telemetry failure는 제품 흐름, HTTP 결과, route transition,
storage/cache fallback과 React error surface를 바꾸지 않는다.
9. mount 전 bootstrap failure는 안전한 build/config/error kind만 별도 evidence로
만들며 untrusted error message, stack과 support 입력을 serialize하지 않는다.
10. 실제 error reporter, RUM, analytics나 tracing SDK는 같은 port 뒤의 외부
adapter로만 추가한다. SDK type과 event API를 application/feature/presentation
public contract에 노출하지 않는다.
## 실행 경계
```text
route/application/HTTP/cache/storage/bootstrap
-> typed DiagnosticsPort 또는 TelemetryPort
-> registry + allowlist + value policy
-> bounded memory/no-op 또는 best-effort HTTP adapter
-> 프로젝트가 선택한 외부 sink
```
- diagnostics contract: `src/contracts/diagnostics.ts`
- telemetry contract: `src/contracts/telemetry.ts`
- application ports: `src/application/ports/diagnostics-port.ts`,
`telemetry-port.ts`
- bounded diagnostics: `src/adapters/diagnostics/bounded-diagnostics.ts`
- best-effort telemetry: `src/adapters/telemetry/best-effort-telemetry.ts`
- composition: `src/bootstrap/runtime-adapters.ts`
## 검증
- `check:diagnostics`는 모든 registry event에 production producer가 있는지와
source의 direct console/sensitive context 우회를 검사한다.
- negative source fixture는 direct console, unknown event와 raw context를 실제로
거절하며 TypeScript fixture는 잘못된 level/event ID를 거절한다.
- unit test는 allowlist, hostile/circular error, bounded diagnostics, no-op,
queue full, sink/observer failure와 pre-mount boot evidence를 검증한다.
- HTTP integration은 success, retry recovery, terminal failure와 abort의 producer
횟수, route/operation/correlation context와 요청 값 비노출을 검증한다.
- cache/storage/release/application/runtime test는 각 production wiring과
diagnostics failure isolation을 검증한다.
## 한계와 재검토 조건
기본 adapter는 운영 log 검색, source map 연계, session replay, distributed span,
analytics consent, sampling budget과 장기 보존을 제공하지 않는다. 실제 sink를
선정할 때 데이터 처리 지역, 보존 기간, consent, CSP, source map 접근 제어,
sampling과 비용 상한을 별도 결정해야 한다.
## Rollback
telemetry exporter는 runtime 설정을 끄거나 adapter wiring을 `noOpTelemetry`
바꾸어 독립적으로 제거할 수 있다. 이때도 `DiagnosticsPort`, registry,
redaction/value policy, producer-count와 negative fixture는 유지한다. 외부 SDK
문제로 application producer와 안전 계약을 함께 되돌리지 않는다.
@@ -0,0 +1,71 @@
# VD-08: 개발용 Storybook과 로컬 시각 회귀 증적
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-test-registry-evidence-hardening`
- 재검토: 제품이 cloud visual review, 다중 OS baseline 또는 별도 디자인 시스템
배포를 요구할 때
## 배경
`/examples/ui``/examples/states`는 실제 application composition 안에서 공통
UI와 상태 표면을 보여 주지만, primitive를 격리해 interaction과 접근성을 검증하는
workshop은 아니었다. 실패 시 screenshot도 디버깅 증거일 뿐 의도된 UI 기준선과
현재 렌더의 차이를 차단하지 못했다.
외부 visual review 서비스, 별도 Storybook 배포와 브랜드별 baseline은 아직
선정되지 않았다. 이 결정을 기다리며 UI 회귀 검증을 비워 두거나 production
application bundle에 workshop runtime을 포함하는 것 모두 적절하지 않다.
## 결정
1. Storybook은 development dependency와 별도 static artifact로만 사용한다.
production entry와 application `dist`에는 Storybook runtime, story 또는
테스트 selector를 포함하지 않는다.
2. story는 public design-system entry를 소비하고 실제 locale, theme, session,
router와 query provider 계약으로 렌더한다. production component를 복제한
story 전용 구현을 만들지 않는다.
3. interaction과 story-level axe는 Playwright가 정적 Storybook을 대상으로
실행한다. unexpected console, page error와 request failure는 테스트 실패다.
4. 시각 회귀는 production `build``preview`를 대상으로 pinned Chromium,
locale, color scheme과 viewport에서 `toHaveScreenshot()`으로 실행한다.
5. 최초 기준선은 wide shell, compact pseudo-locale drawer, dark design-system
gallery, loading/empty/error/access 상태 표면을 포함한다.
6. animation과 caret만 결정적으로 비활성화한다. `html`, `body`, `main` 또는
application 전체를 mask해 false PASS를 만드는 설정은 gate가 거절한다.
7. snapshot 갱신은 `test:visual:update`라는 명시적 명령으로 분리하고 PNG diff를
review한다. 일반 `test:visual`은 승인 기준선을 변경하지 않는다.
8. local visual threshold는 작은 rasterization 차이만 허용하며 실제 layout,
copy, theme 또는 상태 변화가 숨겨지도록 확대하지 않는다.
9. `/examples/*`는 production composition smoke로 유지하고 Storybook story의
대체물로 취급하지 않는다. 반대로 Storybook만 통과해 application shell
integration을 완료 처리하지 않는다.
10. cloud service가 선정되지 않아도 repository-local workshop, interaction,
a11y와 visual baseline gate는 완전하게 실행 가능해야 한다.
## 실행과 증적
- workshop config: `.storybook/main.ts`, `.storybook/preview.tsx`
- story: `src/presentation/design-system/design-system.stories.tsx`
- interaction/a11y: `tests/storybook/workshop.spec.ts`
- visual: `tests/visual/platform.visual.spec.ts`
- baseline: `tests/visual/__snapshots__/`
- production E2E: `playwright.config.ts`
- local dev E2E: `playwright.dev.config.ts`
- evidence policy: `scripts/check-test-evidence.ts`
CI는 JUnit, HTML report, failure trace/screenshot, visual baseline 존재 여부와
금지된 full-screen mask/무소유 skip fixture를 함께 검사한다.
## 한계와 재검토 조건
로컬 기준선은 실제 iOS/Android 기기, 여러 운영체제의 font rasterization,
디자인 승인 workflow와 다중 브랜드를 증명하지 않는다. 이를 요구하면 동일
public component와 story를 입력으로 사용하는 외부 review adapter를 추가하되,
provider 결과가 없을 때 임의 PASS로 대체하지 않는다.
## Rollback
Storybook dependency/config, workshop test와 visual config/baseline은 production
runtime 변경 없이 독립적으로 제거할 수 있다. rollback 후에도 `/examples/*`,
component behavior, automated accessibility와 built-dist E2E는 유지한다.
@@ -0,0 +1,113 @@
# VD-09: 공급망 inventory, license, vulnerability, SBOM과 provenance
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-supply-chain-verification`
- 재검토: 조직 vulnerability scanner, signing/attestation provider와 dependency
exception 승인 체계가 선정될 때
## 배경
기존 release script는 `package.json`의 직접 dependency 이름과 버전, lockfile
전체 digest, `dist` checksum만 기록했다. 전이 dependency, 패키지별 integrity와
license, 실제 baseline diff가 없었고 `highRiskUnreviewed: []`는 계산 결과가 아닌
고정값이었다. secret scan도 `src``dist`만 검사해 config, scripts, test와
generated release metadata를 놓쳤다.
반면 저장소에는 조직이 선택한 vulnerability source, severity exception 승인자,
signing identity와 attestation 저장소가 없다. 외부 provider가 없는 상태를 빈
finding과 서명 성공으로 표현하면 local 검증과 release promotion을 혼동한다.
## 결정
1. `pnpm-lock.yaml`의 모든 `packages` row와 `pnpm list --depth Infinity`의 실제
graph를 결합해 직접/전이, production/development, required/platform-optional,
version, SHA-512 SRI, license와 dependency edge를 기록한다.
2. inventory row 수는 lockfile package row 수와 같아야 한다. 누락된 전이
dependency, malformed integrity와 non-optional `NOASSERTION`은 local gate를
실패시킨다.
3. license는 설치된 package manifest에서 읽고 closed allow/deny policy로
검사한다. 현재 OS에 materialize되지 않은 platform optional만
`NOASSERTION`과 그 이유를 명시적으로 허용한다.
4. 승인 dependency baseline과 approval digest를 보존하고 현재 lock inventory와
actual add/remove/change/upgrade diff를 계산한다. 새 direct production
dependency는 owner와 서로 다른 reviewer, reason과 rollback evidence가
필요하다.
5. inventory를 CycloneDX 1.6 SBOM으로 투영한다. component 수, lockfile digest,
SRI, license와 dependency edge가 inventory와 일치해야 한다.
6. local in-toto/SLSA 형태 provenance statement는 source set, lockfile, SBOM과
`dist` digest를 연결하되 `LOCAL_UNSIGNED`로 표시한다. 이 문서는 외부
provenance를 대신할 수 없다.
7. `immutable_build`는 raw `pnpm-lock.yaml`, `dist`, build/module inventory와
모든 local verification evidence를 한 번만 archive한다. Candidate manifest는
raw lock bytes SHA-256, dependency inventory lock digest와 manifest
`lockfileSha256`의 exact 일치를 요구한다.
8. 두 provider job은 동일 archive를 각각 받아 외부 command를 실행한다.
Vulnerability report는 raw lock digest와 `distSha256`, provenance attestation은
`{name: "dist", digest.sha256}`를 포함한다. 두 문서 모두 strict schema와
별도 trust path/key ID로 선택한 실제 Ed25519 public key 서명을 통과해야 한다.
9. provider report나 trusted key가 없으면 local
inventory/license/SBOM/coherence는 `PASS`, promotion은
`FAIL_UNVERIFIED`다. 저장소 generator나 fixture가 production용 빈 finding 또는
signed PASS를 만들지 않는다.
10. secret scan은 source, scripts, tests, tracked config/schema, public, `dist`
generated release metadata를 검사한다. allowlist는 test path에만 허용하며
owner, reason과 expiry가 필요하다. 발견한 secret 원문은 artifact에 쓰지 않고
rule, path, line과 fingerprint만 남긴다.
11. `SOURCE_DATE_EPOCH`를 지원하고 supply-chain timestamp도 build manifest의
동일 epoch에 결합한다. 같은 source/lock/config의 production build를
두 번 실행해 전체 dist digest 일치를 검증한 뒤 일반 build를 복원한다.
## 실행 경계와 증적
```text
package.json + frozen pnpm-lock.yaml + installed graph
-> deterministic dependency inventory
-> license policy + approved actual baseline diff
-> CycloneDX SBOM
source/config/lock + production dist
-> local provenance statement
-> immutable archive + candidate manifest + distSha256
-> external vulnerability provider + external provenance provider
-> read-only local revalidation + signature/digest verification
-> promotion PASS | FAIL_UNVERIFIED
```
- policy: `config/security/`
- generator: `scripts/generate-supply-chain.ts`
- coherence: `scripts/verify-supply-chain-artifacts.ts`
- secret scan: `scripts/security-scan.ts`
- reproducibility: `scripts/verify-reproducible-build.ts`
- inventory: `artifacts/release/dependency-inventory.json`
- SBOM/provenance: `artifacts/release/sbom.cdx.json`,
`artifacts/release/provenance.json`
- local/promotion status:
`artifacts/security/supply-chain-verification.json`
## 검증
- 현재 lockfile의 561개 package row와 inventory row가 양방향 일치한다.
- ordering-only digest, removal, integrity tamper, baseline tamper, high-risk
self approval, denied license, critical vulnerability와 만료 exception,
provider/digest 오류, SBOM/provenance 불일치 fixture를 검사한다.
- isolated temporary candidate/PEM/report fixture는 실제 environment path wiring을
통해 valid immutable 입력만 promotion `PASS`임을 증명한다. Production artifact를
덮어쓰거나 generator를 provider 모드로 재실행하지 않는다.
- frozen install은 manifest/lock mismatch fixture를 실제 pnpm으로 거절한다.
- source/config/dist 각각의 synthetic secret fixture가 실제 scan을 실패시키고
scoped test allowlist만 통과한다.
## 한계와 재검토 조건
로컬 manifest license는 법률 검토가 아니며 vulnerability report도 외부 scanner가
제공한 데이터의 최신성 자체를 보증하지 않는다. 실제 프로젝트는 provider 버전,
database freshness, network outage, exception 승인 조직, signing identity,
attestation transparency/retention과 비밀 관리를 결정해야 한다.
## Rollback
외부 scanner/attestor command, report path 또는 trusted key 설정을 제거하면 즉시
`FAIL_UNVERIFIED`로 돌아간다. local inventory, lock integrity, license, SBOM,
secret, reproducibility와 actual diff gate는 유지한다. scanner 장애를 이유로
promotion을 PASS로 변경하지 않는다.
@@ -0,0 +1,113 @@
# VD-10: 선택형 frontend capability recipe
- 상태: Accepted
- 결정일: 2026-07-26
- 적용 브랜치: `feature-frontend-optional-adapter-recipes`
- 현재 선택 capability: 없음
- 재검토: 실제 프로젝트가 realtime, offline, PWA, file, generated API,
feature flag, worker, multi-tab, browser permission, client workflow,
large-data UI 또는 production analytics/error provider를 요구할 때
## 배경
서버의 PostgreSQL, MongoDB, Redis, Kafka, MinIO 같은 기술을 브라우저가 직접
소비하지는 않는다. 프론트의 변화 지점은 권한 있는 HTTP/BFF, push event,
offline persistence, file protocol, browser runtime, 사용자 동의와 UI 성능
경계다. 이 capability를 “언젠가 필요할 수 있다”는 이유로 모두 설치하면 초기
bundle, 공급망, runtime config, 보안 표면과 업데이트 비용만 늘어난다.
반대로 문서에 이름만 적으면 실제 프로젝트에서 port 위치, cancellation,
fallback, fake와 제거 기준을 다시 설계해야 한다. 따라서 production runtime에
아무것도 설치하지 않되 검증 가능한 vendor-neutral recipe를 저장소 밖이 아닌
별도 opt-in 경계에 유지한다.
## 결정
1. `config/recipes/frontend-capability-recipes.json`이 12개 recipe의 선택 기준,
금지 조건, port/fake, failure matrix, lifecycle cleanup, owner,
security/privacy, gzip budget, fallback, server-state 정책과 제거 절차의
machine-readable SSOT다.
2. 현재 실제 소비 요구와 project owner가 없으므로 12개 상태는 모두
`RECIPE_AVAILABLE`이며 `INSTALLED`가 아니다. production runtime dependency와
composition registration은 0개다.
3. `recipes/frontend-capabilities`의 TypeScript port와 fake/unavailable adapter는
실행 가능한 설계 예시다. `src` 또는 production entry가 이 디렉터리를 import할
수 없다.
4. 프로젝트가 capability를 선택하면 필요한 최소 contract를
application-owned output port 또는 presentation facade로 이동하고, concrete
vendor adapter는 local adapter 경계에 둔다. recipe 디렉터리를 production에서
그대로 import하지 않는다.
5. WebSocket/SSE처럼 연결은 outbound이고 수신 event는 inbound인 양방향 기술도
한 종류의 “adapter”로 뭉개지 않는다. 연결·credential·reconnect 정책과
event validation·input invocation을 분리한다.
6. Zustand/Redux Toolkit/state machine은 실제 cross-page client-only workflow가
확인된 경우 하나만 선택한다. URL, component state, Context, TanStack Query가
이미 소유한 상태를 복제하지 않는다.
7. browser credential은 localStorage, URL, recipe store, telemetry 또는
BroadcastChannel에 넣지 않는다. 브라우저가 database/object store에 직접
접속하는 recipe도 금지한다.
8. lifecycle이 있는 capability는 unsubscribe, close, unregister, dispose,
cancel 또는 `AbortSignal`을 계약과 contract test에 포함해야 한다.
9. 선택하지 않은 recipe sentinel이나 reference runtime source, vendor
dependency가 production bundle에 들어가면 gate를 실패시킨다.
`referenceRuntime`이 있는 recipe는 catalog `sourceRoots` 전체를 별도의
production-mode synthetic entry로 deterministic하게 bundle/minify하되
tree-shaking을 끄고, 모든 출력의 gzip 합계가 recipe budget을 넘으면
production composition 여부와 무관하게 실패시킨다. 2026-07-28 최초 실측에서
`offline-indexeddb`가 32,930 bytes였으므로 측정 없이 선언됐던 8,000 bytes를
약 9% headroom의 36,000 bytes로 교정했으며 다른 budget은 자동 인상하지
않는다.
10. recipe 전체를 제거한 임시 worktree에서 base typecheck, architecture,
unit/component/integration test와 production build가 통과해야 한다.
## 선택과 설치 절차
```text
measured product/runtime need
-> project owner + security/privacy classification
-> recipe trigger/forbidden/fallback review
-> VD-10 amendment with one selected capability
-> application port or presentation facade copied into src
-> one concrete adapter under local adapter boundary
-> composition-only wiring
-> contract/failure/cleanup/integration tests
-> bundle + dependency baseline approval
-> INSTALLED only after all evidence passes
```
도입 커밋에는 owner, 선택 이유, 대안, gzip 차이, runtime config, browser support,
failure UX, observability, rollback과 제거 명령을 기록한다. vendor가 필요한
behavior를 fake만으로 확인하고 `INSTALLED`로 바꾸지 않는다.
## 증적
- catalog: `config/recipes/frontend-capability-recipes.json`
- contracts/fakes: `recipes/frontend-capabilities`
- 상세 runbook: `docs/architecture/optional-adapter-recipes.md`
- file/IndexedDB/OPFS/Cache 심층 결정:
`docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md`
- browser data 상세 설계:
`docs/architecture/browser-file-and-origin-storage.md`
- realtime/Web Push/Polling 심층 설계와 결정:
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`,
`docs/architecture/decisions/VD-28-realtime-events-web-push-and-bounded-polling.md`
- contract test: `tests/recipes/optional-capability-contracts.test.ts`
- negative fixture:
`tests/fixtures/optional-recipes/forbidden`
- validation:
`scripts/check-optional-recipes.ts`
- removal:
`scripts/test-optional-recipe-removal.ts`
- evidence:
`artifacts/quality/optional-recipes.json`,
`artifacts/quality/optional-recipe-fixtures.json`,
`artifacts/tests/optional-recipes.xml`,
`artifacts/tests/optional-recipe-removal.xml`
## Rollback
현재 branch는 runtime dependency나 production composition을 바꾸지 않으므로
recipe catalog, example과 gate를 함께 revert하면 RP-11 상태로 돌아간다. 실제
프로젝트에서 선택한 capability는 그 capability의 port/adapter/composition/
dependency commit만 revert한다. 여러 vendor 도입을 하나의 되돌릴 수 없는
commit으로 묶지 않는다.
@@ -0,0 +1,269 @@
# VD-11: Browser file and origin-storage 경계
- 상태: Accepted — native reference runtime available, not composed
- 결정일: 2026-07-27
- reference runtime 상태: `AVAILABLE_NOT_COMPOSED`
- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도)
- 관련 결정: VD-10 optional capability recipes, VD-14, VD-15
- current status ledger:
`docs/architecture/browser-data-capability-completion-ledger.md`
- 재검토: 실제 제품이 file intake/delivery, durable offline data, large local
binary 또는 public offline HTTP representation을 선택할 때
## 배경
File, Blob, picker, IndexedDB, OPFS와 Cache Storage는 모두 browser data를
다루지만 같은 storage abstraction이 아니다.
- File/Blob은 transient byte container다.
- picker는 user activation과 permission UX를 소유한다.
- IndexedDB는 indexed structured record와 transaction을 제공한다.
- OPFS는 origin-private large byte storage지만 query와 cross-API transaction이
없다.
- Cache Storage는 HTTP Request/Response map이며 freshness를 자동 관리하지 않는다.
이를 하나의 `StoragePort``FileTransferPort`로 추상화하면 transaction complete,
blocked/versionchange, object URL 수명, stream backpressure, quota, OPFS partial
write, Cache의 인증 response 금지와 release activation이 사라진다.
기존 recipe는 metadata-only upload와 in-memory `Uint8Array` download를 보여 주는
얕은 예시였다. 큰 파일과 production recovery protocol의 출발점으로는 부족했다.
backend upload protocol을 browser file mechanism에 묶는 것 역시 선택하지 않은
제품 capability를 암묵적으로 설치하므로 경계를 분리해야 한다.
## 결정
1. 기존 동기식 `StoragePort`는 작은 public preference만 소유한다. IndexedDB,
OPFS, Cache Storage를 backend enum 하나로 끼우지 않는다.
2. native `File`, `Blob`, `FileList`, `FileSystemHandle`은 browser adapter의
transient vault 안에 둔다. application은 opaque `LocalFileRef`, normalized
metadata와 bounded `readRange()`만 본다.
3. browser 파일 기능을 picker, file content, preview lease, download delivery로
분리한다. backend upload는 `BrowserFileComposition`의 구성요소가 아니며,
별도 선택 가능한 `ExampleQuarantinedUploadPort` 예시로만 둔다. user
dismissal은 failure가 아닌 outcome이다.
4. backend upload를 선택한 경우 file validation, authorization,
malware/archive/active-content 검사와 quarantine은 client hint보다 항상
authoritative하다.
5. 큰 file/download/object는 stream 또는 bounded part로 처리한다. 전체
`Uint8Array`, Blob, base64/Data URL은 승인된 hard cap 안의 small artifact에만
사용한다. File/OPFS/Cache 및 recipe byte source는
`AsyncIterable<Result<Uint8Array, ClosedFailure>>`로 실패를 닫고 raw native
exception을 application으로 throw하지 않는다.
6. download outcome은 browser handoff와 confirmed saved를 분리한다. anchor click을
disk write 완료로 기록하지 않는다.
7. IndexedDB는 feature-specific async repository adapter다. raw database,
transaction callback, store/index/schema version을 application에 노출하지 않는다.
8. DB DDL version과 record codec version을 분리한다. schema upgrade는 짧고
additive하게, data migration은 resumable bounded batch로 수행한다.
9. IndexedDB mutation은 request success가 아니라 transaction complete 이후에만
성공이다. revision CAS와 idempotency key를 기본 계약으로 둔다.
10. 모든 connection은 versionchange/forced-close를 처리하고 blocked/future-schema
상태를 read-only 또는 online-only UX로 드러낸다. 자동 reload loop와 자동
database deletion을 금지한다.
11. OPFS는 큰 immutable bytes와 integrity manifest만 소유한다. logical metadata,
query, generation과 journal commit authority는 IndexedDB가 소유한다.
12. IndexedDB와 OPFS 사이의 비원자성은
`PREPARING -> FILES_READY -> COMMITTED -> CLEANED` journal saga와 startup
reconciliation으로 처리한다. `COMMITTED`만 사용자에게 보인다.
13. OPFS sync access handle은 DedicatedWorker의 신규 staging/chunk file에만
사용하고 항상 flush/close한다. committed file in-place overwrite와
`readwrite-unsafe`를 금지한다.
14. Cache Storage는 same-origin public GET representation 전용 platform-local
facade다. auth, cookie-dependent, private, personal, no-store, opaque, 206,
redirect response를 저장하지 않는다.
15. Cache match는 query/Vary를 보존하고 `ignoreSearch`/`ignoreVary`를 금지한다.
candidate 전체를 type/size/integrity 검증한 뒤에만 release를 활성화하며
verified previous release를 rollback용으로 유지한다.
16. Service Worker lifecycle과 Cache Storage ownership을 구분한다. unregister가
cache 삭제를 의미하지 않으므로 owned-prefix cleanup migration을 별도로 둔다.
17. IndexedDB, OPFS와 Cache Storage는 origin quota budget을 공유한다.
`estimate()`는 rough signal이고 실제 `QuotaExceededError`를 authority로 둔다.
18. credential 저장을 금지한다. same-origin client encryption을 XSS authorization
boundary로 간주하지 않는다.
19. fake는 계약 검증용이고 native production evidence를 대체하지 않는다.
Chromium/Firefox/WebKit, multi-page, crash/fault, migration/rollback과 quota
drill을 설치 capability의 promotion gate로 둔다.
20. 공통 native adapter는 정책 주입형 reference runtime으로 제공하되 현재 제품
owner와 dataset이 없으므로 bootstrap, installed feature, Service Worker
registration과 runtime config에는 연결하지 않는다. catalog recipe
availability는 `RECIPE_AVAILABLE`, reference runtime primary status는
`AVAILABLE_NOT_COMPOSED`이며 product selection은 별도다.
21. API lifecycle, transaction, bounded-memory, integrity와 recovery mechanism은
공통 adapter가 소유한다. schema/codec/query, authority, classification,
retention, quota priority와 cache/file allowlist는 dataset/use-case 정책으로
주입한다.
22. composition은 dataset별 opaque scope와 전체 storage policy를 검증해 깊은
snapshot/freeze한다. 공통 runtime을 여러 dataset의 전역 mega-repository로
구성하지 않는다.
23. IndexedDB physical DB명은 registry-issued
`authorityToken/namespaceToken/partitionToken`에서만 파생한다. readable
namespace/business/account ID는 이름에 쓰지 않는다. immutable scope + full
policy binding을 upgrade transaction, post-open과 maintenance에서 검증하고
mismatch 또는 기존 DB의 missing binding은 fail-closed한다.
24. IndexedDB는 codec `measureStoredBytes`, retention sidecar, dataset
`usedBytes/receiptCount` budget을 mutation과 같은 transaction에서 갱신한다.
TTL은 sweep 전에도 read/query에서 보이지 않으며 `UNTIL_SYNCED`는 confirmed
record만 삭제 가능하다. lifecycle deletion은 composition authority의 opaque
short-lived proof가 매 invocation 필요하고 proof는 검증 후 폐기한다.
25. idempotency receipt retention은 최대 31일, receipt configured cap의 구현 절대
상한은 1,000,000개다. codec migration은 old-writer drain proof와 revision
fence가 필요하고 한 invocation은 최대 500 rows/30,000ms다.
26. OPFS physical layout은
`/ca-frontend-opfs-v1/authorities/<authority>/<namespace>/<partition>/...`이며
세 path segment는 opaque token이다. IndexedDB journal은 logical namespace와
physical scope를 양방향 binding하고 full policy fingerprint를 검증한다.
27. Cache manifest는 정규화된 `expectedContentType`까지 digest에 binding한다.
response의 정규화된 Content-Type이 정확히 일치하지 않으면 candidate activation을
금지한다.
28. optional 상태는 metadata만으로 주장하지 않는다. real-browser JUnit verifier,
Vite source-module inventory, source boundary gate와 runtime removal gate를
promotion evidence로 둔다.
29. File selection/inspection/preview/download dataset policy는 composition-time
registry가 소유한다. port caller는 정확히 등록된 `FilePolicyReference` 객체와
limit reduction만 전달하며 같은 key/intention 문자열로 reference를 재구성해
다른 profile을 선택할 수 없다. verification receipt는 exact profile과 file
snapshot에 binding한다.
30. `BROWSER_MANAGED_RESOURCE` download는 resource와 함께 서버 발급 capability
receipt를 요구한다. synchronous resolver의 결과가 receipt/resource/media
type/safe extension/server max/optional digest/expiry를 정확히 binding하지
않으면 handoff하지 않는다. strategy와 integrity mode를 caller가 선택하지
않는다.
31. IndexedDB actual `StoredRecord`
`key/codecVersion/revision/payload`만 가지며 write time, synchronization,
measured bytes와 eligibility는 retention sidecar에 분리한다. idempotency
receipt와 governance binding/budget도 별도 store에 두고, full partition
purge에는 등록된 모든 `lifecycleMetadataStores`를 포함하되 immutable
governance identity는 유지한다. Cache cleanup retain set은 caller가 cache
name이나 release registry ID로 제출하지 않고 verified active pointer와
composition retention에서 계산한다. control JSON은 정확히 2 MiB
(2,097,152 bytes) bounded stream으로만 decode한다.
32. OPFS의 `LOGOUT`, `UNTIL_SYNCED`, `ACCOUNT_DELETION` policy maintenance는
composition이 `requestMaintenanceAuthority` provider와
`consumeMaintenanceAuthority` consumer를 모두 공급해야 한다. provider는
exact reason/frozen scope/frozen policy에 묶인 최대 5분 proof를 매번 새로
발급하고, consumer는 같은 binding과 expiry를 확인해 원자적으로 consume하여
replay를 막는다. application caller는 proof를 전달할 수 없고 runtime은 이를
저장·반환·관측하지 않는다.
33. origin-wide pressure/write admission/GC, OPFS·Cache forward migration,
OPFS real preflight, bounded Cache maintenance와 preview decode safety의
후속 계약은 VD-15가 소유한다. 기존 store별 primitive를 그 coordinator의
구현 증거로 사용하지 않는다.
34. Service Worker lifecycle, directory/persistent handle과 private/sparse Range
cache는 제품 선택 전 `NOT_SELECTED`인 별도 capability다. Range resumable
download는 VD-14의 `DESIGNED_NOT_IMPLEMENTED` capability이며 public Cache
runtime에 섞지 않는다.
## 계약과 증적
- 심층 계약:
`recipes/frontend-capabilities/browser-file-storage-contracts.ts`
- deterministic fake:
`recipes/frontend-capabilities/browser-file-storage-fakes.ts`
- contract test:
`tests/recipes/browser-file-storage-contracts.test.ts`
- selection SSOT:
`config/recipes/frontend-capability-recipes.json`
- 상세 설계:
`docs/architecture/browser-file-and-origin-storage.md`
- lifecycle/migration 결정:
`docs/architecture/decisions/VD-15-origin-storage-lifecycle-and-migration.md`
- 운영 복구:
`docs/operations/browser-file-storage-recovery.md`
- native reference runtime:
`src/adapters/browser-files/`, `src/adapters/storage/indexeddb/`,
`src/adapters/storage/opfs/`, `src/adapters/cache-storage/`
- real-browser conformance:
`tests/browser-capabilities/`
- browser evidence verifier:
`scripts/verify-browser-capability-evidence.ts`
- production module inventory:
`artifacts/quality/vite-module-inventory.json`
- boundary/removal evidence:
`check:browser-file-storage-boundaries`,
`test:browser-file-storage-removal`
recipe의 durable byte source도 단일 `Uint8Array` 또는 raw-throw stream 대신
chunk별 `CapabilityResult<Uint8Array>`를 반환한다. backend upload example은
`ExampleBackendUploadComposition`으로 browser file composition과 분리되어 있다.
실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend
protocol에 맞게 contract를 더 좁힌다.
현재 checkout의 browser 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개를 모두
실행해야 한다. WebKit은 현재
host의 필수 native libraries(예:
`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`,
`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는
Chromium/Firefox 14개씩 총 28개만 통과했으므로
`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine
evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다.
## 선택 이후 필요한 구현
```text
dataset + owner + classification + backend protocol
-> VD-11 amendment
-> feature-specific application ports
-> adapter-private schema/codec/migrations
-> native picker/file/download/IDB/OPFS/cache adapter 중 필요한 것만
-> unavailable/read-only/online-only fallback
-> deterministic fault + real browser contract tests
-> diagnostics allowlist + recovery runbook drill
-> canary + N-1 rollback evidence
-> project catalog에서만 INSTALLED
```
OPFS를 쓴다는 이유로 Service Worker를 설치하거나, Cache Storage를 쓴다는 이유로
IndexedDB business repository를 만들지 않는다. 실제 capability 조합만 설치한다.
## 결과
장점:
- native API와 clean architecture 경계가 명확하다.
- 대용량 memory blow-up과 거짓 download-complete 신호를 막는다.
- IndexedDB migration/transaction과 OPFS crash recovery가 검증 가능하다.
- auth/private cache poisoning을 fail-closed한다.
- 기술별 fallback, kill switch와 제거 범위가 독립적이다.
비용:
- 하나의 generic adapter보다 port와 contract test 수가 많다.
- native adapter 설치 시 worker, historical schema fixture, multi-page test와
운영 drill이 필요하다.
- offline user-authored data는 browser storage만으로 backup을 보장할 수 없어
server sync 또는 export 제품 결정이 필요하다.
이 비용은 browser persistence의 실제 일관성·수명 차이를 숨기지 않기 위한
의도적인 비용이다.
## Rollback
현재는 native reference runtime source가 있지만 production composition은 없다.
catalog의 세 runtime은 `AVAILABLE_NOT_COMPOSED` /
`productionComposition: false`이고 build module inventory에 runtime source가
없어야 한다. `check:optional-recipes`가 이를 강제한다.
완전 철회하려면 `src/application/ports/browser-file-storage`,
`src/adapters/browser-files`, `src/adapters/browser-file-storage`,
`src/adapters/storage/indexeddb`, `src/adapters/storage/opfs`,
`src/adapters/cache-storage`와 전용 test를 제거하고 catalog의
`referenceRuntime` metadata를 삭제한다.
`test:browser-file-storage-removal`은 이 상태에서 base typecheck, architecture,
test, build와 optional catalog가 유지되는지 검증한다.
제품에 composition한 이후 rollback은 다음 순서를 따른다.
1. 신규 write, worker activation과 cache candidate를 중지한다. 별도 upload
workflow를 설치했다면 그 session도 독립적으로 중지한다.
2. file ref/object URL/handle/connection/channel을 정리한다.
3. offline read-write를 read-only 또는 online-only로 전환한다.
4. N-1 bundle이 future schema를 destructive open 없이 감지하는지 확인한다.
5. user-authored/unsynced data는 export/sync 확인 없이 purge하지 않는다.
6. owned OPFS/cache namespace만 journal/manifest 기준으로 정리한다.
7. adapter composition, runtime config와 dependency를 제거한다.
schema downgrade, blanket `deleteDatabase()`, `caches.keys()` 전체 삭제와 사용자
filename 기반 OPFS 삭제는 rollback 수단으로 금지한다.
@@ -0,0 +1,202 @@
# VD-12: Presigned transfer, resumable upload와 Image CDN 경계
- 상태: Accepted — reference runtime available, not composed
- 결정일: 2026-07-28
- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도)
- reference runtime 상태: `AVAILABLE_NOT_COMPOSED`
- 관련 결정: VD-10, VD-11, VD-14, VD-16
- current status ledger:
`docs/architecture/browser-data-capability-completion-ledger.md`
- 재검토: 제품이 server file upload/download 또는 Image CDN delivery를 선택할 때
## 배경
Presigned URL은 URL 문자열이 아니라 짧은 수명의 bearer capability다.
multipart/resumable upload는 단순 PUT 반복이 아니라 session, part identity,
checksum, authoritative reconciliation, completion과 orphan cleanup protocol이다.
streaming download는 전체 payload를 메모리에 올리지 않지만 response binding,
truncation/overrun, destination commit과 integrity를 별도로 처리해야 한다.
Image CDN URL도 arbitrary transform builder로 노출하면 cache poisoning, pixel/decode
bomb, signed-query 유출과 source-fetch SSRF 경계가 사라진다.
이 네 capability를 범용 `HttpClient``FileService` mega-port 하나로 합치면
control plane authorization과 byte data plane, local browser lifecycle과 server
authority가 섞인다.
## 결정
1. BFF/Web API control plane과 object-storage/CDN data plane을 분리한다.
2. 브라우저는 signing key, cloud 관리자 credential, bucket/container, raw object
key 생성 규칙을 소유하지 않는다.
3. application caller는 raw URL/query/signed headers를 전달하지 않는다.
composition/provider가 발급한 exact capability만 adapter가 소비한다.
4. presigned capability는 version, opaque identity, method, logical resource 또는
session/part, exact URL, origin/path policy, byte/media/checksum 조건과 expiry를
immutable하게 binding한다.
5. signed URL은 bearer credential로 취급하고 persistence, checkpoint, telemetry,
analytics, referrer와 raw exception에 넣지 않는다.
6. data-plane fetch는 기본적으로 `credentials: omit`, `redirect: error`,
`referrerPolicy: no-referrer`, `cache: no-store`를 사용한다. cross-origin은
composition allowlist와 CORS/CSP 계약이 있을 때만 연다.
7. client-side single-use 표시는 UX와 accidental replay를 줄이는 보조책이다.
cross-tab/replay의 최종 authority는 server 또는 composition-owned atomic
consumer다.
8. streaming download는 response body를 closed-result stream으로 변환하고
output chunk, total bytes, media type, encoding과 선택적 incremental integrity를
검증한다. overrun/truncation/abort 시 native reader와 destination을 닫는다.
9. `BROWSER_HANDOFF`와 destination close 이후의 `SAVED`를 계속 분리한다.
10. Range resumable download는 별도 capability다. `206`, `Content-Range`,
validator, destination seek/truncate와 final integrity 없이는 append resume를
허용하지 않는다.
11. upload 상위 계약은 server-authoritative session/status/part/complete/abort를
소유하고 data-plane part executor는 capability 타입에 generic하다. 따라서
S3-style presigned multipart와 BFF proxy part를 같은 application contract
뒤에 둘 수 있지만 wire DTO를 공유하지 않는다.
12. reference upload protocol literal은 `PRESIGNED_MULTIPART_V1`이다. 모든
control-plane request/response, session과 checkpoint가 이를 exact하게
포함하며 다른 값이나 누락을 거절한다.
13. session은 exact source binding, total bytes, media type, part size/count,
concurrency, checksum algorithm과 expiry를 묶는다. part number는 1부터
연속적이고 offset/length/checksum/idempotency를 정확히 binding한다.
14. control transport는 `CREATE_SESSION`, `GET_STATUS`, `COMPLETE`, `ABORT`
closed operation을 composition-owned fixed HTTPS endpoint map으로만
실행한다. presigned 발급도 factory에 고정된 단일 BFF endpoint를 사용하며
caller-provided URL을 받지 않는다.
15. `requestBindingSha256``uploadBindingSha256`
`RESUMABLE-UPLOAD-BINDING-V1`
`RESUMABLE-UPLOAD-SESSION-BINDING-V1` canonical field sequence의 SHA-256이다.
`UPLOAD_PART` capability binding은 exact
`protocol: PRESIGNED_MULTIPART_V1`을 포함한다. BFF는 `sessionId`로 server
session을 조회하고 snapshot으로 protocol, binding과 part plan을 재계산한다.
client digest는 authorization이나 ownership 증명이 아니다.
16. part memory는 `partSize × concurrency × copyFactor` hard ceiling으로 제한한다.
retry는 같은 bytes/checksum/idempotency에만 허용한다.
17. retryable network, 429와 모든 5xx는 bounded attempt/`Retry-After`/abortable
backoff 안에서만 재시도한다. status의 404/410 또는
`NOT_FOUND`/`EXPIRED`는 terminal로 보고 checkpoint를 CAS 제거한다.
18. PUT 성공은 capability-bound status, receipt header,
`expectedResponseByteLength`와 exact `Content-Length`를 검증하고 hard cap과
deadline 안에서 response body를 끝까지 drain한 뒤에만 확정한다. 204는
expected response bytes가 0일 때만 허용하며 `Content-Length` 부재를 0으로
정규화한다.
19. resume는 local checkpoint만 신뢰하지 않는다. server status를 다시 읽고
완료 part의 local range digest와 server checksum/receipt를 대조한 뒤 missing
part만 전송한다.
20. checkpoint에는 opaque session/source binding과 reconciliation에 필요한
protocol-defined SHA-256 file fingerprint, per-part checksum, bounded opaque
non-authorizing part receipt token만 저장한다. 이 값도 account partition과
retention을 적용하고 diagnostics/telemetry에는 내보내지 않는다. presigned
URL, signed header, bearer token/capability, file name, path, account ID,
raw provider ETag와 raw server error는 금지한다.
21. cancel과 server abort를 분리한다. same-origin 다른 tab의 active upload는
strict `RESUMABLE_UPLOAD_CANCEL_V1` BroadcastChannel 신호로 먼저 중단한 뒤
per-key Web Lock 안에서 server abort/reconcile을 수행한다. 이 ephemeral
신호는 opaque upload key만 운반하고 persistence하지 않으며 authority가
아니다. channel이 없으면 abort caller의 bounded signal 아래 lock을 기다린다.
complete/abort가 불명확하면 server reconcile 전까지 성공으로 기록하거나
checkpoint를 파기하지 않는다.
22. multipart complete는 ordered receipt 검증 뒤에도 `QUARANTINED`다. backend
scan/CDR/promotion이 끝나기 전 available/public URL을 발급하지 않는다.
application-facing 성공값은 state/resource/byte length/replay 여부만 노출하고
session ID, request binding과 fingerprint를 제거한다.
23. Image CDN application contract는 opaque asset reference와
composition-registered named preset만 받는다. arbitrary source URL과 raw
transform query는 금지한다.
24. asset descriptor는 immutable revision, delivery class, safe raster media,
natural dimensions, rendition dimensions/formats/URLs와 private expiry를 묶는다.
25. CDN policy는 allowed HTTPS origin/path, preset width/DPR/format/quality/fit,
output pixel/decoded-byte/encoded-byte/candidate/lifetime ceiling과
cache/referrer policy를 소유한다.
composition limit은 exported adapter implementation ceiling을 초과할 수
없고 capability verification concurrency도 절대 상한 아래에서 제한한다.
CDN origin은 composition이 명시한 application origin과 달라야 한다.
`<img crossorigin="anonymous">`가 same-origin 요청에서는 cookie를 보낼 수
있기 때문에 private URL의 credential omission을 probe에만 맡기지 않는다.
26. private capability signature가 허용하는 값은 versioned preset binding ID다.
CDN/BFF는 그 ID를 server-owned immutable preset registry에서 조회하고,
요청의 width/height/DPR/fit/format/quality가 그 preset의 exact candidate인지
재계산해 하나라도 다르면 거절한다. signed URL에 붙은 raw transform query나
client 계산값은 authorization proof가 아니다.
signing key policy는 bounded unique `acceptedKeyIds` overlap set이고 verifier
registry가 모든 ID를 포함해야 한다. descriptor의 단일 key ID는 양쪽
registry에 exact membership이 있어야 한다.
27. browser probe는 native decode 전에 PNG/JPEG/WebP/AVIF header metadata와
static-only container를 검사한다. 선언 dimensions, pixels와 decoded-byte
budget을 넘거나 APNG/WebP animation, AVIF sequence/derived image,
ambiguous/malformed container이면 decode 전에 거절한다.
28. private signed delivery는 `PRIMARY_REQUIRED` probe를 강제하고
`credentials: omit`, exact response URL과 실제 `Cache-Control: no-store`
검증한다. fetch/body/decode 전체에 하나의 timeout을 적용하고 abort/late
completion에서 reader와 bitmap을 닫는다.
29. SVG/HTML/data/blob/javascript와 unknown active media는 기본 거절한다.
animation은 frame/decode budget이 승인된 별도 protocol 전에는 허용하지 않는다.
30. responsive candidate는 한 source set에서 하나의 descriptor 종류만 사용하고,
고유한 양수 width를 오름차순으로 반환한다. `sizes`는 registry-owned layout
token에서 결정한다.
31. public rendition은 immutable revision URL과 public immutable cache를 사용하고,
private rendition은 short-lived capability와 필수 no-store를
사용한다. 같은 URL의 content를 purge로 바꿔치기하지 않는다.
32. Image CDN runtime `close()`는 terminal/idempotent다. runtime lifetime
signal로 진행 중 verification/probe를 중단하고 accepted WeakMap을 새
WeakMap으로 교체해 기존 reference를 즉시 revoke한다. 닫힌 runtime은
재개하지 않고 새 composition으로 교체한다.
33. 공통 runtime은 concrete browser mechanism과 policy validation을 제공하지만
backend endpoint/vendor schema와 제품 asset/upload owner가 없으므로 bootstrap에
조합하지 않는다.
34. runtime source는 production module inventory와 removal gate로 기본 bundle에서
제외됨을 증명한다.
35. Range resume의 detailed state machine과 app-managed background의 플랫폼
경계는 VD-14가 소유한다. VD-12의 whole-object streaming 구현을 그
capability의 구현 증거로 사용하지 않는다.
36. top-level transfer runtime, account-scoped teardown, upload pause/inventory,
Image descriptor HTTP provider/refresh와 safe presentation projection은
VD-16이 소유한다. 개별 runtime factory의 존재를 operational composition
완료로 해석하지 않는다.
## Backend와 맞출 계약
- fixed BFF capability endpoint, closed session endpoint map과 runtime schema
- `PRESIGNED_MULTIPART_V1` canonical binding, server-side session lookup,
authorization/revocation
- object storage CORS, allowed method/headers, exposed receipt/checksum headers
- PUT 성공 status, receipt header, response byte length/body cap
- session expiry, 404/410 terminal 의미, list/status pagination, idempotency와
orphan cleanup
- part/full-object checksum의 정확한 알고리즘·composite 의미
- quarantine scan, promotion, status와 reject/delete lifecycle
- CDN source registry, immutable asset revision, versioned named preset의 exact
candidate 재계산과 query mismatch 거절
- image signing key overlap 배포, signer 전환, capability/client drain과
emergency revocation/forced rollout runbook
- CDN `Content-Type`, static header metadata, dimensions/decoded-byte budget,
private `no-store`, application과 분리된 CDN origin, cache key, `Vary`, CORS와 CSP
브라우저의 local file reference, native `File`/`Blob`, IndexedDB checkpoint physical
schema, OPFS path, signed URL query와 cloud object key는 backend 공유 계약이 아니다.
## 선택하지 않은 대안
- application caller가 arbitrary presigned URL을 직접 전달
- browser bundle에서 cloud signing
- 범용 JSON `HttpClient`로 binary streaming/part protocol까지 처리
- complete 응답을 scan 완료 또는 public availability로 간주
- local checkpoint만 보고 upload complete
- ETag를 무조건 MD5/SHA-256으로 해석
- private signed image URL을 query cache나 persistence에 장기 저장
- raw transform query로 CDN URL 조립
- large download의 unbounded Blob fallback
## 증적
- application ports: `src/application/ports/browser-transfer/`
- concrete adapters: `src/adapters/browser-transfer/`
- unit/fault tests: `tests/unit/`
- real browser cases: `tests/browser-capabilities/`
- 상세 설계:
`docs/architecture/presigned-transfer-and-image-cdn.md`
- Range/background 결정:
`docs/architecture/decisions/VD-14-resumable-download-and-background-transfer.md`
- composition/Image provider 결정:
`docs/architecture/decisions/VD-16-browser-transfer-composition-and-image-delivery.md`
- 운영 복구:
`docs/operations/browser-transfer-recovery.md`
@@ -0,0 +1,880 @@
# VD-13: Client cache scope, persistence와 탭 간 일관성 경계
- 상태: Accepted — staged implementation required
- 결정일: 2026-07-28
- 관련 결정: VD-10, VD-11
- 상세 설계:
`docs/architecture/client-cache-and-storage.md`
- current status ledger:
`docs/architecture/browser-data-capability-completion-ledger.md`
- 재검토:
account/tenant switching, query persistence, SSR 또는 offline mutation을
제품 capability로 선택할 때
## 1. 배경
TanStack Query memory cache, Web Storage, IndexedDB와 BroadcastChannel은 모두
client state에 관여하지만 같은 authority, 수명과 commit point를 갖지 않는다.
- TanStack Query memory cache는 현재 JavaScript runtime의 server-state projection다.
- `localStorage``sessionStorage`는 작은 preference/control record를 위한
동기식 browser storage다.
- IndexedDB는 transaction, index와 durable structured record를 제공한다.
- BroadcastChannel과 `storage` event는 같은 storage partition 안의 best-effort
notification이다.
- SSR dehydration과 browser persistence hydration은 서로 다른 source에서 생성된
cache projection을 합치는 별도 protocol이다.
현재 skeleton은 memory QueryClient, 두 개의 등록 Web Storage key와
invalidate-only cross-tab runtime을 production bootstrap에 조립한다. domain-neutral
IndexedDB runtime은 source와 native contract test가 있지만 product dataset 없이
bootstrap에서 제외돼 있다. IndexedDB query persister, durable namespace epoch,
session/account-scoped QueryClient lifecycle과 SSR hydration은 아직 구현되지
않았다.
이 차이를 숨긴 채 “client cache가 구현됐다”고 표현하면 다음 문제가 생긴다.
- logout 뒤 old account의 cache나 늦은 async result가 새 account 화면에 나타남
- best-effort invalidation event를 authorization 또는 server commit으로 오인함
- 여러 tab의 full cache snapshot이 서로 오래된 record를 다시 살림
- browser persistence가 최신 SSR payload를 덮음
- Web Storage memory fallback 성공과 durable write 성공을 구분하지 못함
- query cache를 offline command repository처럼 사용해 unsynced user data를
eviction으로 잃음
## 2. 표준 capability 상태
이 결정과 상세 설계는 다음 상태만 사용한다.
| 상태 | 의미 |
| --- | --- |
| `COMPOSED` | 구현·계약·test가 있고 production bootstrap이 실제 생성·소비한다. |
| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 test가 있지만 production bootstrap에서 생성하지 않는다. |
| `DESIGNED_NOT_IMPLEMENTED` | 경계와 invariant는 승인됐지만 실행 코드가 없다. |
| `NOT_SELECTED` | 제품 요구·owner·policy가 승인되지 않아 설치 대상이 아니다. |
| `PLATFORM_LIMITED` | browser/platform이 요구 의미를 cross-browser로 보장하지 못한다. |
`AVAILABLE_NOT_COMPOSED``NOT_SELECTED`는 같은 말이 아니다. 전자는 reusable
runtime의 구현 상태고, 후자는 제품 capability 선택 상태다. 하나의 capability에
두 축이 필요하면 “reference runtime”과 “product selection”을 별도 행으로 쓴다.
### 2.1 현재 상태
| capability | 현재 상태 | 현재 보장 |
| --- | --- | --- |
| TanStack Query memory runtime | `COMPOSED` | runtime별 QueryClient, finite inactive GC, retry owner, query AbortSignal |
| registered Web Storage | `COMPOSED` | `COLOR_SCHEME`, `CHUNK_RELOAD_GUARD`만 strict codec/envelope로 사용 |
| invalidate-only cross-tab runtime | `COMPOSED` | versioned topic, BroadcastChannel 우선, localStorage pulse fallback |
| generic IndexedDB repository/maintenance runtime | `AVAILABLE_NOT_COMPOSED` | CAS, idempotency, transaction complete, policy binding, bounded lifecycle/migration |
| session/account-scoped QueryClient lifecycle | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 cache epoch는 release ID만 포함 |
| strict query policy/key codec | `DESIGNED_NOT_IMPLEMENTED` | 현재 object key order canonicalization만 존재 |
| IndexedDB query persistence facade | `DESIGNED_NOT_IMPLEMENTED` | persistence는 registry에서 강제로 disabled |
| durable namespace invalidation ledger | `DESIGNED_NOT_IMPLEMENTED` | 없음 |
| product query persistence | `NOT_SELECTED` | persist 대상 query/owner가 없음 |
| SSR dehydration/hydration | `NOT_SELECTED` | 현재 runtime은 client SPA composition |
| exactly-once cross-tab delivery | `PLATFORM_LIMITED` | BroadcastChannel/storage event는 acknowledgement를 제공하지 않음 |
| browser storage non-eviction guarantee | `PLATFORM_LIMITED` | persist 요청도 user-agent eviction을 절대 금지하지 않음 |
## 3. 결정
### 3.1 하나의 cache/storage abstraction으로 합치지 않는다
다음 경계를 유지한다.
```text
server response
-> feature application result
-> query inbound adapter
-> scope-owned TanStack QueryClient
small approved preference/control value
-> registered Web Storage facade
-> exact localStorage/sessionStorage key
optional reconstructable query projection
-> query persistence facade
-> query-specific stable wire codec
-> governance-bound IndexedDB runtime
committed mutation
-> local namespace invalidation
-> optional durable namespace epoch commit
-> best-effort cross-tab hint
```
QueryClient, native `Storage`, `IDBDatabase`, BroadcastChannel, dehydrated TanStack
types와 physical key/store/index 이름을 application/domain에 노출하지 않는다.
### 3.2 source of truth와 authority
1. 일반 server state와 authorization의 source of truth는 서버다.
2. memory cache와 persisted query record는 재구성 가능한 projection이다.
3. cache hit, persisted restore와 invalidation event는 authorization proof가 아니다.
4. 모든 protected network request는 현재 session credential과 server
authorization을 다시 통과한다.
5. remote invalidation event는 `invalidate`만 요청할 수 있다. `remove`, `clear`,
logout, account deletion과 credential revocation authority를 갖지 않는다.
6. unsynced command, local-first draft와 user-authored offline data는 query
persistence에 저장하지 않는다. feature-specific IndexedDB repository와
sync use case가 소유한다.
## 4. session/account/release scope
### 4.1 immutable scope snapshot
composition의 session authority는 다음 의미를 갖는 immutable snapshot을 발급한다.
구현 type과 field name은 이 의미를 보존해야 한다.
```ts
type CacheScopeSnapshot = Readonly<{
protocolVersion: 1;
authorityToken: string;
partitionToken: string;
sessionEpoch: string;
accountEpoch: string;
releaseEpoch: string;
generation: number;
}>;
```
- 모든 token은 registry/session authority가 발급한 충분한 entropy의 opaque
identifier다.
- email, account/tenant/user ID, domain ID, access token과 낮은 entropy identifier의
단순 hash를 사용하지 않는다.
- `generation`은 현재 page runtime에서 단조 증가하는 local lifecycle fence다.
backend entity revision이나 wire ordering으로 사용하지 않는다.
- `sessionEpoch`는 sign-in, re-auth, credential owner 교체 때 바뀐다.
- `accountEpoch`는 account/tenant switch, logout, account deletion 때 바뀐다.
- `releaseEpoch`는 query-key, mapper, codec 또는 persistence wire compatibility가
깨질 때 바뀐다.
- scope object와 nested policy는 construction 때 copy/freeze한다. async operation은
시작 시 exact snapshot과 generation을 캡처한다.
### 4.2 profile별 scope projection
모든 query가 account token을 key에 넣지는 않는다. registry가 분류에 따라 다음을
고정한다.
| scope | binding |
| --- | --- |
| `ORIGIN_SHARED` | release epoch와 origin-shared token |
| `ACCOUNT_BOUND` | partition token, account epoch, release epoch |
| `SESSION_BOUND` | partition token, account epoch, session epoch, release epoch |
- `PUBLIC``ORIGIN_SHARED`를 사용할 수 있다.
- `INTERNAL`은 제품 authority가 origin-shared public semantics를 증명하지 않는 한
`ACCOUNT_BOUND` 이상이다.
- `PERSONAL``ACCOUNT_BOUND` 이상이고 persistence에는 explicit approval,
bounded retention과 logout purge가 필요하다.
- `CONFIDENTIAL`은 query persistence가 금지되고 필요한 순간의 memory
`SESSION_BOUND`만 허용한다.
- credential은 memory query data, persistence, query key와 invalidation wire
모두에서 금지한다.
### 4.3 composite cache epoch
cross-tab `cacheEpoch`는 raw token을 연결한 문자열이 아니라 선택된 scope projection과
protocol major의 opaque compatibility fingerprint다. receiver는 exact equality만
검사하고 원래 account/session/release 의미 값을 wire에서 복원하지 않는다.
현재 `release.<releaseId>`만 사용하는 값은 transitional implementation이다.
account-dependent query를 production에 설치하기 전에 composite scope fingerprint로
교체한다.
## 5. QueryClient lifecycle와 late-result fence
### 5.1 runtime state
scope-owned query runtime은 다음 terminal lifecycle을 갖는다.
```text
CREATING
-> ACTIVE
-> FENCING
-> DISPOSING
-> DISPOSED
```
- `ACTIVE`만 신규 query/mutation/cache update를 admission한다.
- scope transition이 시작되면 먼저 `FENCING`으로 바꾸고 generation을 올린다.
- `DISPOSING`에서 old query를 cancel하고 provider/controller를 detach한 뒤
QueryClient를 clear한다.
- old cross-tab channel, persistence writer/connection, timer와 listener를 닫는다.
- exact old Web Storage key/IndexedDB partition purge는 policy와 authority를
통과한 bounded lifecycle operation으로 수행한다.
- 새 scope는 새 QueryClient와 새 coordinator를 만든다. old client를 재사용해
key prefix만 바꾸지 않는다.
- dispose와 scope transition은 idempotent하다.
### 5.2 query fence
query execution은 TanStack의 AbortSignal과 scope generation을 모두 캡처한다.
1. 시작 전 runtime이 `ACTIVE`인지 확인한다.
2. application request에 AbortSignal을 전달한다.
3. 완료 시 captured generation과 current generation을 비교한다.
4. mismatch면 성공/실패 모두 새 cache/UI에 적용하지 않고 `STALE_RESULT`
폐기한다.
5. query cancellation 실패가 scope clear를 막지 않게 하되 safe diagnostic을
남긴다.
### 5.3 mutation fence
frontend abort는 이미 서버에 도달한 mutation을 되돌리지 않는다.
- 시작 전 admission과 generation을 확인한다.
- server commit 전 cancellation은 transport의 idempotency/cancellation 계약을
따른다.
- server 결과가 old generation에서 돌아오면 새 cache에 optimistic result,
invalidation 또는 success UI를 적용하지 않는다.
- server side effect의 authoritative 결과는 새 scope에서 정상 revalidation한다.
- mutation success 후 local invalidation 실패나 hint publish 실패가 이미 committed
server mutation을 실패로 바꾸지 않는다.
- conflict resolution은 backend revision/ETag/idempotency 계약과 feature policy가
소유한다. query cache는 business merge authority가 아니다.
### 5.4 session owner 연결
production bootstrap은 auth/session owner subscription을 query lifecycle에
연결한다. 단순 `authenticated` boolean만으로 account identity를 추론하지 않는다.
owner는 opaque scope snapshot 또는 이를 발급할 authority를 제공해야 한다.
다른 tab의 logout은 cache invalidation event에 의존하지 않는다. 각 tab의 auth
owner가 credential/session 변화를 독립적으로 감지하고 local lifecycle을
실행해야 한다.
## 6. strict query scope/persistence registry와 key codec
### 6.1 registry
모든 installed query namespace는 immutable scope/persistence profile을 갖는다.
freshness, GC, refetch, retry, result budget, pagination과 conditional policy의
유일한 source of truth는 VD-25 `ServerStateProfile`이다.
```ts
type QueryScopePersistencePolicy = Readonly<{
policyId: string;
namespace: readonly [string, number];
keySchemaVersion: number;
classification: "PUBLIC" | "INTERNAL" | "PERSONAL" | "CONFIDENTIAL";
scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND";
persistence:
| Readonly<{ kind: "MEMORY_ONLY" }>
| Readonly<{
kind: "INDEXEDDB";
profileId: string;
maxAgeMs: number;
maxEntryBytes: number;
}>;
crossTab: "NONE" | "INVALIDATE";
invalidationTopics: readonly Readonly<{
topicId: string;
topicVersion: number;
}>[];
}>;
```
construction은 최소 다음을 검증한다.
- namespace/topic/policy ID가 closed syntax와 unique version을 가짐
- persistence가 classification, scope, max age와 맞음
- `NONE`은 topic 0개, `INVALIDATE`는 namespace당 unique topic 1..8개
- `(topicId, topicVersion)` 하나는 최대 32개 namespace에 fan-out하며 global
topic→namespace set과 namespace→topic set이 서로 exact inverse
- profile과 nested allowlist를 deep snapshot/freeze함
- VD-25 query definition/`ServerStateProfile`과 join했을 때 owner,
classification, scope, namespace, persistence와 invalidation topic set/version이
일치함
composition은 `QueryDefinition -> QueryScopePersistencePolicy ->
ServerStateProfile`을 exact ID로 join한 뒤에만 TanStack option을 만든다. global
QueryClient default는 안전 baseline일 뿐이고 installed query의 정책 증거가
아니다.
### 6.2 query key wire subset
query key factory의 canonical input은 다음만 허용한다.
- `null`, boolean, finite number, bounded string
- 위 값의 dense array
- own enumerable data property만 가진 plain/null-prototype object
다음을 fail-closed로 거절한다.
- cycle/shared exotic graph
- `undefined`, `BigInt`, symbol, function, accessor
- `NaN`, infinity, negative zero를 구분하지 않는 암묵 변환
- Date, RegExp, Map, Set, class/DOM/native object
- File, Blob, ArrayBuffer와 typed array
- sparse array
- `__proto__`, `prototype`, `constructor` key
- 허용 depth/node/part/string/serialized-byte ceiling 초과
구현 절대 상한:
| 항목 | 상한 |
| --- | ---: |
| installed query profile | 256 |
| query key top-level part | 16 |
| canonical value depth | 8 |
| canonical value node | 256 |
| 단일 string UTF-8 | 1,024 bytes |
| 전체 canonical key UTF-8 | 4,096 bytes |
제품 profile은 더 낮출 수 있지만 이 상한을 높이려면 ADR amendment와
memory/telemetry cardinality evidence가 필요하다.
normative key layout:
```text
[
"query",
keySchemaVersion,
scopeFingerprint,
namespaceName,
namespaceVersion,
queryDefinitionVersion,
canonicalSemanticInput
]
```
VD-25는 이 배열을 재정의하지 않고 마지막 두 field의 의미와 pagination
projection만 소유한다. query function이 의존하는 모든 non-secret input을
포함하되 URL 전체, bearer
token, email, filename, human-readable personal label을 넣지 않는다. domain entity
identity가 필요하면 backend/product contract가 발급한 opaque ID와 bounded codec을
사용한다.
### 6.3 memory pressure
`gcTime`은 inactive retention이지 active cache hard cap이 아니다.
- gateway/mapper가 response count/byte ceiling을 검증한다.
- binary, native object와 unbounded collection을 query cache에 넣지 않는다.
- cache entry/active/inactive와 estimated payload를 safe bucket으로 관측한다.
- hard eviction controller는 joined VD-25 profile별 정책으로만 설치한다.
- memory pressure를 이유로 active personal data를 arbitrary global timer로
삭제하지 않는다. scope lifecycle의 remove/clear와 일반 eviction을 구분한다.
## 7. Web Storage contract
### 7.1 registered key policy
Web Storage는 registered small value 전용이다.
```ts
type WebStorageDefinition<Value> = Readonly<{
logicalName: string;
backend: "localStorage" | "sessionStorage";
scope: "ORIGIN_SHARED" | "OPAQUE_PARTITION" | "TAB";
classification: "PUBLIC_PREFERENCE" | "OPAQUE_CONTROL";
schemaVersion: number;
maxSerializedBytes: number;
retention:
| Readonly<{ kind: "SESSION" }>
| Readonly<{ kind: "TTL"; maxAgeMs: number }>
| Readonly<{ kind: "EXPLICIT_DELETE" }>;
valueCodec: string;
migration:
| Readonly<{ kind: "DISCARD" }>
| Readonly<{ kind: "ADJACENT"; migrationId: string }>;
quotaFallback: "MEMORY" | "NO_PERSIST" | "FEATURE_DISABLE";
logoutAction: "KEEP" | "PURGE_PARTITION";
}>;
```
구현 절대 상한:
| 항목 | 상한 |
| --- | ---: |
| registered persistent key | 64 |
| key별 serialized value | 16,384 bytes |
| 한 sweep에서 검사할 key | 16 |
| 한 read에서 migration step | 2 |
현재 두 key는 각각 더 좁은 codec을 유지한다. `COLOR_SCHEME`은 public
origin-shared preference이고 `CHUNK_RELOAD_GUARD`는 tab session control이다.
server response, credential, signed URL, File/Blob, large draft와 queue를 Web
Storage에 넣지 않는다.
### 7.2 physical identity와 envelope
physical key는 application/environment, scope kind, opaque partition 또는 tab
instance, logical key, schema version에서 결정적으로 파생한다. account/user ID를
포함하거나 origin 전체 key를 열거하지 않는다.
partition-aware 새 envelope는 기존 v1 세 필드의 의미를 변경하지 않고 새
envelope version으로 도입한다.
```ts
type BrowserStorageEnvelopeV2 = Readonly<{
envelopeVersion: 2;
schemaVersion: number;
scopeFingerprint: string;
writtenAtEpochMs: number;
expiresAtEpochMs: number | null;
value: unknown;
}>;
```
- exact field set, schema, scope, codec, written/expiry time 순으로 검증한다.
- TTL expiry는 write time과 registry max age에서 계산하며 caller가 직접 주지 않는다.
- 비정상적으로 먼 expiry, future write time과 clock skew는 fail-closed miss다.
- corrupt/expired/future/wrong-scope record는 exact key만 best-effort 제거한다.
- cleanup 실패는 validated miss를 raw exception으로 바꾸지 않는다.
- memory overlay도 exact envelope와 TTL/scope validation을 공유한다.
### 7.3 read/write outcome
stored `undefined`와 miss를 암묵적으로 합치지 않는다.
```ts
type WebStorageReadResult<Value> =
| Readonly<{ ok: true; state: "HIT"; value: Value; durability: "PERSISTED" | "MEMORY_ONLY" }>
| Readonly<{ ok: true; state: "MISS" }>
| Readonly<{ ok: false; error: ClientStorageFailure }>;
type WebStorageWriteResult =
| Readonly<{ ok: true; durability: "PERSISTED" }>
| Readonly<{ ok: true; durability: "MEMORY_ONLY"; degraded: true }>
| Readonly<{ ok: false; error: ClientStorageFailure }>;
```
memory fallback이 current runtime에서 승인된 성공이면 `ok: true`
`MEMORY_ONLY`를 반환한다. durable write가 필수인 key는 fallback을 성공으로
가장하지 않는다.
### 7.4 migration, quota와 sweep
- migration은 registry에 등록된 deterministic adjacent version만 실행한다.
- migration callback은 network/native storage/telemetry side effect 없이 bounded
pure codec으로 동작한다.
- future version과 unsupported old version은 `DISCARD` policy에서 miss다.
- `QuotaExceededError`이면 reconstructable exact key cleanup 뒤 동일 idempotent
write를 최대 한 번 재시도한다.
- origin 전체 `clear()`와 arbitrary LRU key enumeration을 금지한다.
- TTL은 visibility rule이므로 boot/idle/focus 중 registry-owned bounded sweep을
별도로 수행한다.
- logout/account switch는 exact partition key만 purge한다. public origin-shared
preference를 지우지 않는다.
- `sessionStorage` opener snapshot을 authority로 사용하지 않는다. tab-local
control에는 새 tab instance와 `noopener` policy를 적용한다.
## 8. optional IndexedDB query persistence
### 8.1 selection
query persistence reference facade의 현재 상태는
`DESIGNED_NOT_IMPLEMENTED`, product selection은 `NOT_SELECTED`다. 단순 warm-start
기대만으로 자동 설치하지 않는다.
다음 조건을 모두 충족한 query만 등록한다.
- server-authoritative이며 재구성 가능함
- stable query-key와 payload codec이 있음
- classification/scope/retention owner 승인
- entry/dataset/restore byte와 count budget이 있음
- logout/account deletion/release busting이 정의됨
- measured offline/warm-start 가치가 있음
- three-engine native contract와 rollback evidence가 있음
### 8.2 stable record, raw TanStack snapshot 금지
full QueryClient snapshot이나 library-private object를 그대로 저장하지 않는다.
```ts
type PersistedQueryRecord = Readonly<{
recordVersion: 1;
queryHash: string;
encodedQueryKey: unknown;
policyId: string;
scopeFingerprint: string;
releaseEpoch: string;
namespaceEpoch: number;
dataUpdatedAtEpochMs: number;
persistedAtEpochMs: number;
expiresAtEpochMs: number;
payloadCodecVersion: number;
payload: unknown;
measuredBytes: number;
revision: number;
}>;
```
- approved successful query data만 저장한다.
- error, pending state, mutation, function, Promise, AbortSignal, native/binary
object, credential와 capability를 저장하지 않는다.
- query key와 payload를 각각 strict codec으로 검증한다.
- generic IndexedDB runtime의 opaque scope/policy binding, transaction complete,
CAS, byte budget, migration, lifecycle와 failure mapping을 재사용한다.
### 8.3 구현 상한
reference facade의 기본 절대 상한:
| 항목 | 상한 |
| --- | ---: |
| persisted query record | 1,024 |
| 단일 encoded entry | 512 KiB |
| query persistence dataset | 32 MiB |
| 한 restore record | 256 |
| 한 restore decoded bytes | 8 MiB |
| boot restore deadline | 2,000 ms |
| max age | 7 days |
| write debounce | 2502,000 ms |
제품 policy는 더 낮출 수 있다. 상한 확대는 memory/quota/startup-latency evidence와
ADR amendment가 필요하다.
### 8.4 durable namespace epoch
full snapshot last-write-wins를 금지한다. 기본 writer model은 shared per-query
record + monotonic namespace epoch다.
```ts
type DurableCacheLedger = Readonly<{
ledgerVersion: 1;
scopeFingerprint: string;
releaseEpoch: string;
namespaces: Readonly<Record<string, number>>;
revision: number;
}>;
```
- mutation invalidation은 namespace epoch를 같은 IndexedDB transaction에서
증가시킨 뒤 cross-tab hint를 publish한다.
- persisted record의 namespace epoch가 ledger보다 작으면 hydrate하지 않는다.
- record write는 current ledger epoch와 revision을 CAS 검증한다.
- BroadcastChannel sequence나 wall clock을 global durable ordering으로 사용하지
않는다.
- localStorage read-modify-write counter와 best-effort leader election을 correctness
fence로 쓰지 않는다.
- ledger commit 뒤 hint를 publish한다. hint가 먼저 나가면 receiver가 commit 전
record를 읽을 수 있다.
### 8.5 restore와 hydration order
1. bounded deadline으로 IndexedDB를 연다.
2. immutable dataset/scope/release binding을 검증한다.
3. ledger와 record schema/codec/TTL/byte cap을 검증한다.
4. approved profile과 current namespace epoch만 decode한다.
5. current memory/SSR state와 precedence를 적용한다.
6. hydrate 뒤 normal stale/refetch policy를 실행한다.
wrong scope, expired, busted와 corrupt reconstructable record는 cache miss로
격하하고 exact bounded cleanup한다. persistence unavailable/blocked/timeout은
제품이 optional로 선택했다면 memory+network `ONLINE_ONLY`로 fail open한다.
offline-required workflow를 query persistence로 가장하지 않는다.
### 8.6 writer lifecycle
- cache events는 bounded debounce/coalescing한다.
- writer 하나에서 concurrent save를 serialize하고 superseded write를 버린다.
- `pagehide`/`beforeunload` transaction 완료를 보장으로 간주하지 않는다.
- 정상 runtime 중 주기적으로 commit하고 unload flush는 보조 수단이다.
- dispose는 timer를 취소하고 connection/listener를 닫는다.
- 아직 transaction complete가 아닌 write를 persisted success로 기록하지 않는다.
## 9. cross-tab invalidation
### 9.1 authority
cross-tab wire는 payload/query-key-free invalidate hint만 전달한다.
- query state/data replication 금지
- authorization/logout/server commit 증명 금지
- distributed lock/leader election 금지
- exactly-once/ordered delivery 주장 금지
- offline command 전송 금지
remote hint는 registry topic을 local namespace로 해석해 active query를
invalidate/refetch한다. inactive query는 다음 mount/focus/freshness 정책에서
revalidate한다. remote hint는 `removeQueries`, `clear()` 또는 session transition을
직접 실행하지 않는다.
### 9.2 transport와 source validation
```text
BroadcastChannel
-> construction/post failure
-> registered localStorage pulse + storage event
-> failure/unavailable
-> DEGRADED_LOCAL_ONLY + normal stale/focus/reconnect
```
- current 2,048-byte exact wire envelope와 bounded TTL/dedupe/source tracking을
유지한다.
- topic registry 수에도 query profile과 같은 256개 절대 상한을 적용한다.
- localStorage fallback key를 Web Storage control registry에 등록한다.
- receiver는 exact key, exact `storageArea === localStorage`, exact composite
cache epoch와 event codec을 검증한다.
- `sessionStorage`를 cross-tab fallback으로 사용하지 않는다.
- publisher는 local invalidation을 직접 수행한다.
- publish success는 receiver acknowledgement가 아니다.
- BroadcastChannel과 storage 양쪽 delivery는 event ID로 dedupe한다.
- per-source sequence gap은 global order 증명이 아니라 “hint를 잃었을 수 있음”을
나타낸다.
### 9.3 lost hint
query persistence가 꺼져 있으면 finite stale time, focus/reconnect와 manual refresh가
eventual revalidation을 제공한다. persistence가 켜져 있으면 visibility/focus와
sequence gap에서 durable namespace ledger를 bounded refresh한다.
즉시 global consistency가 업무 invariant라면 browser bus만으로 충족하지 않는다.
backend revision/ETag, server push stream 또는 feature sync protocol을 추가한다.
## 10. SSR 선택 경계
현재 SSR product capability는 `NOT_SELECTED`다. browser-only code가 있다는 이유로
SSR support가 구현됐다고 주장하지 않는다.
SSR을 선택하면 별도 implementation gate에서 다음을 모두 구현한다.
1. HTTP request마다 새 QueryClient를 생성하고 response 뒤 폐기한다.
2. server process에서 Web Storage, IndexedDB와 BroadcastChannel에 접근하지 않는다.
3. approved successful query만 dehydrate한다.
4. serialized state를 HTML context에 안전하게 escape하고 byte/count cap을 적용한다.
5. browser의 최신 SSR payload가 old persisted projection보다 우선한다.
6. persisted state merge는 missing approved query만 복원하거나 explicit server
revision을 비교한다.
7. browser storage read 때문에 initial server/client markup이 달라지지 않게
hydration-safe bootstrap 단계에서 restore한다.
8. request A의 QueryClient/data/scope가 request B에 공유되지 않는 test를 둔다.
SSR support와 IndexedDB query persistence는 서로 독립 선택이다.
## 11. privacy와 encryption
- credential, token, signed URL, authorization header, password와 crypto key는
memory query key/data, Web Storage, query persistence와 invalidation wire에
넣지 않는다.
- logical/physical key, query key/hash input, payload, account/user ID, URL과 native
exception message/stack을 telemetry에 보내지 않는다.
- 같은 origin JavaScript가 ciphertext와 key를 모두 읽을 수 있는 client-side
encryption은 XSS authorization boundary가 아니다.
- external/non-extractable key lifecycle과 compliance requirement가 있는 제품은
encryption을 defense-in-depth로 별도 선택할 수 있지만, 금지 classification을
허용하는 근거가 되지 않는다.
- logout purge는 confidentiality의 유일한 방어가 아니다. wrong-scope binding은
crash로 old bytes가 남아도 새 runtime이 읽지 못하게 해야 한다.
## 12. failure와 observability
failure는 최소 operation, closed code, retry owner, effect certainty와 fallback을
표현한다.
- `ABORTED``DEADLINE_EXCEEDED`를 구분한다.
- IndexedDB transaction `complete``APPLIED`다.
- Broadcast publish success의 remote effect는 `UNKNOWN`이다.
- memory fallback과 persisted success를 구분한다.
- optional persistence failure는 `ONLINE_ONLY`로 degrade할 수 있다.
- scope mismatch/corruption/future version은 raw record를 반환하지 않는다.
- diagnostics failure가 query, storage, lifecycle와 cleanup을 실패시키지 않는다.
safe metric:
- memory active/inactive/estimated-byte bucket
- Web Storage hit/miss/degraded/quota bucket
- persistence restore success/miss/busted/corrupt/deadline bucket
- scope reset duration/cleanup-incomplete
- invalidation publish/receive/drop/duplicate/gap/coalesced bucket
- listener/channel/connection leak count
## 13. implementation gate
### Gate 0 — 상태와 문서
- 이 ADR과 상세 설계가 current/target 상태를 분리한다.
- capability catalog, runbook과 test evidence의 상태가 같은 taxonomy를 사용한다.
- 구현되지 않은 target type을 current API처럼 문서화하지 않는다.
### Gate 1 — strict registry와 codec
- query policy registry와 query key closed codec 구현
- profile/key absolute ceiling 구현
- Web Storage per-key cap, HIT/MISS/durability result 구현
- current v1 key의 discard/upgrade 전략 확정
- hostile/cyclic/oversize/property-accessor test 통과
이 gate는 scope lifecycle을 자동 활성화하지 않는다.
### Gate 2 — scope-owned QueryClient lifecycle
- session authority scope snapshot contract 구현
- auth owner subscription과 local generation fence 구현
- old query cancel/provider detach/client clear/dispose 구현
- late query/mutation result 폐기 구현
- account switch/logout exact partition cleanup 구현
- two-account and lost-event tests 통과
account-dependent query promotion은 이 gate 전 금지한다.
### Gate 3 — cross-tab scope hardening
- composite cache epoch 구현
- registered localStorage pulse와 storageArea 검증 구현
- browser production coordinator E2E와 bfcache/StrictMode leak test
- Chromium/Firefox/WebKit 동일 case evidence
### Gate 4 — optional query persistence reference runtime
- stable query record codec와 IndexedDB facade 구현
- durable namespace ledger/CAS/commit-before-hint 구현
- bounded restore/write/dispose 구현
- wrong-scope/TTL/release/migration/quota/blocked test
- production bootstrap import와 DB open이 없는 module-inventory/removal gate
완료 뒤에도 product selection은 `NOT_SELECTED`이고 reference 상태만
`AVAILABLE_NOT_COMPOSED`로 바뀐다.
### Gate 5 — product composition
- measured requirement와 owner 승인
- exact query profile/persistence allowlist/retention/budget 등록
- account/logout/backend conflict contract 승인
- disabled → canary → enabled traffic admission
- rollback, cleanup-only release와 operational drill
### Gate 6 — SSR 또는 offline workflow
각 capability를 별도 선택하고 별도 gate를 통과한다.
- SSR: request isolation, safe dehydration, precedence와 hydration test
- offline mutation: feature repository, server idempotency/revision/sync protocol,
conflict/export/recovery UX
query persistence gate 통과가 SSR/offline workflow 통과를 의미하지 않는다.
## 14. test와 promotion evidence
### 14.1 deterministic
- independent QueryClient per runtime/scope
- session/account/release transition과 late result
- query key hostile value/ceiling/canonical equality
- Web Storage HIT/MISS/durability, TTL, migration, quota, cleanup, partition
- IndexedDB transaction complete, CAS, ledger epoch와 concurrent writer
- hint commit ordering, duplicate/self/stale/gap/coalescing
- diagnostics redaction와 dispose leak 0
### 14.2 real browser
Chromium, Firefox와 WebKit에서 같은 case set을 실행한다.
- native BroadcastChannel two-page delivery
- localStorage fallback과 exact storageArea
- account switch 중 in-flight query
- event loss 뒤 local auth lifecycle
- IndexedDB concurrent writer/blocked/versionchange/restore deadline
- bfcache/pagehide/StrictMode listener·connection cleanup
- sessionStorage tab/opener semantics
- N-1 release reader와 incompatible buster
현재 native transport spec이 존재해도 production QueryClient lifecycle 전체와
세 engine promotion artifact가 없으면 Gate 3 완료로 보지 않는다.
### 14.3 promotion artifact
artifact는 engine/browser version/OS image/build/release ID/contract suite
version/pass/fail/skip/실행 시각을 보존한다. fake/jsdom 통과를 native provider
통과로 보고하지 않는다. WebKit system dependency 부족은 capability skip이 아니라
promotion evidence 미충족이다.
## 15. rollout과 rollback
### 15.1 rollout
1. strict registry/codec을 기존 behavior 뒤 shadow validation으로 배포한다.
2. scope lifecycle을 single-account environment에서 먼저 관측한다.
3. account switch/logout fault test 뒤 account-dependent query를 허용한다.
4. optional persistence는 source와 test만 추가하고 production composition은
계속 끈다.
5. product selection 뒤 read-only restore/shadow write를 먼저 검증한다.
6. 작은 cohort에서 write/restore/quota/blocked/rollback drill을 수행한다.
7. error budget과 N-1 compatibility를 확인한 뒤 확대한다.
### 15.2 kill switch
서로 독립적으로 끌 수 있어야 한다.
- persistence restore off
- persistence write off
- cross-tab publish off
- cross-tab receive off
- offline mutation admission off
memory QueryClient와 정상 server fetch는 유지한다. account/session local lifecycle은
security boundary이므로 best-effort invalidation kill switch와 함께 끄지 않는다.
### 15.3 rollback
1. 신규 persistence write/restore admission을 중지한다.
2. writer/timer/channel/listener/DB connection을 dispose한다.
3. scope fence와 memory QueryClient clear는 유지한다.
4. rollback bundle이 future record/schema를 miss/online-only로 처리하게 한다.
5. cleanup-only compatible release에서 exact owned partition을 bounded purge한다.
6. retention/rollback window 뒤 registry/adapter/dependency를 제거한다.
schema version을 내리거나 origin 전체 `localStorage.clear()`/
`indexedDB.deleteDatabase()`를 자동 실행하지 않는다. unsynced user-authored data는
export/sync 확인 없이 query-cache cleanup으로 삭제하지 않는다.
## 16. 완료 기준
- [ ] session/account/release scope가 QueryClient, key와 event에 binding된다.
- [ ] scope transition은 admission fence, cancel, detach, clear, dispose와 새
QueryClient 생성으로 완료된다.
- [ ] old generation query/mutation result가 새 scope UI/cache를 변경하지 않는다.
- [ ] strict query scope/persistence registry와 closed key codec이 모든 absolute
ceiling을 강제하고 VD-25 profile과 exact join된다.
- [ ] Web Storage가 per-key cap, HIT/MISS, durability, partition, logout,
migration과 bounded sweep을 구현한다.
- [ ] localStorage invalidation fallback이 registered key와 exact storageArea를
검증한다.
- [ ] Chromium/Firefox/WebKit production coordinator/account lifecycle evidence가
있다.
- [ ] optional query persistence reference facade는 stable record와 durable
namespace ledger를 사용하고 production 미선택 시 zero side effect다.
- [ ] persisted mutation/error/native object/credential이 없음을 negative test가
증명한다.
- [ ] SSR을 선택한 경우 request isolation과 hydration precedence가 증명된다.
- [ ] offline mutation을 선택한 경우 backend idempotency/revision/sync와
conflict/recovery UX가 별도 계약으로 증명된다.
- [ ] rollout/kill-switch/rollback/removal artifact가 보존된다.
현재 이 체크리스트는 완료 선언이 아니라 implementation gate다. 각 행을 실제
source, deterministic test, native evidence와 composition inventory로 증명하기
전에는 완료로 바꾸지 않는다.
## 17. 선택하지 않은 대안
- module singleton QueryClient
- account switch에서 query key prefix만 교체
- BroadcastChannel logout event를 lifecycle authority로 사용
- arbitrary query key와 raw TanStack cache snapshot persistence
- localStorage full-cache snapshot 또는 monotonic counter
- browser persistence를 offline command queue로 사용
- same-origin client encryption을 credential authorization boundary로 사용
- origin 전체 storage clear를 quota/logout/rollback 복구로 사용
- fake browser test만으로 production promotion
## 18. 결과
장점:
- account/session boundary와 best-effort invalidation의 권한 차이가 명확하다.
- persistence를 선택하지 않은 제품에는 DB open/listener/bundle side effect가 없다.
- query key, Web Storage와 IndexedDB의 migration/retention을 독립적으로 검증한다.
- old tab/snapshot이 invalidated data를 되살리는 경로를 durable epoch로 닫는다.
- SSR, offline workflow와 query warm-start를 서로 독립 선택할 수 있다.
비용:
- scope authority와 QueryClient remount lifecycle이 필요하다.
- registry/codec/historical fixture와 multi-page browser test가 늘어난다.
- optional persistence를 설치하는 제품은 IndexedDB migration, quota와 cleanup
runbook을 운영해야 한다.
이 비용은 cache hit, durable restore, cross-tab hint와 server truth를 하나의
“cached” 상태로 잘못 합치지 않기 위한 의도적인 비용이다.
@@ -0,0 +1,898 @@
# VD-14: Resumable download와 background download 경계
- 상태: Accepted — production design complete, implementation pending
- 결정일: 2026-07-28
- catalog recipe availability: `RECIPE_AVAILABLE` (primary status/selection과 별도)
- Range resumable download primary status: `DESIGNED_NOT_IMPLEMENTED`
- app-managed background download primary status: `NOT_SELECTED`
- cross-browser app-managed background download guarantee: `PLATFORM_LIMITED`
- 관련 결정: VD-10, VD-11, VD-12
- current status ledger:
`docs/architecture/browser-data-capability-completion-ledger.md`
- 재검토: 제품이 Range 재개, 탭 종료 뒤 전달 또는 대용량 Safari fallback을
선택할 때
## 1. 배경과 현재 사실
현재 reference runtime은 whole-object `200` response를 bounded stream으로 읽어
foreground destination에 저장하거나 browser download manager에 handoff한다. 이
경로는 전체 payload를 하나의 `Blob`으로 만들지 않고 byte length와 SHA-256을
검증하지만, 네트워크나 탭이 중단되면 다음 실행은 byte 0부터 다시 시작한다.
Range resume는 기존 stream에 `Range` header 하나를 추가하는 기능이 아니다.
representation identity, exact `206 Content-Range`, durable partial destination,
checkpoint CAS, `200/412/416` reconciliation과 마지막 whole-object integrity가
하나의 protocol이어야 한다. background download도 Range resume와 동일하지 않다.
브라우저 download manager에 넘기는 것과 애플리케이션이 Service Worker에서
전송을 계속 관리하는 것은 완료 증거와 상호운용성이 전혀 다르다.
이 ADR은 목표 계약을 정의한다. 이 문서가 존재한다는 사실은 runtime, endpoint,
worker 또는 제품 UX가 구현·조합되었다는 뜻이 아니다.
## 2. 표준 capability 상태
설계, source 존재, 제품 조합과 플랫폼 한계를 하나의 `enabled` boolean으로 합치지
않는다. primary current status는 다음 다섯 값 중 정확히 하나다. 이 taxonomy는
선형 maturity model이 아니며 상태 이름만으로 rollout 또는 production readiness를
추론하지 않는다.
| primary status | 의미 |
| --- | --- |
| `NOT_SELECTED` | 제품 요구, owner, policy 또는 구현 범위가 아직 선택되지 않음 |
| `DESIGNED_NOT_IMPLEMENTED` | versioned contract와 불변조건은 승인됐지만 reference source가 없음 |
| `AVAILABLE_NOT_COMPOSED` | 검증 가능한 reference source가 있지만 제품 bootstrap/endpoint에는 연결되지 않음 |
| `COMPOSED` | 특정 제품 facade, config와 dependency에 실제로 조합됨 |
| `PLATFORM_LIMITED` | 요구 semantics를 target browser/platform 전체에서 보장할 수 없음 |
production readiness와 traffic admission은 primary status와 독립된 축이다.
운영 상태는 completion ledger가 정의한 네 canonical 축만 사용한다.
```text
Selection =
NOT_SELECTED | SELECTED | REMOVING
TrafficAdmission =
DISABLED | SHADOW | CANARY | ENABLED
RuntimeHealth =
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
PromotionEvidence =
MISSING | PARTIAL | COMPLETE | EXPIRED
```
아래 `source evidence`는 ADR과 reference source의 존재를 설명하는 문서 표기일
뿐 canonical readiness 축이 아니다. `DESIGN_REVIEWED`는 native browser나
provider 증거가 아니고, `REFERENCE_TESTED`도 ledger의 browser component를
`PROMOTABLE` 또는 `PromotionEvidence=COMPLETE`로 만들지 않는다.
현재 capability별 판정:
| capability | primary status | source evidence | 비고 |
| --- | --- | --- | --- |
| whole-object foreground streaming | `AVAILABLE_NOT_COMPOSED` | `REFERENCE_TESTED` | 기존 VD-12 범위 |
| browser-managed handoff mechanism | `AVAILABLE_NOT_COMPOSED` | `REFERENCE_TESTED` | 실제 capability issuer는 제품 연결 시 필요 |
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | `DESIGN_REVIEWED` | 이 ADR의 구현 대상 |
| app-managed background download | `NOT_SELECTED` | `DESIGN_REVIEWED` | 제품 요구가 선택될 때만 별도 구현 |
| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | `DESIGN_REVIEWED` | 공통 baseline으로 promotion 불가 |
기존 foreground stream과 browser-managed handoff의 source/evidence를 Range나
app-managed background download 구현 증거로 재사용하지 않는다.
## 3. 결정 요약
1. whole-object foreground streaming, Range resumable download,
browser-managed handoff와 app-managed background download를 서로 다른
capability와 결과 타입으로 유지한다.
2. Range protocol literal은 `RANGE_RESUMABLE_DOWNLOAD_V1`로 고정한다. 기존
whole-object presigned contract에 암묵적으로 섞지 않는다.
3. resume의 authority는 server-owned immutable generation과 strong validator다.
local offset, file name, timestamp 또는 partial byte 존재는 authority가 아니다.
4. 각 data-plane capability는 exact representation, start/end range, method,
response status/header/length와 expiry를 묶고 한 번만 사용한다.
5. checkpoint는 비권한성 recovery metadata만 account-partitioned storage에
보관한다. URL, signed query/header, raw ETag, bearer token과 file path는
저장하지 않는다.
6. destination은 seek/truncate 가능한 명시적 port 또는 owned OPFS staging이다.
순차 writable에 검증되지 않은 partial bytes를 append하지 않는다.
7. checkpoint offset은 destination segment가 durable하게 commit되고 exact length가
재확인된 뒤에만 CAS로 전진한다.
8. final success는 destination 전체를 처음부터 다시 읽어 whole-object SHA-256을
검증하고 final commit을 마친 경우만 `SAVED_VERIFIED`다.
9. browser-managed handoff는 탭 종료 뒤 계속될 수 있는 기본 server-file
fallback이지만 결과는 계속 `BROWSER_HANDOFF`다.
10. app-managed background download는 cross-browser baseline이 아니다. 별도
optional protocol, platform probe, worker control plane과 owned staging이 모두
승인된 환경에서만 progressive enhancement로 조합한다.
11. browser 차이는 user-agent 문자열이 아니라 capability probe와 정책으로
결정한다.
## 4. Topology와 책임
```text
product download use case
-> product-owned download facade
-> DownloadStrategySelector
-> WHOLE_OBJECT_PICKER_STREAM
-> RANGE_RESUMABLE_FOREGROUND
-> BROWSER_MANAGED_HANDOFF
-> BOUNDED_OBJECT_URL
-> APP_MANAGED_BACKGROUND_DOWNLOAD (optional)
RANGE_RESUMABLE_FOREGROUND
-> BFF control plane
authorization
immutable representation lookup
range capability issuance/reissue
-> browser RangeDownloadRuntime
checkpoint + mutation lock
exact HTTP state machine
seekable destination or OPFS staging
whole-object verification
-> object store/BFF byte plane
APP_MANAGED_BACKGROUND_DOWNLOAD
-> window-owned admission and user intent
-> worker-specific control plane
-> owned OPFS staging
-> later foreground export
```
브라우저는 bucket, object key, provider generation locator, signing key 또는 cloud
credential을 소유하지 않는다. BFF가 logical resource를 exact immutable
representation에 binding한다. direct object-store Range가 해당 binding과
capability의 `preconditionMode`가 선택한 exact `If-Range` 또는
immutable-generation precondition을 실제로 강제하지 못하면 BFF proxy/relay를
사용한다.
## 5. Versioned Range capability
### 5.1 Application-visible handle
application에는 raw URL이나 validator를 노출하지 않는다.
```text
RangeDownloadCapability
protocol = RANGE_RESUMABLE_DOWNLOAD_V1
opaque identity
safe receipt
resourceId
representationBindingSha256
totalByteLength
mediaType
wholeObjectSha256
requestedStart
requestedEndExclusive
preconditionMode = STRONG_IF_RANGE | IMMUTABLE_GENERATION_PRECONDITION
allowWholeObjectFallback
expiresAtEpochMs
```
adapter-owned identity vault에는 다음 data-plane binding을 함께 둔다.
```text
exact HTTPS URL/query
exact GET method
exact origin/path
exact Range header
exact precondition header/value selected by preconditionMode
required response headers
allowed statuses = policy-derived exact subset of 200 | 206 | 412 | 416
expected representation binding
maximum response bytes
single-use receipt
```
`representationBindingSha256`는 protocol/version, logical resource, immutable
generation, precondition mode별 normalized strong validator 또는 generation
binding, exact total length, media type와 expected whole-object digest의 canonical
binding이다. 이것은 authorization proof가 아니다. BFF는 client 값을 echo하지
않고 registry snapshot에서 직접 재계산한다.
### 5.2 Strong validator
resume에는 다음 중 하나가 필요하다.
- server registry가 소유하는 immutable object generation과 그 generation에 pin된
proxy/direct request
- RFC semantics를 만족하는 strong ETag와 exact `If-Range`
weak ETag(`W/`), `Last-Modified`만 있는 representation, multipart ETag를 whole
digest로 해석한 값과 CDN이 임의로 다시 쓴 validator는 resume authority로
사용하지 않는다. provider가 strong validator를 제공하지 못하면 BFF가 immutable
generation을 pin하거나 Range resume를 `UNSUPPORTED`로 닫는다.
raw ETag와 provider generation locator는 application, checkpoint, diagnostics와
telemetry에 노출하지 않는다. reload 뒤에는 BFF가 새 capability를 발급하고,
runtime은 새 capability의 `representationBindingSha256`가 checkpoint와 같은지
확인한 뒤 vault 안의 exact precondition만 사용한다.
`STRONG_IF_RANGE` mode는 exact `If-Range`를 보내고 `206`, Range-ignore 또는
validator mismatch의 full `200`과 해당 `416`만 계약한다.
`IMMUTABLE_GENERATION_PRECONDITION` mode는 BFF/provider가 정한 exact `If-Match`
또는 generation precondition을 보내며 `412`를 계약할 수 있다.
`allowWholeObjectFallback``allowedStatuses`는 mode, requested start와 provider
topology에서 capability 발급 시 닫히며 executor가 임의로 넓히지 않는다.
### 5.3 Capability 재발급
capability expiry, data-plane `401/403/410` 또는 최소 잔여 lifetime 부족은 같은
URL의 무조건 retry가 아니다.
1. 현재 response reader를 cancel하고 capability를 consume한다.
2. control plane에 `downloadKey`, resource와 expected representation binding,
exact next range를 전달한다.
3. BFF가 authorization와 current generation을 다시 읽는다.
4. binding이 같을 때만 새 capability로 같은 range를 재시도한다.
5. binding이 바뀌었으면 partial destination을 append하지 않고
`REPRESENTATION_CHANGED/RESTART`로 닫는다.
재발급 횟수, 전체 operation deadline과 retry backoff는 composition hard ceiling
안에 둔다. capability를 durable queue나 worker message에 저장하지 않는다.
## 6. Durable checkpoint
### 6.1 Schema
```text
RangeDownloadCheckpointV1
schemaVersion = 1
protocol = RANGE_RESUMABLE_DOWNLOAD_V1
revision
state = ACTIVE | PAUSED | FINALIZING | CLEANUP_PENDING
downloadKey
resourceBindingSha256
representationBindingSha256
totalByteLength
nextOffset
committedSegmentCount
destination
kind = OPFS_STAGING | SEEKABLE_FILE
opaqueDestinationBinding
createdAtEpochMs
updatedAtEpochMs
retentionExpiresAtEpochMs
```
`downloadKey`, destination binding과 physical database/OPFS namespace는
composition-issued opaque token이다. 사용자 file name, resource ID, account ID,
tenant ID 또는 local path를 넣지 않는다.
checkpoint에 금지하는 값:
- presigned URL, query와 signed request/response header
- bearer/session/auth/CSRF token
- raw ETag, provider object key/generation locator
- file name, user path와 native exception
- incremental hash 내부 state
- raw backend response나 retry body
허용된 digest binding과 offset은 비권한성 recovery metadata다. account partition,
retention, count/byte budget과 logout deletion을 적용하며 log/analytics/ticket에는
내보내지 않는다.
### 6.2 CAS와 durable offset
`downloadKey`는 cross-context exclusive mutation lock으로 직렬화한다. lock은
correctness의 유일한 authority가 아니며 checkpoint revision CAS와 exact
destination binding이 최종 local authority다.
`nextOffset`은 다음 순서가 모두 성공한 뒤에만 전진한다.
1. exact `206` range를 bounded stream으로 읽는다.
2. expected start 위치에만 쓴다.
3. writer close/segment commit을 완료한다.
4. destination의 committed length가 expected end 이상인지 확인한다.
5. unexpected tail이 있으면 authorized `truncate(expectedEnd)`를 완료한다.
6. checkpoint를 `revision + 1`, `nextOffset = expectedEnd`로 CAS한다.
response가 성공했지만 destination commit 전에 crash하면 checkpoint는 이전
offset에 머문다. 재시작은 destination을 checkpoint offset으로 truncate하고 같은
range를 다시 요청한다. destination commit 뒤 checkpoint CAS가 유실된 경우도
동일하게 checkpoint offset까지 truncate한 뒤 재전송한다. 따라서 중복 byte를
append하지 않는다.
### 6.3 Inventory와 retention
checkpoint store는 단일 key read 외에 bounded admin operation을 제공해야 한다.
- account partition 안의 safe summary를 cursor page로 list
- expired/terminal checkpoint를 bounded batch로 classify
- destination binding과 함께 exact owned staging을 cleanup
- active lock/lease가 있는 항목은 건너뜀
- count, logical bytes, maximum age와 cleanup retry budget 강제
- cleanup receipt를 durable하게 남기고 response 유실을 reconcile
inventory에는 resource ID, file name, digest, raw validator와 path를 반환하지
않는다. 제품 resume UI가 필요한 경우 제품 database/query가 별도 safe display
metadata를 소유하고 opaque `downloadKey`로만 연결한다.
## 7. Destination 계약
### 7.1 공통 port
```text
ResumableDownloadDestinationPort
inspect(binding) -> committedLength, readable, writable, permissionState
openWriter(binding, keepExistingData=true)
seek(offset)
write(chunk)
truncate(length)
commitSegment()
openReader(start=0)
finalize()
abortAttempt()
cleanup(authority)
```
native handle, OPFS handle와 path는 adapter 밖으로 노출하지 않는다. 모든 method는
bounded deadline, AbortSignal과 closed failure를 사용한다.
### 7.2 Seekable external file
직접 외부 파일에 resume하려면 browser가 기존 data 보존, seek, truncate,
재읽기와 permission 재확인을 실제로 지원해야 한다.
- picker와 permission request는 Window의 명시적 user activation에서만 실행한다.
- structured-cloned handle을 보존하는 경우 별도 privacy/retention 승인이 필요하다.
- reopen 뒤 `queryPermission`/`requestPermission`을 거치며 denied면
`PERMISSION_DENIED/RESELECT`다.
- writer가 temporary-file commit semantics를 쓰면 segment마다 close한 뒤
committed file size를 다시 확인한다.
- checkpoint보다 큰 tail은 검증하지 않고 사용하지 않으며 exact checkpoint
offset으로 truncate한다.
- checkpoint보다 파일이 작거나 다른 handle이면 `CONFLICT/RESTART`다.
브라우저가 이 계약을 만족하지 못하면 external-file resume를 흉내 내지 않고 OPFS
staging 또는 browser-managed handoff로 전환한다.
### 7.3 OPFS staging
cross-browser app-controlled resume의 우선 destination은 policy-owned OPFS
staging이다.
- physical path는 기존 OPFS authority/namespace/partition registry가 발급한다.
- checkpoint와 OPFS object는 immutable binding과 generation journal로 연결한다.
- quota estimate는 admission hint일 뿐이며 write 중 quota failure도 처리한다.
- download 완료 뒤 staging 전체를 다시 읽어 SHA-256을 검증한다.
- foreground user activation에서 새 외부 destination을 열고 staging을 stream
export한다.
- 외부 export close가 성공하기 전 staging을 삭제하지 않는다.
- export 결과가 유실되면 staging을 유지하고 user에게 retry 가능한 상태를
반환한다.
OPFS 저장 성공은 사용자가 접근 가능한 파일 저장 완료가 아니다. 결과를
`STAGED_VERIFIED``SAVED_VERIFIED`로 구분한다. OPFS는 큰 파일에서 storage와
I/O를 한 번 더 요구하므로 quota/retention owner 없는 기본 fallback이 아니다.
## 8. HTTP 상태 머신
### 8.1 요청 전
1. checkpoint와 destination binding을 exact하게 읽는다.
2. destination length를 검사하고 checkpoint보다 큰 tail을 truncate한다.
3. checkpoint보다 작으면 partial을 신뢰하지 않고 restart/cleanup으로 닫는다.
4. 새 capability의 representation binding과 exact range를 검증한다.
5. `Range: bytes=S-E`와 capability의 `preconditionMode`가 정한 exact
`If-Range` 또는 immutable-generation precondition을 vault binding 그대로
보낸다.
6. `credentials: omit`, `redirect: error`, `no-referrer`, `no-store`,
identity content encoding을 강제한다.
한 request의 range 크기와 exact `S/E`는 capability 발급 **전에** composition
maximum 안에서 계산한다. executor는 capability의 `requestedStart`,
`requestedEndExclusive`와 exact Range header가 일치하는지 검증하고 그대로
전송하며 다시 줄이거나 늘리지 않는다. ceiling을 넘는 capability는 사용 전에
거절한다. 기본 protocol은 sequential range만 허용한다. parallel range와 sparse
destination은 별도 protocol/version 없이는 사용하지 않는다.
zero-byte representation은 유효하지 않은 byte range를 만들지 않는다. exact
length가 0이고 empty-object SHA-256 binding이 일치하는 whole-object `200` 경로로
body/length를 확인한 뒤 바로 final verification으로 이동한다.
response body를 읽거나 destination writer를 열기 전에 `200`, `206`, `412`,
`416` 중 수신한 status가 capability vault의 exact `allowedStatuses` member인지
검사한다. 해당 네 값 중 허용되지 않은 status는 body를 cancel하고 capability를
consume하며 destination과 checkpoint를 변경하지 않은 채
`CONTRACT_MISMATCH`로 fail-closed한다. 아래 네 분기는 이 공통 admission gate를
통과한 경우에만 실행한다. 그 밖의 status는 §8.6의 별도 failure/reissue 규칙으로
처리한다.
### 8.2 `206 Partial Content`
성공 조건:
- `206`이 capability의 `allowedStatuses` member
- final response URL이 capability URL과 exact match
- `Content-Range: bytes S-E/T`가 하나만 존재하고 parse가 엄격함
- `S`가 requested start, `E + 1`이 requested end exclusive
- `T`가 checkpoint total과 같음
- `Content-Length = E - S + 1`
- strong validator/immutable generation binding 일치
- media type과 identity encoding 일치
- body 실제 bytes가 exact content length
하나라도 다르면 reader와 current destination attempt를 abort하고 checkpoint를
전진시키지 않는다. 정상인 경우에만 앞 절의 durable offset 순서로 commit한다.
### 8.3 `200 OK`
`200`은 capability의 `allowedStatuses` member인 경우에만 이 분기로 들어온다.
body를 destination에 쓰기 전에 final response URL, required response header,
media type, identity encoding과 mode별 strong validator 또는 immutable-generation
evidence가 capability의 exact representation binding과 일치하는지 검증한다.
다음 순서로 배타적으로 처리한다.
1. validator/generation evidence가 없거나 binding이 다르면 body를 cancel하고
capability를 consume한다. 기존 checkpoint와 partial은 append하지 않고
quarantine/retention policy로 전환한 뒤 control plane에서 current
representation을 다시 확인한다. 결과는
`REPRESENTATION_CHANGED/RESTART`이며 byte 0의 새 operation만 허용한다.
2. binding은 같지만 requested start가 `0`이고
`allowWholeObjectFallback=true`이면 fresh whole-object destination에서 기존
whole-object stream 계약으로 처리한다. exact total length와 final
whole-object digest를 검증하기 전에는 success나 final commit을 반환하지 않는다.
3. binding은 같고 requested start가 `0`이지만
`allowWholeObjectFallback=false`이면 body를 한 byte도 쓰지 않고 cancel한다.
결과는 `WHOLE_OBJECT_FALLBACK_NOT_ALLOWED`이며 policy가 허용한 새 Range
capability, browser handoff 또는 explicit unsupported만 선택한다.
4. binding은 같고 requested start가 `0`보다 크면 server가 Range를 무시한
것이다. body를 한 byte도 쓰지 않고 cancel하며 기존 partial을 같은 writer에서
덮어쓰지 않는다. control plane reconcile 뒤 같은 representation의 byte 0
restart operation, browser handoff 또는 explicit unsupported만 선택한다.
### 8.4 `412 Precondition Failed`
`412`가 capability의 `allowedStatuses` member이고
`preconditionMode=IMMUTABLE_GENERATION_PRECONDITION`인 경우에만 이 분기로 들어온다.
representation precondition 실패다. body를 cancel하고 checkpoint를 유지한 채
control plane에서 current generation을 확인한다. 같은 binding을 다시 발급하지
못하면 partial은 cleanup policy에 따라 폐기하고 byte 0부터 새 operation을
시작한다.
RFC `If-Range` validator mismatch 자체의 정상 응답은 `200`이다. `412`는 BFF나
provider가 immutable generation을 pin하기 위해 별도 `If-Match` 계열 precondition을
함께 강제하는 topology에서만 이 상태 머신에 들어온다. topology가 `412`를 계약하지
않았다면 unknown status로 fail-closed한다.
### 8.5 `416 Range Not Satisfiable`
`416`이 capability의 `allowedStatuses` member인 경우에만 이 분기로 들어온다.
response body는 download data로 소비하지 않고 cancel한다.
`Content-Range: bytes */T`를 strict하게 검사하며 final response URL, required
headers와 mode별 validator/generation binding도 확인한다. provider의 `416`
binding evidence를 반환할 수 없는 topology라면 BFF control plane reconcile이
exact immutable generation을 다시 증명하기 전에는 EOF나 missing-range 분기로
진행하지 않는다.
다음 순서를 사용하며 한 분기를 처리한 뒤 아래 분기로 fall through하지 않는다.
1. malformed/missing `T`, final URL/header mismatch 또는 증명되지 않은
representation binding은 `CONTRACT_MISMATCH`로 fail-closed한다.
2. `T != expected total`이면 representation changed다. local bytes를 `T`에 맞춰
자동 truncate하거나 append하지 않고 capability를 consume한 뒤 partial을
quarantine/restart한다.
3. `nextOffset > T`이면 checkpoint 자체가 corrupt/stale이다. 잘못된 offset으로
truncate하지 않고 checkpoint와 partial을 quarantine한 뒤 restart/recovery로
닫는다.
4. `nextOffset <= T`이지만 `local committed length != nextOffset`이면 먼저 local
state를 reconcile한다.
- local length가 더 크면 exact `nextOffset`까지만 uncommitted tail을
authorized truncate하고 durable length를 다시 확인한다.
- local length가 더 작으면 journal이 증명하는 마지막 confirmed segment로
destination과 checkpoint를 함께 CAS rollback할 수 있을 때만 복구한다.
그렇지 않으면 quarantine/restart한다.
이 분기는 reconcile 결과를 새 state-machine invocation에서 다시 평가하며 바로
finalization이나 missing-range request로 진행하지 않는다.
5. `local committed length == nextOffset == T`이면 data transfer가 끝난 후보로
보고 `FINALIZING` whole-object verification으로 이동한다.
6. `local committed length == nextOffset < T`이면 local missing range가 남아 있다.
새 capability로 exact `nextOffset` range를 재발급한다. 같은 total에 대해
satisfiable range가 다시 `416`이면 bounded retry하지 않고
`CONTRACT_MISMATCH`로 fail-closed한다.
`416` 자체를 다운로드 성공으로 간주하지 않는다.
### 8.6 나머지 상태와 network failure
| 조건 | 처리 |
| --- | --- |
| `401/403/410` | bounded capability reissue; binding mismatch면 restart |
| `404` | existence-hiding policy에 따라 unavailable/not-found, partial cleanup 예약 |
| `409` | server representation/session reconcile |
| `429`/모든 `5xx`/network | 동일 exact range만 bounded retry |
| redirect/opaque response | policy rejection |
| timeout/cancel | reader와 writer attempt abort, checkpoint 유지 |
| overrun/truncation | integrity failure, checkpoint 유지 |
retry는 destination commit 여부를 먼저 판단한다. effect가 ambiguous하면
checkpoint와 destination length를 reconcile하기 전 새 offset으로 이동하지 않는다.
## 9. Whole-object integrity와 final commit
Range별 transport 검증은 whole-object 무결성 증거가 아니다. 모든 bytes가
수신되면 checkpoint를 `FINALIZING`으로 CAS하고 다음을 수행한다.
1. destination length가 exact total과 같은지 확인한다.
2. destination을 byte 0부터 bounded chunk로 다시 읽는다.
3. vetted incremental SHA-256으로 whole-object digest를 계산한다.
4. capability/representation binding의 expected digest와 constant-time 비교한다.
5. mismatch면 사용자 destination을 성공으로 표시하지 않고 staging을 격리하거나
authorized cleanup한다.
6. OPFS staging이면 foreground external export와 destination close를 완료한다.
7. final destination commit truth를 확인한 뒤만 `SAVED_VERIFIED`를 반환한다.
8. checkpoint와 staging cleanup을 exact revision/receipt로 완료한다.
portable하지 않은 incremental hash 내부 state를 checkpoint에 serialize하지 않는다.
마지막 full reread 비용을 피하려면 chunk digest/Merkle manifest를 별도 protocol로
설계하고 server가 exact proof를 제공해야 한다.
## 10. Pause, cancel, crash와 account lifecycle
### 10.1 Pause
`pause(downloadKey)`는 browser work 중단이며 server resource/capability revoke가
아니다.
- 같은 runtime의 read/write/backoff를 AbortSignal로 중단한다.
- same-origin context에는 opaque key만 담은 versioned ephemeral pause event를
보낸다.
- mutation lock 안에서 checkpoint를 `PAUSED`로 CAS한다.
- in-memory URL/header/capability는 즉시 retire한다.
- committed segment는 유지하고 ambiguous writer attempt는 checkpoint offset으로
reconcile한다.
### 10.2 Cancel과 discard
cancel은 transfer 중단만 의미할 수 있고, discard는 local partial 삭제다. 제품
facade가 두 의도를 구분해야 한다. discard는 exact partition/destination binding과
short-lived maintenance authority를 요구하며 checkpoint와 OPFS staging을 하나의
cleanup journal로 처리한다.
### 10.3 Crash/reload
reload 후 runtime은:
1. account partition과 governance binding을 검증한다.
2. checkpoint schema/protocol/revision을 검증한다.
3. destination을 reopen하고 permission/length를 검사한다.
4. server에서 새 capability를 발급받아 representation binding을 대조한다.
5. exact checkpoint offset부터 resume한다.
source가 같은지 사용자에게 묻는 file-name 기반 확인은 사용하지 않는다.
### 10.4 Logout/account/tenant switch
- 신규 capability 발급과 resume admission을 먼저 닫는다.
- active foreground operation을 abort하고 writer를 정리한다.
- vault와 worker channel을 close한다.
- account partition의 checkpoint와 owned staging을 maintenance-authorized bounded
cleanup으로 제거한다.
- blocked deletion을 성공으로 보고하지 않는다.
- 이전 account handle/reference를 새 runtime에서 resolve하지 않는다.
retention/legal-hold 정책이 local partial 보존을 요구하는 특별한 제품이 아니라면
logout에서 partial을 제거하는 것이 기본이다.
## 11. Download strategy selector
selector는 presentation의 임의 조건문이 아니라 composition-owned immutable
policy와 runtime probe를 받는 공통 application service다.
입력:
- source가 server resource인지 client-generated artifact인지
- exact 또는 maximum byte length
- verified integrity 필요 여부
- resume/background 요구
- system save picker, seek/truncate, OPFS와 worker capability
- user activation
- storage quota admission
- browser-managed capability availability
- data classification와 retention policy
결과:
| 조건 | 선택 |
| --- | --- |
| server file, 탭 종료 뒤 계속 필요 | `BROWSER_MANAGED_HANDOFF` |
| server file, verified foreground save, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` |
| server file, resume 필수, destination 계약 충족 | `RANGE_RESUMABLE_FOREGROUND` |
| 작은 generated artifact | `BOUNDED_OBJECT_URL` |
| 큰 generated artifact, picker 지원 | `WHOLE_OBJECT_PICKER_STREAM` |
| 큰 generated artifact, picker 미지원 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` |
| app background download가 승인·지원되고 OPFS quota 확보 | `APP_MANAGED_BACKGROUND_DOWNLOAD` |
selector는 fallback으로 byte/memory/security ceiling을 올리지 않는다. integrity가
필수인데 browser handoff만 가능하면 “검증된 저장”으로 downgrade하지 않고 제품이
handoff 또는 unsupported 중 하나를 명시적으로 선택한다.
### Safari와 picker 미지원 환경
user-agent 문자열로 Safari를 판별하지 않는다. 필요한 API와 실제 semantics를
capability probe로 확인한다.
- 대용량 server file: authorized `Content-Disposition` browser handoff
- 작은 generated file: bounded Blob/object URL
- 대용량 generated file: server-side generation 또는 unsupported
- OPFS: app-private staging일 뿐 Finder/Files 저장 완료로 표시하지 않음
- system save picker 미지원: unbounded Blob으로 자동 전환하지 않음
- seek/truncate/permission semantics 미충족: external Range resume 비활성화
browser-managed handoff endpoint는 cross-origin `download` attribute에 의존하지
않고 server가 safe `Content-Disposition`, media type, byte/generation policy를
실제 response에서 강제한다.
## 12. Background download 전달의 세 의미
### 12.1 Foreground app-managed
page가 열린 동안 runtime이 fetch, progress, integrity와 destination을 모두
관리한다. 현재 whole-object stream과 목표 Range resume가 이 범주다. page lifecycle
종료 뒤 지속을 보장하지 않는다.
### 12.2 Browser-managed handoff
navigation/download manager에 authorized endpoint를 넘긴다.
- page 종료 뒤 계속될 수 있는 가장 넓은 fallback
- application은 실제 disk write, 저장 위치와 final digest를 관찰하지 못함
- 결과는 `BROWSER_HANDOFF`, `SAVED``VERIFIED`가 아님
- pause/resume UI와 retry semantics는 browser가 소유
### 12.3 App-managed background download
Service Worker/Background Fetch 등에서 application이 progress/retry/staging을
관리하려는 별도 optional capability다.
필수 조건:
- target browser/deployment의 explicit support matrix
- worker-safe authenticated control plane
- worker가 매 range마다 새 short-lived capability를 발급받는 계약
- capability/URL/header를 IDB, OPFS, Cache Storage와 message에 저장하지 않음
- private bytes는 Cache Storage가 아니라 policy-owned OPFS staging 사용
- worker termination을 정상 상태로 보고 checkpoint에서 재개
- concurrency, battery/network, quota와 retention ceiling
- logout/revocation event와 worker admission fence
- client/worker version compatibility와 upgrade drain
- notification/foreground export UX
일반 Service Worker의 수명이나 background execution 시간을 correctness 근거로
삼지 않는다. Background Fetch가 없는 환경에서 timer/keepalive로 장기 download를
흉내 내지 않는다. user-visible external save picker는 worker에서 호출하지 않고
완료된 OPFS staging을 다음 foreground user gesture에서 export한다.
따라서 app-managed background download가 향후 `AVAILABLE_NOT_COMPOSED` 또는 `COMPOSED`
되더라도 지원 browser의 progressive enhancement일 뿐이다. cross-browser 보장
자체의 primary status는 계속 `PLATFORM_LIMITED`다.
## 13. Security, privacy와 observability
- URL/query/header, validator와 capability는 bearer 또는 sensitive metadata로
취급한다.
- `Range``preconditionMode`가 선택한 exact `If-Range` 또는
immutable-generation precondition은 adapter vault가 binding 그대로 생성한다.
- caller는 offset을 늘리거나 arbitrary range를 요청하지 못한다.
- account partition과 resource authorization을 매 capability reissue에서 검사한다.
- partial bytes는 원본과 같은 data classification, retention, encryption-at-rest와
deletion policy를 적용한다.
- OPFS quota pressure가 다른 account partial을 제거할 권한을 주지 않는다.
- preview, execution 또는 Cache Storage promotion은 final verification 전 금지한다.
- high-cardinality ID, file name, path, URL, raw ETag와 digest를 metric label/log에
넣지 않는다.
허용된 aggregate observation:
- strategy와 destination kind
- response state bucket
- expected/committed byte bucket
- retry/reissue/resume count bucket
- duration, pause, restart, integrity와 cleanup outcome
- browser capability support reason code
## 14. Composition과 operational admission
`createBrowserTransferRuntime`에 해당하는 미래 composition owner만 다음을 조합한다.
- versioned wire codecs와 fixed BFF endpoint
- capability vault/provider/executor
- Range checkpoint store, destination registry와 mutation lock
- selector policy와 browser capability probe
- presigned, upload, image와 Range lifecycle
- account/logout cleanup authority
- safe observer
- traffic admission과 kill switch
독립적인 canonical readiness 상태:
```text
Selection = NOT_SELECTED | SELECTED | REMOVING
TrafficAdmission = DISABLED | SHADOW | CANARY | ENABLED
RuntimeHealth = UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
PromotionEvidence = MISSING | PARTIAL | COMPLETE | EXPIRED
```
primary status가 `COMPOSED`여도 `TrafficAdmission` 기본값은 `DISABLED`다.
필수 config, strong validator/provider conformance, destination semantics, cleanup
owner 또는 valid evidence가 없으면 `TrafficAdmission=DISABLED`,
`RuntimeHealth=UNKNOWN | UNAVAILABLE`,
`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 readiness를 fail-closed한다.
이미 승인된 product selection 자체를 provider evidence 부족만으로 되돌리지
않는다.
Kill switch:
- 신규 Range capability issuance off
- Range resume off → whole-object restart 또는 browser handoff
- direct object-store Range off → BFF proxy
- external seek destination off → OPFS staging 또는 handoff
- app-managed background download off → foreground/browser handoff
- final export off → verified staging 유지
kill switch는 partial을 자동 삭제하거나 handoff를 saved/verified로 바꾸지 않는다.
## 15. Test와 conformance matrix
### 15.1 Deterministic runtime
- checkpoint CAS conflict와 corrupt/unknown field
- exact segment commit 전/후 crash
- destination larger/smaller/different binding
- pause/resume/cancel/discard races
- capability expiry/reissue와 representation change
- `200/206/412/416` 모든 분기
- malformed/multiple/overflow `Content-Range`
- weak/missing/mismatched validator
- overrun, truncation, stalled body와 abort
- final whole-object digest mismatch
- cleanup response loss와 replay
- count/byte/age retention sweep
### 15.2 Browser matrix
- system picker 지원/미지원
- seek/truncate/keep-existing-data semantics
- OPFS quota, eviction, reload와 worker termination
- cross-tab lock/pause delivery
- user activation과 permission denied/revoked
- large server handoff
- foreground export close/abort
- Chromium, Firefox와 WebKit 동일 필수 case set
지원하지 않는 API는 skip이 아니라 selector의 expected fallback/`UNSUPPORTED` 결과로
검증한다.
### 15.3 BFF/object provider contract
- immutable generation pin
- capability `preconditionMode`에 따른 strong `If-Range` 또는 immutable
generation precondition
- beginning/middle/end/empty/invalid range
- exact `206 Content-Range`와 length
- deliberate Range ignore `200`
- mode가 계약한 경우의 precondition `412`, EOF/invalid `416`
- mid-transfer capability expiry/revocation
- redirect/CORS/exposed-header/identity-encoding
- object replacement race
- direct provider와 proxy 결과 동등성
- URL/header/log redaction
fake와 route interception은 actual provider conformance를 대체하지 않는다.
### 15.4 Background-download-specific fault
- worker가 range commit 전/후 종료
- worker/client version 교체
- logout과 capability revocation
- offline/online 반복, quota exhaustion과 battery/network policy
- notification 유실과 foreground export replay
- unsupported browser가 foreground/handoff로 정확히 fallback
## 16. Rollout과 promotion
Primary status 변경과 readiness/traffic promotion은 별도로 승인한다.
1. Range의 `DESIGNED_NOT_IMPLEMENTED`와 ADR-local
`sourceEvidence=DESIGN_REVIEWED`를 확인한다.
2. provider-neutral ports/runtime, deterministic fake, negative fixture와 browser
test를 완성한 경우에만 Range primary status를
`AVAILABLE_NOT_COMPOSED`, source evidence를 `REFERENCE_TESTED`로 변경한다.
deterministic/reference test만으로 canonical `PromotionEvidence`
`COMPLETE`로 바꾸지 않는다.
3. 제품 요구, owner, data class와 fallback이 선택되지 않은 app-managed
background download는 계속 `NOT_SELECTED`로 둔다. cross-browser 보장은
`PLATFORM_LIMITED`다.
4. fixed staging BFF/provider, actual config, account lifecycle와 runbook을 설치한
capability만 `COMPOSED`로 기록한다. 이때도
`TrafficAdmission=DISABLED`, `RuntimeHealth=UNKNOWN`,
`PromotionEvidence=PARTIAL`이다.
5. operator probe와 shadow에서 contract evidence를 수집한다.
6. internal cohort에서 BFF proxy Range를 먼저 canary한다.
7. direct provider Range와 external seek destination은 각각 별도 canary한다.
8. app-managed background download를 실제로 선택했다면 지원 browser cohort에서만 별도
opt-in canary한다.
9. contract/provider/browser/operations component gate, SLO, cleanup drill,
rollback과 evidence freshness가 모두 충족된 승인 범위만
`PromotionEvidence=COMPLETE`, `RuntimeHealth=AVAILABLE`,
`TrafficAdmission=ENABLED`로 promotion한다.
provider, endpoint, validator semantics, browser major behavior, destination adapter,
wire protocol 또는 security policy가 바뀌면 relevant evidence를 만료시키고
재승인한다.
## 17. Rollback과 제거
운영 rollback 순서:
1. 신규 Range/background-download admission과 capability 발급을 중지한다.
2. active writer/worker를 abort하고 checkpoint offset으로 reconcile한다.
3. app background download를 foreground/browser handoff로 낮춘다.
4. direct Range를 BFF proxy 또는 whole-object restart로 낮춘다.
5. verified OPFS staging은 retention window 안에서 foreground export 가능 상태로
유지한다.
6. ambiguous partial은 성공으로 표시하지 않고 cleanup queue로 넘긴다.
7. provider/signing credential 노출이 원인이면 backend revoke와 key rotation을
수행한다.
완전 제거:
1. pending checkpoint/staging inventory를 bounded하게 drain, export 또는 discard한다.
2. worker, channel, lock과 runtime을 close한다.
3. account-partition checkpoint/OPFS namespace를 maintenance-authorized cleanup한다.
4. endpoint, worker registration, config, policy와 feature facade를 제거한다.
5. production bundle/module inventory와 removal test로 source 부재를 증명한다.
rollback은 unbounded Blob fallback, validator 완화, digest 생략 또는 partial 자동
append를 허용하지 않는다.
## 18. 완료 기준
Range resumable download는 다음이 모두 참일 때만 구현 완료다.
- `RANGE_RESUMABLE_DOWNLOAD_V1` port와 strict wire codec이 있음
- capability mode별 exact allowed-status subset과 `200/206/412/416` 처리
상태 머신이 실행 가능하게 검증됨
- strong validator/immutable generation이 실제 provider에서 강제됨
- checkpoint CAS, inventory, retention과 account cleanup이 구현됨
- seek/truncate 또는 OPFS staging destination이 crash fault를 통과함
- capability reissue가 representation mismatch를 fail-closed함
- final whole-object SHA-256 뒤에만 verified success를 반환함
- selector가 picker/seek 미지원과 대용량 fallback을 안전하게 결정함
- actual BFF/provider와 Chromium/Firefox/WebKit evidence가 유효함
- SLO, alert, runbook, kill switch, rollback과 cleanup drill이 승인됨
app-managed background download는 위 항목에 더해 다음이 필요하다.
- 지원 browser/deployment 범위가 명시됨
- worker lifecycle 종료를 checkpoint로 복구함
- worker control plane이 durable capability 저장 없이 동작함
- logout/revocation/version upgrade fault가 통과함
- 미지원 browser fallback이 동일 제품 요구를 안전하게 만족하거나 명시적
unsupported UX를 가짐
이 기준 전에는 기존 foreground streaming 또는 browser handoff의 성공을 Range나
background download 구현 완료 증거로 사용하지 않는다.
## 19. 선택하지 않은 대안
- `Range` header만 추가하고 기존 sequential writable에 append
- weak ETag나 file name/lastModified를 representation identity로 사용
- serialized incremental hash state를 검증 없이 checkpoint
- `200` response를 기존 partial 뒤에 append
- `416`을 곧바로 success로 해석
- Service Worker keepalive를 cross-browser background 보장으로 간주
- picker 미지원 대용량 파일을 unbounded Blob으로 fallback
- OPFS staging을 사용자 파일 저장 완료로 표시
- browser-managed handoff를 application-verified save로 표시
- user-agent 문자열 기반 Safari 분기
## 20. 참고
- [Browser data capability completion ledger](../browser-data-capability-completion-ledger.md)
- [VD-16 Browser transfer composition과 Image delivery](./VD-16-browser-transfer-composition-and-image-delivery.md)
- [Browser transfer recovery](../../operations/browser-transfer-recovery.md)
- [RFC 9110 HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110.html)
- [Fetch Standard](https://fetch.spec.whatwg.org/)
- [File System Standard](https://fs.spec.whatwg.org/)
- [Service Workers](https://w3c.github.io/ServiceWorker/)
- [Background Fetch draft](https://wicg.github.io/background-fetch/)
- [기존 transfer 설계](../presigned-transfer-and-image-cdn.md)
- [browser file/origin storage 설계](../browser-file-and-origin-storage.md)
@@ -0,0 +1,858 @@
# VD-15: Origin storage lifecycle, migration, and optional file capabilities
- 상태: Accepted design — implementation pending
- 결정일: 2026-07-28
- 현재 구현 상태: capability별로 아래 표에 명시
- 이 ADR이 선택한 common delta의 목표 reference 상태:
`AVAILABLE_NOT_COMPOSED`
- 관련 결정: VD-10, VD-11, VD-12, VD-14
- current status ledger:
`docs/architecture/browser-data-capability-completion-ledger.md`
- 적용 범위: File/Blob preview, file/directory selection, IndexedDB, OPFS,
Cache Storage, StorageManager, optional Service Worker lifecycle
이 결정은 VD-11의 reference runtime을 실제 제품에 조립하기 전에 남아 있는
origin-wide lifecycle과 migration 경계를 고정한다. 문서가 추가됐다는 사실은
runtime 구현, bootstrap composition 또는 production traffic 승격을 의미하지
않는다.
현재 구현돼 있는 것은 transient file vault, file picker, bounded download,
IndexedDB repository/migration mechanism, OPFS object/journal runtime, public static
Cache Storage release runtime과 StorageManager inspection primitive다. 다음은 아직
구현되지 않았다.
- IndexedDB, OPFS, Cache Storage를 함께 조정하는 pressure/write-admission/GC
coordinator
- origin 전체 eviction을 완전하게 판별하는 mechanism
- OPFS physical layout/journal과 Cache control schema의 forward migration runtime
- 실제 OPFS write/read/delete readiness probe
- cursor/deadline이 있는 bounded Cache Storage inspection/cleanup
- Service Worker update/client-drain controller
- local preview의 pixel/decode/frame safety probe
- directory/persistent handle/drag-and-drop capability
- Range/206 download 또는 private/range response cache
## 1. 상태 모델과 현재/목표
### 1.1 다섯 primary current-status literal
capability의 source 구현, 제품 선택, composition, traffic과 evidence를 하나의
`enabled` boolean으로 합치지 않는다. 이 결정에서 capability의 **primary current
status**로 허용하는 literal은 정확히 다음 다섯 가지다.
| primary status | 의미 |
| --- | --- |
| `COMPOSED` | 실제 owner/policy/provider가 production composition root에 연결돼 있다. traffic이 disabled/canary/enabled인지는 이 상태가 아니라 별도 admission 축이다. |
| `AVAILABLE_NOT_COMPOSED` | 실행 가능한 runtime과 test가 source에 있지만 production bootstrap과 제품 dataset에는 연결하지 않았다. |
| `DESIGNED_NOT_IMPLEMENTED` | port, invariant, failure/recovery와 promotion 기준은 결정됐지만 실행 가능한 runtime이 없다. |
| `NOT_SELECTED` | 제품 요구와 owner가 capability를 선택하지 않았다. source 설계나 일부 primitive가 있더라도 runtime, DB, worker, listener를 만들지 않는다. |
| `PLATFORM_LIMITED` | 요구한 의미를 대상 browser 전체에서 application-controlled capability로 보장할 수 없다. 지원 engine의 progressive enhancement와 명시적 fallback만 허용한다. |
이 다섯 값은 선형 maturity 단계가 아니다. 예를 들어 구현이 존재해도 제품이
선택하지 않은 별도 capability의 primary status는 `NOT_SELECTED`일 수 있고,
cross-browser 보장이 불가능하면 구현량과 무관하게 `PLATFORM_LIMITED`다.
`COMPOSED`도 traffic enablement나 runtime health를 암묵적으로 뜻하지 않는다.
primary status와 별도로 다음 축을 기록한다.
```text
Selection
NOT_SELECTED | SELECTED | REMOVING
TrafficAdmission
DISABLED | SHADOW | CANARY | ENABLED
RuntimeHealth
UNKNOWN | AVAILABLE | DEGRADED | UNAVAILABLE | INCOMPATIBLE
PromotionEvidence
MISSING | PARTIAL | COMPLETE | EXPIRED
```
이 네 이름과 literal은 completion ledger의 canonical readiness 축이다.
`PromotionEvidence`는 별도 임의 enum이 아니라 ledger의 contract, provider,
browser, operations component gate를 합성한 값이다. required artifact가 없으면
`MISSING`, 일부만 terminal이면 `PARTIAL`, 모든 required gate와 freshness가
충족될 때만 `COMPLETE`, 한 번 유효했던 required artifact가 만료되면
`EXPIRED`로 기록한다.
예를 들어 실제 dependency를 조립한 첫 배포는
`primaryStatus=COMPOSED`, `TrafficAdmission=DISABLED`,
`RuntimeHealth=UNKNOWN`, `PromotionEvidence=PARTIAL`일 수 있다. probe와 canary
승격은 primary status를 새 literal로 바꾸지 않고 별도 축만 변경한다.
rollback은 `TrafficAdmission`을 먼저 `DISABLED`로 내린다. schema version을
내리거나, user-authored data를 자동 삭제하거나, unavailable runtime을 in-memory
fake로 교체하지 않는다.
### 1.2 current vs target
| capability | 현재 | 이 결정의 목표 | 비고 |
| --- | --- | --- | --- |
| transient File/Blob vault와 picker | `AVAILABLE_NOT_COMPOSED` | 유지 | 제품 policy가 없으므로 조립하지 않음 |
| foreground streaming/save와 browser handoff | `AVAILABLE_NOT_COMPOSED` | 유지 | Range resume는 포함하지 않음 |
| IndexedDB repository/codec migration | `AVAILABLE_NOT_COMPOSED` | 유지, coordinator hook 추가 대상 | 제품 dataset/schema는 없음 |
| OPFS object/journal v1 | `AVAILABLE_NOT_COMPOSED` | 유지 | 현재 byte runtime과 v1 reconciliation 범위 |
| OPFS real readiness preflight | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | property probe/native test를 composition readiness로 오인하지 않음 |
| public static Cache release v1 | `AVAILABLE_NOT_COMPOSED` | 유지 | 현재 stage/activate/previous retain은 구현 |
| bounded Cache inspect/cleanup | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 현재 ownership은 검증하지만 cache-count scan은 unbounded |
| StorageManager estimate/persist primitive | `AVAILABLE_NOT_COMPOSED` | 유지 | origin coordinator는 없음 |
| origin storage lifecycle coordinator | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 이 ADR이 계약을 확정 |
| OPFS physical/journal forward migration | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 기존 v1 layout/journal을 migrator 구현으로 오인하지 않음 |
| Cache control/prefix forward migration | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 기존 v1 parser/release primitive를 migrator 구현으로 오인하지 않음 |
| local preview safety probe | `DESIGNED_NOT_IMPLEMENTED` | `AVAILABLE_NOT_COMPOSED` | 현재 byte/signature check만 있음 |
| Service Worker update lifecycle | `NOT_SELECTED` | 제품이 PWA를 선택할 때 별도 승격 | Cache Storage 사용만으로 자동 선택하지 않음 |
| directory selection/persistent handles/drop | `NOT_SELECTED` | 제품 workspace 요구가 있을 때 별도 승격 | consent/retention 결정 필요 |
| Range resumable download | `DESIGNED_NOT_IMPLEMENTED` | VD-14의 별도 capability | backend validator/range 계약과 별도 runtime 필요 |
| sparse Range response cache | `NOT_SELECTED` | Range download와도 분리된 별도 capability | segment merge/cache threat model 필요 |
| private response cache | `NOT_SELECTED` | 별도 security review 전 금지 | public cache를 확장해 암묵 설치하지 않음 |
| app-managed background download | `NOT_SELECTED` | 지원 browser용 별도 optional capability | 별도 owner/staging/worker protocol 필요 |
| cross-browser app-managed background download guarantee | `PLATFORM_LIMITED` | browser-managed handoff를 기본 fallback으로 유지 | Service Worker가 장시간 transfer 지속을 보장하지 않음 |
## 2. 변경할 수 없는 불변조건
1. `navigator.storage.estimate()`는 rough origin signal이지 free-space reservation,
per-store usage 또는 eviction guarantee가 아니다.
2. 실제 `QuotaExceededError`가 write failure의 authority다. estimate가 정상이어도
write는 실패할 수 있다.
3. IndexedDB, OPFS와 Cache Storage 사이에는 atomic transaction이 없다. coordinator는
saga와 idempotency를 제공할 뿐 cross-API ACID를 주장하지 않는다.
4. user-authored/unsynced data는 pressure 또는 migration convenience를 이유로 자동
삭제하지 않는다.
5. credential, session token, raw authorization header, presigned URL과 signing key는
어느 origin store에도 저장하지 않는다.
6. migration은 expand/migrate/contract 순서다. committed OPFS file을 in-place로
변환하지 않고, Cache Storage의 검증되지 않은 candidate를 active로 만들지 않는다.
7. future schema를 이전 bundle이 발견하면 destructive open/delete 대신 read-only,
online-only 또는 export-required로 전환한다.
8. capability probe failure를 fake success로 바꾸지 않는다.
9. Service Worker 등록·활성화와 Cache Storage ownership은 서로 다른 capability다.
10. local image preview는 encoded byte cap만으로 decode safety를 주장하지 않는다.
11. directory handle과 persistent file handle은 transient file selection의 자연스러운
연장이 아니라 별도 consent/persistence capability다.
12. public Cache Storage는 private/auth/range representation의 repository가 아니다.
## 3. composition과 owner/policy injection
### 3.1 composition root
제품이 선택하면 한 composition root가 다음 dependency를 immutable snapshot으로
고정한다.
```text
OriginStorageLifecycleComposition
originScope
releaseId
datasetPolicyRegistry
storageDurability
indexedDbMaintenance[]
opfsMaintenance[]
publicCacheMaintenance[]
mutationLock
clock
scheduler
lifecycleAuthority
safeObserver
killSwitches
```
page, hook 또는 domain use case는 native manager와 maintenance adapter를 직접
조합하지 않는다. coordinator에 등록되는 각 dataset profile은 다음을 필수로
소유한다.
| 필드 | owner가 결정할 내용 |
| --- | --- |
| `datasetRegistryId` | readable user/account 값이 아닌 고정된 registry ID |
| `owner` | product owner와 operational owner |
| `technology` | IndexedDB, OPFS, public Cache 중 정확한 storage |
| `authority` | server, local-first, reconstructable |
| `classification` | public, internal, personal, confidential |
| `accountScope` | origin-shared 또는 opaque partition |
| `retention` | session, TTL, until-synced, explicit delete |
| `soft/hardBudget` | logical dataset budget; native free-space claim이 아님 |
| `pressurePriority` | expired/reconstructable, synced-copy, user-authored 순서 |
| `writeCriticality` | essential user write, sync receipt, reconstructable cache |
| `fallback` | read-only, online-only, export-required |
| `migrationOwner` | schema/codec/layout migration과 rollback owner |
| `recoveryOwner` | rehydrate, export, backend sync와 incident owner |
registry는 composition 시 deep snapshot/freeze하고 같은 문자열을 가진 caller-created
profile을 identity로 인정하지 않는다. presentation은 priority, retention 또는
eviction eligibility를 요청별로 고를 수 없다.
### 3.2 authority가 필요한 동작
logout, account deletion, until-synced deletion과 user-authored export/purge는 제품
authority를 요구한다. 기존 OPFS maintenance proof와 동일하게 provider가 exact
reason/scope/policy에 묶인 짧은 proof를 발급하고 consumer가 원자적으로
consume한다.
pressure에 따른 expired/reconstructable GC는 product proof가 없어도 실행할 수
있지만 등록 policy와 bounded budget을 벗어나면 안 된다. `SYNCED_COPY` 삭제는
authoritative server revision 또는 별도 sync receipt가 확인된 항목에만 허용한다.
## 4. origin-wide pressure, write admission, and GC
### 4.1 coordinator port
목표 runtime은 native type을 노출하지 않는 다음 의미의 port를 제공한다.
```ts
type PressureState =
| "UNKNOWN"
| "NORMAL"
| "PRESSURE"
| "CRITICAL"
| "QUOTA_FAILURE";
type WriteAdmission =
| { kind: "ADMITTED"; admissionId: string; attempt: 1 | 2 }
| { kind: "DEFERRED"; recovery: "RETRY" | "ONLINE_ONLY" }
| { kind: "DENIED"; recovery: "READ_ONLY" | "EXPORT_REQUIRED" };
```
실제 API는 repository write를 대신하지 않는다. 각 adapter가 write 직전 admission을
얻고, commit/rollback 이후 exact admission을 완료하도록 좁은 hook을 받는다.
admission ID는 diagnostic 또는 persistence에 남기지 않는 runtime-local fencing
token이다.
### 4.2 pressure state와 hysteresis
기본 threshold 시작점은 VD-11과 일치한다.
| 진입 조건 | 상태 |
| --- | --- |
| usage/quota를 모름 | `UNKNOWN` |
| `< 70%` | `NORMAL` |
| `>= 70%` | `PRESSURE` |
| `>= 85%` | `CRITICAL` |
| 실제 write의 quota exception | `QUOTA_FAILURE` |
flapping을 막기 위해 하향 전이는 더 낮은 threshold를 사용한다.
- `CRITICAL -> PRESSURE`: 두 번 연속 inspection에서 `< 80%`
- `PRESSURE -> NORMAL`: 두 번 연속 inspection에서 `< 65%`
- inspection 간격은 composition policy가 정하되 boot polling loop를 만들지 않는다.
- tab마다 독립 GC하지 않는다. 고정된 origin Web Lock 아래 leader 하나만 maintenance를
수행하고, lock이 없으면 destructive maintenance를 하지 않는다.
두 번 연속 규칙은 in-memory observation일 뿐 영구 storage truth가 아니다. 새
runtime은 persisted pressure state를 맹신하지 않고 `UNKNOWN`에서 시작한다.
### 4.3 admission matrix
| pressure | essential user-authored | sync/export receipt | reconstructable/cache |
| --- | --- | --- | --- |
| `UNKNOWN` | policy hard budget 안에서 허용, failure 대비 | 허용 | 보수적으로 defer 가능 |
| `NORMAL` | 허용 | 허용 | 허용 |
| `PRESSURE` | 허용 | 허용 | 먼저 bounded GC, 신규 speculative write 제한 |
| `CRITICAL` | hard budget과 recovery path가 있을 때만 허용 | 허용 우선 | 거절/online-only |
| `QUOTA_FAILURE` | rollback 후 export/sync UX | rollback 후 retry 조건 평가 | rollback, GC, 최대 1회 retry |
estimate만으로 “N bytes를 예약했다”고 기록하지 않는다. IndexedDB/OPFS의 logical
budget reservation은 동시 writer 간 policy ceiling을 강제하기 위한 값이며 origin
free space가 아니다.
### 4.4 GC 순서와 bounded execution
GC 순서는 모든 기술에서 다음 우선순위를 유지한다.
```text
incomplete candidate / stale staging
-> expired reconstructable
-> unreferenced immutable chunk with grace
-> inactive public cache release
-> confirmed synced copy
-> stop
```
user-authored/unsynced는 자동 GC 목록에 들어가지 않는다. 각 invocation은 다음
두 예산을 모두 가진다.
- 기본 최대 100 items 또는 5초
- 구현 절대 상한 500 items 또는 30초
각 native operation 사이에 deadline과 AbortSignal을 다시 확인한다. 결과는
`inspected`, `removed`, `releasedLogicalBytes`, `moreAvailable`,
`deadlineReached`와 opaque cursor를 반환한다. cursor는 dataset/policy/release
epoch에 묶고 다른 owner에서 replay하면 `STALE_RESULT`다.
### 4.5 quota failure 뒤 단 한 번의 retry
자동 retry는 다음 조건을 전부 만족할 때만 허용한다.
1. 첫 attempt가 실제 `QuotaExceededError`로 rollback됐다.
2. operation이 같은 idempotency key, revision fence와 payload digest를 가진다.
3. 외부 side effect 또는 cross-store publish가 commit되지 않았다.
4. bounded GC가 실제로 candidate를 제거했거나 pressure가 하향됐다.
5. retry가 같은 operation lifecycle에서 정확히 한 번뿐이다.
6. 새 admission token을 발급하고 현재 revision/generation을 다시 읽는다.
두 번째 quota failure, partial external commit, user-authored destructive overwrite,
unknown idempotency는 retry하지 않는다. recovery는 policy에 따라 `READ_ONLY`,
`ONLINE_ONLY` 또는 `EXPORT_REQUIRED`다.
## 5. eviction detection의 범위와 한계
### 5.1 감지할 수 있는 것
각 조립된 dataset은 opaque scope에 다음 binding을 둔다.
- IndexedDB governance row와 dataset epoch
- OPFS journal logical object와 physical manifest/digest
- public Cache active pointer와 verified release marker
- 선택적으로 backend가 알고 있는 opaque dataset installation epoch
다음 partial mismatch는 `STORAGE_EVICTED` 또는 `CORRUPT_DATA`로 구분할 수 있다.
- logical OPFS object는 있는데 physical manifest/chunk가 없음
- Cache active pointer는 있는데 candidate cache/marker가 없음
- migration checkpoint는 있는데 target generation이 없음
- expected dataset epoch와 local governance binding이 다름
reconstructable data는 rehydrate하고, local-first/user-authored data는 자동 empty
state로 초기화하지 않고 read-only/export-required incident로 올린다.
### 5.2 감지할 수 없는 것
browser가 origin의 IndexedDB, OPFS와 Cache Storage를 모두 함께 지우면 local
sentinel도 함께 사라진다. local state만으로 다음 두 상황을 완전하게 구분할 수
없다.
```text
이 browser의 첫 설치
origin storage 전체 eviction/user clear
```
따라서 “sentinel이 없으므로 첫 설치”라고 단정하지 않는다. 제품이 구분을 요구하면
현재 인증 session의 backend에 opaque installation/dataset epoch를 보관하고
authorization 후 비교해야 한다. backend marker도 browser byte backup이 아니며,
local-only data 복구를 보장하지 않는다.
backend epoch가 없으면 UI는 empty/new와 storage-reset-possible 상태를 제품 정책에
맞게 합쳐 표현해야 한다. raw account ID, filename, object ID 또는 digest를
sentinel/log에 넣지 않는다.
## 6. schema, codec, physical migration
### 6.1 독립 version 축
다음 version을 하나의 숫자로 합치지 않는다.
| 축 | 의미 | 현재 |
| --- | --- | --- |
| IndexedDB DDL | store/index/governance shape | reference runtime에 additive planner 있음 |
| IndexedDB record codec | payload decode/encode | resumable maintenance mechanism 있음 |
| OPFS journal DDL | logical object/journal/budget/refcount schema | v1 고정 |
| OPFS physical layout | root/path/manifest/chunk-tree algorithm | v1 고정 |
| Cache control schema | marker/active pointer JSON | v1 고정 |
| Cache release manifest | URL/header/type/length/digest binding | current static release contract |
| lifecycle registry schema | owner/policy/admission binding | 이 결정에서 설계, 구현 없음 |
IndexedDB mechanism이 존재한다고 해서 제품 codec/migration이 자동으로 존재하는
것은 아니다. OPFS와 Cache v1 parser가 있다는 사실도 forward migration 구현을
뜻하지 않는다.
### 6.2 공통 expand/migrate/contract
1. **expand:** 새 reader가 N과 N-1을 읽고 새 metadata/checkpoint를 additive하게
추가한다.
2. **drain:** old writer가 더는 N-1 shape를 쓰지 않는다는 release/lease evidence를
확인한다.
3. **migrate:** bounded batch와 keyset/opaque cursor로 copy/verify한다.
4. **publish:** row, checkpoint, logical budget과 generation fence를 가능한 한 같은
native transaction에서 commit한다.
5. **observe:** canary와 rollback window 동안 N-1 reader compatibility를 확인한다.
6. **contract:** 모든 active/rollback release가 지난 별도 release에서만 old shape를
정리한다.
schema downgrade, blanket database/cache/root deletion과 read-time unbounded rewrite는
금지한다.
### 6.3 OPFS migration
OPFS physical migration은 copy-on-write다.
```text
v1 committed object
-> v2 staging transaction
-> bounded chunk copy/read
-> v2 manifest + tree digest verify
-> IDB journal generation/fencing CAS
-> v2 logical publish
-> rollback window 동안 v1 retain
-> authority 확인 후 v1 bounded cleanup
```
- committed v1 file/chunk를 in-place로 수정하지 않는다.
- checkpoint는 last logical object key와 source/target generation을 저장한다.
- source digest, target digest, bytes와 policy binding이 맞지 않으면 quarantine하고
다음 object로 성공 처리하지 않는다.
- crash가 v2 publish 전이면 v1이 authority다.
- publish 후 cleanup crash는 v2가 authority이고 cleanup을 재개한다.
- N-1 bundle은 v2를 쓰지 않고 read-only/online-only로 degrade한다.
- local-first bytes를 contract하려면 export/sync 또는 승인된 rollback-window
evidence가 필요하다.
### 6.4 Cache migration과 rollback
public Cache data는 reconstructable이므로 byte-by-byte schema rewrite보다 새
release를 다시 stage/verify/activate한다. Service Worker를 선택하지 않은 static
Cache-only 조합의 migration은 다음 흐름이다.
```text
old active verified release
-> new prefix/control schema candidate
-> exact network fetch + integrity verify
-> explicit activation
-> old + previous retain
-> composition-owned rollback/grace window 확인
-> bounded owned-prefix cleanup
```
이 흐름에는 waiting worker, `controllerchange` 또는 controlled-client drain을
성공 조건으로 넣지 않는다. Service Worker를 별도 선택한 조합만 section 9.2의
waiting/activation protocol을 실행하고, old controlled client가 drain된 뒤 해당
release를 cleanup eligible로 만든다.
rollback은 검증된 previous release의 ID와 manifest digest로 같은 activation
protocol을 다시 실행한다. caller가 raw cache name이나 retain list를 전달하지
않는다. new control schema가 unreadable하면 old pointer를 덮어쓰지 않고
network-only로 degrade한다.
Cache control/release cleanup도 section 4의 cursor/deadline 예산을 적용한다.
unregister 또는 새 Service Worker install만으로 cache migration이 완료됐다고
보지 않는다.
### 6.5 N-1 rollback contract
모든 durable migration은 최소 다음 fixture를 보유한다.
- N-1 fresh -> N open
- N-1 populated -> N partial migration crash -> N resume
- N migration 완료 -> N-1 open: destructive write 없이 read-only/online-only
- N canary rollback -> N-1 server path로 정상 동작
- N rollback window 종료 뒤 별도 contract release
rollback bundle은 schema number를 낮추지 않는다. 새로운 writer를 끄고 compatible
reader/fallback을 사용한다.
## 7. OPFS real readiness preflight
현재 `inspectBrowserOpfsSupport()`는 API property를 확인한다. 목표 preflight는
실제 작은 operation을 검증한다.
### 7.1 probe protocol
probe는 `primaryStatus=COMPOSED`, `Selection=SELECTED`이고 readiness 확인이
필요할 때 실행한다. 최초 composition에서는 `TrafficAdmission=DISABLED` 또는
`SHADOW`로 probe하며, 선택하지 않은 skeleton boot에서 OPFS root/DB/worker를
만들지 않는다.
```text
secure context/API check
-> DedicatedWorker boot + protocol handshake
-> origin Web Lock acquire
-> owned opaque probe scope의 IDB journal transaction
-> random staging file create
-> bounded bytes write + flush/close
-> read + length/digest verify
-> file/journal cleanup
-> lock/worker/connection close
```
규칙:
- main thread에서 SyncAccessHandle을 만들지 않는다.
- synchronous path와 configured async writable fallback을 각각 capability로
보고한다.
- probe object ID/path는 secure random opaque value이고 log에 기록하지 않는다.
- 기본 deadline 5초, 절대 상한 30초다.
- timeout/crash 뒤 stale probe는 reconciliation owner가 grace 후 bounded cleanup한다.
- 결과는 runtime memory에 짧게 cache할 수 있지만 browser update, visibility가 긴
sleep에서 복귀, quota/permission failure 뒤 다시 `UNKNOWN`으로 돌린다.
- probe 성공은 future write 또는 persistence guarantee가 아니다.
### 7.2 readiness mapping
| 결과 | runtime health | admission |
| --- | --- | --- |
| full worker/lock/journal/write/read/delete 성공 | `AVAILABLE` | policy에 따라 가능 |
| sync handle 없음, 승인된 async fallback 성공 | `DEGRADED` | size/concurrency ceiling 하향 |
| API 없음/secure context 아님 | `UNAVAILABLE` | online-only |
| protocol/schema mismatch | `INCOMPATIBLE` | read/write 금지 |
| timeout/quota/permission | `DEGRADED` 또는 `UNAVAILABLE` | 신규 write 금지, recovery 실행 |
## 8. bounded Cache Storage maintenance
현재 static public cache는 release stage/verify/activate, previous retain과
owned-prefix cleanup을 구현한다. 현재 `cleanupOwned()``inspect()`에는
max-count/deadline/cursor가 없다. 목표 contract는 이를 bounded operation으로
바꾼다.
```ts
type CacheMaintenancePage = Readonly<{
inspectedCaches: number;
deletedCaches: number;
retainedCaches: number;
unreadableCaches: number;
nextCursor: string | null;
moreAvailable: boolean;
deadlineReached: boolean;
}>;
```
- default 100 caches/5초, absolute 500 caches/30초
- cursor는 owned prefix, active pointer epoch와 policy fingerprint에 binding
- caller는 raw cache name, retain list 또는 prefix를 제출할 수 없음
- mutation Web Lock 아래 active pointer를 다시 읽은 뒤 한 cache씩 처리
- abort/deadline 뒤 이미 완료한 delete truth는 되돌리지 않고 cursor부터 재개
- corrupt active pointer면 destructive cleanup을 중지하고 network-only
- unreadable inactive candidate는 grace와 current/previous binding 확인 뒤 삭제
- `QuotaExceededError`를 이유로 다른 origin cache나 user data를 삭제하지 않음
inspection도 동일한 page contract를 써서 cache 수에 비례한 unbounded boot work를
금지한다.
## 9. static Cache release와 optional Service Worker lifecycle
### 9.1 현재 static release capability
현재 adapter가 소유하는 범위:
- same-origin anonymous public GET
- exact query/request header/Vary
- type, declared/actual length와 SHA-256
- candidate 전체 성공 뒤 explicit activation
- failed candidate 삭제와 기존 active 유지
- active + verified previous release retain
- private/no-store/auth/opaque/redirect/206 거부
Window 또는 Worker에서 Cache Storage를 쓸 수 있으므로 이 기능은 Service Worker
설치를 의미하지 않는다.
### 9.2 Service Worker를 선택할 때의 별도 protocol
PWA/offline interception을 제품이 선택하면 별도 owner가 다음 lifecycle을
composition한다.
```text
installing worker
-> candidate static release stage/verify
-> waiting
-> page update controller:
dirty form / active transfer / compatibility 확인
-> explicit ACTIVATE(version, manifest)
-> pointer flip
-> skipWaiting opt-in
-> controllerchange acknowledgement
-> old clients drain
-> clients.claim opt-in
-> previous release grace retain
-> bounded cleanup
```
`skipWaiting()``clients.claim()`을 install handler에서 자동 호출하지 않는다.
message는 protocol version, release ID, nonce와 exact target worker에 binding하고
unknown message를 drop한다.
fetch 전략은 route registry에 고정한다.
| route class | 허용 전략 |
| --- | --- |
| content-hashed static asset | exact active cache-first |
| navigation | network-first + 별도 검증된 static offline page |
| runtime config/release manifest/auth/API | network-only |
| approved public runtime media | 별도 TTL metadata owner가 있을 때만 bounded SWR |
runtime TTL/SWR은 static release adapter의 묵시적 기능이 아니다. 별도 entry/count/
byte/TTL budget, revalidation owner와 prune cursor가 있어야 한다.
Service Worker는 application-controlled long-running background download를
cross-browser로 보장하지 않는다. download lifecycle은 VD-14의 별도 capability다.
## 10. local preview decode safety
### 10.1 현재와 목표
현재 preview path는 selection byte cap, signature receipt, media allowlist,
active-content denylist와 object URL lease를 제공한다. static raster의 intrinsic
dimensions, decoded surface와 animation frame 수를 검사하지 않는다.
목표 runtime은 object URL을 발급하기 전에 exact registered preview policy에
묶인 `PreviewSafetyProbePort`를 호출한다.
### 10.2 policy와 검사 순서
owner가 최소 다음을 결정한다.
- 허용 static format과 signature parser version
- max encoded bytes
- max width/height
- max total pixels
- max decoded bytes
- animation 허용 여부와 max frames/total pixels
- decode concurrency와 deadline
- malformed/unsupported metadata 동작
기본은 JPEG, PNG, WebP, AVIF 중 검토된 static parser만 허용하고 animation,
SVG, HTML, XML, PDF는 preview에서 거절한다. animation이 제품 요구면 별도
frame/time/memory capability로 승격한다.
```text
bounded header read
-> container/signature parse
-> width/height/frame/static 여부
-> overflow-safe pixel/decoded-byte 계산
-> optional real bitmap decode
-> decoded dimensions exact match
-> bitmap close
-> object URL lease 발급
```
`width * height * 4` 계산은 safe integer overflow를 검사한다. parser header만
신뢰하지 않고 지원 browser에서는 `createImageBitmap` 등 실제 decode를 bounded
concurrency/deadline 아래 확인하고 즉시 `close()`한다. decode failure 뒤 object
URL을 발급하지 않는다.
runtime absolute ceiling은 product policy보다 크거나 같고 caller는 낮출 수만 있다.
원본 filename, digest와 dimensions를 telemetry에 기록하지 않고 bucket만 남긴다.
## 11. directory, persistent handles, and drag-and-drop
세 기능은 현재 transient picker port에 추가하지 않는다.
### 11.1 directory selection
제품이 folder import/workspace를 선택하면 별도 `DirectorySelectionPort`를 만든다.
- `showDirectoryPicker`는 progressive enhancement
- `<input webkitdirectory>`는 검증된 baseline으로만 사용
- depth, entry count, per-file/total bytes, traversal time의 hard cap
- relative path segment NFC 정규화, `.`/`..`, separator, control/bidi 거부
- 파일이 아닌 entry, traversal 중 permission loss와 mutation을 closed failure로
처리
- traversal 결과는 opaque file refs와 sanitized relative metadata만 반환
- directory name/path를 domain ID 또는 log로 사용하지 않음
directory upload가 필요하면 backend도 archive/path/symlink/traversal과 total
expanded budget을 다시 검증한다.
### 11.2 persistent handles and permission
persistent handle은 별도 registry와 consent가 필요하다.
- IDB structured-clone support를 실제 probe
- handle 자체를 application/domain/query cache에 노출하지 않음
- opaque handle ref, account partition, purpose, retention과 last-used bucket만 보관
- boot/background에서 `requestPermission()` 금지
- explicit user action에서 `queryPermission()` 후 필요한 경우에만 request
- denied/revoked/stale handle은 `RESELECT`, silent empty file로 처리하지 않음
- logout/account deletion과 handle registry purge는 authority를 요구
- browser가 OS 권한 철회를 지원하지 않을 수 있음을 UX에 명시
handle persistence는 local bytes backup이 아니며 파일이 외부에서 바뀔 수 있다.
매 open마다 size/lastModified와 제품이 요구하는 content identity를 다시 검사한다.
### 11.3 drag-and-drop
현재 `DROP` source enum은 full adapter를 의미하지 않는다. 선택 시 별도 inbound
adapter가 `DataTransfer`를 event 안에서 snapshot하고, file-only drop과 directory
traversal을 구분한다. pasted/dropped HTML, URL과 string item을 file capability로
승격하지 않는다. same count/byte/type/path policy를 picker와 공유하되 UI event
type을 permission으로 사용하지 않는다.
## 12. Range와 private cache는 별도 capability
### 12.1 Range/206
public static cache는 `Range` request와 206 response를 계속 거절한다. resumable
download에는 별도 계약이 필요하다.
- immutable object version 또는 strong validator
- `Range`/`If-Range`
- exact `206 Content-Range`
- `200`, `206`, `412`, `416` state transition
- destination offset/seek/truncate와 partial checkpoint
- overlap/gap 방지
- capability 재발급 시 같은 representation binding
- 전체 완료 뒤 whole-object integrity
sparse range를 Cache Storage entry로 합치는 것은 현재 public release port의 역할이
아니다. 필요하면 OPFS staging 또는 별도 range store를 선택하고 backend
File/Object Server와 validator/range 계약을 맞춘다.
### 12.2 private response cache
private/auth/account representation은 public Cache Storage adapter에서 계속
fail-closed한다. offline private data가 제품 요구면 별도 설계가 최소 다음을
소유해야 한다.
- current authorization과 server source-of-truth
- opaque account partition
- logout/account-deletion purge authority
- TTL/revalidation/revocation
- offline disclosure threat model
- export/recovery
- XSS가 same-origin key를 사용할 수 있다는 한계
client-side encryption만으로 authorization boundary를 만들었다고 주장하지 않는다.
security/privacy 승인이 없으면 network-only다.
## 13. fault and recovery matrix
| fault | fail-closed 결과 | recovery |
| --- | --- | --- |
| estimate unavailable | `UNKNOWN` | essential만 policy budget 내 허용, speculative cache defer |
| pressure/critical | admission 제한 | bounded GC, sync/export 안내 |
| first quota failure | transaction/candidate rollback | eligible GC 후 exact operation 최대 1회 retry |
| second quota failure | 신규 write 중지 | read-only/online-only/export-required |
| partial sentinel mismatch | `STORAGE_EVICTED`/`CORRUPT_DATA` | reconstructable rehydrate, local-first quarantine |
| 모든 local marker 소실 | first install과 구분 불가 | backend epoch가 있으면 비교, 없으면 정직한 degraded UX |
| migration crash | old committed generation 유지 | checkpoint부터 resume/reconcile |
| future schema | `INCOMPATIBLE` | N-1 destructive write 금지, online-only/read-only |
| OPFS real probe fail | `DEGRADED/UNAVAILABLE` | async fallback probe 또는 online-only |
| Cache cleanup deadline | partial success + cursor | 다음 bounded invocation |
| corrupt active pointer | cleanup/interception 중지 | network-only, verified recovery tool |
| preview pixel/decode limit | `LIMIT_EXCEEDED/POLICY_REJECTED` | attachment-only 또는 reselect |
| persistent permission revoked | `PERMISSION_DENIED` | 명시적 reselect/re-authorize |
| SW old/new incompatibility | activation 중지 | old active 유지 또는 verified previous 재활성화 |
## 14. observability
허용:
- capability/lifecycle/runtime-health 상태
- operation과 closed failure code
- pressure/byte/count/duration bucket
- migration version ID와 processed/remaining bucket
- GC deadline/more-available 여부
- probe phase와 capability boolean
- release registry ID처럼 registry-owned non-user identifier
금지:
- filename, directory path, object ID, account/tenant ID
- URL/query/request/response body
- exact digest, ETag, raw cache/DB/path name
- handle, capability receipt, authority proof
- native exception message/stack
- exact usage/quota로 사용자의 device storage를 fingerprint하는 event
## 15. rollout and rollback
### 15.1 구현 순서
1. lifecycle policy/registry와 deterministic state machine
2. bounded maintenance page/cursor 계약
3. cross-store admission + injected fault adapters
4. OPFS real preflight
5. OPFS/Cache historical migration fixtures와 runtime
6. preview safety probe
7. optional capability는 제품 선택 후 별도 branch에서 구현
새 runtime은 구현과 evidence가 끝나도 skeleton에서는
`AVAILABLE_NOT_COMPOSED`로 종료한다.
### 15.2 제품 승격
```text
owner/policy와 필요한 경우 backend authority/re-sync 결정
-> registry 및 immutable composition
-> primaryStatus=COMPOSED, TrafficAdmission=DISABLED
-> real browser readiness/shadow inspection
-> RuntimeHealth=AVAILABLE 또는 승인된 DEGRADED
-> PromotionEvidence=COMPLETE
-> TrafficAdmission=CANARY (reconstructable dataset)
-> TrafficAdmission=CANARY (user-authored write)
-> migration/rollback drill
-> TrafficAdmission=ENABLED
```
user-authored/local-first를 reconstructable cache보다 먼저 canary하지 않는다.
### 15.3 rollback
1. 신규 write, migration, cache activation과 SW update를 disable한다.
2. in-flight operation을 abort/drain하고 native truth를 reconcile한다.
3. current schema를 읽을 수 있는 bundle은 read-only로 유지한다.
4. N-1이 future schema면 online-only/export-required로 전환한다.
5. previous verified static cache가 있으면 explicit activation으로 rollback한다.
6. user-authored data는 sync/export 확인 없이 purge하지 않는다.
7. old physical/cache generation은 rollback window와 client drain 뒤 bounded
maintenance로 정리한다.
## 16. test and promotion evidence
### 16.1 deterministic tests
- threshold/hysteresis와 concurrent admission
- pressure leader lock loss, abort, timeout
- first quota failure -> GC -> exact one retry
- retry가 non-idempotent/partial commit/second failure에서 차단됨
- GC ordering과 user-authored non-eviction
- sentinel partial mismatch와 all-marker-loss ambiguity
- migration batch crash/resume/replay/fencing
- OPFS v1->v2 copy/verify/publish/cleanup fault
- Cache candidate failure, pointer corruption, rollback과 cursor expiry
- cleanup/inspect count/deadline absolute ceiling
- preview hostile dimensions, integer overflow, animation, truncated container와 decode
- permission denied/revoked/stale persistent handle
- redaction과 dependency snapshot mutation
### 16.2 real browser tests
Chromium, Firefox와 WebKit에서 지원 범위를 명시하고 skip을 success로 세지 않는다.
- StorageManager estimate/persist denial
- native IndexedDB/OPFS/Cache quota exception mapping
- DedicatedWorker + Web Lock + OPFS write/read/delete probe
- two-tab migration/maintenance serialization
- versionchange/future schema and N-1 read-only
- actual Cache stage/activate/previous rollback/controlled client drain
- storage clear 뒤 explicit degraded behavior
- file preview real static decode/cleanup
- directory/handle은 지원 engine + OS manual evidence
quota를 실제로 완전히 채우는 flaky test는 유일한 gate로 쓰지 않는다. deterministic
fault injection과 실제 small-operation smoke를 함께 보존한다.
### 16.3 promotion artifact
artifact는 다음을 포함한다.
- release/commit, browser/OS/image
- policy/registry/migration suite version과 hash
- runtime lifecycle/health/admission
- deterministic + native pass/fail/skip
- historical fixture N-1/N/N+1 결과
- rollback drill과 recovery runbook link
- evidence expiry와 waiver
필수 engine skip, expired evidence, migration fixture 누락, quota retry invariant 위반,
preview decode safety 누락 또는 user-authored auto-delete가 있으면 promotion을
차단한다.
## 17. 완료 기준
이 결정의 공통 runtime 구현은 다음을 모두 만족해야
`AVAILABLE_NOT_COMPOSED`로 완료된다.
- origin coordinator가 immutable registry, 다섯 primary status literal과 독립된
selection/admission/health/evidence 축을 강제
- pressure hysteresis, bounded GC와 exact one-retry가 executable test로 검증
- all-marker-loss ambiguity를 API/result/문서에서 숨기지 않음
- OPFS real preflight가 worker/lock/journal/write/read/delete/cleanup을 검증
- OPFS/Cache forward migration과 N-1 rollback historical fixture 통과
- Cache inspect/cleanup이 cursor/count/deadline 상한을 강제
- static Cache와 optional Service Worker composition이 import/bundle 경계로 분리
- preview가 pixel/decoded-byte/animation/decode limit을 object URL 전에 강제
- directory/persistent/drop이 transient picker에 암묵적으로 추가되지 않음
- Range download와 private/sparse Range cache가 서로도 별도 capability로
남고 public cache가 둘을 계속 거부
- Chromium/Firefox/WebKit의 required evidence와 recovery/rollback drill 완성
- default production build에는 선택되지 않은 runtime, worker, DB open, listener,
timer가 없음
이 기준 전에는 기존 `AVAILABLE_NOT_COMPOSED` runtime 일부가 존재하더라도 origin
storage lifecycle 전체를 production-ready 또는 `COMPOSED`라고 부르지 않는다.
@@ -0,0 +1,638 @@
# VD-16: Browser transfer composition과 Image delivery
- 상태: Accepted — design complete, implementation pending
- 결정일: 2026-07-28
- 이 ADR이 선택한 common delta의 current status:
`DESIGNED_NOT_IMPLEMENTED`
- common delta의 목표 reference status:
`AVAILABLE_NOT_COMPOSED`
- 관련 결정: VD-10, VD-11, VD-12, VD-13, VD-14, VD-15
- current status ledger:
`docs/architecture/browser-data-capability-completion-ledger.md`
- 재검토: 첫 product upload/download/Image CDN capability를 조합하기 전
## 배경
현재 저장소에는 File runtime, presigned capability provider/vault/executor,
multipart/resumable upload, one-shot streaming download와 Image CDN verification
runtime의 개별 factory가 있다. 이 구현들은 production bootstrap에서 제거돼
있고 각각의 local `close()` 또는 `dispose()`만 제공한다.
제품에서 이들을 직접 조합하면 다음 문제가 생긴다.
- account/session이 바뀌어도 이전 capability, checkpoint, refresh 또는 async
completion이 살아남을 수 있다.
- 서로 다른 config가 같은 byte/resource/preset을 다르게 해석할 수 있다.
- 일부 provider만 생성된 partial runtime이 요청을 받기 시작할 수 있다.
- upload, download와 image에 retry, kill switch, deadline과 observation owner가
중복될 수 있다.
- Image engine은 이미 decode된 server descriptor를 받으므로 BFF transport,
expiry refresh와 presentation handoff의 책임이 비어 있다.
- 실제 provider가 frontend mock과 같은 계약을 지키는지 재사용 가능한
conformance harness가 없다.
이 결정은 도메인별 upload 화면이나 cloud vendor를 공통 플랫폼에 넣지 않는다.
선택된 capability를 안전하게 조립·폐기하는 composition protocol과 Image
descriptor acquisition 경계를 정한다.
## 현재 상태
| 항목 | 상태 | 설명 |
| --- | --- | --- |
| 개별 presigned/upload/image runtime | `AVAILABLE_NOT_COMPOSED` | factory와 deterministic test가 있으나 제품 graph에는 없음 |
| one-shot streaming download | `AVAILABLE_NOT_COMPOSED` | Range resume가 아닌 전체 객체 스트림 |
| top-level transfer runtime | `DESIGNED_NOT_IMPLEMENTED` | export index만 있고 atomic factory/readiness/lifecycle 없음 |
| Image descriptor HTTP provider | `DESIGNED_NOT_IMPLEMENTED` | caller가 `BackendIssuedImageAsset`을 직접 전달 |
| safe image DOM projection | `DESIGNED_NOT_IMPLEMENTED` | presentation descriptor는 있으나 renderer boundary 없음 |
| app-managed background download | `NOT_SELECTED` | VD-14의 별도 optional capability |
| cross-browser background-download guarantee | `PLATFORM_LIMITED` | browser-managed handoff가 기본 fallback |
| app-managed background upload | `NOT_SELECTED` | durable source staging/worker auth protocol이 별도로 필요 |
| cross-browser background-upload guarantee | `PLATFORM_LIMITED` | worker lifetime/local source permission을 공통 보장할 수 없음 |
이 ADR을 추가해도 위 상태는 자동으로 바뀌지 않는다. port, runtime, test와
removal evidence가 구현된 뒤에만 reference 상태를 올린다.
## 결정
### 1. 하나의 account-scoped composition owner
선택된 file/transfer/image capability는
`BrowserTransferRuntimeComposition` 역할의 단일 owner가 다음 순서로 생성한다.
```text
parse immutable config
-> validate implementation ceilings
-> obtain immutable session/account scope
-> create policy registries
-> create provider transports
-> create capability vaults
-> create checkpoint/lock/channel owners
-> create file/upload/download/image runtimes
-> run required compatibility probes
-> publish READY facade atomically
```
factory가 중간에 실패하면 생성된 owner를 역순으로 닫고 facade를 반환하지 않는다.
partial runtime, degraded provider 또는 mutable config를 application에 노출하지
않는다.
composition은 다음 두 종류를 반환하는 union이어야 한다.
```text
READY {
generation,
capabilities,
application facades,
readiness,
close()
}
UNAVAILABLE {
safe reason,
retryability,
fallback capability,
disposePartial()
}
```
`UNAVAILABLE`에 provider URL, raw browser exception, account/tenant ID 또는
credential을 넣지 않는다.
### 2. Runtime config는 closed schema다
config는 composition root만 읽고 깊은 snapshot/freeze한다. 최소한 다음
registry-owned reference를 갖는다.
- config schema version과 runtime compatibility version
- opaque session/account scope와 generation
- application origin과 fixed BFF endpoint IDs
- exact `PRESIGNED_TRANSFER_V1`, `PRESIGNED_MULTIPART_V1`,
`RANGE_RESUMABLE_DOWNLOAD_V1`, `IMAGE_CDN_DESCRIPTOR_V1` 중 선택한 protocol
registry와 fixed endpoint map
- upload purpose/profile, part/concurrency/retry/deadline hard ceiling
- download profile, size/integrity/strategy와 Range capability selection
- checkpoint namespace, retention, inventory와 maintenance budget
- lock/cancel transport selection과 unsupported outcome
- Image issuer/origin/preset/key/probe/decode policy
- descriptor refresh lead time, request/decode deadline와 concurrency
- capability별 traffic admission과 kill switch
- observation sink와 redaction policy
- active-operation drain deadline
caller는 raw endpoint, URL, header, object key, transform, retry count, byte ceiling,
cache policy 또는 account partition을 request마다 override할 수 없다.
config는 구현 절대 상한을 높일 수 없다. 구현 상한보다 큰 값, 중복 registry ID,
same-origin private Image CDN, 모순되는 fallback 또는 provider 누락은 startup에서
fail-closed한다.
### 3. Lifecycle state machine
top-level runtime은 다음 상태만 가진다.
```text
CREATING
-> PROBING
-> READY
-> DRAINING
-> CLOSED
CREATING | PROBING
-> FAILED
-> CLOSED
```
- `READY`만 새 operation을 받는다.
- `DRAINING`은 새 operation을 거절하고 진행 중 operation에 bounded deadline을
제공한다.
- deadline 뒤 남은 operation은 runtime lifetime signal로 abort한다.
- `close()`는 terminal/idempotent이며 `DRAINING/CLOSED`에서 반복 호출해도
새 side effect를 만들지 않는다.
- 닫힌 runtime은 reopen하지 않는다. 새 config/scope에는 새 generation을 만든다.
- operation은 시작할 때 runtime generation과 account scope snapshot을 얻고
모든 async boundary와 terminal commit 전에 다시 확인한다.
- 늦게 끝난 fetch, hash, IndexedDB transaction, image verification 또는 decode가
old generation이면 결과를 폐기하고 native resource를 닫는다.
### 4. Logout과 account/tenant switch
session owner notification이 authority다. BroadcastChannel, storage event,
capability expiry 또는 page unload를 logout authority로 사용하지 않는다.
```text
session owner announces local revoke
-> traffic admission CLOSED
-> runtime generation FENCED
-> reject new operations
-> signal active reads/fetch/backoff/probes
-> bounded drain
-> close capability and image vaults
-> close cancel channels and release locks/connections
-> apply checkpoint retention/purge policy with exact old scope
-> dispose observations
-> CLOSED
-> construct new scope/runtime independently
```
- old-scope purge와 new-scope open을 같은 transaction이나 facade에 섞지 않는다.
- checkpoint가 crash 때문에 남아도 exact scope/policy binding이 다르면 새
runtime이 읽지 못해야 한다.
- presigned URL, signed headers와 Image private URL은 어떤 teardown record에도
저장하지 않는다.
- old account의 descriptor refresh, part completion과 download destination
commit은 generation fence 뒤 성공으로 보고하지 않는다.
- logout이 backend capability의 즉시 revoke를 보장하지 않는다. 강한 회수가
필요하면 BFF가 revoke authority 또는 proxy/relay를 제공해야 한다.
### 5. Capability별 facade
application에는 top-level runtime 자체나 native adapter를 반환하지 않는다.
composition은 설치된 feature에 필요한 좁은 facade만 주입한다.
```text
FeatureUploadFacade
-> select local file profile
-> create/resume/pause/abort approved purpose
FeatureDownloadFacade
-> request approved resource
-> receive policy-selected handoff/save outcome
FeatureImageFacade
-> request opaque asset/preset
-> receive safe presentation descriptor
```
feature는 `File`, `Blob`, `Response`, `ReadableStream`, `FileSystemHandle`,
presigned URL, Image signature DTO, checkpoint store 또는 QueryClient를 받지 않는다.
progress UI를 위한 observation도 bounded aggregate snapshot이며 transfer
authority가 아니다.
## Upload lifecycle integration
### 6. Pause와 abort는 다르다
top-level upload facade가 향후 제공할 상태는 다음과 같다.
```text
ACTIVE
-> PAUSE_REQUESTED
-> PAUSED
-> RESUMING
-> ACTIVE
ACTIVE | PAUSED
-> ABORT_REQUESTED
-> ABORT_PENDING
-> ABORTED
ACTIVE
-> COMPLETING
-> QUARANTINED
```
- pause는 새 part와 retry를 중지하고 현재 bounded native operation을 abort한 뒤
non-authorizing checkpoint를 유지한다.
- cross-context pause wire literal은 `RESUMABLE_UPLOAD_PAUSE_V1`이며
`uploadKey`, exact scope/generation과 bounded message metadata만 운반한다.
- durable `PAUSED`를 추가하는 checkpoint는 `schemaVersion: 2`다. v1
`ACTIVE | ABORT_PENDING` reader/writer와 섞지 않고 old-writer drain,
historical migration과 N-1 fail-closed를 증명한다.
- abort는 server session authority와 reconcile한 뒤 terminal checkpoint를
제거한다.
- pause signal은 authority가 아니라 best-effort same-scope hint다. 수신자는
exact upload key/scope/generation을 검증한다.
- checkpoint inventory는 presigned URL이나 raw file path 없이 opaque upload
key, safe state, age/byte/part bucket과 expiry만 반환한다.
- inventory/list와 retention sweep은 count, cursor, deadline을 갖는다.
- abandoned/expired checkpoint는 server status 또는 expiry policy와 CAS를
확인한 뒤 bounded batch로 제거한다.
- file 재선택 뒤 source fingerprint/size/media/part layout이 exact하게 맞지
않으면 resume하지 않는다.
app-managed background upload는 이 state machine을 재사용할 수 있지만 page
runtime의 pause/resume를 background 보장으로 표현하지 않는다.
## Download integration
### 7. Strategy selector
download strategy는 application caller가 지정하지 않고 immutable profile,
resource delivery class, expected bytes, browser capability와 user activation을
입력으로 하는 headless selector가 결정한다.
| 조건 | 결과 |
| --- | --- |
| save picker 지원, user activation 있음, large stream | `WHOLE_OBJECT_PICKER_STREAM` |
| server-managed resource, picker 없음 또는 handoff가 제품 정책 | `BROWSER_MANAGED_HANDOFF` |
| generated artifact가 approved Blob cap 이하 | `BOUNDED_OBJECT_URL` |
| large generated artifact, picker 없음 | `SERVER_GENERATION_REQUIRED` 또는 `UNSUPPORTED` |
| Range profile + seekable destination + provider contract | `RANGE_RESUMABLE_FOREGROUND` |
| background download가 선택되고 지원되는 browser + owned staging | `APP_MANAGED_BACKGROUND_DOWNLOAD` |
selector는 capability probe와 actual invocation failure를 구분한다.
`WHOLE_OBJECT_PICKER_STREAM`인데 picker가 없으면 request validation 오류가 아니라
`UNSUPPORTED` 또는 승인 fallback이어야 한다. Safari/WebView/private mode의
fallback도 동일 표에서 결정하며 user agent 문자열만으로 기능을 가정하지 않는다.
selector의 위 값은 application-level `DownloadExecutionPlan`이다. 현재
file-delivery primitive와의 mapping은 다음처럼 닫는다.
| execution plan | 현재 adapter mapping |
| --- | --- |
| `WHOLE_OBJECT_PICKER_STREAM` | `DownloadStrategy=PROMPT_AND_STREAM` |
| `BROWSER_MANAGED_HANDOFF` | source kind `BROWSER_MANAGED_RESOURCE` + `DownloadStrategy=BROWSER_MANAGED` |
| `BOUNDED_OBJECT_URL` | `DownloadStrategy=BOUNDED_OBJECT_URL` |
| `RANGE_RESUMABLE_FOREGROUND` | VD-14의 별도 future port; 현재 adapter에 mapping 금지 |
| `APP_MANAGED_BACKGROUND_DOWNLOAD` | 별도 optional worker/staging port |
| `SERVER_GENERATION_REQUIRED` / `UNSUPPORTED` | browser delivery를 시작하지 않는 closed outcome |
Range resume의 checkpoint, validator와 seek/truncate 결정은 VD-14를 따른다.
app-managed background download는 기본 selector 결과가 아니다.
## Image descriptor acquisition과 delivery
### 8. Wire protocol
private Image descriptor BFF 계약은 다음 literal을 사용한다.
```text
IMAGE_CDN_DESCRIPTOR_V1
```
request는 composition-owned fixed HTTPS endpoint를 호출하며 최소한 다음
application-safe 입력만 허용한다.
- protocol
- opaque asset reference
- named preset reference 또는 preset family
- intended presentation class
- current runtime generation에 묶인 CSRF/session transport
caller는 CDN URL, source URL, origin, object key, width, height, DPR, quality, fit,
format, cache header, signing key ID나 expiry를 제출하지 않는다.
response decoder는 content type, status, header/body byte cap, total deadline와
closed JSON shape를 검증한다. response에는 최소한 다음이 binding된다.
- exact protocol과 issuer
- opaque asset ID와 immutable revision
- origin/preset binding IDs
- static raster media와 intrinsic dimensions
- allowed preset binding ID set
- issued/expiry time
- signature algorithm, key ID, canonical binding digest와 signature
unknown field 정책은 protocol version에서 고정한다. credential, backend stack,
raw provider key 또는 arbitrary transform은 descriptor에 포함하지 않는다.
HTTP `200`만 descriptor success다. `401/403/404`의 외부 mapping은 existence
hiding 정책에 따라 closed failure로 정규화하며 raw backend message를 버린다.
redirect, opaque response, wrong content type, oversize, timeout와 malformed
descriptor는 capability를 생성하지 않는다.
### 9. Provider와 verifier 경계
- BFF는 authorization, asset existence, quarantine/promotion state와 descriptor
발급 authority를 소유한다.
- verifier registry는 composition이 승인한 bounded old/new public key set만
가진다.
- client signature 검증은 BFF authorization의 대체가 아니라 response tamper와
registry mismatch를 fail-closed하는 보조 경계다.
- CDN은 asset revision과 preset binding ID로 exact transform candidate를
재계산한다. signed query나 client 계산 width가 authority가 아니다.
- private asset의 emergency revocation은 backend/CDN/BFF가 소유한다. client는
runtime close와 short expiry로 exposure를 줄인다.
### 10. Refresh state machine
descriptor provider는 asset/preset/scope/generation별 bounded single-flight만
허용한다.
```text
ABSENT
-> FETCHING
-> VERIFIED
-> FRESH
-> REFRESH_DUE
-> REFRESHING
-> FRESH
FETCHING | REFRESHING
-> TERMINAL_POLICY_FAILURE
-> PLACEHOLDER
FETCHING | REFRESHING
-> RETRYABLE_FAILURE
-> EXISTING_FRESH_UNTIL_EXPIRY | PLACEHOLDER
any state + scope/runtime revoke
-> REVOKED
```
- refresh lead time은 config가 정하되 expiry hard ceiling을 넘지 않는다.
- private descriptor를 generic Query cache, Web Storage 또는 IndexedDB에
persistence하지 않는다.
- concurrent callers는 같은 verified result를 받을 수 있지만 URL/string을
application state에 장기 복사하지 않는다.
- 기존 descriptor가 아직 fresh하고 refresh가 일시 실패하면 expiry까지만
사용할 수 있다. expiry 뒤 stale-while-error를 금지한다.
- `lazy` load로 실제 fetch가 expiry 뒤 시작될 가능성이 있으면 eager/priority로
바꾸거나 load 직전에 새 descriptor를 발급한다.
- logout, key registry replacement와 runtime generation 변경은 in-flight
transport, verification과 probe를 abort하고 늦은 결과를 폐기한다.
### 11. Safe presentation projection
공통 presentation primitive는 검증된 `ImagePresentationDescriptor`를 다음
정적 속성으로만 투영한다.
- fallback `src`
- ordered `<source type srcset>`
- registry-owned `sizes`
- intrinsic `width``height`
- `loading`, `decoding`, `fetchpriority`
- `referrerpolicy`
- `crossorigin="anonymous"`
primitive는 URL을 parse·조립·append하거나 transform query를 생성하지 않는다.
descriptor가 가진 string을 React property로 전달하기 전에 closed allowed
protocol/origin과 runtime generation을 다시 확인한다. raw HTML 주입과 CSS URL
조립을 금지한다.
다음은 제품 presentation owner가 결정한다.
- 의미 있는 `alt`
- placeholder와 오류 copy
- skeleton/aspect-ratio UX
- above-the-fold preload/priority
- route/SSR preload hint
- click/open/download behavior
descriptor refresh 실패를 native broken-image UI에만 맡기지 않고 제품이 승인한
placeholder outcome으로 매핑한다.
## Provider contract harness
### 12. 재사용 가능한 suite
frontend는 transport 구현과 분리된 provider contract harness를 제공한다. 같은
case set을 deterministic fake, local emulator와 실제 BFF/object storage/CDN에
실행한다.
Presigned/download case:
- exact protocol/status/content type/body cap
- method/origin/path/query/header binding
- redirect와 credential omission
- expiry/revocation/replay
- truncation/overrun/content encoding
- CORS exposed receipt/checksum
Multipart case:
- create/status/part/complete/abort idempotency
- part layout/checksum/receipt reconciliation
- 404/410/expiry와 orphan cleanup
- quarantine/promotion
- retry-after와 ambiguous completion
Image case:
- protocol/issuer/key/preset exact match
- old/new signing key overlap과 removal
- immutable revision/cache key
- private no-store/CORS/CSP
- pixel/decode/encoded byte ceiling
- malformed/animated/active content
- expiry refresh, revocation과 placeholder
actual provider test는 bearer URL, signature, account/asset/session ID를 artifact에
기록하지 않는다. fixture는 synthetic opaque values와 disposable storage를 쓴다.
## Failure, readiness와 fallback
### 13. Readiness report
readiness는 application-safe capability별 결과다.
| 상태 | 의미 |
| --- | --- |
| `READY` | 필수 provider/config/browser probe가 모두 유효 |
| `DEGRADED` | 승인된 좁은 fallback만 가능 |
| `UNAVAILABLE` | 기능을 노출하지 않음 |
| `DRAINING` | 기존 작업만 정리 중 |
| `CLOSED` | terminal |
`DEGRADED`는 byte/pixel/security ceiling을 낮출 수는 있지만 높이지 않는다.
예를 들어 enhanced picker off → native input, multipart concurrency off →
sequential, private Image CDN off → approved placeholder는 가능하다.
integrity off, arbitrary URL 허용, private response cache 또는 unbounded Blob은
fallback이 아니다.
### 14. Kill switch
최소한 다음 switch를 독립적으로 둔다.
- new presigned issuance
- direct object-storage data plane
- new upload session
- upload resume
- upload complete
- Range resume
- picker streaming save
- private image descriptor issuance
- advanced image format
- responsive candidates
switch 변경은 active operation의 의미를 소급 변경하지 않는다. 신규 진입을
닫은 뒤 reconcile/drain한다. remote runtime config를 사용한다면 config의
authenticity, release compatibility와 last-known-safe 정책을 별도 hosting 계약으로
검증한다.
## 관측성과 개인정보
허용:
- operation kind와 safe outcome
- runtime/readiness state
- byte/part/candidate/retry/age/deadline bucket
- policy rejection, abort, reconcile와 drain bucket
- aggregate active count, checkpoint count와 orphan age
금지:
- URL, query, signed/request/response header
- capability, bearer token, signature와 key material
- resource/session/asset/upload/account/tenant ID
- file name, local path, object key와 storage physical key
- digest, ETag, receipt와 checkpoint payload
- raw backend/browser exception message와 stack
runtime generation과 registry ID도 외부 telemetry에 그대로 보내지 않고 bounded
compatibility bucket으로 변환한다.
## Rollout과 rollback
### 15. Rollout
1. ledger와 관련 ADR을 accepted로 고정한다.
2. closed config/port와 provider contract harness를 먼저 구현한다.
3. fake와 negative fixture에서 partial composition/late result를 거절한다.
4. top-level runtime을 `AVAILABLE_NOT_COMPOSED`로 유지하고 removal gate를 만든다.
5. product owner, exact scope와 provider config를 선택하고 bootstrap에 kill
switch `DISABLED` 상태로 조합한다. 이 시점 primary status는 `COMPOSED`다.
6. 실제 BFF/storage/CDN conformance와 readiness probe를 통과한다.
7. Chromium/Firefox/WebKit과 실제 device에서 account switch, expiry와 crash를
검증한다.
8. runbook/rollback drill 뒤 internal cohort의 read-only/image public 또는 upload
shadow flow부터 연다.
9. private/image upload/download capability를 독립 canary와 kill switch로 확대한다.
### 16. Rollback
1. 신규 issuance/session/descriptor를 중지한다.
2. runtime을 `DRAINING`으로 바꾸고 bounded operation을 마무리한다.
3. ambiguous upload는 server reconcile하고 Range partial은 checkpoint 정책대로
보존 또는 삭제한다.
4. private capability를 backend에서 revoke하고 client runtime을 close한다.
5. old compatible composition을 새 generation으로 다시 생성하거나 기능을
unavailable로 유지한다.
6. optional source, config와 facade를 제거하고 production module inventory와
removal gate를 재검증한다.
schema version을 내리거나 checkpoint를 무조건 삭제해 rollback하지 않는다.
## 검증과 완료 기준
### 17. Deterministic
- partial factory failure의 reverse-order cleanup
- close idempotency와 closed-runtime rejection
- account switch 중 late fetch/hash/transaction/decode drop
- config duplicate/ceiling/missing provider fail-closed
- selector의 picker/size/resource matrix
- upload pause/abort/reconcile race와 bounded inventory
- descriptor refresh single-flight, expiry와 generation fence
- picture projection의 arbitrary URL/query 생성 0건
- diagnostics forbidden-value negative fixtures
### 18. Native browser
- Chromium/Firefox/WebKit의 native input/save picker fallback
- multi-tab upload pause/cancel과 unsupported Web Locks path
- page reload/account switch 중 active transfer drain
- public/private Image fetch, actual CORS/no-store와 bitmap decode
- offline/timeout/abort/late completion cleanup
- Safari/WebView/private mode의 approved selector result
### 19. Provider와 operations
- fake/emulator/실제 BFF·object storage·CDN 동일 contract suite
- signing key rotation과 emergency revoke drill
- checkpoint retention/orphan cleanup drill
- capability별 kill switch와 N-1 rollback
- runtime removal 후 source-module inventory 0건
다음 조건 전에는 목표 상태를 `AVAILABLE_NOT_COMPOSED`로 올리지 않는다.
- [ ] top-level closed config와 atomic factory가 구현됐다.
- [ ] generation-bound lifecycle과 account teardown이 구현됐다.
- [ ] `RESUMABLE_UPLOAD_PAUSE_V1`, checkpoint schema v2 old-writer
migration과 bounded upload inventory/retention owner가 구현됐다.
- [ ] `PRESIGNED_TRANSFER_V1`과 선택 protocol의 strict codec, unknown-version
rejection 및 reusable provider contract harness가 구현됐다.
- [ ] strategy selector가 browser fallback을 fail-closed한다.
- [ ] Image descriptor provider/refresh와 safe projection이 구현됐다.
- [ ] deterministic fault, boundary, removal test가 통과한다.
다음 조건 전에는 제품 상태를 `COMPOSED`로 올리지 않는다.
- [ ] product owner와 opaque account scope가 정해졌다.
- [ ] strict config를 사용하는 top-level runtime이 production bootstrap에서
생성되고 실제 feature facade consumer까지 연결됐다.
- [ ] traffic 기본값이 `DISABLED`이며 local teardown/close 경로가 연결됐다.
다음 조건 전에는 product-local production traffic을 승인하지 않는다.
- [ ] actual BFF/storage/CDN contract harness가 통과한다.
- [ ] browser/device evidence와 runbook drill이 보존됐다.
- [ ] kill switch와 rollback owner가 운영 승인됐다.
## 관련 문서
- [Browser data capability completion ledger](../browser-data-capability-completion-ledger.md)
- [Presigned transfer and Image CDN](../presigned-transfer-and-image-cdn.md)
- [VD-14 Resumable download와 background download](./VD-14-resumable-download-and-background-transfer.md)
- [Server file capability infrastructure](../server-file-capability-infrastructure.md)
- [Browser transfer recovery](../../operations/browser-transfer-recovery.md)
## 선택하지 않은 대안
- feature가 개별 transfer adapter factory를 직접 조합
- singleton runtime을 여러 account/tenant가 공유
- raw presigned/Image URL을 Query cache나 persistence에 저장
- Image descriptor endpoint/transform을 request마다 caller가 지정
- page abort를 upload pause 또는 server abort 완료로 간주
- browser-managed handoff를 저장 완료로 간주
- user-agent 문자열만으로 Safari fallback 결정
- Service Worker를 설치하면 background upload/download가 보장된다고 가정
- capability 일부만 준비된 partial runtime을 degraded success로 반환
## 결과
장점:
- account 전환과 teardown의 한 owner가 생긴다.
- 개별 runtime의 안전한 메커니즘을 제품별 facade로 좁혀 조합할 수 있다.
- provider mock과 실제 인프라 사이의 계약 차이를 같은 suite로 찾을 수 있다.
- Image URL과 transform이 application/presentation에서 재조립되지 않는다.
- capability별 rollout, kill switch와 제거가 독립적이다.
비용:
- config, lifecycle, provider harness와 browser evidence가 늘어난다.
- 제품이 선택하지 않은 capability는 여전히 조합할 수 없으며 이것이 의도된
결과다.
- actual provider와 운영 증거 없이는 reference runtime 구현만으로 production
완료를 주장할 수 없다.
@@ -0,0 +1,729 @@
# VD-23: API transport selection과 REST execution
- 상태: Accepted — REST v2 security/execution baseline composed, advanced profiles pending
- 결정일: 2026-07-28
- installed REST reference vertical: `COMPOSED`
- REST v2 security/execution baseline: `COMPOSED`
- provider/path/auth profile baseline: `COMPOSED`
- conditional execution/provider-conformance delta: `DESIGNED_NOT_IMPLEMENTED`
- GraphQL/Connect/gRPC-Web/REST Gateway product selection: `NOT_SELECTED`
- 관련 결정: VD-13, VD-24, VD-25, VD-26, VD-27, VD-29, VD-30
- 상세 설계:
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
- browser Protobuf/gateway 설계:
[Protobuf browser transport와 REST Gateway](../protobuf-browser-transport-and-rest-gateway.md)
## 배경
현재 reference feature는 operation registry, request Zod schema, shared HTTP
executor, response envelope/payload schema, mapper, gateway, application input과
TanStack Query까지 실제 production composition에 연결된다. 따라서 REST 경로
자체를 미구현으로 표시하지 않는다.
현재 reference operation은 v2 metadata, collision-aware composition, allowlisted
credential patch, auth fail-before-fetch, prefix-preserving target, shared logical
deadline, exact JSON media/success status, bounded decoder와 typed mapping failure를
실행한다. correlation header, success status group과 401 replay를 포함한 physical
attempt도 terminal observation에 연결한다.
operation은 path placeholder↔codec key exact join, named provider/bearer/CSRF
profile, provider credential-mode ceiling과 encoded query byte ceiling도 실행한다.
남은 production delta는 cookie-CSRF/CORS evidence, 204/304/412 conditional
transaction, artifact digest와 실제 provider conformance다.
`API_CONTRACT_VERSION` 문자열 일치만 backend compatibility 증거로 사용하지 않는다.
## 결정
### 1. Protocol 선택은 operation registry가 소유한다
feature application port는 protocol-neutral이다.
```text
feature use case
-> feature gateway
-> exact semantic operation
-> REST adapter
-> persisted GraphQL adapter
-> Connect adapter
-> gRPC-Web adapter
```
-`operationId`는 한 protocol에만 binding한다.
- UI, query hook과 use case는 protocol을 선택하지 않는다.
- URL, GraphQL document, service/method와 generated request를 application input으로
받지 않는다.
- 같은 command를 provider failure 때문에 다른 protocol로 자동 replay하지 않는다.
- read fallback도 동일 auth/freshness/schema/mapper/query identity와 하나의 total
retry budget을 증명한 registered policy만 허용한다.
- GraphQL/Connect/gRPC-Web adapter를 공통 `HttpClient`의 mode flag로 넣지 않는다.
공통으로 공유하는 것은 execution context, auth collaboration, failure vocabulary와
observation뿐이다.
### 2. REST operation은 실행 source와 정적 manifest를 분리하지 않는다
목표 API는 feature가 generic이 연결된 definition을 만든다.
```text
defineRestOperation<
Path,
Search,
Body,
SuccessWire,
SuccessValue,
Failure
>({
common,
providerId,
method,
uriTemplate,
pathCodec,
searchCodec,
bodyCodec,
successCodec,
errorCodec,
mapper,
policies
})
```
실행 definition에서 registry manifest를 결정적으로 투영한다. schema metadata,
실제 codec map, operation-specific TypeScript map과 mapper를 서로 다른 string
dispatch table에 수기로 중복하지 않는다.
manifest 최소 field:
```text
protocol = REST
registryVersion
providerId
operationId
owner
semantics
method
relativePathTemplate
pathSchemaId
searchSchemaId
bodySchemaId
requestMediaProfile
successStatusProfiles[]
errorStatusProfiles[]
responseSchemaId
mapperId
authProfileId
csrfProfileId
replayPolicy
idempotencyKeyPolicy
deadlineProfileId
retryProfileId
paginationProfileId | null
conditionalProfileId
serverStateProfileId | null
maxRequestBytes
maxDecodedResponseBytes
maxResponseItems
observabilityProfileId
compatibility
```
### 3. Contribution composer는 collision 전에 실패한다
```text
feature contributions[]
-> preserve every source row
-> validate contribution owner/version
-> detect duplicate operation/schema/mapper/query/topic ID
-> resolve every codec/mapper/profile reference
-> validate semantic coherence
-> freeze installed registry
-> emit compatibility manifest
```
object spread의 last-write-wins를 금지한다. duplicate ID가 payload까지 동일해도
owner를 하나 선택하지 않고 build를 실패시킨다. alias/rename은 versioned migration
row로만 허용한다.
semantic coherence:
- `GET`/`HEAD`는 body 없음, `SAFE`만 허용
- `HEAD`는 body success codec 없음
- `POST`/`PATCH` retry는 `IDEMPOTENT | KEYED_COMMAND` 또는 명시적
`SAFE` semantics 필요
- `KEYED_COMMAND``idempotencyKeyPolicy=REQUIRED`, 다른 replay policy의 key
attach/금지는 exact key profile과 일치
- `NON_REPLAYABLE`은 retry와 401 replay 금지
- success/error status가 겹치지 않음
- 204 profile은 body/schema 없음
- 304는 query + conditional profile + existing cache binding 필요
- 412는 precondition profile 필요
- path placeholder 집합과 path codec key 집합이 exact match
- query/cache profile은 `QUERY` operation에만 연결
- mutation invalidation은 registered topic만 사용
### 4. Provider endpoint와 URI
application은 provider URL을 받지 않는다.
```text
RestProviderProfile
providerId
baseOrigin
basePathPrefix
allowedCredentialsModes
corsProfile
referrerPolicy
redirectPolicy = ERROR
defaultHeaders
allowedResponseOrigins
```
- non-local은 HTTPS만 허용한다.
- provider는 credential mode ceiling만 제공하고 operation의 auth transport
profile이 exact mode를 선택한다. anonymous는 `omit`, same-origin cookie는
approved `same-origin`, approved cross-origin cookie만 `include`다.
- base URL의 username, password, query와 fragment를 금지한다.
- exact origin과 canonical base path prefix를 보존한다.
- operation template은 relative API path이며 scheme, authority, query와 fragment를
포함하지 않는다.
- leading slash가 base prefix를 제거하는 `new URL()` ambiguity를 쓰지 않는다.
- encoded slash, dot segment, NUL/control, duplicate slash와 overlong path를
정책대로 거절한다.
- redirect는 기본 `error`다. 로그인/다운로드 handoff는 일반 JSON REST operation과
다른 capability다.
path parameter:
- path codec의 parsed output만 encode한다.
- missing, extra, empty와 length 초과 parameter를 network 전에 거절한다.
- Unicode normalization을 업무 ID에 임의 적용하지 않는다.
- path value와 최종 URL은 diagnostics에 기록하지 않는다.
query:
- key ordering, repeated-array/comma style, boolean, null/absent/empty semantics를
profile에 고정한다.
- URL encoded byte ceiling을 적용한다.
- raw `URLSearchParams`, query string과 next URL을 caller에게 받지 않는다.
- sensitive/private value를 GET query에 넣는 operation은 별도 security review가
없으면 금지한다.
### 5. Request projection과 final invariant
request는 다음 소유 순서로 만든다.
```text
operation + validated input
-> immutable request binding
-> body canonical serialization + digest
-> transport-owned headers/options
-> constrained auth/CSRF patch
-> final invariant validation
-> fetch
```
transport-owned header:
- `Accept`, `Content-Type`
- contract/media version
- bounded correlation/trace context
- idempotency key
- conditional validator
- approved CSRF header
caller와 feature mapper가 arbitrary header를 추가하지 않는다.
auth owner target:
```text
CredentialPatch
credentialMode
allowlisted header name/value
proof expiry/generation
```
operation auth profile은 final Fetch `credentials`를 exact하게 고정한다.
`ANONYMOUS | BEARER_HEADER`는 ambient cookie가 섞이지 않게 `omit`,
same-origin cookie session은 `same-origin`, cross-origin cookie는 별도 CORS/CSRF
provider evidence가 있는 profile만 `include`다. provider ceiling과 맞지 않으면
fetch 0회다.
auth owner가 `Request` 전체를 반환하지 않는다. transition 기간에 current port를
사용한다면 attach 전후의 다음 값이 exact하게 같아야 한다.
- URL/origin/path/query
- method
- body digest
- content type/length
- correlation, idempotency, conditional와 CSRF binding
- redirect/cache/referrer/credentials/mode
다르면 `AUTH_INTEGRATION_FAILURE`, fetch 0회다.
auth-required operation은 session state가 `authenticated`가 아니면 fetch하지 않는다.
`integration-failed`, `unauthenticated`와 credential attach rejection을 anonymous
request로 downgrade하지 않는다.
### 6. Cookie auth, CSRF와 CORS
same-origin BFF cookie session을 기본 권장한다.
- cookie는 Secure/HttpOnly이며 provider가 SameSite 정책을 소유한다.
- unsafe method는 server의 exact Origin/Fetch Metadata 검증과 composition-issued
anti-CSRF proof를 요구한다.
- CSRF proof는 application/query/cache에 노출하거나 persistence하지 않는다.
- custom content type/preflight가 있다는 사실만 CSRF 방어로 간주하지 않는다.
cross-origin profile은 다음을 actual provider에서 증명한다.
- exact `Access-Control-Allow-Origin`, wildcard 금지
- credentials mode와 allow-credentials 일치
- exact allow-method/allow-header
- 필요한 request ID, ETag, Retry-After만 expose
- OPTIONS와 actual response의 policy 동등성
- redirect 없음
### 7. Replay와 idempotency
```text
ReplayPolicy
SAFE
IDEMPOTENT
KEYED_COMMAND
NON_REPLAYABLE
```
- `SAFE`: read-only이며 network/recovery retry 가능
- `IDEMPOTENT`: 같은 principal/operation/payload/precondition의 반복 request가
의도한 server effect를 추가로 만들지 않으며 duplicate response/status mapping을
backend contract가 명시한다. response byte가 항상 동일하다는 뜻은 아니다.
- `KEYED_COMMAND`: application logical command lease가 key를 생성하고 lifecycle
전체에서 유지
- `NON_REPLAYABLE`: ambiguous result에서 자동 재실행 금지
`KEYED_COMMAND` binding:
```text
principal scope
operation ID/version
canonical payload digest
idempotency key
server retention/expiry
```
backend는 atomic claim, concurrent same-key join/replay, same-key different-payload
rejection과 terminal receipt를 제공한다. header를 보냈다는 사실만 replay safety가
아니다. provider conformance가 없으면 retry/401 recovery traffic을 켜지 않는다.
current client가 execute 호출마다 key를 생성하는 방식은 network attempt 안에서는
재사용되지만 ambiguous terminal 뒤 사용자 retry와 연결되지 않는다. 목표 command
owner가 effect certainty를 다음처럼 반환한다.
```text
NOT_APPLIED | COMMITTED | UNKNOWN
```
`UNKNOWN`은 새 key 자동 retry가 아니라 status/reconcile 또는 명시적 사용자 복구로
닫는다.
### 8. Logical deadline, cancel과 retry
```text
LogicalExecutionBudget
totalDeadlineMs
attemptTimeoutMs
maxAttempts
maxCumulativeSleepMs
maxRetryAfterMs
authRecoveryCount = 0 | 1
```
total deadline은 다음 모두를 포함한다.
- operation/schema lookup과 request encode
- credential/CSRF attachment
- fetch attempt
- body read/decode/schema/mapper
- 401 recovery
- retry backoff/Retry-After
각 phase는 남은 total budget보다 긴 timer를 만들지 않는다. timer, abort listener,
response reader와 auth waiter는 모든 terminal path에서 정리한다.
초회, network/status retry와 401 recovery replay를 포함한 **모든 API provider
fetch**는 하나의 monotonic `physicalAttemptCount`를 증가시키고 `maxAttempts`
소비한다. `authRecoveryCount`는 추가 상한일 뿐 attempt counter, sleep budget이나
total deadline을 reset하거나 우회하지 않는다.
retry status는 operation의 exact subset만 허용한다.
```text
network failure
408
429
502
503
504
```
- 429와 503의 `Retry-After`를 injected clock으로 parse한다.
- hard ceiling을 넘는 Retry-After는 sleep하지 않고 terminal로 닫는다.
- full jitter와 attempt/sleep/elapsed 세 상한을 모두 적용한다.
- schema, mapper, 4xx validation/authz/conflict와 redirect failure는 retry하지 않는다.
- caller cancel, navigation supersede, runtime teardown, attempt timeout과 logical
deadline을 다른 safe failure로 유지한다.
- 401 recovery는 auth-required이면서 replay-safe한 operation만 한 번 수행한다.
- 동시 401은 session owner의 single-flight recovery를 공유한다.
- physical attempt, logical retry와 auth replay를 따로 관측한다.
### 9. Closed execution
public executor는 promise rejection 대신 항상 closed result로 끝난다.
```text
RestExecutionResult<T>
SUCCESS
VALIDATION_REJECTED
AUTH_REQUIRED | AUTH_INTEGRATION_FAILURE
REQUEST_ABORTED
REQUEST_ATTEMPT_TIMEOUT | REQUEST_DEADLINE_EXCEEDED
NETWORK_UNREACHABLE
RATE_LIMITED | SERVER_FAILURE
HTTP_FAILURE
CONTENT_TYPE_MISMATCH | BODY_LIMIT_EXCEEDED | MALFORMED_BODY
SCHEMA_MISMATCH | MAPPING_CONTRACT_VIOLATION
PRECONDITION_FAILED | CONFLICT
CONTRACT_INCOMPATIBLE
```
attempt timer와 전체 logical deadline은 error registry, safe user copy와 telemetry
bucket에서도 별도 closed kind로 유지한다. 둘 다 raw URL/timing detail을
노출하지 않는다.
operation lookup, URL construction, `Headers`, body serialization, `Request`,
auth collaboration, fetch, read, parse, schema와 mapper를 모두 catch/normalize
경계 안에 둔다. thrown value/body/header/URL을 failure에 복사하지 않는다.
### 10. Response admission과 bounded decoder
body를 `Response.json()`으로 바로 읽지 않는다.
```text
response
-> final URL/status/header admission
-> exact media type parser
-> present/valid Content-Length advisory preflight
-> bounded stream reader
-> actual browser-visible decoded byte count
-> UTF-8/profile decoder
-> JSON structural ceiling
-> envelope/status codec
-> operation response codec
-> mapper
```
browser Fetch의 `Response.body`는 일반적으로 content decoding 뒤 stream이므로
client가 actual wire/encoded byte를 신뢰성 있게 세었다고 주장하지 않는다.
- BFF/proxy/CDN가 encoded transfer와 decompression ratio ceiling을 집행한다.
- browser client는 present/valid `Content-Length`를 advisory rejection에만 쓰고
actual decoded bytes를 hard cap으로 센다.
- 표준 `JSON.parse` profile은 decoded-byte cap이 pre-parse resource guard이고
depth/node/key/string/item cap은 materialization 뒤 admission guard다.
- 구조 cap을 parse 중 강제해야 하는 더 큰 profile은 bounded tokenizing JSON
parser를 별도로 선택하고 actual browser evidence를 가져야 한다.
cap 초과, truncation과 invalid UTF-8에서 reader를 cancel하고 cache에 쓰지 않는다.
media parser는 type/subtype/parameter를 exact하게 해석한다.
- `application/json`
- approved vendor `application/*+json`
- RFC Problem Details profile
- explicit 204 no-content
`includes("application/json")` 검사는 목표 계약이 아니다.
각 success status는 response codec을 가진다. 현재 envelope는
`REST_ENVELOPE_V1` profile로 유지할 수 있지만 모든 REST provider에 강제하지
않는다. success status와 error body가 모순되면 status/profile 계약 실패다.
### 11. Error projection
backend error는 먼저 status/media별 codec을 통과한다.
- raw message, stack, body와 arbitrary extensions를 버린다.
- code/category는 operation error profile의 allowlist로 mapping한다.
- unknown backend code는 closed generic failure다.
- validation issue는 최대 count, path/code byte/charset와 allowed path를 제한한다.
- request ID, trace ID와 correlation ID도 length/charset cap을 적용한다.
- `retryable` backend boolean을 client retry authority로 사용하지 않는다.
- 401/403/404 existence-hiding은 provider/product policy를 따른다.
- 409 business conflict와 412 representation precondition failure를 분리한다.
### 12. Cursor pagination
```text
CursorPageWire<T>
items
nextCursor | null
hasMore
snapshotToken | null
```
response codec은 item count, item size, cursor/snapshot byte와 total decoded ceiling을
검증한다. mapper는 immutable `CursorPage<ApplicationProjection>`을 만든다.
- cursor는 opaque이며 decode/로그/telemetry 금지
- arbitrary next URL을 따라가지 않음
- filters/sort/scope/snapshot과 cursor를 exact binding
- same cursor 반복, `hasMore=true`인데 cursor 없음, non-progress loop 거절
- maximum pages/items/cache bytes 이후 fetch 중지
- TanStack infinite query의 root key에는 semantic filter만 넣고 cursor는 bounded
`pageParam`으로 관리
- offset pagination은 stable small dataset이 증명된 별도 profile만 허용
current reference list array는 complete pagination 구현이 아니다.
### 13. Conditional read와 optimistic concurrency
operation별 owner:
```text
ConditionalProfile
NO_STORE
APP_ETAG
BROWSER_HTTP_CACHE
APPLICATION_REVISION
```
모든 operation은 한 profile을 가져야 한다.
- `NO_STORE`, `APP_ETAG`, `APPLICATION_REVISION`은 Fetch `cache=no-store`.
- `BROWSER_HTTP_CACHE`만 exact browser cache mode와 server
`Cache-Control`/`Vary` contract를 사용한다.
- caller/library default에 맡기는 implicit `NONE`은 없다.
`APP_ETAG`:
- strong ETag/generation은 adapter-private metadata
- exact operation/query/scope/representation binding과 함께 memory에 보존
- `If-None-Match`를 transport가 생성
- 304는 same binding의 mapped cached value가 있을 때만 freshness 갱신
- 304는 same query-entry cache revision CAS가 성공할 때만 commit
- cached value가 없으면 one-time unconditional request 또는 closed failure
- app-managed conditional operation은 fetch cache mode를 `no-store`로 고정
- full 200 mapped commit과 validator install/update는 같은 entry transaction
- query removal/GC, scope/logout, release/contract/schema/mapper epoch reset에서
validator sidecar도 함께 폐기
- ordinary invalidation에서 validator 보존 여부는 profile이 고정
`BROWSER_HTTP_CACHE`:
- standard browser cache가 revalidation을 소유
- application이 hidden validator/304 logic을 중복 구현하지 않음
- HTTP `Cache-Control`을 TanStack `staleTime`으로 자동 변환하지 않음
write precondition:
- exact resource revision을 `If-Match` 또는 body contract로 binding
- 412는 `PRECONDITION_FAILED`
- current server representation을 refetch한 뒤 feature use case가 overwrite,
merge 또는 cancel을 결정
- 409 business conflict와 합치지 않음
ETag/revision은 authorization proof가 아니며 raw value를 diagnostics에 넣지 않는다.
### 14. Schema와 Mapper 연결
VD-24의 typed codec/mapper를 사용한다.
```text
unknown
-> RuntimeCodec<ValidatedResponseDto>
-> Mapper<ValidatedResponseDto, ApplicationProjection>
-> Result
```
request는 strict/normalized parsed output만 serialize한다. ordinary additive
response는 required/discriminant를 검증하고 unknown field를 폐기한다. control,
authorization와 sealed union은 unknown을 거절한다.
generated OpenAPI client/DTO를 선택해도 adapter-private이다. handwritten gateway와
mapper를 제거하지 않는다.
### 15. Query cache와의 관계
REST transport는 raw response cache를 소유하지 않는다. VD-25가 mapped application
projection을 TanStack Query에 admission한다.
- operation registry의 cache profile만 query를 만들 수 있다.
- Query retry는 `false`; REST transport가 network retry를 소유한다.
- URL, ETag, idempotency key, Response와 DTO를 query key/value에 넣지 않는다.
- mutation success 뒤 registered invalidation topic 또는 exact typed seed policy만
사용한다.
- REST timeout/error를 Query가 다시 network retry하지 않는다.
### 16. Contract source와 release coherence
OpenAPI가 backend authority인 제품:
```text
authenticated immutable OpenAPI artifact
-> source digest/provenance
-> lint + breaking diff
-> pinned deterministic generation
-> runtime codec parity
-> adapter-private DTO/client
-> handwritten mapper/gateway
```
CI:
- generator/runtime/plugin/Node version pin
- clean checkout regenerate diff 0
- stable operation ID
- source/artifact/generated output digest
- runtime codec vs specification fixtures
- N/N-1 and future-major failure
- generated import boundary
global `API_CONTRACT_VERSION`은 selected contract-set compatibility와 digest에
연결한다. runtime config와 release manifest의 같은 문자열만으로 backend
compatibility를 주장하지 않는다.
major version 전략은 URI version 또는 vendor media version 중 provider가 하나를
선택한다. 동시에 둘을 임의 증가시키지 않는다. v1/v2 adapters는 같은
application gateway를 구현할 수 있지만 같은 query entry에 representation을
섞지 않는다.
### 17. Security와 observability
관측 허용:
- semantic operation/provider/profile ID
- method/semantics
- outcome/AppFailure kind/HTTP status group
- logical retry, auth recovery와 physical attempt bucket
- duration/deadline/request-response byte/item bucket
- conditional/cache outcome
금지:
- URL, path/search/header/body
- cursor/snapshot/ETag/revision
- idempotency/CSRF/auth token
- raw backend code/message/request/trace value
- application resource/account/tenant ID
client correlation ID는 bounded syntax로 request에 전달하고 server-projected
request/trace ID는 safe failure/observation에만 제한한다. 한 logical execution은
terminal event 하나를 만든다.
### 18. Composition과 readiness
REST v2 composition owner가 다음을 atomic하게 만든다.
```text
parse provider/config
-> compose collision-free operation registry
-> resolve codec/mapper/policy references
-> install auth/CSRF owner
-> run compatibility/provider probes
-> publish READY facade
```
partial registry/client를 application에 노출하지 않는다.
```text
Selection
TrafficAdmission
RuntimeHealth
PromotionEvidence
```
primary status가 `COMPOSED`여도 provider/auth/CSRF/idempotency/conditional
conformance가 없으면 `TrafficAdmission=DISABLED`
`PromotionEvidence=MISSING | PARTIAL | EXPIRED`로 닫는다.
kill switch:
- provider 전체
- operation family
- keyed retry/401 replay
- conditional request
- optimistic mutation
- query cache admission
### 19. Test와 provider conformance
deterministic:
- contribution collision, missing codec/mapper/profile와 owner mismatch
- URI template/path/query canonicalization/base-prefix preservation
- method/body/replay/status/media coherence
- anonymous/cookie/bearer credentials와 Fetch cache mode matrix
- auth final-request mutation 공격과 unavailable auth fetch 0
- attempt timeout vs total deadline, timer/listener/reader cleanup
- concurrent 401 single-flight
- 401 replay를 포함한 monotonic physical-attempt cap
- retry matrix, injected-clock 429/503 Retry-After
- same key/same payload replay와 same key/different payload rejection
- 204/304/412/422/problem/envelope
- oversized/truncated/malformed/decompression overflow
- cursor loop/snapshot/page ceiling
- ETag 304 without cache, If-Match 412
- mapper failure와 redaction
actual staging provider:
- HTTPS/base path/CORS/preflight/credential
- cookie/Origin/CSRF
- idempotency concurrent claim/TTL/reconcile
- status/media/error codec
- cursor/snapshot/conditional semantics
- rate limit/Retry-After
- correlation/request/trace projection
- proxy/CDN content encoding and body cap
- browser decoded-byte cap과 provider encoded/decompression ceiling
- outbound correlation, success status group와 401 physical-attempt observation
MSW 통과는 provider conformance가 아니다.
### 20. Rollout
1. collision-aware v2 registry/codec과 boot-time binding 검증을 설치한다.
2. auth fail-closed, final invariant, bounded decoder와 total deadline을 local
reference vertical에서 검증한다.
3. actual provider에 같은 fixture를 실행하고 read operation을 canary한다.
4. keyed command는 backend idempotency conformance 뒤 별도 canary한다.
5. pagination/conditional operation을 각각 별도 traffic gate로 올린다.
6. provider/browser/operations evidence가 complete인 operation만 enabled한다.
7. rollback은 우선 safe unavailable로 내리고 contract artifact/frontend/backend를
coherent set으로 복구한다. v1 fallback은 해당 operation의 unexpired
provider/security evidence가 있고 incident가 v1/shared boundary에 영향이 없으며
auth fail-close/final invariant hardening이 유지될 때만 허용한다.
### 21. Removal
GraphQL/Connect/gRPC-Web/REST Gateway 선택을 취소해도 REST v2 common execution
context는 남을 수 있다.
REST provider 제거 시:
1. 신규 operation admission 중지
2. read cancel, command effect certainty reconcile
3. auth/CSRF/retry timer와 response reader close
4. current scope query cache clear/invalidate
5. operation/schema/mapper/query profile 제거
6. provider config/proxy/dependency/fixture 제거
7. production module inventory와 backend route retirement evidence
## 완료 기준
- installed REST operation이 typed path/search/body/success/error codec과 mapper에
하나의 definition으로 연결된다.
- auth unavailable 또는 mutated final request에서 fetch가 0회다.
- 모든 throw/response size/status/media/schema/mapper failure가 closed result다.
- total logical deadline이 auth/recovery/backoff/decode/mapper를 포함한다.
- replay는 declared policy와 actual backend idempotency evidence를 가진다.
- complete cursor page와 conditional/412 state가 bounded하게 동작한다.
- response DTO/URL/header/token/validator가 application/query/log에 없다.
- actual provider conformance와 rollback/removal drill이 통과한다.
@@ -0,0 +1,585 @@
# VD-24: Runtime Schema와 boundary Mapper
- 상태: Accepted — reference typed codec/mapper baseline composed, artifact governance pending
- 결정일: 2026-07-28
- reference REST Schema/Mapper vertical: `COMPOSED`
- semantic compatibility/codegen governance delta:
`DESIGNED_NOT_IMPLEMENTED`
- generated API product selection: `NOT_SELECTED`
- 관련 결정: VD-13, VD-23, VD-25, VD-26, VD-27, VD-29, VD-30
- 상세 설계:
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
## 배경
현재 reference vertical은 다음 trust path를 실제 실행한다.
```text
HTTP response
-> common envelope Zod
-> feature payload Zod
-> feature mapper
-> domain factory
-> application view
-> TanStack Query
```
reference baseline은 schema contribution collision을 boot 전에 거절하고,
schema version/direction/unknown-field policy를 기록한다. request는 strict reject,
ordinary response DTO는 strip projection을 사용하며 list item 수와 response/cache
byte admission을 제한한다. mapper는 no-throw `MappingResult`를 반환하고 예상 가능한
drift는 `MAPPING_CONTRACT_VIOLATION`으로 분류한다.
runtime schema codec과 mapper contribution은 collision-aware composer로 설치되고,
각 REST operation의 path/request/response schema와 mapper input schema reference를
boot 전에 exact resolve한다. operation별 cast-free result guard도 raw executor의
성공 값을 fail-closed로 재검증한다. 남은 delta는
actual codec fingerprint/source provenance/generated artifact join과 전체 scalar
policy set이다.
이 결정은 runtime validation을 특정 library 이름으로 축소하지 않는다. 현재
owned schema는 Zod를 사용하지만 GraphQL generated types와 protobuf messages에도
동일한 trust transition을 적용한다.
## 결정
### 1. 다섯 validation 경계를 분리한다
| 경계 | owner | 목적 |
| --- | --- | --- |
| route/form input | presentation/feature | 사용자 입력 정규화와 UX issue |
| application command/query input | application/feature | use-case precondition과 canonical semantic input |
| transport request wire | adapter/contract | exact outbound representation |
| transport response wire | adapter/contract | untrusted server bytes/message 검증 |
| domain invariant | domain | 업무상 유효한 entity/value 생성 |
하나의 Zod schema를 form, API request, response와 domain에 재사용하지 않는다.
field 이름이 같아도 trust source와 failure semantics가 다르다.
### 2. TypeScript와 generated type은 proof가 아니다
```text
unknown bytes/message
-> bounded decoder
-> RuntimeCodec<ValidatedDto>
-> ValidatedDto
-> BoundaryMapper<ValidatedDto, ApplicationValue>
-> MappingResult<ApplicationValue>
```
`as Dto`, generic `execute<T>()`, generated TypeScript interface와 protobuf class
instance는 runtime proof를 만들지 않는다.
목표 API:
```text
RuntimeCodec<Input, Output>
schemaId
parse(input, budget) -> ValidationResult<Output>
BoundaryMapper<ValidatedDto, ApplicationValue>
mapperId
inputSchemaId
map(dto) -> MappingResult<ApplicationValue>
BoundOperation<Input, Dto, Value>
requestCodec
responseCodec
mapper
```
operation definition 생성 시 codec output과 mapper input type을 compiler가
연결한다. runtime registry도 same IDs/fingerprints를 검증한다.
### 3. Schema registry v2
```text
SchemaDefinitionV2
schemaId
wireVersion
boundary
protocol
sourceKind = OWNED | GENERATED
sourceArtifactId
sourceArtifactDigest
codecId
codecFingerprint
unknownFieldPolicy
numericPolicyId
temporalPolicyId
providerMaxEncodedBytes
maxDecodedBytes
maxDepth
maxNodes
maxObjectKeys
maxStringBytes
maxCollectionItems
compatibilityPolicy
dataClassification
owner
```
정적 metadata는 실행 codec definition에서 결정적으로 투영한다. 실제 codec
resolver가 없는 schema ID, fingerprint가 다른 resolver와 duplicate ID는
contribution composition에서 실패한다.
`codecFingerprint`는 library 내부 AST serialization을 무조건 신뢰하지 않는다.
프로젝트가 소유한 canonical schema manifest를 사용한다.
```text
canonical schema manifest
field/path
required/nullability
scalar/format/range
enum/discriminant
collection/item ceiling
unknown-field policy
transform identifier/version
```
Zod upgrade로 내부 representation이 바뀌어도 canonical meaning diff가 안정적이어야
한다.
### 4. Decode budget
Content-Length나 protobuf frame length만으로 충분하지 않다.
```text
ValidationBudget
decodedBytesRemaining
nodesRemaining
depthRemaining
objectKeysRemaining
stringBytesRemaining
collectionItemsRemaining
deadlineRemaining
```
- BFF/proxy/provider가 actual wire/encoded transfer와 decompression-ratio cap을
집행한다.
- browser Fetch adapter는 present/valid Content-Length를 advisory preflight로만
사용하고 browser-visible decoded stream bytes를 hard cap으로 센다.
- codec은 depth/node/key/string/item cap을 적용한다.
- collection nested item도 global budget을 함께 소모한다.
- transform/refine도 남은 logical deadline 안에서 동기적이고 bounded해야 한다.
- async network/storage refinement를 runtime wire schema에 넣지 않는다.
- budget 초과는 validation issue list를 무한 생성하지 않고 첫 bounded summary로
닫는다.
표준 `JSON.parse` profile에서 decoded-byte cap은 pre-parse guard지만
depth/node/key/string/item cap은 materialization 뒤 admission guard다. parse 중
구조 cap이 필요한 payload는 bounded tokenizing parser를 별도 profile로 선택하며,
그 구현 전에는 큰 byte ceiling을 승인하지 않는다.
current request `limit <= 100`은 response item ceiling이 아니다. response schema가
items maximum과 total byte budget을 별도로 검증한다.
### 5. Unknown-field 정책
```text
UnknownFieldPolicy
REJECT_UNKNOWN
STRIP_UNKNOWN
```
`PRESERVE_UNKNOWN`은 application boundary에서 허용하지 않는다.
| schema class | 기본 정책 |
| --- | --- |
| request, config, command, capability | `REJECT_UNKNOWN` |
| auth/authorization/control envelope | `REJECT_UNKNOWN` |
| ordinary additive REST response DTO | `STRIP_UNKNOWN` |
| GraphQL selected data object | requested field shape만 투영 |
| sealed discriminated union | unknown discriminator 거절 |
| protobuf generated message | codec/library unknown-field behavior 뒤 mapper는 known projection만 사용 |
current response `.strict()`를 모두 `.passthrough()`로 바꾸지 않는다. unknown
field를 제거한 typed projection만 mapper로 보낸다. unknown field 이름/value를
log에 남기지 않는다. 필요한 경우 low-cardinality `unknown-field-detected`
observation만 sampling한다.
### 6. Request와 response 방향성
request:
- strict field set
- trim/coerce/default/normalization 정책이 명시됨
- parsed output만 transport가 serialize
- input 원본을 query key나 request에 따로 사용하지 않음
- route/search/application command 변환이 같은 canonical semantic input을 공유
response:
- untrusted value를 coerce하지 않음
- required/nullability/discriminant/range를 검증
- additive unknown은 profile에 따라 strip
- default value를 서버가 보낸 값처럼 조용히 생성하지 않음
- missing/null/empty를 mapper가 명시적으로 소진
`z.coerce`는 URL/form 같은 string input 경계에서만 허용한다. JSON/protobuf
response에 적용하지 않는다.
### 7. Scalar 의미
#### ID
- opaque bounded string
- empty/control/overlong 거절
- 업무 계약이 없는 case folding, Unicode normalization과 numeric parse 금지
- account/resource ID를 diagnostics label이나 physical cache key에 직접 넣지 않음
#### Integer와 decimal
- JSON integer는 finite safe integer 범위를 증명
- `int64`/`uint64`는 JavaScript number로 mapping하지 않음
- protobuf bigint/string representation은 adapter-private
- money/decimal/high precision은 canonical decimal string + currency/scale policy
- `NaN`, Infinity와 negative zero가 의미상 허용되는지 explicit
- string-to-number response coercion 금지
#### Time
- exact RFC 3339 profile과 offset/precision을 검증
- date-only, instant, local date-time과 duration을 다른 type으로 둠
- leap/invalid date를 JavaScript `Date` normalization에 맡기지 않음
- protobuf Timestamp/Duration range/nanos를 검증
- mapper가 application temporal value로 변환
- locale/timezone formatting은 presentation에서만 수행
#### Null과 absent
```text
ABSENT
NULL
EMPTY
VALUE
```
네 의미를 schema/mapper contract에 명시한다. current mapper처럼 “string이
아니면 모두 null”로 합치지 않는다. optional server field의 default가 필요하면
application policy가 이름 있는 결정으로 적용한다.
#### Enum/union/oneof
- unknown discriminator는 sealed control union에서 fail-closed
- evolvable business enum은 domain이 explicit `UNKNOWN` case와 UX를 소유한
경우에만 mapping
- raw unknown string/number를 domain에 전달하지 않음
- protobuf enum zero value, unknown numeric enum과 oneof absence를 명시적으로
처리
#### Binary
- REST base64는 decoded byte cap과 canonical encoding profile 필요
- GraphQL upload/binary는 이 JSON schema 경계의 기본 기능이 아님
- protobuf `bytes`는 bounded copy/stream policy 뒤에만 application으로 projection
- large binary는 File/transfer capability를 사용
### 8. Transport-specific schema
#### REST
- exact status/media/envelope profile 뒤 operation DTO codec 실행
- error body도 별도 bounded codec
- Problem Details의 type/title/detail/instance를 raw UI copy로 사용하지 않음
- response envelope와 payload unknown policy를 따로 설정
#### GraphQL
- variables와 selected `data` shape에 separate codec
- top-level `data`, `errors`, `extensions`를 GraphQL response codec이 검증
- errors path/message/extensions는 safe failure mapper 전 untrusted
- partial policy가 허용한 missing/null만 operation DTO type에 표현
- persisted operation manifest의 schema digest와 codec fingerprint 일치
#### gRPC-Web
- frame/trailer 검증 뒤 generated protobuf decoder 실행
- generated decode success 뒤에도 semantic validator가 range/presence/enum/oneof를
검증
- descriptor digest/message full name과 codec binding 일치
- `google.rpc.Status` details는 allowlisted type만 decode
#### Connect-Web/Connect
- Connect unary HTTP/error 또는 stream EndStream proof 뒤 generated message decode
- JSON/binary encoding과 descriptor/message binding을 operation profile에 고정
- generated decode와 `ConnectError` code는 semantic domain proof가 아니므로
같은 validator/mapper와 safe failure vocabulary를 통과
#### Protobuf REST Gateway
- HttpRule/ProtoJSON/status/error profile 뒤 ordinary REST DTO codec 실행
- generated OpenAPI type이나 ProtoJSON message를 application model로 사용하지 않음
- direct gateway와 curated BFF의 envelope/schema를 같은 codec으로 추측하지 않음
### 9. Boundary Mapper v2
```text
MapperDefinitionV2
mapperId
mapperVersion
inputSchemaId
outputContractId
scalarPolicySetId
maxOutputItems
maxEstimatedOutputBytes
owner
```
mapper는:
- pure
- deterministic
- synchronous
- side-effect-free
- locale/timezone-independent
- input mutation 없음
- immutable output
- exhaustive
- bounded
mapper가 호출하면 안 되는 것:
- fetch, generated client, QueryClient
- clock/random
- storage/cache
- telemetry/logger
- DOM/browser API
- authorization/feature flag
### 10. Mapping result
```text
MappingResult<T>
{ ok: true, value: T }
{ ok: false,
error:
MAPPING_INVARIANT_REJECTED |
UNSUPPORTED_WIRE_VALUE |
OUTPUT_LIMIT_EXCEEDED }
```
예상 가능한 domain invariant/unknown enum/temporal conversion 실패는 throw하지
않는다. programming defect가 throw되더라도 adapter boundary가
`MAPPING_CONTRACT_VIOLATION`으로 정규화한다. raw DTO/value/path/message를 failure에
복사하지 않는다.
`UNKNOWN_FAILURE`는 mapper drift의 정상 분류가 아니다. operation ID,
schema/mapper profile/version과 safe outcome만 관측한다.
### 11. Domain, application projection과 view
```text
ValidatedDto
-> domain value/entity factory
-> ApplicationReadModel / command result
-> presentation-only ViewModel
```
- DTO는 adapter/contracts 내부
- domain은 transport nullability/error/envelope를 모름
- application read model은 query cache에 넣을 수 있는 immutable plain value
- presentation view는 locale/formatted copy와 UI-only optimistic marker를 소유
- domain class/service, function, native object와 generated message를 Query cache에
넣지 않음
current reference가 domain을 거쳐 view를 만드는 구조는 유지한다. 단 collection과
return object를 immutable/bounded하게 만들고 mapping type proof를 연결한다.
### 12. Collection mapping
- input array/page count는 codec에서 먼저 제한
- mapper는 output count와 estimated bytes를 다시 제한
- item 하나 실패 시 partial collection을 success/cache하지 않음
- stable identity, ordering, duplicate 의미는 feature contract가 결정
- duplicate ID를 임의로 마지막 값으로 덮지 않음
- mapper가 sort/filter/deduplicate를 한다면 이름 있는 policy와 fixture 필요
- pagination page/snapshot binding을 보존
estimated output bytes는 quota/serialization exact value가 아니라 cache admission
ceiling용 보수적 측정이다. 측정 실패는 unlimited로 간주하지 않고 cache
admission을 거절한다.
### 13. Generated와 owned source
```text
backend-authoritative contract
-> authenticated immutable source artifact
-> source digest + provenance
-> pinned codegen
-> adapter-private DTO/client/codec
-> owned semantic validator where required
-> handwritten boundary mapper
```
| protocol | generated source 후보 | 반드시 owned인 것 |
| --- | --- | --- |
| REST | OpenAPI DTO/client/codec | gateway, mapper, application model, query policy |
| GraphQL | schema types, operation types | persisted manifest policy, runtime result/error codec, mapper |
| gRPC-Web | protobuf messages/client | semantic validation, failure mapping, mapper, stream reducer |
normal build가 네트워크에서 최신 schema를 암묵적으로 내려받지 않는다. source
fetch/update는 authenticated explicit workflow이며 reviewable diff를 만든다.
generator:
- exact package/plugin/runtime/Node version pin
- reproducible output
- generated directory 수동 수정 금지
- clean regenerate diff 0
- license/SBOM/secret scan
- vendor import boundary
- generated artifact removal gate
현재 `generated-api` recipe의 generic `execute<TOutput>(unknown)`와 caller-selected
cast는 production schema proof가 아니다.
### 14. Compatibility
change classification:
| 변경 | 기본 판정 |
| --- | --- |
| optional ordinary response field 추가 + strip policy | additive |
| request required field 추가 | breaking |
| response required field 제거/rename/type/nullability 축소 | breaking |
| enum value 추가 | domain unknown policy에 따라 additive 또는 breaking |
| numeric range/precision/temporal profile 변경 | semantic breaking |
| mapper output meaning/identity/order 변경 | application breaking |
| unknown-field policy 변경 | compatibility review |
| codec transform/default 변경 | semantic diff 필수 |
`apiContractVersion` 하나만 올리지 않는다.
```text
ContractSetManifest
globalCompatibilityVersion
REST/OpenAPI artifact digest
GraphQL schema + persisted operation digest
protobuf descriptor digest
runtime schema registry digest
mapper registry digest
query policy digest
```
- N과 N-1 fixture를 보존
- breaking deployment는 old frontend window와 backend compatibility를 고려
- future major는 fail-closed
- rollback은 frontend, generated artifacts, config, BFF/router/proxy와 backend
compatibility를 coherent set으로 복구
- mapper-only semantic change도 cache/release epoch invalidation을 검토
### 15. Failure와 cache admission
다음 상태에서는 cache write가 0회다.
- body/frame limit
- envelope/status/media mismatch
- operation schema mismatch
- mapper failure
- scope/runtime generation mismatch
- output item/byte ceiling 초과
- incompatible contract/source digest
stale data를 유지할지는 VD-25 query profile이 결정한다. schema/mapper
incompatibility를 ordinary transient network failure와 동일하게 retry하지 않는다.
### 16. Security와 privacy
- validation failure에 raw value를 포함하지 않음
- issue path/code를 allowlist와 count/byte cap으로 projection
- schema/mapper error가 PII field/value를 diagnostics에 넣지 않음
- prototype pollution key와 accessor/class/native object 거절
- `structuredClone` 성공을 safe plain-data proof로 사용하지 않음
- generated code가 arbitrary URL/header/logger를 application에 노출하지 않음
- source artifact와 generator provenance 검증
- schema가 frontend authorization boundary라는 주장 금지
관측 허용:
- operation/schema/mapper ID와 version
- source/compatibility outcome
- validation/mapping failure kind
- encoded/decoded/output size와 item bucket
- unknown-field detected bucket
source digest 실제 값, field path/value, DTO, GraphQL error path와 protobuf payload는
high-cardinality/sensitive이므로 telemetry label에 넣지 않는다.
### 17. Testing
schema:
- missing/null/empty/unknown field
- numeric safe bounds, decimal, negative zero, NaN/Infinity
- RFC 3339/Timestamp/Duration edge
- enum/union/oneof future value
- depth/node/key/string/array/byte cap
- invalid UTF-8, base64와 binary cap
- N/N-1/future-major
registry:
- duplicate ID before spread
- missing/mismatched codec/mapper resolver
- codec fingerprint/source digest drift
- operation schema/mapper output type binding
- orphan/owner/version mismatch
mapper:
- typed DTO only
- deterministic/pure/input unmodified
- immutable output
- no throw for expected semantic rejection
- collection partial failure
- item/output byte ceiling
- missing/null/date/numeric/unknown enum matrix
- no raw value leakage
codegen:
- source provenance/digest
- lint/breaking
- clean reproducible generation
- generated import boundary
- runtime codec fixture parity
- dependency/removal inventory
integration:
- bytes → decoder → schema → mapper → application/query
- schema/mapper drift에서 cache write 0
- old runtime generation result 폐기
- actual REST/GraphQL/gRPC provider fixture
### 18. Rollout
1. current string-dispatch schema/mapper를 그대로 두고 typed definition builder를
추가한다.
2. reference operation에 shadow validation/mapping을 실행하되 secondary result를
UI/cache에 쓰지 않는다.
3. collision-aware contribution composer와 codec fingerprint를 먼저 blocking한다.
4. bounded decoder/collection ceiling을 query read부터 canary한다.
5. typed mapping result와 failure taxonomy를 적용한다.
6. current unchecked binder/cast를 제거한다.
7. OpenAPI/GraphQL/proto generation은 제품 contract source가 선택된 것만
별도 canary한다.
8. schema/mapper version을 release/cache epoch와 연결한다.
rollback은 codec schema number를 낮추거나 cache의 incompatible value를 억지로
decode하지 않는다. old adapter/backend path와 coherent artifact로 돌리고 current
scope의 incompatible mapped cache를 폐기한다.
## 완료 기준
- runtime codec output과 mapper input이 compiler/runtime registry 양쪽에서 연결된다.
- schema registry가 actual meaning fingerprint, provenance와 budget을 가진다.
- duplicate contribution이 overwrite 전에 실패한다.
- request strict/response additive 방향 정책이 test로 증명된다.
- scalar/null/enum/collection 의미가 mapper policy로 닫힌다.
- mapper가 typed DTO만 받고 expected failure를 `Result`로 반환한다.
- generated DTO/message가 domain/application/presentation/query public type에 없다.
- schema/mapper failure에서 cache write와 raw-data observation이 0회다.
- N/N-1, breaking diff, provider fixture와 rollback/removal drill이 통과한다.
@@ -0,0 +1,802 @@
# VD-25: Server State Cache lifecycle
- 상태: Accepted — reference bound-query/input-aware mutation baseline composed, lifecycle delta pending
- 결정일: 2026-07-28
- TanStack Query memory runtime: `COMPOSED`
- reference bound-query/profile/input-aware duplicate coordination: `COMPOSED`
- session-generation/identity lifecycle: `COMPOSED`
- conditional sidecar/optimistic layer/cursor runtime: `AVAILABLE_NOT_COMPOSED`
- account projection/infinite-query/effect reconciliation delta:
`DESIGNED_NOT_IMPLEMENTED`
- normalized graph cache: `NOT_SELECTED`
- query persistence product selection: `NOT_SELECTED`
- 관련 결정: VD-13, VD-23, VD-24, VD-26, VD-27, VD-29, VD-30
- 상세 설계:
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
## 배경
현재 production bootstrap은 runtime별 QueryClient와 invalidation coordinator를
실제로 조립한다. reference presentation은 application input을
`useApplicationQuery()`/`useApplicationMutation()`에 연결하며 다음을 제공한다.
- AbortSignal cancellation
- finite global stale/gc default
- Query network retry off
- stale-degraded 표시
- exact-key optimistic snapshot/rollback
- conflict 표면
- mutation topic lease와 cross-context invalidate-only hint
reference list/detail은 bound definition이 key, executor와 profile을 함께
제공한다. strict canonical codec은 depth/node/string/encoded-byte, cycle/shared
reference, undefined/NaN/negative-zero, sparse array, accessor와 non-plain object를
닫고 runtime-private opaque identity만 query key에 넣는다. profile의 stale/gc/
refetch/retry owner와 result item/byte admission이 실제 hook에 적용된다.
current invalidation definition은 namespace당 singular topic이고 같은 topic의
multi-namespace fan-out을 compose하지 못한다. 아래 bounded topic-set registry는
target delta이며 현재 runtime 증거가 아니다.
production scope runtime은 session transition 즉시 old generation을 fence하고
QueryClient cancel/clear 뒤 새 opaque scope를 발급한다. identity registry는 active
lease, refcount, bounded LRU, canonical-byte/entry ceiling과 token collision 검사를
scope별로 소유한다. mutation duplicate baseline도 scope exact semantic input만
join하고 late result를 폐기한다.
ordered optimistic layer, conditional validator CAS sidecar와 bounded cursor chain은
실행 가능한 test runtime까지 존재하지만 reference backend/definition에는 아직
연결하지 않았다. 남은 부분은 account identity projection, logical-key serialization,
HTTP 304 transaction, product optimistic membership/revision, infinite-query binding과
effect certainty reconcile이다.
## 소유권
VD-13:
- `CacheScopeSnapshot`
- ORIGIN/ACCOUNT/SESSION projection
- QueryClient generation/fence/remount
- strict canonical key codec 공통 구현
- cross-tab wire와 durable namespace epoch
- optional IndexedDB persistence와 restore
- logout/account switch purge
VD-25:
- operation/application query definition binding
- per-query freshness/gc/refetch/result budget
- cache admission과 mapped value contract
- cursor/infinite pagination
- conditional revalidation integration
- mutation concurrency, optimistic patch와 reconciliation
- invalidation/seed policy
- transport-independent error/stale behavior
VD-25는 VD-13의 scope snapshot/key codec을 소비하고 다른 epoch/fingerprint
protocol을 만들지 않는다.
## 결정
### 1. TanStack Query가 유일한 기본 Server State owner다
REST, GraphQL과 gRPC-Web unary result는 transport-independent application
projection으로 mapping된 뒤 TanStack Query memory cache에 들어갈 수 있다.
기본적으로 설치하지 않는다.
- Redux/Zustand server entity copy
- Apollo/urql normalized cache
- raw HTTP response cache wrapper
- generated client SDK cache
- custom Map singleton
GraphQL normalized cache가 실제로 필요하면 bounded context에서 TanStack
operation-result cache를 대체하는 별도 ADR을 승인한다. 두 cache에 같은 entity를
동시 write하지 않는다.
browser HTTP cache/Cache Storage, TanStack Query memory와 IndexedDB query
persistence는 서로 다른 owner다.
### 2. Bound Query Definition
caller가 query key와 executor를 독립적으로 조립하지 않는다.
```text
QueryDefinition<Input, Value>
definitionId
owner
operationId
inputCodec
keyCodecId
serverStateProfileId
scopePersistencePolicyId
resultContractId
execute(validatedInput, executionContext)
```
binding:
```text
bindQuery(definition, rawInput, CacheScopeSnapshot)
-> validate/canonicalize input
-> derive branded query key
-> resolve immutable policy
-> freeze execute closure and captured generation
-> BoundQuery<Value>
```
presentation API:
```text
useApplicationQuery(boundQuery)
```
`queryKey`, `queryFn`, stale/gc/retry와 arbitrary TanStack option을 feature page에서
따로 넘기지 않는다. escape hatch가 필요하면 새 profile을 먼저 등록한다.
### 3. Semantic query identity
query key는 VD-13의 단일 normative layout을 그대로 사용한다.
```text
[
"query",
keySchemaVersion,
scopeProjectionFingerprint,
namespaceName,
namespaceVersion,
queryDefinitionVersion,
canonicalSemanticInput
]
```
- namespace/key schema/version과 scope projection은 VD-13 profile-owned
- query definition version과 semantic input projection은 VD-25 definition-owned
- scope projection은 VD-13의 exact profile output
- input은 strict codec의 plain immutable representation
- REST URL/query string, ETag와 cursor raw value를 root identity에 넣지 않음
- GraphQL document/persisted hash를 넣지 않음
- protobuf bytes/generated message를 넣지 않음
- presentation locale/formatted string을 넣지 않음
transport migration이 use-case/result meaning을 보존하면 semantic query family를
유지할 수 있다. schema/mapper meaning, scope나 output identity가 바뀌면
query-definition/release epoch를 바꾼다.
strict key codec은 다음을 거절한다.
- `undefined`, sparse array
- NaN, Infinity, negative zero policy mismatch
- bigint/symbol/function
- Date/Map/Set/RegExp/typed array/native/class instance
- accessor/proxy/prototype pollution key
- cycle/shared-reference ambiguity
- non-plain object
- depth/node/part/string/encoded-byte ceiling 초과
query key에 PII/business ID를 직접 넣지 않는다. 필요한 resource identity는
feature policy가 발급한 opaque bounded token으로 투영한다.
cursor와 command 동일성은 raw value나 충돌 가능 digest만으로 판정하지 않는다.
```text
RuntimeIdentityTokenCodecV1
canonicalCodecVersion
maxCanonicalBytes
maxInternEntries
maxInternCanonicalBytes
tokenEntropyBits >= 128
lifetime = RUNTIME_SCOPE
```
- strict length-prefixed typed canonical encoding이 exact equality source다.
- runtime-private intern table이 canonical bytes를 opaque random token에
일대일로 binding하고 token collision을 reverse map으로 검사한다.
- 같은 token 후보가 다른 canonical bytes와 충돌하면 새 token을 발급한다. bounded
재시도 후에도 해결되지 않으면 `IDENTITY_TOKEN_COLLISION`으로 admission/join을
fail-closed한다.
- intern row는 lease/refcount를 가진다. Query entry가 설치된 동안, active
observer/fetch와 mutation/join이 진행되는 동안 해당 token을 eviction하지 않는다.
- Query removal/GC에서 query token lease를, mutation terminal/join waiter
settlement에서 command token lease를 exact once release한다. runtime/scope
close는 남은 table을 전부 폐기한다.
- refcount 0 row만 bounded LRU eviction할 수 있다. entry 수 또는 total canonical
bytes ceiling을 active lease 때문에 회수할 수 없으면
`IDENTITY_INTERN_LIMIT_EXCEEDED`로 신규 cache/command admission을 fail-closed한다.
- canonical bytes/raw cursor/command input은 query key, diagnostics,
cross-context wire와 persistence에 넣지 않고 runtime/scope close에서 폐기한다.
- token은 backend idempotency key, authorization proof나 durable identity가 아니다.
### 4. ServerStateProfile
```text
ServerStateProfileV1
profileId
classification
scopePersistencePolicyId
staleTimeMs
gcTimeMs
refetchOnMount
refetchOnFocus
refetchOnReconnect
networkMode
retryOwner = TRANSPORT | QUERY | NONE
maxResultItems
maxEstimatedResultBytes
paginationProfileId | null
conditionalProfileId | null
placeholderPolicy
initialFailurePolicy
refreshFailurePolicy
authorizationFailurePolicy
contractFailurePolicy
invalidationTopicRefs[] = { topicId, topicVersion }
owner
```
implementation ceilings:
- query `invalidationTopicRefs` set/version은 joined VD-13
`QueryScopePersistencePolicy`와 exact match
- `gcTimeMs`는 inactive retention이고 `staleTimeMs`는 freshness이므로
`staleTimeMs <= gcTimeMs`를 일반 불변조건으로 강제하지 않는다.
- VD-13 persistence를 선택한 경우에만 restore `maxAgeMs`, retention과
`gcTimeMs`의 join compatibility를 검증한다.
- finite gc 기본, `Infinity`는 explicit immortal-static profile만
- stale/gc 최대값
- result item/estimated byte 상한
- maximum pages
- refetch trigger storm coalescing
- foreground/background concurrency
현재 global 30초/5분은 reference default이지 모든 product query의 production
정책이 아니다.
### 5. Retry owner
한 network operation에는 retry owner가 정확히 하나다.
| 상황 | 기본 owner |
| --- | --- |
| installed REST | REST transport |
| persisted GraphQL | GraphQL transport |
| Connect unary | Connect adapter 또는 selected edge 중 exact one |
| gRPC-Web unary | gRPC-Web transport |
| pure local query computation | Query 또는 none |
transport retry가 있는 definition은 TanStack `retry=false`다. Query retry callback이
same gateway call을 다시 실행해 transport attempts를 배가하지 않는다.
manual UI retry는 새 logical query execution이다. keyed command의 ambiguous outcome을
query retry처럼 재실행하지 않는다.
### 6. Cache admission
query cache에 admission 가능한 값:
- VD-24 mapper가 만든 immutable plain application read model
- exact result contract/version
- current scope/runtime generation
- item/estimated-byte ceiling 안
- complete result 또는 operation이 허용한 typed partial result
금지:
- raw JSON/GraphQL envelope
- generated protobuf message/client
- Response/ReadableStream
- auth/CSRF/idempotency/cursor/validator/trace metadata
- thrown Error/AppFailure detail payload
- function/class/domain service/native object
admission 순서:
```text
transport success
-> schema
-> mapper
-> result budget
-> scope/generation fence
-> Query commit
```
어느 단계든 실패하면 cache write 0회다.
### 7. Result size
`maxEstimatedResultBytes`는 memory reservation이 아니라 hard admission guard다.
- mapper output plain data를 bounded estimator로 측정
- string UTF-8 bytes, key overhead, array/object node count를 보수적으로 합산
- cycle/class/native/accessor는 측정 전에 거절
- 측정 자체가 deadline/node ceiling을 넘으면 admission 거절
- result cap을 넘겨도 transport success를 unbounded UI state로 반환하지 않고
`RESULT_LIMIT_EXCEEDED` 또는 server pagination requirement로 닫음
large binary/collection은 streaming/file 또는 cursor page capability로 이동한다.
### 8. Query lifecycle
```text
IDLE
-> LOADING
-> SUCCESS | EMPTY | TERMINAL_ERROR
SUCCESS | EMPTY
-> REFRESHING
-> SUCCESS | EMPTY
-> STALE_DEGRADED
-> TERMINAL/REAUTH when policy forbids stale visibility
```
current AsyncState의 base와 overlay 구분을 유지한다.
failure policy:
| failure | default |
| --- | --- |
| transient network/5xx refresh failure | valid previous data + stale-degraded |
| caller/navigation abort | terminal error로 표시하지 않음 |
| auth required | sensitive/account query는 stale 숨김, reauth |
| forbidden/account switch | current scope data 즉시 숨김/clear |
| schema/mapper/contract mismatch | cache write 금지, default stale 숨김 또는 explicit safe-static exception |
| rate limit | previous data 정책 + retry-after UX |
| not found | feature policy에 따라 empty/remove/tombstone |
query profile이 sensitive stale data를 계속 보여 주는 결정을 global fallback으로
상속하지 않는다.
### 9. Freshness와 refetch
`staleTime`은 business correctness/authorization TTL이 아니다.
- focus/reconnect/mount refetch는 profile별
- simultaneous trigger는 one in-flight query로 coalesce
- minimum refetch interval과 deadline 적용
- visibility offline state는 hint이며 server revision을 대체하지 않음
- response age/cache-control을 staleTime으로 자동 변환하지 않음
- backend push/invalidation은 stale hint이며 authoritative refetch를 시작
freshness-sensitive command/read-after-write는 mutation receipt/revision 또는
authoritative refetch contract를 사용한다.
### 10. Conditional revalidation
REST app-managed ETag profile만 internal validator metadata를 사용할 수 있다.
```text
ValidatorBinding
query definition/fingerprint
scope fingerprint
runtime generation
representation version
opaque validator
```
- raw validator는 query value/key/diagnostics에 넣지 않음
- 304는 exact binding + existing mapped cache value가 있을 때만 fresh transition
- 304 freshness transition은 같은 query-entry cache revision에 CAS가 성공할 때만
commit하며, concurrent 200/removal 뒤의 late 304를 폐기
- value가 없거나 wrong generation이면 304 success로 만들지 않음
- validator mismatch/full 200은 normal schema/mapper/admission을 다시 수행하고
mapped value commit과 validator install/update를 하나의 entry transaction으로
취급
- Query removal/GC, scope/logout/account switch, release/contract/schema/mapper
epoch 변경과 incompatible cache clear에서 validator sidecar도 함께 폐기
- ordinary invalidation 때 validator를 conditional refetch까지 보존할지 즉시
폐기할지는 profile이 고정하며 query entry와 독립적으로 남기지 않음
GraphQL/gRPC metadata를 arbitrary ETag로 해석하지 않는다. application revision
field를 schema/mapper가 명시적으로 제공한 경우 별도 revalidation policy가
사용한다.
### 11. Cursor pagination
```text
PaginationProfile
CURSOR_SINGLE_PAGE
CURSOR_INFINITE
OFFSET_STABLE
```
cursor page:
```text
CursorPage<T>
items
nextCursor | null
hasMore
snapshotToken | null
```
page invariant:
- `hasMore === (nextCursor !== null)`을 codec에서 강제한다.
- page item count는 requested/implementation ceiling 이하다.
- chain 안의 snapshot token은 provider profile이 허용한 null/동일 값만 사용한다.
- `hasMore=true`인 empty/non-progress page는 explicit sparse-page profile이 없으면
contract failure다.
`CURSOR_INFINITE` root key:
- filters/sort/page size semantics
- scope
- page definition version
- cursor 제외
`CURSOR_INFINITE` page parameter:
- adapter-private/opaque bounded cursor
- previous page/snapshot binding
- raw cursor를 diagnostics/URL state/persistence에 임의 저장하지 않음
`CURSOR_SINGLE_PAGE`:
- first page의 null marker 또는 current cursor의 runtime-scoped non-reversible
identity token을 `canonicalSemanticInput`에 포함한다.
- raw cursor는 bound executor closure에만 두며 query key/value/diagnostics에 넣지
않는다.
- runtime-private exact equality guard/token binding이 실패하면 single-page cache
admission을 끄고 closed failure로 끝낸다.
- runtime-scoped token을 쓰는 `CURSOR_SINGLE_PAGE``MEMORY_ONLY`다.
infinite policy:
- max pages
- max total items
- max estimated bytes
- repeated cursor/non-progress/loop detection
- page eviction direction
- refresh strategy: first page only, visible window 또는 complete bounded chain
- item stable identity/duplicate/revision conflict policy
- snapshot changed 시 old/new page를 섞지 않고 restart
pagination persistence는 기본 disabled다. `CURSOR_SINGLE_PAGE`를 durable하게
만들려면 stable partition-bound keyed codec/key lifecycle을 별도 ADR로 승인해야
하며 runtime token을 persistence key로 재사용하지 않는다. `CURSOR_INFINITE`
persistence를 선택하려면 VD-13 profile이 cursor와 snapshot의
classification/expiry, maximum persisted pages/bytes, restored `pageParams` 사용
여부를 명시적으로 승인해야 한다. raw sensitive cursor 또는 만료 후 page
parameter를 IndexedDB에 저장하지 않는다.
offset pagination은 insert/delete drift를 허용하는 dataset에서 사용하지 않는다.
### 12. Mutation Definition
```text
MutationDefinition<Input, Value>
definitionId
operationId
inputCodec
logicalKeyCodec
commandIdentityTokenCodecId
concurrencyPolicy
duplicatePolicy
optimisticPolicyId | null
invalidationTopicRefs[] = { topicId, topicVersion }
seedPolicyId | null
conflictPolicyId
effectCertaintyPolicy
owner
```
presentation:
```text
useApplicationMutation(boundMutation)
```
caller는 raw optimistic query key/update function과 invalidation topic을 조립하지
않는다.
mutation topic ref는 unique 0..16개이고 모두 VD-13 global topic registry의 exact
version으로 resolve돼야 한다. 한 ref가 가리키는 bounded namespace set만
invalidate하며 caller가 runtime에 topic을 추가하지 못한다.
### 13. Mutation concurrency
```text
ConcurrencyPolicy
PARALLEL
SERIAL_BY_LOGICAL_KEY
SUPERSEDE_PENDING_READ_BY_LOGICAL_KEY
REJECT_WHILE_ACTIVE_BY_LOGICAL_KEY
DuplicatePolicy
JOIN_IDENTICAL
REJECT_DUPLICATE
ALLOW_INDEPENDENT
```
- logical key는 validated input의 approved opaque identity
- command identity token은 operation/definition version, current scope
partition과 **전체 validated semantic input**을
`RuntimeIdentityTokenCodecV1`으로 intern해 만든다. UI transient field와
transport bytes/idempotency key는 canonical equality source에 포함하지 않는다.
- logical key는 serialization/conflict group이고 exact canonical equality +
identity token은 동일 command 판별 값이다. 두 값을 서로 대체하지 않는다.
- `JOIN_IDENTICAL`은 exact equality guard도 통과한 같은 identity token의 기존
in-flight Promise와 terminal result를 공유하며 transport/optimistic layer를
추가하지 않는다.
- `REJECT_DUPLICATE`는 exact-identical token이 active이면 fetch 0회와 closed
`DUPLICATE_IN_FLIGHT`를 반환한다.
- `ALLOW_INDEPENDENT`는 같은 exact identity도 독립 command로 실행한다. backend
replay/idempotency와 UX가 이를 명시적으로 허용한 operation에만 등록한다.
- distinct input을 같은 Promise에 join하지 않음
- same hook, two hooks, two routes의 coordinator가 동일 policy를 사용
- coordinator는 hook-local singleton이 아니라 runtime/scope 수명의 registry-owned
service이며 scope generation 전환에서 신규 admission을 닫고 late commit을 fence
- `SUPERSEDE`는 이미 server로 보낸 non-replayable command를 cancel/rollback했다고
가정하지 않음
- local serialization은 server idempotency/concurrency authority가 아님
- scope/generation change는 pending result commit을 fence
### 14. Optimistic patch
snapshot 전체 restore만 사용하지 않는다.
```text
OptimisticLayer
mutationId
logicalKey
commandIdentityToken
baseCacheRevision
expectedEntityRevision | null
patch
inversePatch
affectedQueryDefinitions
```
선택 가능한 구현:
- cache entry revision CAS
- ordered optimistic layer log
- operation-specific compare-and-apply patch
공통:
1. affected exact queries cancel
2. bounded current revision/value 확인
3. registered pure patch 적용
4. other mutation layer와 ordering 보존
5. failure에서 자기 layer만 제거/역적용
6. success result/revision과 reconcile
7. invalidation/refetch
old whole snapshot을 복원해 다른 mutation commit을 덮지 않는다.
optimistic update를 하지 않는 조건:
- snapshot/patch/result byte ceiling 초과
- cache entry missing/wrong revision
- non-deterministic merge
- high-conflict command
- scope/generation transition
- unknown effect certainty
그 경우 pending UX만 보여 주고 server response/refetch를 기다린다.
### 15. Effect certainty와 conflict
```text
MutationEffect
NOT_APPLIED
COMMITTED
UNKNOWN
```
- timeout/cancel/network failure가 `NOT_APPLIED`를 자동 의미하지 않음
- keyed backend status/receipt가 있어야 ambiguous command reconcile 가능
- `UNKNOWN`은 새 idempotency key로 자동 retry 금지
- 409 business conflict, REST 412, GraphQL safe conflict code, gRPC `ABORTED`
common conflict surface로 mapping하되 의미 차이는 feature policy가 소유
- server revision/merge/overwrite decision은 cache가 아니라 use case 소유
### 16. Mutation success, seed와 invalidation
server commit 뒤:
- exact returned result를 schema/mapper/fence/budget 검증
- registered detail seed policy가 있으면 exact current entity/revision만 write
- list/aggregate는 default invalidate
- list patch는 deterministic sort/filter/membership policy가 있을 때만
- invalidation topic은 query key가 아닌 opaque registry identity
- current mutation lease 동안 remote hints coalesce
local invalidation failure는 committed command를 failure로 바꾸지 않는다.
cache health를 degraded로 기록하고 bounded authoritative refetch를 예약한다.
### 17. Cross-context
현재 cross-tab wire는 invalidate-only다. 유지한다.
- query data/key/input/cursor/validator를 broadcast하지 않음
- remote event는 authority가 아니라 stale hint
- account/scope/version 검증은 VD-13
- mutation ordering/optimistic layer를 tab 간 복제하지 않음
- sequence gap은 모든 registered namespace를 stale 처리하되 active query만
bounded refetch한다. inactive query는 다음 mount/focus에서 revalidate하고,
persistence가 선택된 경우 durable ledger refresh는 VD-13 절차를 따른다.
### 18. GraphQL과 normalized cache
persisted GraphQL query도 mapped operation result를 TanStack에 cache한다.
- query key는 semantic application input
- GraphQL document/hash는 key/value에 없음
- GraphQL SDK cache는 `no-cache`/disabled
- partial data default reject
- approved partial result는 completeness metadata를 application contract가 소유
- missing/error field를 previous complete value와 자동 merge하지 않음
normalized entity cache가 필요하면:
- bounded context 하나가 TanStack operation cache를 대체
- key fields/typename, eviction, pagination merge, optimistic layer, logout/scope,
persistence와 removal을 별도 ADR
- dual write/read 금지
현재 `NOT_SELECTED`다.
### 19. Connect/gRPC-Web server stream
ordinary Query는 terminal operation 결과를 전제로 한다.
- unary는 normal query 가능
- finite server stream을 complete aggregate로 쓸 경우 staging buffer에 bounded
accumulate하고 valid Connect EndStream 또는 gRPC-Web terminal status,
schema/mapper/fence 뒤 atomic cache commit
- long-running stream은 `ServerStreamPort`와 registered reducer/invalidation owner
- frame마다 query cache를 append하여 unbounded event history를 만들지 않음
- stream reconnect를 Query retry로 하지 않음
- gap/overflow는 current snapshot 폐기 또는 authoritative query refetch
### 20. Persistence, SSR와 offline
- memory cache runtime은 `COMPOSED`
- IndexedDB query persister reference는 VD-13 기준
`DESIGNED_NOT_IMPLEMENTED`
- product persistence는 `NOT_SELECTED`
- SSR hydration은 `NOT_SELECTED`
- offline mutation queue는 `NOT_SELECTED`
VD-25 profile은 persistence를 직접 켜지 않는다. joined VD-13
scope/persistence profile, reference runtime과 제품 allowlist/retention/scope가
모두 구현·선택된 query만 IndexedDB persistence를 사용할 수 있다.
server-state policy를 이유로 Web Storage에 query payload를 넣지 않는다.
### 21. Security와 privacy
- authorization result를 staleTime/cache hit으로 대체하지 않음
- account/logout transition에서 sensitive data를 즉시 fence/hide
- query key에 raw PII/account/resource ID/URL/document/message 금지
- cache value에 credential/header/validator/trace/error raw data 금지
- optimistic layer에도 command token/raw body를 저장하지 않음
- command identity token/logical key를 diagnostics, cross-context wire나 persistence에
넣지 않음
- developer tools/diagnostics production exposure policy
- cache poisoning 방지를 위해 schema/mapper/result contract와 generation 검증
- cross-context event에 data 없음
### 22. Observability
허용:
- query/mutation definition/profile ID
- hit/miss/stale/fresh/refresh/evict outcome
- result item/estimated-byte/page bucket
- runtime identity intern entry/canonical-byte/active-lease bucket
- focus/reconnect/invalidation refetch reason
- mutation concurrency/duplicate/optimistic/rollback/conflict/effect bucket
- invalidation/seed/degraded recovery outcome
- scope/profile version의 low-cardinality bucket
금지:
- query key/input/value
- command identity token/logical key
- identity intern canonical bytes/token actual value
- cursor/snapshot/validator/revision actual value
- resource/account/tenant ID
- GraphQL/protobuf/REST DTO
- optimistic patch/snapshot
### 23. Testing
query definition/key:
- definition/input/key/executor type/runtime binding
- VD-13↔VD-25 topic set/version exact join과 bounded many-to-many fan-out
- runtime identity token same-input stability, random-token collision regeneration과
bounded failure의 cache/join 0회
- intern entry/total-byte ceiling, active non-eviction, Query GC/mutation terminal
lease release와 runtime-close leak 0
- undefined/NaN/Date/class/accessor/cycle/sparse/oversize collision fixture
- same semantic input stable identity
- protocol wire identity 변화가 key에 들어가지 않음
- per-profile stale/gc/refetch/retry owner
cache admission:
- mapped immutable plain value only
- item/estimated byte cap
- schema/mapper/generation failure write 0
- auth/contract failure stale visibility
mutation:
- same semantic command identity, same logical key의 distinct input
- same hook/two hooks/two routes
- runtime/scope coordinator의 logical-key serial/parallel과
join/reject/independent duplicate 결과
- out-of-order success/failure
- optimistic layer/CAS rollback without overwriting other commit
- effect `NOT_APPLIED/COMMITTED/UNKNOWN`
- server response detail seed + list invalidate
- invalidation failure after commit
- account switch during pending command
pagination:
- null/repeated/cyclic cursor
- single-page cursor identity-token collision/isolation과 memory-only enforcement
- `hasMore`/`nextCursor` 불일치와 snapshot drift
- snapshot change
- max page/item/byte
- page eviction/refetch
- duplicate identity/revision policy
- cancellation and late page
integration:
- REST/GraphQL/gRPC unary → schema → mapper → cache → UI
- focus/reconnect/offline/stale-degraded
- conditional 304 exact binding
- cross-tab invalidation/mutation lease
- logout/account/release generation
- finite stream atomic commit and overflow
### 24. Rollout
1. VD-13 strict key codec/scope snapshot interface를 확정한다.
2. current `useApplicationQuery({queryKey, execute})` 뒤에 bound definition adapter를
추가한다.
3. reference queries를 shadow key/policy로 비교하되 secondary cache write 금지.
4. per-profile policy/result ceiling을 read query에 canary한다.
5. typed mutation definition과 input-aware coordinator를 도입한다.
6. optimistic layer/CAS를 low-conflict command에만 canary한다.
7. cursor page reference vertical을 구현한다.
8. arbitrary key/executor와 raw optimistic callback API를 제거한다.
9. account/generation browser evidence와 runbook drill 뒤 traffic을 올린다.
rollback:
- 신규 query/mutation admission/optimistic patch를 kill switch로 닫음
- current scope queries cancel
- unsafe/incompatible memory cache clear
- pending command effect certainty reconcile
- current basic facade 또는 no-optimistic authoritative refetch로 downgrade
- schema/mapper/query definition/backend artifact를 coherent set으로 복구
## 규범 기준
- [TanStack Query v5 Important Defaults](https://tanstack.com/query/v5/docs/framework/react/guides/important-defaults)
- [TanStack Query v5 Query Cancellation](https://tanstack.com/query/v5/docs/framework/react/guides/query-cancellation)
## 완료 기준
- caller가 arbitrary key/executor/TanStack option을 조합할 수 없다.
- strict key codec과 VD-13 scope projection이 모든 query에 적용된다.
- VD-13 normative key layout과 scope/persistence profile을 재정의하지 않고 exact
join한다.
- mapped/bounded/current-generation value만 cache에 들어간다.
- transport와 Query retry owner가 중복되지 않는다.
- cursor page가 next/snapshot/loop/page/item/byte ceiling을 갖는다.
- runtime-private exact equality guard까지 통과한 command identity만 declared
join되고 distinct input이 같은 Promise로 잘못 join되지 않는다.
- concurrent optimistic rollback이 다른 committed update를 덮지 않는다.
- effect certainty, conflict, seed와 invalidation owner가 operation별로 닫힌다.
- GraphQL normalized dual cache와 unbounded stream cache가 없다.
- scope/logout/provider fault와 rollback/removal evidence가 통과한다.
@@ -0,0 +1,653 @@
# VD-26: Persisted GraphQL operation
- 상태: Accepted design — reference runtime implementation pending
- 결정일: 2026-07-28
- provider-neutral GraphQL reference adapter:
`DESIGNED_NOT_IMPLEMENTED`
- product GraphQL composition: `NOT_SELECTED`
- batching/subscription/`@defer`/`@stream`: `NOT_SELECTED`
- normalized GraphQL cache: `NOT_SELECTED`
- 관련 결정: VD-13, VD-23, VD-24, VD-25, VD-28
- 상세 설계:
[API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
## 배경
현재 source, package direct dependency, config와 test에는 GraphQL runtime,
operation document/codegen, persisted manifest나 endpoint provider가 없다.
lockfile의 transitive `graphql` package는 MSW 개발 의존성일 뿐 capability
구현 증거가 아니다.
GraphQL은 임의 query string을 보내는 범용 API escape hatch로 도입하지 않는다.
제품이 여러 backend aggregate를 화면별 shape로 조회해야 하고 schema/router,
field authorization, persisted allowlist와 cost budget을 운영할 수 있을 때만
bounded-context operation family로 선택한다.
## 결정
### 1. Production GraphQL은 persisted operation only다
```text
semantic application query/command
-> registered GraphqlOperationDefinition
-> fixed endpoint
-> persisted operation ID/hash
-> validated variables
-> bounded GraphQL response decoder
-> operation data schema
-> boundary mapper
-> application projection
-> TanStack Query or command result
```
production runtime은 다음을 받지 않는다.
- arbitrary GraphQL document
- caller-provided operation name/hash
- arbitrary endpoint/header
- generated SDK selection set builder
- field/fragment string
### 2. Operation artifact
```text
PersistedGraphqlOperationV1
protocol = PERSISTED_GRAPHQL_V1
semanticOperationId
operationName
operationKind = QUERY | MUTATION
canonicalDocumentSha256
persistedOperationId
schemaArtifactId
schemaDigest
variablesSchemaId
dataSchemaId
mapperId
errorProfileId
partialDataPolicy
endpointId
graphqlHttpProfileRevision
persistedEnvelopeProfileId
responseStatusMediaProfileId
authProfileId
csrfProfileId
replayPolicy
deadlineProfileId
retryProfileId
serverStateProfileId | null
maxVariablesBytes
maxResponseBytes
maxErrorCount
maxCost
maxDepth
maxAliases
owner
```
canonical document는 build artifact이고 runtime string이 아니다. stable operation
ID와 hash는 schema/operation manifest에 binding한다.
manifest 생성:
```text
authenticated immutable schema
-> named operation sources
-> parse/validate against schema
-> canonical document
-> operation hash/ID
-> variables/result type generation
-> runtime codec manifest
-> mapper/query profile binding
-> persisted operation manifest
```
### 3. Schema와 codegen
- schema source URL에서 normal build마다 latest를 받지 않는다.
- authenticated explicit update workflow가 immutable artifact와 provenance를
저장한다.
- schema/source/operation manifest digest를 release contract set에 binding한다.
- anonymous operation, duplicate operation name와 invalid fragment를 거절한다.
- generator, plugins, Node와 runtime version을 pin한다.
- clean checkout regenerate diff가 0이어야 한다.
- schema breaking diff, operation validation, deprecated field budget와 generated
output digest를 CI gate로 둔다.
- generated type은 adapter-private DTO다.
- generated TypeScript type만 믿지 않고 variables/data runtime codec과 mapper를
유지한다.
- schema introspection을 production에서 끄는 결정은 server 보안 옵션일 뿐
authorization/cost control을 대체하지 않는다.
### 4. Endpoint와 HTTP profile
```text
GraphqlProviderProfile
endpointId
fixedHttpsUrl
graphqlHttpProfileRevision
persistedEnvelopeProfileId
methodPolicy
credentialsMode
corsProfile
referrerPolicy
redirect = ERROR
mediaProfile
```
GraphQL-over-HTTP draft를 움직이는 implicit `latest`로 구현하지 않는다. selected
revision의 request/response/status 규칙과 provider의 persisted-operation
extension을 immutable profile/fixture에 고정한다. persisted ID/hash-only envelope는
표준 request의 required `query` field를 생략하는 provider extension일 수 있으므로
generic GraphQL-over-HTTP compliance로 가장하지 않는다.
private query와 mutation은 POST가 기본이다.
GET은 다음을 모두 만족하는 public read profile에서만 선택한다.
- persisted ID/hash와 non-sensitive bounded variables
- URL byte ceiling
- no credential/private representation 또는 명시된 safe cache contract
- exact cache key/Vary/CDN policy
- mutation 아님
raw document와 sensitive variables를 URL에 넣지 않는다.
request `Content-Type: application/json`
`Accept: application/graphql-response+json`을 기본 exact profile로 둔다.
`application/json` response 지원은 legacy provider profile로 분리한다. caller가
`fetch` option, headers와 credentials를 override하지 않는다.
status/media matrix:
- final URL/origin과 media/body ceiling을 먼저 확인한다.
- `application/graphql-response+json`은 profile이 허용한 HTTP status 전체에서
bounded GraphQL envelope를 먼저 decode하고 selected revision의 status/body
불변조건을 교차 검증한다.
- non-null `data`가 있는 response는 selected revision이 요구하는 2xx여야 한다.
no-data/error와 partial response의 status는 pinned revision/provider fixture와
exact match해야 한다.
- legacy `application/json`은 허용된 2xx body만 GraphQL envelope로 신뢰한다.
non-2xx body는 intermediary일 수 있으므로 GraphQL error/extensions로
해석하지 않고 bounded generic HTTP failure로 닫는다.
### 5. Request envelope
wire shape는 provider의 persisted-envelope extension이 versioned codec으로
고정한다. 최소 의미:
```text
protocol
persisted operation ID
canonical document hash
operation name
validated variables
client contract manifest version
```
full document는 포함하지 않는다.
provider가 ID/hash-only envelope를 지원하지 않으면 이 capability를 그 endpoint에
compose하지 않는다. production에서 표준 `query` field를 채우기 위해 full
document fallback을 보내는 것으로 우회하지 않는다.
variables:
- request runtime schema의 parsed output만 사용
- unknown field 거절
- depth/node/string/list/encoded byte ceiling
- File/Blob/stream/native/generated class 금지
- ID/decimal/int64/time semantics는 VD-24
- secret/credential를 variable로 전달하는 operation 금지
### 6. APQ와 manifest miss
runtime Automatic Persisted Query negotiation을 production default로 사용하지
않는다.
```text
persisted miss/hash mismatch
-> body/reader cancel
-> PERSISTED_OPERATION_MISMATCH
-> operation traffic disable or coherent manifest recovery
```
hash miss 뒤 full document를 자동 전송하면 server allowlist와 cost governance를
우회할 수 있다. trusted development profile에서만 explicit opt-in 가능하며
production promotion 증거로 사용하지 않는다.
frontend manifest와 router manifest의 N/N-1 rollout을 먼저 증명한다.
### 7. Total deadline, cancellation과 retry
VD-23 common logical deadline을 사용한다.
- credential/CSRF attach
- network attempts/backoff
- response read/JSON parse
- GraphQL envelope/data/error validation
- mapper
Query retry와 GraphQL transport retry를 중복하지 않는다.
retry:
- idempotent query의 selected network/408/429/502/503/504
- keyed mutation은 backend idempotency evidence가 있을 때만
- GraphQL validation, persisted miss, cost/depth, schema/data/error mismatch는
retry하지 않음
- HTTP 200 GraphQL business error를 transient network failure로 자동 retry하지 않음
- UNAUTHENTICATED recovery는 safe query/keyed mutation만 same logical binding으로
한 번
AbortSignal은 fetch와 body/incremental reader를 cancel한다. local cancel이 mutation
미적용을 의미하지 않으며 ambiguous effect는 status/reconcile contract로 닫는다.
### 8. Response decoder
```text
HTTP response
-> final URL/origin/media/header
-> present/valid Content-Length advisory preflight
-> bounded stream reader
-> decoded byte/depth/node/string/list cap
-> GraphQL response envelope
-> pinned HTTP status/body matrix
-> data/errors state machine
-> operation data codec
-> mapper
```
top-level:
```text
GraphqlResponse
data?
errors?
extensions?
```
unknown top-level/extension behavior는 provider profile과 VD-24 unknown-field
정책을 따른다. response body, error message, path와 extensions를 log에 복사하지
않는다.
### 9. Data/error state machine
다음 순서로 배타적으로 처리한다.
1. network/final URL/unsupported media/body limit 실패 또는 legacy
`application/json` non-2xx:
transport 또는 media/limit failure, data/cache write 0.
2. `application/graphql-response+json`은 profile이 허용한 status 전체에서,
legacy `application/json`은 profile-admitted 2xx에서만 bounded parse한다.
top-level response shape 불일치는 `GRAPHQL_ENVELOPE_MISMATCH`.
3. `errors` key가 있으면 non-empty list여야 한다. `errors=[]`는 항상
`GRAPHQL_ENVELOPE_MISMATCH`다.
4. selected GraphQL-over-HTTP revision의 status/body matrix가 맞지 않으면
`GRAPHQL_HTTP_PROFILE_MISMATCH`다.
5. `data` key 존재 + non-null, `errors` 없음:
data codec → mapper → generation fence → success.
6. `data` 없음/null, non-empty `errors`:
safe error mapping; success/cache write 0.
7. non-null `data`와 non-empty `errors` 동시:
operation `partialDataPolicy` 적용.
8. `data` 없음/null이고 errors도 없음:
contract mismatch.
### 10. Error projection
GraphQL error는 untrusted다.
```text
GraphqlError
message
locations
path
extensions
```
application에 허용:
- operation error profile이 allowlist한 `extensions.code`
- bounded typed validation field issue
- effect certainty/conflict category
- bounded server request/trace ID projection
금지:
- raw `message`
- source location
- path actual value
- arbitrary extensions
- resolver/service/stack/database detail
error count, path segment/count/string와 extensions decoded byte cap을 적용한다.
unknown code는 generic closed failure다. backend `retryable` boolean은 retry
authority가 아니다.
common mapping 예:
| safe GraphQL category | AppFailure |
| --- | --- |
| unauthenticated | `AUTH_REQUIRED` |
| forbidden | `FORBIDDEN` |
| not found | `NOT_FOUND` 또는 existence-hiding policy |
| validation | `VALIDATION_REJECTED` |
| conflict/precondition | `CONFLICT` 또는 typed precondition |
| rate limited | `RATE_LIMITED` |
| internal/unavailable | `SERVER_FAILURE` |
| unknown | `UNKNOWN_CLIENT_FAILURE` 또는 contract failure |
### 11. Partial data
default:
```text
partialDataPolicy = REJECT
```
query에만 다음 explicit profile을 허용할 수 있다.
```text
ALLOW_TYPED_PARTIAL
requiredCompletePaths
optionalPartialPaths
errorCodeAllowlist
completenessSchemaId
staleVisibilityPolicy
```
조건:
- data codec이 missing/null path를 정확히 표현
- mapper가 completeness를 application result로 투영
- UI가 complete success와 partial-degraded를 구분
- partial value/result size ceiling
- authorization/error path를 숨기며 unsafe field를 사용하지 않음
- previous complete cache와 field 단위로 임의 merge하지 않음
mutation은 errors가 있으면 partial success data를 ordinary command success로
cache하지 않는다. backend가 effect certainty/receipt를 제공해야
`COMMITTED | NOT_APPLIED | UNKNOWN`을 판단한다. error가 있다는 이유만으로
optimistic layer 전체를 즉시 rollback해 다른 commit을 덮지 않는다.
### 12. Null bubbling
GraphQL nullability propagation은 application null 의미와 다르다.
- nullable field, error-caused null과 absent partial field를 data/error state
machine이 함께 해석
- generated type의 `T | null`만으로 cause를 추측하지 않음
- operation data codec/mapper가 approved partial path와 error code를 결합
- required root/aggregate null은 default failure
- unauthorized field null을 stale previous field로 자동 채우지 않음
### 13. Cache identity
VD-25 TanStack Query가 기본 sole owner다.
- query key는 semantic operation input + VD-13 scope
- persisted operation ID/hash/document를 key에 넣지 않음
- GraphQL data/envelope/generated type을 cache하지 않음
- mapped bounded application projection만 cache
- schema/mapper meaning change는 query/release epoch invalidation
- GraphQL client library cache는 disabled/`no-cache`
normalized cache가 필요하면 separate ADR:
- key fields/`__typename`
- fragment completeness
- pagination merge
- optimistic layers
- eviction/gc/logout/scope
- persistence/SSR
- TanStack replacement/removal
dual cache는 금지한다.
### 14. Batching
현재 `NOT_SELECTED`.
`@defer`/`@stream`은 한 GraphQL HTTP operation의 finite incremental response다.
장기 subscription이나 unsolicited realtime event가 아니며, reconnect/resume
owner를 realtime runtime에 넘기지 않는다.
선택 조건:
- 같은 endpoint/auth/scope
- query only
- same credentials/CSRF policy
- max operation count
- total variables/request bytes
- total cost/depth
- per-operation deadline/result/error/observation 보존
- one operation cancel/failure가 다른 operation semantics를 바꾸지 않음
금지:
- mutation 포함
- query+mutation mixed batch
- 서로 다른 account/session
- batching으로 idempotency/retry owner 합치기
- one HTTP result를 one query cache value로 저장
batch transport failure와 per-operation GraphQL failure를 분리한다.
### 15. Incremental `@defer`/`@stream`
현재 `NOT_SELECTED`.
선택 시 별도 profile:
- exact incremental-delivery draft/provider revision
- exact `Accept`, response `Content-Type`와 boundary/version parameter
- exact `multipart/mixed` media/boundary parser
- total bytes/parts/depth/patch count
- initial/subsequent/terminal payload discriminant와 completion grammar
- operation-owned ID/label/path allowlist와 path progression
- patch/data/items/errors/extensions runtime schema
- part별 및 cumulative error/extension count/byte ceiling
- duplicate/out-of-order/missing path
- terminal marker
- idle/total deadline
- backpressure/cancel/reader cleanup
- proxy/CDN buffering conformance
cache:
- staging projection에 immutable patch 적용
- terminal integrity/completeness 뒤 atomic commit
- 또는 UI가 explicit progressive state를 소유
- existing cached object를 in-place mutate하지 않음
- truncated stream을 complete success로 cache하지 않음
Chromium/Firefox/WebKit과 actual proxy 증거 없이는 traffic promotion 금지다.
exact protocol revision/profile이 없으면 registry composition 자체를 거절한다.
### 16. Subscription
GraphQL HTTP query adapter에 subscription을 넣지 않는다. 현재 `NOT_SELECTED`.
선택 시 transport-specific registered GraphQL subscription capability와
feature-owned `FeatureEventInput`이 필요하다. 범용 `RealtimePort`를 만들지
않는다.
```text
GraphqlSubscriptionCapability
subscribe(registered subscription, validated variables, signal)
-> AsyncIterable<Result<MappedEvent>>
-> unsubscribe()
```
선택된 WebSocket/SSE subprotocol adapter가 frame, media, auth, reconnect/resume를
소유하고 GraphQL event schema와 pure mapper를 통과한 event만
`FeatureEventInput` 또는 invalidation bridge로 전달한다. backend contract가
명시적으로 같은 의미를 채택하지 않는 한 GraphQL payload를
`REALTIME_EVENT_V1`로 강제하거나 다시 감싸지 않는다.
backend 계약:
- exact WebSocket/SSE protocol/version
- auth attach/refresh/revoke
- heartbeat/idle timeout
- reconnect/backoff
- sequence/duplicate/gap/resume cursor
- bounded queue/overflow
- logout/route unmount unsubscribe
event는 invalidation hint 또는 registered bounded reducer를 통해 server-state를
갱신한다. raw event history를 Query cache에 무한 적재하지 않는다.
### 17. Authorization, CSRF와 DoS
- BFF/router가 field/resource authorization을 매 request에 수행
- persisted allowlist는 authorization이 아님
- cookie mutation은 POST + exact Origin/Fetch Metadata + approved CSRF proof
- SameSite/custom header/preflight 단일 요소만 방어라고 주장하지 않음
- cross-origin credential wildcard 금지
- server에서 depth, aliases, fragments, variables/list/page/field cost, total
execution와 response bytes 강제
- frontend ceiling은 server DoS 방어를 대체하지 않음
- introspection off는 field authorization/cost control 대체 아님
- persisted operation manifest와 field authorization change를 coherent rollout
### 18. Backend/router 계약
provider가 제공:
- immutable schema artifact/provenance
- persisted operation registration/lookup
- exact operation hash/schema digest binding
- N/N-1 manifest window와 retirement
- cost/depth/alias/list/response budget enforcement
- stable safe error code vocabulary
- partial/null/effect certainty semantics
- idempotency/conflict/revision
- auth/CSRF/CORS
- request/trace projection
- kill switch와 per-operation traffic
frontend manifest echo만으로 등록/authorization을 승인하지 않는다. router가
server-owned manifest에서 operation binding을 재계산한다.
### 19. Observability
허용:
- semantic operation ID/persisted profile ID
- schema/manifest compatibility outcome
- full/partial/rejected/transport outcome
- safe GraphQL error category
- cost/depth/variables/response/error/part count bucket
- duration/deadline/retry/auth recovery bucket
- cache hit/stale/admission outcome
금지:
- document/hash actual value
- variables/data
- raw error message/path/extensions
- field/resolver name high-cardinality label
- account/resource/cursor/revision
server가 resolver-level telemetry를 소유한다. browser가 raw field trace를 수집하지
않는다.
### 20. Testing
build/contract:
- schema source provenance/digest
- schema lint/breaking/deprecation budget
- named operation validation
- canonical hash/manifest determinism
- clean codegen diff
- generated import boundary
- variables/data codec parity
- N/N-1 persisted manifest and retirement
runtime:
- unknown/hash mismatch, full-document fallback 0
- variables depth/node/string/list/byte cap
- GraphQL HTTP revision/media/status-body matrix와 legacy intermediary body
- HTTP/media/body cap
- all data/errors state branches
- empty errors와 null/absent data matrix
- error count/path/extensions cap/redaction
- null bubbling
- partial allowed/rejected/completeness
- timeout/cancel/retry/auth recovery
- mutation effect certainty/idempotency
- scope/generation late result
- cache admission/write 0 on failure
optional:
- batching mixed/mutation/limit rejection
- multipart boundary/truncated/duplicate/out-of-order/terminal
- subscription ordering/reconnect/resume/logout
provider/browser:
- actual BFF/router allowlist/cost/auth/CSRF/CORS
- manifest rollout/retirement
- proxy/CDN media/body behavior
- Chromium/Firefox/WebKit for selected incremental/subscription capability
### 21. Rollout
1. product owner가 GraphQL이 필요한 bounded operation family를 승인한다.
2. schema/router/manifest owner와 endpoint/auth/cost/error contract를 확정한다.
3. provider-neutral codec/adapter/fake를 구현한다.
4. generated source, boundary mapper와 TanStack query definition을 연결한다.
5. REST current read와 GraphQL shadow read를 비교하되 shadow result는 UI/cache에
쓰지 않는다.
6. actual router conformance를 통과한다.
7. `AVAILABLE_NOT_COMPOSED`에서 product composition behind
`TrafficAdmission=DISABLED`로 이동한다.
8. read-only internal canary 뒤 selected operation만 traffic을 올린다.
9. mutation은 idempotency/effect certainty provider evidence 뒤 별도 canary한다.
10. batching/incremental/subscription은 계속 `NOT_SELECTED` 또는 독립 gate다.
rollback:
- 신규 GraphQL operation admission 중지
- in-flight query cancel, mutation effect reconcile
- current scope GraphQL-mapped query cache clear
- coherent frontend/schema/manifest/router rollback
- approved REST read fallback이 있으면 새 logical read로 전환
- arbitrary/full-document fallback 금지
### 22. Removal
1. operation traffic/registration retirement 시작
2. query/subscription cancel과 mutation reconcile
3. Query cache/invalidation listener clear
4. operation/codec/mapper/query profile 제거
5. generated files, GraphQL runtime/codegen dependencies 제거
6. schema/operation manifest/config/endpoint 제거
7. router persisted entries는 N/N-1 window 뒤 제거
8. production module/dependency/SBOM/removal test 통과
## 규범 기준
- [GraphQL Specification, September 2025](https://spec.graphql.org/September2025/)
- [GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/)
GraphQL-over-HTTP 문서는 현재 draft이므로 링크의 moving text를 production
profile로 쓰지 않고 위에서 결정한 revision/provider fixture로 고정한다.
## 완료 기준
- production에서 registered persisted operation 외 document가 전송되지 않는다.
- schema/operation/codegen/runtime codec/mapper manifest가 digest로 연결된다.
- variables/response/errors가 bounded runtime validation을 거친다.
- persisted envelope extension과 GraphQL-over-HTTP revision/media/status matrix가
actual router profile에 고정된다.
- data/errors/partial/null/effect certainty 상태가 배타적으로 닫힌다.
- auth/CSRF/cost/field authorization과 manifest N/N-1을 actual router에서 증명한다.
- GraphQL SDK normalized cache와 TanStack dual cache가 없다.
- query key/cache에 document/hash/envelope/generated DTO가 없다.
- batching/incremental/subscription은 선택 전 설치되지 않는다.
- kill switch, rollback과 dependency/manifest removal drill이 통과한다.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,640 @@
# VD-28: Realtime events, Web Push와 bounded polling
- 상태: Accepted — reference runtime available, product implementation pending
- 결정일: 2026-07-28
- 관련 결정: VD-10, VD-13, VD-23, VD-24, VD-25, VD-26, VD-27, VD-29
- 상세 설계:
`docs/architecture/realtime-events-web-push-and-bounded-polling.md`
- 현재 product selection: `NOT_SELECTED`
- common runtime delta: `AVAILABLE_NOT_COMPOSED`
- 재검토:
첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider
protocol이 바뀔 때
## 배경
현재 optional recipe catalog는 realtime capability에
`referenceRuntime.status=AVAILABLE_NOT_COMPOSED`를 기록한다. 공통 event authority,
bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE,
bounded Polling, closed WebSocket protocol과 Web Push window/worker adapter는
deterministic test와 함께 존재하지만 production entry에서는 제외된다. generic
mega `RealtimePort`, 제품 event schema, 실제 endpoint, backend replay/provider
contract와 composition은 선택하지 않았다.
추가 설계 범위에는 성격이 다른 네 capability가 있다.
- SSE: active document의 server-to-client event stream
- WebSocket: active document의 duplex application protocol
- Web Push: inactive browser에도 도착할 수 있는 Service Worker 기반 notification
- bounded polling: 기존 HTTP/query operation의 제한된 scheduling policy
이를 “realtime transport” 하나로 합치면 다음 문제가 생긴다.
- Web Push의 permission, push service와 worker lifecycle이 connection 상태에 숨는다.
- Polling을 무한 timer나 transport downgrade로 오해한다.
- WebSocket이 필요하지 않은 server notification까지 duplex protocol이 된다.
- connection open, event delivery, application effect와 server 최신성을 같은 성공으로
표시한다.
- auth refresh, reconnect, HTTP retry와 Query retry가 중첩된다.
- gap, cursor expiry와 browser restore 뒤 authoritative resync owner가 사라진다.
- push subscription endpoint/key나 cursor가 일반 application state와 telemetry에
노출될 수 있다.
기존 recipe의 generic `channel: string`, `sequence: number`, 고정
`resumeToken`, callback과 `heartbeat()`는 선택 시 복사해 좁힐 출발점이다.
scope/epoch, closed event type, byte/queue limit, gap/reset, 진행되는 cursor,
generation과 effect certainty가 없어 production wire authority로 사용할 수 없다.
## 현재 상태
| 항목 | 상태 | 설명 |
| --- | --- | --- |
| optional realtime catalog/recipe | `RECIPE_AVAILABLE` / product `NOT_SELECTED` | uncomposed reference runtime과 conformance script가 있음 |
| common event/recovery/reconnect runtime | `AVAILABLE_NOT_COMPOSED` | scope/gap/barrier authority, finite reconnect owner와 exact close classification test가 있음 |
| live↔Poll handoff coordinator | `AVAILABLE_NOT_COMPOSED` | monotonic generation, one effect writer와 bounded checkpoint/quiescence test가 있음 |
| SSE runtime | `AVAILABLE_NOT_COMPOSED` | fetch-stream parser/adapter/reconnect test 있음; local server/browser evidence pending |
| WebSocket runtime | `AVAILABLE_NOT_COMPOSED` | exact handshake/protocol/queue/recovery test 있음; load/browser evidence pending |
| bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | finite single-flight visible/online lease와 deterministic budget test 있음 |
| Web Push window/worker runtime | `AVAILABLE_NOT_COMPOSED` | subscription, registration/revoke, durable fence, strict inbound worker factory가 있음; provider/browser evidence pending |
| exactly-once/global ordering | `PLATFORM_LIMITED` | 공통 browser delivery 목표로 보장하지 않음 |
| always-on background connection/polling | `PLATFORM_LIMITED` | hidden/frozen/terminated document에서 보장하지 않음 |
| timely cross-browser Web Push | `PLATFORM_LIMITED` | provider/browser/OS가 즉시 delivery를 보장하지 않음 |
reference source는 `AVAILABLE_NOT_COMPOSED`까지 승격됐다. 그러나 이 ADR과
deterministic test만으로 `COMPOSED` 또는 `PRODUCTION_READY`로 올리지 않는다.
제품 endpoint/registry와 backend/provider/target-browser evidence가 생긴 뒤
선택 capability만 별도 승격한다.
## 결정
### 1. 네 capability를 분리한다
다음 의미를 고정한다.
| capability | 선택 의미 | 기본 fallback |
| --- | --- | --- |
| SSE | foreground one-way ordered hint stream | bounded polling 또는 stale UI |
| WebSocket | foreground duplex interaction protocol | 의미가 축소되지 않으면 bounded polling, 아니면 disabled/stale UI |
| Web Push | background user-visible notification hint | foreground inbox/focus refresh |
| bounded polling | finite visible HTTP scheduling | manual refresh/explicit stale UI |
Web Push는 SSE/WebSocket의 fallback이 아니라 보완 capability다. Polling은
WebSocket duplex 기능을 대신할 수 없다. SSE↔WebSocket 자동 downgrade도 하지
않는다. 같은 사용자 의미를 보존하는 fallback만 registry에 명시한다.
추가 transport를 선택하기 전 기존 TanStack Query의 focus/reconnect refetch와
manual refresh가 측정된 freshness 요구를 만족하는지 먼저 확인한다.
Connect/gRPC-Web server stream은 VD-29/VD-27의 operation-bound API protocol이고 GraphQL
subscription은 현재 `NOT_SELECTED`다. GraphQL `@defer`/`@stream`은 finite
incremental HTTP response이지 realtime subscription이 아니다. RPC adapter가
protocol-specific terminal proof와 protobuf decode/schema/mapper를 끝낸
runtime-wide notification branch에서만 공통
scope/gap/resync coordinator를 재사용한다. frame/media/trailer, reconnect와
operation deadline owner를 SSE/WebSocket adapter로 합치거나 protobuf message를
`REALTIME_EVENT_V1` JSON으로 다시 감싸지 않는다. Polling의 개별 attempt는 VD-23의
terminal·replay-safe REST `QUERY` execution contract를 재사용하되 transport/Query
retry는 끄고, 이 결정은 attempt 사이 bounded lease만 소유한다.
### 2. source of truth는 서버다
SSE/WebSocket event의 기본 효과는 registered `QueryInvalidationTopic`과 authoritative
HTTP refetch다. raw event payload를 domain entity나 Query cache의 authoritative
state로 자동 승격하지 않는다.
authoritative delta 적용은 event type별 server revision, base revision, commit
뒤 publication, idempotent reducer, gap/reset과 snapshot reconciliation이 모두
승인된 경우에만 별도 선택한다.
Web Push payload는 작은 opaque notification hint다. Poll response는 해당 HTTP
representation의 결과다. 어느 것도 authorization이나 exactly-once effect를
증명하지 않는다.
### 3. outbound connection과 inbound event adapter를 분리한다
outbound가 소유한다.
- fixed endpoint와 credential 협력
- connect/subscribe/resume/reconnect/close
- selected WebSocket typed send
- push subscription register/revoke
- bounded poll scheduling/cancel
inbound가 소유한다.
- raw byte/frame hard cap
- UTF-8/JSON/schema/version 검증
- stream/event/scope/generation 확인
- dedupe/order/gap
- feature input 또는 query invalidation mapping
- effect 뒤 cursor/ack commit
application/domain에 native browser, TanStack, URL/header나 vendor type을 노출하지
않는다. `send(unknown)`과 arbitrary `channel`/endpoint도 금지한다.
### 4. target event protocol을 versioning한다
foreground common envelope은 다음 의미를 가져야 한다.
```text
protocol = REALTIME_EVENT_V1
streamId = registry-owned ID
streamEpoch = opaque server reset epoch
eventType = closed registry ID
eventId = bounded dedupe ID
sequence = canonical unsigned decimal string
recoveryMode = CURSOR | SNAPSHOT_ONLY | SESSION_REBUILD
resumeCursor = CURSOR면 opaque replay position, 아니면 exact null
occurredAt = strict RFC 3339, ordering authority 아님
scopeBinding = session/BFF-issued opaque exact-match token
payload = event-type-specific closed codec
```
`eventId`, `sequence`, `resumeCursor`와 business revision은 별도 의미다.
sequence는 JSON safe-integer 문제를 피하도록 decimal string으로 전달하고
stream + epoch 안에서만 비교한다.
credential, readable subject/account ID, signed URL, PushSubscription material과
자유 형식 message는 envelope에 넣지 않는다.
event type registry는 payload schema, pure boundary mapper와 effect profile을
함께 bind한다. `scopeBinding`은 cache fingerprint/authorization proof가 아니고,
cursor는 protocol/stream/feed/epoch/registered subscription set/auth scope에
server-side로 bind한다. client는 opaque cursor를 해석하지 않는다.
state-bearing stream의 recovery profile은 snapshot operation/checkpoint codec과
replay/connect-buffer/server-hold barrier를 닫는다. `SESSION_REBUILD`
EPHEMERAL-only다. V1 server-side subset filter는 `NOT_SELECTED`이며 필요하면
contiguous sequence/checkpoint를 가진 별도 stream으로 등록한다.
### 5. delivery guarantee와 authoritative resync를 분리한다
apply 순서는 다음과 같다.
```text
byte cap
-> parse/schema/version
-> registry/scope/generation
-> dedupe/order/gap
-> registered boundary mapper
-> sequential application effect
-> effect commit
-> last-applied cursor
-> optional selected WebSocket protocol ACK
```
effect 뒤 cursor를 commit하므로 crash window에서 duplicate가 생길 수 있다.
effect는 idempotent하거나 query invalidation/refetch여야 한다.
- 전체 browser lifecycle에 대한 delivery guarantee는 없음
- retention 안의 `CURSOR` foreground event 처리만 duplicate-tolerant
at-least-once model
- V1 ordering은 stream-wide 하나; partition은 별도 logical stream
- exact duplicate/old sequence는 safe drop
- 같은 event ID/sequence의 conflicting content는 protocol failure
- old captured generation callback만 safe drop; current connection의
`scopeBinding` mismatch는 security protocol violation으로 close/revalidate/resync
- sequence gap, stream epoch change, cursor expiry, queue overflow는 delta 적용 중단
- authoritative snapshot과
`SnapshotCheckpoint(streamEpoch,lastAppliedSequence,resumeCursor|null,snapshotRevision)`
같은 commit point로 얻은 뒤에만 resume
- exactly-once와 global ordering은 비목표
backend는 commit 이후 publication, replay retention, cursor reset과
snapshot/checkpoint 의미를 소유한다. subscribe ACK는 accepted cursor와
`nextExpectedSequence`를 반환한다. replay가 없는 `SNAPSHOT_ONLY`
connect/bounded-buffer 또는 server hold barrier 없이는 snapshot/connect 사이
event를 잃을 수 있으므로 `CURRENT`를 보장하지 않고 finite revalidation/stale UX로
degrade한다.
### 6. lifecycle은 scope generation으로 fence한다
connection, freshness, authorization, availability와 traffic admission을 별도
상태 축으로 둔다. `connected: boolean` 하나로 표현하지 않는다.
- runtime config/release/session recovery 뒤에만 connect한다.
- route lease는 unmount에서 release한다.
- logout/account/release transition은 old generation을 먼저 fence한다.
- connect/read/backoff/poll/snapshot을 abort하고 queue/cursor/dedupe를 폐기한다.
- late event/response/worker handoff는 captured old generation이면 적용하지 않는다.
- close/dispose/unsubscribe는 terminal/idempotent다.
- React StrictMode 반복 뒤 physical listener/connection/timer가 하나만 남는다.
- admission은 canonical `DISABLED | SHADOW | CANARY | ENABLED`만 사용하고,
drain은 connection lifecycle의 `DRAINING`으로 표현한다.
- `DISABLED`는 새 data-plane side effect를 0으로 한다. 이미 소유한 fixed
resource의 idempotent close/revoke만 bounded `DRAINING` cleanup plane에서
허용하며 `CLOSED` 뒤 network side effect는 0이다.
hidden에서는 Polling을 중지하고 live connection은 configured bounded grace 뒤
close/pause한다. `pagehide`에서 document-owned SSE/WS/Poll을 모두 정리하고
`pageshow`/visible 복귀에는 snapshot freshness gate 뒤 새 runtime으로 resume한다.
`unload` 완료에 의존하지 않는다. backend는 active authorization revoke를
close/control event로 전파하거나 bounded max connection age에 재인가한다.
### 7. retry owner를 하나로 제한한다
reconnect는 capped full-jitter exponential backoff를 사용한다. base/max delay,
max attempts와 max elapsed는 immutable registry/implementation ceiling으로
제한한다.
- stable-open window 또는 valid heartbeat/event 뒤에만 attempt reset
- valid server hint는 local delay보다 이른 retry를 금지하는 not-before bound
- server hint가 implementation max/remaining elapsed budget을 넘으면 낮춰
clamp하지 않고 degraded/stale로 종료
- offline에서는 timer retry를 멈춤
- auth expiry는 session owner single-flight recovery 한 번
- forbidden/protocol/schema failure는 terminal
- 외부 rate/provider failure는 exact bounded server not-before hint가 있을 때만
retry하고, hint가 없으면 terminal
- retry budget 소진 뒤 declared Polling fallback 또는 stale UI
- reconnect는 realtime coordinator, auth는 session owner, Poll cadence는 poll
coordinator가 소유하고 Poll-bound HTTP/Query retry는 비활성
- recovery checkpoint는 exact branded object identity로 다음 attempt에 전달한다.
SSE `onOpen`/WebSocket `onSubscribed` proof와 attempt 성공 proof가 같은
object일 때만 common transport barrier를 확인하고 event admission을 연다.
clone/missing proof와 30초 readiness deadline 초과는 fail-closed다.
- aborted sleep/attempt/closed-receipt는 기본 2초 bounded drain 뒤 run을
fail-closed로 끝내되, 실제 old task가 settle할 때까지 `DRAINING`을 유지한다.
정상 active session의 `waitClosed`에는 deadline을 두지 않는다.
### 8. SSE baseline은 bounded fetch-stream이다
common reference target은 fixed same-origin BFF에 대한 fetch-stream SSE다.
native EventSource보다 다음을 명시적으로 제어하기 위해서다.
- credential integration
- status/content type/redirect
- AbortSignal과 lifecycle
- parser/event byte ceiling
- reconnect/idle/retry budget
- explicit current cursor
native EventSource는 same-origin cookie auth, native `Last-Event-ID`/reconnect,
`204` terminal contract와 lifecycle 뒤 cursor recovery를 backend가 수용한
별도 profile에서만 허용한다. UA cursor를 application effect commit과 묶을 수
없으므로 `INVALIDATION_HINT` 전용이고 reconnect/restore마다 authoritative
snapshot gate를 수행한다. gate 중 hint는 bounded `pendingInvalidation`으로
coalesce하고 checkpoint 뒤 pending refetch까지 drain한다. 이 buffer/barrier가
없으면 `CURRENT`를 금지한다. `AUTHORITATIVE_DELTA`는 fetch-stream만 허용한다.
token을 URL에 넣지 않는다.
fetch-stream parser는 표준 UTF-8 SSE format, BOM/line ending/comment/multi-line
data/id/retry/incomplete EOF를 bounded하게 구현한다. exact `200
text/event-stream`만 stream 성공이며 auth/rate/reset/provider status를 closed
failure로 mapping한다. parsed candidate ID와 effect-committed cursor를 분리하고
각 application event block의 직접 `id`와 envelope cursor를 exact match한다.
SSE baseline은 registry-owned session feed 하나와 feed-wide cursor 하나다.
route lease는 local dispatch만 바꾸며 arbitrary server multiplex와
per-subscription cursor는 `NOT_SELECTED`다.
hosting은 proxy buffering, idle/request timeout, heartbeat, cache/transform,
HTTP connection budget와 client disconnect cleanup을 실제로 검증한다.
### 9. WebSocket은 versioned duplex protocol로만 선택한다
- fixed same-origin `wss:` endpoint와 exact subprotocol
- server `Origin` 검증과 current session authorization
- URL/query/subprotocol에 credential 금지
- closed welcome/subscribe/unsubscribe-ack/event/reset/heartbeat/close frame
- baseline text JSON, binary/extension은 별도 승인
- application heartbeat/watchdog
- bounded incoming sequential queue
- bounded outgoing queue와 `bufferedAmount`
- raw close reason redaction
- same-epoch cursor resume의 `nextExpectedSequence = lastApplied + 1`; accepted
cursor silent advance 금지, mismatch는 reset/snapshot
- state-bearing initial subscribe는 snapshot/checkpoint + barrier 전 `CURRENT` 금지
- `UNSUBSCRIBE` 뒤 matching `UNSUBSCRIBED`까지 tombstone과 quota를 유지하고 late
event/control은 effect 없이 버린다. unknown ACK와 ACK deadline 초과는
connection-level failure다.
classic browser WebSocket은 incoming backpressure를 제공하지 않으므로 queue
overflow에서 임의 delta drop을 하지 않는다. baseline은 connection을 close하고
snapshot resync한다. server의 bounded pause/resume ACK protocol을 별도 증명한
profile에서만 subscription pause를 허용한다.
모든 client control frame은 하나의 FIFO outbound queue를 통과한다. negotiated
message count/queued bytes와 native `bufferedAmount` 중 하나라도 넘으면
`QUEUE_OVERFLOW`, `retryable=false`, `OVERLOADED`로 generation 전체를 닫고
snapshot recovery를 요청한다.
durable business command는 기존 HTTP path를 기본으로 유지한다. WebSocket
command를 선택하면 closed operation, command ID/idempotency, expected revision,
ack와 business commit certainty를 별도로 정의한다.
### 10. Web Push는 별도 window/worker/backend capability다
Web Push 선택에는 다음이 모두 필요하다.
- user-action 기반 permission UX
- active Service Worker registration
- `userVisibleOnly: true`인 window subscription manager
- authenticated backend register/revoke
- server subscription registry
- VAPID private-key/provider owner
- worker push/notification/click inbound adapters
PushSubscription endpoint, `p256dh`, `auth`는 capability material로 취급하고
application state, browser storage, URL, BroadcastChannel과 telemetry에서
금지한다. VAPID private key는 server-only다.
push payload는 versioned, association/release-bound, expiring opaque notification
hint로 제한한다. 개인 내용은 foreground BFF가 current authorization으로
조회한다. worker handler는 `waitUntil` 안에서 bounded validation과
`showNotification`만 수행하며 long retry/sync/migration을 하지 않는다.
decoded application hint는 3 KiB를 넘지 않으며 최상위 JSON member name 중복은
last-wins로 해석하지 않고 거절한다. `issuedAt`의 client clock 대비 future
skew는 최대 5분, `expiresAt - issuedAt` lifetime은 최대 24시간이다.
window의 native permission/subscription operation은 30초, backend
register/reconcile/revoke operation은 15초 안에 종료하며 제품 config는 이
implementation ceiling을 높일 수 없다.
`pushsubscriptionchange` window handoff도 worker lifecycle abort와 10초
deadline을 사용하고, non-cooperative `matchAll()` 또는 동기 `waitUntil()` 예외
뒤에는 늦은 `postMessage`를 허용하지 않는다.
notification copy와 click route는 closed registry를 사용한다. arbitrary backend
text나 URL을 OS notification/openWindow에 전달하지 않는다.
worker restart 뒤 click을 처리하도록 bounded non-sensitive
`NotificationClickDataV1``NotificationOptions.data`에 넣고 click 시
codec/expiry/current association/release를 다시 검증한다. logout 때 owned
notification은 bounded best-effort close하지만 OS 잔존 가능성 때문에 copy는
항상 account-neutral이어야 한다.
worker는 window in-memory session을 authority로 사용할 수 없으므로 opaque
`fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`
`UNASSOCIATED | ACTIVE | REVOKED` association discriminant를 가진
adapter-owned IndexedDB `PUSH_CONTROL_V1` record를 사용한다.
account ID, endpoint/key, credential과 notification content는 이 record에서
금지한다. missing/corrupt/mismatch는 fail-closed한다. 동일 association epoch의
`REVOKED`는 terminal tombstone이다. logout은 durable fence generation rotate와
REVOKED를 먼저 commit한다. 새 `ACTIVE`는 distinct backend epoch와 captured/current
fence generation, server session binding, prior record revision/epoch, release를
한 IDB transaction에서 CAS해 stale-tab response를 거절한다. 첫 register 전
`UNASSOCIATED` record도 같은 generation을 durable하게 보관하므로 logout과
in-flight register response의 race를 association sentinel 없이 닫는다. client
`updatedAt`은 ordering authority가 아니다.
logout은 old generation fence, durable local association `REVOKED` commit과
backend account association revoke를 정상 security commit으로 사용한다. boot에서
native subscription/local fence/server association을 reconcile하고, local commit
실패나 ambiguous revoke는 `PUSH_UNAVAILABLE`로 내려 짧은 TTL, send-time auth와
click-time 재인가에 의존한다. native unsubscribe/old notification close는
best-effort지만 captured native subscription, exact association tag와 unchanged
durable fence를 모두 다시 확인한 경우에만 수행한다. 새 association이 commit되면
old cleanup은 건너뛴다. local fence 실패 뒤 current native subscription 조회나
association wildcard cleanup은 금지한다.
control tombstone purge는 자동 revoke 단계가 아니다. 별도 maintenance owner만
captured revision/authority/association epoch가 exact한 `REVOKED` record를
repository CAS로 삭제할 수 있고, concurrent newer owner가 있으면
`STALE_REVISION`으로 끝난다.
backend register/revoke는 VD-23의 fixed `COMMAND`로 등록하고 cookie session의
exact CSRF를 검증한다. register는 keyed idempotency 또는 atomic installation
upsert/receipt, revoke는 duplicate/`ALREADY_GONE` 성공 의미를 가져야 하며
`associationEpoch`은 server commit 뒤에만 발급한다.
Service Worker를 우회해 UA가 직접 notification을 표시할 수 있는 declarative push
message는 V1에서 `NOT_SELECTED`다. outbound `web_push: 8030` shape를 거절하고
별도 ADR 전에는 encrypted `WEB_PUSH_HINT_V1`만 허용한다.
Service Worker를 선택해도 offline fetch, PWA shell cache나 background sync가
자동 승인되지 않는다. 하나의 worker composition/update owner가 선택된 handler를
조립한다.
### 11. Polling은 bounded lease다
허용 형태:
- visible query의 낮은 빈도 conditional freshness poll
- 사용자 시작 async job의 terminal-state convergence poll
각 lease는 operation owner, minimum/success/max interval, max attempts,
max elapsed, response byte cap, visible-only policy와 terminal states를 가진다.
- operation은 registered terminal·replay-safe REST `QUERY`여야 함
- Poll `maxAttempts`는 physical request 하나인 logical completion을 셈
- Poll-bound VD-23 budget은 `maxAttempts=1`, `authRecoveryCount=0`,
`maxCumulativeSleepMs=0`; TanStack Query retry도 끔
- completion-chained timeout으로 single-flight
- hidden/offline/pagehide/unmount/scope change/user cancel에서 stop
- ETag/`If-None-Match` 또는 server cursor 사용
- `304`, auth, cursor reset, `429/503 Retry-After`를 closed mapping
- common recovery coordinator가 `POLL_ACTIVE -> LIVE_PROBING`에서 poll만 effect
writer로 유지하고 live candidate는 bounded buffer만 사용. handoff mutex에서
poll fence/abort + quiescence를 먼저 완료하고 current-generation
snapshot/checkpoint와 buffered event를 적용한 뒤 live를 활성화
- active writer effect tail도 in-flight 포함 256건/4MiB로 제한하고 overflow는
전체 generation을 `QUEUE_OVERFLOW`로 fail-close
- budget 소진 뒤 manual refresh/stale UI
- page component `setInterval`과 unlimited loop 금지
### 12. resource ceiling과 privacy를 fail-closed한다
상세 설계의 target hard ceiling은 physical connection, logical subscription,
event/frame/parser/queue/dedupe/reorder/outbound buffer, reconnect, poll lease,
push hint와 worker deadline을 제한한다. 제품 config는 더 작게만 설정할 수 있다.
2026-07-28 reference-runtime amendment로, RT-01~RT-04 source 전체를
tree-shaking 없이 합성하는 optional-recipe gzip 예산을 40,000 bytes로
고정한다. 이는 production bundle 허용량이 아니며 미선택 production asset의
realtime module 허용량은 계속 0이다. SSE replay-open과 WebSocket
`SUBSCRIBED`가 exact recovery checkpoint를 증명하고 common barrier가 확인될
때까지 event admission을 막는 readiness gate는 attempt당 최대 30초다.
phase abort 뒤 비협조적인 retry sleep, connect attempt 또는 closed-receipt
cleanup을 기다리는 drain은 2초로 고정하고 구현 절대 최대는 30초다. 상한을
넘긴 task가 settle할 때까지 lifecycle은 `DRAINING`을 유지하며 정상 active
session의 `waitClosed`에는 이 cleanup deadline을 적용하지 않는다.
ceiling 초과는 limit 자동 인상이나 silent drop이 아니라 new lease rejection,
connection close, snapshot resync, typed backpressure, stale/degraded 또는
notification drop으로 처리한다.
telemetry에는 transport/registry ID, closed outcome, count/duration/lag bucket만
허용한다. raw URL/query/credential/subject/event ID/cursor/payload/close reason/
PushSubscription key와 notification private content는 금지한다.
### 13. 실제 provider/browser/operations evidence 전에는 promotion하지 않는다
evidence를 분리한다.
1. pure unit/property와 deterministic fault contract
2. 실제 local SSE/WS server integration
3. backend replay/snapshot/auth/hosting/provider conformance
4. built production asset의 target-browser lifecycle
5. Web Push provider + browser/OS 자동·수동 evidence
6. load/chaos/security negative gate
7. dashboards, kill switch와 drain/recovery/rollback drill
fake/jsdom/MSW만으로 native stream, socket, worker, notification이나 provider
readiness를 주장하지 않는다. 외부 evidence가 없으면 `PromotionEvidence`
`MISSING | PARTIAL`이고 promotion gate result는 `FAIL_UNVERIFIED`다.
## 선택하지 않은 대안
### 범용 transport enum을 가진 `RealtimePort`
전송 교체는 가능해 보이지만 direction, permission, lifecycle, delivery certainty와
fallback 의미를 잃는다. 공통 protocol coordinator만 재사용하고 native capability
port는 분리한다.
### 모든 server event에 WebSocket 사용
one-way notification에도 duplex handshake, heartbeat, queue와 server connection
운영 비용을 강제한다. one-way stream은 SSE를 우선 검토한다.
### native EventSource만 공통 baseline으로 사용
arbitrary auth header, detailed status mapping, bounded reconnect와 explicit
lifecycle cursor 제어가 부족하다. 조건부 profile로는 허용하지만 reference
baseline은 fetch-stream이다.
### token을 SSE/WS URL에 전달
history, log, proxy, analytics와 referrer에 노출될 수 있다. same-origin
BFF/cookie 또는 승인된 별도 handshake를 사용한다.
### event payload로 Query cache 직접 patch
filter/pagination/revision/gap 의미가 없으면 stale projection을 만든다. 기본은
namespace invalidation과 authoritative refetch다.
### Web Push를 silent sync로 사용
permission/browser/OS/provider가 background execution과 timely delivery를
보장하지 않는다. user-visible notification hint와 foreground refresh로 제한한다.
### 무한 `setInterval` Polling
overlap, hidden resource 사용, retry 중첩과 terminal cleanup 누락을 만든다.
finite immutable lease와 single owner를 사용한다.
### cross-tab leader를 기본 제공
leader election/crash/handoff/partition과 SharedWorker 지원이 별도 protocol을
요구한다. 기본은 tab별 bounded runtime과 focus snapshot이다.
### exactly-once delivery
cursor commit과 application effect 사이 crash window, push service와 browser
lifecycle을 공통 frontend만으로 제거할 수 없다. retention 안의 CURSOR event만
duplicate-tolerant하게 처리하고 나머지는 best-effort + authoritative resync를
사용한다.
## 결과
긍정적 결과:
- 전송 선택이 요구와 failure semantics에 연결된다.
- server state/query ownership과 clean architecture 경계를 유지한다.
- gap, late callback, logout과 page restore가 명시적 복구 경로를 가진다.
- Web Push permission/subscription material이 일반 realtime state와 분리된다.
- Polling fallback이 resource-unbounded loop가 되지 않는다.
- 미선택 capability의 bundle/worker/runtime side effect를 0으로 유지할 수 있다.
비용:
- common coordinator 외에도 transport별 adapter와 실제 provider harness가 필요하다.
- backend는 replay/snapshot/outbox/auth와 provider 운영 계약을 제공해야 한다.
- worker와 window에 별도 composition/test matrix가 필요하다.
- direct delta보다 invalidation/refetch가 추가 HTTP 비용을 만들 수 있다.
- target browser/OS에서 자동화할 수 없는 Web Push evidence를 운영해야 한다.
## 구현 순서
```text
RT-00 contract/status
-> RT-01 event authority + scope/gap/resync
-> RT-02 SSE + bounded polling
-> RT-03 WebSocket
-> RT-04 Web Push
-> RT-05 product composition/provider/browser/operations
```
SSE와 WebSocket을 모두 구현해야 skeleton이 완성되는 것은 아니다. 공통
mechanism을 구현한 뒤 실제 product requirement에 필요한 최소 transport만
선택한다.
reference source와 deterministic/native evidence가 생기면 해당 runtime만
`AVAILABLE_NOT_COMPOSED`로 올린다. 제품 endpoint/event registry/policy가
bootstrap에 연결된 transport만 `COMPOSED`다.
## Rollout
capability별 traffic admission:
```text
DISABLED -> SHADOW -> CANARY -> ENABLED
SHADOW | CANARY | ENABLED -> DISABLED
```
- transport, stream, Poll fallback과 push category kill switch를 분리한다.
- safe config default는 `DISABLED`다.
- canary 전에 backend/provider/browser/operations evidence를 만료 검증한다.
- deploy/drain과 reconnect herd를 load test한다.
- freshness/latency만 아니라 gap/resync/queue/memory/battery/push permission
지표를 함께 본다.
## Rollback과 제거
1. admission을 `DISABLED`, connection lifecycle을 `DRAINING`으로 전환한다.
2. logical subscription/send/poll/push registration을 중지한다.
3. active reader/socket/timer/handler를 bounded close한다.
4. HTTP focus/manual refresh 또는 명시된 fallback을 노출한다.
5. server publisher/replay/subscription compatibility window를 유지한다.
6. composition/registry/adapter/dependency/worker handler를 제거한다.
7. CSP/runtime config/provider key와 retained server subscription을 정리한다.
8. typecheck, architecture, tests, build, bundle/module inventory와 removal gate를
실행한다.
미선택/제거 상태에서 connection, timer, push listener/subscription request와
production bundle sentinel이 0이어야 한다.
## 완료 기준
### 이 결정의 설계 완료
- [x] 네 capability의 의미와 선택 조건을 분리했다.
- [x] current status와 target runtime 상태를 구분했다.
- [x] source of truth와 delivery/effect certainty를 정했다.
- [x] target envelope, ordering, cursor와 resync를 정했다.
- [x] lifecycle/retry/resource/security/privacy 경계를 정했다.
- [x] transport별 auth/hosting/worker/Poll contract를 정했다.
- [x] evidence, rollout, rollback과 제거 기준을 정했다.
### 구현과 promotion 상태
- [x] RT-01 공통 coordinator/reconnect/contract suite
- [x] RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence
- [x] RT-03 WebSocket reference runtime과 deterministic evidence
- [x] RT-04 Web Push window/worker reference runtime과 deterministic evidence
- [x] static boundary/security fixture, synthetic bundle budget와 removal blocking gate
- [ ] actual SSE/WS local server, load와 target-browser evidence
- [ ] actual Web Push provider, permission UX와 target-browser evidence
- [ ] provider/browser evidence와 operations drill의 release-blocking gate 등록
- [ ] 실제 product/backend/provider selection
- [ ] operations runbook drill
common runtime status는 `AVAILABLE_NOT_COMPOSED`다. 위의 미완료 promotion
항목 전에는 product selection이 계속 `NOT_SELECTED`이고 production-ready를
주장하지 않는다.
## 관련 자료
- [상세 설계](../realtime-events-web-push-and-bounded-polling.md)
- [VD-10 optional capability recipes](./VD-10-optional-capability-recipes.md)
- [VD-13 client cache scope and persistence](./VD-13-client-cache-scope-and-persistence.md)
- [VD-23 API transport selection and REST execution](./VD-23-api-transport-selection-and-rest-execution.md)
- [VD-25 Server State Cache lifecycle](./VD-25-server-state-cache-lifecycle.md)
- [VD-27 gRPC-Web unary and server stream](./VD-27-grpc-web-unary-and-server-stream.md)
- [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md)
- [Optional adapter recipes](../optional-adapter-recipes.md)
- [Client cache and browser storage](../client-cache-and-storage.md)
- [Frontend ports, adapters, and boundaries](../frontend-ports-adapters-and-boundaries.md)
- [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [WHATWG WebSockets](https://websockets.spec.whatwg.org/)
- [W3C Push API](https://www.w3.org/TR/push-api/)
- [WHATWG Notifications API](https://notifications.spec.whatwg.org/)
- [W3C Service Workers](https://www.w3.org/TR/service-workers/)
- [RFC 8030](https://www.rfc-editor.org/rfc/rfc8030)
- [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291)
- [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff