Skip to main content

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​

WhatStateOn backdevOn production
L0: idempotent sets, upsert, session end, one date encoding, read rights, the data-loss fixCommitted (44cd8ef8 on bugfix/loading_factor_api_technique), not mergedSince 2026-10-06No
L2: join by code, lobby and Go live, the device event, presence, session events, idle end, the clear flagsCommittedSince 2026-10-07No
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-07Not known to usNo
Topic id in either case, a blank inside a code, the device's label on its device sessionCommitted (83a811f8)The label since 2026-10-07 (seen at 13:00); the other two not looked at againNo
Tracking app and portal for all of the aboveBuilt 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​

PartStepsWhat a gym gets from itWhat waits for it
R · Reach productionR1 to R5Everything built since 2026-10-06: no lost sets, sessions that end, join by code, the lobbyEvery coach. Two decisions of the product owner need a number from you first (R2, R3)
S · People's data, and who may writeS1 to S3No athlete's e-mail address or body weight on another athlete's phone. Nobody writes into a session through a device id they only knowS2 belongs into the same release as L2
A to G · The wallItems 1 to 8Who 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 tabletThe client blocks X1 to X3 of the concept
P · Speed with twenty lifters, and one gap in presenceP1 to P4The standings on a tablet stay current in a long session. A tablet that is fine is not reported as silentThe health line of the coach's view and of the display (P4)
L · Laternamed only——

Three answers that unblock the most​

  1. 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.
  2. A yes or no to S2: a live set says id, name and hasImage about a person, nothing more.
  3. 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 doesWhat the server does with it after this handoff
The tracking app sends finished sets onlyStored 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 platformsThe 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 bodyAs today: the set holds counted reps only (item 7)
It reads reduced after each set it deliversThe same answer, except the fields of user named in S2
The portal reads complete and follows live_session_set.created, .updated and .deletedThe same answers and frames, sensor packages included, except the fields of user named in S2
The standalone competition-dashboard app joins through /competition_dashboardUntouched until decision D9 is taken (item 6, S3)
The native iOS app calls switch_athleteUntouched (item 1)

Conventions, as agreed in L2​

  • The device is the one in the JWT, not the device-id header. 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:

BasisMeaning
Observed on backdevA request was sent on 2026-10-06 or 2026-10-07 and the answer read
Reported by youFrom your responses of 2026-10-06 and 2026-10-07
As read in the local checkoutRead in the server code at 1ffdcaec, not executed. It may differ from what backdev runs
As read in the clientRead 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.

DecisionWhere 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 endR2. Needs your count first
Workouts that lost values before the L0 fix: repair them or notR3. Needs your numbers first
D3 · How the wall gets its picture. Recommended: a paired display. The screen shows a number, the coach confirms itItem 6 assumes it
D9 · The standalone competition-dashboard app: fold it into the paired display, or keep bothItem 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 portalItem 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 sessionQuestion 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 fieldAfter the deploy
Sends a set twice after a lost answerOne set, with its values (L0, item 1)
Sends into a session that was ended or is past its end date204, nothing stored. Before: stored for ever (L0, item 4, and R2)
The portal of today writes PUT /competition_dashboard/:id/stateAs agreed in reply 1, question 1
Edits a setSensor packages are replaced, not appended (reply 1)
Everything elseAs 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.

  1. Is anything in L0 or L2 meant to go behind a switch, and what is the way back if production misbehaves after the deploy?
  2. Do the cheaper reads and 83a811f8 go 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. On the set routes (POST, PUT …/tracked, DELETE …/tracked), compare the device of the token with the device-id header without regard to case, keep using the header, and count the requests where the two differ, with the app version. One week on production.
  2. 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.

RequestExpected after step 2
A set POST with the token of tablet A and the device-id of tablet B, both linked to different sessionsThe set is stored in A's session
The same with the device-id of a device in no sessionStored in A's session, not answered with 204
A set POST from a device whose header and token agreeAs today

Questions.

  1. 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.

RequestExpected
reduced, complete, the single set and a created frame, as the owneruser has id, name, hasImage and no other key
reduced from a linked phone that is signed in as an athleteThe same, for the athlete's own sets and for everybody else's
The state event of /competition_dashboardAs today. That surface is not touched here

Questions.

  1. Does anything read more than id and name from a live set's user: the native iOS app, the standalone board, a job?
  2. 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.

  1. Does the standalone dashboard app write state with 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.

StepItemWhat the wall gainsServer work, roughlyShips alone
A1 · Who is on the platformThe screen before a lift, and "up next"One optional field on the heartbeat, on the device row and in its eventYes
A3 · When a set was completedNo highlight for a late set; lifts in the order they were liftedTwo optional body fields, one field on the setYes
B2 · The set while it is liftedThe screen during a liftTwo routes, two event names, one rule for the readsYes. Best after A, it shares a key with item 1
C5 · The board without sensor packagesThe wall opens without loading every sensor package of the sessionOne readYes
D6 · A display without a loginNo coach account open on the gym TV, and S3 is closedPairing, a token, and what it may readAfter the decisions. It names the reads of A to C
E4 · Best before today"Personal best" as the line of a lift, also on a paired displayOne read over personal_bestsYes. On a paired display it is the only way
F7 · Ruled-out reps, and the ruling on an attempt"No lift" is what the judge ruled, and such a lift is not rankedOne column and a second list; one field on the setYes
G8 · The result for those who took partThe final table on the platform tabletOne rule for who may read an ended sessionYes

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 switchLiveAthlete exists and has never had a caller; the product owner confirmed that the app can send the selection.
  • Observed on backdev: switch_athlete answers 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_dashboard only, as a state event. Nothing goes to the set topic. An unknown field platforms on 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 rowMeaning
stationWhich column of the tablet, 1 to 3. A phone has one. With the device's label it names the platform
userIDThe athlete selected in that station
exerciseDefinitionIDThe exercise that is open. Optional
currentPersistentSetIDThe id the set will be sent under when it is completed. The same id as in item 2. Optional
workFalse for a warm-up. Optional
loadThe load planned for the set, in the base unit, as value of the set's loadingMass measurement. Absent when the set has none
plannedRepsOptional
nextWho the app would rotate to next in this station, with their planned load if it knows one. Optional
  • platforms absent: 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/devices and in the live_session.device event, with user in place of userID, 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.device at once when platforms differs 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.

RequestExpected
Heartbeat with one row in platforms, then GET …/:id/devicesThe row comes back with station, user.id, user.name, load and next.user.name
The same with the set topic openOne live_session.device event with the same platforms
The same heartbeat againNo event
Heartbeat with platforms: []The device row has an empty list; one event
Heartbeat without platformsThe 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 after200, 204 and 404 as observed, with the same state event for displays of /competition_dashboard

Questions.

  1. On the heartbeat, as proposed, or on a route of its own (PUT /workout_live_sessions/platform, event live_session.platform)? The body would be the same.
  2. switch_athlete stays untouched. Does anything besides the native iOS app call it in production, and does anything besides the standalone dashboard read its state event?
  3. A userID in platforms that the session's owner has no access to: refuse the heartbeat, or drop that row? Which rule holds for userID on 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) and updatedAt.
  • As read in the client: the tracking app stamps created when 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
}
FieldMeaning
completedAt in a bodyWhen the set was completed, on the device's clock
sentAt in a bodyThe device's clock at the moment this request leaves. The app sets it anew on every attempt
completedAt on the set DTOThe same moment on the server's clock: the time the request arrived, minus sentAt − completedAt. Never later than createdAt
  • Without sentAt: store completedAt as 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.

RequestExpected
POST with completedAt 90 s before sentAtThe 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 offThe same result
POST without the two fields201, the set has no completedAt, everything else as today
PUT …/tracked/:id for a set that was never POSTed, with both fieldsThe set exists, with completedAt worked out the same way
A later PUT with another completedAtUnchanged

Questions.

  1. 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 of PUT …/tracked/:currentPersistentSetID as it is today, holding the set so far: its planned values in measurements, the reps lifted so far in workoutLiveSessionReps, each with its currentPersistentRepID. The app sends no dataPackages here. 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.running on 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 in complete, not in the single-set read, and not in a live_session_set.created, .updated, .deleted or .lobby_* frame. It causes no state event 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 same currentPersistentSetID. You drop the running one and publish live_session_set.created as 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 publish live_session_set.running_ended with the currentPersistentSetID.
    • The device leaves or is removed, the session ends, or nothing came for ten minutes. The same event.
  • 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 measurements and data_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.

RequestExpected
PUT …/running/:id with no reps200; 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 twoOne event each, with the reps of the body in its order
The read of item 5 with ?running=true, and withoutThe running set under running; without the flag it is not there
POST of the finished set under the same idlive_session_set.created with the complete set; the read has no running set with that id
DELETE …/running/:id for another running set204; live_session_set.running_ended
PUT …/running/:id from a device in no session204; nothing published
Running sets, then finish the training in the appThe saved workout has all its measurements

Questions.

  1. 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?
  2. Is currentPersistentSetID enough as its key, or do you want to give it an id that the finished set then keeps?
  3. 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 read complete. A new route and not a flag on complete, because the answer is an object and complete is 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 metricID and key only. 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 as board. An ETag from the set's updatedAt would 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.

RequestExpected
GET …/board/:id after two sets with packages200; now; two sets; every rep with its measurements and hasDataPackages: true, none with dataPackages
GET …/complete/:id and GET …/reduced/:id for the same sessionAs before this handoff, except the user of S2
GET /workout_live_session_sets/:setIDThe set with its reps and their packages
board from a linked device that is signed in as an athlete200
board from a signed-in user with no relation to the session404
board with ?lobby=true in a session that has a lobby setThe lobby set is there, with lobby: true

Questions.

  1. A new route, as proposed, or do you prefer a flag on complete and the server's now somewhere else?
  2. Does a set's updatedAt move 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.

  1. The screen asks for a pairing. POST /live_displays/pairing, no credentials, limited per address. The screen shows pairingCode and keeps pairingSecret. The pairing is good for ten minutes.
{
"pairingCode": "483107",
"pairingSecret": "b7c1…",
"expiresAt": 1791360000000,
"interval": 5
}
  1. 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" }
  1. The screen picks up its token. GET /live_displays/pairing/:pairingSecret, every interval seconds. It answers waiting until the coach has confirmed, then once:
{
"state": "paired",
"displayToken": "p9Qe…",
"liveSessionID": "BBB732A7-A53D-4A36-AEC6-FF2662407026"
}
  1. The token is random and long, not the id of a row. The screen sends it as the display-token header. 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.

RouteFor the display
GET /workout_live_sessions/:idThe session without participatingDevices; the owner as id and name. With joinCode, which the wall shows
GET /workout_live_sessions/:id/logoAs for the owner
GET /workout_live_sessions/:id/devicesLabel, 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/:setIDAs item 5 describes them, with the running sets of item 2
GET /workout_live_sessions/:id/best_beforeItem 4
GET /realtime/events with the topic of its sessionEvery event of that topic
The catalogues the views label with: metrics, text contents, data-package types, GET /exercise_definitions/:idAs for a signed-in user. They hold nothing personal
Everything else401, as without credentials
  • A person is id, name and hasImage for a display, which S2 makes the rule for every reader of a live set.
  • The exercise comes with the set. exercise on a set is the reader's own exercise today. For a display it is the owner's, with name filled in.
  • The display is seen. It sends PUT /live_displays/presence every twenty seconds. The devices read gets a second list, displays, with displayID, label, pairedAt and lastSeenAt, and a change goes out as live_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.

RequestExpected
POST /live_displays/pairing without credentials200 with a code, a secret, an expiry and an interval
GET …/pairing/:secret before the coach confirmedwaiting
The owner confirms with the code; with a wrong code; with eleven wrong codes200 and the display's row; 404; 429
GET …/pairing/:secret afterwards, twicepaired with a token and the session's id; then 404
A pairing nobody confirmed, after its expiry404
With the token: session, logo, devices, board, one set, best_before, the set topic200 each. A set posted afterwards arrives on the stream
The user of a set read with the tokenid, name, hasImage, nothing more
With the token: the board of another session, a set POST, /history/v2/records, a user readRefused; nothing written
The owner removes the displayThe stream ends; the token answers 401
The owner ends the sessionThe token still reads the session and its board
join, state, events and leave of /competition_dashboardAs before

What D9 means for the server.

The app is folded into the paired displayBoth are kept
Display clientsOne: the portal's display page with the token aboveTwo
/competition_dashboardStays untouched while a standalone app from today is in use. After that join, state, events and leave can goStays 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 platformItem 1. switch_athlete stays for the native app until that sends platformsTwo 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 isOne definition, in the clients: work sets, three slots, a set without a counted rep is a no-liftTwo. The server's has to learn item 7 as well
The server's bar path and peaksThe 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 appServed as today
The attempt-board build inside every set POSTCan leave the upload path (P2)Stays, or moves into a job

Questions.

  1. A table of its own for pairings and tokens, or do you want to keep a device-session row of the dashboard category, as join creates it?
  2. Which of the catalogues need a JWT today, and can a display token read them?
  3. 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.
  4. For D9: how often was join called on production in the last 30 days, from which builds, and who can change the standalone app?
  5. 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: GET and POST /history/v2/records answer, 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. An absoluteLoad record carried value: 70, unit: "kg", previousValue: 60 and the athlete's own exercise id; an estimated1RM record carried previousValue: -99999999.
  • As read in the local checkout: personal_bests holds the current best per user, exercise and record type (absoluteLoad, repsAtLoad, volumeExercise, volumeSession, estimated1RM, velocityAtLoad, peakPower, jumpHeight), each with achievedAt, 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 startedAt of 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 value of 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.

RequestExpected
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_beforeOne pair, with absoluteLoad at 100 in the base unit
The same with ?userID=…&exerciseDefinitionID=…That pair alone
An athlete who has never trained the exerciseThe pair with records: []
While the session runs, upload a finished training with 110 kg; read againStill 100
End the session, read againStill 100
The athlete stands in platforms and has no set yetThe pair is there
A signed-in user with no relation to the session404

Questions.

  1. Is personal_bests filled on backdev and on production, that is, has the backfill run?
  2. 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 the peakPower records. So is peakPower comparable with the powerPeak of a live rep?
  3. A read, as proposed, or would you rather push the same records on the created frame 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 currentPersistentRepID across both lists: a rep that is ruled out, or ruled in again, stays the same row and keeps its package.
  • workoutLiveSessionRuledOutReps is absent when there are none. The same in complete, in the single set, in the frames, in board and 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.

RequestExpected
POST with two counted reps and one ruled-out rep, each with a package201. 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 secondThe same rep row, by its currentPersistentRepID, with its package
PUT that moves it backThe same
POST without the second listAs today, and no second list in any answer
A frame on the set topic, and boardThe same two lists
A work set whose reps are all ruled out, read through /competition_dashboardNot built from a ruled-out rep

Questions.

  1. Two lists, as proposed, or one list with valid on 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"
}
  • ruling is good or noLift. 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/ruling with a body that holds ruling as good, as noLift, 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, ruling can 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.

RequestExpected
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 nullThe 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 owner403, the set unchanged
POST without rulingAs today, and no ruling in any answer

Questions.

  1. 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, is ruling best read from and written to that tag?
  2. A field ruling as proposed, or the tag alone?
  3. 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?
  4. 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.

RequestExpected
End a session with a linked tablet; the tablet reads reduced200, the sets of the session
The same from a tablet that left before the end404
The event on the tablet's user stream at the endNo session in it, as today, and endedWorkoutLiveSessionID

Questions.

  1. 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.

RequestExpected
The set topic from a linked tablet signed in as the owner200, as today
The same from a linked phone signed in as an athlete200 (403 on 2026-10-07)
The same after the phone left the session403 or 404, and an open stream ends

Questions.

  1. 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.

  1. 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.

  1. 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.device for 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.device with silent: true for 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.

RequestExpected
A linked tablet sends the same heartbeat every 20 s for three minutes, with the set topic openWith 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 sWith the second way: one event with silent: true
It sends a heartbeat againOne event, as today

Questions.

  1. 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 id line (observed), and the app already sends Last-Event-ID for the day you read it. With board a reconnect costs one small read, and the client orders two versions of a set by updatedAt. 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: an id on 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 config and 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.

  1. 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​

StepTracking appPortalWork block in the concept
R4Says "Live · Platform 1" after a restart. Built—W9, done
S2Nothing; it reads id and name alreadyNothing—
Aplatforms in the heartbeat, per station. completedAt and sentAt on every send from the queueThe screen before a lift. No line and no glow for a set that was completed more than half a minute before it arrivedX1
BA running set after each repetitionThe screen during a liftX1
C—board when a view opens; the single set for a bar path that did not come with a frameX1
D—A display page that pairs by number; the displays of a session in the coach's viewX3
E—The personal-best line from best_before, also in a resultX3, W14
FOne 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 viewX2
GThe final table when a session ends—W14
P1Follows 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 secondsW10

Acceptance, for every step​

ScenarioExpected
An app build from today, unchangedEvery 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 buildPass unchanged
Finish a training whose sets were live, with running sets and ruled-out reps among themThe 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.

  1. 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.WhereQuestion in short
1R1A switch for L0 or L2, and the way back after a deploy
2R1Cheaper reads and 83a811f8 in the same release
3R2Sessions past their end date that are still used: the count
4R3How long live rows keep the values a repair needs; the current numbers
5R4Answered by the rebuild of 2026-10-07
6R5JPEG logos accepted on purpose
7S1The two steps; the native iOS app's device id
8S2Who reads more than id and name from a live set's user
9S2A linked device signed in as an athlete on the set topic today
10S3Does the standalone app write state with its token
11Item 1On the heartbeat or on a route of its own
12Item 1Callers of switch_athlete, readers of its state event
13Item 1A userID the owner has no access to
14Item 3Correct to the server's clock, or store both values
15Item 2Where a running set is held
16Item 2Its key
17Item 2One request per repetition
18Item 5A new route or a flag on complete
19Item 5Does updatedAt move with every stored change
20Item 6A table for pairings and tokens
21Item 6Catalogues a display token can read
22Item 6Photos for a display
23Item 6Use of join on production, for D9
24Item 6A token that reads for a day after the end
25Item 4Is personal_bests filled
26Item 4Are records on the scale of live measurements
27Item 4A read or a push
28Item 7aTwo lists or one flag
29Item 7bThe no-lift tag on production
30Item 7bA field ruling or the tag alone
31Item 7bMay the owner change a ruling
32Item 7bThe standalone board and the ruling
33Item 8What marks a device as "took part"
34P1A linked device on the set topic
35P2The board build behind the answer
36P3The two outages, and slow deletes
37P4A sign of life for a device that only sends heartbeats
38Part LThe cost of decision D13
39AcceptanceThe 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.

PointWhat is builtDiffers from what was asked
S2user 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 includedNo. Not changed: the owner on the session DTO, and the state event of /competition_dashboard
Item 3completedAt and sentAt in the bodies of POST and PUT …/tracked/:id; completedAt on the set, on the server's clockNo. Added: a set that exists without a completedAt takes it from the first body that carries one
Item 1platforms on the heartbeat, on the device row and in live_session.device, with user in the slim formKept 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 5GET /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 onlyNo running key until item 2 exists. No ETag. The slim metric holds for board only
P4A 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 deviceThe first way, with another trigger than "the last event is older than 30 seconds"
P1The 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 deviceAn open stream stays open when its device is unlinked. The device learns it from device_session.live_session_changed and closes the stream itself
P2A set request publishes on the set topic and answers; the board of /competition_dashboard is built afterwardsNo
S1Step 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 productionNo

One database change in all of it: a column completed_at on the live sets.

Not built, and why​

PointState
Item 2, running setsPossible. Would be kept in memory like platforms, apart from the measurement tables
Item 7a, ruled-out repsPossible. Needs a column on the live reps and touches the rep matching of L0
Item 7b, the rulingPossible. Waits for the answer to "a field or the tag" (question 30)
Item 4, best beforePossible. Waits for a look at personal_bests on production (questions 25, 26)
Item 8, the result for those who took partPossible. 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 displayCan start: D3 and D9 are decided (2026-10-07)
S3Waits for the answer to question 10, which is about the standalone app
R2, R3Wait for numbers from production and a decision

The answers, by question number​

No.Answer
1No 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
2Yes, one release, with 13b7a267
3Not counted yet. It has to come from the production database
4Ending 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
5With the product owner. (backdev was rebuilt on 2026-10-07 around 13:00 with 83a811f8.)
6The server stores the type the client names and checks nothing. JPEG works and can be relied on
7The two-step way is fine; step 1 is built. The native iOS app's device id is not known from the code
8Nothing on the server reads more. The native iOS app does not read live sets (product owner, 2026-10-07)
9Until 13b7a267: no, it got 403. From it on: yes, with the slim user
10Not known on the backend's side
11On the heartbeat
12Not known for production. In the code switch_athlete only feeds the state event of /competition_dashboard
13The row is dropped. A set POST does not check its userID against the owner today
14Corrected to the server's clock
15In memory, apart from the measurement tables
16currentPersistentSetID is enough
17One request per repetition is fine
18A new route
19Yes for POST and PUT …/tracked: both set updatedAt on every request, also when only reps changed
20 to 24Open with item 6
25, 26Not looked at yet
27A read
28Two lists
29The tag is in the code as read. Whether production uses it is not known
30Open. A field ruling is one more column; reading and writing the tag needs none
31Yes. The owner's update, PUT …/external/:id, reaches every set of the owner's session, whichever device sent it
32Open with D9
33"Has a set in it" is checked without storing anything new. "Linked at the end" is not
34Yes, built. The open stream does not end
35Yes, built
36Not looked at
37The first way, as built
38Not estimated yet
39The 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 userID is 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.