Backend handoff: Live Hub, everything that is open
Audience: backend developer (enode_backend_v1). Status: sent, and
answered by the backend on 2026-10-07: eight points are built (backend commit
13b7a267), the rest is answered or waits. The answer is under
Agreed with the backend at the end, which holds
where it differs from the body. What is still open after that answer is asked
in the next document,
live-hub-backend-handoff-open-2.md.
This is the one document to work from. It holds every open point on the server side of the Live Hub, in the order it should be done:
- what is left from L0 and L2 before anything reaches a real gym (part R),
- three points about people's data and about who may write (part S),
- the server work for the wall, which was written this morning as the L3 handoff and never sent (steps A to G),
- what the load run found, and one gap in the presence of L2 (part P),
- and what is only named, so that nothing is designed against it (part L).
The earlier documents stay as the record of what was asked and agreed: L0 with reply 1 and reply 2, and L2. The L3 draft is folded into this document and is no longer the thing to answer; it keeps the longer notes on how today's server was read.
Field, route and event names are proposals. Rename freely; the behaviour is what is asked for. Every item can be answered with yes, no or a counter-proposal. The questions are numbered 1 to 39 across the document and listed once more at the end.
In one page
Where things are
| What | State | On backdev | On production |
|---|---|---|---|
| L0: idempotent sets, upsert, session end, one date encoding, read rights, the data-loss fix | Committed (44cd8ef8 on bugfix/loading_factor_api_technique), not merged | Since 2026-10-06 | No |
| L2: join by code, lobby and Go live, the device event, presence, session events, idle end, the clear flags | Committed | Since 2026-10-07 | No |
Cheaper reads (621 statements to 46 for reduced on 40 sets, 2032 to 49 for complete, 173 to 66 for a set POST) | Reported by you on 2026-10-07 | Not known to us | No |
| Topic id in either case, a blank inside a code, the device's label on its device session | Committed (83a811f8) | The label since 2026-10-07 (seen at 13:00); the other two not looked at again | No |
| Tracking app and portal for all of the above | Built and checked against backdev. In the working tree of enode-tracking, not committed | — | No |
So the whole feature as it stands is on the test server and nowhere else.
What is asked, in order
| Part | Steps | What a gym gets from it | What waits for it |
|---|---|---|---|
| R · Reach production | R1 to R5 | Everything built since 2026-10-06: no lost sets, sessions that end, join by code, the lobby | Every coach. Two decisions of the product owner need a number from you first (R2, R3) |
| S · People's data, and who may write | S1 to S3 | No athlete's e-mail address or body weight on another athlete's phone. Nobody writes into a session through a device id they only know | S2 belongs into the same release as L2 |
| A to G · The wall | Items 1 to 8 | Who is about to lift, the reps while they are lifted, a TV without a coach's login, a personal best that is recognised, a ruling per lift, the result on the tablet | The client blocks X1 to X3 of the concept |
| P · Speed with twenty lifters, and one gap in presence | P1 to P4 | The standings on a tablet stay current in a long session. A tablet that is fine is not reported as silent | The health line of the coach's view and of the display (P4) |
| L · Later | named only | — | — |
Three answers that unblock the most
- The number for R2: how many sessions on production are past their end date and still received a set in the last 30 days. Without it the deploy may end sessions that coaches use.
- A yes or no to S2: a live set says
id,nameandhasImageabout a person, nothing more. - The order A to G: which step is cheaper or dearer than it looks here.
The rule that stays: 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.
One exception is asked for, in S2: the user of a live set loses fields.
No client reads them: the tracking app and the portal read id and name of
a live set's user and nothing else (packages/core/src/live-session/feed-schema.ts),
and the build in the stores reads no live sets at all. Question 8 asks whether
anything on your side does.
| What a client from today does | What the server does with it after this handoff |
|---|---|
| The tracking app sends finished sets only | Stored and published as today. The set has no completedAt; the wall treats it as just lifted (item 3) |
It sends no heartbeat, or one without platforms | The device row has no platforms. The wall shows the lifter when the finished set arrives (item 1) |
| It leaves reps that were ruled out off the body | As today: the set holds counted reps only (item 7) |
It reads reduced after each set it delivers | The same answer, except the fields of user named in S2 |
The portal reads complete and follows live_session_set.created, .updated and .deleted | The same answers and frames, sensor packages included, except the fields of user named in S2 |
The standalone competition-dashboard app joins through /competition_dashboard | Untouched until decision D9 is taken (item 6, S3) |
The native iOS app calls switch_athlete | Untouched (item 1) |
Conventions, as agreed in L2
- The device is the one in the JWT, not the
device-idheader. S1 asks to make that true for the set routes as well. - 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. 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 each statement rests on
Your L0 and L2 changes are not in the checkout this was written from
(1ffdcaec, 2026-10-05). So every statement about today's server says which
of these it rests on:
| Basis | Meaning |
|---|---|
| Observed on backdev | A request was sent on 2026-10-06 or 2026-10-07 and the answer read |
| Reported by you | From your responses of 2026-10-06 and 2026-10-07 |
| As read in the local checkout | Read in the server code at 1ffdcaec, not executed. It may differ from what backdev runs |
| As read in the client | Read in enode-tracking, working tree of 2026-10-07 |
Waiting for the product owner
These are product decisions. The items are written so that each outcome is covered, and say where an outcome changes the server work.
| Decision | Where it bites |
|---|---|
| Sessions past their end date end with the production deploy. Proposed: clear the date where a set arrived after it in the last 30 days, let the rest end | R2. Needs your count first |
| Workouts that lost values before the L0 fix: repair them or not | R3. Needs your numbers first |
| D3 · How the wall gets its picture. Recommended: a paired display. The screen shows a number, the coach confirms it | Item 6 assumes it |
| D9 · The standalone competition-dashboard app: fold it into the paired display, or keep both | Item 6 and S3, 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? | Items 1 and 6 and S3 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 (item 2), nor a best from before the session (item 4) | Items 1, 2, 4 and 6 |
| D5 · Who rules good lift or no lift. Answered on 2026-10-07: a person, by hand, at the tablet that records the lift. Open: may the coach also rule from the portal | Item 7b |
| D13 · Who besides its owner may see and run a session. Today only the owner reads a session and its join code; an assistant coach cannot open the head coach's session | Question 38 asks what it would cost |
Part R · Reaching production
R1 · One release for L0 and L2
What is asked. Merge and deploy L0, L2, the cheaper reads and 83a811f8
as one release, the server before the clients.
Why. Nothing of the last two days is in front of a coach. The clients are
written so that they also run against a server without L2: a session without
state is live from its creation, a join code that is absent is not shown, a
clear flag that is ignored is reported in the editor. So the order of the two
deploys is free, and server first is the safe one.
What the deploy changes for a build that is in the field today.
| A build in the field | After the deploy |
|---|---|
| Sends a set twice after a lost answer | One set, with its values (L0, item 1) |
| Sends into a session that was ended or is past its end date | 204, nothing stored. Before: stored for ever (L0, item 4, and R2) |
The portal of today writes PUT /competition_dashboard/:id/state | As agreed in reply 1, question 1 |
| Edits a set | Sensor packages are replaced, not appended (reply 1) |
| Everything else | As before |
From our side after the deploy. Two checks with a real account. The set
topic answers 200 on production: you read it in the code on master, nobody
has tried it. And an idle stream stays open for ten minutes through
production's proxy, which means a heartbeat line reaches the browser at least
every 15 seconds and nothing in between buffers it. Then the portal's old way
of following a session, joining as a display, is deleted
(packages/core/src/live-session/dashboard-bridge.ts).
Questions.
- Is anything in L0 or L2 meant to go behind a switch, and what is the way back if production misbehaves after the deploy?
- Do the cheaper reads and
83a811f8go out with the same release?
R2 · Before the deploy: sessions past their end date
What is asked. A number, and then a migration the product owner chooses.
Why. Until L0 the end date was stored and never read. A coach may run a session every week that "ended" in spring. With L0 that session is over at the moment of the deploy: its tablets are released and their sets answer 204.
Proposed. Count the sessions whose expiration_date is in the past and
that stored a set after it in the last 30 days. In the migration, clear the
date on those and let the rest end. Then nothing a coach still uses stops on
the day of the deploy, the long-dead sessions release their devices, and the
rule holds for every date set from now on.
Questions.
- What is the count on production?
R3 · After the deploy: workouts that lost values
What is asked. Current numbers, a dry run, and a job that an admin starts.
Why. Item 0 of the first handoff was a real defect: after a live edit, or after a live session was deleted, the saved workout had lost that set's values. It is fixed for new data. Your lower bounds from a snapshot to 2026-09-03 were at least 17 sets without a set value and at least 176 sets without rep values, and you wrote that the live rows still hold the values.
Proposed. A job that lists the affected workouts first (dry run) and then restores the values from the live rows, started by hand after the L0 deploy. The product owner decides whether it runs.
Questions.
- How long do the live rows of an ended or deleted session keep the values a repair needs, and are the two numbers still right? If ending or deleting a session removes those rows, the repair has to come before the first clean-up.
R4 · backdev: a rebuild with 83a811f8
Done, as far as we can see. Since 2026-10-07 the device session on backdev
carries workoutLiveSessionDeviceLabel: in the answer to a join and on a
later GET /user_device_sessions (live test
tests/live/offline/50-live-tablet-standings). The app reads it, and a tablet
says "Live · Platform 1" again after a restart (seen in a browser). Thank you.
Not looked at again from a client: the topic id in lower case and a blank inside a code. Two things of L2 are still to be tried: the 429 after ten wrong codes, and the end after idle time.
Questions.
- Answered by the rebuild; nothing to answer. (The number stays, so that the other questions keep theirs.)
R5 · One small confirmation
The session editor uploads the logo as JPEG, which is what the shared image cropper produces. Your spec lists PNG and SVG. Observed on backdev: the upload is accepted and the logo is served.
Questions.
- Is JPEG accepted on purpose, so that we can rely on it?
Part S · People's data, and who may write
S1 · The set routes take the device from the token
This answers the question at the end of your response of 2026-10-07: "Does every app build send the same device id in the header and in the token it was issued?"
As read in the client: yes, for the tracking app and the portal. The
device id is created once per installation (getDeviceId in
packages/core/src/api/storage.ts), kept in the same storage as the token,
and sent as device-id on every request, the sign-in that issues the token
included. No code path replaces it while a token exists. It is written in
lower case, while your UUIDs come back in upper case. We cannot speak for the
native iOS app.
Proposed, in two steps.
- On the set routes (
POST,PUT …/tracked,DELETE …/tracked), compare the device of the token with thedevice-idheader without regard to case, keep using the header, and count the requests where the two differ, with the app version. One week on production. - If the count is zero for builds that matter: use the device of the token and ignore the header, as the L2 routes do.
An app from today. Nothing changes for it in step 1. In step 2 nothing changes for a build whose header and token agree.
Checked on backdev by.
| Request | Expected after step 2 |
|---|---|
A set POST with the token of tablet A and the device-id of tablet B, both linked to different sessions | The set is stored in A's session |
The same with the device-id of a device in no session | Stored in A's session, not answered with 204 |
| A set POST from a device whose header and token agree | As today |
Questions.
- Is the two-step way fine, and does the native iOS app send the device id it signed in with?
S2 · What a live set says about a person
What is asked. In every live set, on every read and in every frame, user
is id, name and hasImage. Nothing else.
Why. Observed on backdev (2026-10-07): the user of a set carries id,
name, email, birthdate, bodyHeight, bodyWeight, gender, tags,
assignedTags, role, availability, trainable, hasImage and
updatedAt. Every device in a session reads the session's sets for its
standings. Until L2 every such device was signed in as the coach. With L2 an
athlete joins on their own phone with a code, and that phone then receives the
e-mail address, the date of birth and the body weight of every other athlete
in the session, with every read.
That is why this belongs into the same release as L2 (R1). It also takes 783 bytes off each set of a read, which is a third of it (part P).
Proposed. The slim person for everyone, the owner included: one rule, no case distinction, and the frames can stay one payload per topic. A coach's portal that needs more about an athlete has the athlete in its own user list.
An app from today. As read in the client, both clients read id and
name of a live set's user and nothing else. The build in the stores reads no
live sets.
Checked on backdev by.
| Request | Expected |
|---|---|
reduced, complete, the single set and a created frame, as the owner | user has id, name, hasImage and no other key |
reduced from a linked phone that is signed in as an athlete | The same, for the athlete's own sets and for everybody else's |
The state event of /competition_dashboard | As today. That surface is not touched here |
Questions.
- Does anything read more than
idandnamefrom a live set'suser: the native iOS app, the standalone board, a job? - Is a linked device that is signed in as an athlete admitted to the set
topic today? The frames carry the same
user. (P1 asks to admit linked devices on purpose.)
S3 · The open way into a board
Observed on backdev (2026-10-07). POST /competition_dashboard/join with a
session id and no credentials answers 200 and hands out a displayToken.
Writing the board without any credentials is refused since L0 (403). With that
token PUT …/state is accepted. So whoever has seen a session id can still
rewrite the board, in two requests.
The first handoff left join open on purpose until a session has a pin. It
closes for good with the paired display (item 6), which waits for decisions D3
and D9.
Asked until then, if the standalone app allows it. A token that join
handed out without credentials may read state and events and may not write
state. Writing needs the owner's JWT, as the portal sends it.
Questions.
- Does the standalone dashboard app write
statewith its display token? If it does not, can the write require the owner's JWT now?
The wall: eight items in seven steps
The items keep the numbers they have in the concept. They are listed in the order that helps the wall most. Every step can ship without the others.
| Step | Item | What the wall gains | Server work, roughly | Ships alone |
|---|---|---|---|---|
| A | 1 · Who is on the platform | The screen before a lift, and "up next" | One optional field on the heartbeat, on the device row and in its event | Yes |
| A | 3 · When a set was completed | No highlight for a late set; lifts in the order they were lifted | Two optional body fields, one field on the set | Yes |
| B | 2 · The set while it is lifted | The screen during a lift | Two routes, two event names, one rule for the reads | Yes. Best after A, it shares a key with item 1 |
| C | 5 · The board without sensor packages | The wall opens without loading every sensor package of the session | One read | Yes |
| D | 6 · A display without a login | No coach account open on the gym TV, and S3 is closed | Pairing, a token, and what it may read | After the decisions. It names the reads of A to C |
| E | 4 · Best before today | "Personal best" as the line of a lift, also on a paired display | One read over personal_bests | Yes. On a paired display it is the only way |
| F | 7 · Ruled-out reps, and the ruling on an attempt | "No lift" is what the judge ruled, and such a lift is not ranked | One column and a second list; one field on the set | Yes |
| G | 8 · The result for those who took part | The final table on the platform tablet | One rule for who may read an ended session | Yes |
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 reps, and after a set the app works out who
rotates in next. It sends none of it. The call
switchLiveAthleteexists and has never had a caller; the product owner confirmed that the app can send the selection. - Observed on backdev:
switch_athleteanswers 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_dashboardonly, as astateevent. Nothing goes to the set topic. An unknown fieldplatformson the heartbeat is accepted and ignored. - As read in the local checkout: the caller must own the session, the board state is one per session and lives in process memory, and the only caller found is the native iOS app.
So switch_athlete carries the athlete and nothing else. 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 row | Meaning |
|---|---|
station | Which column of the tablet, 1 to 3. A phone has one. With the device's label it names the platform |
userID | The athlete selected in that station |
exerciseDefinitionID | The exercise that is open. Optional |
currentPersistentSetID | The id the set will be sent under when it is completed. The same id as in item 2. Optional |
work | False for a warm-up. Optional |
load | The load planned for the set, in the base unit, as value of the set's loadingMass measurement. Absent when the set has none |
plannedReps | Optional |
next | Who the app would rotate to next in this station, with their planned load if it knows one. Optional |
platformsabsent: 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/devicesand in thelive_session.deviceevent, withuserin place ofuserID, in the slim form of S2. A wall without a login has no list of users to look a name up in. - Publish
live_session.deviceat once whenplatformsdiffers 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.
| Request | Expected |
|---|---|
Heartbeat with one row in platforms, then GET …/:id/devices | The row comes back with station, user.id, user.name, load and next.user.name |
| The same with the set topic open | One live_session.device event with the same platforms |
| The same heartbeat again | No event |
Heartbeat with platforms: [] | The device row has an empty list; one event |
Heartbeat without platforms | The 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 after | 200, 204 and 404 as observed, with the same state event for displays of /competition_dashboard |
Questions.
- On the heartbeat, as proposed, or on a route of its own
(
PUT /workout_live_sessions/platform, eventlive_session.platform)? The body would be the same. switch_athletestays untouched. Does anything besides the native iOS app call it in production, and does anything besides the standalone dashboard read itsstateevent?- A
userIDinplatformsthat the session's owner has no access to: refuse the heartbeat, or drop that row? Which rule holds foruserIDon 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. The same time is what a result orders a tie by, and what a stream with a delayed video needs later.
Today.
- Observed on backdev: a set has
created(as the device sent it),createdAt(when it arrived) andupdatedAt. - As read in the client: the tracking app stamps
createdwhen the set is completed and sends it on the create. 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.
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
}
| Field | Meaning |
|---|---|
completedAt in a body | When the set was completed, on the device's clock |
sentAt in a body | The device's clock at the moment this request leaves. The app sets it anew on every attempt |
completedAt on the set DTO | The same moment on the server's clock: the time the request arrived, minus sentAt − completedAt. Never later than createdAt |
- Without
sentAt: storecompletedAtas 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.
Checked on backdev by.
| Request | Expected |
|---|---|
POST with completedAt 90 s before sentAt | The 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 off | The same result |
| POST without the two fields | 201, the set has no completedAt, everything else as today |
PUT …/tracked/:id for a set that was never POSTed, with both fields | The set exists, with completedAt worked out the same way |
A later PUT with another completedAt | Unchanged |
Questions.
- 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. 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. Observed on backdev: the two routes proposed below answer 404, so they are free.
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 ofPUT …/tracked/:currentPersistentSetIDas it is today, holding the set so far: its planned values inmeasurements, the reps lifted so far inworkoutLiveSessionReps, each with itscurrentPersistentRepID. The app sends nodataPackageshere. 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.runningon 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 incomplete, not in the single-set read, and not in alive_session_set.created,.updated,.deletedor.lobby_*frame. It causes nostateevent 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 samecurrentPersistentSetID. You drop the running one and publishlive_session_set.createdas 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 publishlive_session_set.running_endedwith thecurrentPersistentSetID.- The device leaves or is removed, the session ends, or nothing came for ten minutes. The same event.
- The finished set arrives, by POST or by
- 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
measurementsanddata_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.
| Request | Expected |
|---|---|
PUT …/running/:id with no reps | 200; 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 two | One event each, with the reps of the body in its order |
The read of item 5 with ?running=true, and without | The running set under running; without the flag it is not there |
| POST of the finished set under the same id | live_session_set.created with the complete set; the read has no running set with that id |
DELETE …/running/:id for another running set | 204; live_session_set.running_ended |
PUT …/running/:id from a device in no session | 204; nothing published |
| Running sets, then finish the training in the app | The saved workout has all its measurements |
Questions.
- 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?
- Is
currentPersistentSetIDenough as its key, or do you want to give it anidthat the finished set then keeps? - 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. A load run on backdev on 2026-10-07 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, 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. complete with ?packages=false
answers as without it. 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.
Proposed shape.
GET /workout_live_session_sets/board/:liveSessionID, for everyone who may readcomplete. A new route and not a flag oncomplete, because the answer is an object andcompleteis a list.
{
"now": 1791359400352,
"sets": [
{
"id": "E5A18B8A-1CFF-4382-80BA-4B3C9DF23E7E",
"currentPersistentSetID": "0C6B0F2A-51E4-4C0B-9D56-0A1B2C3D4E5F",
"completedAt": 1791359307610,
"user": { "id": "8FCE053A-0885-4170-B22E-536A04B3E064", "name": "Mia Schulz", "hasImage": true },
"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).
- If it is cheap: a measurement names its metric by
metricIDandkeyonly. Today each measurement repeats the metric's whole definition, 354 bytes each (measured in the load run). Every client holds the metric catalogue. This is new against the L3 draft. - 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 asboard. AnETagfrom the set'supdatedAtwould 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.
| Request | Expected |
|---|---|
GET …/board/:id after two sets with packages | 200; now; two sets; every rep with its measurements and hasDataPackages: true, none with dataPackages |
GET …/complete/:id and GET …/reduced/:id for the same session | As before this handoff, except the user of S2 |
GET /workout_live_session_sets/:setID | The set with its reps and their packages |
board from a linked device that is signed in as an athlete | 200 |
board from a signed-in user with no relation to the session | 404 |
board with ?lobby=true in a session that has a lobby set | The lobby set is there, with lobby: true |
Questions.
- A new route, as proposed, or do you prefer a flag on
completeand the server'snowsomewhere else? - Does a set's
updatedAtmove 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. It is also what closes S3.
Both decisions this item waited for are taken (2026-10-07). D3: yes, a
paired display, as a page of the portal, in the same release as the rest.
D9: the standalone app is folded in, which is the left column of the table at
the end of the item. A copy of that app is online at https://comp.enode.ai
and reads from production; the address stays and is pointed at the portal's page when that exists, so
/competition_dashboard stays as it is until then.
Today. The wall is the portal's display mode behind the coach's login.
Observed on backdev: the token that /competition_dashboard/join hands out
opens that session's state and events and nothing else; the set reads, the
session read and the set topic answer 401 for it. As read in the local
checkout: that token is the id of a device-session row of the dashboard
category under the session's owner.
Proposed shape. The pattern is the one televisions use to sign in, the device authorization grant of RFC 8628: the screen shows a short code, a second device that is signed in confirms it, the screen asks until it is answered. Three of its rules are taken over: the code is short-lived, wrong codes are counted, and the secret the screen keeps is long and random.
- The screen asks for a pairing.
POST /live_displays/pairing, no credentials, limited per address. The screen showspairingCodeand keepspairingSecret. The pairing is good for ten minutes.
{
"pairingCode": "483107",
"pairingSecret": "b7c1…",
"expiresAt": 1791360000000,
"interval": 5
}
- 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: more than ten misses by one user in ten minutes answer 429.
{ "pairingCode": "483107", "label": "Gym TV" }
- The screen picks up its token.
GET /live_displays/pairing/:pairingSecret, everyintervalseconds. It answerswaitinguntil the coach has confirmed, then once:
{
"state": "paired",
"displayToken": "p9Qe…",
"liveSessionID": "BBB732A7-A53D-4A36-AEC6-FF2662407026"
}
- The token is random and long, not the id of a row. The screen sends it
as the
display-tokenheader. That name exists since L0 and passed the CORS preflight on backdev. 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.
| Route | For the display |
|---|---|
GET /workout_live_sessions/:id | The session without participatingDevices; the owner as id and name. With joinCode, which the wall shows |
GET /workout_live_sessions/:id/logo | As for the owner |
GET /workout_live_sessions/:id/devices | Label, 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/:setID | As item 5 describes them, with the running sets of item 2 |
GET /workout_live_sessions/:id/best_before | Item 4 |
GET /realtime/events with the topic of its session | Every event of that topic |
The catalogues the views label with: metrics, text contents, data-package types, GET /exercise_definitions/:id | As for a signed-in user. They hold nothing personal |
| Everything else | 401, as without credentials |
- A person is
id,nameandhasImagefor a display, which S2 makes the rule for every reader of a live set. - The exercise comes with the set.
exerciseon a set is the reader's own exercise today. For a display it is the owner's, withnamefilled in. - The display is seen. It sends
PUT /live_displays/presenceevery twenty seconds. The devices read gets a second list,displays, withdisplayID,label,pairedAtandlastSeenAt, and a change goes out aslive_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.
| Request | Expected |
|---|---|
POST /live_displays/pairing without credentials | 200 with a code, a secret, an expiry and an interval |
GET …/pairing/:secret before the coach confirmed | waiting |
| The owner confirms with the code; with a wrong code; with eleven wrong codes | 200 and the display's row; 404; 429 |
GET …/pairing/:secret afterwards, twice | paired with a token and the session's id; then 404 |
| A pairing nobody confirmed, after its expiry | 404 |
With the token: session, logo, devices, board, one set, best_before, the set topic | 200 each. A set posted afterwards arrives on the stream |
The user of a set read with the token | id, name, hasImage, nothing more |
With the token: the board of another session, a set POST, /history/v2/records, a user read | Refused; nothing written |
| The owner removes the display | The stream ends; the token answers 401 |
| The owner ends the session | The token still reads the session and its board |
join, state, events and leave of /competition_dashboard | As before |
What D9 means for the server.
| The app is folded into the paired display | Both are kept | |
|---|---|---|
| Display clients | One: the portal's display page with the token above | Two |
/competition_dashboard | Stays untouched while a standalone app from today is in use. After that join, state, events and leave can go | Stays 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 platform | Item 1. switch_athlete stays for the native app until that sends platforms | Two 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 is | One definition, in the clients: work sets, three slots, a set without a counted rep is a no-lift | Two. The server's has to learn item 7 as well |
| The server's bar path and peaks | The portal draws both from the raw packages itself since 2026-10-07. So this needs a second answer from the product owner: still served, or retired with the app | Served as today |
| The attempt-board build inside every set POST | Can leave the upload path (P2) | Stays, or moves into a job |
Questions.
- A table of its own for pairings and tokens, or do you want to keep a
device-session row of the dashboard category, as
joincreates it? - Which of the catalogues need a JWT today, and can a display token read them?
- 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.
- For D9: how often was
joincalled on production in the last 30 days, from which builds, and who can change the standalone app? - 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. Since
2026-10-07 the portal reads the records itself, with the coach's login,
through POST /history/v2/records, plus one read per athlete to find the
athlete's own exercise. A display without a login cannot do that, the "Before"
screen needs the value before the athlete's first set exists, and the result
of a session needs the value as it was when the session ran, not as it is
weeks later.
Today.
- Observed on backdev:
GETandPOST /history/v2/recordsanswer, the second for the athletes the caller may read. A record appears a few seconds after a finished training was uploaded, not with a live set. AnabsoluteLoadrecord carriedvalue: 70,unit: "kg",previousValue: 60and the athlete's own exercise id; anestimated1RMrecord carriedpreviousValue: -99999999. - As read in the local checkout:
personal_bestsholds the current best per user, exercise and record type (absoluteLoad,repsAtLoad,volumeExercise,volumeSession,estimated1RM,velocityAtLoad,peakPower,jumpHeight), each withachievedAt, the value it replaced and, for the load-bound types, the load. The exercise is the athlete's own exercise, not the definition.
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
startedAtof 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. - It still answers for an ended session, with the same values.
- Base units, like
valueof 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.
An app from today. It asks for none of it. No existing answer changes.
Checked on backdev by.
| Request | Expected |
|---|---|
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_before | One pair, with absoluteLoad at 100 in the base unit |
The same with ?userID=…&exerciseDefinitionID=… | That pair alone |
| An athlete who has never trained the exercise | The pair with records: [] |
| While the session runs, upload a finished training with 110 kg; read again | Still 100 |
| End the session, read again | Still 100 |
The athlete stands in platforms and has no set yet | The pair is there |
| A signed-in user with no relation to the session | 404 |
Questions.
- Is
personal_bestsfilled on backdev and on production, that is, has the backfill run? - 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 thepeakPowerrecords. So ispeakPowercomparable with thepowerPeakof a live rep? - A read, as proposed, or would you rather push the same
recordson thecreatedframe of a pair's 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 and sends an update without it when a rep
is ruled out later. 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
currentPersistentRepIDacross both lists: a rep that is ruled out, or ruled in again, stays the same row and keeps its package. workoutLiveSessionRuledOutRepsis absent when there are none. The same incomplete, in the single set, in the frames, inboardand 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.
| Request | Expected |
|---|---|
| POST with two counted reps and one ruled-out rep, each with a package | 201. 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 second | The same rep row, by its currentPersistentRepID, with its package |
| PUT that moves it back | The same |
| POST without the second list | As today, and no second list in any answer |
A frame on the set topic, and board | The same two lists |
A work set whose reps are all ruled out, read through /competition_dashboard | Not built from a ruled-out rep |
Questions.
- Two lists, as proposed, or one list with
validon each rep and an agreed order of deploys?
7b · The ruling on an attempt
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. The set is still sent, with its own values. On a board ranked by load, which is 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.
- 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"
}
rulingisgoodornoLift. 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/rulingwith a body that holdsrulingasgood, asnoLift, or as 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 29 is the rule on production,
rulingcan 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.
| Request | Expected |
|---|---|
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 null | The 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 owner | 403, the set unchanged |
POST without ruling | As today, and no ruling in any answer |
Questions.
- 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, isrulingbest read from and written to that tag? - A field
rulingas proposed, or the tag alone? - 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?
- Should the standalone board follow the same ruling, so that both boards say the same about a lift?
Item 8 · The result for those who took part (step G)
New on 2026-10-07. It is the one piece of the wrap-up that needs the server.
What is asked. A device that was linked to a session when it ended may read that session's sets for a day afterwards.
Why. When a session ends, the portal shows a result with a podium and a final table, and the display shows the podium. The platform tablet, where the athletes stand, shows nothing: observed on backdev, an ended session's devices are gone, and a device that is in no session may not read its sets. An athlete on their own phone gets no line about how the evening went.
Proposed shape. GET /workout_live_session_sets/reduced/:id and board
also answer a device session that was linked to the session at the moment it
ended, until 24 hours after endedAt. The event
device_session.live_session_changed, which tells the device that its session
is over, carries endedWorkoutLiveSessionID once, so the device knows what to
read.
An app from today. It asks for nothing after the end.
Checked on backdev by.
| Request | Expected |
|---|---|
End a session with a linked tablet; the tablet reads reduced | 200, the sets of the session |
| The same from a tablet that left before the end | 404 |
| The event on the tablet's user stream at the end | No session in it, as today, and endedWorkoutLiveSessionID |
Questions.
- Is "linked at the end, for a day" a rule you can check cheaply, or is there a better mark of "took part", for example "has a set in it"?
Part P · Speed with twenty lifters
What the load run of 2026-10-07 measured on backdev (912 sets in four runs,
none lost, none stored twice, none missing from the stream; the tables are in
docs/testing/live-set-load-run.md): a set of ten reps is on the stream about
0.65 s after its request, and that does not grow from five lifters to twenty.
What grows is the read around the set.
P1 · A linked device may follow the set topic
What is asked. A device session that is linked to a session is admitted to
workout_live_session:sets:<id>, whoever is signed in on it.
Why. A tablet shows the standings. It cannot follow the stream, so it
reads reduced again after every set it delivers. That read returns every set
of the session: 653 kB at 288 sets, 6.5 s with twenty lifters, and up to
15.2 s when all lift at once, which is past the app's deadline for a request.
The set path then manages 2 sets per second instead of 5.3. With the stream a
tablet reads once and applies each set as it is pushed, as the portal does
since L0.
What the client does today (work block W9, built on 2026-10-07): it tries the
topic. A tablet signed in as the session's owner is admitted, reads once and
applies each pushed set, without its reps. A device you refuse reads reduced
once after each set it delivered — no longer when the set is queued, never two
reads at once, and at least two seconds apart. A refusal has to be a 401, 403
or 404 for that: those are the answers the realtime client stops at. S2 and
the slim metric of item 5 take about half of each set off the read.
One thing we lean on in a pushed set: currentPersistentSetID. The device
recognises its own set among the frames by it, and reads if that frame does
not arrive within two seconds of the POST's answer.
Checked on backdev by.
| Request | Expected |
|---|---|
| The set topic from a linked tablet signed in as the owner | 200, as today |
| The same from a linked phone signed in as an athlete | 200 (403 on 2026-10-07) |
| The same after the phone left the session | 403 or 404, and an open stream ends |
Questions.
- Is a linked device on the set topic fine with you, and does an open stream end when its device is unlinked?
P2 · The attempt-board build behind the answer of a set POST
Measured. 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. As read in the local checkout, the POST waits for the attempt board of
/competition_dashboard to be built before it answers.
What is asked. Answer the POST and publish the set first; build the board
state for /competition_dashboard afterwards. If decision D9 folds the
standalone app into the paired display, the build can go altogether.
Questions.
- Can the build move behind the answer without waiting for D9?
P3 · Two outages on backdev, and slow deletes
For your log, not a request for a change in the product:
- On 2026-10-07 from 07:47 to 07:49 UTC backdev stopped answering, right after
a test deleted five athletes at the same time and nine deletes ran into the
app's 15 s deadline. At 09:48 local time it answered 502 for about forty
seconds (Cloudflare
origin_bad_gateway) while a load run was ending. - Deleting one athlete takes 3 to 4 s on backdev, an owner with twenty athletes 20 to 68 s. The tests delete one at a time since.
Questions.
- What does the server's log say about the two windows, and is a delete of 3 to 4 s per athlete what you expect?
P4 · A device that only sends heartbeats must not look silent
New on 2026-10-07, and not about speed: it is a gap in the presence that L2 delivered, found while the screens were looked at.
Seen. In a browser against backdev, with three tablets that were driven
through the API and sent a heartbeat every 20 seconds: after about a minute
the coach's view and the display said "0 of 3 devices connected · Platform 1
not heard from for 2 minutes". GET …/devices at the same moment showed all
three seen a few seconds earlier. To be confirmed with a real tablet; the
code leaves little doubt.
Why. As agreed in L2, a heartbeat that changes nothing publishes nothing,
and the client ages lastSeenAt by itself. So a viewer hears of a device when
its sensor state or its athletes change, and when it returns after more than
60 seconds of silence. A device that is simply fine and unchanged is never
mentioned again, and the viewer cannot tell that from a device that is gone.
What is asked. One of these, whichever is cheaper:
- A sign of life. Publish
live_session.devicefor a heartbeat that changes nothing when the last event for that device is older than 30 seconds. That is at most two events per device and minute. - Or word of silence. The check that runs once a minute publishes
live_session.devicewithsilent: truefor a device whose last heartbeat is older than 60 seconds. A viewer then counts a device as there until it is told otherwise.
Until then the clients read GET …/devices again every 30 seconds while a
session is on screen (work block W10).
An app from today. It sends no heartbeat. Nothing changes for it.
Checked on backdev by.
| Request | Expected |
|---|---|
| A linked tablet sends the same heartbeat every 20 s for three minutes, with the set topic open | With the first way: a live_session.device event at least every 40 s. With the second: none, and none with silent |
| The tablet stops for 90 s | With the second way: one event with silent: true |
| It sends a heartbeat again | One event, as today |
Questions.
- Which of the two ways do you prefer, or do you see a third?
Part L · Later, named so that nothing is built against it
Not asked for now.
- Numbered events and resume. A frame on the set topic has no
idline (observed), and the app already sendsLast-Event-IDfor the day you read it. Withboarda reconnect costs one small read, and the client orders two versions of a set byupdatedAt. To be asked for when a session is so long that this read is felt, or when a wall is seen to miss an event. The usual shape then: anidon every event, a replay window of a few minutes, and a clear answer ("read again") for a client that is further behind than that. - Frames without sensor packages, on a second topic. Only if frames prove heavy on a tablet.
- A read for people without an account, for a spectator link and a public stream. It needs decision D12 of the concept first.
- A start list kept by the server. Until then the list lives in the
session's
configand the clients filter by it. - A ruling suggested by the sensor, and three referees with a device each. Both fit on the field of item 7b.
- Sessions across organisations, for open meets between clubs.
Questions.
- For decision D13: what would it cost to let users with access to the owner read a session and its join code, and to let "organisation" reach one level further, so that an athlete under the head coach can join a session an assistant coach owns?
What the clients will do with it
| Step | Tracking app | Portal | Work block in the concept |
|---|---|---|---|
| R4 | Says "Live · Platform 1" after a restart. Built | — | W9, done |
| S2 | Nothing; it reads id and name already | Nothing | — |
| A | platforms in the heartbeat, per station. completedAt and sentAt on every send from the queue | The screen before a lift. No line and no glow for a set that was completed more than half a minute before it arrived | X1 |
| B | A running set after each repetition | The screen during a lift | X1 |
| C | — | board when a view opens; the single set for a bar path that did not come with a frame | X1 |
| D | — | A display page that pairs by number; the displays of a session in the coach's view | X3 |
| E | — | The personal-best line from best_before, also in a result | X3, W14 |
| F | One question to the judge when an attempt is completed. Ruled-out reps in the second list | "No lift" on the wall; the coach rules from the live view | X2 |
| G | The final table when a session ends | — | W14 |
| P1 | Follows the set topic instead of reading after every set. Built: it switches on by itself for a device you admit | — | W9, done |
| P4 | — | Stops reading the device list every 30 seconds | W10 |
Acceptance, for every step
| Scenario | Expected |
|---|---|
| An app build from today, unchanged | Every request answers as before, except the user of S2 |
The contract tests of L0 and L2 (15-live-l0-contract, 16-live-set-topic, 25-live-l2-contract) and of the result (40-live-result-contract) against the new build | Pass unchanged |
| Finish a training whose sets were live, with running sets and ruled-out reps among them | The 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.
- Is the order R, S, A to G, P right from your side? Which step is cheaper or dearer than it looks here?
All questions, by number
| No. | Where | Question in short |
|---|---|---|
| 1 | R1 | A switch for L0 or L2, and the way back after a deploy |
| 2 | R1 | Cheaper reads and 83a811f8 in the same release |
| 3 | R2 | Sessions past their end date that are still used: the count |
| 4 | R3 | How long live rows keep the values a repair needs; the current numbers |
| 5 | R4 | Answered by the rebuild of 2026-10-07 |
| 6 | R5 | JPEG logos accepted on purpose |
| 7 | S1 | The two steps; the native iOS app's device id |
| 8 | S2 | Who reads more than id and name from a live set's user |
| 9 | S2 | A linked device signed in as an athlete on the set topic today |
| 10 | S3 | Does the standalone app write state with its token |
| 11 | Item 1 | On the heartbeat or on a route of its own |
| 12 | Item 1 | Callers of switch_athlete, readers of its state event |
| 13 | Item 1 | A userID the owner has no access to |
| 14 | Item 3 | Correct to the server's clock, or store both values |
| 15 | Item 2 | Where a running set is held |
| 16 | Item 2 | Its key |
| 17 | Item 2 | One request per repetition |
| 18 | Item 5 | A new route or a flag on complete |
| 19 | Item 5 | Does updatedAt move with every stored change |
| 20 | Item 6 | A table for pairings and tokens |
| 21 | Item 6 | Catalogues a display token can read |
| 22 | Item 6 | Photos for a display |
| 23 | Item 6 | Use of join on production, for D9 |
| 24 | Item 6 | A token that reads for a day after the end |
| 25 | Item 4 | Is personal_bests filled |
| 26 | Item 4 | Are records on the scale of live measurements |
| 27 | Item 4 | A read or a push |
| 28 | Item 7a | Two lists or one flag |
| 29 | Item 7b | The no-lift tag on production |
| 30 | Item 7b | A field ruling or the tag alone |
| 31 | Item 7b | May the owner change a ruling |
| 32 | Item 7b | The standalone board and the ruling |
| 33 | Item 8 | What marks a device as "took part" |
| 34 | P1 | A linked device on the set topic |
| 35 | P2 | The board build behind the answer |
| 36 | P3 | The two outages, and slow deletes |
| 37 | P4 | A sign of life for a device that only sends heartbeats |
| 38 | Part L | The cost of decision D13 |
| 39 | Acceptance | The order, and what is cheaper or dearer |
Agreed with the backend
From the backend's response of 2026-10-07 ("Live Hub, everything that is open"). Where a point here and the body disagree, this section holds.
Built
Eight points, committed on bugfix/loading_factor_api_technique as
13b7a267. Not pushed, not merged, 499 backend tests green, not run against a
client. Names of fields, routes and events are kept as asked.
| Point | What is built | Differs from what was asked |
|---|---|---|
| S2 | user of a live set and of each of its reps is id, name, hasImage: on complete, reduced, the single set, board and in every created, updated and lobby_* frame, for every reader, the owner included | No. Not changed: the owner on the session DTO, and the state event of /competition_dashboard |
| Item 3 | completedAt and sentAt in the bodies of POST and PUT …/tracked/:id; completedAt on the set, on the server's clock | No. Added: a set that exists without a completedAt takes it from the first body that carries one |
| Item 1 | platforms on the heartbeat, on the device row and in live_session.device, with user in the slim form | Kept in memory. After a restart of the server a row has none until that device's next heartbeat. A row whose userID the owner has no access to is dropped, and so is such a next; the heartbeat is not refused. More than 8 rows answer 400 |
| Item 5 | GET /workout_live_session_sets/board/:liveSessionID answers now and sets. A rep has no dataPackages and says hasDataPackages. A measurement is id, created, value, valueUser, confidence, metricID and a metric that holds its key only | No running key until item 2 exists. No ETag. The slim metric holds for board only |
| P4 | A heartbeat that changes nothing is announced as live_session.device when it is the first of that device in a 30-second window of the server's clock (seconds 0 to 29 and 30 to 59). With a heartbeat every 20 seconds: an event every 20 to 40 seconds per device | The first way, with another trigger than "the last event is older than 30 seconds" |
| P1 | The set topic admits the owner, users with access to the owner, and a device that is linked to the session, by the device and user in its token. A session that is over admits no linked device | An open stream stays open when its device is unlinked. The device learns it from device_session.live_session_changed and closes the stream itself |
| P2 | A set request publishes on the set topic and answers; the board of /competition_dashboard is built afterwards | No |
| S1 | Step 1: a set request whose device-id header names another device than its token is logged with the app version, compared without regard to case. Step 2 follows after a week on production | No |
One database change in all of it: a column completed_at on the live sets.
Not built, and why
| Point | State |
|---|---|
| Item 2, running sets | Possible. Would be kept in memory like platforms, apart from the measurement tables |
| Item 7a, ruled-out reps | Possible. Needs a column on the live reps and touches the rep matching of L0 |
| Item 7b, the ruling | Possible. Waits for the answer to "a field or the tag" (question 30) |
| Item 4, best before | Possible. Waits for a look at personal_bests on production (questions 25, 26) |
| Item 8, the result for those who took part | Possible. A device's link is removed when its session ends, so "was linked at the end" would have to be kept first. "Has a set in it" can be checked without storing anything |
| Item 6, the paired display | Can start: D3 and D9 are decided (2026-10-07) |
| S3 | Waits for the answer to question 10, which is about the standalone app |
| R2, R3 | Wait for numbers from production and a decision |
The answers, by question number
| No. | Answer |
|---|---|
| 1 | No switch. The way back is a deploy of the previous build: the migrations only add columns and tables, so the previous build runs on the migrated database |
| 2 | Yes, one release, with 13b7a267 |
| 3 | Not counted yet. It has to come from the production database |
| 4 | Ending a session keeps its live rows. Deleting a session removes the rows that no saved workout has claimed. So the values of a deleted session are gone, and a repair can only use sessions that still exist. The two numbers are from a snapshot that ends on 2026-09-03 and are not renewed |
| 5 | With the product owner. (backdev was rebuilt on 2026-10-07 around 13:00 with 83a811f8.) |
| 6 | The server stores the type the client names and checks nothing. JPEG works and can be relied on |
| 7 | The two-step way is fine; step 1 is built. The native iOS app's device id is not known from the code |
| 8 | Nothing on the server reads more. The native iOS app does not read live sets (product owner, 2026-10-07) |
| 9 | Until 13b7a267: no, it got 403. From it on: yes, with the slim user |
| 10 | Not known on the backend's side |
| 11 | On the heartbeat |
| 12 | Not known for production. In the code switch_athlete only feeds the state event of /competition_dashboard |
| 13 | The row is dropped. A set POST does not check its userID against the owner today |
| 14 | Corrected to the server's clock |
| 15 | In memory, apart from the measurement tables |
| 16 | currentPersistentSetID is enough |
| 17 | One request per repetition is fine |
| 18 | A new route |
| 19 | Yes for POST and PUT …/tracked: both set updatedAt on every request, also when only reps changed |
| 20 to 24 | Open with item 6 |
| 25, 26 | Not looked at yet |
| 27 | A read |
| 28 | Two lists |
| 29 | The tag is in the code as read. Whether production uses it is not known |
| 30 | Open. A field ruling is one more column; reading and writing the tag needs none |
| 31 | Yes. The owner's update, PUT …/external/:id, reaches every set of the owner's session, whichever device sent it |
| 32 | Open with D9 |
| 33 | "Has a set in it" is checked without storing anything new. "Linked at the end" is not |
| 34 | Yes, built. The open stream does not end |
| 35 | Yes, built |
| 36 | Not looked at |
| 37 | The first way, as built |
| 38 | Not estimated yet |
| 39 | The order is fine. Dearer than it looks: items 6 and 7a. Cheaper: items 1 and 3, P1, P4 |
What the answer changes on our side
- P4: the clients will call a device silent after 90 seconds without an event, not 60, once they rely on the events. With an event every 20 to 40 seconds, one heartbeat that is lost leaves 60 seconds between two events. Until then they read the device list every 30 seconds, as built on 2026-10-07.
- P1: the tablet closes its stream when it learns that it is out of the session.
- Item 7b: the owner rules through
PUT …/external/:id, which exists (answer 31). The route proposed in the body is not needed. - Item 8: "has a set in it" is taken (answer 33).
- Two points the answers opened are asked in the next document: a set's
userIDis not checked against the owner (answer 13), and a deleted session takes the values with it that a repair would need (answer 4).
Observed on backdev after delivery
2026-10-07, 13:15: backdev does not run 13b7a267 yet. One check per
built point that a client can see is in
tests/live/offline/82-live-open-contract.live.test.ts: S2, item 3, item 5,
item 1, P4 and P1. All six failed, each in the way an older server answers:
the whole profile on a set, no completedAt, 404 on board, no platforms
on the device row, no event for an unchanged heartbeat, 403 for an athlete's
phone on the set topic. P2 and S1 cannot be seen by a client.
2026-10-07, 13:45: backdev runs 13b7a267, and all six checks pass.
npx vitest run --config tests/live/vitest.config.ts tests/live/offline/82-live-open-contract
The values seen are listed, together with those of the next step, in live-hub-backend-handoff-open-2.md.