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

28 KiB

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.tsscripts/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() === falseUNREGISTERED로 보고

  • 우선순위/분류: 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_REGISTRATIONPURGE_OWNED_RESOURCES는 실제 outcome과 무관하게 DISABLED를 반환한다.
    • disabledCleanupOWNERSHIP_MISMATCHDISABLED로 반환한다.
  • 영향: 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 재계산을 확인하지 않는다.
    • 잘못된 contentTypestoreAsset().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.originevent.source를 검증하지 않는다.
    • general listener/reset은 origin 일부만 확인하며 expected waiting/controller source와 correlation하지 않는다.
    • 동시에 requestActivation() 또는 resetOwnedCaches()를 여러 번 호출하면 nonce와 listener가 중복 생성된다.

결정:

  • activation reply는 request 시 capture한 registration.waitingevent.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-01SW-04 → 기존 plan Task 4 bounded activation-marker reader → SW-05 build decoder와 기존 Task 5/SW-10 통합 → SW-06SW-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. WebPushFailureCodeMUTATION_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는 associationEpochsessionBindingEpoch만 반환하고 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을 사용한다.

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로 관찰해 운영자가 일부 처리만 된 사실을 알 수 없다.
  • 수정: WebPushObservationcountBucket: "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-01SW-04, WP-01WP-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-06SW-09, WP-04WP-07을 protocol/lifecycle PR로 나눈다.
  5. 제품이 Web Push를 선택할 때 별도 composition 계획으로 registry/provider/consent/browser evidence를 추가한다.

집중 검증:

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의 미조합 상태를 구현 완료로 오인하지 않는다.