{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://chatbox.example/ws-schemas.json",
  "title": "Chatbox WebSocket message schemas",
  "description": "Wire shapes for /v1/chat/ws and /v1/rtc/ws. Both endpoints authenticate at upgrade via Sec-WebSocket-Protocol: chatbox, bearer.<JWT>.",
  "$defs": {
    "chatClientEnvelope": {
      "type": "object",
      "required": ["v", "request_id", "type"],
      "properties": {
        "v": { "const": 1 },
        "request_id": { "type": "string", "format": "uuid" },
        "type": {
          "enum": ["message.send", "message.edit", "message.delete", "receipt.delivered", "receipt.read", "reaction.add", "reaction.remove", "typing.started", "typing.stopped", "broadcast", "catchup", "ping"]
        },
        "payload": { "type": "object" }
      },
      "allOf": [
        {
          "if": { "properties": { "type": { "const": "broadcast" } } },
          "then": {
            "properties": {
              "payload": {
                "type": "object",
                "required": ["body"],
                "description": "An ephemeral room event: fanned out to the members connected at that moment, never stored, never replayed by catchup. No ack on success; a refusal is an error ack (payload_too_large over the body cap, rate_limited past 20 a second per socket with a burst of 40, blocked in a frozen direct conversation, forbidden without chat:send) and the socket stays open.",
                "properties": {
                  "body": { "description": "free-form JSON, at most 1024 bytes as compact JSON, delivered exactly as sent" },
                  "to": { "type": "string", "minLength": 1, "maxLength": 256, "description": "deliver to this one member's connected sockets instead of the whole room; a member with no socket in the room receives nothing and the sender is not told" }
                }
              }
            }
          }
        },
        {
          "if": { "properties": { "type": { "const": "message.send" } } },
          "then": {
            "properties": {
              "payload": {
                "type": "object",
                "required": ["client_message_id"],
                "properties": {
                  "client_message_id": { "type": "string", "maxLength": 128 },
                  "kind": { "enum": ["text", "image", "voice", "video", "story", "file", "system", "mls.commit", "mls.proposal", "mls.application", "mls.welcome"], "default": "text" },
                  "body": {},
                  "reply_to": { "type": "string", "format": "uuid" },
                  "attachment_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "maxItems": 10 },
                  "forward_of": { "type": "string", "format": "uuid", "description": "forward this message (from any room the sender is in) instead of composing one; kind, body and attachment_ids are then ignored" }
                }
              }
            }
          }
        },
        {
          "if": { "properties": { "type": { "enum": ["receipt.delivered", "receipt.read", "message.delete"] } } },
          "then": { "properties": { "payload": { "type": "object", "required": ["message_id"], "properties": { "message_id": { "type": "string", "format": "uuid" } } } } }
        },
        {
          "if": { "properties": { "type": { "const": "message.edit" } } },
          "then": { "properties": { "payload": { "type": "object", "required": ["message_id", "body"], "properties": { "message_id": { "type": "string", "format": "uuid" }, "body": {}, "if_version": { "type": "string" } } } } }
        },
        {
          "if": { "properties": { "type": { "const": "catchup" } } },
          "then": { "properties": { "payload": { "type": "object", "required": ["after_seq"], "properties": { "after_seq": { "type": "integer" }, "limit": { "type": "integer", "maximum": 500 } } } } }
        }
      ]
    },
    "chatAck": {
      "type": "object",
      "required": ["v", "type", "request_id"],
      "properties": {
        "v": { "const": 1 },
        "type": { "enum": ["ack", "error"] },
        "request_id": { "type": "string", "format": "uuid" },
        "payload": { "type": "object" },
        "error": {
          "type": "object",
          "required": ["code", "message"],
          "properties": { "code": { "type": "string" }, "message": { "type": "string" } }
        }
      }
    },
    "chatServerEnvelope": {
      "type": "object",
      "required": ["v", "event_id", "type", "room_id", "payload", "occurred_at"],
      "properties": {
        "v": { "const": 1 },
        "event_id": { "type": "integer", "description": "messages.event_seq for message.*; 0 for ephemeral events" },
        "type": {
          "enum": ["message.created", "message.updated", "message.deleted", "receipt.delivered", "receipt.read", "reaction.added", "reaction.removed", "attachment.ready", "attachment.failed", "typing.started", "typing.stopped", "presence.changed", "broadcast", "member.added", "member.removed", "member.updated", "call.started", "call.ended", "resync", "pong"]
        },
        "room_id": { "type": "string", "format": "uuid" },
        "payload": {},
        "occurred_at": { "type": "string", "format": "date-time" }
      },
      "allOf": [
        {
          "if": { "properties": { "type": { "const": "broadcast" } } },
          "then": {
            "properties": {
              "event_id": { "const": 0 },
              "payload": {
                "type": "object",
                "required": ["from", "body"],
                "description": "A member's ephemeral event, as they sent it. Live only: a member who was not connected never receives it, and catchup never replays it. The sender's own sockets receive it too.",
                "properties": {
                  "from": { "type": "string", "description": "the sender's principal_id" },
                  "body": { "description": "the sender's body, unchanged" },
                  "to": { "type": "string", "description": "present only when it was addressed to one member: the receiver" }
                }
              }
            }
          }
        }
      ]
    },
    "rtcClientFrame": {
      "type": "object",
      "required": ["cmd"],
      "properties": {
        "cmd": { "enum": ["offer", "answer", "candidate", "leave"], "description": "register is NOT accepted — identity comes from the verified token" },
        "sdp": {
          "type": "object",
          "required": ["type", "sdp"],
          "properties": { "type": { "enum": ["offer", "answer"] }, "sdp": { "type": "string" } }
        },
        "candidate": {
          "type": "object",
          "required": ["candidate"],
          "properties": { "candidate": { "type": "string" }, "sdpMid": { "type": ["string", "null"] }, "sdpMLineIndex": { "type": ["integer", "null"] } },
          "description": "trickle-ICE candidate (RTCIceCandidateInit) for cmd=candidate"
        },
        "request_id": { "type": "integer", "description": "echo the id of an SFU re-offer when answering it" }
      }
    },
    "rtcServerFrame": {
      "description": "One server->client frame: an SDP answer/re-offer, a trickle-ICE candidate, a roster event, or a media-policy error event. Discriminate on type, then candidate, then event; ignore unrecognised frames.",
      "oneOf": [
        { "$ref": "#/$defs/rtcServerSdpFrame" },
        { "$ref": "#/$defs/rtcServerCandidateFrame" },
        { "$ref": "#/$defs/rtcRosterFrame" },
        { "$ref": "#/$defs/rtcPolicyErrorFrame" }
      ]
    },
    "rtcPolicyErrorFrame": {
      "type": "object",
      "required": ["event", "reason", "operation", "policy_revision", "retryable"],
      "description": "The member's media policy (or current role) refused something. operation=publish: one frame per refused m-section of your offer, sent beside the answer, which negotiates that section inactive; the socket stays open. reason=policy_changed: a live publication the policy has since withdrawn - stop that capture and re-offer within 5 s, or the socket is closed 4409 policy_apply_failed. operation=join with reason=device_limit: the member is at its device limit (on_limit reject-new); the socket is closed 4411 right after this frame, before any peers frame.",
      "properties": {
        "event": { "const": "error" },
        "reason": { "enum": ["audio_not_allowed", "video_not_allowed", "audio_track_limit", "video_track_limit", "publish_forbidden", "policy_changed", "device_limit"] },
        "operation": { "enum": ["publish", "join"] },
        "policy_revision": { "type": "integer", "description": "the member's stored policy revision; 0 when the member has no policy (publish_forbidden from the role alone)" },
        "retryable": { "const": false },
        "mid": { "type": "string", "description": "operation=publish: the refused m-section" },
        "request_id": { "type": "integer", "description": "the offer's request_id, echoed when the offer carried one" },
        "max_active": { "type": "integer", "description": "reason=device_limit: the member's devices.max_active" }
      }
    },
    "rtcServerSdpFrame": {
      "type": "object",
      "required": ["type", "sdp"],
      "properties": {
        "type": { "enum": ["offer", "answer"], "description": "answer = reply to your offer; offer = SFU subscribe re-offer, reply with cmd=answer echoing request_id" },
        "sdp": { "type": "string" },
        "request_id": { "type": "integer" }
      }
    },
    "rtcServerCandidateFrame": {
      "type": "object",
      "required": ["candidate"],
      "description": "Trickle-ICE candidate to apply via addIceCandidate. Not emitted while the SFU is ICE-lite, which injects its host candidate into the answer.",
      "properties": {
        "candidate": {
          "type": "object",
          "properties": { "candidate": { "type": "string" }, "sdpMid": { "type": ["string", "null"] }, "sdpMLineIndex": { "type": ["integer", "null"] } }
        }
      }
    },
    "rtcPeer": {
      "type": "object",
      "required": ["client_id", "principal_id"],
      "properties": {
        "client_id": { "type": "string", "pattern": "^[0-9]+$", "description": "One signaling connection. A DECIMAL STRING, not a number: it is a u64 that routinely exceeds 2^53, where JSON.parse rounds. It is the same value the SFU stamps into every forwarded track's msid as peer-<client_id>-<original>, so match it as a string (a socket that negotiated the chatbox.msid-principal subprotocol sees the publisher's principal_id as the stream id and the publisher's own track id instead). A reconnect, a second tab and a second device each get their own and ids are never reused, so two peers may share one principal_id." },
        "principal_id": { "type": "string", "description": "The tenant's identifier for the human. Stable across that peer's reconnects; group tracks by client_id, attribute them to a person by principal_id." }
      }
    },
    "rtcRosterFrame": {
      "type": "object",
      "required": ["event"],
      "description": "Live call roster. 'peers' is sent once on connect, before any SDP, and excludes the receiving client. 'peer.joined' is an upsert. 'peer.left' is a roster hint, not a media event - the SFU stops forwarding that peer's tracks on its own. Delivery is best-effort; the peers snapshot on the next connect is the resync.",
      "properties": {
        "event": { "enum": ["peers", "peer.joined", "peer.left"] },
        "peers": { "type": "array", "items": { "$ref": "#/$defs/rtcPeer" }, "description": "event=peers only; [] when alone in the call" },
        "client_id": { "type": "string", "pattern": "^[0-9]+$", "description": "event=peer.joined and peer.left only" },
        "principal_id": { "type": "string", "description": "event=peer.joined only" }
      },
      "allOf": [
        {
          "if": { "properties": { "event": { "const": "peers" } }, "required": ["event"] },
          "then": { "required": ["peers"] }
        },
        {
          "if": { "properties": { "event": { "const": "peer.joined" } }, "required": ["event"] },
          "then": { "required": ["client_id", "principal_id"] }
        },
        {
          "if": { "properties": { "event": { "const": "peer.left" } }, "required": ["event"] },
          "then": { "required": ["client_id"] }
        }
      ]
    }
  }
}
