SGA‑OISOpen Interoperability Standard
1.0 Draft
SGA-OIS/Specification

Capability handshake and the connection matrix

Normative.

A device joins. Nobody knows what it is. Ten seconds later every participant in the bay knows exactly what it can provide, what it wants, who is feeding it, and what happens if it leaves.

That negotiation is the core of SGA-OIS. Everything else is payload.


1. Roles#

A participant declares one or more roles. Roles are additive; a device that both measures and displays declares both.

roleprovidestypical
sensor.ballball flightlaunch monitor
sensor.clubclub delivery — path, face, attack anglecamera array, some monitors
sensor.club_idwhich club is in the player's handsRFID reader in the playfield, a separate camera, a bag sensor, or a monitor that also does this
sensor.swingbody/swing kinematicsswing camera, wearable, pressure mat
sensor.playerwho is at the bayproximity beacon, the member's phone
sensor.environmentambient temperature, pressure, humidity, illuminance, and outdoor windweather module, thermometer, the phone in an outdoor round
simulatorcourse, hole, lie, wind, resultsimulator software, course-play application
actuatorresponds to shot statelie platform, turf changer, wind machine
displayconsumes for presentationHUD, projector, spectator board
recorderconsumes for storagelocal journal, cloud sync
officiatorconsumes for integritytournament monitor
couriercarries the journal when the bay's own link is downa member's phone

Roles determine defaults, not permissions. What a participant may assert comes from the connection matrix (§2.2); what it may ask for comes from the request table (§1.1).

There is deliberately no operator role. Operator authority is real and it does not arrive over this protocol: it arrives by relay from the cloud, which authorized it against the bay's claim before enqueuing anything (05 §4b.3). Nothing connecting to a bay can claim it.

That is a security decision and it is worth stating why, because the obvious design is the other one. A locally-authenticated operator needs a local credential, and a local credential would be the first thing at a bay worth stealing — on a machine in a building the association does not control, and unrevocable at exactly the moment revocation matters, since the thing that would revoke it is the network that is down. The standard is otherwise consistent that a bay holds no credential at all: ingest is unauthenticated by design, and an installation id is an identifier and never a secret.

The case such a credential would exist for argues against it. If a bay's link is down, the front desk's browser cannot reach the cloud either — so the fallback is not a local console, it is the twenty meters to the bay, and the machine in the room remains the final authority on what the machine in the room does.

sensor.environment is the thinnest role here, and worth being honest about. Simulated weather is a setting, not a measurement, and it arrives from the simulator role. The environment role exists for three narrower cases: outdoor and hybrid play, where conditions are real; a launch monitor that needs to know which air its flight model should assume; and ambient temperature indoors, which is a genuine competition-fairness variable because a cold ball is measurably slower and a cold garage is not the same contest as a heated facility. Nothing should be built for it early.

sensor.club_id is deliberately separate from sensor.club. Measuring how a club was delivered and knowing which club it was are different jobs, and nothing says one device has to do both. An RFID reader in the playfield, a camera mounted somewhere else entirely, a bag sensor, or the simulator's own UI may each be the best available source — and the best source may not be the one producing the shot data. A device declaring only sensor.club_id and providing only club.club_id is a complete, conformant participant.


1.1 Requests, and who may make them#

Almost everything in a bay is a participant asserting something — a ball speed, a lie, a roster. A few things are a participant asking for something to happen: end this round, seat this player, produce a card. Those are requests, they are the only messages that ask a bay to change or produce rather than to record, and they all live in one namespace so that the complete list is one thing to read.

requestasks forwho may ask
request.end_roundthe round in progress ends, with or without releasing the playersoperator
request.add_playera player joins the round — name, tee and check-in reference in any combinationoperator, engine
request.remove_playera player leaves the roundoperator, engine
request.scorecarda card is produced, for a stated reasonoperator, engine
request.apply_rulesa signed settings package is put in forceengine
request.apply_rules_ack— (the answer to the above)the simulator it was asked of

request.apply_rules is the one whose authority is not in this table. Every other request is permitted because the asker holds a role. This one is permitted because the PACKAGE is signed by SGA. The engine MUST verify that signature, against the pinned SGA root key it verifies bundles with, before it sends the request, and MUST NOT deliver a package that is unsigned, does not verify, has expired, or names an event or bundle version other than the one running (08 §1.1). The signature is the whole of the authority the request carries. The engine appears in the table because something has to deliver it, not because delivering it confers anything. A simulator accepts the request only from the SGA Engine, and MAY verify the signature itself; it is not required to.

Authorization is this table, not the name. A request is permitted because the table says the asker's role may make it, and the table ships in the bundle (04 §2) alongside the capability-to-role mappings the connection matrix already uses. It can therefore be corrected in a bundle rather than in a protocol revision — which matters, because who may ask for something is exactly the kind of rule that turns out to need adjusting after meeting a real facility.

  • An engine MUST refuse a request whose asker does not hold a role this table permits, and MUST journal the refusal. A refusal is not an error.
  • A recipient MAY refuse a request the engine permitted. The machine in the room is the final authority on what the machine in the room does (05 §4c).
  • Every request MUST carry who it is being made for — the role, and where a person authorized it, an opaque operator_ref. Not an account: no member identity crosses the bay-local protocol (00 §6), and that rule does not bend for staff. The reference means "the person who was signed in at the desk when this was asked", the bay records it and ships it, and only SGA can say who that was — the same shape and the same rule as checkin_ref. A simulator receiving request.end_round can then answer on whose authority from the message itself, rather than from the fact that it arrived.
  • That field MUST be written by the engine and MUST NOT be taken from the asker. A request that arrived with it already filled in is a participant asserting its own authority, which is worth nothing — the field is only worth having because the thing that wrote it is the thing that knows. An engine MUST replace whatever a caller supplied rather than pass it on.
  • A request MUST NOT carry free-form arguments. Each has a declared shape, and a field nobody specified is a field nobody validated.
  • Every request MUST be journaled — what was asked, by whom, on whose behalf, and what became of it. Both outcomes: a request that was honored and one that was refused are equally worth being able to point at, and a refusal that left no trace is indistinguishable afterwards from a request nobody made. A round that ended because somebody at a desk pressed a button must say so, or it looks like the round simply stopped.
  • A request MUST carry a lifetime and MUST be discarded once it has passed. A bay that was offline when somebody pressed a button comes back to an instruction about a situation that has moved on, and request.remove_player is the one where that is worst: the slot it names may since have been taken by a different person, and removing them is an act nobody asked for. Two minutes for anything that changes the roster, longer only where the passage of time cannot change the meaning. An expired request is dropped silently and is not a refusal — nobody is left waiting on an answer, because the person who asked has long since walked away.
  • A request MUST travel over the protocol. There is no side channel and no local back door: a bay that could be asked things another way would have a second authority that nothing validates, nothing journals, and nobody can audit. Anything that can ask a bay to change is a participant, holds a role, and is subject to this table.

Why the engine may ask for three of the four#

The engine is not a participant and holds no role. It is listed here anyway, because three of these are things it does in the course of its ordinary work:

  • request.add_player is how a member who scanned in gets seated. The check-in tells the engine somebody is here (§3.4a); seating them is asking the simulator to add a player, and it is the same ask an operator makes from a desk. One mechanism, two askers — a second path for the engine's version would be a second thing to get wrong.
  • request.remove_player is the same fact running out: a check-in that lapses or is ended leaves somebody in a round they are no longer at.
  • request.scorecard is reconciliation. The engine asks for cards on its own cadence because the card and the shot stream are produced by different logic and disagreement is the signal (06 §9.1).

request.end_round is the one it may not make. Ending a round is a decision about somebody's afternoon, and the engine has no standing to take it. The engine records that a round ended — including the case where its session ends with one still open (06 §9.6) — but recording that something stopped and asking that it stop are different acts, and only the second needs authority.

request.add_player takes whatever the caller knows#

Name, tee and check-in reference, in any combination, and at least one. They answer different questions and a caller rarely has all three:

the caller hasthe case
a namea front desk setting a group up before they have walked over
a check-in referencea member scanned in; their screen name comes from their account, not from anything typed at a desk
a teean operator who knows the group is playing the whites, said once for everybody

There is deliberately no slot. The bay assigns positions and nothing else may: a caller naming one is guessing at a roster it cannot see, and that guess has already gone wrong — a scan that took slot 1 on a bay where nobody had been added seated a member in a position the simulator had never heard of, and they appeared nowhere.

There is no local operator#

A participant cannot declare itself an operator and no device can be given the authority by connecting. A front desk that wants to end a round talks to the cloud, which authorizes against the claim and relays — the same path an operator's phone takes, and the same one that produces the audit trail. One authorization path, one place revocation works, and nothing at the bay to steal.


1.2 What kind of thing each message is#

Every message name says what kind of message it is. There are five kinds, and nothing sits outside them — there is no top-level namespace to accumulate odds and ends, which is what it had begun to do.

Knowing which kind you are holding answers most of what you would otherwise have to look up: who may send it, whether refusing it means anything, and whether it belongs in the record. A reader who has never seen update.refused before knows three things about it from the name alone.

kindmessageswhat it is
Sessionsession.hello session.describe session.manifest session.denied session.heartbeat session.going_awayestablishing and maintaining the connection itself
Recordrecord.shot record.swing_detected record.state record.scorecard record.coursewhat happened, or what is the case
Requestrequest.scorecard request.add_player request.remove_player request.end_round request.apply_rules request.apply_rules_ack request.shot_decline request.shot_extendasking a participant to do something, and the answer to it
Noticenotice.post notice.response notice.round_endingaddressed to a person, not to software
Transferupdate.fetch update.begin update.data update.end update.refusedthe bulk path, deliberately separate from the live path

request.shot_decline and request.shot_extend are in that list for a reason worth spelling out: both are addressed to the engine's correlation window rather than being statements about the round. "Hold the window open, I am still measuring" and "stop waiting for me" are both instructions about what the engine should do next, and an engine may decline either — the window has a hard ceiling (06 §10).

The test for request. is whether refusing it is a meaningful answer. A request is an imperative addressed to somebody who might reasonably say no, so it carries asked_by, carries a lifetime, and is governed by the permission table in §1.1.

A request's acknowledgment lives in the namespace with it, named request.<name>_ack and correlated by request_id. The namespace holds the request machinery — the asking and the disposition — not imperatives alone. An ack may carry a great deal of detail about what the recipient did without becoming a Record: what separates them is that an ack answers a specific request_id, while a record stands alone as a statement about the round.

Most requests need no ack, and MUST NOT have one. An ack is required only where a request's effect is not otherwise visible on the wire. request.add_player changes the roster; request.scorecard produces a card; request.end_round produces a round_ended record. Each of those answers itself, and an ack would only restate it — a second thing to keep in agreement with the first.

request.apply_rules is the exception, and the reason the convention exists. Applying rules answers nothing on its own: a simulator that never received the package, one that refused it, one still loading a course and one that quietly applied half of it all look identical from outside. In a sanctioned event that is the difference between a result that stands and one that cannot be defended.

A record is not an acknowledgement, however closely it follows a request. record.shot follows record.swing_detected without being an ack of it. record.scorecard may follow request.scorecard, but most cards are unprompted: a checkpoint at every hole, and the final card that ends the round. Naming any of these *_ack would describe the rarer case and misdescribe the common one.

The two kinds are also governed differently, which is the practical reason to keep them apart. A request is permissioned by who may ask (§1.1, a table shipped in the bundle). A record is permissioned by who may assert (§2.0b, the sender's own declared provides and roles). Same word in English, two different mechanisms.

An ack reports; it does not grade. request.apply_rules_ack carries the value actually in force for every key in the package, and no verdict. The engine compares that against what was required and signs the conformance vector into the journal (08 §2) — a participant grading its own compliance would be doing the one job this standard keeps away from the thing being measured.

2. The exchange#

device                               SGA Engine
  │                                       │
  │◄──────── session.hello ───────────────│   protocol version, bundle version,
  │                                       │   engine identity, session nonce
  │                                       │
  │───────── session.describe ───────────►│   who I am, what I provide,
  │                                       │   what I want, signed
  │                                       │
  │◄──────── session.manifest ────────────│   the connection matrix:
  │                                       │   your sources, your subscribers
  │                                       │
  │◄════════ data flows ═════════════════►│
  │                                       │
  │◄──────── session.manifest v2 ─────────│   topology changed, here is the new one

2.0 session.describe comes first#

A participant's first frame MUST be session.describe. The engine speaks first with session.hello; the next frame in the other direction identifies the sender, and until it arrives the connection is anonymous.

An engine MUST refuse anything else sent before session.describe with describe_required and remedy describe_first, and MUST then close the connection. It MUST do the same for a frame that does not decode at all from a participant that has not yet described.

This is the one refusal that does not keep the connection open. Elsewhere in this standard a refused device stays connected, because the remedies that follow a refusal — fetch new firmware, report a serial, try a different role — are things it can go and do. All of them presuppose an engine that knows who it is talking to. Anonymity is the one condition a device cannot be helped out of while it persists.

The rule is not a restriction on what a device may say. An engine cannot route, attribute or record a frame from something it cannot name, so such a frame has nowhere to go even when it is perfectly well formed. Refusing it says only that the connection is not yet in a state where anything it sends could mean something.

An admitted participant is treated differently: an engine SHOULD tolerate an occasional undecodable frame from a device that has described, rather than disconnect over it and lose the good frames around it. It MUST bound that tolerance, since a peer that never recovers otherwise holds a connection for as long as it likes.

2.0b A participant may only say what it declared#

An engine MUST refuse a value on a field the sender did not declare in provides, with not_your_field and remedy declare_field. A refused frame is neither forwarded nor journaled.

This is not adjudication, and 00 §7 stands: nothing here weighs whether a value is true. It asks only whether the sender is speaking inside the declaration it made at handshake. A device that says it measures ball speed and then reports ball speed is believed however wrong the number is — the connection matrix and the attestation tiers decide whose number wins. A device that declared it provides nothing and then reports ball speed is not making a claim the engine could be wrong about; it is contradicting its own descriptor, and there is no reading of the record under which that contribution means anything.

The descriptor is the right thing to check against because it is stated once, before admission, hashed into the manifest, and visible to every consumer of the journal. Enforcing it introduces no second source of truth about what a device does.

Some fields are the engine's own. bay.checkin_code, bay.claim_code, bay.checked_in and round.previous_stroke are published by the engine, and an engine MUST refuse them from any participant whatever that participant declared — a device is free to write anything in its own descriptor, so comparing the two is not enough on its own. The check-in code is the sharp case: subscribers render it as a QR, so a device able to publish one could put its own code on the bay screen.

A scorecard is a lifecycle act, not merely a record. A card with reason final_score ends the round and retires the check-in code that let people into it (06 §9.6). An engine MUST therefore accept scorecards and course documents only from a participant holding simulator or officiator; anything else could end a stranger's round from across the bay network.

A declared field that is never sent is recorded, not refused. When a round ends, the engine MUST journal a declared_unsent record for each participant that took part in it, listing every field the participant declared in provides at any point during the round and never sent a value for:

JSON
{ "sga_kind": "declared_unsent", "round_ref": "r2",
  "device_id": "sga:device:ax3-00119423",
  "fields": ["club.face_angle_deg", "status.lm_ready"] }

A participant that sent everything it declared gets no record. This is a report, not a fault: a field may depend on something the round never had — a tagged ball, a particular club, a putt — so an engine MUST NOT refuse, flag, notify anyone or change the manifest because of it. Its consumer is SGA, which reads it across many rounds: a field that a model declares and never sends in any of them means the descriptor and the device disagree, and that is a conversation with the manufacturer, not a judgment about a round.

2.0c Restating a descriptor#

A participant MAY send session.describe again on a live connection when what it provides genuinely changes — a fusion device whose club sensor has failed provides less than it did a minute ago, and making it drop the connection to say so would lose the shots in flight. This is not in tension with "exchanged once, at handshake, and never again" in §2.1, which forbids repeating the descriptor per shot, not correcting it when the facts change.

The second descriptor is expected, and MUST NOT be rate limited. A device need not declare everything it can do at the moment it connects: a manufacturer may describe conservatively, read the manifest to see whether a peer meeting its requirements is present, and only then restate itself with the proprietary interactions that peer makes possible — in its private namespace or the public one. A license key presented over the standard can activate features the same way, in a conversation spanning two devices. Both are one prompt follow-up descriptor, and a limit starting at the first re-describe would punish exactly the pattern the standard wants to enable.

From the third descriptor an engine MUST rate limit. One descriptor renegotiates the matrix, journals a manifest and delivers that manifest to every participant, so a device repeating it turns one frame it sends into a broadcast the whole bay pays for. Nothing legitimate needs a third without pausing; four seconds is the reference engine's floor, and a refusal is a pause, not a ban.

2.0d One device id, one device#

Two connections claiming one device id is either the same unit reconnecting or two units misconfigured with the same id. A device id is an identifier, never a credential, so the question is settled by whatever evidence of sameness the engine holds. There are two kinds, and they are not equally good:

  • The serial is declared by the device. It is what a vendor means by "which unit is this", and it survives the device moving to another network. It is also self-asserted, so anything that can read one can claim one.
  • The peer address is observed by the engine, on the transport — an IP for the TCP binding, a device address for BLE. Nothing the device says affects it, which makes it harder to forge than the serial and worse at identifying a unit, since it says where something is rather than what it is. Every connection has one.

The second matters because the cheap devices are the ones most likely to omit a serial, and they still arrive over a transport that knows where they came from. Requiring a serial would leave exactly the class least able to comply unable to recover a half-open connection.

serialspeer addressoutcome
both present, equal—newcomer wins; incumbent told superseded and closed
both present, different—newcomer refused device_id_in_use, remedy change_device_id
not both presentsamenewcomer wins; incumbent told superseded and closed
not both presentdifferentnewcomer refused serial_required, remedy report_serial

An engine MUST compare the peer host, not host and port: the port is fresh on every reconnection, and comparing it would make every returning device look new.

The same unit reconnecting wins because the incumbent is typically a half-open socket neither end has noticed, and there is no oscillation risk: there is only one device, so nothing is left to reconnect and take the id back. Two different units do not displace each other, for the same reason singleton roles keep their incumbent (§2.4) — they would simply alternate forever. Multiple launch monitors in one bay are expressly allowed; two of them answering to one name are not, because every record either produced would name the same participant.

Where neither serials nor addresses match, the engine cannot tell a device that has moved from a second device wearing its id, and the costs are asymmetric: assuming "same device" lets anything that learns a device id evict that device by claiming to be it — from anywhere on the network, which is precisely the position an attacker is in and a reconnecting device usually is not. Assuming "different device" costs a retry. The incumbent keeps it, and the newcomer is told to report a serial: the one piece of evidence that would settle it.

An engine MUST NOT let a superseded connection's later departure remove the participant that replaced it.

2.0e Bounds#

An engine MUST bound what one participant can make it hold or do. The reference engine's values, each set by taking the largest a real device could plausibly need and doubling it:

boundvalue
concurrent connections25 — over it, a newcomer is refused bay_full, remedy retry_later
silence before session.describe10 s
provides / wants entries512 each
roles32
field-name length128
device id length256
vendor / model / serial / firmware length128

For scale: a full bay is around six participants and the longest field name in this standard is 25 characters. The provides ceiling is an order of magnitude above anything the public catalog can justify, deliberately — a vendor's private namespace is theirs to fill, and a bound set from what this standard can currently name would be a bound on somebody else's product. A device exceeding one is told which, with descriptor_too_large and remedy reduce_descriptor, so a genuine case can be argued rather than guessed at.

2.0f Which engine a device belongs to#

§5 makes physical presence the pairing authority: a device joining a bay presents a token obtained from that bay, which is what stops it joining the bay next door. That works for anything with a screen or a camera. It does not work for fixed bay infrastructure — a ceiling camera, an environment sensor, a club rack (13 §4) — which has no screen, frequently no UI at all, and cannot be told which room it is in.

At a multi-bay facility such a device discovers several engines and cannot tell them apart. It connects to one and asks, by describing itself. There is no new message: the answer is a session.denied frame with a new code, so a device that already parses refusals needs nothing it does not have.

An engine answers from the facility configuration — which bay each enrolled device belongs to, and which other engines are at the site. Every engine holds a copy.

the engine's configuration saysoutcome
no facility, or no bay id yetadmitted
this device belongs to this bayadmitted
this device belongs to another bayrefused associated_elsewhere, remedy connect_to_bay, with redirect
no association, and this is the only engine at the siteadmitted
no association, and there are other engines hererefused ownership_unknown, remedy wait_for_association
two engines claim it and the claims cannot be orderedrefused ownership_disputed, remedy wait_for_association

An engine MUST NOT admit a device its configuration binds to another bay, and MUST NOT let such an attempt have any other effect: it does not supersede an incumbent holding the same device id, and it is not answered with a firmware refusal, because a bay that was never going to admit the device must not send its vendor looking for a build that would fix nothing.

A device MUST match on its device id, and SHOULD fall back to its serial when the device id is unknown. A unit that has been factory reset comes back with a new device id and the same serial, and an engine matching only on the id would call its own camera a stranger. An engine MUST NOT re-key the association on the strength of that match: a serial is self-asserted, and moving a binding is an operator's decision.

The two refusals that are not about the device#

ownership_unknown and ownership_disputed say nothing is wrong with the device. A vendor MUST NOT treat either as a fault to fix in firmware, and MUST NOT answer either by retrying faster.

Adoption is the reason the first exists. An engine that is the only one at its site may adopt an unknown device, because there is no other bay it could belong to. An engine with neighbors may not: it would be right most of the time, and the one time it was not it would have captured the device the bay next door was waiting for, with nobody looking for it.

A dispute is not resolved automatically. Two engines that each bound the device while the other was unreachable produce claims that cannot be ordered, and a rule picking one would choose by something irrelevant — which engine had been busier. The loser's operator would watch a device leave their bay with nothing to point at and no way to say when. So both engines answer "wait" and surface the disagreement to somebody who can settle it.

What a redirect carries, and what it deliberately does not#

JSON
{
  "type": "session.denied",
  "code": "associated_elsewhere",
  "remedy": "connect_to_bay",
  "retry_after_ms": 300000,
  "redirect": {
    "bay_id": "sga:bay:0482-12",
    "engine_id": "sga:device:engine-1.4.0",
    "instance": "sga-engine-4f2a9c",
    "alias": "Bay 12"
  },
  "detail": "this device is associated with bay sga:bay:0482-12"
}

bay_id is always present; the others are filled where the engine knows them.

It carries no address. An address in a redirect is stale in exactly the situation that produces redirects — a facility coming back up after a power cut with every lease renewed — and a device that followed one would have no way to tell it had been sent somewhere else. The device MUST rediscover, by whichever mechanism brought it to the wrong engine, and MUST confirm it arrived by comparing the new engine's session.hello engine_id against engine_id in the redirect where one was given. A device that arrives at a different engine has been misdirected and MUST NOT join.

This answer is not published in DNS, for three reasons: it would broadcast the whole device-to-bay map to anything on the network that asked, it would mean engines answering queries for names they do not own, and an unauthenticated multicast answer can carry neither authority nor a reason. Discovery finds engines. Which one is yours is a question for the engine.

The connection stays open after any of these three refusals, as it does after a firmware refusal. A redirected device is expected to close it and go, but an engine hanging up would strand a device that is about to be bound: an operator is frequently at the console at exactly this moment, and a device already connected is admitted on its next session.describe with nobody reconnecting anything.

2.1 session.describe — "what can you do"#

JSON
{
  "identity": {
    "vendor": "acme",
    "model": "AX-3",
    "serial": "AX3-00119423",
    "firmware": "4.2.1",
    "device_id": "sga:device:9f2c1a4e-...",
    "attestation": "<signature — see 02-identity-trust>"
  },
  "roles": ["sensor.ball", "sensor.club"],
  "protocol_version": "1.0",
  "built_against_bundle": "2026.09.1",
  "provides": [
    { "field": "ball.speed_mps", "provenance": "measured", "resolution": 0.05, "latency_ms": 140 },
    { "field": "ball.spin_rpm",  "provenance": "measured", "resolution": 50, "conditions": ["requires_ball_marker"] },
    { "field": "club.speed_mps", "provenance": "inferred", "model": { "id": "acme.smash", "version": "2.1" } }
  ],
  "wants": [
    { "field": "round.player_up",     "reason": "per-player calibration" },
    { "field": "origin.lie_surface",  "reason": "surface compensation" },
    { "field": "status.sim_ready",    "reason": "power management" }
  ],
  "transports": ["wss", "tcp"],
  "max_rate_hz": 200
}

A device does not have a bundle version. Only an engine loads bundles (04); a launch monitor emits field names its firmware was written with and has no catalog to load. Giving both the same field name would be the exact mistake this standard exists to prevent -- one name, two meanings.

So the device declares built_against_bundle instead: optional, a build-time constant, saying which generation of the vocabulary the vendor consulted. It is for diagnosis, and it answers one question the field list alone cannot -- whether a device predates a field or merely declined to provide it. That distinction matters when reading unmet wants across a fleet, and it lets us tell a vendor their firmware is two vocabularies behind. It gates nothing, and a device that does not track it omits it.

discovered_via says how the device found this engine — one of the values in §5. Only the device knows: a connection looks the same whichever mechanism produced the address. It is optional, and an engine MUST NOT refuse a device that omits it, because that would lose the device as well as the information. A conforming device SHOULD send it, because a bay where no device says is a bay nobody can diagnose. An engine MUST record it as given, including a value it does not recognize: a newer device reporting a mechanism this engine has never heard of is still telling the truth about the network.

Everything in session.describe is exchanged once, at handshake, and never again. It does not ride along with shots. Identity, provenance, resolution, latency and sensor attribution are properties of the device and its firmware, not of an individual swing, so they are stated once, signed once, and referenced by manifest hash thereafter. Re-sending them per shot would roughly double the live payload to repeat information that changes approximately never.

Two fields are optional and worth calling out because implementers ask:

  • sensor on a provided field — which of the device's modalities produced it. Never required. Advisory even when several sensors are declared, where the conformance kit warns (R042) rather than failing, because some vendors treat the mapping as architectural detail they would rather not publish. Encouraged because on a fusion device it is the only way the aggregate means anything.
  • entitlement — whether a field the hardware can produce is licensed on this unit. Defaults to enabled, so a vendor with no tiering ignores it entirely.

2.1b Asking for fields: wildcards, and how much you need them#

wants is not only a request list. It is the clearest statement anyone ever makes about what a product actually implements — and it is the only place that information exists. Nothing else in the protocol distinguishes a feature a vendor built from one they have not got to yet.

That is worth protecting, and it is why the two mechanisms below exist.

Wildcards#

A want may name a namespace instead of a field, by ending in .*:

JSON
"wants": [
  { "field": "origin.lie_surface" },
  { "field": "ball.*" }
]
  • Only a trailing .* is a wildcard. *.speed_mps and ball.*.rpm are not patterns, they are typos, and the schema rejects them. Matching is a prefix scan, so nothing on the wire requires a regular-expression engine to take part.
  • The engine expands it at handshake and publishes the expansion in the manifest, which keeps the manifest the single readable answer to who feeds what. No consumer ever performs matching itself, and a device joining later causes a re-expansion on the next manifest — which is the whole point of asking this way.
  • A core wildcard never reaches a vendor namespace. ball.* will not subscribe you to x-acme.ball.whatever; otherwise one vendor's private extension becomes a broadcast to everybody who typed a wildcard. Naming a vendor field or namespace is a deliberate act and is allowed.
  • No wildcard matches a restricted or entitlement-gated field. Those exist because somebody's license agreement governs them, and a wildcard must never be the way that gets bypassed.

Importance, which is normally derived rather than stated#

importanceset byunmet behavior
opportunistica wildcard matchnot reported as unmet at all
requestednaming a fieldreported as unmet, and counted as demand
requiredstating it, on a named fieldreported, and surfaced to the operator

You do not normally write this field. Name a field and it is requested; match it with a wildcard and it is opportunistic. State it only to raise a wildcard to requested, or to mark a named field required.

A wildcard MUST NOT be required. "I cannot operate without some unspecified set of fields" is not a claim anyone can act on.

Why opportunistic is excluded from unmet: an unmet want is diagnostic gold — it is how the ecosystem's real needs get counted without surveying anyone. A wildcard in a sparse bay would otherwise generate dozens of unmet entries nobody was waiting for, and bury the signal under noise. Speculative asks do not get to vote.

required is for what it says. It exists to make an operator's evening worse on purpose, when something genuinely will not work. A descriptor marking many wants required is advertising fragility, not importance.

Be specific in a shipped product#

ball.* is valid, works in production, and is genuinely useful while you are exploring a bay you have never seen. It is still the wrong thing to ship.

Naming the fields you consume says I implemented this. A wildcard cannot say that, and "everything" and "I did not say" are indistinguishable to anyone reading it afterwards. It also sends you data you have no code for, which costs radio time on a device that has little to spare — and the engine journals every field offered regardless (§3), so nothing is lost by not subscribing to it.

The conformance kit reports this as an informational notice with the expansion included, so converting a wildcard to the fields you actually parse is mechanical.

2.1a Coded or free text: which fields are which#

Two kinds of string appear in a descriptor and they are governed differently.

Free text where the point is to learn. wants[].reason is deliberately unconstrained. Nothing in the protocol branches on it, and a vocabulary can only contain what we already thought of -- which is the opposite of what the field is for. A vendor whose device wants a field for a reason nobody anticipated should be able to say so on the day, not after a ratification cycle. An optional reason_code sits alongside it purely so the aggregate can be counted rather than text-mined; send other plus text rather than forcing a fit.

A registered vocabulary where the standard makes a claim about it. conditions is the case, and it cannot be free strings. The spec says a device operating outside its declared conditions is operating outside its certification -- that is normative, and it cannot be checked if one vendor writes indoor_only and another writes requires_indoor. Certification has to be able to read it, so it is an enum, with conditions_note for free-text detail and the usual x- namespace for anything genuinely new.

The test: if something in the system acts on a string, it needs a vocabulary. If the string exists only to be read by a human or counted in aggregate, free text is better and costs nothing.

wants is the half that existing protocols do not have, and it is what makes this two-way rather than a data firehose. A launch monitor that knows the simulator has rotated to the next player can recalibrate for a left-handed golfer without anyone touching it. A monitor that knows the lie is sand can change its expectations. Today that requires a bespoke integration per pair; here it is a declared want, satisfied by whoever can satisfy it.

2.1c Own-vendor-only roles#

A product MAY accept a role only from its own vendor's devices: a simulator that works only with its maker's launch monitor, for instance. It says so in its descriptor:

JSON
{ "type": "session.describe",
  "identity": { "vendor": "acme", "model": "Fairway Pro" },
  "roles": ["simulator"],
  "own_vendor_only": ["sensor.ball"] }

While that participant is in the session:

  • A device from another vendor that holds a listed role is admitted and recorded as usual. Recording is total (section 3), and a device that connected and was ignored is itself something the record should show.
  • It is not a source for any field in the matrix. This applies to the whole bay, not only to the declaring product. A simulator that plays the shot from its own launch monitor's numbers would otherwise sit in a record composed from somebody else's, describing a shot the simulator never played.
  • The manifest lists it under excluded, with the role and the participant that excluded it, and any want left without a source as a result is unmet with reason vendor_exclusive. The excluded device therefore learns why its data is going nowhere, and an operator sees "this simulator uses its own launch monitor only" instead of a device that silently does nothing.

The engine compares identity.vendor, ignoring case. At A0 a vendor name is a claim, not a proof. A product that needs to be certain its partner really is its own reads the partner's attestation (02).

Absent or empty, own_vendor_only means every conforming device is accepted, which is the default and what most products declare. Two limits come from governance rather than the protocol, because the engine cannot check them: a product may list only roles in which its vendor ships a device of its own, and a product that accepts a role from any other vendor MUST accept it from every conforming device. There is no list of approved partners. See "Own equipment, or everyone's" in Governance.

2.2 session.manifest — the connection matrix#

The engine resolves every wants against every provides and publishes the result to all participants.

JSON
{
  "manifest_version": 2,
  "manifest_hash": "sha256:7a3f...",
  "session_id": "sga:session:01J9X4...",
  "bundle_version": "2026.09.1",
  "sources": {
    "round.player_up":    { "from": "sga:device:sim-1", "provenance": "calculated" },
    "origin.lie_surface": { "from": "sga:device:sim-1", "provenance": "calculated" }
  },
  "subscribers": {
    "ball.speed_mps": ["sga:device:sim-1", "sga:device:hud-1", "sga:device:recorder"]
  },
  "unmet": [
    { "field": "status.sim_ready", "reason": "no_provider" }
  ],
  "clock_skew_ms": { "sga:device:hud-1": 34 }
}

3.4 Who is playing, and who just played#

Three fields, because three different things need to know and none of them can work from a slot number alone.

fieldprovided byis
round.player_upthe simulatorwho is at the ball now: slot and label
round.rosterthe simulatorevery player in the round, label to slot
round.previous_strokethe enginewho took the last one
bay.checked_inthe enginewho has scanned in at this bay, before a round exists (§3.4a)
round.allowancesthe enginestrokes received per slot, for rated formats (§3.4b)
bay.checkin_codethe enginethe check-in code, for a device with a screen to render (§3.4c)
round.settingsthe simulatorwhat is in force: assists classified by what they do to the evidence, conditions, course (§3.4g)

round.player_up is requestable by anybody, and the uses are ordinary: a launch monitor putting the current player on its own display, a HUD projecting the name onto the screen, a statistics board naming whose shot just landed. A device asking for it gets a label and a slot together, because the label is for showing a human and the slot is for attributing data.

round.roster exists for two things nothing else can supply. A display naming the whole group is the obvious one. The one that matters more is check-in: a member scanning the bay's QR code has to pick which of the in-game names is them, and that list has to come from somewhere. The roster persists for the round, so a player who arrives late — or claims their slot after the round has finished — still has something to choose from.

Its mirror, running the other way and before a round exists, is bay.checked_in.

JSON
{ "round.roster": [
    { "slot": 1, "display_label": "Andrea", "checkin_ref": "9c1f4a2e" },
    { "slot": 2, "display_label": "Sam R" },
    { "slot": 3, "display_label": "guest" } ] }

checkin_ref is where the binding of §3.4a is recorded. A player who reached the roster by scanning carries the ref of that scan; one whose name was typed carries nothing, and MUST NOT be given one. This is what lets the cloud resolve a position to a member without anybody typing a name or scanning a second time. It is opaque in the bay: nothing here can turn it into a member, and nothing here should try.

round.previous_stroke is the first field the engine itself provides, and it exists for a specific failure. Some data completes long after the stroke it belongs to: swing kinematics that finish processing, a video clip that finishes encoding, a pressure trace that arrives a second late. If the group has rotated in the meantime, that data lands while somebody else is up — and attributing it to whoever is at the ball now silently files one player's swing under another player's name.

So there are two defenses, and a device should use whichever is easier for it:

  • Snapshot at the stroke. A camera or kinematics device that wants round.player_up can record the slot and label at the moment of impact and send them back with the late payload. Simplest, and it needs nothing from us.
  • Ask the engine afterwards. round.previous_stroke names who the last stroke was attributed to, so a device that did not snapshot can still be attributed correctly.

Either way the engine stamps the composed shot record with player_up as it was at the stroke, not as it is when a contributor happens to finish. A late arrival is matched to a stroke, and the stroke already knows whose it was.

Selection rules, in order:

  1. Provenance wins. measured beats calculated beats inferred, always.
  2. Tie broken by declared resolution, finer wins.
  3. Then by latency_ms, lower wins.
  4. Then by explicit operator priority in the session policy.
  5. Then deterministically by device_id, so that two identical devices resolve the same way on every engine, every time. Non-determinism here would make sessions unreproducible, which is fatal for forensics.

club.club_id is ranked by identification method, not by provenance, since every source of it would otherwise claim measured:

rfid_nfc  >  marker  >  imaged  >  holder_absence  >  inferred_from_impact
          >  bag_sequence  >  ui_selected

Without this rule a simulator's dropdown could outrank an RFID reader purely by declaring itself first, which is exactly backwards. The ordering is by strength of evidence about what the player actually swung.

The break is between holder_absence and inferred_from_impact. Everything above it observed the club; everything below it is a guess or a claim. A sensed club rack sits at the bottom of the evidence group rather than the top of the guesses, because it knows what was drawn and not what was swung — a player may pull two clubs or change their mind at address. Strong evidence, not conclusive.

3.4a Check-in, before there is a round#

round.roster runs simulator → engine → phone: the names are typed at round setup, and a member scanning in picks which of them is theirs. That is the right shape for a group that is already playing, and the wrong shape for the moment before. At round setup somebody is standing at a touchscreen typing four names that the association already knows.

bay.checked_in runs the other way. A member scans the bay placard, their app checks them in, and the engine holds them — with the screen name from their SGA account — until they leave or the entry expires. The engine is already running before anyone opens the simulator's menus, so by the time the group reaches player configuration the list is there to be offered.

fieldprovided byis
bay.checked_inthe enginemembers present at this bay who have checked in
JSON
{ "bay.checked_in": [
    { "checkin_ref": "9c1f4a2e", "display_label": "Sam R",
      "checked_in_at": "2026-02-14T18:02:11Z", "expires_at": "2026-02-14T22:02:11Z" },
    { "checkin_ref": "51b0d7c3", "display_label": "Andrea",
      "checked_in_at": "2026-02-14T18:04:47Z", "expires_at": "2026-02-14T22:04:47Z" } ] }

checkin_ref is opaque and engine-scoped. It is not a member identifier, not derived from one, and not resolvable to one by the bay or by the simulator — 00 §6 is unchanged. It means "the person who scanned in at 17:02", and only the cloud can say who that was.

display_label here is supplied by the member's account rather than typed at a touchscreen. That makes it more accurate and no less a label: it is still not unique, members still collide, and nothing may key on it. Everything 00 §6 says about display_label applies unchanged, including that it is the first field stripped when the claim window closes.

Rules:

  • A simulator MAY use bay.checked_in to pre-fill player configuration. It MUST let the operator edit or remove any pre-filled player. Check-in says a phone was at the bay; it does not say that person is in this round.
  • When a checked-in person is assigned to a slot, the simulator or the engine MUST record the checkin_ref against that slot. That binding is what the cloud resolves to a member, and it is what makes the slot claim happen without anybody typing a name or scanning a second time.
  • Entries MUST carry expires_at and the engine MUST drop them when it passes. Somebody who scans, plays nothing and leaves must not still be offered to the next group in that bay.
  • The engine MUST drop an entry on explicit check-out.
  • An engine MUST NOT publish bay.checked_in to a device that has not been granted it in the connection matrix. It is a list of who is in the building.
  • Check-in MUST remain optional. A bay whose players never scan works exactly as it does today: names are typed and slots are claimed later.

How the engine learns of a scan. The scan happens on a phone, against the cloud, so the bay is the one participant that is not told — and the bay is where the person is standing. The engine therefore asks. Everything else in this system flows bay to cloud; this is the exception, and it is worth being precise about why it is safe.

A list of who is in a building is not a public fact, and ingest is unauthenticated by design (09). A feed keyed on a bay identifier would be readable by anyone who knows a bay exists, because a bay identifier is printed on a placard. So the engine asks by the rotating check-in code it minted (§3.4c): the code was on a screen in that room for three minutes, and the only people who have seen it were in the room. "Who redeemed the codes I put on my own screen" is a question nothing outside the bay can ask.

  • An engine SHOULD ask about the codes it has shown recently, not only the one in force: a member who scanned four minutes ago used a code that has since rotated.
  • A service answering this MUST scope the answer to the bay, the session and the codes together, and MUST bound how many codes one request may carry — otherwise the request becomes a way to guess them.
  • No credential is created for this. An installation identifier remains an identifier and never becomes a password (02 §3).
  • A person who scanned the code can ask it too, and will see the others who used that same code. They are standing next to those people.

Check-in is already the baseline presence signal in 07 §2.3. This field is what that scan produces locally; the proximity token in the same chapter is what proves the member stayed.

3.4b-i A claimed position names itself#

A member who scans before any names are typed is recorded as present with no position, and the bay seats them: the bay assigns positions and the cloud never guesses one (§1.1).

The other case is a member picking their own name from the list the bay has already published. That claim names a position, and a position number is only as good as the moment it was chosen. A simulator is free to reorder its roster before a round starts — dragging players into playing order is an ordinary thing for one to let an operator do — and a slot chosen thirty seconds ago may by now be somebody else.

So a claim carries both: the slot the member chose and the claimed_label that was on it when they chose it.

  • A bay MUST NOT bind a claim on the number alone when the label disagrees with what that position now holds. Binding there attributes one player's round to another account, which is the single worst outcome this standard has, and it would do it silently.
  • A bay MAY resolve the disagreement by label: if exactly one position now carries the claimed label, that is where the member meant to be, and binding them there honors the choice they actually made.
  • A bay MAY instead refuse and leave the member present and unplaced. The claim was made against a roster that no longer exists, and asking again costs a scan.
  • A bay that never reorders positions will never see the two disagree, and nothing here asks it to do anything differently.

The pair is the point. Either half alone is ambiguous — a number that may have moved, or a label that two players might share — and together they say the person who was in position 3 when I looked, which is what the member meant.

3.4c Putting the check-in code on the screen#

Check-in assumes a printed placard on the wall. A simulator has a large screen that people are already looking at, and the player-selection screen is the exact moment somebody wants to check in. So a device that has a display may ask the engine for the check-in code and render it.

fieldprovided byis
bay.checkin_codethe enginethe check-in payload for this bay, to be rendered as a QR code or shown as a short code
JSON
{ "bay.checkin_code": {
    "payload": "https://golfsga.com/c/8F3K2Q",
    "short_code": "8F3K2Q",
    "expires_at": "2026-02-14T18:12:00Z",
    "rotates": true } }

It is delivered in session.manifest, not in session.hello. session.hello is sent before session.describe, which means before the device has presented a bay pairing token and before the engine knows what it is talking to — anything in session.hello goes to whoever opened a socket. The check-in code is the thing that proves somebody was physically at the bay (07 §2.3), and it is the same artifact a device presents to join the bay at all (§5). Handing it to an unauthenticated peer would let somebody check in — and be counted present for a ranked round (08 §4) — from a car park, or from anywhere on a facility's flat network. So it goes in the matrix with everything else, and only a device that has already proved where it is can be granted it.

A screen code can rotate, which a printed one cannot. This is the part worth building for. A placard is ink: whatever it encodes is fixed for the life of the sign, so it can only ever say "this is bay 12". A code on a screen can carry a short-lived nonce, which makes a scan evidence that somebody was at this bay in this window rather than at some point since the sign was printed. A simulator that renders this is not just saving a walk to the wall — it is producing a materially stronger presence signal than the placard it replaces.

Rules:

  • bay.checkin_code MUST be granted through the connection matrix and MUST NOT appear in session.hello.
  • A device granted it MAY display it. It MUST NOT log it, persist it beyond display, or forward it to anything else.
  • A device MUST stop displaying a code once expires_at has passed, and SHOULD request a fresh one rather than showing a stale one.
  • When rotates is false the code is equivalent to the printed placard and carries no timing evidence. An engine MUST NOT report rotates: true for a value that does not actually change. A screen code belongs to one round. It is issued for the round in play, or, between rounds, for the round about to open, and it carries which. A lobby code checks somebody in for the round it names before anybody has chosen a course or typed a name; the same code carries on into that round once it opens. A code shown during play (a simulator's players screen, for instance) is for the round in play.
  • When a round closes, the engine MUST stop offering every code it issued for that round, including codes still inside the window it otherwise keeps recently shown codes askable for, and MUST issue the next round's code.
  • The cloud MUST refuse a code for a round that has closed, telling the member the round is over and nothing is joinable with that code. It MUST NOT queue the holder for a later round: a code from a finished game is not evidence of presence at the next one.
  • The cloud MUST treat a code for a round that has not begun as a check-in for that round, so the bay can seat the member when it opens.
  • A printed placard is not a screen code and names no round. Its rules are different: see the placard row of 07 §2.3.
  • Rendering the code is OPTIONAL. A bay whose simulator ignores this field works exactly as it does today, because the placard is still on the wall.
  • A renderer SHOULD encode payload as a QR code at error-correction level H, with the standard four-module quiet zone, in dark-on-light with the full contrast the display can manage, and at the lowest QR version that holds the payload. This is not pedantry about barcodes. A simulator screen is the worst surface a camera phone will ever be asked to read: often projected, frequently at an angle, sometimes with a bright course rendered behind it, and scanned from wherever the player happens to be standing rather than from arm's length. H recovers from about thirty percent damage, which is what buys a scan through glare and a cheap lens. The quiet zone is what a scanner uses to find the code at all, and a code drawn flush against a UI panel frequently will not be found. It is a SHOULD rather than a MUST because a code that scans is the actual requirement, and a renderer with a better answer for its own display should use it. A renderer that cannot meet this SHOULD still display short_code, which needs no camera.

3.4b Stroke allowances, so a rated match can be set up by itself#

Implementers: the two ways to get this wrong.

  1. Applying allowances when only some players have one. Check complete. False means apply nothing — an unrated player is not a scratch player, and treating them as one hands the match to whoever we happen not to know yet.
  2. Writing net where gross goes. gross is what was actually shot and it never moves. net is reported alongside it. Player ratings post from gross, so a net figure in the gross field makes every future allowance for that member wrong, compounding every round.

See integration/simulator.md.

Rated match play needs one number per side before the first tee shot, and today somebody types it in — if they know it at all. When every player in the group has checked in (§3.4a) and the association has already rated them, that number is known before anybody reaches for the keyboard.

round.allowances is engine-provided and arrives after the simulator has committed to a course. It cannot ride along with check-in: an allowance is only meaningful against a course, a tee set and a format, and none of those exist yet when somebody scans the placard at the door. So the simulator declares what is about to be played, and the engine answers.

fieldprovided byis
round.allowancesthe enginestrokes received per slot, for the course and format about to be played
JSON
{ "round.allowances": {
    "basis": { "course_digest": "sha256:1f3c…", "tee_set": "Blue", "format": "match" },
    "complete": true,
    "source": "sga",
    "computed_at": "2026-02-14T18:31:02Z",
    "players": [
      { "slot": 1, "strokes_received": 0 },
      { "slot": 2, "strokes_received": 5, "stroke_holes": [1, 3, 5, 7, 11] } ] } }

It is keyed by slot, not by member. The engine already knows which checkin_ref sits in which slot, because §3.4a required that binding. Everything downstream of it is a slot number and a small integer, so 00 §6 holds: no index, no account, nothing that outlives the round.

complete is the gate. It is true only when every occupied slot has an allowance. A field where two players are rated and one is not is not a rated match — it is a match with a guess in it, and the standard should not let a simulator quietly split the difference.

stroke_holes is optional and authoritative when present. A simulator knows its own stroke indexes and can allocate strokes_received itself, which is the common case. When the engine supplies the holes — because an event ruleset fixes the allocation, or because the allocation is not the naive one — the simulator MUST use them rather than its own.

An allowance changes the result of a match and nothing else. This is the rule most likely to be implemented wrong, so it is stated as a list of things that MUST NOT move:

stays exactly as playedwhy
every stroke on every holea stroke happened or it did not; an allowance is not evidence about the ball
every shot recordthey are measurements, and no measurement changes because of who somebody is playing
gross and to_parthe card must still show what was actually shot
what the round posts for ratingposting works from gross, or the system feeds on its own output and drifts

What the allowance produces is net, and the match result computed from it. A card that has had allowances applied shows the real strokes and the adjusted outcome, side by side, because a player who shot 84 shot 84.

Sanctioned events are not the casual case. Rated formats are perfectly legitimate in competition — net events are most of amateur golf. The constraint is narrower than "no player ratings in tournaments": in a sanctioned round the allowance MUST come from the Event Rules Package (08 §1) and MUST NOT be a bay-time lookup. An event fixes its allowances in advance, publishes them, and is judged against them. A number fetched at the bay minutes before play is a different claim, and a committee cannot audit what it never saw.

So source: event outranks source: sga whenever both exist. Everything below is for the round that most simulator golf actually is: four people who want a fair match on a Tuesday, and an association that already knows how well each of them plays.

Rules:

  • A simulator MAY pre-apply allowances. It MUST NOT do so when complete is false, and it MUST let the operator override any value.
  • An allowance MUST NOT alter any stroke, any shot record, gross, or what the round posts for rating. It produces net and the match result.
  • Under an Event Rules Package, source MUST be event. An engine MUST NOT substitute an sga allowance into a sanctioned round.
  • The simulator MUST record what it actually applied on the scorecard (strokes_received, stroke_holes, allowance_source). A match result without the strokes that produced it cannot be checked by anybody, which puts it outside everything this standard is for.
  • source MUST say where the number came from — sga, event from the Event Rules Package (08 §1), operator when a human typed it, or local when the simulator computed it from its own stored figures. These are different strengths of evidence and a consumer must be able to tell them apart.
  • An engine that cannot reach the association MUST omit the field or return it with complete: false. A stale allowance is worse than none: it is a wrong result that looks official.
  • Allowances MUST NOT be cached beyond the round they were issued for.

3.4d Changing the roster while the round is running#

Groups are not fixed. Somebody arrives at the second tee, somebody else has to leave at the seventh, and plenty of simulator software lets an operator add or drop a player without ending the round. Plenty does not. The difference matters enough that it cannot be guessed.

featuremeans
roster_add_in_rounda player may be added after the round has started
roster_remove_in_rounda player may be removed after the round has started

Both default to false, and absence is a refusal rather than an unknown. A simulator that says nothing gets a roster fixed at the first tee, which is the behavior every implementation already has. An engine MUST NOT send a roster directive to a participant that has not declared the matching feature, and a simulator that receives one anyway MUST refuse it with session.denied.

JSON
{ "type": "request.add_player", "display_label": "Sam R", "checkin_ref": "9c1f4a2e" }
{ "type": "request.remove_player", "slot": 3, "reason": "member_left" }

A player added mid-round starts from the hole in play. The holes already played by the group are not theirs and MUST NOT be backfilled as anything — not zero, not par, not blank strokes. They are simply holes this player has no result for, which 06 §9 already expresses as a hole that is absent from their card. A partial round is a first-class record.

Slots are never renumbered. This is the rule the whole mechanism rests on. Shots have already been journaled against slot 3, and a simulator that closed the gap by moving slot 4 down would reattribute them — silently, after the fact, to a player who never hit them. A removed slot stays removed and its number stays spent for the life of the round.

Joining one is a decision, not a side effect#

A member who scans a bay code mid-round is not obviously asking to join the round in progress. They may be next up, or watching, or have scanned the wrong bay. And joining is not free: the holes the group has already played are holes they will have no result for, so the card they walk away with is a short one.

So a client that offers a mid-round join MUST confirm it, and the confirmation MUST say what the member is joining — which round, and how much of it has already gone:

This round is already on hole 5. Join now? Holes 1-4 will not be on your card.

  • A confirmation MUST NOT be pre-accepted, defaulted to yes, or skipped because the member confirmed something similar earlier.
  • Declining MUST leave them checked in and unplaced. Somebody who scanned and said "not this round" is still standing in the bay, and will usually want the next one.
  • Where the bay has not declared roster_add_in_round, the option MUST NOT be offered at all. Offering something the bay will refuse is worse than offering nothing.

Removal is a withdrawal, not a deletion#

The round happened. A player who leaves at the seventh played six holes and those six stand exactly as golfed: their shots keep their slot, their card keeps its holes, and nothing about the record changes because the person went home. What stops is new strokes.

Removal can start from either end, and both converge on the same observable:

  • The member asks, from their phone — "I am leaving, take me out". The engine sends request.remove_player.
  • The operator does it at the simulator. Nothing is sent, because nothing needs to be: round.roster is standing state (§2.2a) and is re-asserted for as long as the round lasts. A slot that is no longer in it has been withdrawn.

That second path is why removal needs no new upward message. A consumer MUST NOT infer withdrawal from silence — a slot that simply stops producing shots is indistinguishable from a player who is slow, a network that dropped, or a sensor that failed. Withdrawal is a positive statement: the roster stops listing them. Absence of shots is not evidence of anything.

Late and early, recorded#

A round where somebody joined at the third and somebody else left at the seventh is a different round from one where four people played all nine, and a scorecard showing two short cards cannot say which happened. So the platform records it against the playing position:

set when
arrived_latethe position first appears on the roster after the round's first stroke
left_earlythe position leaves the roster before the round ends

Both are derived from the roster, not asserted by anybody: they follow from when a slot appeared and when it stopped being listed. Neither changes a score. They exist so that "this member leaves halfway through most of their rounds" is a query rather than an anecdote, and so a player rating review can see a short card for what it is.


3.4e Ready for a stroke#

Two different things have to be true before a player can hit, and they are held by two different participants.

The simulator is ready when the round is at a point where a shot means something: somebody is up, the camera is not mid-flyover, no menu is open, the ball is teed in the virtual world. The launch monitor is ready when it can see: sensors live and settled, the ball detected in view, no recalibration in progress. Neither can observe the other's answer, and a stroke struck while either is false is the commonest way a real shot goes unrecorded.

"Launch monitor" here means the participant that measures what the stroke did to the ball — the one holding sensor.ball, whatever technology it uses to do so. It observes the ball's launch: speed, angle, direction and spin as it leaves the face. Most also read the club delivering it — face angle, path, angle of attack — and a unit that does holds sensor.club as well.

Camera, radar and infrared units are all launch monitors and all have the same problem: they must be able to see the ball before a stroke can be recorded. That is the question a simulator is asking, and it is not the same question as whether some other sensor in the bay is awake. A bay's environment sensor being unready says nothing about whether a shot can be recorded, and a video capture rig has its own readiness that is a different fact again — if one is ever needed it gets its own field rather than overloading this one.

fieldprovided byis true when
status.sim_readythe simulatorthe round can accept a stroke now
status.lm_readythe launch monitorit can see the ball and record a stroke now

They share the status. namespace because both are a participant's report on its own condition rather than a measurement of anything in the bay — which is also what keeps them out of sim., a namespace this standard does not have. Each is an ordinary standing boolean, asserted and withdrawn like any other (§2.2a), carrying a max_age_ms so a consumer can tell a live answer from a stale one.

Two fields and not one. Folding them into a single "armed" flag loses the only thing an operator needs when a shot goes missing, which is which half was not ready.

The engine composes nothing here#

There is no bay.ready, and an engine MUST NOT synthesize one.

The temptation is obvious — take both values, AND them, publish the answer — and it is exactly the thing 00 §7 forbids. "Ready" composed from two sources is no longer either participant's statement; it is the engine's opinion about what they add up to, published under the engine's name, with no way for a reader to recover which half was false or whether either was ever asserted at all. The engine routes these values and records them. It does not decide what they mean together.

Nothing new is needed to make them useful. A simulator that wants to know whether the launch monitor can see puts status.lm_ready in its wants; a launch monitor that wants to know whether the round will accept a shot puts status.sim_ready in its. The matrix connects them like any other field, and each decides for itself what to do with the answer. Where a bay runs more than one launch monitor, status.lm_ready resolves through the matrix as any field does — the readiness that reaches a subscriber is the one from the unit that would actually measure the shot.

A field nobody provides#

No special case, and no interlock to fail open: a want with no provider is already reported. The manifest carries it in unmet with reason no_provider (§2.2), at handshake, before a shot is struck.

So a launch monitor that asked for status.sim_ready in a bay whose simulator does not publish it is told so immediately, and behaves exactly as it did before this section existed. A device MUST NOT treat an unmet want as a reason to stop measuring or stop reporting: a bay that answered no questions about readiness has not said it is unready, and a shot that happened still happened (07 §4 — observed absence is not the same as absent observation).

Reconstructing it afterwards#

Both fields are standing values, so both are journaled as they change, with timestamps, like all standing state. Anyone reading the journal can therefore say what each participant claimed at the moment of any stroke, and say it from the record rather than from an engine's summary of it.

That is the whole reason the engine does not compose them: a composed flag would have answered the question once, at write time, in a way nobody could check afterwards. The contributions answer it forever.

3.4f Handedness, from the two places it is known#

Commercial launch monitors determine handedness. So do simulators. Both answers are real, both are worth having, and the standard carries both rather than picking one.

fieldprovided byis
player.sim_handednessthe simulatorwhat the software is configured for — which decides how the shot is rendered and where the ball is teed
player.lm_handednessthe launch monitorwhat it observed — which side of the ball armed (06 §10), or the swing itself

The provenance differs and says so: a menu somebody clicked is inferred, a swing somebody watched is measured. That is the whole of what a consumer needs to rank them, and it is already how every other field in this standard carries that distinction.

These are not two opinions about one field. They are two fields. The same call 06 §4.1 already makes for conditions: a wind value that is a game setting and a wind value that is the weather are not the same fact. 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.

Nothing records the disagreement, because nothing merged them. Both values reach the record, and a reader compares them. A flag asserting that two visible values differ would be one more thing that can be wrong about something anyone can check for themselves.

What this makes possible#

The exchange runs in both directions, and that is the point:

  • A monitor sees a left-handed swing and publishes player.lm_handedness. The simulator subscribes, corrects its setup, and publishes the new player.sim_handedness. The bay has followed the player without anybody touching a menu.
  • A monitor that subscribes to player.sim_handedness can tell an operator the simulator is set for the other hand — before a round is scored on it, rather than after.

A single merged field could not have done this, for a structural reason worth stating plainly: an engine never echoes a field to its own producer (§3). One field could carry the observation to the simulator, but never the setting back to the monitor. The exchange these exist for would run one way only.

Where a bay publishes just one of them, that is the only answer it has, and the manifest says so in unmet for anything that asked for the other — the ordinary mechanism, with no special case.

3.4g Settings in force: what the simulator is doing for the player#

Simulators help. Auto-putt, a gimme distance, aim assist, a preview of the putt before it is struck: in casual play that is the product working as intended, and nothing here discourages it. But everything downstream of a round — a player rating, a statistic, a comparison between two members, the result of an event — assumes the player hit the ball, on the course and in the conditions the record describes. A record that cannot say when that was not true cannot be read at all.

fieldprovided byis
round.settingsthe simulatorwhat is in force for the round: its assists, its conditions and its course
JSON
{
  "assists": {
    "complete": true,
    "active": [
      { "id": "gimme", "effect": "skips_the_stroke",
        "applies_to": ["putting"], "distance_m": 0.9 },
      { "id": "x-acme.fairway_finder", "label": "Fairway Finder",
        "effect": "changes_the_result", "applies_to": ["tee_shot"] }
    ]
  },
  "conditions": { "mode": "fixed", "wind_speed_mps": 3.1, "green_speed_stimp": 11.5 },
  "course": { "course_digest": "sha256:...", "tee_set": "Members" },
  "changed_by": "player"
}

One declaration, in every round#

The simulator has one job here: say what is in force. It does that in every round, casual or sanctioned, with this one field. Everything else is a comparison somebody else makes:

  • In a casual round, a rating or a statistic reads the declaration and decides what to do about it.
  • In a sanctioned round, the engine compares the declaration with the event's rules package and signs the result into the journal (08 §2). There is no second report for events: the simulator that already declares its settings for every round has built the whole of what an event asks of it.

A simulator SHOULD publish round.settings before the first stroke of every round, and again whenever anything in it changes. It is a standing value (§2.2a), so every change is a new version, and the journal holds them all, each with the moment it took effect. Which strokes were played under which settings is read from the record, not reconstructed, and a change has no message of its own: before and after are the version that was in force and the one that replaced it.

Each version says who made it, in changed_by: the player, an operator, the simulator itself (a default, a restart, an automatic change), or a rules_package. A version that applies an event's package — and every later version while that package is in force — carries its request_id, so a mid-round change is read against what the event required.

Only the simulator may publish it. It is a statement about the simulator's own configuration, and nothing else in the bay is in a position to make it — and because an event compares it with the rules package, a declaration forged by any other participant could make an assisted round look conforming. An engine MUST refuse round.settings from a participant that does not hold the simulator role, whatever that participant declared.

Assists are classified by what they do, not by what they are called#

No list of assists could be complete. Every simulator has its own, they change between releases, and many have no equivalent anywhere else. What does not change is what an assist can do to the evidence, and there are six things:

effectthe recorded stroke…for example
plays_the_strokewas not hit by the playerauto-putt
skips_the_strokewas never playeda gimme distance, a cap on strokes per hole
changes_the_resultwent somewhere the swing did not send itaim assist, shot shaping, automatic wind or elevation compensation
changes_the_coursewas played on an easier hole than the one describeda larger cup, a removed hazard, an improved lie
more_than_one_attemptwas not the player's only trymulligans, shot replay, a preview of the putt or the flight
informs_the_decisionis the player's own, and advice shaped the choicegreen reading, club recommendation

The effects are closed; the assists are not. A consumer writes its policy once, against six effects, instead of against every simulator's settings menu.

applies_to says where on the hole an assist acts — tee_shot (the tee shot on a par 4 or 5), approach (which includes a par-3 tee shot), around_green, putting, or all — because an assist that works on the greens says nothing about the tee shots.

The well-known assists have their effect fixed#

For the assists this standard names, the effect and the scope are part of the definition, and the schema rejects any other:

ideffectapplies toparameter
auto_puttplays_the_strokeputtingdistance_m: the range it takes over within; absent means every putt
gimmeskips_the_strokeputtingdistance_m
max_strokes_per_holeskips_the_strokeallcount
aim_assistchanges_the_resultall
shot_shaping_assistchanges_the_resulttee shot, approach
distance_auto_adjustchanges_the_resultall
wind_auto_compensationchanges_the_resulttee shot, approach, around the green
elevation_auto_compensationchanges_the_resulttee shot, approach, around the green
mulliganmore_than_one_attemptallcount
shot_replaymore_than_one_attemptall
putt_previewmore_than_one_attemptputting
ball_flight_previewmore_than_one_attempttee shot, approach, around the green
green_readinginforms_the_decisionputting
club_recommendationinforms_the_decisiontee shot, approach, around the green

A simulator cannot file auto-putt as advice. Fixing the effect for the well-known names is what makes the classification worth anything: the assists that matter most are exactly the ones whose effect should not be the vendor's to describe.

They are also the vocabulary an event states its requirements in (08 §1), so what an event prohibits or requires and what a round declares can be compared id for id. A gimme distance, a mulligan allowance and a stroke cap are assists with a parameter, not separate rules.

Anything else is the vendor's — and so is its classification#

Every other assist goes in the vendor's namespace, x-<vendor>.<name>, and the vendor declares its effect and scope. label is the vendor's own name for it, for display: untrusted text, rendered escaped, and never decided on.

A vendor could file an assist under a gentler effect than it deserves. Three things stand against that, and none of them is the engine judging it (00 §7):

  • It is attributable. The declaration is journaled against the simulator that made it, under that simulator's identity. A misclassification is a false statement by a named vendor, not an ambiguity in the standard.
  • It is reviewable. An assist a vendor registers through the technology partner portal can be reviewed, and a classification corrected once, for every bay that runs it.
  • It shows in the outcomes. An assist filed as advice that actually moves the ball leaves a trace in what the strokes it touched went on to do. Nothing here needs the vendor's word to be the last one.

Complete, partial, and undeclared#

assists.complete: true is the simulator saying the list is everything in force — and, with an empty active, that nothing is. It is the strongest statement a round can carry about its own evidence, and the only one that can show an event that a prohibited assist was off.

complete: false is the honest answer from a simulator that can report the assists it knows about but cannot swear there are no others. It is worth a great deal more than silence.

A round with no round.settings at all is a third state — undeclared — and a consumer MUST NOT read it as either of the others. In particular, silence is not a declaration that nothing was on. In a sanctioned round, undeclared is what 08 calls unattested.

conditions uses the keys an event package requires them in, and reports the value actually in force, not the value asked for: a simulator set to 11.0 that quantizes to 11.5 declares 11.5. A condition not reported is unsupported, never assumed. course names the course by digest and the tee set in play.

What a consumer does with it is the consumer's decision#

The standard records what was in force and what each assist does. It does not say what a rating, a leaderboard or a statistic should do about it: that is policy, and it belongs to whoever publishes the number (00 §7). An event is the one consumer whose policy the standard does define, because the event's rules package is itself part of the standard.

2.2a Standing state, and how to withdraw it#

Most fields are produced by the shot: a launch monitor measures ball speed because a ball was struck. Those are shot_synchronous, and they cannot be stale because they did not exist a moment earlier.

Some fields are not like that. A club rack notices a club was drawn long before anyone swings. A platform reports the angle it settled at. A weather module reports conditions continuously. The simulator says whose turn it is. These are standing — asserted between shots and maintained until something changes — and they need two things shot_synchronous fields do not.

1. A device MUST be able to withdraw a standing assertion.

JSON
{ "type": "record.state", "field": "club.club_id", "value": "7i",
  "confidence": 0.9, "t_ms": 812440 }

{ "type": "record.state", "field": "club.club_id", "value": null,
  "reason": "ambiguous", "t_ms": 845102 }

value: null means "I no longer know" — not "nothing changed" and not "the device died". Those three are different and silence cannot distinguish them, which is why withdrawal is an explicit message rather than an absence of messages.

The motivating case is the club rack with two empty slots. It can tell that its previous answer is no longer safe and cannot tell what the new answer is. With no way to say so, it would keep asserting the last club it saw drawn, and every subsequent shot would be attributed to a club nobody was holding. A device that cannot retract is worse than a device that never asserted, because its wrongness is invisible and persistent.

A device MUST retract when it can no longer support its assertion. It MUST NOT simply fall silent, and it MUST NOT substitute a guess.

2. A standing assertion has an age, and may have a limit.

A provided standing field declares max_age_ms — how long an assertion stays meaningful without renewal. Beyond it the engine treats the field as absent rather than carrying a stale value into a shot. A club rack might reasonably use minutes; a moving platform, seconds.

Within the window, the engine records assertions[field].age_ms on the composed shot record. A standing value is not wrong because it is old, but its age is evidence: a club identified four seconds before the swing and one identified four minutes before are different claims, and only the consumer knows which matters. Record it, surface it, let the rating model decide — the same posture as everywhere else here.

Engine composition rule. At shot time, for each standing field, take every assertion that has not been retracted and is inside its max_age_ms. Then:

  • none -- the field is absent.
  • all agreeing -- that is the value. Corroboration from several sources is recorded, not collapsed.
  • disagreeing -- the field is absent, and the disagreement is journaled.

Never carry a value forward past its window, never infer one, and never break a disagreement by taking the most recent writer. Several sources can be legitimately authoritative at once -- two golf bags with a club out of each -- and picking one produces a confident wrong answer where the truth is that nobody knows.

2.2a2 Attribution: objects follow the swing, people follow themselves#

A device describes either a thing or a person, and that decides whose record its data lands in.

scopeattributed towhy
objectthe active slotthe club that left the rack is the club being swung, whoever swung it
personits bound slot, alwaysa wearable describes its wearer, and only its wearer

The person case is not a refinement. A wrist sensor keeps producing motion whether or not its wearer is the one hitting — standing about, taking a practice swing, reaching for a drink — and attributing that to whoever happens to be at the tee would file one player's movement as another player's swing.

That failure is worse than a wrong club, because a wrong club is visibly wrong and corrupt kinematics are not. They look like a swing. They would train a coaching model, move a player's biomechanical baseline, and never be questioned.

So the fix is structural rather than a heuristic: a person-scoped device's data reaches its bound slot and no other, ever. If its wearer was not swinging, the shot simply has no swing data attached, which is the truth.

Roles imply scope. sensor.swing, sensor.pressure and sensor.player are person-scoped whether or not a descriptor says so. A vendor who forgets to declare it does not get the dangerous default.

Binding comes from the phone. At check-in the member picks their in-game player name from the round roster (§3.4), and their app tells the engine which slot their wearables belong to. The bay learns a slot number, which is not a person, so 00 §6 still holds.

Unbound person-scoped data is recorded and attributed to nobody. Not discarded — a binding may arrive later, exactly as a slot claim may — and never guessed at.

Sharing policy is set by the owner at binding time, not declared by the vendor.

It is not in the capability descriptor, deliberately. Everything else there is what this device can do; whether it may change hands is what its owner permits, and the two belong to different people. The same wrist sensor is personal on one player and a loaner in a teaching professional's fleet -- identical hardware, opposite policy, and the manufacturer cannot know which. Leaving it to them guarantees a wrong default: optimize for "just works" and everything is shareable; optimize for safety and every loaner needs support.

So it arrives with the binding, from the member's app, which is already doing the binding because it is the thing that knows which player they are.

It only means anything for person-scoped devices. An object-scoped device -- a rack, a bag, a launch monitor -- has no binding to move: it follows whoever is swinging, so it is inherently shared and the question does not arise. The flag exists for wearables and pressure plates, where it does.

The default is not shareable, and the app should say why in the one place a player will read it: a personal sensor keeps producing motion whether or not you are the one hitting, so lending it without a proper handover puts your movement in someone else's record and theirs in yours.

It decides how a binding may move, not whether it can.

A personal device can be lent — refusing outright would block an ordinary thing people do, and a rule people cannot follow gets worked around by declaring everything shareable. It takes two acts by two people instead of one:

   wearer RELEASES  ->  device belongs to nobody  ->  next player CLAIMS

A shareable device — a facility loaner nobody owns — skips the release and re-binds directly. A personal one cannot: a reassignment that needs only the receiving end is exactly the silent drift this mechanism exists to prevent.

Between release and claim the device is unbound, and its data attributes to nobody. That gap is correct: during a handover, nobody knows whose motion it is either.

Bindings are a history, not a current value. A wearable replaying buffered samples after a BLE reconnect may span a handover, and attributing all of it to whoever holds the device now would file one player's swings under another. Attribution resolves at the moment data was produced, not the moment it arrived — the same rule as everywhere else here. A binding takes effect at its timestamp; data from before any binding belongs to nobody and is never claimed retroactively.

2.2b Shot records are composed, not emitted#

Worth stating plainly, because implementers assume the opposite.

No single participant produces a shot record. The launch monitor knows ball and club delivery. The simulator knows lie, hole, distance and outcome. An RFID reader may know the club. A weather module may know the wind. Each sends only what it knows; the engine assembles the canonical record using the manifest to decide, field by field, whose value wins.

So a launch monitor never needs to know the hole number, and a playfield RFID reader never needs to know anything except which club just passed over it. That is the whole reason a device can be small and still useful — and the reason a new class of accessory can appear later and contribute one field without any existing participant changing.

unmet is published, not hidden. A want nobody can satisfy is diagnostic gold — it is how we learn which fields the ecosystem actually needs, straight from the devices, without surveying anyone.

Never silently substitute downward. If the measured source disappears and only an inferred one remains, the engine issues a new manifest version. Every consumer sees the provenance change explicitly. Silent degradation is exactly the failure that makes historical data untrustworthy.

2.3 Renegotiation#

Any topology change — device joins, leaves, changes firmware, changes configuration, loses a capability — increments manifest_version and republishes to everyone.

  • Manifest versions are monotonic within a session and recorded in the journal. The set of manifests is a complete history of the bay's technical configuration for that round.
  • A participant MUST tolerate a manifest arriving at any time, including mid-shot.
  • Data in flight under manifest n stays valid; it is stamped with the manifest that was in force when it was produced.

2.4 Singleton roles: one simulator to a bay#

Most roles are plural by design. A bay may hold several ball sensors, several cameras, several accessories, and the connection matrix resolves between them field by field — that is what it is for.

simulator is not one of them, and neither is officiator.

The failure this prevents is not a collision. Two simulators do not conflict; they get silently merged. The matrix resolves per field, so the lie surface comes from whichever declared measured, the distance to pin from whichever declared finer resolution, the player slot from whichever answered faster. Both contribute to the same shot under one shot_id (06 §10), and the engine composes one record out of two different games.

The player watches one of them on a screen. The record may describe the other. Nothing errors, and the round looks perfectly valid afterwards — which is the worst way for this to fail, and exactly what the rest of this standard exists to prevent.

Rules:

  • An engine MUST admit at most one participant holding a singleton role.
  • A second MUST be refused with role_occupied, remedy close_other_software, and the denial MUST carry occupied_by naming the incumbent.
  • The incumbent keeps the role. The newcomer is refused.

Naming the incumbent is the point. A bare refusal tells the second simulator's author only that something went wrong, and they go looking for a bug that is not theirs. With occupied_by they can name it — "Acme Sim is already running in this bay" — which is a sentence an operator can act on without calling anybody.

Why the incumbent wins rather than the newcomer. Reconnect backoff is capped at five seconds (09 §5), so displacing the incumbent does not hand over the role — the displaced software is back within five seconds and displaces the newcomer in turn. The bay would oscillate, owned by whichever simulator most recently reconnected. First-in wins is the only stable rule while clients retry, which they are required to do.

The cost is that a lingering socket from closed software holds the seat, which is why closing it is a MUST on the participant (09 §5.1) and a shipping condition for an endorsement.

3. Subscription filters live distribution — not recording#

Two rules that look contradictory and are not:

Distribution is filtered. A participant receives only the fields it declared it wants. A wall display asking for three values does not receive a 200 Hz kinematics stream. This is what keeps a hundred-bay facility off its knees and a Bluetooth device inside its budget.

Recording is total. The engine's local journal records every field every participant offered, at full rate, whether or not anything subscribed to it.

The subscription model is a bandwidth optimization for the live path. It must never become a data-retention policy. The reasons:

  • Forensics. When a tournament result is challenged three weeks later, the question will be about something nobody thought to subscribe to. Data not recorded is an argument that cannot be settled.
  • Future value. Fields no current consumer wants — club delivery, impact location, environment — are precisely the fields that become valuable when someone eventually asks what a golf ball or a shaft actually does across a million shots.
  • It is free. The marginal cost of writing a field already crossing the wire to a local append-only journal is negligible. The cost of not having it is unrecoverable.

Egress from that journal — what leaves the bay, to whom, under what consent — is a separate decision made at upload time, not at capture time. That separation also lets a player who claims a slot late still get their whole round, and lets a player who opts out suppress a session retroactively.


4. Latency#

Forwarding never blocks on processing. A shot goes to its subscribers before the engine parses it, records it, or does anything else. A slow recorder, a wedged display, or an unreachable cloud MUST NOT delay a shot reaching the simulator.

This is not a nice-to-have. If SGA Engine adds perceptible latency between the stroke and the ball flying on screen, no golfer will tolerate it and no vendor will ship it. Budget: under 5 ms added by the engine, end to end, excluding transport.

This is normative for every implementation.


5. Discovery#

transportmechanismprivilege
LANmDNS / DNS-SD, _sga-ois._tcpnone
LAN, large sitesexplicit endpoint from facility confignone
Bluetooth LEGATT service, standard advertisementnone
USB / serialvendor adapternone
loopbackfixed port, config filenone

Everything a device needs to be discovered and paired works without administrative privileges on any supported OS. That is a design requirement, not an accident — see 05-deployment.

Multi-bay facilities. mDNS on a flat network with two hundred bays is a broadcast problem and, worse, an ambiguity problem: a device must never join the bay next door. So:

  • Each engine advertises a bay-scoped instance name and a facility subtype.
  • A device joining a bay must present a bay pairing token, obtained from the physical bay — the QR placard, an NFC tag, or a short code shown on the bay screen. Physical presence is the pairing authority.
  • Facilities over a size threshold SHOULD disable mDNS and use explicit configuration from the facility controller. The standard supports both; the deployment guide recommends per size.

A device that cannot prove which bay it is in does not join a bay. That rule is the difference between a hundred-bay facility and a hundred-bay incident.

How a device found the bay is reported, because fallbacks hide a broken network. When multicast discovery stops working — a firewall rule, client isolation on an access point, a switch filtering multicast — devices quietly reach the bay some other way and everything keeps running. So each device says which way it came, in discovered_via (§2.1):

valuethe device found the bay bywhat it tells you
mdnsmulticast DNS-SD on the local networklocal discovery works; this should normally win
unicast_dnssdrecords in the venue's own DNSa venue with managed DNS
blea Bluetooth rendezvous
dhcpan option in its DHCP lease
public_txtSGA's public lookup, the last resortif this works and the others do not, the local network is broken and the internet is carrying the bay
cacheda last-known-good addressnot evidence that discovery works
configuredan address typed by somebody or shipped in a filenot evidence that discovery works
loopbackthe engine is on the same machinea development bay or the reference simulator
redirectanother engine sent it here (§2.0f)it reached the wrong bay first; many of these mean a configuration to check

cached and configured are how a device reaches a bay somebody set up by hand. A bay whose devices only ever arrive that way has not shown it is discoverable at all, and treating those values as a yes is how a bay with dead discovery reports itself healthy.

5.1 The Bluetooth LE identifiers#

SGA-OIS allocates one block of 128-bit UUIDs, minted 30 Sep 2026. They vary only in the second half of the first group, so a family member is recognizable on a sniffer instead of looking like six unrelated numbers:

738Bxxxx-2551-4C83-BACE-C49B0FCE933A

xxxxUUIDwhat
0001738B0001-2551-4C83-BACE-C49B0FCE933Athe SGA-OIS transport service, which an engine exposes and advertises (09 §2.3)
0002738B0002-2551-4C83-BACE-C49B0FCE933Aengine → device, notify
0003738B0003-2551-4C83-BACE-C49B0FCE933Adevice → engine, write without response
0010738B0010-2551-4C83-BACE-C49B0FCE933Athe presence beacon a member's app advertises (07 §2.2)
0011reserveda bay signaling that it needs a courier (09 §6.6). Allocated, and how it is advertised is not yet settled — see below
0020reserveda device with no association, advertising itself to be found by an operator

These are 128-bit and therefore free, unregistered and permissionless. That is a deliberate choice and it has a cost, which the next paragraph is about.

The 31-byte budget is the real constraint#

A legacy advertisement carries 31 bytes. Flags take 3 and a 128-bit UUID takes 18 — a length byte, a type byte and the 16-byte value — leaving 10. A 16-bit UUID would take 4, but a 16-bit UUID must be bought from the Bluetooth SIG, and this standard will not put a purchase between a vendor and an implementation.

So an implementer should plan on ten spare bytes and no more. In particular, service data for a 128-bit UUID does not fit beside the UUID itself — it repeats the 16-byte value, costing another 18 — which is why 0011 above is allocated but its mechanism is left open rather than specified into a packet that has no room for it.

The presence beacon UUID MUST be fixed#

0010 is a constant. It MUST NOT rotate, even though the token it carries does, and the reason is a forty-bay facility rather than a single bay.

An engine can ask its radio to deliver only advertisements matching a known UUID, and on hardware with the relevant controller support that filter runs in the radio. With a fixed UUID a bay in a busy facility is woken for the beacons it cares about. With a rotating one no filter is possible: every engine would have to receive and process every advertisement in a room containing forty other engines, forty phones, and every pair of earbuds and smart watch in the building — and would have to journal them, because it cannot tell which are beacons.

That is the difference between a presence scanner and a device that records every radio in the room, and it is settled here so that nobody has to rediscover it at scale.

No Bluetooth qualification is claimed#

SGA-OIS describes Bluetooth LE as a transport it can run over. It is not a Bluetooth specification, it claims no Bluetooth qualification for any implementation, and nothing in it licenses the Bluetooth word mark or logos — those come from the Bluetooth SIG, to its members, for qualified products. An implementer who brands a product with the mark is responsible for their own membership and qualification.

Simulated Golf Association · SGA-OIS · generated from the specification sources