# VD-28: Realtime events, Web Push와 bounded polling - 상태: Accepted — reference runtime available, product implementation pending - 결정일: 2026-07-28 - 관련 결정: VD-10, VD-13, VD-23, VD-24, VD-25, VD-26, VD-27, VD-29 - 상세 설계: `docs/architecture/realtime-events-web-push-and-bounded-polling.md` - 현재 product selection: `NOT_SELECTED` - common runtime delta: `AVAILABLE_NOT_COMPOSED` - 재검토: 첫 제품 stream/Web Push를 선택할 때, 또는 backend replay/hosting/provider protocol이 바뀔 때 ## 스트림 lifecycle은 freshness와 직교한다 (R-02, R-03) `RealtimeStreamLifecycle = OPEN | DRAINING | CLOSED`는 freshness (`UNKNOWN/CURRENT/STALE/RESYNCING`)와 별개다. - effect/recovery deadline에 도달하면 commit capability를 즉시 영구 무효화하고 abort한다. caller에는 bounded `IDLE_TIMEOUT`(non-retryable, operation `APPLY`/`RECOVER`)을 반환하되 **실제 task는 버리지 않고 retain**한다. - retain된 task가 하나라도 있으면 stream은 `DRAINING`이고 새 event/recovery admission을 거절한다. 실제 settlement가 일어나야 `STALE`로 돌아가 authoritative recovery를 요구하거나, close 요청이면 `CLOSED`가 된다. - `close()`는 `Promise>`다. 모든 retain task가 실제로 settle해야 success이고, drain bound를 넘기면 `IDLE_TIMEOUT/CLOSE`를 반환하며 stream은 계속 `DRAINING`이다. teardown success가 곧 quiescence다. - LIVE↔POLL overflow fail-close는 active/probe/quiescing/transition lease를 모두 abort한 뒤 **retired writer set**으로 옮기고 나서 reference를 지운다. `close()`는 current와 retired를 dedupe해 함께 기다리므로, 버려진 non-cooperative writer가 아직 실행 중인데 close가 성공을 보고할 수 없다. ## 배경 현재 optional recipe catalog는 realtime capability에 `referenceRuntime.status=AVAILABLE_NOT_COMPOSED`를 기록한다. 공통 event authority, bounded reconnect owner, single-writer live↔Poll handoff, fetch-stream SSE, bounded Polling, closed WebSocket protocol과 Web Push window/worker adapter는 deterministic test와 함께 존재하지만 production entry에서는 제외된다. generic mega `RealtimePort`, 제품 event schema, 실제 endpoint, backend replay/provider contract와 composition은 선택하지 않았다. 추가 설계 범위에는 성격이 다른 네 capability가 있다. - SSE: active document의 server-to-client event stream - WebSocket: active document의 duplex application protocol - Web Push: inactive browser에도 도착할 수 있는 Service Worker 기반 notification - bounded polling: 기존 HTTP/query operation의 제한된 scheduling policy 이를 “realtime transport” 하나로 합치면 다음 문제가 생긴다. - Web Push의 permission, push service와 worker lifecycle이 connection 상태에 숨는다. - Polling을 무한 timer나 transport downgrade로 오해한다. - WebSocket이 필요하지 않은 server notification까지 duplex protocol이 된다. - connection open, event delivery, application effect와 server 최신성을 같은 성공으로 표시한다. - auth refresh, reconnect, HTTP retry와 Query retry가 중첩된다. - gap, cursor expiry와 browser restore 뒤 authoritative resync owner가 사라진다. - push subscription endpoint/key나 cursor가 일반 application state와 telemetry에 노출될 수 있다. 기존 recipe의 generic `channel: string`, `sequence: number`, 고정 `resumeToken`, callback과 `heartbeat()`는 선택 시 복사해 좁힐 출발점이다. scope/epoch, closed event type, byte/queue limit, gap/reset, 진행되는 cursor, generation과 effect certainty가 없어 production wire authority로 사용할 수 없다. ## 현재 상태 | 항목 | 상태 | 설명 | | --- | --- | --- | | optional realtime catalog/recipe | `RECIPE_AVAILABLE` / product `NOT_SELECTED` | uncomposed reference runtime과 conformance script가 있음 | | common event/recovery/reconnect runtime | `AVAILABLE_NOT_COMPOSED` | scope/gap/barrier authority, finite reconnect owner와 exact close classification test가 있음 | | live↔Poll handoff coordinator | `AVAILABLE_NOT_COMPOSED` | monotonic generation, one effect writer와 bounded checkpoint/quiescence test가 있음 | | SSE runtime | `AVAILABLE_NOT_COMPOSED` | fetch-stream parser/adapter/reconnect test 있음; local server/browser evidence pending | | WebSocket runtime | `AVAILABLE_NOT_COMPOSED` | exact handshake/protocol/queue/recovery test 있음; load/browser evidence pending | | bounded polling coordinator | `AVAILABLE_NOT_COMPOSED` | finite single-flight visible/online lease와 deterministic budget test 있음 | | Web Push window/worker runtime | `AVAILABLE_NOT_COMPOSED` | subscription, registration/revoke, durable fence, strict inbound worker factory가 있음; provider/browser evidence pending | | exactly-once/global ordering | `PLATFORM_LIMITED` | 공통 browser delivery 목표로 보장하지 않음 | | always-on background connection/polling | `PLATFORM_LIMITED` | hidden/frozen/terminated document에서 보장하지 않음 | | timely cross-browser Web Push | `PLATFORM_LIMITED` | provider/browser/OS가 즉시 delivery를 보장하지 않음 | reference source는 `AVAILABLE_NOT_COMPOSED`까지 승격됐다. 그러나 이 ADR과 deterministic test만으로 `COMPOSED` 또는 `PRODUCTION_READY`로 올리지 않는다. 제품 endpoint/registry와 backend/provider/target-browser evidence가 생긴 뒤 선택 capability만 별도 승격한다. ## 결정 ### 1. 네 capability를 분리한다 다음 의미를 고정한다. | capability | 선택 의미 | 기본 fallback | | --- | --- | --- | | SSE | foreground one-way ordered hint stream | bounded polling 또는 stale UI | | WebSocket | foreground duplex interaction protocol | 의미가 축소되지 않으면 bounded polling, 아니면 disabled/stale UI | | Web Push | background user-visible notification hint | foreground inbox/focus refresh | | bounded polling | finite visible HTTP scheduling | manual refresh/explicit stale UI | Web Push는 SSE/WebSocket의 fallback이 아니라 보완 capability다. Polling은 WebSocket duplex 기능을 대신할 수 없다. SSE↔WebSocket 자동 downgrade도 하지 않는다. 같은 사용자 의미를 보존하는 fallback만 registry에 명시한다. 추가 transport를 선택하기 전 기존 TanStack Query의 focus/reconnect refetch와 manual refresh가 측정된 freshness 요구를 만족하는지 먼저 확인한다. Connect/gRPC-Web server stream은 VD-29/VD-27의 operation-bound API protocol이고 GraphQL subscription은 현재 `NOT_SELECTED`다. GraphQL `@defer`/`@stream`은 finite incremental HTTP response이지 realtime subscription이 아니다. RPC adapter가 protocol-specific terminal proof와 protobuf decode/schema/mapper를 끝낸 runtime-wide notification branch에서만 공통 scope/gap/resync coordinator를 재사용한다. frame/media/trailer, reconnect와 operation deadline owner를 SSE/WebSocket adapter로 합치거나 protobuf message를 `REALTIME_EVENT_V1` JSON으로 다시 감싸지 않는다. Polling의 개별 attempt는 VD-23의 terminal·replay-safe REST `QUERY` execution contract를 재사용하되 transport/Query retry는 끄고, 이 결정은 attempt 사이 bounded lease만 소유한다. ### 2. source of truth는 서버다 SSE/WebSocket event의 기본 효과는 registered `QueryInvalidationTopic`과 authoritative HTTP refetch다. raw event payload를 domain entity나 Query cache의 authoritative state로 자동 승격하지 않는다. authoritative delta 적용은 event type별 server revision, base revision, commit 뒤 publication, idempotent reducer, gap/reset과 snapshot reconciliation이 모두 승인된 경우에만 별도 선택한다. Web Push payload는 작은 opaque notification hint다. Poll response는 해당 HTTP representation의 결과다. 어느 것도 authorization이나 exactly-once effect를 증명하지 않는다. ### 3. outbound connection과 inbound event adapter를 분리한다 outbound가 소유한다. - fixed endpoint와 credential 협력 - connect/subscribe/resume/reconnect/close - selected WebSocket typed send - push subscription register/revoke - bounded poll scheduling/cancel inbound가 소유한다. - raw byte/frame hard cap - UTF-8/JSON/schema/version 검증 - stream/event/scope/generation 확인 - dedupe/order/gap - feature input 또는 query invalidation mapping - effect 뒤 cursor/ack commit application/domain에 native browser, TanStack, URL/header나 vendor type을 노출하지 않는다. `send(unknown)`과 arbitrary `channel`/endpoint도 금지한다. ### 4. target event protocol을 versioning한다 foreground common envelope은 다음 의미를 가져야 한다. ```text protocol = REALTIME_EVENT_V1 streamId = registry-owned ID streamEpoch = opaque server reset epoch eventType = closed registry ID eventId = bounded dedupe ID sequence = canonical unsigned decimal string recoveryMode = CURSOR | SNAPSHOT_ONLY | SESSION_REBUILD resumeCursor = CURSOR면 opaque replay position, 아니면 exact null occurredAt = strict RFC 3339, ordering authority 아님 scopeBinding = session/BFF-issued opaque exact-match token payload = event-type-specific closed codec ``` `eventId`, `sequence`, `resumeCursor`와 business revision은 별도 의미다. sequence는 JSON safe-integer 문제를 피하도록 decimal string으로 전달하고 stream + epoch 안에서만 비교한다. credential, readable subject/account ID, signed URL, PushSubscription material과 자유 형식 message는 envelope에 넣지 않는다. event type registry는 payload schema, pure boundary mapper와 effect profile을 함께 bind한다. `scopeBinding`은 cache fingerprint/authorization proof가 아니고, cursor는 protocol/stream/feed/epoch/registered subscription set/auth scope에 server-side로 bind한다. client는 opaque cursor를 해석하지 않는다. state-bearing stream의 recovery profile은 snapshot operation/checkpoint codec과 replay/connect-buffer/server-hold barrier를 닫는다. `SESSION_REBUILD`는 EPHEMERAL-only다. V1 server-side subset filter는 `NOT_SELECTED`이며 필요하면 contiguous sequence/checkpoint를 가진 별도 stream으로 등록한다. ### 5. delivery guarantee와 authoritative resync를 분리한다 apply 순서는 다음과 같다. ```text byte cap -> parse/schema/version -> registry/scope/generation -> dedupe/order/gap -> registered boundary mapper -> sequential application effect -> effect commit -> last-applied cursor -> optional selected WebSocket protocol ACK ``` effect 뒤 cursor를 commit하므로 crash window에서 duplicate가 생길 수 있다. effect는 idempotent하거나 query invalidation/refetch여야 한다. - 전체 browser lifecycle에 대한 delivery guarantee는 없음 - retention 안의 `CURSOR` foreground event 처리만 duplicate-tolerant at-least-once model - V1 ordering은 stream-wide 하나; partition은 별도 logical stream - exact duplicate/old sequence는 safe drop - 같은 event ID/sequence의 conflicting content는 protocol failure - old captured generation callback만 safe drop; current connection의 `scopeBinding` mismatch는 security protocol violation으로 close/revalidate/resync - sequence gap, stream epoch change, cursor expiry, queue overflow는 delta 적용 중단 - authoritative snapshot과 `SnapshotCheckpoint(streamEpoch,lastAppliedSequence,resumeCursor|null,snapshotRevision)`를 같은 commit point로 얻은 뒤에만 resume - exactly-once와 global ordering은 비목표 backend는 commit 이후 publication, replay retention, cursor reset과 snapshot/checkpoint 의미를 소유한다. subscribe ACK는 accepted cursor와 `nextExpectedSequence`를 반환한다. replay가 없는 `SNAPSHOT_ONLY`는 connect/bounded-buffer 또는 server hold barrier 없이는 snapshot/connect 사이 event를 잃을 수 있으므로 `CURRENT`를 보장하지 않고 finite revalidation/stale UX로 degrade한다. ### 6. lifecycle은 scope generation으로 fence한다 connection, freshness, authorization, availability와 traffic admission을 별도 상태 축으로 둔다. `connected: boolean` 하나로 표현하지 않는다. - runtime config/release/session recovery 뒤에만 connect한다. - route lease는 unmount에서 release한다. - logout/account/release transition은 old generation을 먼저 fence한다. - connect/read/backoff/poll/snapshot을 abort하고 queue/cursor/dedupe를 폐기한다. - late event/response/worker handoff는 captured old generation이면 적용하지 않는다. - close/dispose/unsubscribe는 terminal/idempotent다. - React StrictMode 반복 뒤 physical listener/connection/timer가 하나만 남는다. - admission은 canonical `DISABLED | SHADOW | CANARY | ENABLED`만 사용하고, drain은 connection lifecycle의 `DRAINING`으로 표현한다. - `DISABLED`는 새 data-plane side effect를 0으로 한다. 이미 소유한 fixed resource의 idempotent close/revoke만 bounded `DRAINING` cleanup plane에서 허용하며 `CLOSED` 뒤 network side effect는 0이다. hidden에서는 Polling을 중지하고 live connection은 configured bounded grace 뒤 close/pause한다. `pagehide`에서 document-owned SSE/WS/Poll을 모두 정리하고 `pageshow`/visible 복귀에는 snapshot freshness gate 뒤 새 runtime으로 resume한다. `unload` 완료에 의존하지 않는다. backend는 active authorization revoke를 close/control event로 전파하거나 bounded max connection age에 재인가한다. ### 7. retry owner를 하나로 제한한다 reconnect는 capped full-jitter exponential backoff를 사용한다. base/max delay, max attempts와 max elapsed는 immutable registry/implementation ceiling으로 제한한다. - stable-open window 또는 valid heartbeat/event 뒤에만 attempt reset - valid server hint는 local delay보다 이른 retry를 금지하는 not-before bound - server hint가 implementation max/remaining elapsed budget을 넘으면 낮춰 clamp하지 않고 degraded/stale로 종료 - offline에서는 timer retry를 멈춤 - auth expiry는 session owner single-flight recovery 한 번 - forbidden/protocol/schema failure는 terminal - 외부 rate/provider failure는 exact bounded server not-before hint가 있을 때만 retry하고, hint가 없으면 terminal - retry budget 소진 뒤 declared Polling fallback 또는 stale UI - reconnect는 realtime coordinator, auth는 session owner, Poll cadence는 poll coordinator가 소유하고 Poll-bound HTTP/Query retry는 비활성 - recovery checkpoint는 exact branded object identity로 다음 attempt에 전달한다. SSE `onOpen`/WebSocket `onSubscribed` proof와 attempt 성공 proof가 같은 object일 때만 common transport barrier를 확인하고 event admission을 연다. clone/missing proof와 30초 readiness deadline 초과는 fail-closed다. - aborted sleep/attempt/closed-receipt는 기본 2초 bounded drain 뒤 run을 fail-closed로 끝내되, 실제 old task가 settle할 때까지 `DRAINING`을 유지한다. 정상 active session의 `waitClosed`에는 deadline을 두지 않는다. ### 8. SSE baseline은 bounded fetch-stream이다 common reference target은 fixed same-origin BFF에 대한 fetch-stream SSE다. native EventSource보다 다음을 명시적으로 제어하기 위해서다. - credential integration - status/content type/redirect - AbortSignal과 lifecycle - parser/event byte ceiling - reconnect/idle/retry budget - explicit current cursor native EventSource는 same-origin cookie auth, native `Last-Event-ID`/reconnect, `204` terminal contract와 lifecycle 뒤 cursor recovery를 backend가 수용한 별도 profile에서만 허용한다. UA cursor를 application effect commit과 묶을 수 없으므로 `INVALIDATION_HINT` 전용이고 reconnect/restore마다 authoritative snapshot gate를 수행한다. gate 중 hint는 bounded `pendingInvalidation`으로 coalesce하고 checkpoint 뒤 pending refetch까지 drain한다. 이 buffer/barrier가 없으면 `CURRENT`를 금지한다. `AUTHORITATIVE_DELTA`는 fetch-stream만 허용한다. token을 URL에 넣지 않는다. fetch-stream parser는 표준 UTF-8 SSE format, BOM/line ending/comment/multi-line data/id/retry/incomplete EOF를 bounded하게 구현한다. exact `200 text/event-stream`만 stream 성공이며 auth/rate/reset/provider status를 closed failure로 mapping한다. parsed candidate ID와 effect-committed cursor를 분리하고 각 application event block의 직접 `id`와 envelope cursor를 exact match한다. SSE baseline은 registry-owned session feed 하나와 feed-wide cursor 하나다. route lease는 local dispatch만 바꾸며 arbitrary server multiplex와 per-subscription cursor는 `NOT_SELECTED`다. hosting은 proxy buffering, idle/request timeout, heartbeat, cache/transform, HTTP connection budget와 client disconnect cleanup을 실제로 검증한다. ### 9. WebSocket은 versioned duplex protocol로만 선택한다 - fixed same-origin `wss:` endpoint와 exact subprotocol - server `Origin` 검증과 current session authorization - URL/query/subprotocol에 credential 금지 - closed welcome/subscribe/unsubscribe-ack/event/reset/heartbeat/close frame - baseline text JSON, binary/extension은 별도 승인 - application heartbeat/watchdog - bounded incoming sequential queue - bounded outgoing queue와 `bufferedAmount` - raw close reason redaction - same-epoch cursor resume의 `nextExpectedSequence = lastApplied + 1`; accepted cursor silent advance 금지, mismatch는 reset/snapshot - state-bearing initial subscribe는 snapshot/checkpoint + barrier 전 `CURRENT` 금지 - `UNSUBSCRIBE` 뒤 matching `UNSUBSCRIBED`까지 tombstone과 quota를 유지하고 late event/control은 effect 없이 버린다. unknown ACK와 ACK deadline 초과는 connection-level failure다. classic browser WebSocket은 incoming backpressure를 제공하지 않으므로 queue overflow에서 임의 delta drop을 하지 않는다. baseline은 connection을 close하고 snapshot resync한다. server의 bounded pause/resume ACK protocol을 별도 증명한 profile에서만 subscription pause를 허용한다. 모든 client control frame은 하나의 FIFO outbound queue를 통과한다. negotiated message count/queued bytes와 native `bufferedAmount` 중 하나라도 넘으면 `QUEUE_OVERFLOW`, `retryable=false`, `OVERLOADED`로 generation 전체를 닫고 snapshot recovery를 요청한다. durable business command는 기존 HTTP path를 기본으로 유지한다. WebSocket command를 선택하면 closed operation, command ID/idempotency, expected revision, ack와 business commit certainty를 별도로 정의한다. ### 10. Web Push는 별도 window/worker/backend capability다 Web Push 선택에는 다음이 모두 필요하다. - user-action 기반 permission UX - active Service Worker registration - `userVisibleOnly: true`인 window subscription manager - authenticated backend register/revoke - server subscription registry - VAPID private-key/provider owner - worker push/notification/click inbound adapters PushSubscription endpoint, `p256dh`, `auth`는 capability material로 취급하고 application state, browser storage, URL, BroadcastChannel과 telemetry에서 금지한다. VAPID private key는 server-only다. push payload는 versioned, association/release-bound, expiring opaque notification hint로 제한한다. 개인 내용은 foreground BFF가 current authorization으로 조회한다. worker handler는 `waitUntil` 안에서 bounded validation과 `showNotification`만 수행하며 long retry/sync/migration을 하지 않는다. decoded application hint는 3 KiB를 넘지 않으며 최상위 JSON member name 중복은 last-wins로 해석하지 않고 거절한다. `issuedAt`의 client clock 대비 future skew는 최대 5분, `expiresAt - issuedAt` lifetime은 최대 24시간이다. window의 native permission/subscription operation은 30초, backend register/reconcile/revoke operation은 15초 안에 종료하며 제품 config는 이 implementation ceiling을 높일 수 없다. `pushsubscriptionchange` window handoff도 worker lifecycle abort와 10초 deadline을 사용하고, non-cooperative `matchAll()` 또는 동기 `waitUntil()` 예외 뒤에는 늦은 `postMessage`를 허용하지 않는다. notification copy와 click route는 closed registry를 사용한다. arbitrary backend text나 URL을 OS notification/openWindow에 전달하지 않는다. worker restart 뒤 click을 처리하도록 bounded non-sensitive `NotificationClickDataV1`만 `NotificationOptions.data`에 넣고 click 시 codec/expiry/current association/release를 다시 검증한다. logout 때 owned notification은 bounded best-effort close하지만 OS 잔존 가능성 때문에 copy는 항상 account-neutral이어야 한다. worker는 window in-memory session을 authority로 사용할 수 없으므로 opaque `fenceGeneration`, `sessionBindingEpoch`, `releaseEpoch`과 `UNASSOCIATED | ACTIVE | REVOKED` association discriminant를 가진 adapter-owned IndexedDB `PUSH_CONTROL_V1` record를 사용한다. account ID, endpoint/key, credential과 notification content는 이 record에서 금지한다. missing/corrupt/mismatch는 fail-closed한다. 동일 association epoch의 `REVOKED`는 terminal tombstone이다. logout은 durable fence generation rotate와 REVOKED를 먼저 commit한다. 새 `ACTIVE`는 distinct backend epoch와 captured/current fence generation, server session binding, prior record revision/epoch, release를 한 IDB transaction에서 CAS해 stale-tab response를 거절한다. 첫 register 전 `UNASSOCIATED` record도 같은 generation을 durable하게 보관하므로 logout과 in-flight register response의 race를 association sentinel 없이 닫는다. client `updatedAt`은 ordering authority가 아니다. logout은 old generation fence, durable local association `REVOKED` commit과 backend account association revoke를 정상 security commit으로 사용한다. boot에서 native subscription/local fence/server association을 reconcile하고, local commit 실패나 ambiguous revoke는 `PUSH_UNAVAILABLE`로 내려 짧은 TTL, send-time auth와 click-time 재인가에 의존한다. native unsubscribe/old notification close는 best-effort지만 captured native subscription, exact association tag와 unchanged durable fence를 모두 다시 확인한 경우에만 수행한다. 새 association이 commit되면 old cleanup은 건너뛴다. local fence 실패 뒤 current native subscription 조회나 association wildcard cleanup은 금지한다. control tombstone purge는 자동 revoke 단계가 아니다. 별도 maintenance owner만 captured revision/authority/association epoch가 exact한 `REVOKED` record를 repository CAS로 삭제할 수 있고, concurrent newer owner가 있으면 `STALE_REVISION`으로 끝난다. backend register/revoke는 VD-23의 fixed `COMMAND`로 등록하고 cookie session의 exact CSRF를 검증한다. register는 keyed idempotency 또는 atomic installation upsert/receipt, revoke는 duplicate/`ALREADY_GONE` 성공 의미를 가져야 하며 `associationEpoch`은 server commit 뒤에만 발급한다. Service Worker를 우회해 UA가 직접 notification을 표시할 수 있는 declarative push message는 V1에서 `NOT_SELECTED`다. outbound `web_push: 8030` shape를 거절하고 별도 ADR 전에는 encrypted `WEB_PUSH_HINT_V1`만 허용한다. Service Worker를 선택해도 offline fetch, PWA shell cache나 background sync가 자동 승인되지 않는다. 하나의 worker composition/update owner가 선택된 handler를 조립한다. ### 11. Polling은 bounded lease다 허용 형태: - visible query의 낮은 빈도 conditional freshness poll - 사용자 시작 async job의 terminal-state convergence poll 각 lease는 operation owner, minimum/success/max interval, max attempts, max elapsed, response byte cap, visible-only policy와 terminal states를 가진다. - operation은 registered terminal·replay-safe REST `QUERY`여야 함 - Poll `maxAttempts`는 physical request 하나인 logical completion을 셈 - Poll-bound VD-23 budget은 `maxAttempts=1`, `authRecoveryCount=0`, `maxCumulativeSleepMs=0`; TanStack Query retry도 끔 - completion-chained timeout으로 single-flight - hidden/offline/pagehide/unmount/scope change/user cancel에서 stop - ETag/`If-None-Match` 또는 server cursor 사용 - `304`, auth, cursor reset, `429/503 Retry-After`를 closed mapping - common recovery coordinator가 `POLL_ACTIVE -> LIVE_PROBING`에서 poll만 effect writer로 유지하고 live candidate는 bounded buffer만 사용. handoff mutex에서 poll fence/abort + quiescence를 먼저 완료하고 current-generation snapshot/checkpoint와 buffered event를 적용한 뒤 live를 활성화 - active writer effect tail도 in-flight 포함 256건/4MiB로 제한하고 overflow는 전체 generation을 `QUEUE_OVERFLOW`로 fail-close - budget 소진 뒤 manual refresh/stale UI - page component `setInterval`과 unlimited loop 금지 ### 12. resource ceiling과 privacy를 fail-closed한다 상세 설계의 target hard ceiling은 physical connection, logical subscription, event/frame/parser/queue/dedupe/reorder/outbound buffer, reconnect, poll lease, push hint와 worker deadline을 제한한다. 제품 config는 더 작게만 설정할 수 있다. 2026-07-28 reference-runtime amendment로, RT-01~RT-04 source 전체를 tree-shaking 없이 합성하는 optional-recipe gzip 예산을 40,000 bytes로 고정한다. 이는 production bundle 허용량이 아니며 미선택 production asset의 realtime module 허용량은 계속 0이다. SSE replay-open과 WebSocket `SUBSCRIBED`가 exact recovery checkpoint를 증명하고 common barrier가 확인될 때까지 event admission을 막는 readiness gate는 attempt당 최대 30초다. phase abort 뒤 비협조적인 retry sleep, connect attempt 또는 closed-receipt cleanup을 기다리는 drain은 2초로 고정하고 구현 절대 최대는 30초다. 상한을 넘긴 task가 settle할 때까지 lifecycle은 `DRAINING`을 유지하며 정상 active session의 `waitClosed`에는 이 cleanup deadline을 적용하지 않는다. ceiling 초과는 limit 자동 인상이나 silent drop이 아니라 new lease rejection, connection close, snapshot resync, typed backpressure, stale/degraded 또는 notification drop으로 처리한다. telemetry에는 transport/registry ID, closed outcome, count/duration/lag bucket만 허용한다. raw URL/query/credential/subject/event ID/cursor/payload/close reason/ PushSubscription key와 notification private content는 금지한다. ### 13. 실제 provider/browser/operations evidence 전에는 promotion하지 않는다 evidence를 분리한다. 1. pure unit/property와 deterministic fault contract 2. 실제 local SSE/WS server integration 3. backend replay/snapshot/auth/hosting/provider conformance 4. built production asset의 target-browser lifecycle 5. Web Push provider + browser/OS 자동·수동 evidence 6. load/chaos/security negative gate 7. dashboards, kill switch와 drain/recovery/rollback drill fake/jsdom/MSW만으로 native stream, socket, worker, notification이나 provider readiness를 주장하지 않는다. 외부 evidence가 없으면 `PromotionEvidence`는 `MISSING | PARTIAL`이고 promotion gate result는 `FAIL_UNVERIFIED`다. ## 선택하지 않은 대안 ### 범용 transport enum을 가진 `RealtimePort` 전송 교체는 가능해 보이지만 direction, permission, lifecycle, delivery certainty와 fallback 의미를 잃는다. 공통 protocol coordinator만 재사용하고 native capability port는 분리한다. ### 모든 server event에 WebSocket 사용 one-way notification에도 duplex handshake, heartbeat, queue와 server connection 운영 비용을 강제한다. one-way stream은 SSE를 우선 검토한다. ### native EventSource만 공통 baseline으로 사용 arbitrary auth header, detailed status mapping, bounded reconnect와 explicit lifecycle cursor 제어가 부족하다. 조건부 profile로는 허용하지만 reference baseline은 fetch-stream이다. ### token을 SSE/WS URL에 전달 history, log, proxy, analytics와 referrer에 노출될 수 있다. same-origin BFF/cookie 또는 승인된 별도 handshake를 사용한다. ### event payload로 Query cache 직접 patch filter/pagination/revision/gap 의미가 없으면 stale projection을 만든다. 기본은 namespace invalidation과 authoritative refetch다. ### Web Push를 silent sync로 사용 permission/browser/OS/provider가 background execution과 timely delivery를 보장하지 않는다. user-visible notification hint와 foreground refresh로 제한한다. ### 무한 `setInterval` Polling overlap, hidden resource 사용, retry 중첩과 terminal cleanup 누락을 만든다. finite immutable lease와 single owner를 사용한다. ### cross-tab leader를 기본 제공 leader election/crash/handoff/partition과 SharedWorker 지원이 별도 protocol을 요구한다. 기본은 tab별 bounded runtime과 focus snapshot이다. ### exactly-once delivery cursor commit과 application effect 사이 crash window, push service와 browser lifecycle을 공통 frontend만으로 제거할 수 없다. retention 안의 CURSOR event만 duplicate-tolerant하게 처리하고 나머지는 best-effort + authoritative resync를 사용한다. ## 결과 긍정적 결과: - 전송 선택이 요구와 failure semantics에 연결된다. - server state/query ownership과 clean architecture 경계를 유지한다. - gap, late callback, logout과 page restore가 명시적 복구 경로를 가진다. - Web Push permission/subscription material이 일반 realtime state와 분리된다. - Polling fallback이 resource-unbounded loop가 되지 않는다. - 미선택 capability의 bundle/worker/runtime side effect를 0으로 유지할 수 있다. 비용: - common coordinator 외에도 transport별 adapter와 실제 provider harness가 필요하다. - backend는 replay/snapshot/outbox/auth와 provider 운영 계약을 제공해야 한다. - worker와 window에 별도 composition/test matrix가 필요하다. - direct delta보다 invalidation/refetch가 추가 HTTP 비용을 만들 수 있다. - target browser/OS에서 자동화할 수 없는 Web Push evidence를 운영해야 한다. ## 구현 순서 ```text RT-00 contract/status -> RT-01 event authority + scope/gap/resync -> RT-02 SSE + bounded polling -> RT-03 WebSocket -> RT-04 Web Push -> RT-05 product composition/provider/browser/operations ``` SSE와 WebSocket을 모두 구현해야 skeleton이 완성되는 것은 아니다. 공통 mechanism을 구현한 뒤 실제 product requirement에 필요한 최소 transport만 선택한다. reference source와 deterministic/native evidence가 생기면 해당 runtime만 `AVAILABLE_NOT_COMPOSED`로 올린다. 제품 endpoint/event registry/policy가 bootstrap에 연결된 transport만 `COMPOSED`다. ## Rollout capability별 traffic admission: ```text DISABLED -> SHADOW -> CANARY -> ENABLED SHADOW | CANARY | ENABLED -> DISABLED ``` - transport, stream, Poll fallback과 push category kill switch를 분리한다. - safe config default는 `DISABLED`다. - canary 전에 backend/provider/browser/operations evidence를 만료 검증한다. - deploy/drain과 reconnect herd를 load test한다. - freshness/latency만 아니라 gap/resync/queue/memory/battery/push permission 지표를 함께 본다. ## Rollback과 제거 1. admission을 `DISABLED`, connection lifecycle을 `DRAINING`으로 전환한다. 2. logical subscription/send/poll/push registration을 중지한다. 3. active reader/socket/timer/handler를 bounded close한다. 4. HTTP focus/manual refresh 또는 명시된 fallback을 노출한다. 5. server publisher/replay/subscription compatibility window를 유지한다. 6. composition/registry/adapter/dependency/worker handler를 제거한다. 7. CSP/runtime config/provider key와 retained server subscription을 정리한다. 8. typecheck, architecture, tests, build, bundle/module inventory와 removal gate를 실행한다. 미선택/제거 상태에서 connection, timer, push listener/subscription request와 production bundle sentinel이 0이어야 한다. ## 완료 기준 ### 이 결정의 설계 완료 - [x] 네 capability의 의미와 선택 조건을 분리했다. - [x] current status와 target runtime 상태를 구분했다. - [x] source of truth와 delivery/effect certainty를 정했다. - [x] target envelope, ordering, cursor와 resync를 정했다. - [x] lifecycle/retry/resource/security/privacy 경계를 정했다. - [x] transport별 auth/hosting/worker/Poll contract를 정했다. - [x] evidence, rollout, rollback과 제거 기준을 정했다. ### 구현과 promotion 상태 - [x] RT-01 공통 coordinator/reconnect/contract suite - [x] RT-02 SSE/Poll 및 single-writer handoff reference runtime과 deterministic evidence - [x] RT-03 WebSocket reference runtime과 deterministic evidence - [x] RT-04 Web Push window/worker reference runtime과 deterministic evidence - [x] static boundary/security fixture, synthetic bundle budget와 removal blocking gate - [ ] actual SSE/WS local server, load와 target-browser evidence - [ ] actual Web Push provider, permission UX와 target-browser evidence - [ ] provider/browser evidence와 operations drill의 release-blocking gate 등록 - [ ] 실제 product/backend/provider selection - [ ] operations runbook drill common runtime status는 `AVAILABLE_NOT_COMPOSED`다. 위의 미완료 promotion 항목 전에는 product selection이 계속 `NOT_SELECTED`이고 production-ready를 주장하지 않는다. ## 관련 자료 - [상세 설계](../realtime-events-web-push-and-bounded-polling.md) - [VD-10 optional capability recipes](./VD-10-optional-capability-recipes.md) - [VD-13 client cache scope and persistence](./VD-13-client-cache-scope-and-persistence.md) - [VD-23 API transport selection and REST execution](./VD-23-api-transport-selection-and-rest-execution.md) - [VD-25 Server State Cache lifecycle](./VD-25-server-state-cache-lifecycle.md) - [VD-27 gRPC-Web unary and server stream](./VD-27-grpc-web-unary-and-server-stream.md) - [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) - [Optional adapter recipes](../optional-adapter-recipes.md) - [Client cache and browser storage](../client-cache-and-storage.md) - [Frontend ports, adapters, and boundaries](../frontend-ports-adapters-and-boundaries.md) - [WHATWG Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html) - [WHATWG WebSockets](https://websockets.spec.whatwg.org/) - [W3C Push API](https://www.w3.org/TR/push-api/) - [WHATWG Notifications API](https://notifications.spec.whatwg.org/) - [W3C Service Workers](https://www.w3.org/TR/service-workers/) - [RFC 8030](https://www.rfc-editor.org/rfc/rfc8030) - [RFC 8291](https://www.rfc-editor.org/rfc/rfc8291) - [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)