Backend handoff: Live Hub, phase L2
Audience: backend developer (enode_backend_v1). Status: delivered
with six changes and on backdev since 2026-10-07; see
Agreed with the backend at the end, which holds
where it differs from the body, and
Observed on backdev below it, which ends
with three small things for the backend. Date: 2026-10-06, updated
2026-10-07.
L0 is on backdev and holds (see live-hub-backend-reply-2.md). This document is the server work for L2: set-up and lobby. It is what the first handoff announced under "After L0", now with the two product decisions it was waiting for.
What L2 changes for the people in the gym:
- A tablet or phone joins from its own screen with a code. The coach no longer has to pick devices from a list.
- A new session opens in a lobby. The coach sees which devices are in and whether each is ready, and then presses Go live.
- A tablet knows at once that it was added, removed, or that the session started or ended.
Field and route names below are proposals. Rename freely; the behaviour is
what is asked for. Line references are to 1ffdcaec (2026-10-05), the state
before your L0 changes, because those are not in the checkout this was
written from.
Decided by the product owner (2026-10-06)
- Who may join with a code: a signed-in user of the session owner's
organisation, and nobody else. That is the owner, and every user the owner
has access to: the rule
createalready applies toparticipatingDeviceIDs(getAccessToUserIdsof the owner,WorkoutLiveSessionService.swift:44-60). - Consent: on an athlete's own phone the athlete agrees once per session, after seeing what is shared. On a gym tablet the coach decides, and the tablet says so in one line. The app shows the right screen and holds a phone's sets back until the athlete agreed. The server only records that it was shown (item 2).
The rule from L0 still holds: additive only
An app build that is in the stores today cannot join by code, knows no lobby and sends no heartbeat. It must keep working, with every request it sends now getting the answer it gets now.
| What an app from today does | What the server does with it in L2 |
|---|---|
| Is added by the coach through the portal's device picker | Unchanged. The picker stays as the second way in |
| Sends sets while the session is still in the lobby | Stored, answered as today, and flagged as lobby sets (item 1) |
| Sends no heartbeat | Its tile falls back to lastUsedAt; readiness is unknown, not false (item 4) |
| Ignores realtime events it does not know | Nothing to do; the new events are new names |
A session created without the new lobby field is live from the moment it exists, as today.
1. Session states: lobby, live, ended
Today: a session is live when it is created. L0 added the end (endedAt,
POST …/:id/end).
Ask:
- Create in the lobby. An optional
startsInLobby: trueon the create body. Absent or false: as today. startedAton the session DTO, Unix ms. Null while in the lobby. Existing sessions and sessions created without the field: the creation time.- Go live:
POST /workout_live_sessions/:id/start(owner). SetsstartedAt. A second call changes nothing and answers 200. - A
stateon the session DTO:lobby,liveorended. AndworkoutLiveSessionStateon the device-session DTO, next toworkoutLiveSessionName, so a device can say "Lobby" without a second read. - Lobby sets. A set first stored while the session is in the lobby is a
lobby set: the test rep that proves sensor, app, network and server work.
- Flag it (
lobby: trueon the set DTO). Do not delete it on Go live. Its measurements and data packages can be claimed by the finish upload of the same training; that is the path of item 0 in the first handoff. reduced,completeand the set topic leave lobby sets out unless the request asks for them (?lobby=true). Then no client that predates the lobby shows a test rep as a result.- Optional, if it is cheap:
countLobbySets: trueonstartclears the flag on the session's lobby sets. It covers the group that started lifting before the coach pressed Go live.
- Flag it (
- Idle end, smallest priority: an optional
endsAfterIdleMinuteson create and update. A live session with no stored set for that long ends, like one whoseexpirationDatehas passed. Absent on create: never, as today. On update a null means "unchanged", as forexpirationDate, so it needs a way to be switched off again.
2. Join by code
Today: only the owner can link a device, with participatingDeviceIDs.
Ask:
-
A
joinCodeper session. Six characters from an alphabet without look-alikes (no0 O 1 I L), unique among sessions that have not ended, valid until the session ends. Returned on the session DTO to the owner and to users with access to the owner. Existing sessions get one. -
Preview:
GET /workout_live_sessions/code/:code(JWT). For a user who may join:id,name, the owner's name,state,hasLogo,config, andsuggestedDeviceLabel(see below). For anyone else, an unknown code and an ended session alike: 404, as you answer refused reads. -
Join:
POST /workout_live_sessions/code/:code/join(JWT anddevice-id). Links the calling device session and returns its device-session DTO.Body field Meaning deviceLabelOptional. What this device is called in the session, "Platform 1". Absent: the suggested one sharingAcknowledgedRequired true. The app has shown what is shared, and the athlete or coach confirmed. Store the time and the user with the linkswitchOptional. Leave the session the device is linked to now Case Answer User outside the owner's organisation, unknown code, ended session 404 Device category is not phone or tablet 403 Device is linked to another session that has not ended, no switch409, with that session's name Device is already in this session 200, unchanged Too many wrong codes from one user 429 -
Leave:
POST /workout_live_sessions/leave(JWT anddevice-id). Unlinks the calling device. 204, also when it was in no session. An athlete has to be able to leave from their own phone; today only the owner can remove a device. -
Acknowledge later:
POST /workout_live_sessions/acknowledge_sharing(JWT anddevice-id), for a device the coach linked through the picker. The app shows the same screen when it learns it was added, and calls this orleave. -
suggestedDeviceLabel: "Platform N" with the lowest number no linked device of the session holds. A device from an app of today has no label; the portal shows itsdeviceName.
The join code replaces nothing. participatingDeviceIDs keeps working on
create and update.
3. Tell the device when its link changes
Today: a device learns that it was added by re-reading its device session at app start and when it returns to the foreground. A tablet that is added while a training runs does not send until then, and a session that ended is noticed only through the 204 on the next set.
Ask: an event on pro:user:<userID> of the user signed in on that device.
Every app holds that stream already.
- Name:
device_session.live_session_changed. - Payload: the device-session DTO (
id,deviceId,workoutLiveSessionID,workoutLiveSessionName,workoutLiveSessionState). Several tablets share one login, so each filters by its ownid. - When: the device is linked (picker or code), unlinked (removed, left, session ended, expired or deleted), the session goes live, or is renamed.
4. Presence and readiness
Today: nothing says whether a linked device is reachable. lastUsedAt is
refreshed at most every five minutes (UserDeviceSession.swift:69), which is
too coarse for "offline for 2 minutes".
Ask:
-
Heartbeat:
PUT /workout_live_sessions/presence(JWT anddevice-id). A linked device calls it about every 20 seconds while the app is in front, and at once when something in the body changes. 204 when the device is in no session, like a set POST. Only the latest state is kept; no history.Body field Meaning sensorConnectedWhether a sensor is connected on the device right now athleteIDsWho is in the workout on this device, at most 30 -
Read for the lobby:
GET /workout_live_sessions/:id/devices, for the owner and users with access to the owner. One row per linked device:Field Meaning deviceSessionID,deviceName,deviceLabel,appVersionWhich device joinedAt,joinedByWhen, and codeorowner(the picker)sharingAcknowledgedAtNull until item 2 recorded it lastSeenAtThe last heartbeat. Without one: lastUsedAtsensorConnected,athleteIDsFrom the last heartbeat. Null without one: unknown, not false lobbySetAtWhen the server last stored a lobby set from this device And the server's
nowbeside the list. Tablets and wall screens have clocks that are off, and the client turnslastSeenAtinto "seen 2 min ago".
"Ready" is computed in the client: joined, sensor connected, one lobby set seen.
5. Session events on the stream the portal already holds
Today: the portal re-reads the session once a minute to notice a changed
name or config, and would have to poll the lobby.
Ask: two more event names on workout_live_session:sets:<liveSessionID>.
The same topic on purpose: a browser allows few open streams per host, and
the portal holds this one already. A client that does not know the names
ignores them.
| Event | Payload | When |
|---|---|---|
live_session.updated | The session DTO without its sets | Name, config, logo, startedAt or endedAt changed |
live_session.device | One row of item 4, or { deviceSessionID, left: true } | A device joined or left, its label, sensor state, athletes or lobbySetAt changed, or it is heard again after more than a minute of silence |
A heartbeat that changes nothing publishes nothing. The client ages
lastSeenAt by itself.
Not asked for
- Gym tablets that join by themselves. The portal remembers the coach's
tablets and sends them in
participatingDeviceIDswhen a session is created, as today. Item 3 makes the tablet notice at once. - Paired displays, the platform state, the
validflag, numbered events: L3 to L5, as listed in the first handoff. In L2 the wall keeps running on the coach's login.
What the clients will do with it
- Tracking app: "Join live session" in the sidebar: type or scan the code, see the session and what is shared, join. The live status button says Lobby or Live. Heartbeat while linked. Reacts to item 3 instead of re-reading.
- Portal: a three-step set-up page that lands in the lobby. The lobby shows the code, a tile per device with its three steps (joined, sensor, test rep), and Go live. The device picker stays, marked as the way in for older apps.
Acceptance
| Scenario | Expected |
|---|---|
Create without startsInLobby | state is live, startedAt is the creation time |
| Create with it, then a set from a linked device | Stored, flagged, absent from reduced and complete without ?lobby=true |
start, then a set from the same device | state is live; the new set is in the reads, the lobby set is not |
start, then finish the training that held the lobby set | The saved workout has all its measurements |
| Preview and join with the code, user inside the organisation | 200; the device is linked and labelled |
| The same from a user outside it | 404 both times |
Join without sharingAcknowledged | Refused, not linked |
| Join while linked to another running session | 409 with its name; with switch, moved |
leave from a linked phone | 204; unlinked; the phone stays signed in |
| A device is linked through the picker | The event of item 3 arrives on its user's stream |
| The session ends | Every linked device gets that event, with no session in it |
Heartbeat with sensorConnected: true | GET …/devices shows it, and a live_session.device event went out |
| No heartbeat for two minutes | lastSeenAt is unchanged; no event |
| An app build from today, unchanged | Every request answers as before |
Questions back
- Lobby sets and the saved workout. Is flagging instead of deleting enough to keep the finish upload whole, or does the live purge still touch those rows somewhere else?
- The heartbeat's cost. Twenty tablets make one request a second. Is a database write per heartbeat acceptable, or do you want to keep presence in memory and write only changes?
- Athletes with their own login. Does
getAccessToUserIdsof the owner contain every athlete of the organisation who signs in on their own phone? If some are missing, the code will not work for them. - Order. Items 2 and 3 alone already replace the device picker for current apps. If L2 has to be split, that half first.
Agreed with the backend
From the backend's response of 2026-10-06 ("Live Hub, phase L2 (plan)"). L2 is accepted as asked, with the names above, and with these changes. Where a point here and the body disagree, this section holds.
Six changes
- Lobby sets have their own event names on the set topic:
live_session_set.lobby_created,.lobby_updatedand.lobby_deleted. The hub fans out per topic, not per subscriber, so a stream cannot be opened "with lobby sets". A client that predates the lobby ignores the names.?lobby=trueonreducedandcompletestays as asked. - The device is the one in the JWT, not the
device-idheader, onjoin,leave,acknowledge_sharingandpresence. The same rule as the L0 reads. - Wrong codes are counted on the preview too.
GET …/code/:codeandjoinboth answer 429 after too many misses by one user. - Expiry and idle end reach a device within a minute, not at once. A job
checks once a minute, unlinks the devices of sessions that are over and
sends the event of item 3. An explicit
end, a removal and a leave are sent at once. - A
joinCodeis unique among sessions not ended with/end. A session that only expired keeps its code, because it can be reopened. joinCodeon the session DTO goes to the owner only, for now. Users with access to the owner have no read of the session today.
What "organisation" means for L2
getAccessToUserIds of the owner is the owner and the owner's direct
children. It is not transitive, and a relation that is not accepted yet
counts.
- An athlete who is a child of the head coach only cannot join a session that an assistant coach owns.
- In the data so far, every linked device was signed in as the owner. An athlete joining on their own phone has not happened yet; that path is untried.
Also settled
- Phone and tablet only. A tracking app that runs in a browser registers as
weband gets 403 onjoin. A browser test of joining needs a dev server started with a tablet device type. - 409 on
joincarries the other session's id and name. The exact shape comes with the delivery. - Lobby sets and the saved workout: flagging is enough; "start, then finish the training that held the lobby set" gets its own backend test.
- Heartbeat: a database write per heartbeat is accepted.
Order of delivery
| Step | Content |
|---|---|
| A | Join code, preview, join, leave, acknowledge_sharing; the event device_session.live_session_changed (items 2 and 3) |
| B | startsInLobby, startedAt, start, state, lobby sets (item 1) |
| C | presence, GET …/:id/devices, live_session.updated, live_session.device (items 4 and 5) |
| D | endsAfterIdleMinutes, and the once-a-minute check of change 4 |
All four are built, committed and on backdev (2026-10-07).
The set topic on production
Asked in reply 2. The authorizer is on master, which deploys by itself, so
production should grant workout_live_session:sets:<id>. That was read in the
code, not tried. The display fallback (dashboard-bridge.ts) stays until a 200
has been seen there.
Clearing values on update
Asked after the backend's first response, and delivered with the four steps:
PUT /workout_live_sessions/:id takes clearExpirationDate, clearLogo and
clearEndsAfterIdleMinutes. A null value still means "unchanged".
Observed on backdev, 2026-10-07
Through the app's own API functions, with disposable accounts and the owner signed in a second time as a tablet:
npx vitest run --config tests/live/vitest.config.ts tests/live/offline/25-live-l2-contract
| Observed | Result |
|---|---|
A new session with startsInLobby | state is lobby, no startedAt, a joinCode from the agreed alphabet |
| Preview with the code in lower case | The session's name, the owner's name, state, "Platform 1" as suggestion |
join from a tablet | 200 with the device session: session id, name and workoutLiveSessionState |
join a second session without switch, then with it | 409 with the first session's id and name; then moved |
join from a web device; a wrong code; a tablet of another organisation | 403; 404; 404 |
leave, twice | 204 both times, unlinked |
device_session.live_session_changed | Arrives on join, on leave, on start, on a rename, and when the owner removes or adds the device |
| A second login of the same account, as a tablet | The first login stays valid |
| Set POST in the lobby | Stored, lobby: true, absent from reduced unless ?lobby=true; live_session_set.lobby_created on the topic |
presence, then GET …/devices | Label, joinedBy, sensor state, athletes, sharingAcknowledgedAt, lastSeenAt, lobbySetAt, and now |
start; start with countLobbySets | state is live, live_session.updated carries the code; the lobby set is a result |
clearExpirationDate and clearLogo | Both removed, the session keeps running |
end | state is ended, no joinCode; a heartbeat afterwards answers 204 |
In a browser against backdev: a tablet joins in the tracking app with the code typed as the portal shows it; its sidebar row changes within a second when the session is started and when the owner removes or adds the device; it leaves. The portal shows the code and removes an end date and a logo.
An athlete on their own login (tried on 2026-10-07, after your note that the
path was untried): created by the coach, signed in on a phone, invitation
accepted. Joins by code, a set of their own is stored, acknowledge_sharing
sets the time on a device the coach added by hand, leave works.
Not tried: the 429 after ten misses, and the idle end.
Three things for the backend
Answered on 2026-10-07: all three are done and committed on the backend
(83a811f8), with backend tests. They reach backdev with its next rebuild. A
topic is then the same topic however its UUID is written, a code is read by
its letters and digits only, and the device session carries
workoutLiveSessionDeviceLabel. The app does not read that field yet; see
R4 in live-hub-backend-handoff-open.md.
- A set topic with the session id in lower case is admitted and never gets
an event.
workout_live_session:sets:<id>answers 200 for an id in lower case, because the authorizer parses the id; the hub then matches the topic by its text, and publishes under the id in upper case. The client now writes the id in upper case. Please make the two agree on the server too: a stream that opens and stays silent is hard to notice. - A blank inside a code is not ignored.
GET …/code/K7M%202QXanswers 404, and counts as a miss, while blanks around the code are ignored. A screen shows the code in two groups, so that is how people type it. The client strips everything that is not a letter or a digit before sending. Doing the same on the server would cover other clients. - The device session does not say what the device is called. The label
("Platform 1") is on the devices read and the
live_session.deviceevent, which a device may not read. After a restart the tablet can no longer say "This tablet is Platform 1". Ask: the label on the device-session DTO, next toworkoutLiveSessionState.