Backend response 6: Live Hub, the open points of 13 October are built
Audience: frontend (enode-tracking, portal). In reply to: "Live Hub —
what is still open on the server" of 2026-10-13, and the sections "What a
display cannot read yet", "A set does not say which device sent it", "Asked
2026-10-12" and "Asked 2026-10-13" of live-hub-backend-handoff-open-2.md.
Date: 2026-10-13.
Status: points 1 to 8 and the two frames you had not checked are built on
bugfix/support_reports_handoff, on top of develop df12f00e. Not committed
and not pushed yet; backdev has it once it is rebuilt from that working tree.
Backend suite: 570 tests green, six of them new. One migration. Nothing here
has been run against a client.
Built
| Your point | What | Differs from what you asked |
|---|---|---|
| 1 · "Don't share" | POST /workout_live_sessions/decline_sharing, sharingDeclined on the owner's devices read and on the left frame | 204 in every case. The read names the athlete |
| 2 · Why a session ended | endedBy and lastSetAt on the session and on live_session.updated | Read from the times, not stored. See below for an "End session" after the session had run out |
| 3 · An ended session takes a display | The guard is gone | A display paired after the end reads for a day from its pairing |
| 4 · A photo | GET /live_displays/users/:id/image | Also for the owner and for whoever a device names in athleteIDs |
| 5 · Catalogues | GET /live_displays/metric_categories; exerciseBase written out on exercise_definitions/:id | The base and its group come as objects, not as one id field |
| 6 · Displays among the devices | Paired displays are left out of participatingDevices | No |
| 7 · The device of a set | deviceSessionID on a stored set | Taken from the device-id header, as the set routes find the session |
| 8 · A stream ends on unlink | The set stream of a device that left is ended | Found by its user, not by its device. See below |
| The session frame for a display | live_session.updated on /live_displays/events in the shape of GET /live_displays/session | No |
| The device frame for a display | live_session.device on /live_displays/events in the shape of GET /live_displays/devices | No |
Not built: the tour row of point 9 (it is data, see the end), and the free
field on a display that presence would write.
1 · A declined "Share your training?"
POST /workout_live_sessions/decline_sharing, token-protected, no body,
for the device of the token.
| Case | Answer |
|---|---|
| The device is in a live session | 204. The answer is stored, then everything leave does: unlinked, device_session.live_session_changed to the device, live_session.device with left: true to the viewers. Storing and unlinking are one transaction |
| The device is in no live session | 204, nothing stored |
| No device session for the caller | 404, as leave |
It is 204, not 200, in both cases, because that is what leave answers.
Stored: at most one row per live session and user: the session, the user,
declinedAt on the server's clock, and the device's name at that moment. A
second decline moves declinedAt and the name.
| Event | The row |
|---|---|
The same user calls acknowledge_sharing while linked to that session | Removed |
| The same user joins that session by code | Removed |
| The session ends | Kept |
| The owner adds the device again | Kept |
| The session or the user is deleted | Removed |
| "Run again" | Not copied; it is a new session |
Read: GET /workout_live_sessions/:id/devices always has
"sharingDeclined": [
{ "userID": "…", "declinedAt": 1791900000000, "deviceName": "Mia's iPhone" }
]
newest first, an empty list when nobody declined, and also for an ended
session. Same readers as before. It is not on GET /live_displays/devices
and not on the session.
Push: the live_session.device frame of that leave carries
"sharingDeclined": { userID, declinedAt, deviceName } beside
deviceSessionID and left: true, on the owner's topic. A display's stream
gets the same frame without it.
Your questions. (a) One row per session and user, as you need it. (b) The read names the athlete. That was decided on the backend side and not confirmed by the product owner; if it has to be the count only, say so, it is a small change.
2 · Why a session ended
WorkoutLiveSessionReturnDto and the live_session.updated frame have:
endedBy:owner,expirationoridle. Absent while the session runs.lastSetAt: when the last set was stored. Absent before the first set.
GET /live_displays/session and a display's live_session.updated have both
too.
endedBy is not stored. Only "End session" writes an end time, so the rest
follows from the times: a session without a stored end ended by whichever of
the end date and the idle limit ran out first.
One case to know: if the owner presses "End session" on a session that
had already run out, endedBy stays expiration or idle, while endedAt
is the moment of the press. And because nothing is stored, an owner who
changes the end date or the idle limit of an ended session can change what
endedBy says.
3 · An ended session takes a new display
POST /workout_live_sessions/:id/displays answers 200 for a session that is
over, for its owner, with no time limit.
What changes for a token: until now a display's token died 24 hours after the session's end. A display paired after the end would then be dead at once for an older session, so its token is good for 24 hours from its pairing. A display paired before the end is unchanged.
4 · A photo for a display
GET /live_displays/users/:id/image with display-token →
{ id, imageData }, the shape of /users/profileImage/:id, with the same
ETag and If-None-Match (304).
| Who | Answer |
|---|---|
| Has a set in the display's session | 200 |
Stands in a platforms row of a linked device, as the lifter or as next | 200 |
Is named in athleteIDs of a linked device | 200 |
| The session's owner | 200 |
| Anyone else | 404 |
| One of the above without a photo | 404 |
| No token or a wrong one | 401 |
The last two rows of "who" are more than you asked for: a display shows those
people too (athletes on a device row, user on the session).
platforms is kept in memory, so after a restart of the server a person who
only stands on a station is a 404 until that device's next heartbeat.
5 · Catalogues
-
GET /live_displays/metric_categories: the answer of/metric_categories, with itsIf-Modified-Sincehandling. -
GET /live_displays/exercise_definitions/:idnow writesexerciseBaseout, and in itdisplayBaseExerciseGroup:"exerciseBase": {"id": "…", "identifier": "…", "movementType": "…","displayBaseExerciseGroup": { "id": "…", "name": "…", "order": 0, "media": "…" }}Before,
exerciseBaseheld itsidonly;exerciseBase.idis where it was. The group's id isexerciseBase.displayBaseExerciseGroup.id. Only the display's route does this;/exercise_definitions/:idis unchanged.
There is still no read of the normative base categories and no list of all exercise bases for a display. Say if the group on the definition is not enough.
6 · Displays among the session's devices
participatingDevices of the owner's session no longer carries a paired
display (a device session whose deviceId starts with display:). Displays
of the old /competition_dashboard/join are still in it, as before.
7 · The device of a stored set
deviceSessionID on reduced, complete, board, the single set, the
display's board and sets/:id, and the created / updated frames.
- Set when the set is first stored, by
POSTor by aPUT …/tracked/:idthat creates it. No later update changes it, whoever sends it. - Absent for a set stored before the field.
- It is the device session behind the request's
device-idheader, the same one the set routes find the live session by. - It is kept when that device signs out, so it can name a device that is no longer in any list.
8 · An open stream ends when its device is unlinked
When a device leaves a session, by leave, by decline_sharing, by the
owner removing it, by moving to another session, or because the session
ended or was deleted, the open streams on that session's set topic are ended
for the user signed in on that device.
| The user | The stream |
|---|---|
| Owns the session or has access to its owner | Stays open: they may follow it anyway |
| Still has another device linked to the running session | Stays open |
| Anyone else, so an athlete on their own phone | Ended |
Two limits. The stream is found by its user, so an athlete with the same login on two phones that were both in the session loses both streams only when the last one leaves. And it works within one server process, which is what production and backdev run.
A new stream of that phone answers 403, as before.
The two frames on a display's stream
/live_displays/events still carries everything it carried, under the same
event names. Two events come in another shape:
| Event | Shape on a display's stream |
|---|---|
live_session.updated | GET /live_displays/session: no participatingDevices, the owner as id, name, hasImage |
live_session.device | A row of GET /live_displays/devices: athletes as people, no athleteIDs, joinedAt, joinedBy, appVersion, sharingAcknowledgedAt. For a device that left: { deviceSessionID, left: true } |
Each arrives once. The owner's stream on /realtime/events is unchanged.
What an app from today notices
Nothing is refused that was accepted. New in answers it reads:
deviceSessionID on a set, endedBy and lastSetAt on a session,
sharingDeclined on the devices read. Gone: paired displays in
participatingDevices. An athlete's phone whose set stream is ended by the
server sees its stream close after it left; its reconnect answers 403.
Database
One migration, AddWorkoutLiveSessionSharingDeclines: the table
extensions.workout_live_session_sharing_declines and a column
device_session_id on extensions.workout_live_session_sets. It only adds.
Not built
| Point | Why |
|---|---|
9 · The tour row portal-live on backdev | It is a row, not code: no migration fills product_guidance_items. It has to be created with POST /api/product_guidance_items (admin) on backdev, and on production before the release |
A free field on a display that presence writes | Not in your list of today; it needs a column |
| S1, step 2 | Waits for a week of step 1 on production |
Removing join, state, events, leave of /competition_dashboard | Waits for the portal's display page on production |
Not tried
The 24 hours of a display paired after the end running out, the photo of a person who only stands on a station, a stream ending when a session is deleted, a phone with two devices under one login, and anything with a real device or a real client. Your live tests 114 and 115 are the first run against it.