Files
tech-log-frontend/docs/reviews/adapters/05-service-worker-and-web-push.md
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +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의 미조합 상태를 구현 완료로 오인하지 않는다.