Backend handoff: Live Hub, phase L0
Audience: backend developer (enode_backend_v1). Status: requested,
nothing delivered yet. Date: 2026-10-06.
The Live Hub is being overhauled. The full analysis and concept are in
LIVE_HUB_CONCEPT.html at the repository root; the feature itself is described
in live-performance-hub.md. This document is the
server work for the first phase, L0: stop the losses. It changes no
interface. It makes the existing endpoints safe to retry, closes two open
doors, and gives a session an end. A last section lists what later phases will
ask for, so L0 can be built with it in mind.
Line references are to the branch bugfix/loading_factor_api_technique
(1ffdcaec, 2026-10-05). The deployed backdev build may differ.
What is observed, and what is read
| Claim | Basis |
|---|---|
| A create sent twice stores a second set, without values | Observed on backdev, 2026-10-06 (the probe below) |
reduced returns the set-level measurements and the exercise, and no reps | Observed on backdev, 2026-10-06 |
A session created with participatingDeviceIDs has those devices linked | Observed on backdev, 2026-10-06 |
| Everything else in this document | Read in the code, not executed |
The probe is a live test with a disposable account:
npx vitest run --config tests/live/vitest.config.ts tests/live/offline/12-live-outbox
It logs repeated create … storedTwice. That line reads true today and
should read false once item 1 is in.
The one rule: additive only
Tablets in gyms do not update on release day. An app build that is in the stores today must keep working against the new server, with every request it sends now getting the answer it gets now.
- No existing endpoint changes its path, its required fields, or the meaning of a status code it already returns.
- New request fields are optional. New response fields are additions.
- The native app builds send their version in the
app-versionheader on every request (a browser build does not; the header fails the CORS preflight). Please store the last seen value onuser_device_sessionsand return it on the device-session DTO, so the portal can mark devices that run an older app. - The device stays the unit that takes part. A phone or tablet is linked to
a session, and whoever lifts on it is in. That model is kept; nothing here
replaces
participatingDeviceIDs.
0. Check first: can live staging remove data from a saved workout?
This is a suspicion from reading the code, not an observed bug. It comes first because it would affect the saved workout, not the live view.
- Live sets share the
measurementsanddata_packagestables with recorded sessions (client-supplied ids, nullable live FKs). - The purge in
WorkoutLiveSessionService.swift:217-239soft-deletes those rows. It runs on session delete (:185-197), on every tracked or external PUT (WorkoutLiveSessionSetService.swift:172-176, 198-202, 326-330, 396-400) and on set or rep delete. - The finish-time upload matches existing rows by id including soft-deleted
ones and calls
update(WorkoutSessionService.swift:676-680, 845-881). Fluent'supdateskips soft-deleted rows.
Scenario to test: push a set live, then edit it live (or delete the live session), then finish the training in the app. Are the set's measurements and data packages present on the saved workout?
Ask: a test for this path. If it fails: restore the rows on the finish upload, or stop soft-deleting rows that a recorded session will claim.
1. Make set upload idempotent
Today: every POST /workout_live_session_sets creates a new row and never
looks at currentPersistentSetID (WorkoutLiveSessionSetService.swift:29-32).
Measurements are deduplicated by client id, so a repeated request leaves a
second set with reps and no values. The portal shows a value-less work set as a
failed attempt. Observed on backdev: two identical POSTs, two sets, the second
with no load.
Ask:
- A unique index on (
workout_live_session_id,current_persistent_set_id) where the latter is not null, plus an index for the lookup. POSTwith acurrentPersistentSetIDthat already exists in that session replaces that set and answers as it does today (201, empty body).- Existing duplicate rows need a cleanup in the migration (keep the newest per key).
The new tracking app sends from a durable queue and repeats a request whose
answer it did not receive. That is only safe with this change. The app build in
the stores today already sends currentPersistentSetID on every create, so it
benefits without an update.
2. Make update an upsert, and allow it from the set's own device
Today (WorkoutLiveSessionSetService.swift:280-306):
PUT /workout_live_session_sets/tracked/:currentPersistentSetIDrequires the requester to own the session. A device signed in as the athlete can POST and DELETE but gets 404 on every edit.- When no set matches, it answers 200 and does nothing.
Ask:
- Allow the update when the request comes from a device that is linked to the session (the same gate POST uses), not only from the owner.
- When no set matches the key, create it from the body, in the session the device is linked to. The body already carries everything a create needs. A device that is in no session gets the same answer POST gives it (204).
- Reps: replace the set's reps with the ones in the body. Today they are
matched by position and surplus rows stay (
:344-383), so removing one rep leaves a stale row with another rep's values.
3. Close the open dashboard endpoints
Today: /api/competition_dashboard/* is registered without any middleware
(CompetitionDashboardController.swift:12-23).
PUT /:liveSessionID/statelets anyone who knows a session id rewrite the board.POST /:displayToken/leavedeletes aUserDeviceSessionrow by id with no category check, which signs that device out.
Ask, without breaking the standalone dashboard app:
PUT …/state: require the session owner (JWT), or a display token that belongs to that session.leave: only delete rows of the dashboard device category.joinstays unauthenticated for now. It is the one thing a display without a login needs. Please read thepinit already accepts, once the session has one (a later phase adds it).
4. Give a session an end
Today: there is no status. expirationDate is stored and never read. A
device linked once keeps posting every later training into the old session,
and PUT refuses to move it to a new one
(WorkoutLiveSessionService.swift:423-440). PUT also ignores name and
expirationDate (:107-133).
Ask:
- Apply
nameandexpirationDateonPUT. - Treat a session whose
expirationDatehas passed as ended: unlink its devices, and answer a set POST from such a device like "not in a session" (204, as today). - An explicit end:
POST /workout_live_sessions/:id/end(owner). It sets anendedAt, unlinks the devices, and keeps sets and reps. ReturnendedAton the session DTO. - Make create and update agree on a device that is linked elsewhere. Today
create takes it silently (
:59-69) and update fails the whole request withidMissing. Proposed: both move the device, since sessions now end. A device is in one session at a time.
5. One date encoding on realtime frames
Today: SSE payloads use a default JSONEncoder()
(RealtimeEvent.swift:60-73), so dates go out as seconds since 2001. REST
sends milliseconds since 1970, and the client reads any number as that. A set
that arrives by push would carry a 1970 timestamp until the next snapshot.
Ask: encode realtime payloads with the same date strategy as REST. The portal ships together with this change; the tracking app does not read the stream.
6. Reads: who may read, and what the clients now depend on
Today the set reads (complete, reduced, /:setID) answer any
signed-in user who knows a session id
(WorkoutLiveSessionSetService.swift:456-556).
Ask: gate them, but not to the owner alone. Allowed are:
- the session's owner, and users with access to the owner (the rule the
realtime authorizer already uses,
configure.swift:457-475); - any device that is linked to the session, whoever is signed in on it.
The second point is new and it matters: the tracking app now shows the
session's standings on the training device (a Live tab and each athlete's
place). It reads GET /workout_live_session_sets/reduced/:id after each set it
delivers. On a platform tablet the signed-in user is usually the owner, on an
athlete's phone it is not.
Contract the app relies on. Please keep it:
reducedreturns every set with its set-levelmeasurements(withmetricinlined as today) andexercise.exerciseDefinitionID, and without reps.completekeeps returning the reps with their data packages. The portal's stream, attempt inspector and bar-path views read them.
Also in this area:
- The session's name for a linked device.
GET /workout_live_sessions/:idis owner-only, so an athlete's phone cannot say which session it is in. Either allow that read for a linked device, or put the session'snameon the device-session DTO next toworkoutLiveSessionID. Additive either way. - One unknown exercise breaks the snapshot: a set whose exercise definition
the viewer has no row for makes the whole response 404
(
ExerciseService.swift:567-584). Return that set with a null exercise instead. - Return what the client needs to order and correlate: add
currentPersistentSetID,orderand the server'screatedAt/updatedAtto the set return DTO. Additive. Today every displayed time comes from the client'screated, which is stamped when the workout starts. - Tests: nothing under
Tests/covers live sessions, the dashboard or the realtime hub. Items 0 to 4 each want one.
Two questions back
- Is the set topic authorized on backdev? The code authorizes
workout_live_session:sets:<id>for the owner and for users with access to the owner (configure.swift:457-475), and publisheslive_session_set.created|updated|deletedon it (WorkoutLiveSessionSetRealtimePublisher.swift:22-43). The portal last saw 403 on that topic (July) and works around it by joining/competition_dashboardas a display, which adds a device-session row under the coach for every open browser tab. If the topic works on the deployed build, the portal drops that workaround. - Upload path cost. Each set POST waits for an attempt-board build and a
full re-read of the set (
WorkoutLiveSessionSetService.swift:107-118), with one query per measurement in the DTO mapping. With twenty lifters finishing together this is the likeliest cause of a lagging board. Not asked for in L0. A measurement would help:tests/live/load/replay.mjscan drive it.
What the clients do in L0
All of it is in the working tree of enode-tracking, branch
feature/announcements, not yet committed.
- Tracking app — durable outbox (
packages/core/src/offline/live-queue.ts). Each create, update and delete is written to IndexedDB before it is sent. One record per set; the queue knows whether the create went out, so it never sends an update before its create and works against the current server. It repeats a request after a lost answer, which is where item 1 matters. A 204 on POST counts as delivered. A 4xx drops the operation. - Tracking app — standings on the device
(
packages/core/src/live-session/use-live-standings.ts): readsreduced, ranks with the same function the portal uses. See item 6. - Portal: create now sends
participatingDeviceIDs; the device picker stays, because an app from today cannot join any other way. A failed feed load is shown as an error.
Acceptance
| Scenario | Expected |
|---|---|
| The same set POSTed twice | One set, with its values |
| PUT for a set whose POST never arrived | The set exists afterwards |
| PUT from a device signed in as the athlete | 200, set updated |
| Edit that removes one rep of three | Two reps, each with its own values |
PUT …/state without credentials | Refused |
leave with the id of a phone's device session | Refused, the phone stays signed in |
| Set POST after the session ended or expired | 204, nothing stored |
PUT session with a new name | The name is changed |
reduced read from a linked device signed in as an athlete | 200, sets with measurements |
reduced read by a signed-in user with no relation to the session | Refused |
| Live edit, then finish the training | Saved workout has all measurements |
| An app build from today, unchanged | Every request answers as before |
After L0: what later phases will ask for
Not requested now. Listed so that L0's schema and naming leave room for it. Details are in the concept document, section "What changes, per layer".
| Phase | Server work |
|---|---|
| L1 · truth on screen | Presence: last seen per linked device and per display, readable by the owner. A "session updated" event on the realtime hub when name or config change: the portal now keeps the ranking in config and re-reads the session list once a minute so a wall display picks up a change; with an event it stops asking. The list and the single read also eager-load every set and then return none (WorkoutLiveSessionService.swift:312, 345), which that minute makes worth removing |
| L2 · set-up and lobby | Session states lobby → live → ended, with timestamps (builds on item 4). Join by code: a short code per session; a signed-in device of the owner's organisation links itself with it. The portal's device picker stays as the second way in. Push to the device when it is linked or unlinked, so it does not have to re-read its device session |
| L3 · display | Paired display: a screen without a login is linked by a number the coach confirms and gets a read-only token for that session; this replaces today's open join. Platform state: which athlete is on the bar on a device, with the planned load (switch_athlete exists as a starting point) |
| L4 · competition rules | A valid flag on the live rep. Rules, look and the optional start list stay in the opaque config blob (16 KiB today), so nothing else is needed |
| L5 · deeper insights | Running set: accept a set that is still growing (the upsert of item 2 makes this possible). Personal best before today per athlete and exercise on the live wire. Numbered events and resume (Last-Event-ID or a read "since number"), so viewers stop re-reading the whole session on every change |
| L6 · wrap-up | Stored results of an ended session, readable after the devices are released |