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

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

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

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

345 lines
28 KiB
Markdown

# Adapter Review — Service Worker and Web Push
> 검토 기준: `develop` / `4dc033c` (2026-08-13)
>
> 범위: `src/adapters/service-worker/**`, `src/adapters/web-push/**`, `src/contracts/service-worker.ts`, `src/contracts/web-push.ts`, 관련 build input·unit test·architecture 문서
## 결론
서비스 워커는 registration ownership, static asset install의 byte/digest 검증, activation drain handshake, `clients.claim()` 금지와 staged removal이라는 좋은 기반을 갖고 있다. Web Push도 raw endpoint/key를 durable control record에서 분리하고, push/click 전에 association fence를 두 번 확인하며, notification copy/route를 closed registry로 제한한다. 이 경계들은 유지해야 한다.
현재 코드에는 조합 전에 고쳐야 할 P1 항목이 있다.
- generated manifest는 root-relative URL을 가지지만 fetch 분류는 absolute `Request.url`과 비교해 정적 cache path가 사용되지 않을 수 있다 (`SW-URL-01`).
- Cache Storage 전체에서 match하여 현재 release가 아닌 구 cache response를 반환할 수 있다 (`SW-01`).
- reset가 소유권 parser가 아니라 문자열 prefix만 사용해 유사 이름의 타 cache까지 삭제한다 (`SW-02`).
- `unregister()``false`를 성공으로 보고하며 removal mode도 실패/ownership mismatch를 `DISABLED`로 숨긴다 (`SW-03`, `SW-04`).
- build input의 static manifest decoder가 asset row와 set digest를 실제로 검증하지 않는다 (`SW-05`).
- Push fence CAS adapter가 repository의 다음 revision을 확인하지 않고, deadline 뒤 late mutation effect도 표현하지 못한다 (`WP-01`, `WP-02`).
- backend registration response가 request의 전체 authority를 echo/bind하지 않아 client가 잘못 묶인 association을 검출할 수 없다 (`WP-03`).
Web Push는 현재 `AVAILABLE_NOT_COMPOSED`이고 제품 선택도 `NOT_SELECTED`다. service worker entry에 연결되지 않은 사실 자체는 회귀가 아니다. 아래 P1 계약을 해결하고 product-owned registry/provider/consent가 준비되기 전에는 default composition에 추가하지 않는다.
## 파일별 판정
| 파일 | 역할 | 판정 | 후속 |
| --- | --- | --- | --- |
| `service-worker-entry.ts` | 단일 physical worker entry와 event wiring | KEEP/REFACTOR | `SW-06`, `SW-10`; 두 번째 registration 생성 금지 |
| `service-worker-lifecycle.ts` | install/activate/fetch/activation/reset | VERIFIED_DEFECT | `SW-URL-01`, `SW-01`, `SW-02`, `SW-07`, `SW-08` |
| `service-worker-page-controller.ts` | registration/update/activation/reset page facade | VERIFIED_DEFECT | `SW-04`, `SW-06` |
| `service-worker-protocol.ts` | page-worker strict message codec/nonce | CONTRACT_GAP | `SW-06`, `SW-10` |
| `service-worker-removal.ts` | exact registration/cache ownership cleanup | VERIFIED_DEFECT | `SW-03` |
| `service-worker-static-assets.ts` | static manifest/install/cache policy | KEEP/REFACTOR | `SW-01`, `SW-05`, `SW-09` |
| `web-push/index.ts` | public exports | KEEP | product selection 전 surface 확대 금지 |
| `web-push/notification-registry.ts` | closed copy/route registry | KEEP | arbitrary copy/URL 허용 금지 |
| `web-push/push-association-fence-store.ts` | durable authority state machine | VERIFIED_DEFECT | `WP-01`, `WP-02` |
| `web-push/push-codec.ts` | bounded hint/click codec | KEEP | exact keys, expiry, no raw text 유지 |
| `web-push/push-registration-gateway.ts` | fixed backend commands/decoders | CONTRACT_GAP | `WP-03`, `WP-04` |
| `web-push/push-subscription-adapter.ts` | window consent/native/backend/local orchestration | VERIFIED_DEFECT/REFACTOR | `WP-04`, `WP-05`, `WP-06` |
| `web-push/runtime-support.ts` | deadline/link/observation mechanics | CONTRACT_GAP | `WP-02`, `WP-07` |
| `web-push/service-worker-runtime.ts` | push/click/subscriptionchange handler composition | REFACTOR | `WP-06` |
| `web-push/service-worker-scope-host.ts` | native scope facade | KEEP | single worker entry 내부에서만 사용 |
| `web-push/inbound/push-event-adapter.ts` | hint → fence → safe notification | KEEP/CONTRACT_GAP | `WP-07` |
| `web-push/inbound/notification-click-adapter.ts` | click → fence → safe route handoff | KEEP/CONTRACT_GAP | `WP-07` |
직접 경계 inventory도 대조했다: `src/contracts/service-worker.ts`는 protocol/cache ownership identity, `src/contracts/web-push.ts`는 push protocol/selection을 소유한다. `src/bootstrap/register-service-worker.ts`는 page composition, `scripts/lib/service-worker-build-input.ts``scripts/generate-service-worker-assets.ts`는 build decode/generation, `vite.service-worker.config.ts`는 worker bundle entry를 소유한다. 이 파일들은 `SW-05`/`SW-10`의 shared codec과 rollout scope에 포함한다.
## Service Worker 상세
### SW-URL-01 — generated root-relative manifest와 absolute fetch URL의 분류 불일치
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: generator `scripts/generate-service-worker-assets.ts`는 asset URL을 `/assets/...`로 생성하고, `service-worker-lifecycle.ts`는 그 문자열 set을 absolute `Request.url`과 직접 비교한다.
- 영향: generator output을 그대로 사용하면 verified static URL이 manifest member로 분류되지 않아 current cache lookup path에 들어가지 않고 network fallback이 된다. `SW-01`의 cache 선택을 고쳐도 URL identity를 먼저 맞추지 않으면 cache path는 여전히 작동하지 않는다.
- 결정: runtime 생성 시 각 root-relative manifest URL을 `new URL(asset.url, scope.registrationScope).href`로 canonicalize하고 same-origin을 재확인한 frozen absolute URL set을 만든다. install cache key, fetch classification, lookup/delete validation이 이 canonical URL identity를 공유한다. generator의 persisted manifest shape는 root-relative로 유지한다.
- 테스트: generator-shaped `/assets/app.<hash>.js` fixture와 absolute `https://app.example/assets/app.<hash>.js` request를 사용해 `onFetch()`가 current cache로 들어가는지 직접 검증한다. 다른 origin, scope 밖 path, query/hash 변형은 거절한다.
### SW-01 — fetch가 current static cache가 아닌 전역 CacheStorage를 조회
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-lifecycle.ts:166-192`; worker facade `service-worker-entry.ts:38-44`
- 현재 동작: verified static URL에 `scope.caches.match(request.url)`을 호출한다. CacheStorage-wide match는 current, previous 또는 같은 URL을 가진 다른 cache 중 먼저 찾은 response를 반환할 수 있다.
- 영향:
- current release manifest에 URL이 포함되어 있어도 구 cache의 동일 URL response가 반환될 수 있다.
- invalid hit를 발견해도 삭제는 current cache에만 수행하므로 실제로 반환된 stale cache entry는 남는다.
결정: `onFetch()``config.manifest.setDigest`로 계산한 current cache를 `open()`하고 그 cache에서만 `match()`한다. worker scope facade의 CacheStorage-wide `match`는 제거한다.
테스트 추가 (`tests/unit/service-worker-runtime.test.ts`):
- current/previous cache에 같은 URL과 다른 bytes가 있을 때 current만 반환
- previous에만 entry가 있으면 network fallback (`null`)
- current invalid response만 current cache에서 삭제
- unrelated cache의 same URL은 조회/삭제하지 않음
완료 조건: runtime fetch path에 `caches.match` 호출이 0이고 current cache name이 exact digest에서 파생된다.
### SW-02 — cache reset가 exact ownership 대신 prefix를 사용
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-lifecycle.ts:333-365`; exact helper `src/contracts/service-worker.ts:111-116`
- 현재 동작: `name.startsWith("ca-static-v1-")`이면 삭제한다. `isOwnedStaticCacheName()`은 정확히 16자리 lower-hex suffix를 요구하지만 reset path가 이를 사용하지 않는다.
- 영향: `ca-static-v1-not-owned`, suffix가 더 긴 이름 등 같은 prefix를 가진 타 기능/cache가 삭제될 수 있다.
- 수정: import되어 있는 `isOwnedStaticCacheName(name)`만 사용한다. cache name 상수 literal도 lifecycle에서 제거한다.
- 테스트: valid 16-hex 두 개만 삭제하고 short/long/non-hex/upper-hex/unrelated cache를 보존한다.
### SW-03 — `unregister() === false`를 `UNREGISTERED`로 보고
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-removal.ts:89-120`
- 현재 동작: Promise가 resolve하면 boolean을 무시하고 `UNREGISTERED`를 반환한다.
- 수정: `const unregistered = await registration.unregister()``true`만 성공으로 인정한다. `false``{ kind: "FAILED", operation: "UNREGISTER" }`로 닫는다. 새 outcome을 추가할 필요는 없다.
- 테스트: true, false, rejection, absent, ownership mismatch를 각각 고정한다.
### SW-04 — explicit removal mode가 cleanup 실패를 `DISABLED`로 숨김
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `service-worker-page-controller.ts:78-121`
- 현재 동작:
- `REMOVE_REGISTRATION``PURGE_OWNED_RESOURCES`는 실제 outcome과 무관하게 `DISABLED`를 반환한다.
- `disabledCleanup``OWNERSHIP_MISMATCH``DISABLED`로 반환한다.
- 영향: staged removal이 끝난 것으로 판단해 다음 release에서 worker source/handler를 제거할 수 있지만 실제 registration 또는 cache가 남아 있을 수 있다.
결정 매핑:
| cleanup outcome | page start outcome |
| --- | --- |
| `ABSENT`, `UNREGISTERED`, `PURGED` | `DISABLED` |
| `OWNERSHIP_MISMATCH` | `INCOMPATIBLE` |
| `FAILED` | `FAILED` (`DISABLE_CLEANUP_FAILED`, `REMOVE_FAILED`, `PURGE_FAILED`) |
관찰 이벤트만 남기고 success로 바꾸지 않는다. 테스트는 selection 세 종류와 위 outcome matrix를 모두 table-driven으로 작성한다.
### SW-05 — build input의 manifest row와 set digest 검증 부재
- 우선순위/분류: **P1 / CONTRACT_GAP**
- 근거: `scripts/lib/service-worker-build-input.ts:82-94`; runtime의 부분 검사 `service-worker-static-assets.ts:85-112`; 생성 canonical hash `scripts/generate-service-worker-assets.ts:48-93`
- 현재 동작:
- build input은 manifest top-level shape만 보고 `assets`를 type cast한다.
- runtime validator도 build/release identity, exact row keys, unique/canonical URL, content type type/allowlist, set digest 재계산을 확인하지 않는다.
- 잘못된 `contentType``storeAsset()``.toLowerCase()`에서 typed rejection이 아니라 throw가 될 수 있다.
결정: runtime-neutral shared manifest codec이 exact row keys, content-type/extension allowlist, root-relative canonical URL, length-prefixed canonical byte serialization을 소유한다. generator와 Node build gate는 같은 bytes를 Node SHA-256으로 hash하고 worker는 injected WebCrypto digest로 같은 bytes를 재검증한다. Node `crypto` 구현을 worker에서 import하지 않는다. 이 작업은 기존 2026-08-01 plan Task 5/SW-10의 **선행 build-decoder 단계**로 병합하며 canonical digest를 별도 PR에서 두 번 구현하지 않는다.
Build gate 필수 조건:
- top-level/asset row exact keys
- buildId/releaseId exact match
- sorted unique same-origin root-relative hashed asset URL
- 허용 content type/extension pair
- non-negative safe byte length와 전체 bound
- lower-hex SHA-256
- generator와 같은 length-prefixed canonical algorithm으로 `setDigest` 재계산
테스트 (`tests/unit/service-worker-build-input.test.ts`): 각 row field tamper, duplicate/reorder, cross-origin URL, dot segment, wrong extension/content type, wrong set digest, unknown field. valid generator output을 decoder에 다시 넣는 parity test도 추가한다.
### SW-06 — activation/reset command의 source identity와 single-flight 부재
- 우선순위/분류: **P2 / CONTRACT_HARDENING**
- 근거: `service-worker-page-controller.ts:174-211`, `:231-305`, `:308-369`
- 현재 동작:
- activation 전용 listener는 `event.origin``event.source`를 검증하지 않는다.
- general listener/reset은 origin 일부만 확인하며 expected waiting/controller source와 correlation하지 않는다.
- 동시에 `requestActivation()` 또는 `resetOwnedCaches()`를 여러 번 호출하면 nonce와 listener가 중복 생성된다.
결정:
- activation reply는 request 시 capture한 `registration.waiting``event.source`가 같아야 한다.
- reset reply는 request 시 capture한 `container.controller`와 같아야 한다.
- long-lived `CLIENT_DRAIN_REQUEST` listener도 expected `registration.waiting` source와 correlation한다. nonce가 없더라도 arbitrary same-origin source가 page admission을 닫게 하지 않는다.
- empty origin을 신뢰 근거로 사용하지 않고 source identity + nonce + target identity를 함께 검증한다.
- 각 command를 single-flight Promise로 만들고 concurrent caller는 같은 Promise를 받는다.
- message 수신 직전에 `event.source`, captured source, 현재 `registration.waiting`/`container.controller`가 모두 동일한지 확인한다. 교체되었으면 ignore 후 timeout이 아니라 즉시 `PROTOCOL_MISMATCH`로 종료한다.
테스트: wrong source with correct nonce, source swap, concurrent 10 calls가 postMessage 한 번, stop 중 pending 종료, retry after terminal.
### SW-07 — zero-client drain 의미가 불필요하게 activation을 막음
- 우선순위/분류: **P2 / VERIFIED_BEHAVIOR_CHANGE**
- 근거: `service-worker-lifecycle.ts:268-300`
- 현재 동작: scope 내 client가 0이면 `false`를 반환한다. requester가 request 직후 닫힌 경우 dirty client가 없는데도 waiting worker가 거절된다.
- 결정: empty set은 vacuously drained이므로 `true`다. 단, `clients.matchAll()` 실패는 reject/throw로 유지한다.
- 테스트: zero clients → skipWaiting, one missing ack → timeout/reject, out-of-scope only → zero in-scope로 처리.
### SW-08 — client `postMessage()` 예외가 activation event 전체를 깨뜨림
- 우선순위/분류: **P2 / VERIFIED_DEFECT**
- 근거: `service-worker-lifecycle.ts:225-265`, `:290-299`
- 현재 동작: drain request/accepted/reload notification loop에 per-client 예외 격리가 없다.
- 결정: drain request 전달 실패는 해당 expected client를 failed 처리하고 pending state를 즉시 정리한다. drain 완료 뒤 `skipWaiting()` 호출 성공을 activation admission commit으로 기록한다. 그 다음 `ACTIVATE_ACCEPTED`/reload 알림은 client별 best effort로 보내고 실패를 degraded observation으로 남긴다. 현재 코드의 pre-commit `ACTIVATE_ACCEPTED` 순서는 바꾸거나 protocol V2에서 그 message를 제거한다. `skipWaiting()` 실패는 `REJECTED/FAILED`이고 accepted 성공으로 관찰하지 않는다.
- 테스트: 첫/중간/마지막 client throw, skipWaiting throw, partial delivery, pending map leak 없음.
### SW-09 — install deadline 뒤 late candidate 작업
- 우선순위/분류: **P2 / LIFECYCLE_HARDENING**
- 근거: `service-worker-static-assets.ts:119-153`, `:156-254`, `:259-274`
- 현재 동작: deadline Promise가 먼저 끝나면 candidate cache를 삭제하고 반환하지만, signal을 무시한 fetch/digest/cache put은 뒤늦게 계속될 수 있다. digest rejection도 `storeAsset()`에서 직접 typed outcome으로 변환되지 않는다.
- 결정: public install result는 overall 60초에 닫고 candidate generation fence를 세워 뒤늦은 worker가 새 fetch/digest/put을 시작하지 못하게 한다. late `Response` body는 compensator로 취소한다. 이미 시작한 `cache.put`은 취소할 수 없으므로 background settlement를 관찰한 뒤 candidate cache를 다시 exact-delete하는 second cleanup을 등록한다. cleanup을 public completion에 포함하려면 그 budget을 총 60초 안에 미리 예약하며, 60초 뒤 별도 cleanup deadline을 await해 public bound를 늘리지 않는다. 모든 dependency exception은 closed `FETCH_FAILED`/`INTEGRITY_MISMATCH`로 mapping한다.
- 테스트: non-cooperative late fetch/digest, late cache put, digest rejection, delete rejection, unhandled rejection 없음.
### SW-10 — message protocol을 kind별 discriminated schema와 full identity로 승격
- 우선순위/분류: **P1 before release hardening / 기존 계획 승계**
- 근거: `service-worker-protocol.ts:44-143`; `SERVICE_WORKER_PROTOCOL_VERSION = 1`; 기존 `docs/superpowers/plans/2026-08-01-http-worker-adapter-remediation.md` Task 5
- 현재 동작: 모든 kind가 하나의 optional field bag을 공유하고 page-worker correlation은 주로 buildId에 의존한다. `service-worker-entry.ts:151-166`의 sync message는 codec 대신 V1 literal을 직접 만든다.
결정:
- 기존 계획대로 protocol V2에서 protocol/cache schema/build/release/contract/static set 전체 canonical identity digest를 교환한다.
- kind별 exact required/forbidden field schema를 사용한다. activation/reset kinds에는 nonce와 target identity가 필수다.
- 모든 message, including `SYNC_WAKE_OBSERVED`,는 `createServiceWorkerMessage()`만 사용한다.
- V1/V2 worker가 같은 scope에서 교차 activation하지 않도록 mismatch는 fail-closed하고 강제 skipWaiting 하지 않는다.
이 항목은 기존 계획을 **유지**한다. 정확한 sequence는 `SW-URL-01`, `SW-01`~`SW-04` → 기존 plan Task 4 bounded activation-marker reader → `SW-05` build decoder와 기존 Task 5/`SW-10` 통합 → `SW-06`~`SW-09`다. 같은 canonical digest/codec을 중복 구현하지 않는다.
## Web Push 상세
### WP-01 — CAS success receipt의 expected next revision 미검증
- 우선순위/분류: **P1 / VERIFIED_DEFECT**
- 근거: `push-association-fence-store.ts:475-513`; remove는 `:516-546`에서 next revision을 검사함
- 현재 동작: compareAndSwap success는 key/revision type/replayed만 확인하고 `revision === (expectedRevision ?? 0) + 1`을 확인하지 않는다.
- 영향: repository가 stale/임의 receipt를 반환하면 adapter가 실제로 확인되지 않은 control을 새 revision으로 포장한다. 이후 CAS authority가 틀어진다.
- 수정: write와 remove 모두 exact next revision, expected key, replay semantics를 같은 validator로 검증한다. replayed receipt도 동일 idempotency command의 exact revision이어야 한다.
- 테스트 (`tests/unit/web-push-fence-store.test.ts`): stale/same/skipped/huge revision, wrong key, malformed replay, valid initial/next/replayed receipt.
### WP-02 — deadline 뒤 local fence mutation effect가 UNKNOWN일 수 있음
- 우선순위/분류: **P1 / CONTRACT_GAP**
- 근거: `runtime-support.ts:51-113`; fence store `:411-423`, `:493-503`
- 현재 동작: deadline은 signal을 abort하고 실패를 반환하지만 generic `PushControlRepository`가 signal을 무시하거나 commit 경계 직후 늦게 resolve하면 CAS는 반환 이후 적용될 수 있다.
- 영향: security fence adapter가 `DEADLINE_EXCEEDED`를 반환한 뒤 ACTIVE/REVOKED record가 실제로 바뀔 수 있다.
결정:
1. read deadline wrapper와 mutation wrapper를 분리한다. repository는 commit 전 abort 시 `NOT_APPLIED`, commit 후 success receipt를 반환한다. deadline뿐 아니라 caller abort와 commit/receipt race도 unknown일 수 있다.
2. `WebPushFailureCode``MUTATION_OUTCOME_UNKNOWN`과 recovery reason을 추가한다. lifecycle은 `OPEN | RECONCILIATION_REQUIRED | CLOSED`이며 unknown 뒤 mutation admission을 닫는다.
3. 복구는 새 bounded read로 exact revision/authority/state를 확인한 뒤에만 한다.
4. `withAbortableDeadline`을 mutation의 correctness authority로 사용하지 않는다. deadline은 caller wait bound이며 effect는 repository receipt/read-back이 결정한다.
테스트: timeout-before-commit, timeout-racing-commit, late success, late rejection, recovery read, dispose 중 late ACTIVE 금지.
### WP-03 — backend commit이 전체 request authority에 binding되지 않음
- 우선순위/분류: **P1 / CONTRACT_GAP**
- 근거: request `push-registration-gateway.ts:73-121`; response `:170-218`; activation check `push-subscription-adapter.ts:682-711`
- 현재 동작: request는 `fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`을 보낸다. response는 `associationEpoch``sessionBindingEpoch`만 반환하고 adapter도 session epoch만 비교한다.
- 영향: provider/server bug 또는 stale response가 다른 fence/release request의 association을 반환해도 local current fence가 unchanged이면 ACTIVE로 commit할 수 있다.
결정: register와 reconcile의 request/response protocol을 V2로 올리고 서로 다른 exact response union을 사용한다.
```ts
type WebPushRegisterCommitV2 = Readonly<{
protocol: "WEB_PUSH_REGISTRATION_RECEIPT_V2";
associationEpoch: string;
fenceGeneration: string;
sessionBindingEpoch: string;
releaseEpoch: string;
requestBindingSha256: string;
replacedAssociationEpoch: string | null;
}>;
type WebPushReconciliationV2 =
| Readonly<{
protocol: "WEB_PUSH_RECONCILIATION_V2";
state: "ACTIVE";
associationEpoch: string;
fenceGeneration: string;
sessionBindingEpoch: string;
releaseEpoch: string;
requestBindingSha256: string;
}>
| Readonly<{
protocol: "WEB_PUSH_RECONCILIATION_V2";
state: "ABSENT";
fenceGeneration: string;
sessionBindingEpoch: string;
releaseEpoch: string;
requestBindingSha256: string;
}>;
```
`WEB_PUSH_PROTOCOLS`가 V2 literal과 length-prefixed field order를 소유한다. register digest에는 operation, authority tuple, subscription fingerprint, idempotency key, expected previous association epoch를 넣는다. reconcile에는 idempotency key가 없으므로 명시적으로 제외한다. decoded fixed-length digest bytes를 비교한 뒤 fence CAS를 수행한다.
배포: server가 V1 request에는 V1 response, V2 request에는 V2 response를 반환하도록 request protocol negotiation 배포 → V2 client → old client drain → V1 제거. exact decoder를 깨뜨리는 response dual-emit은 하지 않는다. authority field mutation과 reconcile `ABSENT` fixture를 추가한다.
### WP-04 — repeated enable의 backend upsert/rotation 의미가 타입에 없음
- 우선순위/분류: **P2 / CONTRACT_GAP**
- 근거: `push-subscription-adapter.ts:162-275`, fence `prepare():175-215`, `activate():219-270`
- 현재 동작: 같은 authority가 이미 ACTIVE여도 `enable()`은 새 idempotency key로 backend register를 다시 수행한다. server atomic installation upsert가 같은 association을 반환하거나 old association을 폐기한다는 문서 요구가 gateway receipt에 표현되지 않는다.
- 결정: public `enable()`이 public `reconcile()`을 호출하지 않는다. permission/prepare 뒤 private `reconcilePrepared()` flow를 공유해 exclusive guard 내부에서 호출한다. ACTIVE + valid native material이면 먼저 reconcile하고 `ABSENT`일 때만 register한다. request에 `expectedPreviousAssociationEpoch: string | null`을 보내고 receipt의 `replacedAssociationEpoch`과 exact match해야 한다. local activate는 old ACTIVE와 다른 epoch를 무조건 덮어쓰지 않는다.
- 테스트: double enable same epoch, reconcile active, server absent then register, replacement receipt, replacement without old epoch rejection, compensation on CAS failure.
### WP-05 — pre-aborted operation이 항상 INSPECT로 기록됨
- 우선순위/분류: **P3 / VERIFIED_DEFECT**
- 근거: `push-subscription-adapter.ts:462-478`
- 수정: `webPushFailure("ABORTED", failureOperation)`을 사용한다.
- 테스트: enable/reconcile/revoke/inspect 각각 pre-aborted operation field.
### WP-06 — bounded truncation을 성공으로 관찰
- 우선순위/분류: **P2 / EVIDENCE_CORRECTNESS**
- 근거: subscriptionchange client handoff `service-worker-runtime.ts:139-172`; notification cleanup `push-subscription-adapter.ts:908-945`
- 현재 정책: client 32개, notification 64개/2초로 bounded best effort이다. architecture 문서는 notification cleanup을 privacy guarantee로 보지 않고 account-neutral copy를 요구하므로 상한 자체는 결함이 아니다.
- 문제: 목록이 상한을 넘었는데도 success로 관찰해 운영자가 일부 처리만 된 사실을 알 수 없다.
- 수정: `WebPushObservation``countBucket: "0" | "1_8" | "9_32" | "33_64" | "GT_64"``truncated: boolean`을 추가한다. subscriptionchange는 32 초과 시 `LIMIT_EXCEEDED/DEGRADED`; notification cleanup은 64 초과 시 revoke authority와 분리된 cleanup observation을 `DEGRADED`로 기록하고 `{ complete: false }`를 반환한다. 무제한 loop나 전체 정리를 주장하지 않는다.
- 테스트: 33 clients, 65 notifications, owned item이 cap 밖에 있는 경우, account-neutral copy/click fence가 계속 안전함.
### WP-07 — user-visible native effect와 deadline result의 certainty
- 우선순위/분류: **P2 / CONTRACT_GAP**
- 근거: `runtime-support.ts:51-113`, `push-event-adapter.ts:144-174`, `notification-click-adapter.ts:144-176`
- 현재 동작: deadline/abort가 먼저 반환된 뒤 `showNotification`, `focus`, `openWindow`가 늦게 성공할 수 있다. 결과는 failure지만 user-visible effect는 발생할 수 있다.
- 결정: native 호출 전 terminal=`NOT_APPLIED`, native Promise pending 중 terminal=`MAYBE_APPLIED`, fulfillment=`CONFIRMED`로 phase를 고정한다. native-effect 전용 observation union에 effect를 두고 wrapper가 `onLateValue/onLateError`로 outer result 종료 뒤에도 safe observation을 한 번 남긴다. 이 observation을 authorization/retry에 사용하지 않는다. account-neutral notification과 click-time fence가 최종 안전 장치다.
## 유지해야 할 설계
- 한 scope에 physical Service Worker registration은 하나만 둔다.
- static install은 immutable hashed asset만 대상으로 하고 byte/digest 검증 후 all-or-nothing으로 공개한다.
- navigation, runtime config, release manifest, API response는 static cache에 넣지 않는다.
- `skipWaiting()`은 page/client drain handshake 이후에만 호출하고 baseline에서 `clients.claim()`은 사용하지 않는다.
- registration과 cache ownership을 exact scope/script/cache parser로 확인한다.
- Web Push endpoint, p256dh, auth, account/user ID, notification content를 durable fence/diagnostics에 저장하지 않는다.
- push와 click 모두 initial/final fence를 확인하고 arbitrary URL 또는 backend raw copy를 사용하지 않는다.
- revoke는 local generation fence를 먼저 commit하고 backend/native cleanup은 bounded best effort로 수행한다.
- notification cleanup 성공을 privacy 보장으로 주장하지 않는다. copy는 항상 account-neutral이어야 한다.
- `WEB_PUSH`가 선택되지 않은 현재 baseline에서 worker import/handler를 억지로 추가하지 않는다.
## 실행 순서
1. `SW-URL-01`, `SW-01`~`SW-04`, `WP-01`~`WP-03`을 독립 P1 PR로 처리한다.
2. 기존 2026-08-01 plan Task 4 bounded activation-marker reader를 완료한다.
3. `SW-05` shared build decoder와 기존 Task 5/`SW-10` protocol V2를 한 sequence로 구현한다.
4. `SW-06`~`SW-09`, `WP-04`~`WP-07`을 protocol/lifecycle PR로 나눈다.
5. 제품이 Web Push를 선택할 때 별도 composition 계획으로 registry/provider/consent/browser evidence를 추가한다.
집중 검증:
```bash
corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts \
tests/unit/service-worker-build-input.test.ts \
tests/unit/web-push-codec.test.ts \
tests/unit/web-push-fence-store.test.ts \
tests/unit/web-push-store-port-compatibility.test.ts \
tests/unit/web-push-runtime-support.test.ts \
tests/unit/web-push-subscription-adapter.test.ts \
tests/unit/web-push-worker-runtime.test.ts
corepack pnpm check:types
corepack pnpm check:architecture
corepack pnpm lint
git diff --check
```
## 완료 정의
- generated root-relative asset가 canonical absolute request와 일치하고, current cache 외 response가 반환되지 않으며 exact owned cache만 삭제된다.
- unregister/removal 결과가 실제 browser outcome을 숨기지 않는다.
- build gate가 static manifest row와 canonical set digest tamper를 거절한다.
- every command reply는 expected worker source, nonce, target full identity에 묶인다.
- fence mutation receipt가 exact next revision과 effect certainty를 보장한다.
- backend association receipt가 authority 3-tuple과 request digest에 묶인다.
- bounded truncation과 MAYBE_APPLIED native effect가 성공으로 과장되지 않는다.
- Web Push의 미조합 상태를 구현 완료로 오인하지 않는다.