Conventions
Normative. MUST, SHOULD, MAY per RFC 2119.
Everything in this file exists to make one class of bug impossible: two correct implementations that disagree about what a number means.
1. Units#
1.1 The rule#
Every numeric field name ends in its unit. The unit is fixed by the standard and is not negotiable, not configurable, and not carried in the payload.
"ball_speed_mps": 71.3,
"carry_m": 214.6,
"launch_angle_deg": 12.4,
"spin_rpm": 2740There is no units: "imperial" mode. There is no per-field unit tag. A field called carry_m is meters, on every device, in every country, in every version of this standard, forever.
1.2 Why fixed rather than declared#
Letting a device declare its own units is the obvious design and it is the wrong one. It moves conversion from one place — the vendor's adapter, written by the party that actually knows what the sensor emits — to N places, one in every consumer. Every consumer then reimplements the same conversion table, and they will not all get it right. One vendor's mistake becomes everyone's outage.
Fixed units also make failure loud. A number that is 1.094x too large is a caught unit bug. A number that is silently reinterpreted is a wrong player rating that nobody notices for a season.
The unit is in the field name rather than in a lookup table because a reviewer reading a payload should be able to see the bug without opening the documentation. carry_m: 234 for a pitching wedge is visibly wrong. carry: 234 is not.
1.3 Canonical units#
| quantity | unit | suffix | notes |
|---|---|---|---|
| distance | meter | _m | carry, total, distance to pin, elevation |
| small distance | millimeter | _mm | impact offset, face contact position |
| speed | meter/second | _mps | ball, club head |
| angle | degree | _deg | launch, face, path, lie, spin axis |
| spin rate | rev/minute | _rpm | universal in the industry, kept |
| mass | gram | _g | ball, club head |
| time, absolute | RFC 3339 UTC | _at | see §3 |
| time, duration | millisecond | _ms | |
| temperature | degree Celsius | _c | |
| pressure | hectopascal | _hpa | |
| humidity | percent | _pct | 0–100 |
| ratio | dimensionless | _ratio | smash factor |
| money | ISO 4217 minor units | _minor | integer, paired with currency |
| illuminance | lux | _lux | camera systems declaring requires_lighting |
| air density | kg/m³ | _kgm3 | |
| force | newton | _n | also _bw_ratio, force as a multiple of body weight |
| torque | newton-meter | _nm |
Presentation is not this standard's problem. A member in Texas sees yards; a member in Dublin sees meters. That conversion happens in the app, once, at the edge. A session MAY carry display_units: "imperial" as a hint for user interfaces only. It MUST NOT change any wire value, and an implementation that reads it to decide how to interpret a number is non-conformant.
1.4 Precision#
Values are IEEE-754 doubles. A device MUST NOT invent precision it does not have. A monitor with 0.1 mph ball-speed resolution reports the converted value and declares its resolution in the capability descriptor, so consumers can round honestly. Showing 71.3009 from a device with 0.1 mph resolution is a presentation bug the standard can prevent by publishing the resolution.
2. Sign conventions#
Units alone are not enough. The second half of this bug class is two implementations that agree on degrees and disagree on which way is positive.
All directional conventions are stated from the player's address position, looking down the target line, and are independent of handedness. A left-handed player's data is not mirrored. A device MUST NOT flip signs based on is_right_handed; that field is descriptive, not an instruction.
| field | positive means |
|---|---|
launch_direction_deg | right of the target line |
club_path_deg | in-to-out |
face_angle_deg | open — pointing right of target |
face_to_path_deg | face open relative to path |
spin_axis_deg | tilted right |
angle_of_attack_deg | upward |
lie_angle_deg | toe up |
elevation_change_m | target above the ball |
wind_direction_deg | compass bearing the wind blows toward, 0 = north |
slope_deg | uphill along the intended line |
spin_axis_deg is the one that bites. It is a geometric quantity, not a shot shape. A positive spin axis is a fade for a right-handed player and a draw for a left-handed one — the same number, two names. "Draw" and "fade" are handedness-dependent interpretations and MUST NOT appear as values anywhere in this standard.
3. Time#
Three different kinds of millisecond value appear in this standard, and they are not interchangeable. Confusing them is the most likely source of a bug that only shows up on a long session.
| kind | example | origin | typical magnitude |
|---|---|---|---|
| session clock | t_ms | monotonic, since session start | 0 to ~86,400,000 |
| event offset | swing.events.top_ms, seriesRef.t0_ms | signed, relative to impact | -3,000 to +3,000 |
| duration / age | flight_time_ms, age_ms, latency_ms | unsigned elapsed time | 0 to a few minutes |
Only the first grows with the length of a session. The other two are always small, because they are measured against something nearby.
3.1 Wall clock#
- Absolute times are RFC 3339 with an explicit offset, always. Naive local timestamps are non-conformant.
2026-09-05T14:22:31.482-05:00is valid;2026-09-05 14:22:31is not. - Millisecond precision minimum; microsecond permitted.
..._atsuffix marks a wall-clock instant.- Wall clock is for humans, for audit, and for ordering across sessions. It is never used for ordering within one.
3.2 The session clock, and who owns it#
t_ms is milliseconds since the session started, from a clock that cannot run backwards.
The engine assigns
t_ms. It is the only participant that may.
Devices have their own clocks, and those clocks drift, restart, and disagree. If ordering depended on device timestamps, two devices could each be internally consistent and jointly produce a sequence in which a shot precedes the manifest that describes it. So the engine stamps everything it receives, from one clock, and that stamp is authoritative for ordering and duration.
A device MAY additionally send device_t_ms — its own monotonic reading. The engine measures the offset between the two during the handshake and records it in clock_skew_ms. This matters for buffered data: a BLE device replaying thirty seconds of backlog after a reconnect would otherwise have every record stamped with its arrival time rather than its observation time. device_t_ms lets the engine reconstruct when a thing actually happened, and the correction is recorded rather than applied silently.
Ordering and duration MUST be computed from t_ms, never from wall clock. Bay machines take NTP corrections mid-round, and a shot must never be able to arrive before the one that preceded it.
3.3 The clock rolls over. The round does not end.#
Nothing in this standard may end a round because a counter got large.
A cap on session length that ends the round when it is reached would assume a simulator can save a round in progress and restore it later. Many cannot. Widely used simulators offer no visible pause-and-resume for a round underway, and the ordinary way people use a home simulator is to switch off the projector and leave the software running — the sim is still sitting on hole 12 the next morning, perfectly happy, having lost nothing.
An engine that ended that round at midnight would be destroying state the simulator was holding correctly. That is the engine causing the failure it exists to prevent.
So the session clock rolls over instead.
session_epoch 0 t_ms 0 … 86,399,999 session_epoch 1 t_ms 0 … 86,399,999 same session, same round, session_epoch 2 t_ms 0 … same connections, nothing interrupted
t_mscounts milliseconds within the current epoch and never exceeds 86,400,000.session_epochincrements at each rollover, starting at 0.- Ordering within a session is by
(session_epoch, t_ms)— a tuple comparison, and the only thing a consumer has to change. - Rollover emits a new manifest, so every participant learns the new epoch through a mechanism it already handles.
- Nothing disconnects. No round ends. No simulator is asked to do anything.
A bay left running for a month reaches epoch 30 and every number in it is still small.
3.4 What actually ends a session#
A session ends when:
- an operator or the bay closes it explicitly, or
- every participant has been disconnected past the reconnect window.
Idleness alone does not end a session. An idle timeout would close the session of exactly the person this design is meant to serve — someone who left the sim running and came back in the morning. If the participants are still connected, the session is still open. It is merely quiet, and quiet is not a fault.
3.5 Sessions, rounds, and resumption#
A session is a connected period at a bay. A round is the golf. A round may span several sessions, joined by round_id, with wall clock ordering across them.
That path is only available when the simulator can actually restore a round in progress. It frequently cannot, so it is a declared capability:
"features": { "round_resume": false }An engine MUST NOT promise resumption a simulator cannot deliver. Where round_resume is false:
- the resume window is effectively zero
- a round interrupted by a genuine session end is recorded as
partial_recorded, not held open pretending it can be continued - the operator is told this before the round starts, not after it is lost
Where round_resume is true, the resume window applies — default 15 days, configurable per facility and per event.
Round duration is measured in wall-clock dates and has no counter behind it, so a long window costs nothing.
3.6 Does it get too big?#
| bound | value in ms | note |
|---|---|---|
| a round of golf, 5 hours | 18,000,000 | |
| epoch rollover, 24 hours | 86,400,000 | 4% of int32 |
| signed 32-bit maximum | 2,147,483,647 | ~24.9 days |
| exact integer limit in JSON | 9,007,199,254,740,992 | ~285,000 years |
With rollover, t_ms is bounded by construction no matter how long a session runs, so a constrained device can hold it in an int32 and still have room to do arithmetic on it. session_epoch is a small integer that only ever increments.
Two honest notes on this. First, JSON was never the constraint — it is doubles, exact to 2^53. Second, the engine assigns t_ms and always has 64-bit arithmetic available; devices mostly receive the value rather than accumulate it, so the earlier int32 argument was stronger than the facts warranted. Rollover is worth having anyway, because it makes the bound a property of the design rather than a property of how long someone happened to leave a machine switched on — but it is cheap insurance, not a rescue.
3.7 Ending a round#
An engine can end a round, and sometimes must. It goes to every participant:
{ "type": "request.end_round",
"round_id": "sga:round:01J9X4...",
"reason": "event_deadline",
"grace_ms": 0,
"disposition": "partial_recorded",
"at": "2026-10-04T01:00:00Z" }| reason | when |
|---|---|
completed | the golf finished normally |
event_deadline | a tournament's closing time passed |
resume_window_expired | a resumable round was never picked back up |
operator / official | a human ended it |
facility_closing | |
device_failure | |
player_request |
There is deliberately no clock-hygiene reason in that list. Epoch rollover handles long sessions and interrupts nothing.
disposition is complete, partial_recorded, abandoned or disqualified. partial_recorded is the normal outcome for an unfinished round and is not a failure state — partial rounds are first-class records, per-hole completeness is already recorded, and whether one posts is a query rather than an ingest decision.
A warning precedes the directive. The engine emits notice.round_ending with the time remaining before any deadline it knows about. Nobody should be cut off mid-swing, and for a tournament this is the difference between a player choosing to hurry and a player losing a hole they were standing in.
grace_ms permits finishing the hole in progress where the rules allow it; zero for a hard deadline.
3a. Language#
US English, in prose and in identifiers alike.
Worth writing down because the alternative is not "either is fine" -- it is a catalog that spells a field one way and a document that spells it the other, and a vendor who cannot tell which is normative.
Including unit names: meter, not metre. The international spelling is BIPM's; the US one is NIST's, and SGA is a US association writing for a predominantly US market. The SYMBOLS are unaffected either way -- m, mm, m_s are the same in both, and it is the symbols that appear in field names.
Where a term comes from an external standard, that standard's spelling wins: RFC 8785 is the JSON Canonicalization Scheme, and quoting it any other way would be quoting it wrongly.
3b. Canonical form#
Everything hashed or signed is canonicalized with RFC 8785 (JCS) first.
The problem is invisible until a second implementation exists, and then it is maddening. The same object serializes many ways — key order, whitespace, number formatting, which characters an encoder chooses to escape — so hashing raw bytes makes two implementations that agree completely about content disagree about every hash. And they disagree silently: a signature that obviously ought to verify simply does not, with nothing to point at.
Three details that catch implementers, all of them from RFC 8785 §3.2:
- Numbers follow ECMAScript
Number::toString.1e3is1000;1e21is1e+21;1e-7is1e-7but1e-6is0.000001. Most languages' default float formatting differs somewhere in that range. - Strings use minimal escaping. Only
",\and control characters. Note that Go'sencoding/jsonescapes<,>and&by default and Python's escapes non-ASCII by default; both change the bytes and neither is wrong for its own purposes. - Object keys sort by UTF-16 code unit, not by UTF-8 byte. The two orders agree for ASCII — which is every key in this specification — and disagree above U+FFFF, which vendor
x-extensions are free-form enough to reach.
Arrays keep their order. They are data, not form.
4. Identifiers#
Our SGA-<TYPE>-<n> scheme, tightened so identifiers can be minted offline and never collide.
sga:facility:0482 # short, human-facing, on a placard sga:bay:0482-07 # short, human-facing sga:device:9f2c1a4e-... # UUIDv7, minted at manufacture or first pairing sga:session:01J9X4... # UUIDv7 — time-ordered, sorts chronologically sga:shot:01J9X4...
- UUIDv7 for anything generated at runtime. Time-ordered, so it indexes and sorts well, and mintable offline with no coordination — which matters because a bay with no internet must still start sessions and record shots.
- Human-facing identifiers (the number on the bay placard) stay short and readable. Machine identifiers are UUIDs. They are different things and the standard keeps them separate.
- Identifiers are opaque. A consumer MUST NOT parse structure out of one.
sga:player:...is deliberately absent from the bay. See §6.
5. Versioning#
Three version numbers, versioned independently. Conflating them is a mistake that is very hard to undo later.
| version | example | changes when |
|---|---|---|
| Protocol | 1.0 | Framing, handshake, or transport changes. Rare. |
| Bundle | 2026.09.1 | The field catalog changes — new fields, new device classes. Frequent. See 04-bundle-distribution. |
| Implementation | 0.3.1 | An engine or adapter build. Irrelevant to the wire. |
Backward compatibility is absolute. A core field, once ratified, is never removed and never redefined. Deprecation marks a field as discouraged; it does not stop it being emitted or understood.
Unknown fields are skipped, never rejected. A v1.0 consumer MUST parse a v3.0 payload successfully, using the fields it recognizes and ignoring the rest. This one property is what makes it safe for a facility to run a five-year-old display alongside a monitor that shipped last week — which is the actual condition of every commercial bay in the country, and the reason bespoke connectors keep breaking.
5a. The core catalog and the vendor namespace#
There are two namespaces: the ratified core catalog, and x-<vendor>. for everything else. One rule keeps them from becoming two standards.
Where a ratified core field covers a capability, an implementation MUST emit the core field. The vendor namespace is for what the core does not yet describe. It is not an alternative spelling for what it already does.
Emitting a richer or differently-conditioned version alongside the core field is always allowed and often welcome — x-acme.ball.spin_tilt_deg beside ball.spin_rpm costs a consumer nothing and tells it more. What is not allowed is emitting only the private spelling for a capability the catalog already names.
Why this has to be normative. Without it, an implementation could satisfy every other letter of this specification and still be unreadable by every other participant: the minimum core needed to operate, and everything of value in a namespace only its author understands. That is precisely the fragmentation the standard exists to prevent, achieved through the standard rather than around it. Note how it interacts with the rule immediately above — a consumer skipping an unknown x- field is a safety property, and it turns into a liability the moment the unknown field is where all the information went.
x- is the right choice for a genuinely new sensor class, for a vendor diagnostic the core has no business describing, and for a field on its way through Tier 2. It is the wrong choice for ball speed.
If the core field does not fit, say so. Wrong unit, wrong semantics, insufficient resolution, a sign convention that does not match the hardware — those are real, they happen, and they are a Tier 2 proposal. The committee wants to hear it, and a shipping implementation is already most of what a proposal needs. Quietly emitting your own version instead is how a gap in the specification becomes permanent, because nobody is ever told it exists.
5b. Naming a field#
Two rules, and between them they answer most of what a vendor would otherwise have to ask.
A field is named for its SUBJECT, never its SOURCE#
ball.speed_mps is about the ball. round.roster is about the round. Nothing in a field name says which device produced it, because provenance already carries that, per contribution, and a name cannot.
This is not a style preference. The connection matrix resolves per field across every device that offers it — the lie surface from whichever declared measured, the club from whichever identified it better (03 §2.4). Two candidates can only be compared if they share a field name. Encode the source in the name and the comparison becomes impossible: the simulator's club.club_id and a bag's would be different fields, and the rule that ranks them would have nothing to rank.
The set of fields with competing providers is also not knowable in advance. Today only a simulator can say what the ball is lying on. The day somebody ships a sensing mat, a source-named field has to be renamed — a breaking change to a published standard — where a subject-named one simply gains a second provider.
The one thing that IS named for a participant is a statement a participant makes about itself, where the participant is genuinely the subject: status.sim_ready is not the simulator's opinion about the bay, it is the simulator's report on the simulator.
One field or two#
When two participants both have an answer, the question is whether merging them would lose something. It usually would, and there is a structural test that settles it without judgment:
If either participant might want the other's answer, they are two fields.
An engine never echoes a field to its own producer (03 §3). So a single field can carry one participant's answer to the other, but never back. If the exchange needs to run both ways, one field cannot do it — no matter how similar the two values look.
That is why status.sim_ready and status.lm_ready are separate, and why player.sim_handedness and player.lm_handedness are (03 §3.4e, §3.4f). It is also why environment and ambient are separate blocks: a wind value that is a game setting and a wind value that is the weather are not the same fact, however identical the numbers.
The corollary saves work. Two fields need no contention machinery — no confidence score, no disagreement flag, no precedence table. Both values are published, both are journaled, and a reader that cares compares them. A flag asserting that two visible values differ is one more thing that can be wrong about something anyone can check.
6. What the bay is not allowed to know#
No member identity crosses the bay-local protocol. The bay holds a label, never an account.
Shots bind to a slot — a position in the round, slot:1 through slot:n. Slots bind to SGA member accounts later, in the cloud, when a player claims the slot. The engine holds a short-lived opaque claim token and nothing else: no email, no member ID, no player rating, no account of any kind.
One exception, and it is narrow on purpose: a round-scoped stroke allowance. Rated match play cannot work if the simulator does not know how many strokes each side gives. What it needs to know is not a player rating — it is "slot 2 receives five strokes on this course, from these tees, in this match". That distinction is the whole of the exception:
| a player rating | a stroke allowance |
|---|---|
| an attribute of an account | a property of one round |
| stable for months | expires with the round |
| meaningful anywhere | meaningless off this course and tee |
| identifies a golfer over time | a small integer against a slot number |
So the index stays in the cloud, where it is computed and where it belongs. The allowance — a count of strokes and the holes they fall on — may reach the bay, because a match cannot be scored without it and because it carries none of the properties that made the index dangerous to put on a bay machine. See 03 §3.4b.
Be honest about the residue, as everywhere else in this rule: strokes received is correlated with ability, and a five-stroke allowance against a name and a timestamp narrows who somebody is. It is a smaller leak than display_label already is, not the absence of one.
What the bay does hold is display_label — the screen name a player is playing under. It has to: a launch monitor showing whose turn it is on its own display, a HUD projecting the current player, and a phone at check-in offering "which of these are you" all need it, and none of them can work from a slot number alone.
The distinction that matters is label versus identity. "Sam" is what somebody typed. It is not a member, it collides with every other Sam, and nothing at the bay may treat it as an account. The slot is the thing that binds to a person, and that binding happens in the cloud where it can be checked.
Be honest about what this buys, because it is less than "anonymous": a typed name plus a facility, a bay and a timestamp identifies somebody against a booking record without much effort. display_label is therefore the first field stripped when the claim window closes.
Three reasons, in increasing order of what they cost if ignored:
- A bay machine in a commercial facility is a shared, physically accessible, frequently unpatched Windows box. It is the worst place in the system to put PII and it is outside our control.
- Screen names are not identity. Players type "Sam", "DL", "guest". They collide constantly. Treating a typed label as a person is how a stroke lands on the wrong member's player rating.
- A player may claim a slot mid-round or after it ends, and the whole round MUST still attach. Designing for late claim from the start means no data is lost when someone forgets to sign in — the common case, not the edge case.
This is a permanent constraint of the standard.
This rule governs the bay, not the cloud. It guarantees that a bay machine holds nothing identifying. It does not by itself make an uploaded round anonymous: a typed screen name plus a facility, bay and timestamp identifies a person against a booking record, and swing kinematics are individuating. What happens to a round nobody ever claims is a policy question with legal and brand consequences, and the association has not settled it. Nothing in this standard depends on the answer: the bay records the round either way, and what becomes of an unclaimed one is decided where the record is held, not where it was made.
7. What the bay is not allowed to decide#
§6 bounds what reaches a bay. This one bounds what a bay may conclude from it, and the two are the same argument pointed in different directions.
The engine is an instrument. It records what happened and it does not weigh it.
An engine composes shot records, observes proximity, notes how a device was admitted, and marks which values arrived through whose hands. What it never does is decide what any of that is worth. It does not score a round's credibility, threshold a signal into a judgment, suppress a value it finds doubtful, or raise a flag saying somebody should be looked at. It writes down what it saw, including the parts that are unflattering or ambiguous, and hands the whole of it onward.
Evaluation belongs to the cloud, to the competition ruleset (08), and to a human officiator.
Why the line is here and not somewhere more convenient#
Three reasons, and the third is the one that decides it.
Thresholds are policy, and policy revises. What counts as insufficient evidence of presence, or as equipment too member-controlled to trust, is a question a governing body answers and re-answers as it learns. An engine that has an opinion has compiled that opinion into software, and software at a facility we do not own is the least revisable surface in the entire system. Fixing a bad threshold would mean shipping a release to every bay in the world.
An engine cannot see enough to judge. It knows one round in one bay. It does not know the stakes, the entrants, what the same member's other rounds look like, or that this signal has been misfiring on this hardware all month. Judgment made on a fraction of the evidence is worse than judgment deferred.
Recording and evaluating are different jobs, and fusing them destroys the record. Once an engine drops, downgrades or annotates on its own assessment, a consumer can no longer distinguish what happened from what the engine thought of it — and cannot recover the original, because the engine is where the original was. An adjudicator overruling a bad call needs the evidence the call was made on, and by then it is gone.
The shape this takes everywhere#
| the engine records | it does not record |
|---|---|
| the RSSI it measured (07 §2.2) | a distance, or a verdict about attendance |
| that a window of scanning heard nothing (07 §2.2) | that the member left |
| how a device was admitted, and whether a phone composed its values (13 §4.1) | that a member-owned sensor is worth less |
| that a field was unmet, or a window never closed (06 §9) | that the round should not count |
Rules#
- An engine MUST record what it observed, including observations that are incomplete, ambiguous, or unfavorable to a participant.
- An engine MUST NOT score, rank, threshold or flag on its observations, and MUST NOT withhold or downgrade a value on the strength of its own assessment of that value's credibility.
- Where an engine is required to classify — provenance (01), identification method (06 §5), completeness — it classifies what kind of claim it is, never how good the claim is. Those are different questions and only the first is answerable at a bay.
- A consumer MUST NOT treat any single recorded signal as dispositive.
- Refusing a device that is dangerous to other participants (13 §2) is not an exception to this. That is a safety interlock operating on a published rule, not an engine forming a view, and its refusals are coded and journaled precisely so a higher layer can review them.
The one thing this costs#
An engine that will not flag anything means nothing is flagged at the bay, which is where somebody might have caught it while the round was still happening. That is a real loss and it is accepted deliberately: a bay that accuses a player mid-round, on partial evidence, using a threshold nobody can revise, does far more damage than a review that happens a day later with the whole record in front of it.