Skip to main content

Backend handoff: Live Hub, phase L3

Audience: backend developer (enode_backend_v1). Status: never sent, and no longer the document to send. On the afternoon of 2026-10-07 its seven items were folded into live-hub-backend-handoff-open.md, which holds everything that is open on the server side, renumbers the questions and changes three things: a live set's user is slim for every reader (its S2), a measurement in board names its metric by id and key only, and a pairing expires and says how often to ask. This file stays for its longer notes on how today's server was read. Date: 2026-10-07.

L0 and L2 are on backdev and hold (see live-hub-backend-reply-2.md and live-hub-backend-handoff-l2.md). This document is the server work for the wall: the gym display, a TV that shows the session to everyone in the room. It is phase L3 of the plan, together with the server items of L4 and L5 that change the same data. They were announced at the end of the first handoff, under "After L0".

Today the wall is the portal in a browser that is signed in as the coach. It learns of a lift when the finished set has been sent. What changes for the people in the gym:

  • The wall says who is about to lift, with which load, and who is next.
  • It shows the reps of a set while they are lifted.
  • A TV shows the wall without a coach being signed in on it.
  • A set that arrives late is not announced as if it had just been lifted, a personal best is recognised, and the wall opens quickly in a long session.

Field, route and event names below are proposals. Rename freely; the behaviour is what is asked for. Each item can be answered with yes, no or a counter-proposal. The open questions are numbered 1 to 23 across the document, so they can be answered by number.

The one rule: the server only adds​

Decided on 2026-10-06, and unchanged: tablets in gyms do not update on release day, so an app build from today must keep working against the new server.

  • New request fields are optional. New response fields are additions.
  • No existing field changes its meaning.
  • No existing endpoint changes its answer. What is new comes on a new route, under a new event name, or in a new field.
What a client from today doesWhat the server does with it after this handoff
The tracking app sends finished sets onlyStored and published as today. The set has no completedAt; the wall treats it as just lifted, as today (item 3)
It sends no heartbeat, or one without platformsThe device row has no platforms. The wall shows the lifter when the finished set arrives, as today (item 1)
It leaves reps that were ruled out off the bodyAs today: the set holds counted reps only (item 7)
It reads reduced after each set it deliversThe same answer: no running set, no ruled-out rep, no new wrapper
The portal reads complete and follows live_session_set.created, .updated and .deletedThe same answers and the same frames, sensor packages included (item 5)
The standalone competition-dashboard app joins through /competition_dashboardUntouched until decision D9 is taken (item 6)
The native iOS app calls switch_athleteUntouched (item 1)

Conventions kept from L2​

Read off "Agreed with the backend" in the L2 handoff. Everything below follows them.

  • The device is the one in the JWT, not the device-id header.
  • A field without a value is absent, not null. For a state that means "not known", never "no".
  • Events go out on workout_live_session:sets:<liveSessionID>, the stream every viewer holds. A new kind of thing gets a new event name, because the hub fans out per topic and not per subscriber. A client that does not know a name ignores it.
  • A body that describes a state is the whole state. Only the latest is kept. A request that changes nothing publishes nothing.
  • Reads that carry times also carry the server's now. Tablets and wall screens have clocks that are off.
  • A device in no session gets 204, as on a set POST. A refused read gets 404.
  • Times are Unix milliseconds.

What is observed, and what is read​

Your L0 and L2 changes are not in the checkout this was written from (1ffdcaec, 2026-10-05, branch bugfix/loading_factor_api_technique). So every statement about today's server has one of three bases, and says which:

BasisMeaning
Observed on backdevA request was sent on 2026-10-07 and the answer read. The table below
As read in the local checkoutRead in the code at 1ffdcaec, not executed. It may differ from what backdev runs
As read in the clientRead in enode-tracking (tracking app and portal), working tree of 2026-10-07

Observed on backdev before writing​

On 2026-10-07 between 09:49 and 09:58, with a disposable account as the session's owner, one athlete under it, the owner's own device linked through the device picker, and a temporary probe that is deleted again.

ObservedResult
PUT /workout_live_sessions/switch_athlete/:userID from the linked device200. Nothing on the set topic. A display that joined through /competition_dashboard/join gets a state event: viewMode is attemptBoard, with the athlete (id, name, bodyweight) and the athlete's stored attempts, none before the first set
The same from a device in no session, and after the owner removed the device204
The same with a JSON body; and for a user id the caller has no access to200, the body is ignored; 404
POST /competition_dashboard/join with a session id and device headers, no credentials200 and a displayToken. Its events stream opens with a state event and gets one more per set POST (viewMode is currentAttempt). Each has an id line that counts up
That displayToken as a display-token header, without a JWT, on reduced, on GET /workout_live_sessions/:id and on the set topic401 each
A set POST with fields the server does not know: completedAt and running on the set, valid: false on one of two reps201. The fields do not come back. The rep sent with valid: false is stored and returned like the other
The times of a setcreated as the device sent it. createdAt and updatedAt from the server, with a fraction of a millisecond (1791359397758.1082)
The user of a set, in a frame on the set topicid, name, email, birthdate, bodyHeight, bodyWeight, gender, tags, assignedTags, role, availability, trainable, hasImage, updatedAt
reduced, complete and GET /workout_live_session_sets/:setIDSets without reps; sets with reps and every rep's packages; one set with its reps and packages
complete with ?packages=falseThe same answer. An unknown query flag is ignored
A frame on the set topicNo id line
PUT /workout_live_sessions/presence with an unknown field platforms200, a live_session.device event goes out, the field is neither stored nor returned
PUT /workout_live_session_sets/running/:id and GET /workout_live_session_sets/board/:id404 both. The two routes proposed below are free
GET /history/v2/records, and POST /history/v2/records with the athlete's id200 both. An athlete without history has records: []

Not tried: switch_athlete from an athlete's own login, a session with two devices, a real sensor package, and the standalone dashboard app itself.

One thing beside the subject: at 09:48 backdev answered 502 for about forty seconds (Cloudflare origin_bad_gateway). A load run of another work block was just ending. Whether the two are related was not looked into. The probe was repeated afterwards.

Waiting for the product owner​

These are product decisions. The items below are written so that each outcome is covered, and say where an outcome changes the server work.

DecisionWhere it bites
D3 · How the wall gets its picture. Recommended in the concept and part of its plan for L3: a paired display. The screen shows a number, the coach confirms it. Not marked as decidedItem 6 assumes it
D9 · The standalone competition-dashboard app: fold it into the paired display, or keep both. Not decided yetItem 6, with what each outcome means for the server
Does "an app from today keeps working" also hold for the standalone dashboard app and for the native iOS app? Both use routes this handoff leaves aloneItems 1 and 6 leave switch_athlete and /competition_dashboard as they are until told otherwise
What the wall may show about a person. The sheet an athlete agrees to names "your name and photo" and "exercise, load, reps and bar speed of each set you complete". It names neither who is about to lift with which load (item 1), nor reps of a set that is not completed yet (item 2), nor a best from before the session (item 4), nor body weight and squad, which the "Before" picture of the concept showsItems 1, 2, 4 and 6. For item 6 the proposal is name and photo only
D5 · Who decides good lift or no lift. Answered on 2026-10-07: a person does, by hand, at the tablet that records the lift — an observer or a judge. Nothing rules a lift automatically. The product owner is open to a better way than today'sItem 7 was extended by a ruling on the attempt. Whether the coach may also rule from the portal is proposed there and waits for a yes

The seven items, in the order they should be built​

The items keep the numbers they have in the concept (work block H3), so that references elsewhere still fit. They are listed in the order that helps the wall most. Every step can ship without the others.

StepItemWhat the wall gainsServer work, roughlyShips alone
A1 · Who is on the platformThe screen before a lift, and "up next"One optional field on the heartbeat, on the device row and in its eventYes
A3 · When a set was completedNo highlight for a late set; lifts in the order they were liftedTwo optional body fields, one field on the setYes
B2 · The set while it is liftedThe screen during a liftTwo routes, two event names, one rule for the readsYes. Best after A, it shares a key with item 1
C5 · The board without sensor packagesThe wall opens without loading every sensor package of the sessionOne readYes
D6 · A display without a loginNo coach account open on the gym TVPairing, a token, and what it may readAfter the decisions. It names the reads of A to C
E4 · Best before today"Personal best" as the line of a lift, also on a paired displayOne read over personal_bestsYes. On a paired display it is the only way
F7 · Ruled-out reps, and the ruling on an attemptRuled-out reps are shown and not counted; "No lift" is what the judge ruled, and such a lift is not rankedOne column and a second list; one field on the setYes

Item 1 · Who is on the platform (step A)​

What is asked. The server takes from each device, per station: which athlete is selected, on which exercise, with which load on the bar, and who is next in the rotation. It tells everyone who watches the session.

Why. Before a lift the wall names the lifter, the load, and what a good lift would do to their place. Today the wall learns of a lifter only when the set is over.

Today.

  • As read in the client: the tracking app knows all of it. A tablet runs up to three stations side by side, each with its own athlete. The active set carries the planned load and the planned reps, and after a set the app works out who rotates in next. It sends none of it. The call exists (switchLiveAthlete in packages/core/src/api/live-sessions.ts) and has never had a caller.
  • Since 2026-10-07 in the working tree: the tracking app sends the L2 heartbeat, with the athletes of each station and the sensor's state (packages/core/src/live-session/presence.ts). The product owner has confirmed that the selected athlete can be added on the app's side; it is a small change once the server takes the field.
  • Observed on backdev: switch_athlete answers 200 from a linked device, 204 from a device in no session and 404 for a user the caller cannot reach. It reaches displays of /competition_dashboard only, as a state event with the athlete and the athlete's stored attempts. Nothing goes to the set topic.
  • As read in the local checkout: the caller must own the session (WorkoutLiveSessionService.swift:522), so an athlete's own phone would get 404. The board state is one per session and lives in process memory (CompetitionDashboardService.swift:21), so with two platforms the last switch wins and a restart loses it. The request loads the athlete's last six sets and builds each attempt with its bar path before it answers (CompetitionDashboardUpdatePublisher.swift:227-286).
  • As read in the native iOS app (application, branch appRefactoring, 2026-10-02): it calls switch_athlete when the selected athlete of its tracking view changes (TrackingView.swift:340). That is the only caller found.

So switch_athlete carries the athlete and nothing else: no load, no exercise, no station, no next lifter. The proposal leaves it exactly as it is and does not build on it.

Proposed shape. One more optional field on the heartbeat of L2, PUT /workout_live_sessions/presence. The heartbeat already is the whole state of a device, it is sent at once when something changes, and its lastSeenAt tells the wall when to stop believing it.

{
"sensorConnected": true,
"athleteIDs": ["8FCE053A-0885-4170-B22E-536A04B3E064"],
"platforms": [
{
"station": 1,
"userID": "8FCE053A-0885-4170-B22E-536A04B3E064",
"exerciseDefinitionID": "B1160428-8F82-41B2-9B9C-515F537942B4",
"currentPersistentSetID": "0C6B0F2A-51E4-4C0B-9D56-0A1B2C3D4E5F",
"work": true,
"load": 117.5,
"plannedReps": 1,
"next": {
"userID": "AF945C54-AA90-4D23-8B7B-9A7A2042C216",
"load": 107.5
}
}
]
}
Field of a rowMeaning
stationWhich column of the tablet, 1 to 3. A phone has one. With the device's label it names the platform
userIDThe athlete selected in that station
exerciseDefinitionIDThe exercise that is open. Optional
currentPersistentSetIDThe id the set will be sent under when it is completed. The same id as in item 2. Optional
workFalse for a warm-up. Optional
loadThe load planned for the set, in the base unit, as value of the set's loadingMass measurement. Absent when the set has none
plannedRepsOptional
nextWho the app would rotate to next in this station, with their planned load if it knows one. Optional
  • platforms absent: not known, as for a device with an older app. An empty list: nobody is selected on this device. A station that is left out is empty.
  • Store it with the presence. Return it on the device row of GET /workout_live_sessions/:id/devices and in the live_session.device event, with user in place of userID, as a set carries it. A wall without a login has no list of users to look a name up in.
  • Publish live_session.device at once when platforms differs from what is stored. The same body again publishes nothing.
  • It ends with the device: left, removed, session ended. No timer on the server. The wall stops showing a platform whose device has not been seen for a minute.
{
"deviceSessionID": "5D0C7C1E-7C3B-4E5B-8F2D-1A2B3C4D5E6F",
"deviceLabel": "Platform 1",
"lastSeenAt": 1791359400000,
"sensorConnected": true,
"platforms": [
{
"station": 1,
"user": { "id": "8FCE053A-0885-4170-B22E-536A04B3E064", "name": "Mia Schulz" },
"exerciseDefinitionID": "B1160428-8F82-41B2-9B9C-515F537942B4",
"currentPersistentSetID": "0C6B0F2A-51E4-4C0B-9D56-0A1B2C3D4E5F",
"work": true,
"load": 117.5,
"plannedReps": 1,
"next": {
"user": { "id": "AF945C54-AA90-4D23-8B7B-9A7A2042C216", "name": "Elif Kaya" },
"load": 107.5
}
}
]
}

An app from today. It sends no heartbeat, so its row has no platforms and the wall shows its lifter with the finished set, as now. A server from today ignores the field (observed), so the app may send it before the server knows it.

Checked on backdev by.

RequestExpected
Heartbeat with one row in platforms, then GET …/:id/devicesThe row comes back with station, user.id, user.name, load and next.user.name
The same with the set topic openOne live_session.device event with the same platforms
The same heartbeat againNo event
Heartbeat with platforms: []The device row has an empty list; one event
Heartbeat without platformsThe device row has no platforms; everything else as today
The owner removes the device{ deviceSessionID, left: true } as today; the devices read no longer has the row
switch_athlete, before and after200, 204 and 404 as observed, with the same state event for displays of /competition_dashboard

Questions.

  1. On the heartbeat, as proposed, or on a route of its own (PUT /workout_live_sessions/platform, event live_session.platform)? The body would be the same.
  2. switch_athlete stays untouched. Does anything besides the native iOS app call it in production, and does anything besides the standalone dashboard read its state event?
  3. A userID in platforms that the session's owner has no access to: refuse the heartbeat, or drop that row? Which rule holds for userID on a set POST today?

Item 3 · When a set was completed (step A)​

What is asked. Each set carries the time it was completed, on the server's clock.

Why. After a lost connection the tablet delivers the sets it held back. The wall then announces a lift from ten minutes ago as the latest one, with a line and a glow. With the time of completion it adds such a set quietly and keeps the lifts in the order they were lifted.

Today.

  • Observed on backdev: a set has created (as the device sent it), createdAt (when it arrived) and updatedAt.
  • As read in the client: the tracking app stamps created when the set is completed (completeActiveSet in packages/core/src/training/sessions.ts) and sends it on the create. The first handoff said created is stamped when the workout starts. For the tracking app that is not so. It is still no basis for the wall: it is the tablet's clock, the update body does not carry it, and a set that a PUT creates gets the time of the request (question 3 of your first response).
  • As read in the client: the portal takes as the latest lift the set that reached its browser last.

Proposed shape. Two optional fields on the create body and on the tracked update body, one field on the set DTO.

{
"completedAt": 1791359307654,
"sentAt": 1791359397702
}
FieldMeaning
completedAt in a bodyWhen the set was completed, on the device's clock
sentAt in a bodyThe device's clock at the moment this request leaves. The app sets it anew on every attempt
completedAt on the set DTOThe same moment on the server's clock: the time the request arrived, minus sentAt − completedAt. Never later than createdAt
  • Without sentAt: store completedAt as sent, capped at the arrival.
  • Without completedAt: the set has none. The wall treats it as just lifted.
  • The first value stays. A later body does not move it.
  • Returned wherever a set is returned: reduced, complete, the single set, the frames, and the read of item 5.

An app from today. It sends neither field, and a server from today ignores both (observed). The build in the stores has no queue: it sends a set at the moment it is completed or not at all, so "just lifted" is right for it. A build with the queue and without the two fields is announced late, as now.

Checked on backdev by.

RequestExpected
POST with completedAt 90 s before sentAtThe set's completedAt is createdAt minus 90 000, within a second
The same with both fields an hour ahead, as from a tablet whose clock is offThe same result
POST without the two fields201, the set has no completedAt, everything else as today
PUT …/tracked/:id for a set that was never POSTed, with both fieldsThe set exists, with completedAt worked out the same way
A later PUT with another completedAtUnchanged

Questions.

  1. Do you correct to your clock, as proposed, or would you rather store both values as sent and return them, and the client subtracts?

Item 2 · The set while it is lifted (step B)​

What is asked. A device sends the set it is working on: with no reps yet, then with each rep. Viewers get it as it grows. No board ranks it. The finished set replaces it.

Why. During a lift the wall shows this set over the athlete's previous one, rep by rep: "rep 3 of 5, 0.81 m/s". Today nothing reaches the wall until the athlete has confirmed the set.

Today. As read in the client: only completed sets are sent (packages/core/src/live-session/broadcast-diff.ts). The set that is being lifted exists on the tablet under the id it will later be sent with, and that id does not change when the set is completed.

Proposed shape. A route and an event name of their own. On every route and under every event name that exists today, a set stays a finished set.

  • PUT /workout_live_session_sets/running/:currentPersistentSetID (JWT; the device from the JWT). The body is the body of PUT …/tracked/:currentPersistentSetID as it is today, holding the set so far: its planned values in measurements, the reps lifted so far in workoutLiveSessionReps, each with its currentPersistentRepID. The app sends no dataPackages here. The body is the whole set so far and replaces what you hold for that id. 204 for a device in no session.
  • Event live_session_set.running on the set topic, with the field names of the set DTO:
{
"currentPersistentSetID": "0C6B0F2A-51E4-4C0B-9D56-0A1B2C3D4E5F",
"running": true,
"deviceSessionID": "5D0C7C1E-7C3B-4E5B-8F2D-1A2B3C4D5E6F",
"user": { "id": "8FCE053A-0885-4170-B22E-536A04B3E064", "name": "Jonas Weber" },
"exerciseDefinitionID": "B1160428-8F82-41B2-9B9C-515F537942B4",
"work": true,
"order": 3,
"side": "bilateral",
"measurements": [
{ "metricID": "…", "value": 150, "metric": { "key": "loadingMass" } },
{ "metricID": "…", "value": 5, "metric": { "key": "repCount" } }
],
"workoutLiveSessionReps": [
{
"currentPersistentRepID": "9A1B2C3D-4E5F-4A6B-8C7D-0E1F2A3B4C5D",
"phase": "concentric",
"created": 1791359300000,
"measurements": [
{ "metricID": "…", "value": 0.81, "metric": { "key": "velocityMean" } }
]
}
],
"updatedAt": 1791359301200
}
  • Nobody sees it who did not ask. A running set is not in reduced, not in complete, not in the single-set read, and not in a live_session_set.created, .updated, .deleted or .lobby_* frame. It causes no state event on /competition_dashboard. The read of item 5 returns running sets when asked with ?running=true, under a key of their own.
  • It ends in one of three ways:
    • The finished set arrives, by POST or by PUT …/tracked, with the same currentPersistentSetID. You drop the running one and publish live_session_set.created as today. The client replaces by that id.
    • DELETE /workout_live_session_sets/running/:currentPersistentSetID, when the reps were discarded or another athlete was selected. You publish live_session_set.running_ended with { "currentPersistentSetID": "…" }.
    • The device leaves or is removed, the session ends, or nothing came for ten minutes. The same event.
  • It must not touch the saved workout. A running set is rewritten with every rep. If it went through the staging rows that the finish upload claims by id, it would walk the path of item 0 of the first handoff once per rep. It is display state that lives for a minute: please keep it apart from measurements and data_packages.
  • How often. At most once per repetition and station, and once when the set comes up with no reps yet. Nothing while nothing changes.

An app from today. It sends nothing here. Its set appears when it is finished, as now. A viewer from today never sees a running set, because it comes under a new event name and is in none of today's reads. A server from today answers the new route with 404 (observed); the new app then stops sending running sets for that session and goes on as an app from today.

Checked on backdev by.

RequestExpected
PUT …/running/:id with no reps200; one live_session_set.running with no reps; reduced and complete unchanged; no state event on /competition_dashboard
The same id with one rep, then with twoOne event each, with the reps of the body in its order
The read of item 5 with ?running=true, and withoutThe running set under running; without the flag it is not there
POST of the finished set under the same idlive_session_set.created with the complete set; the read has no running set with that id
DELETE …/running/:id for another running set204; live_session_set.running_ended
PUT …/running/:id from a device in no session204; nothing published
Running sets, then finish the training in the appThe saved workout has all its measurements

Questions.

  1. Where do you hold a running set: apart from the measurement tables, as asked, or as a flagged row like a lobby set? If a row: what keeps it out of the finish upload's way?
  2. Is currentPersistentSetID enough as its key, or do you want to give it an id that the finished set then keeps?
  3. One request per repetition from every lifting station, each answered with a publish. Is that fine, or should the app hold back, for example to one request per second and device?

Item 5 · The board without sensor packages, one bar path on request (step C)​

What is asked. A read of the session's sets with their reps and without the raw sensor packages. The packages of one set stay available on request.

Why. The wall draws a bar path for the lift it features, not for every set of the evening. Today it loads every package of the session whenever it opens, reconnects or returns to the front. In a long session that is several megabytes each time, and the people in the gym look at an empty screen for that long.

Today. Observed on backdev: reduced has sets with their measurements and no reps. complete has every rep with its measurements and its packages. GET /workout_live_session_sets/:setID returns one set, complete. A created or updated frame is the complete set.

The portal needs the reps: it ranks by rep values, shows the reps of a set as bars, and its line "bar speed dropped 12 %" reads them. So reduced is too little and complete too much. A load run on backdev on 2026-10-07 (tests/live/load/live-set-load.ts, simulated sensor data) put one set of five repetitions at 32 kB on the wire. Twenty athletes with fifteen sets each are then about 10 MB per read.

Proposed shape.

  • GET /workout_live_session_sets/board/:liveSessionID, for everyone who may read complete. A new route and not a flag on complete, because the answer is an object and complete is a list.
{
"now": 1791359400352,
"sets": [
{
"id": "E5A18B8A-1CFF-4382-80BA-4B3C9DF23E7E",
"currentPersistentSetID": "0C6B0F2A-51E4-4C0B-9D56-0A1B2C3D4E5F",
"completedAt": 1791359307610,
"workoutLiveSessionReps": [
{
"id": "1AA7C719-33DD-4FB4-97E5-77E5BA3DBC4A",
"currentPersistentRepID": "9A1B2C3D-4E5F-4A6B-8C7D-0E1F2A3B4C5D",
"phase": "concentric",
"measurements": [],
"hasDataPackages": true
}
]
}
],
"running": []
}

A set is the set of complete, field for field, except that a rep has no dataPackages and says hasDataPackages instead. ?lobby=true works as on the other reads. running is there only with ?running=true (item 2).

  • The bar path on request is the read that exists: GET /workout_live_session_sets/:setID. Asked for it: keep it, and let the same readers use it as board. An ETag from the set's updatedAt would help a wall that asks for the same attempt again after a reload; it is not needed.
  • The frames stay as they are, packages included. A wall then has the bar path of a new lift without a second request, and a portal from today keeps reading it from the frame.

An app from today. The tracking app reads reduced, the portal reads complete. Both keep their answer. A new portal against a server from today gets 404 on board (observed) and reads complete as now.

Checked on backdev by.

RequestExpected
GET …/board/:id after two sets with packages200; now; two sets; every rep with its measurements and hasDataPackages: true, none with dataPackages
GET …/complete/:id and GET …/reduced/:id for the same sessionAs before this handoff
GET /workout_live_session_sets/:setIDThe set with its reps and their packages
board from a linked device that is signed in as an athlete200
board from a signed-in user with no relation to the session404
board with ?lobby=true in a session that has a lobby setThe lobby set is there, with lobby: true

Questions.

  1. A new route, as proposed, or do you prefer a flag on complete and the server's now somewhere else?
  2. Does a set's updatedAt move with every stored change, also one that only changes its reps? The client tells two versions of a set apart by it. That is why this handoff does not ask for numbered events.

Item 6 · A display without a login (step D)​

What is asked. A screen that is not signed in is paired with one session and gets one token. With it the screen may read that session, its devices and its sets, and nothing else.

Why. Today a coach signs in on the gym TV and leaves the account open there for the evening. With pairing the coach types a number from the TV into the portal, and the TV shows the wall.

This item waits for two decisions, D3 and D9; see the table at the top. It is written for the recommended outcome of D3. For D9, both outcomes are laid out at the end of the item.

Today.

  • The wall is the portal's display mode behind the coach's login.
  • Observed on backdev: POST /competition_dashboard/join hands a displayToken to anyone who sends a session id. The token opens that session's state and events and nothing else: the set reads, the session read and the set topic answer 401. The first handoff left join open on purpose until a session has a pin.
  • As read in the local checkout: the token is the id of a device-session row of the dashboard category under the session's owner (CompetitionDashboardController.swift:74-104).

Proposed shape.

  1. The screen asks for a pairing. POST /live_displays/pairing, no credentials, limited per address. The screen shows pairingCode and keeps pairingSecret.
{
"pairingCode": "483107",
"pairingSecret": "b7c1…",
"expiresAt": 1791360000000
}
  1. The coach confirms. POST /workout_live_sessions/:id/displays, owner only, with the number and a name for the screen. A wrong number is a 404 and counts as a miss, like a wrong join code.
{ "pairingCode": "483107", "label": "Gym TV" }
  1. The screen picks up its token. GET /live_displays/pairing/:pairingSecret, every few seconds. { "state": "waiting" } until the coach has confirmed, then once:
{
"state": "paired",
"displayToken": "p9Qe…",
"liveSessionID": "BBB732A7-A53D-4A36-AEC6-FF2662407026"
}
  1. The token is random and long, not the id of a row. The screen sends it as the display-token header. That name exists since L0 and passed the CORS preflight on backdev (reply 2). The token is bound to one session. It dies when the owner removes the display (DELETE /workout_live_sessions/:id/displays/:displayID) and a day after the session ended, so the wall can still show the result.

What the token opens. Reads are answered as the session's owner would get them, in the language the screen asks for.

RouteFor the display
GET /workout_live_sessions/:idThe session without participatingDevices; the owner as id and name. With joinCode, which the wall shows
GET /workout_live_sessions/:id/logoAs for the owner
GET /workout_live_sessions/:id/devicesLabel, name, lastSeenAt, sensorConnected, lobbySetAt, platforms (item 1). athleteIDs as athletes with id and name
GET /workout_live_session_sets/board/:id and GET /workout_live_session_sets/:setIDAs item 5 describes them, with the running sets of item 2
GET /workout_live_sessions/:id/best_beforeItem 4
GET /realtime/events with the topic of its sessionEvery event of that topic
The catalogues the views label with: metrics, text contents, data-package types, GET /exercise_definitions/:idAs for a signed-in user. They hold nothing personal
Everything else401, as without credentials
  • A person is id, name and hasImage for a display. Today the user of a set also holds the e-mail address, date of birth, height, body weight, gender and tags (observed). None of that may reach a screen that is not signed in.
  • The exercise comes with the set. exercise on a set is the reader's own exercise today. For a display it is the owner's, with name filled in.
  • The display is seen. It sends PUT /live_displays/presence every twenty seconds. The devices read gets a second list, displays, with displayID, label, pairedAt and lastSeenAt, and a change goes out as live_session.display. A list of its own, so that a client from today does not take a TV for a tablet.

An app from today. Nothing changes for it. The coach's login on a TV keeps working, and so does every route of /competition_dashboard.

Checked on backdev by.

RequestExpected
POST /live_displays/pairing without credentials200 with a code, a secret and an expiry
GET …/pairing/:secret before the coach confirmedwaiting
The owner confirms with the code; with a wrong code; with eleven wrong codes200 and the display's row; 404; 429
GET …/pairing/:secret afterwards, twicepaired with a token and the session's id; then 404
With the token: session, logo, devices, board, one set, best_before, the set topic200 each. A set posted afterwards arrives on the stream
The user of a set read with the tokenid, name, hasImage, nothing more
With the token: the board of another session, a set POST, /history/v2/records, a user readRefused; nothing written
The owner removes the displayThe stream ends; the token answers 401
The owner ends the sessionThe token still reads the session and its board
join, state, events and leave of /competition_dashboardAs before

What D9 means for the server.

The app is folded into the paired displayBoth are kept
Display clientsOne: the portal's display page with the token aboveTwo
/competition_dashboardStays untouched while a standalone app from today is in use. After that join, state, events and leave can goStays for good. join still hands a token to anyone with a session id, so it needs the pairing or a pin too, and with it a new build of the standalone app
Who is on the platformItem 1. switch_athlete stays for the native app until that sends platformsTwo sources. The board state is one per session and in memory, so it needs a state per platform and a place that survives a restart
What an attempt isOne definition, in the clients: work sets, three slots, a set without a counted rep is a no-liftTwo. The server's (order up to 3, a tag for a no-lift, the rep with the longest bar travel) has to learn item 7 as well
The server's bar path and peaksThe concept keeps them and serves them to the paired display. Since 2026-10-07 the portal draws both from the raw packages itself. So this needs a second answer from the product owner: still served, or retired with the appServed as today
The attempt-board build inside every set POSTCan leave the upload pathStays, or moves into a job

Questions.

  1. A table of its own for pairings and tokens, or do you want to keep a device-session row of the dashboard category, as join creates it?
  2. Which of the catalogues need a JWT today, and can a display token read them?
  3. Photos: is there a way for the token to read the profile picture of a user who has a set in its session? Until then the wall shows initials.
  4. For D9: how often was join called on production in the last 30 days, from which builds, and who can change the standalone app?
  5. A token that reads for a day after the session ended: acceptable?

Item 4 · Best before today (step E)​

What is asked. For an athlete and an exercise in a session: the athlete's best values from before the session started, from the records the server keeps.

Why. "Personal best · 2.5 kg more than ever before" is the strongest line the wall has, and before a lift the wall says what there is to beat. The live feed holds today's sets only. Since 2026-10-07 the portal reads the records itself, with the coach's login, through POST /history/v2/records — one request for the athletes on the board, plus one read per athlete to find the athlete's own exercise. A display without a login cannot do that, and the "Before" screen needs the value before the athlete's first set exists.

Today.

  • Observed on backdev: GET /history/v2/records and POST /history/v2/records answer, the second for the athletes the caller may read.
  • Observed on backdev while building the portal's read (2026-10-07): a record appears a few seconds after the finished training was uploaded, not with the upload's answer. An absoluteLoad record carried value: 70, unit: "kg", previousValue: 60 and the athlete's own exercise id; an estimated1RM record carried previousValue: -99999999. An athlete the caller may not read is left out of the answer, without an error.
  • As read in the local checkout: personal_bests holds the current best per user, exercise and record type: absoluteLoad, repsAtLoad, volumeExercise, volumeSession, estimated1RM, velocityAtLoad, peakPower, jumpHeight. Each has achievedAt, the value it replaced (previousValue, previousAchievedAt), and for the load-bound types the load (contextLoad). The exercise is the athlete's own exercise, not the definition. Values are stored in base units; the records routes convert them for the caller. Records are detected when a finished training is uploaded, not from live sets (PersonalBestService.swift).

Proposed shape. A read, for everyone who may read board: GET /workout_live_sessions/:id/best_before. Without a query it answers for every athlete and exercise that has a set in the session or stands in a platforms row. With ?userID=…&exerciseDefinitionID=… it answers for one pair.

{
"now": 1791359400352,
"pairs": [
{
"userID": "8FCE053A-0885-4170-B22E-536A04B3E064",
"exerciseDefinitionID": "B1160428-8F82-41B2-9B9C-515F537942B4",
"records": [
{
"recordType": "absoluteLoad",
"metricKey": "loadingMass",
"value": 115,
"achievedAt": 1788700000000
},
{
"recordType": "velocityAtLoad",
"metricKey": "velocityMean",
"value": 0.42,
"contextLoad": 100,
"achievedAt": 1787900000000
}
]
}
]
}
  • Before today is before startedAt of the session. A record that was achieved later is answered with the value it replaced, if that one is from before. Otherwise it is left out.
  • Base units, like value of a measurement on a set. Not the reader's display unit.
  • An athlete without history has records: []. That is an answer, and different from a pair that is missing.
  • Records that belong to no exercise (volumeSession) are left out.

The concept asked for this "with the athlete's first set", as a push. It is a read here for two reasons: the screen before a lift needs the value before the first set exists, and a push would put one more lookup into the set POST. If you prefer the push, the same records on the created frame of a pair's first set would do for the line after the lift.

An app from today. It asks for none of it. No existing answer changes.

Checked on backdev by.

RequestExpected
Upload a finished training with a 100 kg work set for an athlete; then a live session with a set of that athlete and exercise; GET …/best_beforeOne pair, with absoluteLoad at 100 in the base unit
The same with ?userID=…&exerciseDefinitionID=…That pair alone
An athlete who has never trained the exerciseThe pair with records: []
While the session runs, upload a finished training with 110 kg; read againStill 100
The athlete stands in platforms and has no set yetThe pair is there
A signed-in user with no relation to the session404

Questions.

  1. Is personal_bests filled on backdev and on production, that is, has the backfill run?
  2. Is every record on the scale of the live measurement with its metricKey? The doubt comes from the local checkout: its last commit scales a rep's force and power by the set's load, and there is a migration that rescales the peakPower records. So is peakPower comparable with the powerPeak of a live rep?
  3. A read, as proposed, or the push with the first set?

Item 7 · Ruled-out reps, and the ruling on an attempt (step F)​

Two things, and the second is the one a competition needs. They can be delivered apart.

7a · Reps that were ruled out​

What is asked. A rep can be stored as ruled out: valid is false. Such a rep is kept with its values and its sensor package, and is not counted.

Why. A rep is ruled out by the app's check for mis-detected reps when a set completes, or by a person on the tablet. Today that rep vanishes from the wall, and a work set whose reps were all ruled out looks like a set that was never lifted. With the flag the wall shows the rep, muted.

Today.

  • As read in the client: the live rep has no valid. The app leaves a ruled-out rep off the body (packages/core/src/api/mappers/live-sessions.ts), and sends an update without it when a rep is ruled out later. The recorded training has the flag.
  • Observed on backdev: a rep sent with valid: false among the others is stored and returned like any rep, and the flag is gone.

Proposed shape. The counted reps stay where they are. Ruled-out reps travel in a second, optional list, in bodies and in answers.

{
"workoutLiveSessionReps": [
{ "currentPersistentRepID": "9A1B2C3D-4E5F-4A6B-8C7D-0E1F2A3B4C5D", "phase": "concentric" }
],
"workoutLiveSessionRuledOutReps": [
{ "currentPersistentRepID": "3F2E1D0C-9B8A-4765-8432-1A0B9C8D7E6F", "phase": "concentric" }
]
}
  • A rep has the same shape in both lists. On the way out each rep also says valid, true or false, so a rep can be read on its own.
  • One table, one column, default true. Match by currentPersistentRepID across both lists: a rep that is ruled out, or ruled in again, stays the same row and keeps its package.
  • workoutLiveSessionRuledOutReps is absent when there are none. The same in complete, in the single set, in the frames, in board and in a running set.
  • Whatever the server works out from reps skips them: the attempt of the standalone board, its bar path and its peaks.

Why two lists and not one flag in the list that exists: workoutLiveSessionReps means "the reps that count" to every reader from today. A flag inside it would change that meaning. A portal that was not reloaded would count ruled-out reps, and a new app in front of a server from today would have them stored as counted ones, which is what was observed. With a second list neither can happen, and nobody has to deploy in a certain order.

An app from today. It sends counted reps only, in the list it always used. The answer it gets has no second list.

Checked on backdev by.

RequestExpected
POST with two counted reps and one ruled-out rep, each with a package201. complete has two reps in workoutLiveSessionReps and one in workoutLiveSessionRuledOutReps, each with its own package and its valid
PUT that moves a rep from the first list to the secondThe same rep row, by its currentPersistentRepID, with its package
PUT that moves it backThe same
POST without the second listAs today, and no second list in any answer
A frame on the set topic, and boardThe same two lists
A work set whose reps are all ruled out, read through /competition_dashboardNot built from a ruled-out rep

Questions.

  1. Two lists, as proposed, or one list with valid on each rep and an agreed order of deploys?

7b · The ruling on an attempt​

Added on 2026-10-07, after the product owner said how a lift is ruled today.

What is asked. A set can carry a ruling: good lift or no lift. The device that recorded the set sends it with the set. The owner of the session can set or change it afterwards. Every read and every frame returns it.

Why. In a competition a person rules each lift, by hand, at the tablet that records it. Nothing rules a lift automatically. Today that person has one tool, the validity of a rep, and it does not reach the wall as a ruling:

  • As read in the client: a ruled-out rep is left off the live set (item 7a). The set is still sent, with its own values. On a board ranked by load — the default — the set therefore still carries its load, is ranked, and can be announced as the new leader although the lift was ruled out. Only on a board ranked by a value that reps carry does the attempt show as failed.
  • The ruling belongs to the attempt, not to a rep. A lift can be ruled out for a reason no sensor sees, and its rep is then perfectly valid as a measurement.

With a ruling on the set, the tablet asks the judge one question when an attempt is completed, the wall says "No lift" for that attempt, and a no-lift is not ranked and not announced. A judge who does not stand at the platform rules from the coach's view, and a mis-tap can be corrected there.

Proposed shape. One optional field on the set, in create and update bodies and in every answer and frame:

{
"currentPersistentSetID": "0C6B0F2A-51E4-4C0B-9D56-0A1B2C3D4E5F",
"ruling": "noLift"
}
  • ruling is good or noLift. Absent means not ruled: a training set, or a lift the judge has not ruled yet. A reader treats an absent ruling as today.
  • The device changes it with the update it already sends (PUT /workout_live_session_sets/tracked/:currentPersistentSetID).
  • The owner rules too: PUT /workout_live_session_sets/:id/ruling with { "ruling": "noLift" }, or { "ruling": null } to take a ruling back. For the session's owner and users with access to the owner, as for the set topic. It changes nothing else of the set.
  • Either change goes out as live_session_set.updated.
  • The server ranks nothing, so it has nothing to recompute. Where it builds an attempt itself (the standalone board), a no-lift is a no-lift.
  • If the no-lift tag of question 19 is the rule on production, ruling can be read from and written to that tag. Then nothing new is stored; the set DTO only has to say it.

An app from today. It sends no ruling. Its sets have none, and every reader treats them as today.

Checked on backdev by.

RequestExpected
POST of a set with ruling: "noLift"201. complete, reduced, the single set and the created frame carry ruling: "noLift"
The device's PUT with ruling: "good"The set says good; an updated frame
The owner's PUT on …/ruling with noLift, then with nullThe set says noLift, then carries no ruling; an updated frame each time; reps, values and packages untouched
The same PUT from a user without access to the owner403, the set unchanged
POST without rulingAs today, and no ruling in any answer

Questions.

  1. As read in the local checkout, the standalone board calls an attempt a no-lift when the set carries the tag 627bb862-5d4d-4e20-9cef-8ad4da42f1e3 (CompetitionDashboardUpdatePublisher.swift:599-606). Is that the rule on production? If so, is ruling best read from and written to that tag, with tagIDs or ruling returned on the set DTO?
  2. A field ruling as proposed, or the tag alone?
  3. May the owner change a set's ruling, on the route proposed or on another? Today's update by the owner — does it reach a set another device sent?
  4. Should the standalone board follow the same ruling, so that both boards say the same about a lift?

Not asked for​

  • Numbered events and resume. The first handoff named them for L5. A frame on the set topic has no id line today (observed). With board a reconnect costs one small read, and the client orders two versions of a set by updatedAt (question 9). They can come later if a wall turns out to miss events.
  • Frames without packages, on a second topic. Only if frames prove heavy.
  • Moving the attempt-board build off the upload path. It depends on D9. The load run of 2026-10-07 has the numbers: a set POST was answered in about 0.5 to 0.8 s in the middle and in 1.3 s when ten arrived at once, against 0.08 s for a request with nothing to do.
  • What the coach steers on the wall, rules, presets and the start list. They live in the session's config, and live_session.updated carries a change since L2.
  • A ruling worked out by the sensor, and three referees with a device each. The ruling of item 7b is one person's. Both can be added on top of the same field later.
  • Results of an ended session. The owner can read them (observed on 2026-10-06).

What the clients will do with it​

  • Tracking app: platforms in the heartbeat, per station. A running set after each repetition. completedAt and sentAt on every send from the queue. Ruled-out reps in the second list instead of leaving them out.
  • Portal: the screens before and during a lift. No line and no glow for a set that was completed more than half a minute before it arrived. board when a view opens, the single set for a bar path that did not come with a frame. A display page that pairs by number. The personal-best line from best_before.

Acceptance, for every step​

ScenarioExpected
An app build from today, unchangedEvery request answers as before
The contract tests of L0 and L2 (15-live-l0-contract, 16-live-set-topic, 25-live-l2-contract) against the new buildPass unchanged
Finish a training whose sets were live, with running sets and ruled-out reps among themThe saved workout has all its measurements

The checks of each item become one contract test against backdev, written like tests/live/offline/25-live-l2-contract.live.test.ts, with disposable accounts.

One last question.

  1. Is the order A to F right from your side? Which step is cheaper or dearer than it looks here?

Agreed with the backend​

Nothing yet. The backend's answer goes here, by question number. Where it and the body disagree, this section will hold.

Observed on backdev after delivery​

Nothing yet. When a step is on backdev, its checks are run and their results are listed here, with the date.