Skip to main content

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 create already applies to participatingDeviceIDs (getAccessToUserIds of 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 doesWhat the server does with it in L2
Is added by the coach through the portal's device pickerUnchanged. The picker stays as the second way in
Sends sets while the session is still in the lobbyStored, answered as today, and flagged as lobby sets (item 1)
Sends no heartbeatIts tile falls back to lastUsedAt; readiness is unknown, not false (item 4)
Ignores realtime events it does not knowNothing 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: true on the create body. Absent or false: as today.
  • startedAt on 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). Sets startedAt. A second call changes nothing and answers 200.
  • A state on the session DTO: lobby, live or ended. And workoutLiveSessionState on the device-session DTO, next to workoutLiveSessionName, 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: true on 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, complete and 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: true on start clears the flag on the session's lobby sets. It covers the group that started lifting before the coach pressed Go live.
  • Idle end, smallest priority: an optional endsAfterIdleMinutes on create and update. A live session with no stored set for that long ends, like one whose expirationDate has passed. Absent on create: never, as today. On update a null means "unchanged", as for expirationDate, 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 joinCode per session. Six characters from an alphabet without look-alikes (no 0 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, and suggestedDeviceLabel (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 and device-id). Links the calling device session and returns its device-session DTO.

    Body fieldMeaning
    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 link
    switchOptional. Leave the session the device is linked to now
    CaseAnswer
    User outside the owner's organisation, unknown code, ended session404
    Device category is not phone or tablet403
    Device is linked to another session that has not ended, no switch409, with that session's name
    Device is already in this session200, unchanged
    Too many wrong codes from one user429
  • Leave: POST /workout_live_sessions/leave (JWT and device-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 and device-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 or leave.

  • 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 its deviceName.

The join code replaces nothing. participatingDeviceIDs keeps working on create and update.

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 own id.
  • 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 and device-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 fieldMeaning
    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:

    FieldMeaning
    deviceSessionID, deviceName, deviceLabel, appVersionWhich device
    joinedAt, joinedByWhen, and code or owner (the picker)
    sharingAcknowledgedAtNull until item 2 recorded it
    lastSeenAtThe last heartbeat. Without one: lastUsedAt
    sensorConnected, 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 now beside the list. Tablets and wall screens have clocks that are off, and the client turns lastSeenAt into "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.

EventPayloadWhen
live_session.updatedThe session DTO without its setsName, config, logo, startedAt or endedAt changed
live_session.deviceOne 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 participatingDeviceIDs when a session is created, as today. Item 3 makes the tablet notice at once.
  • Paired displays, the platform state, the valid flag, 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​

ScenarioExpected
Create without startsInLobbystate is live, startedAt is the creation time
Create with it, then a set from a linked deviceStored, flagged, absent from reduced and complete without ?lobby=true
start, then a set from the same devicestate is live; the new set is in the reads, the lobby set is not
start, then finish the training that held the lobby setThe saved workout has all its measurements
Preview and join with the code, user inside the organisation200; the device is linked and labelled
The same from a user outside it404 both times
Join without sharingAcknowledgedRefused, not linked
Join while linked to another running session409 with its name; with switch, moved
leave from a linked phone204; unlinked; the phone stays signed in
A device is linked through the pickerThe event of item 3 arrives on its user's stream
The session endsEvery linked device gets that event, with no session in it
Heartbeat with sensorConnected: trueGET …/devices shows it, and a live_session.device event went out
No heartbeat for two minuteslastSeenAt is unchanged; no event
An app build from today, unchangedEvery request answers as before

Questions back​

  1. 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?
  2. 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?
  3. Athletes with their own login. Does getAccessToUserIds of 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.
  4. 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​

  1. Lobby sets have their own event names on the set topic: live_session_set.lobby_created, .lobby_updated and .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=true on reduced and complete stays as asked.
  2. The device is the one in the JWT, not the device-id header, on join, leave, acknowledge_sharing and presence. The same rule as the L0 reads.
  3. Wrong codes are counted on the preview too. GET …/code/:code and join both answer 429 after too many misses by one user.
  4. 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.
  5. A joinCode is unique among sessions not ended with /end. A session that only expired keeps its code, because it can be reopened.
  6. joinCode on 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 web and gets 403 on join. A browser test of joining needs a dev server started with a tablet device type.
  • 409 on join carries 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​

StepContent
AJoin code, preview, join, leave, acknowledge_sharing; the event device_session.live_session_changed (items 2 and 3)
BstartsInLobby, startedAt, start, state, lobby sets (item 1)
Cpresence, GET …/:id/devices, live_session.updated, live_session.device (items 4 and 5)
DendsAfterIdleMinutes, 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
ObservedResult
A new session with startsInLobbystate is lobby, no startedAt, a joinCode from the agreed alphabet
Preview with the code in lower caseThe session's name, the owner's name, state, "Platform 1" as suggestion
join from a tablet200 with the device session: session id, name and workoutLiveSessionState
join a second session without switch, then with it409 with the first session's id and name; then moved
join from a web device; a wrong code; a tablet of another organisation403; 404; 404
leave, twice204 both times, unlinked
device_session.live_session_changedArrives 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 tabletThe first login stays valid
Set POST in the lobbyStored, lobby: true, absent from reduced unless ?lobby=true; live_session_set.lobby_created on the topic
presence, then GET …/devicesLabel, joinedBy, sensor state, athletes, sharingAcknowledgedAt, lastSeenAt, lobbySetAt, and now
start; start with countLobbySetsstate is live, live_session.updated carries the code; the lobby set is a result
clearExpirationDate and clearLogoBoth removed, the session keeps running
endstate 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.

  1. 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.
  2. A blank inside a code is not ignored. GET …/code/K7M%202QX answers 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.
  3. The device session does not say what the device is called. The label ("Platform 1") is on the devices read and the live_session.device event, 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 to workoutLiveSessionState.