asyncapi: '2.6.0'
info:
  title: Live Earnings — Streaming Channels
  version: '1.0.0'
  description: |
    Server-Sent Event channels for live, attributed earnings-call transcripts.

    Each channel is a unidirectional HTTP stream — the client opens a GET
    and the server pushes `event:` / `data:` framed messages until the
    call ends or the connection drops.

    Auth is identical to the REST surface (`Authorization: Bearer ek_*`
    or `?token=ek_*`). See `openapi.yaml` for the REST contract.

  contact:
    name: Live Earnings
    email: inder@live-earnings.live

# NO SQUATTABLE DEFAULT HOST — see the matching note in openapi.yaml. This file
# is served publicly and feeds codegen; an unregistered default host plus a
# bearer token on every request is a credential-exfiltration path that activates
# the moment someone registers the name.
servers:
  production:
    url: "{host}"
    protocol: https
    variables:
      host:
        default: localhost:3222
        description: |
          Your Live Earnings PIPELINE host — the service that holds the live
          streams. In a split deployment that is not the pages host (the site
          you browse): these streams are served only by the pipeline
          (NEXT_PUBLIC_LE_STREAM_ORIGIN on the web client). No public default.
    description: Production
    security:
      - bearerAuth: []
      - queryToken: []
  pipeline:
    url: "{streamHost}"
    protocol: wss
    variables:
      streamHost:
        default: localhost:3222
        description: The pipeline host (the same host as the streams above). No public default.
    description: Operator audio ingest (WebSocket). The token is the ingest token, not a subscriber key.
    security:
      - bearerAuth: []
      - queryToken: []

defaultContentType: application/json

channels:
  /api/live/{slug}/stream:
    description: |
      Finalized attribution events. The canonical subscriber stream.
      Each envelope carries a monotonic `seq`; clients use it to detect
      gaps after a reconnect (then recover via REST `/events?from=`).

      Includes:
        - `attribution` — speaker named, transcript text included
        - `unknown` — turn we couldn't confidently attribute
        - `phase_transition` — section boundary
        - `ai_insight` — model-surfaced moment anchored to a turn
        - `turn_speaker_amend` — late operator correction (in-place
          rename of an already-published turn; dedicated event type
          as of 2026-05-21)
        - `done` — call ended

      Treat the latest signal per `turnId` as canonical. A
      `turn_speaker_amend` overrides the speaker `name` on the
      original turn; the turn keeps its chronological position.
    parameters:
      slug:
        $ref: '#/components/parameters/Slug'
    subscribe:
      operationId: subscribeStream
      message:
        $ref: '#/components/messages/Envelope'
      bindings:
        sse:
          eventName: envelope

  /api/live/{slug}/partial-stream:
    description: |
      In-flight transcript partials. Each speaker turn surfaces here
      with `transcript` growing word-by-word as the speaker speaks.

      When a partial finalizes, an envelope publishes on `/stream` with
      the same `turnId`. Use that to clear your live cursor.

      Partial-stream messages have a deliberately minimal shape — no
      `seq`, no `publishedAt`, no `phase`. They're transient. Don't
      persist them; use them only as a live read-out of the in-flight
      turn.

      Some calls also name the in-flight turn EARLY, from its first
      sentence: once known, `speakerName` (with `naming: "early"`) rides on
      every later partial of that turn (the frame that first carries it may
      repeat the previous transcript). It is provisional — the turn's
      final name arrives on `/stream` (the `turn_speaker_amend` that follows
      the turn) and always replaces it. Absent when the call does not name
      early or has no early name yet.

      A `hello` event may open the connection: `{"features": {"segments":
      true}}` means this call streams SETTLED SENTENCES of the in-flight turn
      into the transcript under its (early) name. A settled sentence is one
      followed by at least 4 more words (see `settledPrefix` in the viewer's
      source); the whole, final turn arrives on `/stream` as usual and replaces
      them. Clients may ignore `hello`.
    parameters:
      slug:
        $ref: '#/components/parameters/Slug'
    subscribe:
      operationId: subscribePartials
      message:
        $ref: '#/components/messages/Partial'
      bindings:
        sse:
          eventName: partial

  /api/live/{slug}/audio-stream:
    description: |
      Raw call audio as base64-encoded PCM chunks. Subscriber-only.

      Format: 16 kHz, 1 channel, signed 16-bit little-endian PCM,
      base64-encoded. Each event carries one chunk (~40-100 ms).
      Concatenate chunks in arrival order to reconstruct the audio.

      Cycle 2 product surface — contact Live Earnings for access.
    parameters:
      slug:
        $ref: '#/components/parameters/Slug'
    subscribe:
      operationId: subscribeAudio
      message:
        $ref: '#/components/messages/AudioChunk'
      bindings:
        sse:
          eventName: audio

  /audio-ingest-ws/{slug}:
    x-audience: operator
    servers: [pipeline]
    description: |
      OPERATOR-ONLY. The VM capture rig's audio WebSocket (currently unused:
      live capture goes through the console's HTTP `POST
      /api/live/{slug}/audio-ingest`, documented in openapi.yaml). Not a
      Next.js route: it is attached to the pipeline server itself, so it
      exists only on the pipeline host.

      The client sends raw 16 kHz mono signed 16-bit little-endian PCM as
      binary frames. Auth: the ingest token (LIVE_EARNINGS_INGEST_TOKEN only —
      unlike the HTTP route, the operator token is not accepted), as
      `Authorization: Bearer` or `?token=`.

      A connection that cannot start is closed with one of these codes
      (also in `x-close-codes`). The rig reconnects on any non-1000 close.

      | Code | Meaning |
      |---|---|
      | 4400 | Bad path (in practice the server drops a non-matching upgrade before it opens) |
      | 4401 | Unauthorized |
      | 4500 | Speech-to-text not configured on the server |
      | 4503 | Session setup failed (a failure after setup drops the audio instead of closing) |
      | 4504 | Engine setting could not be read — transient; a reconnect retries it |
      | 4505 | Stored engine not available in this build — permanent until the call's engine changes |
      | 4506 | Capture suspended by the stray-capture guard — reconnect with `?capture_start=1` once an operator restarts capture, or clear it with the console's Stop |
    x-close-codes:
      "4400": Bad path
      "4401": Unauthorized
      "4500": STT not configured
      "4503": Session setup failed
      "4504": STT engine unavailable (transient)
      "4505": STT engine not available (permanent)
      "4506": Capture suspended
    parameters:
      slug:
        $ref: '#/components/parameters/Slug'
    bindings:
      ws:
        query:
          type: object
          properties:
            token:
              type: string
              description: The ingest token, when the client cannot set a header.
            capture_start:
              type: string
              enum: ["1"]
              description: First connect of a capture an operator just started — clears a suspension.
    publish:
      operationId: sendAudioFrame
      message:
        name: AudioFrame
        contentType: application/octet-stream
        payload:
          type: string
          format: binary

components:
  parameters:
    Slug:
      description: Call slug — e.g., `nvda-q4-fy26`.
      schema:
        type: string
        pattern: '^[a-z0-9-]+$'

  messages:
    Envelope:
      name: envelope
      title: Finalized event envelope
      summary: Wire-format envelope shared with REST `/events`.
      contentType: application/json
      payload:
        $ref: '#/components/schemas/EventEnvelope'
      bindings:
        sse:
          eventName: envelope

    Partial:
      name: partial
      title: In-flight transcript partial
      contentType: application/json
      payload:
        type: object
        required: [turnId, transcript]
        properties:
          turnId:
            type: string
            description: |
              Matches the eventual `attribution` envelope's `turnId`
              on `/stream`. Use this to associate the in-flight partial
              with its final form.
          transcript:
            type: string
            description: Growing word-by-word transcript.
          speakerName:
            type: string
            description: |
              Provisional speaker name from the turn's first sentence
              (early naming). Optional. Replaced by the final name on
              `/stream`; never shown there itself.
          naming:
            type: string
            enum: [early]
            description: Present with `speakerName`; marks it provisional.
      bindings:
        sse:
          eventName: partial

    AudioChunk:
      name: audio
      title: Base64-encoded PCM chunk
      contentType: application/json
      payload:
        type: object
        required: [pcm, publishedAt]
        properties:
          pcm:
            type: string
            description: |
              Base64-encoded raw PCM. 16 kHz, 1 channel, signed 16-bit
              little-endian, no headers.
          publishedAt:
            type: string
            format: date-time
      bindings:
        sse:
          eventName: audio

  schemas:
    Phase:
      type: string
      enum:
        - unknown
        - operator_opening
        - ir_intro
        - cfo_remarks
        - ceo_remarks
        - qa
        - closing

    EventEnvelope:
      type: object
      required: [tenantId, callSlug, seq, publishedAt, event]
      properties:
        tenantId:
          type: string
          example: live-earnings
        callSlug:
          type: string
        seq:
          type: integer
        publishedAt:
          type: string
          format: date-time
        event:
          oneOf:
            - $ref: '#/components/schemas/AttributionEvent'
            - $ref: '#/components/schemas/UnknownEvent'
            - $ref: '#/components/schemas/PhaseTransitionEvent'
            - $ref: '#/components/schemas/AiInsightEvent'
            - $ref: '#/components/schemas/TurnSpeakerAmendEvent'
            - $ref: '#/components/schemas/DoneEvent'

    AttributionEvent:
      type: object
      required: [type, turnId, name, source, sttSpeakerChange]
      properties:
        type:
          const: attribution
        turnId:
          type: string
        name:
          type: string
        source:
          const: live-earnings
        text:
          type: string
        phase:
          $ref: '#/components/schemas/Phase'
        sttSpeakerChange:
          $ref: '#/components/schemas/SpeakerChange'

    UnknownEvent:
      type: object
      required: [type, turnId, sttSpeakerChange]
      properties:
        type:
          const: unknown
        turnId:
          type: string
        text:
          type: string
        phase:
          $ref: '#/components/schemas/Phase'
        sttSpeakerChange:
          $ref: '#/components/schemas/SpeakerChange'
        naming:
          type: string
          enum: [pending]
          description: |
            `pending`: published before the speaker was named (the server's
            publish-before-naming mode). The name follows as a
            `turn_speaker_amend` with `by: model` — `newName` `unknown` when
            the turn could not be named. Show the text now; fill the name in
            place when it arrives.

    SpeakerChange:
      type: string
      enum: [same, new, unknown]
      description: |
        The speech engine's observation that this turn's speaker is the
        `same` as the previous turn's, `new`, or `unknown` when it cannot
        tell. Always present.

        - Only the Meta Muse engine observes it (speaker diarization). Calls
          on AssemblyAI (a per-call option; Muse is the default) are always
          `unknown`. So is the
          first turn on each new Muse connection (at the start, at its
          55-minute handover, after a reconnect — labels restart) and a turn
          right after one the engine could not label.
        - It is what the engine heard, not truth: an operator's rename does
          not change it, and it is never an input to `name`.
        - `same` means "same as the turn just before". If you did not receive
          the turn with the previous turn number (turnId `<slug>-<n-1>`),
          treat `same` as `unknown`.
        - Measured against published transcripts on two calls: every real
          speaker change was marked `new` (104 of 104) — counting the changes that fell on a turn boundary; Muse merged 3 of TTWO's 76 into the previous turn, and those carry no mark —, so a `same` mark was
          never wrong (176 of 176). `new` was right about 94% of the time on
          turns of more than 5 words (101 of 108), but only 3 of 17 times on
          turns of 5 words or fewer.

    PhaseTransitionEvent:
      type: object
      required: [type, fromPhase, toPhase, triggerTurnId]
      properties:
        type:
          const: phase_transition
        fromPhase:
          $ref: '#/components/schemas/Phase'
        toPhase:
          $ref: '#/components/schemas/Phase'
        triggerTurnId:
          type: string

    AiInsightEvent:
      type: object
      required: [type, turnId, insight, category]
      properties:
        type:
          const: ai_insight
        turnId:
          type: string
          description: |
            Anchors back to the attribution event the insight applies
            to. Match against your already-rendered turn.
        insight:
          type: string
          description: One-paragraph synthesis of why this turn matters.
        category:
          type: string
          description: |
            Coarse bucket — e.g. `guidance`, `surprise`, `pressure`,
            `numbers`. New values may be added without notice; treat
            unknown values as an opaque tag, never strict-validate.
        frame:
          type: string
          description: Optional extra context paragraph.
        watchFor:
          type: string
          description: Optional follow-up signal to listen for.

    TurnSpeakerAmendEvent:
      type: object
      required: [type, turnId, newName]
      description: |
        Post-publish operator correction to a previously-published
        turn's speaker label. Apply in-place: the original turn keeps
        its chronological position; only the displayed speaker name
        changes. An operator correction beats a `by: model` name; otherwise the
        latest amend per `turnId` wins.

        Historical note: calls published before 2026-05-21 may carry
        a second `AttributionEvent` on the same `turnId` instead
        (the legacy amend path). Subscribers should treat either
        pattern as a correction.
      properties:
        type:
          const: turn_speaker_amend
        turnId:
          type: string
        newName:
          type: string
        by:
          type: string
          enum: [model, operator]
          description: |
            Who named the turn. `model`: the model's late name for a turn
            published before naming (`newName` `unknown` = it could not be
            named); show no "corrected" marker. Absent or `operator`: an
            operator's correction. An operator's name always wins over the
            model's, whatever order they arrive in; within one origin the
            latest amend wins.
        basis:
          type: string
          enum: [early]
          description: |
            `early`: the model's full-text naming failed, and `newName` is the
            turn's early, first-sentence name (already shown on the partial
            stream), kept rather than dropped. Show it as provisional. Only
            ever with `by: model`; absent on every other amend.
        reason:
          type: string
          description: Optional operator-supplied rationale (capped 200 chars).

    DoneEvent:
      type: object
      required: [type]
      properties:
        type:
          const: done

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
    queryToken:
      type: httpApiKey
      in: query
      name: token
