{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://golfsga.com/sga-ois/schema/0.1/common.schema.json",
  "title": "SGA-OIS common definitions",
  "description": "Shared vocabulary and primitive types. Normative. Units and sign conventions are fixed by spec/00-conventions.md and are not carried in payloads.",
  "$defs": {
    "allowanceSource": {
      "type": "string",
      "enum": [
        "sga",
        "event",
        "operator",
        "local"
      ],
      "description": "Where a stroke allowance came from. sga: computed by the association from the member's rating. event: fixed by the Event Rules Package, which overrides everything else for a sanctioned round. operator: a human typed it at the bay. local: the simulator worked it out from figures it stores itself. These are different strengths of evidence and a consumer MUST be able to tell them apart -- a match decided by an operator-entered allowance is not the same claim as one decided by a rated field."
    },
    "attestationTier": {
      "enum": [
        "A0",
        "A1",
        "A2",
        "A3"
      ],
      "description": "A0 open: no agreement. A1: the Technology Partner Agreement and a published digest SGA verifies; nothing is signed. A2: also signs what it sends, with a key in firmware or a protected executable. A3: signs with a key in hardware nobody can extract. A result takes the lowest tier in its chain (02 section 2.3). A device MUST NOT claim a tier above what it demonstrates."
    },
    "bayCheckedIn": {
      "type": "array",
      "maxItems": 32,
      "items": {
        "$ref": "#/$defs/checkinEntry"
      },
      "description": "Members present at this bay who have checked in, requestable as the field bay.checked_in. The mirror of round.roster: that runs simulator to phone and names are typed at round setup, this runs engine to simulator and the names come from the members' own accounts before a round exists. A simulator MAY pre-fill player configuration from it and MUST let the operator edit or remove any pre-filled player -- a scan proves a phone was at the bay, not that its owner is in this round. Entries expire and the engine drops them; see 15 section 2."
    },
    "bodySegment": {
      "enum": [
        "pelvis",
        "thorax",
        "head",
        "lead_shoulder",
        "trail_shoulder",
        "lead_arm",
        "trail_arm",
        "lead_wrist",
        "trail_wrist",
        "lead_knee",
        "trail_knee",
        "lead_foot",
        "trail_foot",
        "hands",
        "club"
      ],
      "description": "Lead and trail rather than left and right, so the vocabulary is handedness-independent. A left-handed golfer's lead side is their right."
    },
    "bundleVersion": {
      "type": "string",
      "pattern": "^[0-9]{4}\\.[0-9]{2}\\.[0-9]+$",
      "examples": [
        "2026.09.1"
      ],
      "description": "Version of the SGA Bundle in force. Bundles swap only at round boundaries, so this is stable for the whole life of a round."
    },
    "certificationTier": {
      "type": "object",
      "required": [
        "tier",
        "rank"
      ],
      "additionalProperties": false,
      "description": "One named certification tier, carried in the BUNDLE rather than in a bay's certificate. Same shape as an event's device_requirements, given a name -- so a bay certified for a tier is by construction eligible for events at that tier, with no second rulebook to keep in step.",
      "properties": {
        "tier": {
          "type": "string",
          "maxLength": 32,
          "description": "The tier name being defined, as SGA publishes it."
        },
        "name": {
          "type": "string",
          "maxLength": 64,
          "description": "The name members and operators see, as SGA publishes it: \"SGA Gold Certified\". tier is the identifier software matches on and never changes; the name is for people."
        },
        "rank": {
          "type": "integer",
          "description": "Orders the tiers, so an event requiring 'at or above Diamond' means something. DECLARED, never derived: tier names are marketing words and nothing about them says which outranks which, so an engine that inferred an order would be wrong on the first tier SGA adds. An engine MUST refuse a required tier it cannot resolve rather than assume it is satisfied."
        },
        "lapse": {
          "$ref": "#/$defs/lapsePolicy",
          "description": "Overrides the bundle-wide grace and reminder schedule for this tier -- a residential bay and a championship venue do not obviously deserve the same clock. Unset fields inherit from the bundle policy and then from the contract baseline, so a tier wanting only a tighter calibration window does not silently lose the reminder schedule. The extensions map is ignored here; extensions are bundle-wide."
        },
        "min_attestation_tier": {
          "$ref": "#/$defs/attestationTier",
          "description": "The floor for any device that resolves a required field. Expressed as an attestation tier rather than a list of demanded evidence ON PURPOSE: below A2 a device can claim any serial, firmware version or digest it likes, so demanding those of an unattested device buys a populated log field and no assurance. What makes a serial mean something is the signature over it. An unrecognized value on a device ranks below A0, so inventing a tier name clears nothing."
        },
        "required_fields": {
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 64
          },
          "description": "Which fields the tier needs, and therefore which fields the attestation floor applies to -- it applies to THESE AND NOTHING ELSE. These also define what counts as a critical device: a device is critical if it is the resolved source for one of these, not by role and not by a maintained list of device types. A wind machine at A0 is irrelevant because no required field resolves to it. A certification NEVER refuses a device admission: a device below the floor connects, cannot win these fields (which is the whole protection, since a field it cannot win is a field it cannot influence), and still wins everything else it offers. Most devices below the floor are not competing for these fields at all -- a pressure mat or a swing camera reports things nothing else in the room can see."
        },
        "certified_models_only": {
          "type": "boolean",
          "description": "When true, only device models on SGA's certified list may contribute to a round at this tier. Uncertified equipment may still be present and still record -- it just cannot be the source of record."
        },
        "effective_from": {
          "$ref": "#/$defs/timestamp",
          "description": "When this definition of the tier takes effect (14 section 1). A bundle may carry the current definition and the next one for the same tier; an engine uses the one with the latest effective_from not after the time it evaluates. Absent means in effect now."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "checkinCode": {
      "type": "object",
      "required": [
        "payload",
        "rotates"
      ],
      "additionalProperties": false,
      "description": "The check-in code for this bay, so a device with a screen can render it as a QR code instead of sending somebody to the placard on the wall. Requestable as the field bay.checkin_code. MUST be granted through the connection matrix and MUST NOT appear in HELLO: HELLO precedes DESCRIBE, so it reaches peers that have not proved which bay they are in, and this value is what proves physical presence for ranked play. See 15 section 4.",
      "properties": {
        "payload": {
          "type": "string",
          "maxLength": 512,
          "description": "Exactly what to encode. Rendered verbatim -- a device MUST NOT reformat, shorten or append to it."
        },
        "short_code": {
          "type": "string",
          "maxLength": 16,
          "description": "A human-readable equivalent, for somebody whose camera will not focus or who is reading a screen across the room. Optional."
        },
        "expires_at": {
          "$ref": "#/$defs/timestamp"
        },
        "rotates": {
          "type": "boolean",
          "description": "True when this value actually changes over time. A rotating code makes a scan evidence of presence in a window rather than merely at this bay, which a printed placard can never be. An engine MUST NOT report true for a static value: the difference is what a competition ruleset weighs."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "checkinEntry": {
      "type": "object",
      "required": [
        "checkin_ref",
        "expires_at"
      ],
      "additionalProperties": false,
      "description": "One member who has scanned in at this bay and not yet left. Produced by the engine before any round exists, so a simulator reaching player configuration has a list to offer.",
      "properties": {
        "checkin_ref": {
          "$ref": "#/$defs/checkinRef"
        },
        "display_label": {
          "$ref": "#/$defs/displayLabel"
        },
        "checked_in_at": {
          "$ref": "#/$defs/timestamp"
        },
        "expires_at": {
          "$ref": "#/$defs/timestamp"
        },
        "slot": {
          "type": "integer",
          "minimum": 1,
          "maximum": 36,
          "description": "The position this member chose, where they chose one. Present only when they picked from the names the bay had published -- a member who scanned before any names were typed is present with no position, and the bay seats them."
        },
        "claimed_label": {
          "type": "string",
          "maxLength": 64,
          "description": "The label that was ON that position when the member chose it. Carried so the bay can tell a claim that still means what it meant from one that does not: a simulator is free to reorder its roster before the round starts, and a slot number chosen thirty seconds ago may since have become somebody else. Where this and the bay's current label disagree, the bay MUST NOT bind on the number alone -- see 15 section 5."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "checkinRef": {
      "type": "string",
      "minLength": 8,
      "maxLength": 64,
      "pattern": "^[A-Za-z0-9_-]+$",
      "description": "An opaque, engine-scoped reference to one check-in. NOT a member identifier and not derived from one: it means 'the person who scanned in at this bay at this time', and only SGA can resolve it. The bay and the simulator MUST NOT treat it as an account or attempt to correlate it across bays or sessions. It is what a slot binds to when a checked-in player is assigned to one, which is how a slot claim happens without anybody typing a name."
    },
    "clubIdMethod": {
      "enum": [
        "imaged",
        "marker",
        "rfid_nfc",
        "holder_absence",
        "inferred_from_impact",
        "ui_selected",
        "bag_sequence",
        "unknown"
      ],
      "description": "HOW the club in a player's hands was identified. A separate axis from field provenance, because the club is a categorical identity claim rather than a measured quantity. imaged: the club head was visually recognized. marker: a sticker, label or dot was read. rfid_nfc: the club announced itself. inferred_from_impact: derived from loft, speed and impact -- a guess with physics behind it. ui_selected: a human told the simulator, which is a claim and not evidence. holder_absence: the club is inferred from what is MISSING from a known set -- a sensed rack with an empty slot, a bag with an RFID reader that can see every club still in it, any holder that knows its own inventory. Strong evidence of what was drawn, though not of what was swung. bag_sequence: assumed from order of play. Everything through holder_absence is evidence; ui_selected and bag_sequence are claims."
    },
    "denialCode": {
      "enum": [
        "firmware_below_minimum",
        "firmware_build_denied",
        "model_denied",
        "vendor_denied",
        "unit_denied",
        "serial_required",
        "role_occupied",
        "describe_required",
        "not_your_field",
        "descriptor_too_large",
        "device_id_in_use",
        "bay_full",
        "superseded",
        "associated_elsewhere",
        "ownership_unknown",
        "ownership_disputed"
      ],
      "description": "What was refused, as a registered value rather than prose. unit_denied is a bad production batch -- a hardware fault, or units shipped with a key that leaked -- which firmware matching cannot express. serial_required means a batch of this model is denied and this unit will not say which one it is; it may well be a good unit, and the code says so.\n\nrole_occupied is the only code here that is not about the device itself. Some roles admit exactly one participant -- two simulators driving one bay produce a record composed from two different games -- so the second is refused and told who holds the role, by name. A refusal that does not name the incumbent sends a vendor hunting for a bug that is not theirs.\n\nThe last three are not refusals of the DEVICE at all, and a vendor should not treat them as faults to fix in firmware. associated_elsewhere means the device belongs to another bay at this facility and carries a redirect saying which; the same connection one engine along would have succeeded. ownership_unknown means no engine here has an association for it and this is not the only engine at the site, so adopting it might capture the device a neighboring bay was waiting for -- a person has to bind it. ownership_disputed means two engines claim it and the claims cannot be ordered; it is deliberately not resolved, because picking one would choose by something irrelevant, like which engine had been busier, and the loser's operator would watch a device leave their bay with nothing to point at."
    },
    "denialRemedy": {
      "enum": [
        "update_firmware",
        "change_firmware",
        "report_serial",
        "contact_vendor",
        "close_other_software",
        "describe_first",
        "declare_field",
        "reduce_descriptor",
        "change_device_id",
        "retry_later",
        "reconnect",
        "connect_to_bay",
        "wait_for_association"
      ],
      "description": "What would fix it. update_firmware moves forward to required_firmware; change_firmware moves OFF the named builds to any other supported one, which may mean backwards; report_serial identifies the unit and retries; contact_vendor is the honest answer where nothing the device can do unattended will help, and saying it stops a device retrying into the void.\n\nconnect_to_bay and wait_for_association are the two answers to which engine a fixed device belongs to: follow the redirect, or idle and keep advertising until somebody binds the device to a bay. Neither is contact_vendor, which would send an operator to the wrong company."
    },
    "deviceDenyRule": {
      "type": "object",
      "required": [
        "vendor"
      ],
      "additionalProperties": false,
      "description": "One entry in the bundle's admission list: a device this build of firmware may not join a bay at all. Reserved for a flaw somebody ELSE can exploit -- tampering with another player's data mid-match, or improving their own. A device that merely misbehaves on its own is a matter for its owner; a vulnerable one is a hazard to everyone in the room, which is what makes refusing it proportionate.",
      "properties": {
        "vendor": {
          "type": "string",
          "maxLength": 64,
          "description": "Manufacturer to match."
        },
        "model": {
          "$ref": "#/$defs/modelName",
          "description": "Absent matches every model from that vendor."
        },
        "firmware_below": {
          "type": "string",
          "maxLength": 32,
          "description": "Denies builds older than this, and a device that reaches it is admitted again the moment it does -- no republished bundle, nobody's attention. A device that will not state its firmware counts as older."
        },
        "firmware_in": {
          "type": "array",
          "maxItems": 32,
          "items": {
            "type": "string",
            "maxLength": 32
          },
          "description": "Names EXACT builds. Some faults are one bad release with good builds either side, and a range would condemn versions that were never affected. May be set alongside firmware_below, in which case the rule matches if either does. Unlike a range, a device that will not state its firmware does NOT match a list -- silence is not a claim to be one of the named builds."
        },
        "serials": {
          "type": "array",
          "maxItems": 256,
          "items": {
            "type": "string",
            "maxLength": 64
          },
          "description": "Exact units. For one device known to be compromised."
        },
        "serial_prefix": {
          "type": "array",
          "maxItems": 32,
          "items": {
            "type": "string",
            "maxLength": 64
          },
          "description": "Denies a batch by lot code, which is how production runs are usually numbered. The case firmware matching cannot express: a hardware fault, or units shipped with a signing key that leaked. No update fixes those, and denying the whole model punishes everyone who bought a good one."
        },
        "serial_range": {
          "type": "object",
          "required": [
            "from",
            "to"
          ],
          "additionalProperties": false,
          "description": "A contiguous batch, compared LEXICOGRAPHICALLY -- not numerically, because serial grammars are the vendor's own and parsing one would be guessing. Right for the zero-padded, fixed-width serials batches actually use. Both ends MUST be the same length; an engine MUST reject a bundle whose range ends differ in length rather than admit a device the rule meant to refuse.",
          "properties": {
            "from": {
              "type": "string",
              "maxLength": 64,
              "description": "Inclusive lower bound of the serial range, compared as a string. Ranges exist for recalls that landed on a manufacturing batch rather than on a firmware build."
            },
            "to": {
              "type": "string",
              "maxLength": 64,
              "description": "Inclusive upper bound of the serial range, compared as a string."
            }
          },
          "patternProperties": {
            "^x-[a-z0-9_-]+\\.": {}
          }
        },
        "device_ids": {
          "type": "array",
          "maxItems": 256,
          "items": {
            "$ref": "#/$defs/sgaId"
          },
          "description": "Named units, for one known to be compromised."
        },
        "require_serial": {
          "type": "boolean",
          "description": "Denies a device inside this rule's vendor and model scope that will not say which unit it is. Only valid on a rule that already carries a serial constraint -- without one it would deny every quiet unit of the vendor, which is a model-wide denial written by accident, in a signed artifact shipped to every bay. It is per-rule rather than global so it never becomes a blanket demand for serials."
        },
        "reason": {
          "type": "string",
          "maxLength": 200,
          "description": "Human fallback, carried to the device as denial.detail for a log or an operator screen. It is NOT the actionable part: what a device acts on is the coded code/remedy pair in denial.schema.json. A device that cannot tell why it was refused will retry forever; one that can may update itself unattended and be admitted on the next attempt with nobody involved."
        },
        "advisory": {
          "type": "string",
          "maxLength": 32,
          "description": "The published advisory, e.g. SGA-2027-004."
        },
        "since": {
          "$ref": "#/$defs/timestamp"
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "deviceMs": {
      "type": "integer",
      "description": "A device's own monotonic reading, optional and advisory. The engine measures its offset at handshake and records it in clock_skew_ms. Its purpose is buffered data: a device replaying backlog after a reconnect would otherwise have everything stamped with arrival time rather than observation time."
    },
    "devicePolicy": {
      "type": "object",
      "additionalProperties": false,
      "description": "The bundle's device admission list. Signed, so it cannot be forged or edited away at a bay, and already distributed everywhere -- including through the offending vendor's own installer.",
      "properties": {
        "deny": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/deviceDenyRule"
          },
          "description": "Rules that refuse admission outright. A device matching any rule is denied and told which condition it matched, in coded form, so it can remediate without a support call."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "digest": {
      "type": "string",
      "pattern": "^sha256:[0-9a-f]{64}$",
      "description": "A content hash, lowercase hex, prefixed with the algorithm that produced it. Used wherever two parties must prove they are talking about the same bytes -- a bundle, a course, a firmware image, a series held on the bulk path."
    },
    "discoveryPath": {
      "type": "string",
      "enum": [
        "mdns",
        "unicast_dnssd",
        "ble",
        "dhcp",
        "public_txt",
        "cached",
        "configured",
        "loopback",
        "redirect"
      ],
      "description": "How a device found the engine it is talking to (03 section 5). mdns: multicast DNS-SD on the local network, which should normally win. unicast_dnssd: the same records published in the venue's own DNS and browsed by unicast DNS-SD (RFC 6763). cached: a last-known-good address. configured: an address typed by somebody or shipped in a file. cached and configured are NOT evidence that discovery works. loopback: the engine and the device are one machine. redirect: sent here by another engine (03 section 2.0f). RESERVED, not to be reported until specified: dhcp, ble, public_txt."
    },
    "displayLabel": {
      "type": "string",
      "maxLength": 64,
      "description": "The screen name a player is playing under, chosen at round setup or picked from their phone at check-in. A LABEL, never an identity: players type 'Dave', 'DL', 'guest' and they collide constantly, so it MUST NOT be treated as a member identifier -- the slot is what binds to an account, in the cloud. It is nonetheless the most identifying element that leaves the bay: a typed name plus a facility, bay and timestamp identifies a person against a booking record. Stripped when the claim window closes."
    },
    "downgradeReason": {
      "enum": [
        "no_spin_lock",
        "occluded",
        "out_of_range",
        "low_light",
        "no_ball_marker",
        "sensor_fault",
        "calibration_stale",
        "thermal_limit",
        "degraded_link",
        "other"
      ],
      "description": "Why a device produced a value of lower quality than it normally can, so the drop is explained rather than merely observed. Recorded because a run of no_spin_lock or low_light is a bay maintenance problem, while calibration_stale is an operator problem, and the two need different answers."
    },
    "eventSurface": {
      "enum": [
        "tee",
        "fairway",
        "first_cut",
        "rough",
        "deep_rough",
        "fescue",
        "sand_bunker",
        "sand_waste",
        "hardpan",
        "pine_straw",
        "divot",
        "cart_path",
        "rock",
        "water",
        "penalty_area",
        "fringe",
        "green",
        "foliage",
        "tree_trunk",
        "netting",
        "wall",
        "fence",
        "post",
        "structure",
        "flagstick",
        "cup",
        "equipment",
        "water_surface",
        "player",
        "unknown"
      ],
      "description": "What the ball met at one trajectory event. A SUPERSET of lieSurface, not the same vocabulary: a ball comes to rest on a lie, but in flight it hits things a ball can never lie on -- foliage, netting, a flagstick, a person. Reusing lieSurface here made the standard's own worked example non-conformant, because a ball deflected by a tree struck foliage and foliage is not a lie. Every lieSurface value is included, so a landing, bounce or roll uses exactly the term it would use anywhere else; the additions are only ever things struck. Pair it with `object` for the specific noun: surface is the coded material, object is the free-text thing."
    },
    "fetchPolicy": {
      "type": "object",
      "additionalProperties": false,
      "description": "Limits on firmware the engine fetches on a device's behalf. Destination is controlled by ADDRESS, not by a host list: an SGA-maintained allowlist of vendor update hosts is permanently stale across hundreds of partners with moving CDN endpoints, and a stale allowlist breaks legitimate updates until somebody disables it. See 13 section 3.1 for the address rules, which need no maintenance.",
      "properties": {
        "max_file_bytes": {
          "type": "integer",
          "minimum": 0,
          "description": "One image. Default 15728640 (15 MB), generous for the class this exists for -- an nRF52 image is 100-500 KB and an ESP32 OTA partition 1-2 MB."
        },
        "per_device_bytes": {
          "type": "integer",
          "minimum": 0,
          "description": "Rolling 24-hour budget per device. Default 62914560 (60 MB). Bytes rather than a file count: a device several versions behind may legitimately need a sequence of images plus a bootloader, and a file count fails it for doing the right thing while letting another device pull five 15 MB files when it needed five 200 KB ones. Bytes TRANSFERRED are charged, including a download that fails partway -- otherwise a device aborts near the end forever and consumes unlimited bandwidth while never completing anything."
        },
        "per_bay_bytes": {
          "type": "integer",
          "minimum": 0,
          "description": "Hard ceiling across every device in the bay, IN ADDITION to per_device_bytes rather than instead of it. Default 188743680 (180 MB). Per-device limits multiply: ten devices at 60 MB each is 600 MB off a facility's connection in a day, which an operator on a metered link would notice and rightly blame us for."
        },
        "min_interval_ms": {
          "type": "integer",
          "minimum": 0,
          "description": "Shortest gap between one device's requests. Default 10000. A byte budget does not stop ten thousand 1 KB requests, or polling an endpoint that answers 304 and costs no budget while costing a TLS handshake every time, on a machine that is also running a simulator and must not stutter during a shot. Invisible to a real updater, which must flash each image before asking for the next."
        },
        "vendor_file_bytes": {
          "type": "object",
          "additionalProperties": {
            "type": "integer",
            "minimum": 0
          },
          "description": "Raises max_file_bytes for named vendors, for products that are genuinely bigger -- a Linux-based system shipping an on-device model rather than an MCU image."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "fieldCondition": {
      "enum": [
        "requires_ball_marker",
        "requires_club_marker",
        "requires_clean_ball",
        "indoor_only",
        "outdoor_only",
        "requires_full_flight",
        "requires_min_ceiling",
        "requires_lighting",
        "requires_level_surface",
        "requires_fixed_tee_position",
        "requires_calibration_current",
        "requires_correct_replacement",
        "temperature_range_only",
        "full_swing_only",
        "putting_only",
        "right_handed_only",
        "left_handed_only"
      ],
      "description": "What must hold for a field to be valid at its declared provenance. requires_correct_replacement is the one for position-based holder_absence: a slotted rack is only right if clubs go back where they came from, while a tag-reading bag does not care. A REGISTERED vocabulary, not free text, because the spec makes a normative claim about it: a device operating outside its declared conditions is operating outside its certification. Certification cannot check a condition only one vendor understands. Vendor-specific conditions extend under an x- namespace and MUST also declare the nearest core value."
    },
    "fieldTiming": {
      "enum": [
        "shot_synchronous",
        "standing"
      ],
      "description": "shot_synchronous: produced by the act of the shot itself -- ball speed, club path. standing: a state the device asserts and maintains BETWEEN shots -- which club was drawn, what angle the platform reached, whose turn it is. The two need different handling: a standing assertion can be stale, and can be withdrawn."
    },
    "firmness": {
      "enum": [
        "soft",
        "medium_soft",
        "medium",
        "medium_firm",
        "firm",
        "unknown"
      ],
      "description": "Ground firmness where the ball lies. Coarse on purpose: no simulator measures this, and a five-step scale is the most any of them can express honestly. unknown is a legitimate answer and is preferred to a confident guess."
    },
    "goingAwayReason": {
      "enum": [
        "shutdown",
        "restart",
        "update",
        "reconfigure",
        "superseded"
      ],
      "description": "Why the engine is closing the connection on purpose.\n\nThe distinction that matters is planned versus crashed. A socket that simply closes is indistinguishable from an engine that died, so a participant applies its normal backoff -- capped at five seconds, which is right for a crash and wrong for a thirty-second update.\n\nsuperseded means the participant has lost a role to another, and is the one value that normally carries no reconnect interval: there is nothing to come back to until something changes at the bay."
    },
    "handedness": {
      "enum": [
        "right",
        "left",
        "unknown"
      ],
      "description": "Which side the player swings from. DESCRIPTIVE ONLY -- no implementation may flip a sign based on it (00 section 2). It is carried as TWO fields, never one: player.sim_handedness is what the software is configured for and player.lm_handedness is what a sensor in the room observed -- a launch monitor, or a camera. A simulator set for a right-hander while a left-hander plays mirrors every number after it, and that is only visible if both answers survive (15 section 8)."
    },
    "integrityStatus": {
      "enum": [
        "verified",
        "verified_with_exception",
        "pending_review",
        "unverified"
      ],
      "description": "unverified is a status, not a rejection: the round is still recorded and still visible to the player; it does not feed sanctioned ranking."
    },
    "lapsePolicy": {
      "type": "object",
      "additionalProperties": false,
      "description": "How a certification or a calibration is allowed to run out. Carried in the BUNDLE rather than in a certificate, because grace is policy: in the certificate it could never be changed without reissuing every certificate in the field. An engine with no policy uses 30 days, so an old bundle still behaves sensibly rather than granting no grace at all.",
      "properties": {
        "grace_days": {
          "type": "integer",
          "minimum": 0,
          "maximum": 365,
          "description": "How long a certification keeps its force after expiry. Default 30, which is a TERM OF THE CERTIFICATION AGREEMENT rather than a tuning knob: a tier may lengthen it, but shortening it below the contracted figure is a change to what facilities were promised when they signed and belongs in an agreement rather than a bundle release. During grace the tier is still enforced and the operator is warned; past it the bay drops to uncertified and keeps playing -- though it may no longer host an event."
        },
        "calibration_grace_days": {
          "type": "integer",
          "minimum": 0,
          "maximum": 365,
          "description": "The same for a device's calibration. Absent means use grace_days. A calibration expiry is a date on a certificate, not the moment a sensor becomes wrong -- a monitor does not start lying at midnight -- and treating it as a cliff decertifies a bay over a technician's scheduling rather than over its equipment."
        },
        "warn_before_days": {
          "type": "array",
          "items": {
            "type": "integer",
            "minimum": 0
          },
          "description": "Days-remaining marks that each raise ONE notice, with rising urgency as the number falls: background beyond 30 days, attention from 30 to 3, interrupt at 2 or fewer. Default [90, 60, 30, 10, 5, 3, 2, 1]. A ninety-day warning that interrupts somebody is a warning they will turn off before it matters."
        },
        "warn_after_days": {
          "type": "array",
          "items": {
            "type": "integer",
            "minimum": 0
          },
          "description": "Days PAST expiry, inside grace, that raise further reminders. Default [10, 20, 25]. A bay in grace is still working perfectly, which is exactly why its operator can keep ignoring the email -- so the channel keeps going rather than falling silent at the moment it starts mattering most."
        },
        "extensions": {
          "type": "object",
          "description": "Extra grace for named bays, keyed by bay_id. In the bundle because that is how SGA reaches a bay it may have no other route to, including one offline for weeks. A facility CANNOT award itself an extension, which is the property that makes grace a courtesy rather than a loophole. A bundle published after an extension's not_after MUST NOT carry it -- an engine ignores an expired one anyway, so removal is about the list staying readable by whoever reviews which bays are running on an indulgence. Extensions are always bundle-wide and are never overridden by a tier: an extension names one bay, and which tier it holds is not what the exception is about.",
          "additionalProperties": {
            "type": "object",
            "required": [
              "grace_days"
            ],
            "additionalProperties": false,
            "properties": {
              "grace_days": {
                "type": "integer",
                "minimum": 0,
                "maximum": 730,
                "description": "Extra days granted beyond the standard grace period before the certificate is treated as lapsed."
              },
              "reason": {
                "type": "string",
                "maxLength": 200,
                "description": "Why the extension was granted, recorded so a reviewer can tell a considered decision from an automatic one."
              },
              "not_after": {
                "$ref": "#/$defs/timestamp",
                "description": "When the EXTENSION itself stops applying. The reason to date it is institutional rather than technical: an open-ended exception granted once and never revisited is how temporary becomes permanent. A dated one has to be renewed by somebody who looks at the situation again."
              }
            },
            "patternProperties": {
              "^x-[a-z0-9_-]+\\.": {}
            }
          }
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "lieSurface": {
      "enum": [
        "tee",
        "fairway",
        "first_cut",
        "rough",
        "deep_rough",
        "fescue",
        "sand_bunker",
        "sand_waste",
        "hardpan",
        "pine_straw",
        "divot",
        "cart_path",
        "rock",
        "water",
        "penalty_area",
        "fringe",
        "green",
        "unknown"
      ],
      "description": "Vendor-specific surfaces extend under an x- namespace and MUST map to the nearest core value."
    },
    "modelName": {
      "type": "string",
      "maxLength": 64,
      "description": "Model name of a device, exactly as it identifies itself at handshake. Together with `vendor` this is what deny rules, bay certificates and certified-model lists match on, and matching is exact."
    },
    "modelRef": {
      "type": "object",
      "required": [
        "id",
        "version"
      ],
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^[a-z0-9_-]+\\.[a-z0-9_.-]+$",
          "description": "Vendor-namespaced. The model need not be published, but MUST be named and versioned so that a change in results can be attributed to a model change rather than to a player."
        },
        "version": {
          "type": "string",
          "description": "Version of the referenced model. Present so a stored result can be recomputed later without guessing which revision was in force."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "additionalProperties": false,
      "description": "A reference to a named, versioned model -- a flight model, a course, a scoring model -- recorded so a result can be reproduced later against the same model that produced it."
    },
    "monotonicMs": {
      "type": "integer",
      "minimum": 0,
      "maximum": 86400000,
      "description": "Milliseconds within the current session EPOCH, from a clock that cannot run backwards. ASSIGNED BY THE ENGINE and by no one else -- device clocks drift, restart and disagree. At 24 hours the epoch increments and this resets to zero; the session, the round and every connection continue untouched. Order within a session by the tuple (session_epoch, t_ms), never by wall clock. Nothing in this standard ends a round because a counter got large."
    },
    "noticeAudience": {
      "enum": [
        "round",
        "slot",
        "operator"
      ],
      "description": "round: everyone playing. slot: one player, addressed BY SLOT and never by name -- the app resolves it and the bay stays ignorant of who anyone is. operator: staff, for something a player cannot fix."
    },
    "noticeBlockRule": {
      "type": "object",
      "required": [
        "vendor",
        "action"
      ],
      "additionalProperties": false,
      "description": "One entry in the bundle's notification blocklist. Misuse of the notice channel gets a proportionate answer: block the messages rather than revoke the certification, and lift it when the firmware is fixed.",
      "properties": {
        "vendor": {
          "type": "string",
          "maxLength": 64,
          "description": "Manufacturer to match."
        },
        "model": {
          "$ref": "#/$defs/modelName",
          "description": "Absent matches every model from that vendor."
        },
        "firmware_below": {
          "type": "string",
          "maxLength": 32,
          "description": "Blocks builds older than this, so a corrected build clears ITSELF the moment it ships -- no republished bundle, no negotiation. A device that will not state its firmware counts as older."
        },
        "firmware_in": {
          "type": "array",
          "maxItems": 32,
          "items": {
            "type": "string",
            "maxLength": 32
          },
          "description": "Names EXACT builds. Some faults are one bad release with good builds either side, and a range would condemn versions that were never affected. May be set alongside firmware_below, in which case the rule matches if either does. Unlike a range, a device that will not state its firmware does NOT match a list -- silence is not a claim to be one of the named builds."
        },
        "codes": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/noticeCode"
          },
          "description": "Absent blocks every device-originated code."
        },
        "action": {
          "enum": [
            "warn",
            "block"
          ],
          "description": "warn delivers and records; block does not deliver. A graduated response, so a first offense is not a ban."
        },
        "reason": {
          "type": "string",
          "maxLength": 200,
          "description": "Returned to the device so it knows what to fix."
        },
        "since": {
          "$ref": "#/$defs/timestamp"
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "noticeCode": {
      "enum": [
        "club_ambiguous",
        "source_degraded",
        "device_left",
        "unmeasured_stroke",
        "settings_divergent",
        "settings_unattested",
        "calibration_expired",
        "unbound_wearable",
        "time_unverified",
        "media_capture_suppressed",
        "round_ending",
        "bay_offline",
        "update_available",
        "device_denied",
        "certification_shortfall",
        "calibration_expiring",
        "certification_expiring",
        "bay_ineligible_for_event",
        "device_message",
        "device_question"
      ],
      "description": "A registered vocabulary because consumers act on it. device_message and device_question are the two a PARTICIPANT may raise rather than the engine; everything else is engine-only. an app renders and localizes from the code, and the English `text` is a fallback for a surface with no app behind it."
    },
    "noticeUrgency": {
      "enum": [
        "interrupt",
        "attention",
        "background",
        "record"
      ],
      "description": "How much of the player's attention this deserves -- NOT which channel to use. The engine has no idea whether someone wants a push, an SMS or nothing until the round summary; the platform maps urgency to channel using the member's own preferences. interrupt: they are over the ball and can fix it now. attention: they should see it before the next hole. background: the round summary is soon enough. record: journal only, no human involved."
    },
    "penaltyReason": {
      "enum": [
        "water",
        "out_of_bounds",
        "lost_ball",
        "unplayable",
        "wrong_ball",
        "slow_play",
        "equipment",
        "provisional_taken",
        "conceded",
        "other"
      ],
      "description": "Why a penalty stroke was added, coded so scoring and player rating can treat the categories differently -- a lost ball and a slow-play penalty are not the same evidence about how someone played."
    },
    "playerUp": {
      "type": "object",
      "required": [
        "slot"
      ],
      "additionalProperties": false,
      "description": "Who is at the ball. Carried as the field round.player_up, and by the engine as round.previous_stroke for the player who took the last one.",
      "properties": {
        "slot": {
          "$ref": "#/$defs/slot"
        },
        "display_label": {
          "$ref": "#/$defs/displayLabel"
        },
        "t_ms": {
          "$ref": "#/$defs/monotonicMs"
        },
        "session_epoch": {
          "$ref": "#/$defs/sessionEpoch"
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "protocolVersion": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]+$",
      "description": "The SGA-OIS wire version the sender speaks."
    },
    "provenance": {
      "enum": [
        "measured",
        "calculated",
        "inferred",
        "absent"
      ],
      "description": "measured: a sensor on this device directly observed the quantity on this shot. calculated: deterministically computed from quantities measured on this same shot; MUST declare `from`. inferred: estimated using assumptions or history not measured on this shot; MUST declare `model`."
    },
    "retractionReason": {
      "enum": [
        "ambiguous",
        "out_of_range",
        "sensor_fault",
        "superseded",
        "reset",
        "obstructed",
        "powered_down",
        "unknown"
      ],
      "description": "Why a device is withdrawing a standing assertion. 'ambiguous' is the common one: the device can tell that its previous answer is no longer safe, but cannot tell what the new answer is."
    },
    "role": {
      "enum": [
        "sensor.ball",
        "sensor.club",
        "sensor.club_id",
        "sensor.equipment",
        "sensor.swing",
        "sensor.pressure",
        "sensor.putting",
        "sensor.environment",
        "sensor.player",
        "simulator",
        "actuator",
        "display",
        "recorder",
        "officiator",
        "courier"
      ],
      "description": "One part a participant plays in a bay. Additive: a device may hold several, and roles determine defaults rather than permissions -- permissions come from the connection matrix for what a participant may ASSERT, and from the request table (03 section 1.1) for what it may ASK. There is deliberately no `operator` role: operator authority arrives only by relay from the cloud, which authorized it against the bay's claim, so nothing connecting locally can claim it."
    },
    "rosterEntry": {
      "type": "object",
      "required": [
        "slot"
      ],
      "additionalProperties": false,
      "description": "One player in the round, as the bay knows them: a slot and the label they are playing under.",
      "properties": {
        "slot": {
          "$ref": "#/$defs/slot"
        },
        "display_label": {
          "$ref": "#/$defs/displayLabel"
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "assistEffect": {
      "type": "string",
      "enum": [
        "plays_the_stroke",
        "skips_the_stroke",
        "changes_the_result",
        "changes_the_course",
        "more_than_one_attempt",
        "informs_the_decision"
      ],
      "description": "What an assist does to the evidence, which is the only thing a consumer needs to know about it. Six effects, closed: every assist any simulator offers is one of these, whatever its vendor calls it. `plays_the_stroke` -- The software hit a stroke the player did not: auto-putt is the clear case. `skips_the_stroke` -- Strokes that are never played: a gimme distance, a cap on strokes per hole. `changes_the_result` -- Where the ball went is not only what the swing produced: aim assist, shot shaping, automatic compensation for wind or elevation. `changes_the_course` -- The hole is easier than the one described: a larger cup, a removed hazard, an improved lie. `more_than_one_attempt` -- The recorded stroke is not the player's only try, before or after it: mulligans, shot replay, a preview of the putt or the flight. `informs_the_decision` -- Advice only. The player still hits the ball, and the ball goes where the swing sends it: green reading, club recommendation. See 15 section 9."
    },
    "assistScope": {
      "type": "string",
      "enum": [
        "tee_shot",
        "approach",
        "around_green",
        "putting",
        "all"
      ],
      "description": "Where on the hole an assist acts, in the ordinary vocabulary of the game: the tee shot on a par 4 or 5, an approach (including a par-3 tee shot), a shot around the green, a putt -- or all of them."
    },
    "assistId": {
      "type": "string",
      "maxLength": 96,
      "anyOf": [
        {
          "enum": [
            "auto_putt",
            "gimme",
            "max_strokes_per_hole",
            "aim_assist",
            "shot_shaping_assist",
            "distance_auto_adjust",
            "wind_auto_compensation",
            "elevation_auto_compensation",
            "mulligan",
            "shot_replay",
            "putt_preview",
            "ball_flight_preview",
            "green_reading",
            "club_recommendation"
          ]
        },
        {
          "pattern": "^x-[a-z0-9_-]+\\.[a-z0-9_.-]+$"
        }
      ],
      "description": "A well-known assist, or a vendor's own as `x-<vendor>.<name>`. One vocabulary for a round's declaration and an event's requirements, so the two can be compared key for key."
    },
    "assist": {
      "type": "object",
      "required": [
        "id",
        "effect",
        "applies_to"
      ],
      "additionalProperties": false,
      "description": "One assist in force. A well-known id has its effect and scope fixed here, so a simulator cannot file auto-putt as advice; any other assist is named in the vendor's namespace and the vendor declares its effect. See 15 section 9.",
      "properties": {
        "id": {
          "$ref": "#/$defs/assistId"
        },
        "label": {
          "type": "string",
          "maxLength": 64,
          "description": "The simulator's own name for the assist, for display. Untrusted text: render it escaped, and never decide anything from it -- `effect` is what is decided on."
        },
        "effect": {
          "$ref": "#/$defs/assistEffect"
        },
        "applies_to": {
          "type": "array",
          "minItems": 1,
          "uniqueItems": true,
          "items": {
            "$ref": "#/$defs/assistScope"
          }
        },
        "distance_m": {
          "type": "number",
          "minimum": 0,
          "description": "The range an assist acts within, where it has one: a gimme distance, the distance inside which auto-putt takes over. Absent on auto_putt means every putt."
        },
        "count": {
          "type": "integer",
          "minimum": 0,
          "description": "The number an assist allows, where it has one: mulligans per round, the stroke cap per hole."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "allOf": [
        {
          "if": {
            "properties": {
              "id": {
                "const": "auto_putt"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "plays_the_stroke"
              },
              "applies_to": {
                "const": [
                  "putting"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "gimme"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "skips_the_stroke"
              },
              "applies_to": {
                "const": [
                  "putting"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "max_strokes_per_hole"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "skips_the_stroke"
              },
              "applies_to": {
                "const": [
                  "all"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "aim_assist"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "changes_the_result"
              },
              "applies_to": {
                "const": [
                  "all"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "shot_shaping_assist"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "changes_the_result"
              },
              "applies_to": {
                "const": [
                  "tee_shot",
                  "approach"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "distance_auto_adjust"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "changes_the_result"
              },
              "applies_to": {
                "const": [
                  "all"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "wind_auto_compensation"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "changes_the_result"
              },
              "applies_to": {
                "const": [
                  "tee_shot",
                  "approach",
                  "around_green"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "elevation_auto_compensation"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "changes_the_result"
              },
              "applies_to": {
                "const": [
                  "tee_shot",
                  "approach",
                  "around_green"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "mulligan"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "more_than_one_attempt"
              },
              "applies_to": {
                "const": [
                  "all"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "shot_replay"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "more_than_one_attempt"
              },
              "applies_to": {
                "const": [
                  "all"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "putt_preview"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "more_than_one_attempt"
              },
              "applies_to": {
                "const": [
                  "putting"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "ball_flight_preview"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "more_than_one_attempt"
              },
              "applies_to": {
                "const": [
                  "tee_shot",
                  "approach",
                  "around_green"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "green_reading"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "informs_the_decision"
              },
              "applies_to": {
                "const": [
                  "putting"
                ]
              }
            }
          }
        },
        {
          "if": {
            "properties": {
              "id": {
                "const": "club_recommendation"
              }
            },
            "required": [
              "id"
            ]
          },
          "then": {
            "properties": {
              "effect": {
                "const": "informs_the_decision"
              },
              "applies_to": {
                "const": [
                  "tee_shot",
                  "approach",
                  "around_green"
                ]
              }
            }
          }
        }
      ]
    },
    "assistRequirement": {
      "type": "object",
      "required": [
        "id"
      ],
      "additionalProperties": false,
      "description": "An assist an event requires to be in force, with the parameter it must have: a 0.6 m gimme, two mulligans, a ten-stroke cap. Its effect is not restated -- the id carries it.",
      "properties": {
        "id": {
          "$ref": "#/$defs/assistId"
        },
        "distance_m": {
          "type": "number",
          "minimum": 0,
          "description": "The range an assist acts within, where it has one: a gimme distance, the distance inside which auto-putt takes over. Absent on auto_putt means every putt."
        },
        "count": {
          "type": "integer",
          "minimum": 0,
          "description": "The number an assist allows, where it has one: mulligans per round, the stroke cap per hole."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "roundAssists": {
      "type": "object",
      "required": [
        "complete",
        "active"
      ],
      "additionalProperties": false,
      "description": "The assists block of round.settings: every assist in force, and whether that list is the whole of it. See 15 section 9.",
      "properties": {
        "complete": {
          "type": "boolean",
          "description": "True when `active` lists every assist in force -- including an empty list, which then means none. False when the simulator is reporting what it can and there may be others. A round with no round.settings at all is a third state, undeclared, and a consumer MUST NOT read it as either of these."
        },
        "active": {
          "type": "array",
          "maxItems": 64,
          "items": {
            "$ref": "#/$defs/assist"
          }
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "roundHole": {
      "type": "object",
      "required": [
        "number"
      ],
      "additionalProperties": false,
      "description": "The hole in play, asserted by the simulator as one value whenever it changes. Requestable as the field round.hole. The engine spreads it across every shot record it composes: number to hole, par to course.hole_par, length_m to course.hole_length_m, course_name to course.name (15 section 1).",
      "properties": {
        "number": {
          "type": "integer",
          "minimum": 1,
          "maximum": 36,
          "description": "Which hole, as the course numbers it."
        },
        "par": {
          "type": "integer",
          "minimum": 1,
          "maximum": 10,
          "description": "Par for this hole from the tees in play."
        },
        "length_m": {
          "type": "number",
          "minimum": 0,
          "description": "Length of the hole from the tees in play."
        },
        "course_name": {
          "type": "string",
          "maxLength": 200,
          "description": "The course as the simulator presents it. A label for humans; a course's identity is its digest (06 section 9.5)."
        },
        "course_holes": {
          "type": "integer",
          "minimum": 1,
          "maximum": 36,
          "description": "How many holes the round is over. Carried for displays; the course document and the card are where a consumer reads it."
        },
        "course_par": {
          "type": "integer",
          "minimum": 1,
          "description": "Par for the round. Carried for displays, as course_holes is."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "roundSettings": {
      "type": "object",
      "required": [
        "assists",
        "changed_by"
      ],
      "additionalProperties": false,
      "description": "What the simulator has in force for the round: its assists, its conditions and its course. Requestable as the field round.settings; simulator-provided and standing, so every change is a new version and the journal holds them all. The same declaration serves a casual round and a sanctioned one: in an event the engine compares it with the rules package. See 15 section 9 and 08 section 2.",
      "properties": {
        "assists": {
          "$ref": "#/$defs/roundAssists"
        },
        "conditions": {
          "$ref": "event-rules-package.schema.json#/properties/conditions",
          "description": "The playing conditions in force, in the same keys an event package requires them in -- reporting the value actually in force, not the value asked for. A key not reported is unsupported, never assumed."
        },
        "course": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "course_digest": {
              "$ref": "#/$defs/digest"
            },
            "tee_set": {
              "type": "string",
              "maxLength": 64,
              "description": "The tee set in play, spelled as the course names it."
            }
          },
          "patternProperties": {
            "^x-[a-z0-9_-]+\\.": {}
          }
        },
        "swing_inputs": {
          "type": "array",
          "uniqueItems": true,
          "items": {
            "enum": [
              "launch_monitor",
              "mouse",
              "keyboard",
              "gamepad",
              "touch",
              "simulated",
              "replay"
            ]
          },
          "description": "Every way a stroke can be produced right now. The same names an event's prohibited_inputs uses, plus launch_monitor, so an event that requires a physical swing can see that nothing else is switched on."
        },
        "locked": {
          "type": "array",
          "uniqueItems": true,
          "items": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128
          },
          "description": "The settings this simulator is holding fixed for the round -- `assists`, `course`, or a conditions key such as `conditions.wind_speed_mps` -- so a change to them is refused rather than reported. Absent means nothing is held (08 section 2.1a)."
        },
        "changed_by": {
          "enum": [
            "player",
            "operator",
            "simulator",
            "rules_package"
          ],
          "description": "Who made this version. `simulator` is the software itself -- a default, a restart, an automatic change; `rules_package` is an event's package being applied."
        },
        "request_id": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "description": "The request.apply_rules this version answers, when changed_by is rules_package -- and on every later version while that package is in force, so a mid-round change is read against what the event required."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "roundAllowances": {
      "type": "object",
      "required": [
        "basis",
        "complete",
        "source",
        "players"
      ],
      "additionalProperties": false,
      "description": "Strokes received per slot for the round about to be played, requestable as the field round.allowances. Engine-provided, and only after the simulator has declared a course: an allowance means nothing without one. See 15 section 5.",
      "properties": {
        "basis": {
          "type": "object",
          "required": [
            "format"
          ],
          "additionalProperties": false,
          "description": "What these allowances were computed against. Recorded so a consumer can tell that a card was scored under allowances issued for a different course or tee -- which is a wrong result, not a rounding difference.",
          "properties": {
            "course_digest": {
              "$ref": "#/$defs/digest"
            },
            "tee_set": {
              "type": "string",
              "maxLength": 64,
              "description": "The tee set the allowance was computed from, spelled as the course names it (for example `Blue`, or `championship`)."
            },
            "format": {
              "$ref": "#/$defs/scoringFormat"
            }
          },
          "patternProperties": {
            "^x-[a-z0-9_-]+\\.": {}
          }
        },
        "complete": {
          "type": "boolean",
          "description": "True only when every occupied slot has an allowance. A simulator MUST NOT pre-apply allowances when this is false: a field where some players are rated and some are not is a match with a guess in it, and guessing here decides the result."
        },
        "source": {
          "$ref": "#/$defs/allowanceSource"
        },
        "computed_at": {
          "$ref": "#/$defs/timestamp"
        },
        "players": {
          "type": "array",
          "maxItems": 32,
          "items": {
            "$ref": "#/$defs/strokeAllowance"
          },
          "description": "Per-player stroke allowances for this round, one entry per occupied slot."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "roundDisposition": {
      "enum": [
        "complete",
        "partial_recorded",
        "abandoned",
        "disqualified"
      ],
      "description": "partial_recorded is the normal outcome for an unfinished round and is not a failure state. Partial rounds are first-class records; whether one posts is a query, not an ingest decision."
    },
    "roundRoster": {
      "type": "array",
      "maxItems": 32,
      "items": {
        "$ref": "#/$defs/rosterEntry"
      },
      "description": "Every player in the round, label to slot. Requestable as the field round.roster. Two things need it and nothing else can supply them: a display or HUD showing the group, and a member's phone at check-in -- somebody scanning in has to choose which of the in-game names is them, and that list has to come from somewhere. It persists for the round, so a player who arrives late or claims afterwards still has something to pick from."
    },
    "scoringFormat": {
      "type": "string",
      "enum": [
        "stroke",
        "stableford",
        "match",
        "scramble",
        "best_ball",
        "alternate_shot",
        "skins"
      ],
      "description": "How the card is scored. Recorded rather than inferred: the same strokes mean different things under stableford, match play and skins, and a consumer that guessed would be wrong for every format but the one it assumed."
    },
    "sensorType": {
      "enum": [
        "radar_doppler",
        "camera_photometric",
        "camera_infrared",
        "camera_highspeed",
        "camera_stereo",
        "lidar",
        "time_of_flight",
        "optical_grid",
        "photometer",
        "pressure_mat",
        "strain_gauge",
        "imu",
        "magnetometer",
        "hall_effect",
        "encoder",
        "rfid_nfc",
        "ultrasonic",
        "acoustic",
        "gnss",
        "environmental_probe",
        "anemometer",
        "simulated",
        "none"
      ],
      "description": "One sensing technology. Real devices routinely combine several -- dual radar with a camera, quadrascopic photometric cameras, overhead high-speed infrared -- so the device-level declaration is an ARRAY and multiple entries are the normal case, not an exception. A provided field may additionally name the specific sensors behind it. Declarative only: it does not affect routing. Collected because sensing modality is the strongest predictor of which fields a device can measure rather than infer, and because the aggregate is genuinely useful back to manufacturers.\n\nSome pairs here are easy to conflate, and the distinction is the whole reason both exist:\n\n- magnetometer measures the AMBIENT field, which is how a device works out heading or orientation. hall_effect detects a magnet passing or sitting close to it -- club head over a mat, a magnet in a shaft, a rotating shaft counted per turn. Same physics, different question.\n- encoder is position feedback on something that moves under control: a tilting platform, a robotic tee. A Hall sensor is sometimes wired to do this job, but an encoder is the part that exists for it.\n- lidar scans. time_of_flight is a single-point range finder, which is a different cost, a different footprint and a far narrower set of fields.\n- photometer reports illuminance. It is worth declaring because it is what says whether a camera-based system can work at all in this bay.\n- environmental_probe covers air temperature, humidity and pressure together, because one component routinely does all three. anemometer is separate: wind is its own instrument, and outdoors it is the only ambient value that materially moves a ball.\n\nrfid_nfc reads a tag. The standard already ranks it above every other way of identifying a club (see club.identification.method), so the reader that does it must be able to say what it is."
    },
    "seriesRef": {
      "type": "object",
      "required": [
        "field",
        "rate_hz",
        "ref"
      ],
      "additionalProperties": false,
      "description": "A pointer to sampled data held on the bulk path rather than inlined. Biomechanical and pressure data are time series, not scalars -- a 240 Hz trace of pelvis rotation through a two-second swing is 480 samples, and inlining that in a latency-critical shot record would be an error of kind, not of size.",
      "properties": {
        "field": {
          "type": "string",
          "description": "The field this series carries samples of."
        },
        "rate_hz": {
          "type": "number",
          "exclusiveMinimum": 0,
          "description": "Sample rate of the series."
        },
        "samples": {
          "type": "integer",
          "minimum": 0,
          "description": "Number of samples in the series. With rate_hz and t0_ms this bounds the series completely, so a consumer can decide whether to fetch it without fetching it."
        },
        "t0_ms": {
          "type": "integer",
          "description": "Offset of the first sample relative to the impact event."
        },
        "ref": {
          "type": "string",
          "maxLength": 512,
          "description": "Opaque handle resolvable through the engine journal."
        },
        "digest": {
          "$ref": "#/$defs/digest"
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "sessionEpoch": {
      "type": "integer",
      "minimum": 0,
      "description": "Increments each time the session clock rolls over, every 24 hours. Exists so that t_ms stays bounded however long a session runs -- a bay left switched on for a month reaches epoch 30 and every number in it is still small. Ordering within a session is by (session_epoch, t_ms)."
    },
    "sgaId": {
      "type": "string",
      "pattern": "^sga:(facility|bay|device|session|shot|round|course|event|software):[A-Za-z0-9._:-]{1,128}$",
      "description": "Opaque. Consumers MUST NOT parse structure out of an identifier."
    },
    "shotKind": {
      "enum": [
        "stroke",
        "whiff",
        "provisional",
        "mulligan",
        "practice",
        "gimme",
        "concession",
        "drop",
        "penalty"
      ],
      "description": "What kind of stroke this record represents. Kinds other than stroke/whiff/provisional/mulligan/practice involve no swing and carry no ball, club or trajectory data -- but they still produce a record, because every stroke added to a score must be reportable and auditable."
    },
    "signature": {
      "type": "object",
      "required": [
        "alg",
        "key_id",
        "sig"
      ],
      "properties": {
        "alg": {
          "enum": [
            "ed25519",
            "p256"
          ],
          "description": "Signature algorithm. ed25519 is the default and the only one SGA's own keys use. p256 is ECDSA over P-256 with SHA-256, the signature carried as the 64-byte r||s concatenation, and exists because inexpensive secure elements implement it in hardware and Ed25519 in none (02 section 3). A verifier MUST support both."
        },
        "key_id": {
          "type": "string",
          "maxLength": 128,
          "description": "Which key produced the signature, so keys can be rotated without invalidating everything already signed. For a device, the number of its certificate: 6 to 16 uppercase hexadecimal characters (02 section 3)."
        },
        "nonce": {
          "type": "string",
          "description": "The engine's session nonce, included so a descriptor captured from a previous session cannot be replayed into this one."
        },
        "sig": {
          "type": "string",
          "contentEncoding": "base64",
          "description": "The raw signature, standard base64 with padding."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "additionalProperties": false,
      "description": "A detached signature. What it covers is one rule everywhere a participant signs (02 section 3a): the RFC 8785 canonical form of {\"session_id\": ..., \"nonce\": ..., \"message\": <the message with this signature's sig removed>}. Canonicalization is what makes the signature verifiable by a party that reserialized the JSON -- integrity, note, not authorship: it proves the bytes did not change, and key_id is what says who stood behind them."
    },
    "slot": {
      "type": "integer",
      "minimum": 1,
      "maximum": 32,
      "description": "Anonymous position in the round. Slots bind to SGA accounts in the cloud, never at the bay. No PII accompanies a slot."
    },
    "specSource": {
      "enum": [
        "member_bag",
        "sim_bag_setup",
        "rfid_tag",
        "rack_slot",
        "fitting_record",
        "operator_entered",
        "manufacturer_lookup",
        "unknown"
      ],
      "description": "Where an equipment specification came from. member_bag resolves in the cloud on claim; the others are known at the bay. It matters for the same reason club identification does: an equipment corpus is only worth money if you can filter to rows where the equipment was actually known rather than assumed."
    },
    "strokeActor": {
      "enum": [
        "player",
        "simulator_auto",
        "operator",
        "official"
      ],
      "description": "Who added this stroke. 'operator' and 'official' are high-signal integrity events: a human adjusting a score is exactly what an audit trail exists to capture."
    },
    "strokeAllowance": {
      "type": "object",
      "required": [
        "slot",
        "strokes_received"
      ],
      "additionalProperties": false,
      "description": "How many strokes one slot receives in this round. Round-scoped and meaningless off this course, tee set and format -- it is NOT a player rating, and 00 section 6 permits it at the bay for exactly that reason.",
      "properties": {
        "slot": {
          "$ref": "#/$defs/slot"
        },
        "strokes_received": {
          "type": "integer",
          "minimum": 0,
          "maximum": 54,
          "description": "Strokes this slot receives over the holes being played. In match play the convention is that the lowest player receives zero and the others receive the difference, so a zero here is a real value and not a missing one."
        },
        "stroke_holes": {
          "type": "array",
          "maxItems": 54,
          "items": {
            "type": "integer",
            "minimum": 1,
            "maximum": 36
          },
          "description": "Which holes the strokes fall on. Optional: a simulator knows its own stroke indexes and normally allocates strokes_received itself. When present this is authoritative and the simulator MUST use it -- an event ruleset may fix an allocation that is not the naive one. A hole appearing twice means two strokes on that hole."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "swingEvent": {
      "enum": [
        "address",
        "takeaway",
        "top",
        "transition",
        "impact",
        "finish"
      ],
      "description": "The named moments every biomechanical measurement anchors to. Without a shared set of these, two systems reporting 'pelvis turn at the top' are not reporting the same thing."
    },
    "timeAnchor": {
      "type": "object",
      "required": [
        "source"
      ],
      "additionalProperties": false,
      "description": "How the engine knows what time it is, for any deadline that matters. A deadline evaluated against the bay's system clock is not a deadline -- the clock is settable by whoever is standing at the machine.",
      "properties": {
        "source": {
          "enum": [
            "signed_remote",
            "signed_cached",
            "roughtime",
            "system_clock",
            "none"
          ],
          "description": "signed_remote: a timestamp signed by SGA's time key, fetched over the uplink with the engine's nonce in it (08 section 2.2). signed_cached is the offline case: a signed timestamp from the last successful contact, advanced by the engine's monotonic clock. roughtime is RESERVED and not used in 1.0. system_clock and none are untrusted and MUST NOT satisfy a competition deadline."
        },
        "anchored_at": {
          "$ref": "#/$defs/timestamp"
        },
        "elapsed_since_anchor_ms": {
          "type": "integer",
          "minimum": 0,
          "description": "Monotonic elapsed time since the anchor. Monotonic because it cannot be wound back, and an attempt to do so is itself detectable."
        },
        "trusted": {
          "type": "boolean",
          "description": "True only when the anchor came from a source that cannot be set by whoever is standing at the bay machine. A competition deadline MUST NOT be evaluated against an untrusted anchor."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "timestamp": {
      "type": "string",
      "format": "date-time",
      "description": "RFC 3339 with an explicit offset. Naive local times are non-conformant."
    },
    "trajectoryPhase": {
      "enum": [
        "launch",
        "flight",
        "apex",
        "interaction",
        "landing",
        "bounce",
        "roll",
        "rest",
        "penalty"
      ],
      "description": "Which part of the ball's life a trajectory event belongs to, from launch through flight, whatever it interacts with, landing, bounce and roll, to rest -- or penalty, where the ball's own physics stopped mattering. apex marks the top of the flight: a single event, carried so the vertical profile is readable without re-deriving it from the point series, and so a shot that never got airborne is distinguishable from one whose apex nobody recorded."
    },
    "vendorExtensions": {
      "type": "object",
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      },
      "additionalProperties": false,
      "description": "Tier-1 vendor extensions. Any vendor may define any field in their own namespace and ship it immediately. Consumers MUST skip unrecognized x- fields, never reject them."
    },
    "wantImportance": {
      "enum": [
        "opportunistic",
        "requested",
        "required"
      ],
      "description": "How much a participant needs a field, and -- because it is normally derived from HOW the field was asked for rather than stated -- also a declaration of what the product actually implements.\n\nrequested is the default for a field named explicitly: the participant is saying it consumes this field, which is the only way anyone can tell an implemented feature from an unimplemented one.\n\nopportunistic is the default for a field matched by a wildcard: take it if it is there, and do not report it as unmet if it is not. Without that last part a wildcard in a sparse bay buries the real demand signal under fields nobody was actually waiting for.\n\nrequired means the participant cannot perform its declared role without the field, and an unmet one is surfaced to the operator. It MUST name a field: 'I cannot operate without some unspecified set of fields' is not something anyone can act on. Reserve it for what it says -- a descriptor marking many wants required is advertising fragility, not importance."
    },
    "wantReason": {
      "enum": [
        "calibration",
        "player_setup",
        "compensation",
        "validation",
        "actuation",
        "display",
        "recording",
        "scoring",
        "power_management",
        "safety",
        "diagnostics",
        "other"
      ],
      "description": "Why a participant wants a field, as a countable code. OPTIONAL and never a gate: it exists only so the aggregate -- what does the ecosystem actually need -- can be counted rather than text-mined. Pair it with free-text `reason` for anything the list does not cover, and send `other` plus text rather than forcing a fit."
    },
    "operatorRef": {
      "type": "string",
      "minLength": 8,
      "maxLength": 64,
      "pattern": "^[A-Za-z0-9_-]+$",
      "description": "An opaque, bay-scoped reference to whoever authorized a request. NOT an account identifier and not derived from one: it means 'the person who was signed in at the desk when this was asked', and only SGA can resolve it. The bay and the simulator MUST NOT treat it as an account or correlate it across bays or sessions. It exists so a journal can say a round ended because somebody pressed a button, and say WHICH somebody, without a member identity ever crossing the bay-local protocol (00 section 6). The same shape and the same rule as checkin_ref, for the same reason."
    },
    "roundPermitted": {
      "type": "object",
      "required": [
        "permitted"
      ],
      "additionalProperties": false,
      "description": "Whether a sanctioned round may begin on this bay now. Engine-provided and standing; requestable as the field bay.round_permitted. A simulator MUST NOT open a round under an event while it is false (14 section 3.1a, 08 section 2.1), and SHOULD show the codes to the operator. Rounds that claim no event are never held by it.",
      "properties": {
        "permitted": {
          "type": "boolean"
        },
        "codes": {
          "type": "array",
          "uniqueItems": true,
          "items": {
            "$ref": "#/$defs/noticeCode"
          },
          "description": "Why not, as notice codes: settings_divergent, settings_unattested, certification_shortfall or bay_ineligible_for_event. Empty when permitted."
        },
        "event_id": {
          "$ref": "#/$defs/sgaId",
          "description": "The event this answer is about."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "deviceHandle": {
      "type": "string",
      "minLength": 8,
      "maxLength": 64,
      "pattern": "^[A-Za-z0-9_-]+$",
      "description": "A session-scoped handle for a member's own device, built like checkinRef: opaque, deterministic within the session, and not reversible by the bay. Never a hardware address, serial or account-level identifier (13 section 4.1)."
    },
    "admissionPath": {
      "enum": [
        "operator_enrollment",
        "member_registered",
        "member_introduced",
        "direct"
      ],
      "description": "How a device was admitted (13 section 4.1). operator_enrollment: enrolled to the bay by its operator. member_registered: a member's registered device, admitted by their check-in. member_introduced: presented by a member's phone. direct: joined by presenting the bay token itself."
    },
    "mediaEntitlement": {
      "type": "object",
      "required": [
        "slot",
        "clips"
      ],
      "additionalProperties": false,
      "description": "Whether one slot is entitled to swing clips this round (11 section 5a). Engine-provided; carried in the field round.media_entitlements as an array of these.",
      "properties": {
        "slot": {
          "$ref": "#/$defs/slot"
        },
        "clips": {
          "type": "boolean",
          "description": "true: a camera may keep clips of this slot's strokes."
        }
      },
      "patternProperties": {
        "^x-[a-z0-9_-]+\\.": {}
      }
    },
    "roundMediaEntitlements": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/mediaEntitlement"
      },
      "description": "Requestable as the field round.media_entitlements: one entry per slot in the round (11 section 5a)."
    }
  }
}
