Transport, sessions, and latency
Normative.
A golfer must never be able to feel that SGA Engine is in the path. If the ball flies perceptibly later because we are between the monitor and the simulator, no vendor ships us and no facility keeps us. Everything here follows from that.
1. Connections are persistent. Always.#
A participant opens one connection at session start and holds it for the life of the session. Nothing in this standard establishes a connection in order to send a message.
Per-message connections would be catastrophic here and the arithmetic is not close:
| path | connect cost before a byte of payload moves |
|---|---|
| loopback TCP | ~0.1 ms |
| LAN TCP + TLS | 3–15 ms (2–3 round trips) |
| Wi-Fi TCP + TLS | 10–60 ms, worse under contention |
| BLE connect + MTU negotiate | 100–1000 ms |
A shot is a ~1 KB message. Spending three round trips to set up a connection for it is spending an order of magnitude more time on ceremony than on data — and on BLE it is disqualifying outright.
Requirements
- Connections MUST be established during session setup, before the first stroke. A shot arriving MUST NOT be the thing that triggers a connect.
- A participant MUST hold its connection open across idle periods. A golfer taking four minutes between shots is normal and MUST NOT cost a reconnect.
- Idle timeouts MUST be at least 300 seconds, and keepalive (§4) MUST be frequent enough that the connection never reaches one.
2. Bindings#
Three, chosen so every device class has a path that costs it nothing new.
2.0 The connection point is the compatibility surface#
Everything joins a bay the same way: as a participant that opens a connection, says what it is, and is told what it is connected to. A launch monitor, a simulator, a bag sensor, a scoreboard and a phone are the same kind of thing to an engine, and that is the property the whole standard rests on.
So the connection point must not vary, and the bindings below are only the byte pipe underneath it. Across all three:
- the handshake is the same three messages, in the same order —
session.hello,session.describe,session.manifest; - the message vocabulary is the same, with the same names (03 §1.2);
- the field catalog, provenance rules and schemas are the same;
- the refusal codes and their remedies are the same.
No binding may add, remove or alter a message, a field or a rule. There is no capability that exists on WebSocket and not on TCP, no extra handshake step on BLE, and no field whose meaning depends on how it arrived. A binding that needed one would be a second standard wearing this one's name.
The practical consequence, and the reason to care: porting a device between bindings is changing the socket, not rewriting the integration. A vendor who builds against raw TCP on a development board and ships over WebSocket has changed the transport layer and nothing else. The descriptor, the field names, the shot frames and the conformance results all carry over.
What an engine must offer#
An engine MUST accept §2.2 raw TCP. It is the floor, and it is the floor on purpose: a microcontroller with a TCP stack and a JSON encoder can be a fully conformant participant. Requiring TLS and a WebSocket implementation of every device would exclude precisely the class of device this standard exists to bring in — the single-field accessory, the bag sensor, the board somebody built in a garage — and a standard that only the largest vendors can implement is a standard that has already failed at its main job.
An engine SHOULD also offer §2.1 WebSocket over TLS. That is what makes a browser a first-class participant, which is how displays, spectator boards and phone pages join without a shim.
An engine MAY offer §2.3 BLE GATT, and should where the bay expects cable-free devices.
What a device must offer#
One binding. A device is conformant on the binding it implements and is under no obligation to support the others. Choose the one that costs your hardware least; the bay adapts to you, and the conformance kit tests the messages rather than the pipe.
The reference engine today implements raw TCP only. The
wssand BLE bindings are specified and not yet built there. If you are developing against it, use TCP — and because of the rule above, that choice constrains nothing about what you ship.
2.1 WebSocket over TLS (wss) — the default for software#
The primary binding for anything with an IP stack.
- Framing is built in. No delimiter guessing, no length-prefix bugs.
- Ping/pong is built in.
- A browser can speak it, which means a HUD, a wall display, a spectator board or a phone page is a first-class participant with no shim.
- TLS is not optional even on loopback. A bay machine is a shared, physically accessible box, and "it is only localhost" is how the OpenConnect socket ended up unauthenticated on
127.0.0.1:921.
2.2 Raw TCP with a length prefix — for embedded#
For devices that cannot carry a WebSocket stack.
┌────────────┬──────────────────────────┐ │ uint32 BE │ payload │ │ length │ (JSON, or CBOR profile) │ └────────────┴──────────────────────────┘
Big-endian, length excludes the header, maximum frame 1 MiB on the live path.
The length prefix is deliberate. Simulator sockets in the field commonly ship without one and without a delimiter guarantee, which forces every consumer to frame by walking JSON braces at the byte level. That works, and it is a permanent tax on every implementer, paid forever, to save four bytes once.
2.3 BLE GATT — for cable-free devices#
Notify characteristic for engine→device, write-without-response for device→engine, negotiated MTU with chunking above it, and the sequence numbers in §5 for reassembly. Constrained devices SHOULD use the CBOR profile here; JSON on BLE wastes scarce bandwidth on field names.
The UUIDs are allocated in 03 §5.1, and written out in full here because a vendor implementing this binding should not have to reconstruct one:
| UUID | |
|---|---|
| service | 738B0001-2551-4C83-BACE-C49B0FCE933A |
| engine → device, notify | 738B0002-2551-4C83-BACE-C49B0FCE933A |
| device → engine, write without response | 738B0003-2551-4C83-BACE-C49B0FCE933A |
Two characteristics, not a pair per purpose. Every message here is already framed and carries a type, so a second pair would re-solve multiplexing at the GATT layer — where each characteristic costs a handle, a descriptor and a row in every device's table. The live and bulk paths of §3 are the one thing that genuinely must not share a channel, and on this binding they do not share because bulk does not travel over it at all: BLE throughput makes tier 3 payloads hopeless, and 07 §1 says never to try.
2.3a A member's phone checking in over BLE#
A member checks in by scanning the bay's code: the code on the screen (03 §3.4c) or a printed placard. A placard is the same for every scan, so a placard check-in is completed only over this link, where the phone answers a challenge the engine has just made: that is the proof it is at the bay now. A screen code may use the link too, and does when the engine has no internet, because the phone tells the engine who it is directly.
The phone uses the transport service and characteristics of §2.3. It is not a device: it sends no session.describe and is never admitted to the session.
Framing. Each value written or notified is one header byte and part of a message. The header is 0x00 when more of the message follows and 0x01 on its last part. A message is the parts of one connection joined in order, and is one UTF-8 JSON object of at most 16 KiB. A part is no larger than the link's ATT MTU less the 3-byte ATT header.
The exchange.
| message | carries | |
|---|---|---|
| phone → engine | member.hello | v: 1 |
| engine → phone | member.challenge | challenge (at least 128 random bits, unpadded base64url), bay_id, round (the round a screen code issued now would name), and roster: each player's slot, label, and linked (an account already holds the position) |
| phone → engine | member.checkin | pass, pass_sig, challenge_sig, and the member's choice: slot with the label they saw, or slot: 0 for a new player |
| engine → phone | member.result | state: linked, waiting (added as a new player) or refused, with code and message when refused |
The phone shows the roster's unlinked players and asks the member which one they are, offering "not shown, add me" too, before it sends member.checkin. When every player is linked it sends slot: 0 without asking.
The pass. SGA issues a member pass to the member's app: a JSON object of v (1), pass_id, label (the name the member's settings show to others), device_key (the phone's Ed25519 public key, base64), issued_at, expires_at (RFC 3339) and key_id. pass_sig is SGA's Ed25519 signature over the pass's JCS canonical form (00 §3b). The phone holds the private half of device_key and never sends it. challenge_sig is the phone's Ed25519 signature over the UTF-8 bytes of
sga-member-checkin/1 <bay_id> <challenge>so it is good at this bay, for this challenge, and nowhere else. SGA publishes the keys passes are signed with at /.well-known/sga-member-pass-keys.json on the cloud's host, as keys[] of key_id, alg (Ed25519) and public_key.
Rules for the engine:
- It MUST keep SGA's published keys while it is online and use them when it is not. With none, it MUST refuse (
code: keys) rather than trust a name. - It MUST refuse a pass whose
pass_sigdoes not verify against the key itskey_idnames, a pass outsideissued_attoexpires_at, and achallenge_sigthat does not verify against the pass'sdevice_key. - It MUST accept one
member.checkinper challenge, within 60 seconds of issuing it. - A chosen
slotMUST name a player with no linked account whose label is still thelabelthe phone sent; otherwise it refuses, and the phone asks again. - On success it puts the member on
bay.checked_in(03 §3.4a) with acheckin_refit derives from the session and thepass_id, the pass'slabel, and the chosenslot, and journals amember_checkinrecord carryingcheckin_ref, thepassexactly as received,pass_sig,challenge,challenge_sig,slotandat. - Check-ins made this way end with the round, as every check-in does (03 §3.4c).
Rules for the cloud: on receiving a member_checkin record it MUST verify pass_sig, the pass's live window at at, and challenge_sig over the message rebuilt from the bay the journal came from and the record's challenge. Only a record that verifies binds checkin_ref to the member, so the position the roster ties to that reference, and its strokes, become theirs. The bay never learns a member id: it holds the pass id and the label.
3. The live path and the bulk path are separate#
The single most important rule in this file after §1.
Head-of-line blocking is real and it will bite exactly when it matters most. Multiplex a 40 MB swing video upload and a shot event onto one TCP connection and the shot waits behind the video — not because of bandwidth, but because TCP delivers in order. A round with a recorder attached would get slower shots than a round without one, which is an absurd property for a recording system to have.
So:
| channel | carries | properties |
|---|---|---|
| live | shots, manifests, settings, heartbeats, actuator commands | small frames, latency-critical, never blocked |
| bulk | journal upload, video, diagnostic frames, bundle transfer | large, throughput-oriented, freely delayed |
- The two MUST NOT share a TCP connection.
- Bulk transfers MUST be rate-limited so they cannot starve the live path on a shared link.
- A stalled or failed bulk transfer MUST have no effect on the live path.
And forwarding never blocks on processing. A shot goes to its subscribers before the engine parses it, journals it, or does anything else with it. A slow recorder, a wedged display or an unreachable cloud MUST NOT delay a shot reaching the simulator. This is normative.
3.1 Nagle off#
TCP_NODELAY MUST be set on every live-path socket.
This is the classic silent 40 ms. Nagle's algorithm holds a small write waiting for more data to coalesce; combined with delayed ACK it produces exactly the stall pattern our traffic triggers — small, bursty, latency-critical messages. It is one setsockopt, it is invisible when you forget it, and forgetting it would consume eight times our entire engine latency budget.
Compression MUST be off on the live path. A 1 KB shot does not benefit, and the compressor adds latency and a shared-state failure mode. Bulk may compress freely.
4. Keepalive#
Two mechanisms, doing different jobs.
Transport keepalive — WebSocket ping/pong, or TCP keepalive on the raw binding. Every 10 seconds, 30-second failure threshold. Its only job is to keep NAT and firewall state alive and detect a dead peer.
Application heartbeat — every 1 second on the live path, carrying:
- monotonic
t_ms, for the clock-skew measurement in 00 §3 - device liveness and readiness
- current
manifest_version, so a participant that missed a manifest update is detected within a second rather than at the next shot
The application heartbeat is not redundant with the transport one. A device whose TCP stack is fine but whose sensor thread has wedged answers pings and reports nothing — and that failure is invisible without a liveness heartbeat.
Heartbeat loss is graded, not binary: 3 missed heartbeats degrades the participant in the manifest, 30 seconds removes it and triggers renegotiation. A device is not dead the first time a packet is lost.
5. Ordering, gaps, and reconnection#
Every message on the live path carries a per-connection seq, monotonic from 1.
- A gap in
seqis detectable and MUST be reported. Silent loss is the failure mode that makes a scorecard reconciliation impossible to interpret later. - Reconnection resumes the same session by
session_idplus last receivedseq. A dropped Wi-Fi link mid-round MUST NOT start a new session or lose what was buffered. - A disconnected participant buffers its outbound messages, bounded by memory, and flushes on reconnect. Shots survive a reconnect; they are the last thing we may drop.
- Reconnect backoff is exponential with jitter, capped at 5 seconds. Capped low, because a bay reconnecting after a sim restart should be back before the golfer has re-addressed the ball. Jittered, because two hundred bays in a facility recovering from one network blip must not retry in lockstep.
This rule is for links inside the bay only. The cap is deliberately aggressive because a golfer is standing there and the population behind any one failure is small. Applied to the cloud uplink the same cap is harmful, for reasons set out in §6.5.
5.1 A connection lasts as long as the session, and no longer#
A participant MUST close its connection when its session ends. A simulator whose round is over, or whose software is closing, does not leave the socket open.
This is not tidiness. Some roles admit exactly one participant (03 §2.4), and a lingering connection means the seat is still taken by software nobody is using — so the next simulator a facility opens is refused, and the bay looks broken to an operator who did everything right.
Nothing else can fix this from the outside. The engine cannot distinguish software that has quietly exited from software that is merely between shots: a golfer taking four minutes to hit is normal and MUST NOT cost a reconnect, so silence cannot be treated as departure.
5.2 session.going_away — the engine leaving on purpose#
A socket that simply closes is indistinguishable from an engine that crashed. The participant applies the backoff above, which is right for a crash and wrong for a planned thirty-second update — where it produces a reconnect storm against an engine that is trying to start.
So the engine says so first:
{
"type": "session.going_away",
"reason": "update",
"reconnect_after_ms": 30000,
"resume": {
"session_id": "sga:session:01J9X4...",
"session_epoch": 3,
"journal_head": "sha256:7a3f...",
"standing_state_retained": true
},
"advisory": "Engine updating. Back in about 30 seconds."
}Rules:
- The engine SHOULD send
session.going_awayto every affected participant before closing, whenever the close is intentional. - A participant MUST NOT treat it as an error, alarm the operator, or start its normal backoff. It waits
reconnect_after_msand reconnects. - Jitter applies to the interval exactly as it applies to backoff. Two hundred devices told to return in thirty seconds must not all return at thirty seconds precisely, or the interval has merely scheduled the storm it was meant to prevent.
- Absent
reconnect_after_msmeans do not reconnect. There is nothing to come back to until something changes at the bay. - A participant MUST continue to buffer outbound messages across the gap, as it does for any other disconnection. A planned outage is not a reason to drop shots.
What resume promises#
Three things have to survive, and each is already solved elsewhere in the standard — resume only says whether they will be.
session_id. Reconnection resumes the same session bysession_idplus last receivedseq, exactly as after an unplanned drop. Nothing new.session_epoch. A restart is a clock rollover (00 §2):t_msbegins again at 0 and the epoch increments, so ordering stays a(session_epoch, t_ms)comparison and the session clock never runs backwards.journal_head. The journal is hash-chained and signed (07 §1). A restart MUST continue that chain rather than begin a new one. A round whose tamper-evidence breaks precisely at the seam of an engine restart is worse than one with no chain at all, because the seam is the first place a reviewer looks.
standing_state_retained: false means a participant re-asserts its standing values after reconnecting. Either way the engine republishes the manifest on resume, which is already how any change is announced — so no participant needs a second mechanism to find out what survived.
Where this leads later#
reason: superseded is the same message used to tell a participant it has lost a role to another, with no reconnect interval. That path is deliberately specified and deliberately not used: the standard currently refuses the second simulator rather than displacing the first (03 §2.4). Having the mechanism written down means that decision can be revisited without a protocol change.
6. The uplink — the one link that leaves the building#
Everything above this point is the bay's own network: a launch monitor, a simulator, a display, all on a wire or a Wi-Fi radio somebody in the facility owns. The uplink is different in kind, and the differences all point the same way.
| bay-local links | the uplink | |
|---|---|---|
| owned by | the facility | nobody we can call |
| failure | a device, a cable, a radio | a router, an ISP, a region |
| affects | one bay | every bay behind the same failure |
| far end | one engine | one cloud, with 250,000 other bays on it |
| golfer feels it | immediately | not at all, if this section is obeyed |
That last row is the design goal. Nothing about the uplink is in the path of a stroke. The bay records, signs and journals whether or not the cloud exists (07 §1), so every rule here is free to optimize for cost and for the cloud's survival rather than for latency.
6.1 Correctness does not live in the transport#
Before choosing between transports it is worth being precise about what the transport is allowed to be responsible for, which is nothing.
- Every journal record carries
session_idand a monotonicseq(02 §5). - The cloud holds a server-authoritative high-water mark per round and applies records idempotently: a record it already has is accepted and discarded.
- So delivery is at least once, never exactly once, and the bay's only correct behavior on doubt is to send again.
Three requirements follow, and they are the load-bearing ones in this section:
- A bay MUST NOT advance its upload pointer on anything except an acknowledgement from the cloud naming the sequence the cloud holds. A connection that closes after the upload and before the acknowledgement is indistinguishable from one that closed before the upload arrived, and the bay MUST resolve that ambiguity by resending. Duplicate delivery is a non-event; a pointer moving past an unconfirmed record is a lost round.
- Every rung of the ladder below MUST carry the same records with the same acknowledgement semantics. A transport that needs its own reconciliation rule is not an alternative rung, it is a second protocol, and the standard does not have two.
- A bay MAY change rung at any moment, including mid-round, without ceremony. There is no handover, because there is no state in the transport to hand over.
This is the same argument that lets a phone carry a journal it cannot read. It is restated here because it is what makes the rest of this section cheap: a fallback ladder is only simple if falling down it cannot corrupt anything.
6.2 The ladder#
| rung | transport | when | what it buys |
|---|---|---|---|
| 1 | persistent connection (wss) | preferred, always tried first | one handshake per session; sub-second check-in; no per-request overhead |
| 2 | transactional HTTPS | rung 1 unavailable or unreliable | works through anything that passes ordinary web traffic |
| 3 | courier (§6.6) | no route to the cloud at all | the round still arrives, eventually |
Rung 3 sits in the table for completeness, but it is not chosen the way the others are and it does not wait for them to fail: see §6.6.
The order is cost. A round on rung 1 pays one TLS handshake and then sends frames; the same round on rung 2 pays a handshake — or at best a connection reuse and a fresh request — every time it has anything to say, which for a bay watching for check-ins is every few seconds. On rung 1 the check-in feed and the journal upload share the one connection, so a member scanning in appears essentially at once and the journal ships on a socket that was already open.
Rung 2 SHOULD merge those two conversations into a single long-held request rather than running them as separate polls: the bay sends whatever journal it has, the cloud holds the response open for a bounded interval waiting for a check-in to report, and the answer carries both the acknowledgement and the news. One request per interval instead of two, and a join that lands in seconds rather than on the next poll.
6.3 Demoting: fast, and for the right reason#
Rung 1 fails in two quite different ways, and treating them alike gets one of them wrong.
Deterministic failures are a property of the network, not luck. A proxy that refuses the upgrade, strips the Upgrade header, or answers with an HTML error page will do so every time. Spending a retry budget to discover that is pure delay.
An upgrade attempt that does not produce a WebSocket is read by what came back, because a status code alone does not say whether the path or the cloud is at fault:
| what came back | what it is | what the bay does |
|---|---|---|
any status other than 101, 429 or a 5xx; a 101 without a valid Upgrade handshake; an answer that is not HTTP | the path refusing WebSocket | demotes at once |
429, or 503 with Retry-After | the cloud shedding load (§6.5) | stays on rung 1 and returns when told |
| any other 5xx | either: an intermediary's error page, or the cloud unwell | asks rung 2 once, and demotes only if rung 2 answers |
- A bay MUST demote to rung 2 on the first refused upgrade, without consuming a failure budget, and MUST record what the far end said.
- A bay MUST NOT demote on a
429or a503carryingRetry-After. - On any other 5xx a bay SHOULD make one ordinary rung-2 request on the same path. If it is answered, the 5xx came from something that refuses WebSocket and passes ordinary traffic, and the bay MUST demote. If it fails too, the failure would equally have failed rung 2, and the bay MUST NOT demote. Without this test the bay has to guess: guessing "path" demotes the whole fleet during a cloud outage, and guessing "cloud" strands a bay behind a proxy that answers every upgrade with an error page.
Probabilistic failures are what a budget is for. A connection that established and later dropped may have been a Wi-Fi roam, a NAT table expiring, or a middlebox with an idle timer shorter than our keepalive.
- A bay MUST demote after more than three mid-session drops within a rolling hour.
- A bay MUST NOT count against that budget any failure that would equally have failed rung 2: DNS failure, TLS failure, connection refused, no route, or a 5xx from the cloud. Those are the internet being down or the cloud being unwell, and neither is evidence about WebSocket. A standard that got this wrong would demote its entire fleet permanently the first time it had an outage of its own.
- A bay MUST NOT count a
429or a503carryingRetry-After. That is the cloud deliberately shedding load (§6.5), and punishing the rung for obeying it would move the whole fleet onto the more expensive path at precisely the moment the cloud is least able to carry it.
6.4 Promoting: slowly, and without flapping#
Demotion with no counterpart is a one-way door: a bay that hits one bad afternoon stays on the expensive rung until somebody restarts it.
- A bay SHOULD re-probe the rung above at the start of each session, and otherwise no more often than hourly, doubling the interval after each failed probe up to a day.
- A probe MUST NOT interrupt the working rung. The bay opens the candidate connection alongside, and switches only once it is established and has carried an acknowledged exchange. A promotion that costs an upload is a promotion that costs more than it saves.
- A bay MUST remain on a rung for at least fifteen minutes before promoting, or before demoting on the drop budget (§6.3). Without hysteresis the ladder oscillates, and an oscillating transport burns exactly the handshakes the ladder exists to avoid.
- A refused upgrade (§6.3) demotes at once, however recently the bay arrived on rung 1. A rung that cannot connect carries nothing, and holding a bay on it for the rest of fifteen minutes only stops its journal moving.
6.5 The stampede#
The hard problem here is not the outage. It is the recovery.
A bay whose link drops for six seconds is nobody's problem. A region whose link drops for twenty minutes is a different event, because the thing that ends it ends it for every bay at the same instant — and every one of those bays is, at that instant, sitting in a tight retry loop waiting to find out.
The reconnect discipline in §5 — exponential, jittered, capped at five seconds — is right for what it was written for: a simulator restarting, where the golfer is standing there waiting. Applied to the uplink it is actively dangerous, and the arithmetic says why. A five-second cap means that after the first minute every bay is probing every five seconds, so the moment service returns, the entire affected fleet discovers it inside one five-second window:
| recovery discipline | 250,000 bays arrive over | peak arrival rate |
|---|---|---|
| §5 backoff, 5 s cap | ~5 s | ~50,000/s |
| random offset, 120 s window | ~120 s | ~2,100/s |
| random offset, 900 s window | ~900 s | ~280/s |
Two orders of magnitude, bought with one random number per bay. And the 50,000/s column is not a capacity problem to be solved with capacity: every bay arriving in that window arrives carrying twenty minutes of queued journal, so it is the most expensive possible second in which to receive all of them at once.
Jitter alone does not fix this, and it is worth being clear about why, because "we jitter our backoff" is the usual and insufficient answer. Jitter randomizes phase relative to when each client started. An outage destroys that relationship: it synchronizes the whole fleet on a single external event, and after a few cycles of a capped backoff every bay is phase-aligned to the outage rather than to its own history. Jitter on a five-second interval spreads arrivals across five seconds, which is not spreading.
So a long interruption changes the discipline, not merely the interval.
- A bay whose uplink has been unavailable for more than 60 seconds MUST enter spread recovery, and MUST NOT attempt a connection except at a uniformly random offset within a window
W. Wis 120 seconds on entry, doubles after each failed attempt, and is capped at 15 minutes.- The offset MUST be redrawn before every attempt. A bay that draws once and keeps it belongs to a fixed cohort: the same bays lead every recovery, and if that leading wave is what takes the service down again, the same wave hits it on every cycle.
- The offset MUST NOT be derived from bay identity, installation id, or anything else stable. A deterministic order is that same fixed cohort with extra steps, and it is a fingerprint besides.
- Uniform, specifically. Decorrelated jitter is a good answer to a different question — one client finding a sustainable retry rate — and it concentrates arrivals toward the low end of its range. Here the goal is a flat arrival rate, so the distribution should be flat.
Link-up is a synchronizing event, not a shortcut#
An implementer will reasonably want to shortcut the wait: the operating system knows when the route came back, so why sit out fourteen more minutes of a window after connectivity has plainly returned?
Because link-up is the synchronizing event. Every bay behind one facility router sees it in the same millisecond; every bay in a region sees it within a second or two. A link-up signal that bypasses the offset reproduces the stampede exactly, using a faster and more precisely synchronized trigger than the retry loop it replaced.
- A local connectivity signal MAY reset
Wto its entry value, so that a bay does not serve out a long window after a short outage. - It MUST NOT bypass the offset.
What the cloud owes in return#
- The cloud SHOULD shed load with
429or503and an explicitRetry-Afterrather than by failing connections. A refusal that says when to come back is the only refusal a client can obey correctly. - A bay MUST honor
Retry-After, and MUST add uniform random jitter of at least ±20% to it. A fleet obeyingRetry-After: 60to the second re-synchronizes itself perfectly: the load-shedding mechanism becomes the stampede generator, on a tidy one-minute cycle. - A cloud closing rung-1 connections on purpose (a server restarting) SHOULD say so with WebSocket close code 1001. Every bay on that server receives the close at the same instant, so a bay SHOULD reconnect at a uniform random offset within 30 seconds rather than at once.
Mass demotion is the same problem wearing a different hat#
If something breaks rung 1 fleet-wide — a certificate, a load balancer configuration, an intermediary shipping an update — then within minutes every bay in the fleet is on rung 2. Rung 2 costs more per byte, so the failure mode is that a rung-1 incident becomes a rung-2 capacity incident several minutes later, about when the responders have declared the first one contained.
- Rung 2 MUST be provisioned for the entire fleet, not for the fraction expected to be using it.
- A bay demoting because of a server-signaled condition MUST apply a spread-recovery offset before its first attempt on the lower rung. A demotion triggered from the far end is, by construction, simultaneous across everyone who received it.
What the golfer experiences during all of this#
Nothing. The round is on disk, hash-chained and signed, and it was never waiting on an acknowledgement to continue. The one thing that genuinely degrades is check-in latency — a member scanning in during spread recovery may wait minutes rather than seconds — and that is the correct thing to sacrifice, because a member scanning in during a regional internet outage was not going to have a fast experience under any design.
6.6 The courier rung is not like the other two#
Rungs 1 and 2 are alternatives: the bay is on exactly one of them, it chooses which, and the choice is entirely within its control. Rung 3 is none of those things. It depends on a person being in the room, having opted in, and consenting — so it cannot be selected, only offered, and it can be offered and come to nothing.
That makes it a parallel path rather than a lower rung, and three consequences follow.
- The bay MUST NOT treat arming the courier path as leaving rungs 1 and 2. Spread recovery (§6.5) and promotion probes (§6.4) continue on their own schedule regardless.
- A successful courier delivery MUST NOT count as evidence that any rung works. It says something about a phone's cellular carrier and nothing about the bay's uplink.
- Play MUST NOT wait on a courier at any point, for any reason. The journal is on disk and signed; the courier is an optimization on when it arrives.
It is the one path immune to the stampede#
This is worth stating because it inverts the usual expectation about a last-resort mechanism.
Everything in §6.5 exists because an outage synchronizes the fleet. The courier path is not synchronized by anything, because the event that triggers a delivery is a member walking out of a bay — a distribution that is already spread, that no outage aligns, and that no recovery concentrates. It also runs over a different physical network: a fiber cut or a routing failure that takes a facility's wired uplink usually leaves cellular standing.
So during spread recovery — precisely when the bay is deliberately not probing — the courier path should be working at full tilt. It is the only thing moving data during the window, and it contributes nothing to the herd it is moving data around.
No new channel is opened to arm this#
BLE has two modes and the difference is the whole of this subsection. Advertising is a device broadcasting a few dozen bytes every second or so to nobody in particular: no pairing, no connection, no session, and a power cost close to nothing. A connection is two devices agreeing to talk, which costs a negotiation of 100–1000 ms (§1) and keeps both radios awake. They are not degrees of the same thing. A device can advertise for a year and never once connect.
The presence beacon in 07 §2.2 is already advertising, for a session the member already consented to. So the courier path does not open anything to arm itself:
- Arming MUST be a flag in an advertisement that is already being sent, not a new advertisement and not a connection. The radio cost of arming is zero, and a bay with nothing to ship is indistinguishable on the air from one that has never heard of this section.
- A connection MUST NOT be established for courier purposes until there is something to ship and a member has consented to carry it.
- A phone may already be connected for an unrelated reason — introducing or relaying the member's own equipment (13 §4.1), which is an ordinary participant link and has nothing to do with the uplink being down. Where such a link exists, the courier SHOULD use it rather than opening a second one, and the member MUST still choose separately whether to carry a journal. Agreeing to relay a bag sensor is not agreeing to spend cellular data on somebody else's round.
- A bay whose presence beacon is not running — no consenting member in the session, presence not enabled — MAY advertise for a courier directly. That is the one case where arming costs a new advertisement, and it is the case where no phone has opted into anything, so nothing is being asked of a device that did not volunteer.
Arming, and the clock#
| uplink state | courier state | what the bay does |
|---|---|---|
| healthy | dormant | nothing. The flag is clear; the beacon is unchanged |
| gone < 60 s | dormant | §5 retry. Most interruptions end here and never needed a courier |
| gone > 60 s | armed | sets the flag, after a jittered delay (below) |
| courier consents | carrying | now a connection opens. Seals what it has, hands over tier 1, re-hands incrementally as records accrue |
| courier out of range | handed, unconfirmed | connection closes. Pointer does not move; the records stay |
| receipt returns | confirmed | pointer advances to the sequence the receipt names |
| uplink returns | dormant | clears the flag; resumes from its pointer, re-sending anything unconfirmed |
- The arming threshold MUST be the same 60 seconds that enters spread recovery. Two thresholds for one condition is two things to get out of step.
- A bay MUST delay setting the flag by a uniformly random interval of up to 60 seconds after arming. The reasoning is §6.5 at facility scale: forty bays behind one router lose the uplink in the same millisecond, and forty phones prompting at once is forty consent dialogs a member did not ask for.
- A courier MAY decline, and a bay MUST treat declining as ordinary. It MUST NOT re-prompt the same member for the same session more than once.
- A courier SHOULD bound how many bays it carries for concurrently, and SHOULD present a facility as a single consent rather than one per bay.
- The advertisement MUST NOT carry roster information, member names, or anything else identifying who is playing. It identifies a bay with something to ship; that is all a phone needs to decide.
Hand off early and often, not at the end#
The instinct is to wait until the round is complete and hand over one clean journal. That instinct loses rounds.
The courier is a person, and the moment they are most likely to leave is the moment the round ends — which under that design is the moment the handoff was about to happen. The window in which a courier is available is the round itself, not the end of it.
- A bay SHOULD hand over incrementally: at least once per hole, and at least every five minutes while a consenting courier is in range.
- Each handoff carries everything not yet confirmed, not only what is new. A courier that arrived on the fourteenth hole carries the whole round, because the earlier chunks went to a member who has already gone home and whose receipt may never come back.
- The cost of this is close to nothing: tier 1 for a complete round is 20–60 KB compressed (07 §1.1), so an incremental chunk is a couple of kilobytes and the duplication is a rounding error against the thing it protects.
The receipt, and why a courier cannot be taken at its word#
The hard part of the courier path is not delivery. It is that the acknowledgement has to come back, and it comes back over the same unreliable human channel — if it comes back at all. Meanwhile §6.1 is absolute: the pointer moves only on an acknowledgement naming the sequence the cloud holds.
A courier saying "I delivered it" cannot satisfy that. It is an unverifiable claim by an untrusted intermediary, and believing it is exactly how a round is lost — the bay advances past records the cloud never received, and nothing downstream can tell, because the records are simply absent rather than wrong.
So the cloud issues a courier_receipt: a signed statement naming session_id, the acked_seq it now holds, and the chain digest it verified. It is signed against the pinned SGA root, reusing the bundle machinery (04 §3) — the courier cannot forge it for the same reason it cannot forge a bundle.
- A bay MUST NOT advance its pointer on a courier's claim of delivery.
- A bay MAY advance it on a receipt whose signature verifies and whose
session_idis one it holds. It MUST ignore any other receipt. - Where several receipts arrive, the bay takes the highest verifying
acked_seq. Multiple couriers carrying the same journal is expected and the cloud already deduplicates by chain digest (07 §1.2). - Never receiving a receipt MUST be survivable and MUST NOT be an error state. The bay keeps the records, and re-sends them when a rung comes back.
The failure modes fall out well. A courier that loses the journal costs nothing, because the bay still has it. A courier that withholds the receipt costs one duplicate upload, because duplicates are free. Those are the only two attacks available to it, and both are priced in.
Standing down#
- A bay MUST stop advertising as soon as a rung is carrying traffic again.
- It MUST NOT hand over anything after its pointer has advanced past it.
- A courier already holding data it no longer needs to deliver MAY deliver it anyway. There is no harm — it is sealed, it is destined for the cloud, and the cloud discards what it has.
What does not travel this way, and why#
Bundles already travel back down the courier path (07 §1). The check-in feed is the obvious next candidate — a member standing at an offline bay with a phone that has cellular is, on the face of it, a perfect relay for exactly the message they are waiting for.
It is not, and the reason is worth recording before somebody builds it. The check-in feed is authorized by the rotating nonce the engine minted (03 §3.4a), and its response carries the screen names of members who scanned in. Relaying it the naive way would hand a member's phone both a rotating secret it has no business holding and PII for other players, which 07 §1.2 forbids outright.
- An engine MUST NOT disclose a rotating check-in nonce to a courier.
- A courier-relayed check-in, if it is ever specified, MUST be constructed so the courier carries a response it cannot read.
Out of scope here. The constraint is recorded so the shape of any future answer is fixed in advance rather than discovered after it ships.
7. The latency budget#
End to end, measured from the monitor emitting a shot to the simulator receiving it:
| segment | budget |
|---|---|
| engine processing and fan-out | < 5 ms |
| loopback transport | < 1 ms |
| LAN transport | < 5 ms |
| total added by SGA-OIS on a wired bay | < 10 ms |
For context, a golfer perceives simulator response above roughly 100 ms, and the launch monitor's own measurement and processing latency is typically 100–200 ms — declared per field as latency_ms in the capability descriptor. Our budget is a small fraction of a delay the player already accepts and cannot change.
Implementations SHOULD measure and report their own added latency, and the conformance kit SHOULD assert against this budget. A performance claim nobody measures decays quietly, and this is the claim that determines whether vendors adopt us.