{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.kodemed.ch/websocket/Message-1.0.schema.json",
  "title": "KodeMed WebSocket Message Envelope",
  "description": "Full-duplex JSON message between the CodingClient (Windows tray) and the server over `/ws/dll`. The same envelope shape carries every event the server sends and every command the client sends; the `eventType` discriminator selects the payload variant.\n\n## Field-name alignment with the HIS REST webhook (#640)\n\nThe envelope uses `eventType`, `sessionId`, `instanceId`, `occurredAt` — the EXACT same field names the HIS REST webhook uses. A partner writing a result-handler can re-use the same `CodingResult` parser for both transports. The earlier draft used `type` instead of `eventType` and carried a separate `correlationId` — both dropped (Ricardo 2026-06-01 + last-review feedback): single `eventType` discriminator, single `instanceId` correlator across REST + webhook + WS.\n\n## Model-separation invariant (also #640)\n\nWhen the `case.coded` variant carries a `payload.codingResult`, the matching SPIGES XML (when included as `payload.data`) stays pure — no `<GrouperResult>` injection inside `<Fall>`. Same rule as the HIS REST webhook.",
  "type": "object",
  "required": ["eventType", "occurredAt"],
  "properties": {
    "eventType": {
      "$ref": "../_common/EventType.schema.json",
      "description": "Discriminator. Selects the `payload` shape via the `oneOf` below."
    },
    "occurredAt": {
      "$ref": "../_common/Timestamp.schema.json",
      "description": "Server-side or client-side timestamp the event was emitted. Independent of network arrival time."
    },
    "sessionId": {
      "$ref": "../_common/Identifier.schema.json",
      "description": "The session this message relates to. NOTE: on the session-completion events the server actually emits (`case.coded`, `case.discarded`, `session.expired`) the sessionId travels INSIDE `payload.sessionId` and the top-level correlator is `instanceId` — see `CodingSessionService.notifyDllSessionCompleted`. Top-level `sessionId` is reserved for client→server commands that address a session directly."
    },
    "instanceId": {
      "$ref": "../_common/OpaqueIdentifier.schema.json",
      "description": "HIS-supplied correlator echoed from the original SessionCreate request. Single correlation key across REST + webhook + WebSocket (#640 last review: no separate `correlationId` — `instanceId` is the unique id across every transport)."
    },
    "payload": {
      "type": "object",
      "description": "Variant-specific data. See `oneOf` below for the shape under each `eventType`. Some events (e.g. `dll.heartbeat`) carry no payload at all — `payload` is then absent."
    },
    "message": {
      "type": "string",
      "description": "Human-readable diagnostic emitted by the server alongside some events (e.g. `Coding session applied` on case.coded). Informational only — partners parse `eventType` + `payload`, never this field."
    },
    "error": {
      "type": "string",
      "description": "Human-readable error text on server-side failures. Diagnostic only; structured errors arrive as `app.error` frames with `payload.errorCode` / `payload.errorMessage`."
    },
    "errorCode": {
      "type": "integer",
      "description": "Numeric error code accompanying `error`. Diagnostic only."
    }
  },
  "oneOf": [
    {
      "title": "session.created",
      "properties": {
        "eventType": { "const": "session.created" },
        "payload": {
          "type": "object",
          "required": ["redirectUrl", "expiresAt"],
          "properties": {
            "redirectUrl": { "type": "string", "format": "uri", "description": "Iframe URL — same value the SessionResponse REST endpoint returned." },
            "expiresAt":   { "$ref": "../_common/Timestamp.schema.json" }
          },
          "unevaluatedProperties": false
        }
      },
      "required": ["sessionId", "payload"]
    },
    {
      "title": "case.coded",
      "description": "Coder pressed Apply. CodingClient-facing — server pushes case.coded to the Windows tray CodingClient so it can close / refresh the session UI. \n\n**Partner integrations should NOT consume this WS event** — the canonical partner-facing carrier is the HIS REST WEBHOOK (`schemas/his_rest/WebhookPayload-1.0.schema.json`) which carries the same outcome with the strict canonical shape (data + format at top level, codingResult sibling). The WS case.coded payload is shaped for CodingClient UI needs (action + logout signals control the tray app) and intentionally diverges from the webhook contract on those internal fields.\n\n#640 audit (2026-06-02 integration-flow review CRITICAL #4) confirmed the pre-audit WS schema was stricter than the server's actual emission — the schema rejected every legitimate case.coded frame. This branch is now aligned with what `CodingSessionService.notifyDllSessionCompleted` emits.",
      "properties": {
        "eventType": { "const": "case.coded" },
        "payload": {
          "type": "object",
          "description": "CodingClient-specific payload. Carries (a) the canonical case data + the action + logout flag the tray UI needs, OR (b) the codingResult when the grouper has produced one. Both shapes accepted because the server emits (a) directly and the grouper-aware pipeline emits (b) — partners consume case.coded via the WEBHOOK schema instead.",
          "properties": {
            "sessionId":    { "$ref": "../_common/Identifier.schema.json" },
            "action":       { "type": "string", "enum": ["APPLIED", "DISCARDED", "CANCELLED", "TIMEOUT"], "description": "CodingClient-internal action label mirrored from CodingAction. Partners use eventType instead." },
            "logout":       { "type": "boolean", "description": "When true, the CodingClient closes the session UI; when false, it stays open for next case." },
            "data":         { "type": "string", "description": "Coded SPIGES XML — present only when the coder pressed Apply with a populated case." },
            "format":       { "type": "string", "enum": ["spiges", "bfs", "custom"] },
            "codingResult": { "$ref": "../_common/CodingResult.schema.json", "description": "Grouper output. Present when the grouper has produced a result for this case; absent on the immediate CodingClient notification (the grouper-aware pipeline backfills it later)." }
          },
          "unevaluatedProperties": false
        }
      },
      "required": ["instanceId", "payload"]
    },
    {
      "title": "case.discarded",
      "description": "Coder pressed Discard. Same emission path as case.coded (`notifyDllSessionCompleted`): top-level `instanceId` correlator, `payload.sessionId` + `action` + `logout`. No `data` (nothing was applied).",
      "properties": {
        "eventType": { "const": "case.discarded" }
      },
      "required": ["instanceId"]
    },
    {
      "title": "session.expired",
      "description": "Session timed out before the coder finished. Same emission path as case.coded: top-level `instanceId`, `payload.sessionId`.",
      "properties": {
        "eventType": { "const": "session.expired" }
      },
      "required": ["instanceId"]
    },
    {
      "title": "code.added",
      "description": "Intermediate event: coder added a code to the case mid-session. Lightweight, no full CodingResult.",
      "properties": {
        "eventType": { "const": "code.added" },
        "payload": {
          "type": "object",
          "required": ["caseId", "code", "catalog"],
          "properties": {
            "caseId":  { "$ref": "../_common/Identifier.schema.json" },
            "code":    { "type": "string", "examples": ["E11.9", "47.01"] },
            "catalog": { "type": "string", "enum": ["ICD-10-GM", "ICD-10-WHO", "CHOP", "ATC"] }
          },
          "unevaluatedProperties": false
        }
      },
      "required": ["sessionId", "payload"]
    },
    {
      "title": "code.removed",
      "properties": {
        "eventType": { "const": "code.removed" },
        "payload": {
          "type": "object",
          "required": ["caseId", "code", "catalog"],
          "properties": {
            "caseId":  { "$ref": "../_common/Identifier.schema.json" },
            "code":    { "type": "string" },
            "catalog": { "type": "string", "enum": ["ICD-10-GM", "ICD-10-WHO", "CHOP", "ATC"] }
          },
          "unevaluatedProperties": false
        }
      },
      "required": ["sessionId", "payload"]
    },
    {
      "title": "grouping.refreshed",
      "description": "Server pushed an updated grouping result after a code add/remove. The CodingResult shape is the same as the webhook + case.coded — partners parse with one helper.",
      "properties": {
        "eventType": { "const": "grouping.refreshed" },
        "payload": {
          "type": "object",
          "required": ["codingResult"],
          "properties": {
            "codingResult": { "$ref": "../_common/CodingResult.schema.json" }
          },
          "unevaluatedProperties": false
        }
      },
      "required": ["sessionId", "payload"]
    },
    {
      "title": "dll.heartbeat",
      "properties": { "eventType": { "const": "dll.heartbeat" } }
    },
    {
      "title": "dll.heartbeat_ack",
      "description": "Server acknowledgement of a dll.heartbeat (DllInstanceService.sendHeartbeatResponse). Empty / minimal payload.",
      "properties": { "eventType": { "const": "dll.heartbeat_ack" } }
    },
    {
      "title": "dll.connected",
      "description": "DLL announced presence. Optional payload carrying the dllInstanceId + sessionId so the UI can route subsequent traffic.",
      "properties": {
        "eventType": { "const": "dll.connected" },
        "payload":   { "type": "object" }
      }
    },
    {
      "title": "dll.disconnected",
      "description": "DLL disconnected (clean or timeout). Optional payload carrying which dllInstanceId + which session was lost.",
      "properties": {
        "eventType": { "const": "dll.disconnected" },
        "payload":   { "type": "object" }
      }
    },
    {
      "title": "app.* / internal app-control events",
      "description": "Server-internal control envelopes (CodingClient lifecycle, session-action requests). Schema accepts the canonical envelope; the per-event payload shape is intentionally permissive ('object') because these events are server↔client control traffic NOT part of the partner-visible contract — partners SHOULD ignore app.* events. Listed here so the WebSocket envelope schema is exhaustive against the canonical EventType enum and the drift detector (R13) can prove coverage.",
      "properties": {
        "eventType": {
          "enum": [
            "app.coding_session_launch",
            "app.ui_session_joined",
            "app.ui_session_join",
            "app.ui_session_leave",
            "app.session_cancelled",
            "app.session_action",
            "app.session_close",
            "app.process_request",
            "app.config_request",
            "app.error"
          ]
        },
        "payload": { "type": "object" }
      }
    }
  ],
  "unevaluatedProperties": false,
  "examples": [
    {
      "eventType":  "case.coded",
      "occurredAt": "2026-06-01T19:42:11Z",
      "instanceId": "HIS-PMS-7f3a2b",
      "message":    "Coding session applied",
      "payload": {
        "sessionId": "0f9ea092-c774-4624-bb10-8910ec03d1db",
        "action":    "APPLIED",
        "logout":    false,
        "data":      "<?xml version=\"1.0\"?><spiges><Fall fall_id=\"F001\">…</Fall></spiges>"
      }
    },
    {
      "eventType":  "dll.heartbeat",
      "occurredAt": "2026-06-01T19:42:11Z"
    }
  ]
}
