{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://cassini-format.codemyriad.io/schema/cassini-portable-meeting-manifest-v1.schema.json",
  "title": "Cassini Portable Meeting Manifest v1",
  "description": "This schema is deliberately open. Every object below accepts members it does not declare, except integrity and every payloadRef, and a consumer MUST ignore a manifest member it does not recognise, at any depth, rather than treat its presence as an error. Those two objects are the exception because everything in them is an instruction, for reassembling bytes or for deciding whether this audio and this transcript belong together, so an unrecognised member there is a decode or verification failure and not a lost hint. Adding a member to either one is a new major version. Being open does not mean the file may be anything: every member this schema does declare keeps the type and the meaning declared here.",
  "type": "object",
  "required": [
    "kind",
    "version",
    "profile",
    "meeting",
    "audio",
    "integrity",
    "speakers",
    "transcripts"
  ],
  "properties": {
    "kind": {
      "const": "cassini-portable-meeting",
      "description": "The literal string that says this document is a portable meeting manifest. It does not vary, in any version of the format."
    },
    "version": {
      "const": 1,
      "description": "The wire version of this manifest. The published format is 1."
    },
    "profile": {
      "const": "ogg-opus"
    },
    "meeting": {
      "$ref": "#/$defs/meeting"
    },
    "audio": {
      "$ref": "#/$defs/audio"
    },
    "integrity": {
      "$ref": "#/$defs/integrity"
    },
    "speakers": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/speaker"
      }
    },
    "transcripts": {
      "type": "array",
      "minItems": 1,
      "items": {
        "$ref": "#/$defs/transcriptEntry"
      }
    },
    "readableTranscripts": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/readableTranscriptEntry"
      }
    },
    "provenance": {
      "type": "object",
      "description": "Open. A new kind of provenance is the likeliest addition of all: meetingSummary, attribution and wordTimings were each exactly that, and each cost a version bump under a closed schema.",
      "properties": {
        "speechToText": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/$defs/processingStep"
          }
        },
        "readableCleanup": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/$defs/processingStep"
          }
        },
        "displayTranscript": {
          "type": "object",
          "additionalProperties": {
            "$ref": "#/$defs/processingStep"
          }
        },
        "meetingSummary": {
          "$ref": "#/$defs/processingStep"
        },
        "attribution": {
          "$ref": "#/$defs/attributionProvenance"
        },
        "wordTimings": {
          "$ref": "#/$defs/wordTimingProvenance"
        }
      }
    },
    "chapters": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/chapter"
      }
    },
    "summary": {
      "type": "object",
      "description": "Metadata about the summary attachment, not the summary."
    },
    "attachments": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "name",
          "mime",
          "contentBase64"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "mime": {
            "type": "string",
            "minLength": 1
          },
          "contentBase64": {
            "type": "string"
          }
        }
      }
    }
  },
  "$defs": {
    "meeting": {
      "type": "object",
      "description": "Open. Meeting metadata is open-ended by nature, and nothing in it is load-bearing for decoding, so a member a consumer does not recognise is ignored.",
      "required": [
        "id",
        "title",
        "createdAtUtc",
        "durationMs"
      ],
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1
        },
        "title": {
          "type": "string",
          "minLength": 1
        },
        "createdAtUtc": {
          "type": "string",
          "format": "date-time",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"
        },
        "recordedAtLocal": {
          "type": "string",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}$",
          "description": "Wall-clock time at the recording site, no offset. A label, not an instant."
        },
        "processedAtUtc": {
          "type": "string",
          "format": "date-time",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$"
        },
        "durationMs": {
          "type": "integer",
          "minimum": 0
        },
        "language": {
          "type": "string"
        },
        "roomId": {
          "type": "string",
          "pattern": "^rm_[0-9a-f]{16}$",
          "description": "Id of the conversation this meeting was recorded in: a deterministic one-way derivation of the room's identity, never the identity itself. For a Nextcloud Talk recording it derives from the conversation token, which is also that room's join link and is therefore never published. Shaped rm_<16 lowercase hex>. Absent when the room is unknown."
        },
        "roomName": {
          "type": "string",
          "description": "LEGACY, read-only. The room's display name frozen at record time. Producers stopped writing this: a display name is editable and a published recording is not, so the current name lives in the catalog entry instead. The record-time name is still available as the meeting title. Absent in anything packed after that change."
        },
        "jobId": {
          "type": "string",
          "description": "Id of the operator job that produced this artifact. Absent when the producer was not an operator job. Discloses nothing new: the operator publishes the artifact under this same id."
        },
        "attemptNumber": {
          "type": "integer",
          "minimum": 1,
          "description": "Which attempt of that job produced this artifact, 1-based. Absent when unknown. There is no separate attempt id: an attempt's identity is the pair (jobId, attemptNumber)."
        },
        "summary": {
          "type": "string",
          "description": "A short plain-text summary of the meeting, for display."
        }
      }
    },
    "audio": {
      "type": "object",
      "description": "Open. Descriptive, not instructional: it restates what the Ogg stream already says, so a consumer that ignores a member it does not recognise loses nothing.",
      "required": [
        "container",
        "codec",
        "sampleRate",
        "channels",
        "sampleCount",
        "durationMs"
      ],
      "properties": {
        "container": {
          "const": "ogg"
        },
        "codec": {
          "const": "opus"
        },
        "sampleRate": {
          "const": 48000
        },
        "channels": {
          "type": "integer",
          "enum": [
            1,
            2
          ]
        },
        "sampleCount": {
          "type": "integer",
          "minimum": 0
        },
        "durationMs": {
          "type": "integer",
          "minimum": 0
        }
      }
    },
    "integrity": {
      "type": "object",
      "additionalProperties": false,
      "description": "CLOSED. A member here that a consumer does not recognise is a verification failure and not a hint: a consumer that ignored an unrecognised digest field would report a file as verified while ignoring the field that defines its identity. Adding a member to this object is a new major version, and that is the deliberate price of the open-world rule everywhere else.",
      "required": [
        "matchPolicy",
        "opusAudioSha256",
        "sampleRate",
        "channels",
        "sampleCount",
        "durationMs"
      ],
      "properties": {
        "matchPolicy": {
          "const": "exact-opus-audio-v1"
        },
        "opusAudioSha256": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$"
        },
        "sampleRate": {
          "const": 48000
        },
        "channels": {
          "type": "integer",
          "enum": [
            1,
            2
          ]
        },
        "sampleCount": {
          "type": "integer",
          "minimum": 0
        },
        "durationMs": {
          "type": "integer",
          "minimum": 0
        }
      }
    },
    "speaker": {
      "type": "object",
      "description": "Open. The two-string speaker is deliberate, but a colour or an external identity is a hint and not an instruction, so a member a consumer does not recognise is ignored.",
      "required": [
        "id",
        "label"
      ],
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1
        },
        "label": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "transcriptEntry": {
      "type": "object",
      "description": "Open. Everything load-bearing is in required, so a missing or misspelled one is still an error, and anything else is a hint a consumer may ignore.",
      "required": [
        "id",
        "role",
        "format",
        "payloadRef"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/transcriptId"
        },
        "role": {
          "enum": [
            "raw-asr",
            "human-corrected",
            "translation",
            "scripted"
          ],
          "description": "How this transcript came to exist. raw-asr is what a recogniser produced; human-corrected is that text after a person fixed it; translation is it in another language; scripted is authored text the recording was made from, which is not a transcription of anything and is authoritative rather than derived."
        },
        "default": {
          "type": "boolean"
        },
        "format": {
          "type": "string",
          "minLength": 1
        },
        "language": {
          "type": "string"
        },
        "wordCount": {
          "type": "integer",
          "minimum": 0
        },
        "createdAtUtc": {
          "type": "string",
          "format": "date-time"
        },
        "sourceTranscriptId": {
          "$ref": "#/$defs/transcriptId",
          "description": "The transcript this one was derived from. Expected on human-corrected and translation entries; a raw-asr entry MUST NOT carry it. The id must name a transcript this file declares."
        },
        "payloadRef": {
          "$ref": "#/$defs/payloadRef"
        }
      }
    },
    "readableTranscriptEntry": {
      "type": "object",
      "description": "Open. Same grounds as transcriptEntry: everything load-bearing is in required, so a missing or misspelled one is still an error, and anything else is a hint a consumer may ignore.",
      "required": [
        "id",
        "role",
        "format",
        "sourceTranscriptId",
        "payloadRef"
      ],
      "properties": {
        "id": {
          "$ref": "#/$defs/transcriptId"
        },
        "role": {
          "enum": [
            "readable-cleanup",
            "display"
          ]
        },
        "default": {
          "type": "boolean"
        },
        "format": {
          "type": "string",
          "minLength": 1
        },
        "language": {
          "type": "string"
        },
        "wordCount": {
          "type": "integer",
          "minimum": 0
        },
        "sourceTranscriptId": {
          "$ref": "#/$defs/transcriptId",
          "description": "The entry this readable rendering was made from. Required here. The id must name a transcript this file declares, in either array."
        },
        "createdAtUtc": {
          "type": "string",
          "format": "date-time"
        },
        "payloadRef": {
          "$ref": "#/$defs/payloadRef"
        }
      }
    },
    "transcriptId": {
      "type": "string",
      "pattern": "^[a-z0-9][a-z0-9-]{0,31}$",
      "description": "Lowercase, and no underscore. A transcript's tag prefix is its id upper-cased with - replaced by _, so raw-asr and raw_asr would both address CASSINI_TX_RAW_ASR_PAYLOAD_: two entries, one chunk set, one body lost with no error anywhere."
    },
    "payloadRef": {
      "type": "object",
      "additionalProperties": false,
      "description": "CLOSED. A member here that a consumer does not recognise is a decode failure and not a hint: everything in this object is an instruction for reassembling bytes, and a consumer that ignored one would build the wrong bytes and then fail the digest it was given. Adding a member to this object is a new major version.",
      "required": [
        "prefix",
        "chunkCount",
        "sha256",
        "rawBytes",
        "gzipBytes",
        "mime",
        "encoding"
      ],
      "properties": {
        "prefix": {
          "type": "string",
          "pattern": "^CASSINI_TX_[A-Z0-9_]+_PAYLOAD_$"
        },
        "chunkCount": {
          "type": "integer",
          "minimum": 1
        },
        "sha256": {
          "type": "string",
          "pattern": "^[0-9a-f]{64}$"
        },
        "rawBytes": {
          "type": "integer",
          "minimum": 0
        },
        "gzipBytes": {
          "type": "integer",
          "minimum": 0
        },
        "mime": {
          "type": "string",
          "minLength": 1
        },
        "encoding": {
          "type": "string",
          "minLength": 1
        }
      }
    },
    "processingStep": {
      "type": "object",
      "description": "Open. Every member is optional and every member is a note about how one stage ran, so a consumer ignores what it does not recognise.",
      "properties": {
        "backend": {
          "type": "string"
        },
        "engine": {
          "type": "string"
        },
        "model": {
          "type": "string"
        },
        "device": {
          "type": "string"
        },
        "language": {
          "type": "string"
        },
        "source": {
          "type": "string"
        },
        "version": {
          "type": "string"
        }
      }
    },
    "attributionProvenance": {
      "type": "object",
      "description": "Open. The five required counters are what a consumer needs to read the record; a sixth statistic is a hint.",
      "required": [
        "ran",
        "mode",
        "wordsMeasured",
        "wordsFlagged",
        "wordsDropped"
      ],
      "properties": {
        "ran": {
          "type": "boolean",
          "description": "Whether the cross-track attribution stage ran against this file's default raw transcript."
        },
        "mode": {
          "type": "string",
          "minLength": 1,
          "description": "How flagged words were handled: annotate (per-word evidence kept on the words), drop (flagged words deleted before publication; this record is then the only trace they existed), or disabled."
        },
        "reason": {
          "type": "string",
          "description": "Why the stage did not run. Absent when ran is true."
        },
        "wordsMeasured": {
          "type": "integer",
          "minimum": 0
        },
        "wordsFlagged": {
          "type": "integer",
          "minimum": 0
        },
        "wordsDropped": {
          "type": "integer",
          "minimum": 0
        },
        "thresholdDb": {
          "type": "number",
          "description": "The meeting's estimated crosstalk threshold in dB. Absent when the meeting showed no crosstalk population."
        }
      }
    },
    "wordTimingProvenance": {
      "type": "object",
      "required": [
        "endsBoundedByAudio"
      ],
      "description": "How the producer decided where each word ends. Absent on files produced before the rule changed, and on files whose speech recognizer makes no such guarantee. Absent means only that the guarantee was not made: the ends may be token-derived, in which case a word's end came from its last token including a trailing punctuation mark — a mark the recognizer stamps at the next acoustic onset, so such a word can run for seconds while its speaker is silent — or they may be sound and simply undeclared. A consumer keys off presence: absent means those ends are unvouched-for and a repair that clips an over-long word is appropriate; present means they were measured against the speaker's own audio and clipping them destroys correct timing. Open: one required boolean, and the object is built around presence rather than exhaustiveness.",
      "properties": {
        "endsBoundedByAudio": {
          "description": "True when each word's end was measured against its own speaker's track rather than taken from its last token's timestamp.",
          "const": true
        }
      }
    },
    "chapter": {
      "type": "object",
      "description": "Open.",
      "required": [
        "startMs",
        "title"
      ],
      "properties": {
        "startMs": {
          "type": "integer",
          "minimum": 0
        },
        "endMs": {
          "type": "integer",
          "minimum": 0
        },
        "title": {
          "type": "string",
          "minLength": 1
        }
      }
    }
  }
}
