# Realtime And Push Contract

| 항목 | 값 |
|---|---|
| Status | Active realtime/push contract |
| Owner | 공통 계약: 주우철·정정일 |
| Last verified | 2026-07-16 KST |
| Supersedes | 초기 notification-only mock 계획 |

이 문서는 ZERRO chat/notification의 realtime transport와 OS push provider 경계를 정리한다. APP/Web/PC가 서로 다른 임시 프로토콜을 만들지 않도록, backend wire contract를 먼저 고정한다.

## Current Decision

| 항목 | 결정 |
|---|---|
| Chat in-room realtime | backend/Web 정본인 STOMP-over-WebSocket을 따른다 |
| Web 인증 | `/ws` handshake의 httpOnly access cookie |
| APP 인증 | STOMP `CONNECT` frame의 `Authorization: Bearer <accessToken>` |
| Topic | `/topic/chat/{roomId}` |
| Event schema | `PcChatRealtimeEventSchema` |
| APP fallback | STOMP transport가 주입되지 않은 기본 상태에서는 REST list/detail/read/send + explicit refresh를 유지 |
| OS push | Expo push provider를 1차 provider로 둔다. direct FCM/APNs는 후속 adapter 후보로만 남긴다 |

### Push policy approval and recipient source

2026-07-16에 제품 책임자가 [Push Notification Matrix](./PUSH_NOTIFICATION_MATRIX.md)의 Google Sheet `시트!A1:F13` snapshot을 기준으로 역할별 수신 matrix를 승인했다. 원본은 [0602_채팅방 알림 종류 정리](https://docs.google.com/spreadsheets/d/1FgLP5COPtsot4ZQP2Ov4-ancDxyPnZvEWBX-XSgIih4/edit?gid=0#gid=0)이며, matrix의 `채팅창 (3명)`은 OS push가 아니라 in-app chat system card projection을 뜻한다.

- `처리 대기`는 수신자를 두지 않는다.
- `상차 시작`은 문서상 운전자 대상이지만 현재 backend는 별도 이벤트를 발행하지 않는다. 운전자 액션이 상차 시작·완료 전이를 한 번에 처리하므로, 별도 push를 추가하려면 상태 모델과 event contract를 먼저 바꿔야 한다.
- lock-screen copy는 업체명·폐기물·전화번호·인계번호·주소 원문을 포함하지 않는다.
- actor self event는 기본적으로 suppress한다.
- tap은 session 복구 → role 확인 → 대상 상세 이동, 실패 시 해당 역할 알림 목록 fallback 순서로 구현한다.
- driver는 chat viewer가 아니므로 driver push는 chat room으로 deep link하지 않는다.

이 승인으로 제품 정책 `DECIDE` gate는 닫혔고, local/dev token registration/storage는 #1023에서 구현·검증됐다. Expo delivery, receipt/retry, 실제 tap 복원과 production Expo credential은 구현·운영 검증 gate로 남는다.

APP에서 별도 SSE, long polling, custom socket protocol을 새 정본으로 만들지 않는다. 필요하면 `AppChatRealtimeTransport` seam을 만들되 event envelope은 `PcChatRealtimeEventSchema`를 재사용한다.

## Backend Wire Contract

Backend는 Spring STOMP simple broker를 사용한다.

- Endpoint: `/ws`
- Broker destination: `/topic/chat/{roomId}`
- APP auth path: STOMP `CONNECT` native header `Authorization: Bearer <accessToken>`
- Web auth path: `zerro_access` httpOnly cookie promoted during handshake
- Subscribe authorization: `/topic/chat/{roomId}`는 `chat_room_member` membership이 있는 사용자만 허용한다
- Dev broker: Spring simple broker, heartbeat `10s/10s`
- Production scale-out: external broker or durable event replay/idempotency decision이 필요하다

Backend event publisher는 `ChatWirePublisher`다. It emits only these event families:

- `message.created`
- `message.read`
- `typing`
- `presence`

Wire payload에는 client-derived fields such as `mine` or formatted `at`을 넣지 않는다. 클라이언트가 active user/role context로 파생한다.

## Client Contract

### Web/PC

Web/PC는 이미 `PcChatTransport` seam을 사용한다.

- `NoopPcChatTransport`: fallback
- `MockSsePcChatTransport`: mock/dev event source
- `StompWsPcChatTransport`: backend STOMP-over-WS

### Mobile APP

APP은 #841 이후 REST runtime binding이 완료됐다.

- list: `GET /chat/rooms`
- detail: `GET /chat/rooms/{roomId}`
- send: `POST /chat/rooms/{roomId}/messages`
- read: `POST /chat/rooms/{roomId}/read`
- realtime seam: `AppChatRealtimeTransport`
- default transport: `NoopAppChatRealtimeTransport`
- STOMP transport class: `StompAppChatRealtimeTransport`

APP runtime transport가 주입되지 않은 기본 상태에서는 다음만 허용한다.

- room open 시 detail fetch
- send 성공 response 기반 local detail/list refresh
- room open/read action 기반 read-state sync
- user-triggered retry/refresh
- foreground-only explicit refresh
- future realtime event 또는 reconnect callback 수신 시 REST detail/list refresh로 message gap을 메운다

다음은 금지한다.

- 화면 컴포넌트에서 raw WebSocket/STOMP client 직접 생성
- APP 전용 realtime event schema 신설
- OS push provider를 in-room realtime 대체 수단으로 사용하는 것
- background notification delivery를 read-state persistence로 간주하는 것

APP foreground realtime 구현은 pure JS `@stomp/stompjs`를 사용한다. 새 native module을 추가하지 않는다.

- `StompAppChatRealtimeTransport`는 Web/PC와 같은 `/topic/chat/{roomId}`와 `PcChatRealtimeEventSchema`를 사용한다.
- `beforeConnect`에서 APP bearer token을 다시 읽어 `Authorization` CONNECT header를 갱신한다.
- invalid JSON 또는 schema 불일치 frame은 무시한다.
- 최초 연결은 기존 REST detail fetch와 중복하지 않고, reconnect에서는 `onReconnect`를 통해 REST detail/list refresh로 message gap을 메운다.
- backend API base URL은 `resolveAppChatRealtimeWsUrl`로 `/ws` endpoint로 변환한다.
- 실제 runtime 주입은 `bootstrapAppChatRealtime`가 APP root layout에서 수행한다. `EXPO_PUBLIC_API_BASE`가 없거나 websocket URL로 변환할 수 없으면 `NoopAppChatRealtimeTransport`로 명시적으로 수렴한다.

APP runtime injection은 아래 기준으로 유지한다.

- Web/PC 정본인 STOMP-over-WebSocket과 `PcChatRealtimeEventSchema` 재사용
- `setAppChatRealtimeTransport`로 feature 밖에서 주입. screen/hook에서 raw client 생성 금지
- token restore는 `createSecureMobileTokenStore`에서 connection 직전 다시 읽는다
- reconnect는 room hook의 REST detail/list refresh callback으로 message gap을 메운다
- cleanup은 현재 active transport가 bootstrap에서 만든 instance일 때만 no-op fallback으로 되돌린다
- duplicate message idempotency, durable replay, production broker topology는 backend/realtime 운영 결정으로 별도 관리한다

## Push Provider Boundary

OS push는 chat room foreground realtime과 다른 계층이다.

| 계층 | 책임 | 현재 상태 |
|---|---|---|
| Backend persistence | notification row, chat room/member/message/read receipt 저장 | implemented |
| Foreground realtime | open room에서 message/read/typing/presence 수신 | backend/Web implemented, APP runtime injection bootstrap covered for local/dev `EXPO_PUBLIC_API_BASE` |
| OS push | background/terminated device notification | Expo push primary contract + APP permission/token payload skeleton covered, local/dev backend registration/storage covered, delivery/credential/tap evidence pending |
| Deep link | notification tap -> role/surface/detail route | shared schema skeleton covered, runtime route restore pending |

Push provider가 도입돼도 message source of truth는 backend persistence다. Push payload는 notification summary/deep link hint이며, room history는 REST detail 또는 realtime resync로 확인한다.

현재 1차 provider는 Expo push다. APP은 production credential이 붙기 전까지도 registration payload의 `provider`를 `expo`로 보낸다. direct FCM/APNs는 provider enum과 adapter extension 후보로만 유지하고, 별도 native credential/build/store decision 없이는 APP 기본 경로로 사용하지 않는다.

APP/backend는 `@zerro/contracts`의 다음 schema를 따른다.

- `MobilePushPrimaryDeviceRegistrationRequestSchema`: APP 기본 registration shape. `provider=expo`와 Expo push token 형식을 요구한다
- `MobilePushDeviceRegistrationRequestSchema`: backend storage/API 확장을 위한 provider-agnostic shape. direct FCM/APNs는 이 schema 안의 future adapter candidate다
- `MobilePushDeviceRegistrationResultSchema`: backend-owned registration id와 active/disabled 상태
- `MobilePushDeliveryPayloadSchema`: backend notification row에서 파생된 title/body/category/deep link summary
- `MobilePushDeepLinkSchema`: role home, notification detail, emission detail, driver assignment, processor inbound, chat room

### Device Registration API Shape

local/dev backend API는 기존 `device_token` 테이블을 재사용한다. 기존 migration comment에는 provider 미확정 전제가 남아 있으나 checksum 리스크 때문에 migration은 수정하지 않고, 현재 정본은 이 문서와 contracts다.

- Method/path: `PUT /mobile/push/device-registrations`
- Auth: APP bearer session
- Request: `MobilePushPrimaryDeviceRegistrationRequestSchema`
- Response: `MobilePushDeviceRegistrationResultSchema`
- Storage target: `device_token(user_id, provider, token, platform, app_id, app_version, build_number, permission_status, locale, time_zone, last_seen_at, deleted_at)`
- Upsert key: 살아있는 `token`을 기준으로 같은 사용자·token metadata를 갱신하고, 다른 사용자에게 재할당되면 이전 row를 soft-delete한 뒤 새 row를 만든다
- Permission denied/undetermined: token이 없으면 registration API를 호출하지 않는다. token이 이미 있으나 OS permission이 내려가면 backend는 registration을 `disabled`로 전환할 수 있다
- Audit/log: provider token 원문은 application log, PR evidence, UX artifact에 남기지 않는다

APP은 `createMobilePushRegistrationClient`를 통해 알림 설정 화면에서 OS permission 상태를 사용자-facing copy로 보여준다. 실제 Expo token 생성은 permission granted/provisional 상태와 Expo project id가 있을 때만 시도하고, `MobilePushPrimaryDeviceRegistrationRequestSchema`로 payload를 검증한다. local/dev backend는 `PUT /mobile/push/device-registrations`와 owner-scoped `DELETE /mobile/push/device-registrations/{registrationId}`를 제공한다. APP의 자동 registration call, delivery/receipt/retry, production Expo credential과 tap runtime은 별도 promotion gate다.

Push payload에는 다음 값을 넣지 않는다.

- provider credential
- signed URL
- object key
- OCR raw text
- user token/session secret

운전자는 lifecycle notification과 assignment 대상이지만 current APP chat room viewer role은 아니다. 따라서 `chat_room` push deep link는 `AppChatRoleSchema`를 사용하며 `emitter | dispatcher | processor`만 허용한다. 운전자용 push는 `driver_assignment` deep link로 보낸다.

## Verification Evidence

Current evidence:

- Backend `WebSocketConfig`: `/ws`, Bearer CONNECT, membership subscribe guard
- Backend `ChatWirePublisher`: realtime event envelope publish
- Backend `ChatStompIntegrationTest`: REST send -> `message.created`, typing, presence smoke
- Backend `ChatRoomFlowTest`: read cursor/receipt persistence
- Contracts `pc-chat-realtime.test.ts`: event schema variants
- Web `PcChatTransport`: no-op/mock/STOMP transport seam
- Mobile `createRuntimeAppChatClient`: REST runtime binding for list/detail/read/send
- Mobile `AppChatRealtimeTransport`: Web/backend realtime event schema를 받는 default no-op seam. config missing/invalid 상태에서는 REST refresh fallback만 수행
- Mobile `StompAppChatRealtimeTransport`: pure JS `@stomp/stompjs` adapter, Bearer CONNECT header refresh, `/topic/chat/{roomId}` subscribe, schema-filtered event delivery, reconnect REST refresh callback
- Mobile `bootstrapAppChatRealtime`: APP root layout에서 `EXPO_PUBLIC_API_BASE`를 `/ws` URL로 변환하고 SecureStore bearer token lookup을 STOMP transport에 주입. missing/invalid API base는 explicit no-op fallback
- Contracts `mobile-push.test.ts`: device registration payload, delivery deep link payload, sensitive field stripping, driver chat deep link rejection
- Mobile `createMobilePushRegistrationClient`: notification permission view model, Expo primary token registration payload preparation, bearer-gated registration request skeleton
- Backend `MobilePushDeviceRegistrationFlowTest`: Expo token format·permission·platform validation, same-user upsert, account reassignment soft revoke, owner-scoped revoke, response redaction을 local/Testcontainers에서 검증
- Backend `V61__push_device_registration.sql`: 기존 `device_token`에 APP metadata·permission 상태와 `notification:register` RBAC를 추가
- Mobile Maestro `emitter-notifications`: 알림 설정 surface 진입과 push permission card 노출 smoke
- Mobile Maestro `emitter-chat-backend`, `common-chat-backend`: backend-mode screen send smoke

## Open Decisions

- Production broker topology beyond the approved initial single-replica policy, replay/idempotency persistence and SLA
- Expo push delivery/receipt/retry implementation, production credential, delivery retention, and production registration lifecycle
- Direct FCM/APNs adapter activation, credential/build/store validation
- Notification tap runtime evidence: role/session restoration, route fallback, expired-auth handling
- Separate `loading_started` push/event emission, if the domain later splits the atomic loading action into two states
