# 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..js` fixture와 absolute `https://app.example/assets/app..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의 미조합 상태를 구현 완료로 오인하지 않는다.