{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://golfsga.com/sga-ois/schema/0.1/scorecard.schema.json",
  "title": "SGA-OIS scorecard checkpoint",
  "description": "Emitted by the simulator after every player in the round has completed a hole. Two purposes, and both matter: it is an independent auditable record that the shot stream can be reconciled against, and it is the canonical current state for any display that joins mid-round. A display should never have to replay a shot stream to know the score.",
  "type": "object",
  "required": [
    "sga_kind",
    "session_id",
    "reason",
    "issued_at",
    "slots"
  ],
  "additionalProperties": false,
  "properties": {
    "sga_kind": {
      "const": "scorecard",
      "description": "Document type discriminator. Always scorecard."
    },
    "session_id": {
      "$ref": "common.schema.json#/$defs/sgaId"
    },
    "round_id": {
      "$ref": "common.schema.json#/$defs/sgaId"
    },
    "event_id": {
      "$ref": "common.schema.json#/$defs/sgaId"
    },
    "manifest": {
      "$ref": "common.schema.json#/$defs/digest"
    },
    "bundle_version": {
      "$ref": "common.schema.json#/$defs/bundleVersion"
    },
    "reason": {
      "type": "string",
      "enum": [
        "hole_complete",
        "begin_round",
        "interim_verify",
        "final_score"
      ],
      "description": "Why this card was issued. hole_complete is the unsolicited checkpoint after every player finishes a hole. The other three are answers to a REQUEST_SCORECARD directive: begin_round is the card before a ball is struck and carries the layout with no results; interim_verify is a reconciliation taken mid-round; final_score is the last word on the round. A consumer that treats a begin_round card as a score would read an unplayed round as a round of zeros, which is why the reason is required rather than inferred from whether results are present."
    },
    "hole_completed": {
      "type": "integer",
      "minimum": 1,
      "maximum": 36,
      "description": "The hole just finished by every player in the round. Checkpoints are emitted in order and MUST NOT skip a played hole. Absent on a begin_round card, where no hole has been finished."
    },
    "issued_at": {
      "$ref": "common.schema.json#/$defs/timestamp"
    },
    "t_ms": {
      "$ref": "common.schema.json#/$defs/monotonicMs"
    },
    "checkpoint_index": {
      "type": "integer",
      "minimum": 1,
      "description": "Monotonic within the round. Gaps are detectable and are themselves evidence."
    },
    "previous_checkpoint_hash": {
      "$ref": "common.schema.json#/$defs/digest",
      "description": "Chains checkpoints, so one cannot be removed or reordered after the fact without breaking the chain."
    },
    "course": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "course_id": {
          "$ref": "common.schema.json#/$defs/sgaId"
        },
        "name": {
          "type": "string",
          "description": "Course name as the simulator presents it. For display; course identity for matching purposes rests on the digest, not on this string."
        },
        "version": {
          "type": "string",
          "description": "Course file version, because a vendor can revise a layout under an unchanged name."
        },
        "digest": {
          "$ref": "common.schema.json#/$defs/digest",
          "description": "Digest of the course document this card is to be read against. Pins the card to an exact course: a tee re-measured after the round produces a different digest rather than silently restating what this score was played over."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "description": "Which course this round was played on -- a REFERENCE, not a copy of it. What the course IS (its holes, their par and stroke index, and what each measures from every set of markers) is a property of the course and not of any round, so it travels once in its own document (06 section 9.5) and is joined to here by course_id and digest. A card that restated it would repeat the same table on every checkpoint and give a consumer two places to disagree about what par is.",
      "required": [
        "course_id",
        "digest"
      ]
    },
    "format": {
      "$ref": "common.schema.json#/$defs/scoringFormat",
      "default": "stroke"
    },
    "slots": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": [
          "slot",
          "holes"
        ],
        "additionalProperties": false,
        "properties": {
          "slot": {
            "$ref": "common.schema.json#/$defs/slot"
          },
          "display_label": {
            "$ref": "common.schema.json#/$defs/displayLabel"
          },
          "tee_set": {
            "type": "string",
            "description": "Hole number (as a string key) -> that hole's result.",
            "patternProperties": {
              "^(?:[1-9]|[12][0-9]|3[0-6])$": {
                "$ref": "#/$defs/holeResult"
              }
            },
            "additionalProperties": false
          },
          "holes": {
            "type": "object",
            "description": "Hole number (as a string key) -> that hole's result.",
            "patternProperties": {
              "^(?:[1-9]|[12][0-9]|3[0-6])$": {
                "$ref": "#/$defs/holeResult"
              }
            },
            "additionalProperties": false
          },
          "gross": {
            "type": "integer",
            "minimum": 0,
            "description": "Total strokes counted across every hole on this card."
          },
          "to_par": {
            "type": "integer",
            "description": "Gross relative to the par of the holes actually completed, not of the full course. A partial round is a first-class record here, so this figure must not silently assume eighteen holes."
          },
          "strokes_received": {
            "type": "integer",
            "minimum": 0,
            "maximum": 54,
            "description": "Strokes this slot actually received on this card. Present whenever an allowance was applied, whatever its source. A match result without the strokes that produced it cannot be checked by anybody -- recording the allowance is what makes a player rating format auditable rather than merely reported."
          },
          "stroke_holes": {
            "type": "array",
            "maxItems": 54,
            "items": {
              "type": "integer",
              "minimum": 1,
              "maximum": 36
            },
            "description": "Which holes the strokes fell on, as applied. Recorded rather than left to be re-derived: allocation depends on the stroke index the simulator held at the time, and a course revision can change it."
          },
          "allowance_source": {
            "$ref": "common.schema.json#/$defs/allowanceSource"
          },
          "net": {
            "type": "integer",
            "minimum": 0,
            "description": "Gross less the strokes received on the holes actually completed. Present only when an allowance was applied. Like to_par it must not assume eighteen holes -- a partial round is a first-class record here."
          },
          "stableford_points": {
            "type": "integer",
            "description": "Points under the stableford format. Present only when format is stableford."
          },
          "holes_completed": {
            "type": "integer",
            "minimum": 0,
            "description": "How many holes on this card are complete. Posting eligibility is a query run against this and the per-hole status, never a filter applied at ingest -- the record is kept whole and the policy decides later."
          }
        },
        "patternProperties": {
          "^x-[a-z0-9_-]+\\.": {}
        },
        "dependentRequired": {
          "net": [
            "gross",
            "strokes_received"
          ],
          "strokes_received": [
            "gross"
          ],
          "stroke_holes": [
            "strokes_received"
          ],
          "allowance_source": [
            "strokes_received"
          ]
        }
      },
      "description": "One entry per playing position. A slot is a position in the round, not a person -- see the slot definition in common. A slot with nobody claimed against it still records a full card."
    },
    "signature": {
      "$ref": "common.schema.json#/$defs/signature"
    },
    "x": {
      "$ref": "common.schema.json#/$defs/vendorExtensions"
    }
  },
  "patternProperties": {
    "^x-[a-z0-9_-]+\\.": {}
  },
  "$defs": {
    "holeResult": {
      "type": "object",
      "required": [],
      "additionalProperties": false,
      "properties": {
        "strokes": {
          "type": "integer",
          "minimum": 0,
          "description": "Strokes counted toward the score for this hole. MUST equal the number of shot records for this slot and hole with outcome.counted = true. Divergence is the whole point of the checkpoint. Absent until the hole has been played -- on a begin_round card no hole has one, and a zero would be a score rather than a silence."
        },
        "length_m": {
          "type": "number",
          "exclusiveMinimum": 0,
          "description": "What this hole ACTUALLY played for this player, in meters, when it differed from the card yardage -- a temporary tee, a pin moved to the front. Omitted when it played as the card says, which is the ordinary case: the tee's yardage is in the course document, joined by this slot's tee_set."
        },
        "putts": {
          "type": "integer",
          "minimum": 0,
          "description": "Putts taken on this hole."
        },
        "penalties": {
          "type": "integer",
          "minimum": 0,
          "description": "Penalty strokes added on this hole. Already included in strokes; carried separately so a consumer can tell a bad hole from an unlucky one."
        },
        "gimmes": {
          "type": "integer",
          "minimum": 0,
          "description": "For display. Not load-bearing for reconciliation: a gimme emits its own record."
        },
        "mulligans": {
          "type": "integer",
          "minimum": 0,
          "description": "For display. Mulligans emit records but do not count."
        },
        "shot_records": {
          "type": "integer",
          "minimum": 0,
          "description": "Total records emitted for this slot and hole, counted and uncounted. Lets a consumer detect a missing shot even when the score is arithmetically fine."
        },
        "counted_strokes": {
          "type": "integer",
          "minimum": 0,
          "description": "Sum of outcome.strokes_counted over records with counted = true. MUST equal `strokes`. Because every stroke that changes a score emits a record -- gimmes, drops and penalties included -- reconciliation is a plain equality with no exemptions to argue about."
        },
        "completeness": {
          "enum": [
            "complete",
            "partial",
            "conceded",
            "not_played",
            "quarantined"
          ],
          "description": "How the hole ended. Partial rounds are first-class records: a round abandoned on the twelfth green keeps eleven usable holes rather than becoming unusable in full. Posting eligibility is a query, not an ingest filter."
        },
        "status": {
          "enum": [
            "ok",
            "reconciled",
            "divergent",
            "unverified"
          ],
          "default": "ok",
          "description": "Whether this hole's card agrees with the shot stream. It is a reconciliation verdict, NOT how the hole ended -- a conceded or abandoned hole is recorded in `completeness`. The two were described identically here once, and a simulator duly wrote 'conceded' into this field, where it is not a value."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "description": "One hole for one player. Carries the layout as that player faced it and, once they have played it, the result. On a begin_round card only the layout half is present."
    }
  },
  "allOf": [
    {
      "if": {
        "properties": {
          "reason": {
            "const": "begin_round"
          }
        },
        "required": [
          "reason"
        ]
      },
      "then": {
        "description": "A card issued before the round has nothing to report yet.",
        "properties": {
          "hole_completed": false
        }
      },
      "else": {
        "required": [
          "hole_completed"
        ]
      }
    }
  ]
}
