# heymoa-web ↔ heymoa-server 비동기 구간 계약. 두 출처의 합본이다.
#
# - 전사 STOMP·노트 토픽 구간: heymoa-server/asyncapi.yml 미러. 수정은 원본에서 하고 여기로 복사한다
#   (원본은 `*WebSocketDocsTest`가 실제 DTO 직렬화로 검증한다).
# - agent 채팅 SSE 구간: 이 파일이 원본. server는 이벤트를 변환 없이 통과시켜 자기 DTO를 갖지
#   않으므로 server 쪽 DocsTest로 검증할 수 없다. SSE 이벤트 스키마의 단일 출처가 여기이며
#   asyncapi-server-ai.yml이 $ref로 참조한다.
asyncapi: 3.0.0
info:
  title: Heymoa web ↔ server 비동기 계약 (전사 STOMP·노트 토픽 + agent 채팅 SSE)
  version: 2.1.0
  description: |
    브라우저와 heymoa-server 사이의 비동기 구간은 둘이다. 프로토콜도 수명도 다르다.

    | | 전사 | agent 채팅 |
    |---|---|---|
    | 프로토콜 | STOMP 1.2 over WebSocket (`wss`) | SSE (`POST` 응답 본문) |
    | 서버 | `production` | `sseEdge` |
    | 생산자 | heymoa-server | heymoa-ai (server는 passthrough) |
    | 수명 | 회의 녹음 구간 전체 | 메시지 한 건의 응답 |

    공통: 인증은 `access_token` cookie 하나이고, 브라우저는 heymoa-ai에 직접 연결하지 않는다 —
    heymoa-server가 유일한 public edge다.

    ---

    ## 1. 전사 STOMP·노트 토픽 구간

    브라우저와 Heymoa 서버 사이의 STOMP 1.2 over WebSocket 계약이다.
    브라우저는 access_token cookie와 허용된 Origin으로 /ws/transcriptions에 연결한다.
    SUBSCRIBE를 먼저 등록한 뒤 connect destination으로 전사 stream을 시작한다.
    같은 STOMP session의 inbound와 outbound message 순서는 서버에서 보존한다.
    provider VAD가 발화 뒤 침묵을 감지하면 segment를 확정하고, stop 수신 시 남은
    오디오를 강제로 commit한 뒤 final을 최대 10초간 drain한다.

    ## 세션 수명

    전사 session 하나는 STOMP session 하나에 바인딩된다. terminal event(completed·error)를
    받으면 그 바인딩은 해제되며 **같은 전사 session으로 다시 connect할 수 없다** — client는
    연결을 deactivate하고 필요하면 새 전사 session을 발급받는다.

    stop 없이 WebSocket이 끊기면 서버는 세션을 CLIENT_DISCONNECTED로 중단 처리한다.
    이때 이미 확정·저장된 segment는 남지만 **terminal event는 가지 않는다** (보낼 연결이 없다).

    ## 이 계약이 보장하지 않는 것

    - **commit이 final을 보장하지 않는다.** 확정 구간의 전사가 비면 그 commit에 대응하는
      final 없이 다음 발화로 넘어간다. client는 commit과 final을 1:1로 세지 않는다.
    - **partial 없이 final만 오는 발화가 있다.** partial은 provider가 중간 텍스트를 줄 때만,
      그리고 비어 있지 않을 때만 발행된다.
    - **바인딩 전에 보낸 audio·commit·stop은 조용히 버려진다** (오류 event도 없다).
      client는 connected event를 받은 뒤에 오디오를 보낸다.

    ---

    ## 2. agent 채팅 SSE 구간

    web이 유저 메시지를 POST하면 그 **응답 본문**이 `text/event-stream`으로 스트리밍된다.
    진입점은 둘이고 이벤트 포맷은 같다.

    - 개인 챗봇: `POST /v1/agent-chats/{chatId}/messages`
    - 공유 챗봇: `POST /v1/notes/{noteId}/chat/messages`

    실제 이벤트 생산자는 heymoa-ai이고 server는 **변환 없이 통과(passthrough)**시키면서
    히스토리만 자기 DB에 저장(tee)한다. 그래서 이 구간과 server↔ai 구간의 이벤트 포맷은
    반드시 같다 (asyncapi-server-ai.yml이 아래 message들을 $ref한다).

    각 프레임은 `event: <이름>` + `data: <JSON>`이다.

    ### 스트림은 두 가지로 끝난다 — 그리고 세 번째가 있다

    1. `message_end` — 정상. `content`가 완성된 응답 전문이고 server가 이 값을 저장한다.
    2. `error` — 복구 불가. 부분 응답은 저장하지 않는다.
    3. **종료 이벤트 없이 연결만 끊긴다.** web은 이 경우를 반드시 처리해야 한다.
       원인은 동시 스트림 상한 초과(즉시 거부), upstream 유휴 60초 초과, upstream이 종료
       이벤트 없이 끊김, 공유 챗의 입력 잠금 상실이다. 이때 **응답은 저장되지 않았다** —
       web은 재시도 UX로 다루고, 히스토리를 다시 불러 상태를 맞춘다.

    ### heartbeat

    server는 SSE comment(`: keepalive`)를 **20초 이하 간격**(현재 구현 10초)으로 보낸다.
    프록시·로드 밸런서의 유휴 타임아웃이 승인 대기 상한(300초)보다 짧아 스트림이 먼저
    끊기는 것을 막기 위함이다.

    이 타이머는 **upstream과 독립**이다 — heymoa-ai의 keepalive는 server가 소비하고 흘려보내지
    않으므로, 도구 실행 지연·LLM 장고로 upstream이 조용해도 간격은 지켜진다. 이벤트가 흐르는
    구간에서도 멈추지 않는다. comment는 이벤트가 아니라 web은 무시하면 된다
    (EventSource·fetch 파서 모두 자동 무시).

    스트림 절대 상한은 **30분**이다. 좀비 스트림 방지용이며 정상 수명 제어는 upstream 유휴
    타임아웃(60초)이 한다.

    ### 스트림이 열리기 전의 실패는 SSE가 아니라 JSON이다

    멤버십·소유권(404), 회의 비ACTIVE·입력 잠금(409)은 스트림을 열기 전 동기 단계에서 끝난다.
    web은 응답 Content-Type을 보고 갈라야 한다 — 이 경우는 SSE가 아니라 평소의 오류 JSON이다
    (본문 스키마는 openapi3-server.yml).
defaultContentType: application/json
servers:
  production:
    host: api.heymoa.app
    pathname: /ws/transcriptions
    protocol: wss
    protocolVersion: '1.2'
    description: |
      STOMP WebSocket endpoint. HTTP upgrade 요청에는 access_token cookie와
      서버 CORS allowlist에 포함된 Origin header가 모두 필요하다.

      전송 계층 제약 (server 설정과 같은 값):
      - **STOMP heartbeat 10초 / 10초.** 서버가 CONNECTED 프레임에 이 값을 실어 협상한다.
        client는 자기 방향 heartbeat를 보내야 한다 — 조용한 회의 구간에서도 연결이 살아 있으려면
        오디오 전송이 아니라 이 heartbeat가 근거다.
      - **WebSocket 유휴 timeout 60초.** heartbeat가 트래픽으로 잡히므로 위 협상을 지키면 걸리지 않는다.
      - **STOMP 프레임 상한 1 MiB + 16 KiB** (binary + text 합). 오디오 frame 자체의 상한은
        이보다 훨씬 작다 (PcmAudio 참조).
    security:
      - $ref: '#/components/securitySchemes/accessCookie'
  sseEdge:
    host: api.heymoa.app
    protocol: https
    description: |
      agent 채팅 SSE. 별도 endpoint가 아니라 REST와 같은 public edge이며,
      POST 응답 본문이 `text/event-stream`으로 열린다.
    security:
      - $ref: '#/components/securitySchemes/accessCookie'
channels:
  connectCommand:
    servers:
      - $ref: '#/servers/production'
    address: /app/transcription-sessions/{sessionId}/connect
    title: 전사 stream 연결
    description: |
      SEND 프레임에 **`reply-id` header(UUID)가 필수**다. 서버는 이 값으로 event를 돌려보낼
      destination을 정하므로, client는 같은 UUID로 만든 transcriptionEvents를 **먼저 SUBSCRIBE한 뒤**
      이 SEND를 보낸다. 순서가 뒤집히면 connected event를 놓친다.

      **연결 실패는 모두 error(STT_CONNECTION_FAILED)로 돌아온다** — 없는 session,
      남의 session, 이미 연결된 session, provider 장애가 한 code로 합쳐진다. 현재 구현의 한계이며
      client는 이 code로 원인을 가르지 않는다.

      같은 STOMP session에서 connect를 두 번 보내면 **먼저 바인딩된 stream이**
      error(INVALID_CLIENT_MESSAGE)로 중단된다.
    parameters:
      sessionId:
        $ref: '#/components/parameters/sessionId'
    messages:
      ConnectCommand:
        $ref: '#/components/messages/ConnectCommand'
  audioCommand:
    servers:
      - $ref: '#/servers/production'
    address: /app/transcription-sessions/{sessionId}/audio
    title: PCM 오디오 전송
    description: |
      프레임 제약(비어 있지 않음·짝수 byte·최대 크기)을 어기면 그 frame을 버리는 데 그치지 않고
      **세션이 error(INVALID_AUDIO_FRAME)로 종료된다.**

      바인딩된 STOMP session과 destination의 sessionId가 다르면 error(INVALID_CLIENT_MESSAGE)로
      종료된다 — audio·commit·stop 모두 같다.
    parameters:
      sessionId:
        $ref: '#/components/parameters/sessionId'
    messages:
      PcmAudio:
        $ref: '#/components/messages/PcmAudio'
  commitCommand:
    servers:
      - $ref: '#/servers/production'
    address: /app/transcription-sessions/{sessionId}/commit
    title: 현재 발화 수동 확정
    description: |
      마지막 commit 이후 받은 오디오가 없으면 **아무 일도 일어나지 않는다** (no-op).
      확정된 구간의 전사가 비면 final 없이 다음 발화로 넘어간다.
    parameters:
      sessionId:
        $ref: '#/components/parameters/sessionId'
    messages:
      CommitCommand:
        $ref: '#/components/messages/CommitCommand'
  stopCommand:
    servers:
      - $ref: '#/servers/production'
    address: /app/transcription-sessions/{sessionId}/stop
    title: 전사 세션 종료
    description: |
      남은 오디오를 강제로 commit하고 대기 중인 final을 **최대 10초**(`transcription.elevenlabs.
      final-drain-timeout`) drain한 뒤 completed를 보낸다. 그 안에 다 받지 못하면
      error(STT_TRANSCRIPTION_FAILED)로 끝난다 — 이미 저장된 segment는 남는다.

      확정할 오디오가 없으면 drain 없이 곧바로 completed가 온다.
    parameters:
      sessionId:
        $ref: '#/components/parameters/sessionId'
    messages:
      StopCommand:
        $ref: '#/components/messages/StopCommand'
  transcriptionEvents:
    servers:
      - $ref: '#/servers/production'
    address: /user/queue/transcription-sessions/{replyId}/events
    title: 현재 STOMP 연결 전용 전사 event
    description: |
      다른 탭의 동일 사용자 연결과 event가 섞이지 않도록 STOMP session별 queue를 구독한다.
      `{replyId}`는 client가 connect SEND의 `reply-id` header로 정한 UUID이므로,
      이 SUBSCRIBE가 connect보다 **먼저** 등록돼야 한다.

      completed와 error는 terminal이다 — 받으면 더 이상 event가 오지 않으며 client는 연결을
      deactivate한다. 두 event 중 정확히 하나만, 최대 한 번 온다.
    parameters:
      replyId:
        $ref: '#/components/parameters/replyId'
    messages:
      ConnectedEvent:
        $ref: '#/components/messages/ConnectedEvent'
      PartialEvent:
        $ref: '#/components/messages/PartialEvent'
      FinalEvent:
        $ref: '#/components/messages/FinalEvent'
      CompletedEvent:
        $ref: '#/components/messages/CompletedEvent'
      ErrorEvent:
        $ref: '#/components/messages/ErrorEvent'
  noteTopic:
    servers:
      - $ref: '#/servers/production'
    address: /topic/notes/{noteId}
    title: 노트 멤버용 실시간 event
    description: |
      노트 멤버만 구독할 수 있다. 서버는 SUBSCRIBE 시점과 30초마다 멤버십을 재검증한다.
      권한 상실, 형식 오류, 노트별 100개 구독 상한 초과는 ERROR 없이 조용히 구독을 해제하거나
      거절한다. 이벤트는 원본 transaction commit 뒤 발행되며, stream 기반 partial과 chat
      이벤트처럼 transaction 밖에서 생긴 이벤트는 즉시 발행된다.
    parameters:
      noteId:
        $ref: '#/components/parameters/noteId'
    messages:
      NoteTranscriptPartialEvent:
        $ref: '#/components/messages/NoteTranscriptPartialEvent'
      NoteTranscriptFinalEvent:
        $ref: '#/components/messages/NoteTranscriptFinalEvent'
      NoteMeetingStartedEvent:
        $ref: '#/components/messages/NoteMeetingStartedEvent'
      NoteMeetingEndedEvent:
        $ref: '#/components/messages/NoteMeetingEndedEvent'
      NoteRecordingStartedEvent:
        $ref: '#/components/messages/NoteRecordingStartedEvent'
      NoteRecordingStoppedEvent:
        $ref: '#/components/messages/NoteRecordingStoppedEvent'
      NoteChatTokenEvent:
        $ref: '#/components/messages/NoteChatTokenEvent'
      NoteChatMessageEndEvent:
        $ref: '#/components/messages/NoteChatMessageEndEvent'
      NoteChatLockEvent:
        $ref: '#/components/messages/NoteChatLockEvent'
  agentChatStream:
    servers:
      - $ref: '#/servers/sseEdge'
    address: /v1/agent-chats/{chatId}/messages
    title: 개인 챗봇 응답 스트림
    description: |
      요청 본문은 `{message: string}`이고(스키마는 openapi3-server.yml), 응답 본문이 이 채널의
      이벤트를 나르는 SSE 스트림이다.

      스코프는 채팅 세션 생성 시 정해진다 — `workspace`(agentic 검색) 또는 `note`.
      같은 채팅에 동시 스트림을 막는 잠금은 **없다**.
    parameters:
      chatId:
        $ref: '#/components/parameters/chatId'
    messages:
      MessageStart:
        $ref: '#/components/messages/MessageStart'
      Token:
        $ref: '#/components/messages/Token'
      ToolCallStart:
        $ref: '#/components/messages/ToolCallStart'
      ToolApprovalRequest:
        $ref: '#/components/messages/ToolApprovalRequest'
      ToolApprovalResolved:
        $ref: '#/components/messages/ToolApprovalResolved'
      ToolCallResult:
        $ref: '#/components/messages/ToolCallResult'
      MessageEnd:
        $ref: '#/components/messages/MessageEnd'
      Error:
        $ref: '#/components/messages/Error'
  noteSharedChatStream:
    servers:
      - $ref: '#/servers/sseEdge'
    address: /v1/notes/{noteId}/chat/messages
    title: 노트 공유 챗봇 응답 스트림
    description: |
      이벤트 포맷은 개인 챗봇과 같고 게이트만 다르다. 노트 멤버가 아니면 404,
      회의가 ACTIVE가 아니거나 다른 멤버가 입력 중이면 409다 — **셋 다 스트림이 열리기 전
      JSON으로** 돌아온다.

      입력 잠금은 한 번에 한 명이고 스트림이 살아 있는 동안 갱신된다. 스트림이 끝나면
      풀리는데, **종료 이벤트보다 해제가 먼저**다 — `message_end` 직후 보낸 다음 메시지가
      아직 남은 잠금에 걸려 409가 되지 않게 하기 위함이다.

      스트림 수신자는 입력자 한 명뿐이다. 관전자는 이 채널을 구독하지 않고 히스토리 폴링
      (`GET /v1/notes/{noteId}/chat/messages`)의 `pendingApproval`로 "OO님이 승인 대기 중"을 본다.
    parameters:
      noteId:
        $ref: '#/components/parameters/noteId'
    messages:
      MessageStart:
        $ref: '#/components/messages/MessageStart'
      Token:
        $ref: '#/components/messages/Token'
      ToolCallStart:
        $ref: '#/components/messages/ToolCallStart'
      ToolApprovalRequest:
        $ref: '#/components/messages/ToolApprovalRequest'
      ToolApprovalResolved:
        $ref: '#/components/messages/ToolApprovalResolved'
      ToolCallResult:
        $ref: '#/components/messages/ToolCallResult'
      MessageEnd:
        $ref: '#/components/messages/MessageEnd'
      Error:
        $ref: '#/components/messages/Error'
operations:
  connectTranscription:
    action: receive
    summary: SUBSCRIBE 프레임 직후 빈 body의 SEND로 전사 stream을 연결한다.
    channel:
      $ref: '#/channels/connectCommand'
    messages:
      - $ref: '#/channels/connectCommand/messages/ConnectCommand'
  receiveTranscriptionAudio:
    action: receive
    summary: STOMP binaryBody로 PCM16 오디오를 전송한다.
    channel:
      $ref: '#/channels/audioCommand'
    messages:
      - $ref: '#/channels/audioCommand/messages/PcmAudio'
  commitTranscription:
    action: receive
    summary: 빈 body의 SEND로 현재 non-empty provider buffer를 확정한다.
    channel:
      $ref: '#/channels/commitCommand'
    messages:
      - $ref: '#/channels/commitCommand/messages/CommitCommand'
  stopTranscription:
    action: receive
    summary: 빈 body의 SEND로 남은 final을 drain하고 세션을 완료한다.
    channel:
      $ref: '#/channels/stopCommand'
    messages:
      - $ref: '#/channels/stopCommand/messages/StopCommand'
  sendTranscriptionEvent:
    action: send
    summary: 현재 STOMP 연결의 전사 상태와 결과를 보낸다.
    description: |
      partial은 provider commit 전에 도착할 수 있다. partial.text는 utteranceId별 누적
      snapshot이므로 같은 utteranceId의 표시를 교체한다. final은 DB transaction commit
      성공 후에만 sequence 오름차순으로 전달된다. completed 또는 error는 terminal event이며
      수신한 client는 STOMP 연결을 deactivate한다.
    channel:
      $ref: '#/channels/transcriptionEvents'
    messages:
      - $ref: '#/channels/transcriptionEvents/messages/ConnectedEvent'
      - $ref: '#/channels/transcriptionEvents/messages/PartialEvent'
      - $ref: '#/channels/transcriptionEvents/messages/FinalEvent'
      - $ref: '#/channels/transcriptionEvents/messages/CompletedEvent'
      - $ref: '#/channels/transcriptionEvents/messages/ErrorEvent'
  sendNoteTopicEvent:
    action: send
    summary: 노트 멤버에게 회의·녹음·전사·공유 챗 상태를 팬아웃한다.
    description: |
      transcript.partial은 같은 utteranceId의 누적 snapshot이고 transcript.final은 저장 transaction
      commit 뒤에만 발행된다. chat.lock의 locked=false는 정상 종료뿐 아니라 중단 시에도 편집 잠금이
      풀렸음을 알리는 신호다. tool approval 이벤트는 이 토픽에 포함하지 않는다.
    channel:
      $ref: '#/channels/noteTopic'
    messages:
      - $ref: '#/channels/noteTopic/messages/NoteTranscriptPartialEvent'
      - $ref: '#/channels/noteTopic/messages/NoteTranscriptFinalEvent'
      - $ref: '#/channels/noteTopic/messages/NoteMeetingStartedEvent'
      - $ref: '#/channels/noteTopic/messages/NoteMeetingEndedEvent'
      - $ref: '#/channels/noteTopic/messages/NoteRecordingStartedEvent'
      - $ref: '#/channels/noteTopic/messages/NoteRecordingStoppedEvent'
      - $ref: '#/channels/noteTopic/messages/NoteChatTokenEvent'
      - $ref: '#/channels/noteTopic/messages/NoteChatMessageEndEvent'
      - $ref: '#/channels/noteTopic/messages/NoteChatLockEvent'
  receiveAgentChatEvents:
    action: receive
    summary: heymoa-web이 개인 챗봇 응답 이벤트를 SSE로 수신한다.
    description: |
      server가 producer지만 이벤트를 만들지는 않는다 — heymoa-ai의 스트림을 변환 없이 중계하고,
      유저 메시지·`message_end.content`·도구 실행 기록만 자기 DB에 저장(tee)한다.
    channel:
      $ref: '#/channels/agentChatStream'
    messages:
      - $ref: '#/channels/agentChatStream/messages/MessageStart'
      - $ref: '#/channels/agentChatStream/messages/Token'
      - $ref: '#/channels/agentChatStream/messages/ToolCallStart'
      - $ref: '#/channels/agentChatStream/messages/ToolApprovalRequest'
      - $ref: '#/channels/agentChatStream/messages/ToolApprovalResolved'
      - $ref: '#/channels/agentChatStream/messages/ToolCallResult'
      - $ref: '#/channels/agentChatStream/messages/MessageEnd'
      - $ref: '#/channels/agentChatStream/messages/Error'
  receiveNoteSharedChatEvents:
    action: receive
    summary: heymoa-web이 공유 챗봇 응답 이벤트를 SSE로 수신한다.
    description: |
      수신자는 입력자다. 도구 실행 기록(`tool_approval_resolved`·`tool_call_result`)은
      회의 상태와 무관하게 저장되므로, 승인 직후 회의가 끝나도 관전자의 히스토리에 남는다.
    channel:
      $ref: '#/channels/noteSharedChatStream'
    messages:
      - $ref: '#/channels/noteSharedChatStream/messages/MessageStart'
      - $ref: '#/channels/noteSharedChatStream/messages/Token'
      - $ref: '#/channels/noteSharedChatStream/messages/ToolCallStart'
      - $ref: '#/channels/noteSharedChatStream/messages/ToolApprovalRequest'
      - $ref: '#/channels/noteSharedChatStream/messages/ToolApprovalResolved'
      - $ref: '#/channels/noteSharedChatStream/messages/ToolCallResult'
      - $ref: '#/channels/noteSharedChatStream/messages/MessageEnd'
      - $ref: '#/channels/noteSharedChatStream/messages/Error'
components:
  securitySchemes:
    accessCookie:
      type: httpApiKey
      name: access_token
      in: cookie
      description: 로그인 시 발급된 access token cookie
  parameters:
    sessionId:
      description: |
        연결할 전사 세션의 13자리 TSID. destination 경로에 직접 담기며 header가 아니다
        (`@DestinationVariable`).
      examples:
        - 0HZX2K7M9Q4AB
    replyId:
      description: |
        client가 connect SEND의 `reply-id` header로 정한 UUID. 그 값이 이 destination 경로에
        그대로 들어간다 — event message의 header에는 실리지 않는다.
      examples:
        - 550e8400-e29b-41d4-a716-446655440000
    chatId:
      description: heymoa-server가 발급한 개인 채팅 세션 id (13자 TSID)
      examples:
        - 0HZX2K7M9Q4AE
    noteId:
      description: 구독할 노트의 13자리 TSID
      examples:
        - 0HZX2K7M9Q4AF
  messages:
    ConnectCommand:
      name: ConnectCommand
      title: 전사 stream 연결 command
      summary: payload 없이 전사 session을 현재 STOMP session에 바인딩한다.
      description: |
        body는 비어 있고 계약은 header에 있다 — `reply-id`(UUID)가 필수다.
        서버는 이 SEND를 받고 나서야 provider에 연결하므로, connected event까지 지연이 있다.
    PcmAudio:
      name: PcmAudio
      title: PCM 오디오 STOMP binary body
      summary: signed PCM16, 24 kHz, mono, little-endian binary body
      contentType: application/octet-stream
      payload:
        type: string
        format: binary
        minLength: 2
        maxLength: 48000
        description: |
          비어 있지 않은 짝수 byte 크기의 PCM16 body. ElevenLabs 권장 범위에 맞춰 최대 1초(48,000 byte)이다.
          상한은 `min(transcription.websocket.max-binary-message-size, 1초분)`이라 설정을 줄이면 함께 줄어든다.
          세 조건 중 하나라도 어기면 세션이 error(INVALID_AUDIO_FRAME)로 종료된다.
    CommitCommand:
      name: CommitCommand
      title: 현재 오디오 구간 확정 command
      summary: payload 없이 현재 non-empty audio buffer를 확정한다.
    StopCommand:
      name: StopCommand
      title: 전사 세션 정상 종료 command
      summary: payload 없이 pending final을 처리한 뒤 세션을 완료한다.
    ConnectedEvent:
      name: ConnectedEvent
      title: 전사 연결 준비 완료
      payload:
        type: object
        additionalProperties: false
        required: [type, sessionId]
        properties:
          type:
            type: string
            enum: [connected]
          sessionId:
            $ref: '#/components/schemas/Tsid'
      examples:
        - name: connected
          payload:
            type: connected
            sessionId: 0HZX2K7M9Q4AB
    PartialEvent:
      name: PartialEvent
      title: 진행 중인 누적 전사
      summary: 같은 utteranceId의 이전 partial 표시를 이 snapshot으로 교체한다.
      description: |
        비어 있는 중간 텍스트는 발행하지 않으므로 **partial이 한 번도 오지 않는 발화가 있다.**
        `utteranceId`는 commit(수동·VAD 모두)마다 새로 발급된다 — 확정된 발화의 partial 표시를
        지우고 새 발화를 시작할 기준이다.
      payload:
        type: object
        additionalProperties: false
        required: [type, utteranceId, text]
        properties:
          type:
            type: string
            enum: [partial]
          utteranceId:
            $ref: '#/components/schemas/Tsid'
          text:
            type: string
            minLength: 1
            example: 오늘 회의에서는
      examples:
        - name: partial
          payload:
            type: partial
            utteranceId: 0HZX2K7M9Q4AC
            text: 오늘 회의에서는
    FinalEvent:
      name: FinalEvent
      title: 저장이 완료된 확정 전사 segment
      summary: DB 저장 성공 후 sequence 오름차순으로 전송된다.
      description: |
        `transcriptionSessionId`가 `sequence`의 범위를 식별한다. `sequence`는 그 세션 안에서
        1부터 빈틈없이 증가한다. `startedAtMs`는 직전 확정 경계이고
        `endedAtMs`는 그때까지 받은 오디오의 누적 길이라, 두 값은 **수신한 오디오 바이트로 계산한
        경계**이지 발화의 실제 시각이 아니다.

        전사가 빈 확정 구간은 이 event 없이 지나간다 — commit 수와 final 수는 일치하지 않는다.
      payload:
        type: object
        additionalProperties: false
        required: [type, transcriptionSessionId, segmentId, utteranceId, sequence, text, startedAtMs, endedAtMs]
        properties:
          type:
            type: string
            enum: [final]
          transcriptionSessionId:
            $ref: '#/components/schemas/Tsid'
          segmentId:
            $ref: '#/components/schemas/Tsid'
          utteranceId:
            $ref: '#/components/schemas/Tsid'
          sequence:
            type: integer
            format: int64
            minimum: 1
            example: 1
          text:
            type: string
            minLength: 1
            example: 오늘 회의에서는 출시 일정을 논의했습니다.
          startedAtMs:
            type: integer
            format: int64
            minimum: 0
            example: 0
          endedAtMs:
            type: integer
            format: int64
            minimum: 0
            example: 8230
      examples:
        - name: final
          payload:
            type: final
            transcriptionSessionId: 0HZX2K7M9Q4AB
            segmentId: 0HZX2K7M9Q4AD
            utteranceId: 0HZX2K7M9Q4AC
            sequence: 1
            text: 오늘 회의에서는 출시 일정을 논의했습니다.
            startedAtMs: 0
            endedAtMs: 8230
    CompletedEvent:
      name: CompletedEvent
      title: 전사 세션 정상 완료
      payload:
        type: object
        additionalProperties: false
        required: [type, sessionId]
        properties:
          type:
            type: string
            enum: [completed]
          sessionId:
            $ref: '#/components/schemas/Tsid'
      examples:
        - name: completed
          payload:
            type: completed
            sessionId: 0HZX2K7M9Q4AB
    ErrorEvent:
      name: ErrorEvent
      title: 전사 세션 실패
      summary: event 수신 후 client는 STOMP 연결을 deactivate한다.
      description: |
        code별 원인:
        - `INVALID_CLIENT_MESSAGE` — 같은 STOMP session의 중복 connect, 바인딩과 다른 sessionId로 온 command
        - `INVALID_AUDIO_FRAME` — 빈 body·홀수 byte·크기 초과
        - `STT_CONNECTION_FAILED` — connect 실패 전반 (없는 session·권한 없음·중복 연결·provider 연결 실패)
        - `STT_TRANSCRIPTION_FAILED` — provider 오류, stop drain timeout
        - `INTERNAL_ERROR` — segment 저장 실패, event 전송 실패, 완료 상태 저장 실패
      payload:
        type: object
        additionalProperties: false
        required: [type, code, message]
        properties:
          type:
            type: string
            enum: [error]
          code:
            type: string
            enum:
              - INVALID_CLIENT_MESSAGE
              - INVALID_AUDIO_FRAME
              - STT_CONNECTION_FAILED
              - STT_TRANSCRIPTION_FAILED
              - INTERNAL_ERROR
            example: STT_TRANSCRIPTION_FAILED
          message:
            type: string
            minLength: 1
            example: 실시간 전사 처리에 실패했습니다.
      examples:
        - name: upstreamFailure
          payload:
            type: error
            code: STT_TRANSCRIPTION_FAILED
            message: 실시간 전사 처리에 실패했습니다.
    NoteTranscriptPartialEvent:
      name: NoteTranscriptPartialEvent
      title: 노트의 진행 중인 누적 전사
      payload:
        type: object
        additionalProperties: false
        required: [type, transcriptionSessionId, utteranceId, text]
        properties:
          type:
            type: string
            enum: [transcript.partial]
          transcriptionSessionId:
            $ref: '#/components/schemas/Tsid'
          utteranceId:
            $ref: '#/components/schemas/Tsid'
          text:
            type: string
            minLength: 1
      examples:
        - name: partial
          payload:
            type: transcript.partial
            transcriptionSessionId: 0HZX2K7M9Q4AB
            utteranceId: 0HZX2K7M9Q4AC
            text: 오늘 회의에서는
    NoteTranscriptFinalEvent:
      name: NoteTranscriptFinalEvent
      title: 노트에 저장된 확정 전사
      payload:
        type: object
        additionalProperties: false
        required: [type, transcriptionSessionId, segmentId, utteranceId, sequence, text, startedAtMs, endedAtMs]
        properties:
          type:
            type: string
            enum: [transcript.final]
          transcriptionSessionId:
            $ref: '#/components/schemas/Tsid'
          segmentId:
            $ref: '#/components/schemas/Tsid'
          utteranceId:
            $ref: '#/components/schemas/Tsid'
          sequence:
            type: integer
            format: int64
            minimum: 1
          text:
            type: string
            minLength: 1
          startedAtMs:
            type: integer
            format: int64
            minimum: 0
          endedAtMs:
            type: integer
            format: int64
            minimum: 0
      examples:
        - name: final
          payload:
            type: transcript.final
            transcriptionSessionId: 0HZX2K7M9Q4AB
            segmentId: 0HZX2K7M9Q4AD
            utteranceId: 0HZX2K7M9Q4AC
            sequence: 1
            text: 오늘 회의에서는 출시 일정을 논의했습니다.
            startedAtMs: 0
            endedAtMs: 8230
    NoteMeetingStartedEvent:
      name: NoteMeetingStartedEvent
      title: 회의 시작
      payload:
        type: object
        additionalProperties: false
        required: [type]
        properties:
          type:
            type: string
            enum: [meeting.started]
      examples:
        - name: started
          payload:
            type: meeting.started
    NoteMeetingEndedEvent:
      name: NoteMeetingEndedEvent
      title: 회의 종료
      payload:
        type: object
        additionalProperties: false
        required: [type]
        properties:
          type:
            type: string
            enum: [meeting.ended]
      examples:
        - name: ended
          payload:
            type: meeting.ended
    NoteRecordingStartedEvent:
      name: NoteRecordingStartedEvent
      title: 녹음 시작
      payload:
        type: object
        additionalProperties: false
        required: [type, transcriptionSessionId]
        properties:
          type:
            type: string
            enum: [recording.started]
          transcriptionSessionId:
            $ref: '#/components/schemas/Tsid'
      examples:
        - name: started
          payload:
            type: recording.started
            transcriptionSessionId: 0HZX2K7M9Q4AB
    NoteRecordingStoppedEvent:
      name: NoteRecordingStoppedEvent
      title: 녹음 종료
      payload:
        type: object
        additionalProperties: false
        required: [type, transcriptionSessionId]
        properties:
          type:
            type: string
            enum: [recording.stopped]
          transcriptionSessionId:
            $ref: '#/components/schemas/Tsid'
      examples:
        - name: stopped
          payload:
            type: recording.stopped
            transcriptionSessionId: 0HZX2K7M9Q4AB
    NoteChatTokenEvent:
      name: NoteChatTokenEvent
      title: 공유 챗 응답 token
      payload:
        type: object
        additionalProperties: false
        required: [type, delta]
        properties:
          type:
            type: string
            enum: [chat.token]
          delta:
            type: string
            minLength: 1
      examples:
        - name: token
          payload:
            type: chat.token
            delta: 안녕하세요
    NoteChatMessageEndEvent:
      name: NoteChatMessageEndEvent
      title: 공유 챗 응답 완료
      payload:
        type: object
        additionalProperties: false
        required: [type, messageId, content]
        properties:
          type:
            type: string
            enum: [chat.message_end]
          messageId:
            type: string
            minLength: 1
          content:
            type: string
            minLength: 1
      examples:
        - name: messageEnd
          payload:
            type: chat.message_end
            messageId: m1
            content: 안녕하세요
    NoteChatLockEvent:
      name: NoteChatLockEvent
      title: 공유 챗 편집 잠금 상태
      payload:
        type: object
        additionalProperties: false
        required: [type, chatId, locked, lockedByUserId]
        properties:
          type:
            type: string
            enum: [chat.lock]
          chatId:
            $ref: '#/components/schemas/Tsid'
          locked:
            type: boolean
          lockedByUserId:
            type: string
            nullable: true
            pattern: '^[0-9A-HJKMNP-TV-Z]{13}$'
      examples:
        - name: released
          payload:
            type: chat.lock
            chatId: 0HZX2K7M9Q4AF
            locked: false
            lockedByUserId: null

    # --- agent 채팅 SSE 이벤트 (server↔ai 구간과 공유하는 단일 출처) ---
    #
    # payload를 additionalProperties로 닫지 않는다: server는 이벤트를 파싱하지 않고 통과시키므로
    # heymoa-ai가 필드를 더해도 web까지 그대로 도착한다. web은 모르는 필드를 무시한다.
    MessageStart:
      name: message_start
      title: 응답 시작
      description: assistant 응답 스트림의 시작. 스트림당 정확히 1회, 항상 첫 이벤트다.
      payload:
        type: object
        required: [chatId, messageId]
        properties:
          chatId:
            $ref: '#/components/schemas/Tsid'
          messageId:
            type: string
            description: 이 assistant 응답의 id
      examples:
        - name: messageStart
          payload:
            chatId: 0HZX2K7M9Q4AE
            messageId: 0HZX2K7M9Q4AG
    Token:
      name: token
      title: 텍스트 델타
      description: |
        assistant 응답 텍스트 조각. 수신 순서대로 이어붙인다. **누적 snapshot이 아니라 델타다**
        (전사 partial과 반대다 — 그쪽은 교체, 이쪽은 append).
      payload:
        type: object
        required: [delta]
        properties:
          delta:
            type: string
      examples:
        - name: token
          payload:
            delta: 회의에서 정한
    ToolCallStart:
      name: tool_call_start
      title: 도구 호출 시작
      description: |
        agent가 외부 도구(Linear, GitHub 등) 호출을 시작했다. **조회(read) 도구만 이 이벤트로
        바로 시작한다** — 쓰기 도구는 tool_approval_request를 먼저 거친다.
      payload:
        type: object
        required: [toolCallId, tool]
        properties:
          toolCallId:
            type: string
          tool:
            type: string
            description: 도구 이름 (예 linear.create_issue, github.create_issue)
          summary:
            type: string
            description: UI 표시용 한 줄 설명 (예 "Linear 이슈 생성 중")
      examples:
        - name: toolCallStart
          payload:
            toolCallId: call_01
            tool: linear.search_issues
            summary: Linear 이슈 검색 중
    ToolApprovalRequest:
      name: tool_approval_request
      title: 쓰기 도구 승인 요청
      description: |
        agent가 쓰기 도구를 실행하기 전 입력자 승인을 요청한다.
        스트림은 열린 채 유지되고, 입력자가 승인 API
        (`POST /v1/agent-chats/{chatId}/approvals/{approvalId}`)로 응답하면
        tool_approval_resolved가 이어진다.

        **대기 상한은 300초**다 — heymoa-ai는 이보다 오래 대기하지 않고, 만료되면 REJECTED로
        처리한다. server의 SSE 수명이 이 상한을 근거로 잡혀 있으므로 값이 바뀌면 양쪽을 함께 고친다.

        스트림 수신자는 입력자뿐이다. 관전자(공유 챗봇)는 이 이벤트를 받지 않고 히스토리 폴링의
        `pendingApproval`로 대기 상태만 본다.

        **스트림이 끝나면 그 스트림이 등록한 미처리 승인은 EXPIRED가 된다** — 이후 승인 API는
        404다. 승인 카드는 스트림이 살아 있는 동안에만 유효하다.
      payload:
        type: object
        required: [approvalId, toolCallId, tool]
        properties:
          approvalId:
            allOf:
              - $ref: '#/components/schemas/Tsid'
            description: |
              승인 API 호출에 쓰는 id. **heymoa-ai가 13자 TSID로 발급한다** —
              server의 승인 API path와 공유 챗봇 조회 응답(`pendingApproval.approvalId`)에
              그대로 노출되는 공개 식별자이고, server는 외부 식별자를 13자 TSID로 통일한다.
              13자 TSID가 아니면 server가 승인 row 등록을 건너뛰므로 **카드는 뜨지만 눌러도 404**다.
            pattern: "^[0-9A-HJKMNP-TV-Z]{13}$"
            minLength: 13
            maxLength: 13
            examples: ["0K9GVJT2C4Q7F"]
          toolCallId:
            type: string
          tool:
            type: string
            description: 도구 이름 (예 linear.create_issue)
          summary:
            type: string
            description: 승인 카드 표시용 설명 (예 "Linear 이슈 'APP 버그 수정' 생성")
      examples:
        - name: approvalRequest
          payload:
            approvalId: 0K9GVJT2C4Q7F
            toolCallId: call_02
            tool: linear.create_issue
            summary: Linear 이슈 'APP 버그 수정' 생성
    ToolApprovalResolved:
      name: tool_approval_resolved
      title: 승인 처리 결과
      description: |
        tool_approval_request와 `approvalId`로 짝을 이룬다.
        APPROVED면 도구가 실행되고 tool_call_result가 이어진다.
        REJECTED(타임아웃 포함)면 도구를 실행하지 않고 agent가 거절을 반영해 응답을 이어간다 —
        **어느 쪽이든 스트림은 정상 종료(message_end)된다.**

        server는 이 이벤트를 받은 시점에 승인 상태를 확정하고 히스토리에 기록한다.
        승인 API의 204는 "중계했다"는 뜻일 뿐 확정이 아니다 — 확정의 단일 출처는 이 이벤트다.
      payload:
        type: object
        required: [approvalId, decision]
        properties:
          approvalId:
            $ref: '#/components/schemas/Tsid'
          decision:
            type: string
            enum: [APPROVED, REJECTED]
      examples:
        - name: approved
          payload:
            approvalId: 0K9GVJT2C4Q7F
            decision: APPROVED
    ToolCallResult:
      name: tool_call_result
      title: 도구 호출 결과
      description: |
        `toolCallId`로 tool_call_start(조회 도구) 또는 tool_approval_request(쓰기 도구)와 짝을 이룬다.
        짝을 못 찾으면 server는 도구 이름을 `unknown`으로 기록한다 — 이 값이 히스토리에 보이면
        시작 이벤트를 놓쳤다는 뜻이다.

        **status가 error여도 스트림은 계속된다** (agent가 실패를 반영해 응답). 도구 토큰
        만료(401)도 error 이벤트가 아니라 이 이벤트로 전달되며, 다음 메시지에서 server가 새 토큰을
        보내면 자연 복구된다.
      payload:
        type: object
        required: [toolCallId, status]
        properties:
          toolCallId:
            type: string
          status:
            type: string
            enum: [success, error]
          summary:
            type: string
            description: UI 표시용 결과 요약 (예 "APP-12 생성됨"). 에러 시 사유.
          url:
            type: string
            format: uri
            description: 생성·수정된 리소스 링크 (있는 경우)
      examples:
        - name: toolCallResult
          payload:
            toolCallId: call_02
            status: success
            summary: APP-12 생성됨
            url: https://linear.app/heymoa/issue/APP-12
    MessageEnd:
      name: message_end
      title: 응답 완료
      description: |
        정상 종료. `content`는 완성된 assistant 응답 전문이며 server가 이 값을 히스토리로 저장한다.
        토큰을 이어붙인 결과와 같아야 하고, **비어 있거나 문자열이 아니면 server가 스트림을
        실패로 끝낸다** (조용히 저장을 건너뛰지 않는다).
      payload:
        type: object
        required: [messageId, content]
        properties:
          messageId:
            type: string
          content:
            type: string
            description: 완성된 assistant 응답 전문 (markdown)
      examples:
        - name: messageEnd
          payload:
            messageId: 0HZX2K7M9Q4AG
            content: 회의에서 정한 액션 아이템 3건을 Linear에 등록했습니다.
    Error:
      name: error
      title: 스트림 실패
      description: |
        복구 불가 오류. 이 이벤트 후 스트림이 종료되며 message_end는 오지 않는다.
        부분 스트리밍된 assistant 응답은 저장하지 않는다.

        도구 실패는 이 이벤트가 아니다 — tool_call_result(status=error)로 가고 스트림은 계속된다.
      payload:
        type: object
        required: [code, message]
        properties:
          code:
            type: string
            description: 기계 판독용 코드 (예 LLM_PROVIDER_ERROR, INTERNAL)
          message:
            type: string
            description: UI 표시 가능한 사유
      examples:
        - name: providerFailure
          payload:
            code: LLM_PROVIDER_ERROR
            message: 응답 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.
  schemas:
    Tsid:
      type: string
      pattern: '^[0-9A-HJKMNP-TV-Z]{13}$'
      minLength: 13
      maxLength: 13
      example: 0HZX2K7M9Q4AB
