Backend handoff: Live Hub, what is open after your answer
Audience: backend developer (enode_backend_v1). Status: sent, and
answered by the backend on 2026-10-07, twice: the four points asked for next
are built (backend commit af446f6e, on top of 13b7a267), and so is item 7,
the ruling and ruled-out reps (5cae8750). backdev runs all three.
The answer is under Agreed with the backend, which
holds where it differs from the body, and what was seen on backdev is under
Observed on backdev after delivery.
What is left on the server side waits for decisions and for numbers from
production; it is listed at the end of that section.
Thank you for 13b7a267. Eight of the points of
the first document are built in a day,
and nearly every question has an answer. Your response is written into that
document's section "Agreed with the backend". This one is what comes next:
- what we read out of your answer, so that you can correct us (part 1),
- two points your answers opened, and one we missed (part 2),
- the next steps of the wall, with the answers you were waiting for (part 3),
- the release (part 4).
The items keep their names. The questions go on from the first document's last number, so that every question has one number: this document asks 40 to 49.
The rule stays: the server only adds, so that an app build from today keeps working. S4 below is the one place where a request that is accepted today would be refused; it says why no build of ours sends such a request.
In one page
| What | Who moves | |
|---|---|---|
| Now | backdev runs 13b7a267 | Whoever rebuilds backdev. We then run the six checks of live test 82 |
| Now | The count for R2 and the list for R3, from the production database | You, or whoever may read that database |
| Next | S4: a set names only people of the session's owner | You. Small |
| Next | Item 2, the set while it is lifted | You. Everything you asked is answered |
| Next | Item 8, the result for those who took part, by "has a set in it" | You. Small |
| After a yes of the product owner | Item 7, ruled-out reps and the ruling. Our answer to "a field or the tag" is in part 3 | The product owner, then you |
| After a look at production | Item 4, best before | You |
| After decisions D3 and D9 | Item 6, the paired display, and S3 | The product owner, then you |
| After a week on production | S1, step 2 | You |
What each statement rests on
| Basis | Meaning |
|---|---|
| Reported by you | From your response of 2026-10-07 to the first document |
| Observed on backdev | A request was sent on 2026-10-07 and the answer read |
| As read in the client | Read in enode-tracking, working tree of 2026-10-07 |
Part 1 · What we read out of your answer
backdev does not run it yet
Observed on backdev, 2026-10-07 at 13:15: one check per built point that a
client can see, in tests/live/offline/82-live-open-contract.live.test.ts.
All six answered as a server without 13b7a267 does.
| Point | Check | Answer on backdev at 13:15 |
|---|---|---|
| S2 | The keys of user on reduced, complete, the single set and a created frame | The whole profile |
| Item 3 | POST with completedAt 90 s before sentAt, then the set's completedAt | The set has none |
| Item 5 | GET /workout_live_session_sets/board/:id | 404 |
| Item 1 | Heartbeat with one row in platforms, then the device row | The row has no platforms |
| P4 | The same heartbeat four times, 20 s apart, with the set topic open | No live_session.device event |
| P1 | The set topic from a linked phone that is signed in as an athlete | 403 |
The label of a device (83a811f8) is on backdev since about 13:00 that day,
and the app reads it. So the rebuild that brought it was made before
13b7a267. When backdev has the commit, the six checks are run again and
their results go into the first document, under "Observed on backdev after
delivery". P2 and S1 cannot be seen by a client.
The eight points, and what the clients do with each
| Point | What the clients do | State on our side |
|---|---|---|
| S2 | Nothing. Both read id and name of a live set's user and no more | Done by you alone |
| Item 3 | The tracking app sends completedAt and sentAt with every send from its queue. The display adds a set that was completed more than half a minute before it arrived without a line and without a glow, and orders lifts by completedAt | To build (work block X1) |
| Item 1 | The tracking app sends platforms in its heartbeat, per station. The display shows who is up before the lift | To build (X1). Behind a switch of the session until the product owner has said what the wall may show |
| Item 5 | The display reads board when it opens and after a reconnect, and the single set for a bar path that did not come with a frame | To build (X1). Against a server that answers 404 it reads complete, as today |
| P4 | See below | The stand-in is built |
| P1 | The tablet follows the set topic, reads once and applies each pushed set. It closes the stream when device_session.live_session_changed says it is out | Built on 2026-10-07; it switches on by itself for a device you admit |
| P2 | Nothing | — |
| S1 | Nothing | — |
Four remarks
P4 · We will count 90 seconds, not 60. Your events come every 20 to 40
seconds for a device that sends a heartbeat every 20. If one heartbeat is
lost on the way, two events can be 60 seconds apart, and a rule "silent after
60 seconds" would name a tablet that is fine. So a viewer that relies on the
events calls a device silent after 90 seconds. Nothing to change on your
side. Today the clients do not rely on the events yet: since 2026-10-07 they
read GET …/devices again every 30 seconds while a session is on screen, and
that read can go once P4 is on production.
Item 1 and item 2 · Kept in memory. Fine for a restart: a device row has
no platforms for at most 20 seconds, and a running set lives for a minute
anyway. What we cannot see from outside is question 40.
P1 · A stream that stays open. Fine for now: the app closes it. A phone that the coach removed keeps receiving the session's sets until then, and only its own app makes it stop. Question 41.
Item 5 · No ETag, no running yet. Both fine. running is asked for
with item 2 below.
Questions.
- How many server processes answer requests on production, and on backdev?
platformsis kept in memory, and running sets will be. With more than one process: does the devices read on another process see what a heartbeat brought to the first, and does "the same body publishes nothing" compare against the right state? - What would it cost to end an open stream on the set topic when its device is unlinked?
Part 2 · Two points your answers opened, and one we missed
S4 · A set names only people of the session's owner
From your answer 13: "A set POST does not check its userID against the
owner today."
What is asked. POST /workout_live_session_sets and
PUT …/tracked/:id refuse a set whose userID the session's owner has no
access to, by the rule you built for platforms: the owner and the users the
owner has access to. Nothing is stored and nothing is published.
Why. A device that is linked to a session can put any user of the system on that session's board by naming their id: the set comes back with that person's name and photo. Until S2 it came back with their e-mail address, date of birth and body weight as well. The id of a user is not easy to guess, and it is not a secret either: it stands in addresses of the portal.
An app from today. It sends the athletes of the training on that device. On a coach's tablet those are the coach's athletes. On an athlete's own phone it is the athlete, who is under the session's owner, or the phone could not have joined. So no build of ours sends a set this rule refuses. If a set is refused all the same, the app's queue drops an operation that is answered with a 4xx other than 401, 408 and 429, and does not send it again.
Checked on backdev by.
| Request | Expected |
|---|---|
A set POST from a linked tablet with the userID of an athlete of another organisation | 403 or 404. reduced does not have the set, no frame went out |
| The same with an athlete of the session's owner | 201, as today |
| A set POST from an athlete's own phone, with the athlete's own id | 201, as today |
Questions.
- 403 or 404? And does any client you know of send sets for users outside the owner's list, for example the native iOS app?
S3, one thing more · Body weight in the state event
Observed on backdev, 2026-10-07: POST /competition_dashboard/join needs
no credentials. The state event its token receives names the athlete with
id, name and bodyweight. S2 took a person's body weight out of every
live set; this is the one place left where it reaches a reader who is not
signed in.
What is asked. No bodyweight in a state event for a token that join
handed out without credentials. If the standalone app shows it, say so, and
it becomes part of decision D9.
Questions.
- Does anything read
bodyweightfrom thestateevent?
R3 · The values a repair needs leave with a deleted session
From your answer 4: ending a session keeps its live rows; deleting a session removes the rows that no saved workout has claimed. So every session that a coach deletes takes away what a repair of older workouts would be made from.
What is asked, now and without a decision: one read on the production database. How many of the sets that lost values (at least 17 without a set value and 176 without rep values, by your snapshot to 2026-09-03) belong to a live session that still exists and still holds the values? With that number the product owner decides whether a repair is worth a job, and how soon.
Questions.
- What is that number today, and can the list of the workouts be kept, so that a later job does not have to find them again?
Part 3 · The wall: the next steps
The shapes are the ones of the first document. This part only says what is settled since, and what changes.
Item 2 · The set while it is lifted
Everything you asked is answered, by you: kept in memory apart from the
measurement tables (15), currentPersistentSetID as its key (16), one
request per repetition (17). Please build it as the first document describes
it, with one addition that waited for it:
GET /workout_live_session_sets/board/:id?running=truereturns the running sets underrunning. Without the flag the key is absent, as today.
After a restart of the server a running set is gone until its next repetition. That is fine: the app sends the whole set so far with every repetition, so the next one brings it back.
Checked on backdev by the table of the first document, item 2.
Item 8 · The result for those who took part
Your answer 33: "has a set in it" can be checked without storing anything new; "linked at the end" cannot. We take the first.
What is asked, restated. For 24 hours after endedAt,
GET /workout_live_session_sets/reduced/:id and board also answer a device
that sent a set into that session. The event
device_session.live_session_changed that tells a device its session is
over carries endedWorkoutLiveSessionID once.
A tablet that was in the session and sent nothing gets no result. That is acceptable: it has nobody on the board.
Checked on backdev by.
| Request | Expected |
|---|---|
End a session in which a tablet sent a set; the tablet reads reduced | 200, the sets of the session |
| The same from a tablet that was linked and sent nothing | 404 |
| The same 25 hours after the end | 404 |
| The event on the tablet's user stream at the end | No session in it, as today, and endedWorkoutLiveSessionID |
Questions.
- Is "sent a set" known per device, or per user? If per user: a coach's tablets all carry the coach's login, so every one of them would read the result, which is fine too. Say which it is, so that the app asks from the right device.
Item 7 · Ruled-out reps, and the ruling
7a is agreed as two lists (answer 28). You call it dearer than it looks, because it touches the rep matching of L0. It can come after 7b: a competition needs the ruling first.
7b · Our answer to "a field or the tag" (question 30): a field. A ruling has three states: good lift, no lift, and not ruled yet. The display shows all three, and "not ruled yet" is what an attempt is between the lift and the judge's tap. One tag can say "no lift" and cannot tell a good lift from one nobody has ruled. So:
rulingon the set:good,noLift, or absent. One column.- While the standalone board lives, keep its no-lift tag in step: a set
whose
rulingisnoLiftcarries the tag, any other does not. Then both boards say the same about a lift (your question 32), and the tag can go with the standalone app. - No new route for the owner.
PUT …/external/:idreaches every set of the owner's session (your answer 31). It takesrulingin its body: a value sets it, an explicit null takes it back, an absent field leaves it. The routePUT …/:id/rulingof the first document is dropped. - The device sends
rulingwith its create and withPUT …/tracked/:id, as proposed.
This step waits for the product owner's yes to the ruling as such; see the table below.
Questions.
- Is "an explicit null takes it back" possible on
PUT …/external/:id, where a null means "unchanged" elsewhere in this API? If not: a third value,notRuled.
Item 4 · Best before today
Agreed as a read (answer 27). It waits for your look at personal_bests on
production (questions 25 and 26 of the first document): whether the table is
filled, and whether a record is on the scale of the live measurement with the
same metric. One thing to add from our side: the portal is being changed to
keep an athlete's bests in a session's stored result when the session ends
(work block W14, in work on 2026-10-07), so that a result does not change
when the athlete sets a new record. best_before is still what a display
without a login needs, and what makes a result right for a session that
ended with nobody watching.
Item 6 and S3 · The paired display, and the open way into a board
Both wait: item 6 for decisions D3 and D9, S3 for an answer to question 10, which neither of us can give from the code. See the table below.
S1 · Step 2
After a week of step 1 on production: the count of requests whose header and token named different devices, by app version. If it is zero for the builds that matter, the set routes take the device from the token.
Questions.
- Can the log of step 1 be read by app version without a deploy, so that the week's count is one query?
Part 4 · The release
Agreed: one release of L0, L2, 83a811f8 and 13b7a267; no switch; the way
back is the previous build, because the migrations only add (answers 1, 2).
What stands before it:
| What | State |
|---|---|
| R2 · Sessions past their end date that are still used. The count has to come from the production database (answer 3) | Open. Without it the deploy may end sessions that coaches use every week |
| S2 with L2. | Solved by 13b7a267 being part of the release |
| backdev runs the release as a whole, and live tests 15, 16, 25, 40, 80 and 82 pass on it | Open. 80 is the tracking app of release tag v3.0.7 against the server of today; it passed on 2026-10-07 without 13b7a267 |
After the deploy we check with a real account: the set topic answers 200, and
an idle stream stays open for ten minutes. Both are described in
docs/live-hub-release.md.
Questions.
- Who runs the count for R2 on production, and when?
- Is there anything in
13b7a267that an app build from today could notice, other than the shorteruserof S2? Live test 80 will be run against it; say what else it should send.
Waiting for the product owner
| Open point | Who can answer | What waits for it |
|---|---|---|
Does the standalone dashboard app write state with its display token? (question 10 of the first document) | Whoever builds or knows that app. Neither the backend nor the clients can tell from their code | S3: closing the write for a token from the open join |
| D3 · The paired display. D9 · Fold the standalone app in, or keep both | Decided on 2026-10-07: yes to the paired display, and the standalone app is folded into it. See "What is left" | Nothing any more |
| D5 · The ruling belongs to the attempt: one question to the judge on the tablet, the coach may rule from the live view | The product owner. Proposed on 2026-10-07, not confirmed | Item 7b |
| What the wall may show about a person: who is about to lift with which load, the reps of a set in progress, a best from before the session | The product owner (decision D17 of the concept) | Showing items 1 and 2 in a gym. They can be built and seen in the demo before |
| R2 · What happens to sessions past their end date. Proposed: clear the date where a set arrived after it in the last 30 days | The product owner, after the count | The release |
| R3 · Whether workouts that lost values are repaired | The product owner, after question 44 | A job |
| The native iOS app's device id: does it send the id it signed in with? (question 7) | Whoever builds that app | S1, step 2 |
When backdev is rebuilt with 13b7a267 | Whoever rebuilds backdev | Our six checks, and work block X1 against a real server |
All questions, by number
Questions 1 to 39 are in the first document, with their answers.
| No. | Where | Question in short |
|---|---|---|
| 40 | Part 1 | More than one server process, and state that is kept in memory |
| 41 | Part 1 | Ending an open stream when its device is unlinked |
| 42 | S4 | 403 or 404; clients that send sets for users outside the owner's list |
| 43 | S3 | Who reads bodyweight from the state event |
| 44 | R3 | How many lost values can still be repaired, and the list |
| 45 | Item 8 | "Sent a set": per device or per user |
| 46 | Item 7b | Taking a ruling back on PUT …/external/:id |
| 47 | S1 | Reading the log of step 1 by app version |
| 48 | Release | Who counts for R2, and when |
| 49 | Release | What else an app from today could notice in 13b7a267 |
Acceptance, for every step
| Scenario | Expected |
|---|---|
| An app build from today, unchanged (live test 80) | Every request answers as before |
The contract tests of L0, L2 and the result (15, 16, 25, 40) | Pass unchanged |
The checks of 13b7a267 (82-live-open-contract), once backdev has it | Pass |
| Finish a training whose sets were live, with running sets and ruled-out reps among them | The saved workout has all its measurements |
Agreed with the backend
From the backend's response of 2026-10-07 ("Backend response 2"). Where a point here and the body disagree, this section holds.
One correction to the first answer
Answer 33 of the first document was too short. A live set stores who
lifted (userID), not the device that sent it and not who was signed in.
So "has a set in it" is known per user who lifted, not per device. Item 8 is
built on that.
Built
Four points, committed on bugfix/loading_factor_api_technique as af446f6e,
on top of 13b7a267. No database change. 503 backend tests green.
| Point | What is built | Differs from what was asked |
|---|---|---|
| S4 | POST /workout_live_session_sets and PUT …/tracked/:id answer 403 for a userID that is neither the session's owner nor a user the owner has access to. Nothing is stored or published. PUT …/running/:id follows the same rule | 403, not 404 |
| Item 2 | PUT …/running/:currentPersistentSetID: 200 when taken, 204 for a device in no running session, dataPackages ignored. Event live_session_set.running with the payload of the first document; a measurement carries metricID, value and the metric's key. The same set again publishes nothing. DELETE …/running/:id answers 204 and publishes live_session_set.running_ended. GET …/board/:id?running=true returns the running sets under running, newest first; without the flag the key is absent. In no other read or frame, not on /competition_dashboard, nothing in the database. A running set may be sent in the lobby | A device ends only a running set it sent itself; for another device's set the delete answers 204 and nothing happens. After ten minutes without a request the end is noticed by the check that runs once a minute, so running_ended comes ten to eleven minutes after the last request |
| Item 8 | For 24 hours after the end, a user who lifted in the session reads its sets from any device they are signed in on: reduced, board, complete and the single set. device_session.live_session_changed carries endedWorkoutLiveSessionID when the device was released because its session is over (ended, expired, idled out) | By the user who lifted, not by the device. Not when the device left or was removed, and not when the session was deleted |
| S3, body weight | The state event of /competition_dashboard carries no bodyweight | For every reader: the hub sends one payload per topic |
What "per user" means for item 8:
| Device | Reads the result |
|---|---|
| An athlete's own phone, the athlete lifted | Yes |
| A coach's tablet, signed in as the owner or someone with access to the owner | Yes, as before, without a time limit |
| An athlete's own phone, linked, the athlete did not lift | No (404) |
| A tablet signed in as an athlete, on which only other athletes lifted | No (404) |
For contract tests: a disposable athlete has to be a child of the session's owner. An athlete account with no relation to the owner had its sets stored until now and gets 403 from this build on.
The answers, by question number
| No. | Answer |
|---|---|
| 40 | One process answers requests on production; a second one runs jobs and answers none (from a note on the deployment of 2026-09-23, not checked again). backdev is one process. With one process the devices read and "the same body publishes nothing" see the right state. With more than one they would not: events would still reach every viewer, but the devices read and board?running=true would answer from the process that got the request |
| 41 | Not estimated yet; it needs a look at how the hub holds its streams |
| 42 | 403. No client known to the backend sends sets for users outside the owner's list. For the native iOS app that is not known |
| 43 | Nothing on the server reads bodyweight. For the standalone app: not known |
| 44 | Not counted. It needs the production database. The list can be written to a file by the same read |
| 45 | Per user who lifted. The app asks with its own login, from any device |
| 46 | A third value, notRuled. An explicit null and an absent field are not told apart anywhere else in this API |
| 47 | The log line carries the app version. Whether a week's count is one query depends on where production keeps its logs; not looked at |
| 48 | With the product owner |
| 49 | In 13b7a267: the state event of /competition_dashboard arrives a moment after the answer of a set request instead of before it, and a device that sends heartbeats causes more live_session.device events. In af446f6e: the 403 of S4 and the missing bodyweight. No other answer to a request of today changes |
What the answer changes on our side
- Item 7b: the ruling has three request values:
good,noLiftandnotRuled, which takes a ruling back. A set that was never ruled, or whose ruling was taken back, has norulingin an answer. - Item 8: the result on a device is asked for with the login of whoever is signed in. A coach's tablet reads it as it reads any of the coach's sessions. An athlete's own phone reads it when the athlete lifted.
- Item 2: when another athlete is selected on a station, the tablet ends the running set itself; no other device can.
- Answer 40:
platformsand running sets are right as long as one process answers requests. A second process would need them in a shared place first. Worth a line wherever the deployment is written down.
Response 4: item 7 is built, and two decisions
From the backend's response 4 of 2026-10-07. It names a response 3 that is not recorded in this document.
Two decisions of the product owner, as the backend reports them:
- D5: yes to the ruling as such.
- R3: no repair. The workouts that lost values before the L0 fix stay as they are. No job, no list. Question 44 is closed with it.
Built: items 7a and 7b, committed as 5cae8750 on top of af446f6e. One
migration: a column ruling on the live sets and a column valid on the
live reps. 505 backend tests green.
| Point | What is built | Differs from this document |
|---|---|---|
| 7b, in answers | ruling on the set is good or noLift, and absent when the set is not ruled: on complete, reduced, the single set, board and in every frame | No |
| 7b, in requests | good, noLift or notRuled, which takes a ruling back. A body without the field leaves the ruling as it is. The device sends it on POST and on PUT …/tracked/:id | No |
| 7b, whoever runs the session | PUT /workout_live_session_sets/:id/ruling with the ruling in its body, where :id is the live set's id. For the owner and users with access to the owner: 200 and the set. For anyone else 404. It changes nothing else of the set | The route of its own is back. PUT …/external/:id does not take ruling: that route sets the set's user to whoever sends the request and replaces the set's tags, so a coach who ruled through it would become the lifter. And 404, not 403, for someone without access |
| 7b, the event | Either change goes out as live_session_set.updated, or .lobby_updated for a lobby set, also when the ruling is the one the set had | No |
| 7b, the standalone board | The no-lift tag is kept in step: a set ruled noLift carries it, any other does not, and a device update that sends other tags does not drop it | No. A client cannot see it: no set read returns a set's tags |
| 7a, in requests | POST, PUT …/tracked/:id and PUT …/running/:id take workoutLiveSessionRuledOutReps, with reps of the same shape | No |
| 7a, in answers | workoutLiveSessionReps holds the counted reps, workoutLiveSessionRuledOutReps the others, absent when there are none: on complete, the single set, board, the frames and a running set. Every rep says valid, in both lists | No |
| 7a, one row per rep | The two lists are matched as one by currentPersistentRepID: a rep that is ruled out or in again stays the same rep with the same id and keeps its package, also when the body sends none | No |
| 7a, the body is the whole set | A rep in neither list is gone. A body with neither list leaves the reps. A body with the counted reps and without the second list has no ruled-out reps: that is what an app from today sends, and reps ruled out earlier are then removed | Said in so many words for the first time |
An app from today sends neither. Its sets have no ruling and no second
list. New in every answer for it: valid: true on each rep.
What it changes on our side: the coach's view rules through
PUT …/:id/ruling with the live set's id, not through
PUT …/external/:id. A refusal there is a 404.
Observed on backdev after delivery
2026-10-07, 13:45 to 13:55: backdev runs 13b7a267 and af446f6e, and
every point a client can see holds. Through raw requests with disposable
accounts: an owner with a managed athlete, the owner signed in a second time
as a tablet, an athlete with a login of their own on a phone, and a second
owner who is a stranger to the first.
npx vitest run --config tests/live/vitest.config.ts \
tests/live/offline/82-live-open-contract tests/live/offline/83-live-open-2-contract
| Point | Observed |
|---|---|
| S2 | user has exactly hasImage, id and name on reduced, complete, the single set and in a created frame |
| Item 3 | A set sent with completedAt 90 s before sentAt has a completedAt 90 001.7 ms before its createdAt. The same with both an hour ahead. Without the two fields the set has none. A later PUT with another value does not move it |
| Item 5 | board answers 200 with now and sets. A rep has no dataPackages and says hasDataPackages: true; a measurement's metric holds key only; no running key without the flag. The single set still returns the package |
| Item 1 | A heartbeat with one row in platforms comes back on the device row with station, user (slim), load, plannedReps and next.user; a live_session.device frame carries it; an empty list is stored as an empty list |
| P4 | The same heartbeat four times, 20 s apart: live_session.device at seconds 20, 41 and 81. So 20 to 40 s between two signs of life |
| P1 | An athlete's phone: 403 on the set topic before it joined, 200 after. A set sent then reaches it, with the slim user |
| S4 | A set for a user of another organisation: 403 on POST, on PUT …/tracked and on PUT …/running, and it is not in reduced. The owner's athlete and the owner: 201 |
| Item 2 | 200 with no reps, then with one; a running frame each, with the payload as written (slim user, the metric's key, deviceSessionID, running: true). The same body again: no frame. Not in reduced, not in complete, no running key on a plain board; under running with the flag. The finished set under the same id: 201, a created frame, and the running one is gone. A delete: 204 and running_ended. From a device in no session: 204 |
| Item 8 | The athlete's phone gets device_session.live_session_changed with endedWorkoutLiveSessionID. After the end it reads reduced and board of the session it lifted in (200), and gets 404 for a session it was only linked to |
| S3 | A display that joined /competition_dashboard without credentials gets a state event that names the selected athlete and holds no bodyweight |
Nothing older broke. Against the same build, at 13:51: the contract tests
of L0, L2 and the result (15, 16, 25, 40) and the requests of the
tracking app of release tag v3.0.7 (80) pass unchanged, eleven tests in
all. So the 403 of S4 and the shorter user of S2 refuse or change nothing
those clients send or read.
Not tried: the end of a running set after ten minutes, a delete of another device's running set, the 24-hour limit of item 8, a tablet that is signed in as an athlete, and anything with a real device.
2026-10-07, 14:25: backdev runs 5cae8750 as well, and item 7 holds.
npx vitest run --config tests/live/vitest.config.ts tests/live/offline/84-live-ruling-contract
| Point | Observed |
|---|---|
| 7b | A set sent with ruling: "noLift" says so on reduced, complete, board and in its created frame. A device update without the field leaves it; one with good changes it. PUT …/:id/ruling by the owner: 200 with noLift, 200 with notRuled, after which the set has no ruling; at least one updated frame each; the set's reps and its load are untouched. The same request from an owner of another organisation: 404, and the set is unchanged. A set that was never ruled has no ruling key |
| 7a | Two counted reps and one ruled out, each with a package: complete has two in the first list with valid: true and one in the second with valid: false, each with its own package. board has both lists, without packages. An edit that sends no packages and moves a rep into the second list: the same rep id, valid: false, its package kept. Moved back: the same id, valid: true, its package kept. A body with the counted reps and no second list: the second list is gone from the answer |
Not seen, because no read returns a set's tags: that the no-lift tag follows the ruling. Not tried: a lobby set, a running set with ruled-out reps, and the standalone board.
Nothing older broke with 5cae8750 either. Against the same build, at
14:35: the contract tests of L0, L2 and the result (15, 16, 25, 40),
the requests of the tracking app of release tag v3.0.7 (80), and the
checks of the two earlier commits (82, 83) pass: 21 tests in seven files.
So valid: true on each rep is all an app from today sees of it.
Observed on backdev, 2026-10-08: item 6, item 4 and the write of S3 answer
No response of the backend about these points has reached us; this is what
backdev answered to raw requests on 2026-10-08 at about 10:25, with disposable
accounts (an owner with a managed athlete, the owner as a tablet, an athlete on
a phone, a stranger). Since 11:40 of that day the rows of item 6, item 4 and S3
are held by a contract test, tests/live/offline/100-live-display-contract
(eleven checks, green twice in a row). What it found beyond the table is in
"What a display cannot read yet" below.
| Point | Observed |
|---|---|
| Item 6, pairing | POST /live_displays/pairing without credentials: 200 with pairingCode, pairingSecret, expiresAt, interval. GET /live_displays/pairing/:secret: { "state": "waiting" }; after the owner confirmed, once paired with displayToken and liveSessionID, then 404 |
| Item 6, the owner | POST /workout_live_sessions/:id/displays with the code and a label: 200 with displayID, label, pairedAt, lastSeenAt. A wrong code: 404. After ten wrong codes the next request is 429, and so is the right code; a code confirmed in between does not start the count again. A session takes several displays; a session that has ended takes none (404). The owner's devices read has a list displays; there is no GET …/displays. DELETE …/displays/:displayID: 204 |
| Item 6, what the token reads | Not the owner's routes: with the token, the session, a board, a set POST, /history/v2/records, a user read and the realtime topic answer 401. The display has routes of its own, with the header display-token: GET /live_displays/session (id, name, state, config, joinCode, hasLogo, logoMimeType, createdAt, startedAt, the owner as slim user), …/session/logo (id, logoData, logoMimeType), …/devices (devices, displays, now), …/board (also ?running=true), …/sets/:id, …/best_before, …/metrics, …/text_contents, …/data_package_types, …/exercise_definitions/:id, and the stream GET /live_displays/events. PUT /live_displays/presence: 200, and lastSeenAt of the display moves |
| Item 6, a set | A set on …/board names its user by id, name, hasImage and carries exercise with a name. A set posted afterwards arrives on …/events as live_session_set.created |
| Item 6, the end | After the owner ended the session the token still reads session (state: "ended", endedAt) and board. After the owner removed the display its stream ended and session and presence answer 401. A wrong token: 401 |
| Item 6, the old board | POST /competition_dashboard/join without credentials: 200, as before |
| Item 4 | GET /workout_live_sessions/:id/best_before as the owner: 200 with now and pairs; the athlete with a set has records: []. With ?userID=…&exerciseDefinitionID=…: that pair. A stranger: 404 |
| S3, the write | PUT /competition_dashboard/:id/state without credentials: 403. With a display token of the open join: 403. With the owner's JWT: 200 |
Not there on that day:
| Point | Observed |
|---|---|
| Ending an open stream on unlink (question 41) | A phone joined, opened the set topic (200) and left (204). Its open stream was not ended and a set posted afterwards still arrived on it. A new stream of that phone: 403 |
| S1, step 2 | A set POST with the token of a linked tablet and the device-id of a device in no session: 404, and the set is not stored. So the set routes do not go by the token's device |
Not tried or not seen: a best_before for an athlete with a history or one
who only stands in platforms, a pairing that expires, the token a day after
the end.
Seen since, by the contract test: the logo is at /live_displays/session/logo
(/live_displays/logo answers 404). In the display's devices a row carries
sensorConnected, platforms (each with its user as a person), athletes
(people, where the owner's read has athleteIDs) and lobbySetAt after a
heartbeat that sent them; it never carries joinedAt, joinedBy,
appVersion or sharingAcknowledgedAt. So the "Before" scene can be built
on it. The stream …/events carries live_session.updated,
live_session.device, live_session_set.created, .updated, .deleted,
.running, .lobby_created and a heartbeat, under the names of the
realtime topic. A created frame carries the reps' sensor packages. The
pairing routes and the display's routes answer without the app's api-key,
and a browser may send display-token from another origin.
What a display cannot read yet
Found on 2026-10-08 while the display's reader was built
(packages/core/src/live-session/display-feed.ts). In the order of what it
costs a gym display. None of it has been sent to the backend yet.
| Point | Observed | What it costs | Asked for |
|---|---|---|---|
| The session frame on the display's stream tells too much | live_session.updated on /live_displays/events is the owner's frame: user with email, role, tags, assignedTags, availability, gender, and participatingDevices with every device's deviceId, userID and device session id. The display's own read of the session names the owner by id, name and hasImage | A television without a login is sent the owner's email and the device ids of the gym's tablets with every change of the session. The client drops them before any code sees them, but they are on the wire. The same kind of point as S2 | The frame in the shape of GET /live_displays/session |
| An athlete's photo | No route. Tried /live_displays/users/:id/image, …/users/profileImage/:id, …/profile_images/:id, …/user_images/:id, …/images/:id: 404 each. /users/profileImage/:id with the token: 401. A set says hasImage | A paired display shows initials where a display under a coach's login shows photos. The product owner asked for the photos back on 2026-10-08 (work block F3) | GET /live_displays/users/:id/image for a person who has a set in the session or stands in a platforms row, in the shape of /users/profileImage/:id |
| A session that has ended takes no display | POST …/displays on an ended session: 404, as for a wrong code. A display that was paired before the end keeps reading session and board | A result cannot be put on a second screen afterwards, and a television that lost its token after the end cannot be paired again | Pairing for an ended session, for as long as the owner can read it |
| The display is among the session's devices for the owner | After pairing, participatingDevices of the owner's session carries the display as a device session of type api whose deviceName is the label and whose deviceId starts with display:. The lobby's devices read does not list it | Every count of a session's devices in the portal that goes by participatingDevices counts a television as a training device | Leave displays out of participatingDevices; they have displays |
| Catalogues a display has no route for | There is metrics, text_contents, data_package_types and exercise_definitions/:id. There is no read of the metric categories, the normative base categories, the exercise bases or their display groups. exercise_definitions/:id names its base by id only | The client's metric store counts as loaded only with the categories. Whether an exercise is a weightlifting lift (the bar path layout) goes by the exercise base's display group | metric_categories beside metrics, and on exercise_definitions/:id the base's display group; or say which existing read a display should use |
| The same row in two shapes | GET /live_displays/devices says athletes (people); the live_session.device frame for the same row says athleteIDs and no athletes, and carries joinedAt, joinedBy and sharingAcknowledgedAt, which the read leaves out | A display that learns of a device from the frame has ids it cannot turn into names | The frame in the shape of the read |
| Where a display keeps how it is set | PUT /live_displays/presence takes a body and answers 200; a display's row has displayID, label, pairedAt and lastSeenAt only | The profile of a screen (desk, television, projector) can be kept in the television's browser only. The owner cannot see or set it from the portal | A small free field on the display that presence writes and the owner's displays list returns. Not urgent |
Not a gap, to know: a display has no read of all sets with their sensor packages. It reads the board and, for a bar path, one set at a time; the reader does that for the three newest sets and gets the packages of every later set from its frame.
A set does not say which device sent it
Found on 2026-10-08, for the Stream's filter by device. Not sent yet.
A stored live set carries userID and no device (the correction to answer
33 above). A running set does carry deviceSessionID. The portal's Stream
filters by device all the same, by the athletes a device names in its
heartbeat, which is wrong for an athlete who changed tablets and empty for a
device without a heartbeat.
Asked for: deviceSessionID on a stored set, taken from the device session
of the request that created it, and returned on reduced, board,
complete, the single-set read and the created / updated frames. Absent
for a set stored before the field. An update by another device or by the
owner does not change it.
Answered on our side afterwards: the standalone app (questions 10 and 43)
Looked up on 2026-10-07 in the app's own repository
(vmaxpro-server/webdev/enode-competition-dashboard, a Next.js app; its last
commit is of 2026-05-13).
- Question 10: no, it never writes
state. The app makes four requests and no other:POST …/join,GET …/:displayToken/state,GET …/:displayToken/eventsandPOST …/:displayToken/leave(lib/dashboardApi.ts). The portal does not callPUT …/stateeither (reply 1, question 1), and the native iOS app only callsswitch_athlete. So no client we know of writesstate, and S3 can be closed as the first document asks:PUT /competition_dashboard/:id/stateneeds the owner's JWT, or goes. - Question 43: it does not show body weight. Its type knows an optional
bodyweightand its demo data has one; no component reads it. It shows the athlete's name, club, weight class and group. Leavingbodyweightout breaks nothing there. - The repository holds no deployment set-up, and its local configuration
points at backdev. A copy runs against production all the same:
https://comp.enode.ai, hosted on Vercel; the bundle it serves nameshttps://api.enode.ai(looked at on 2026-10-07). The product owner does not think anyone still uses it; that is not confirmed. Question 23 of the first document (how oftenjoinwas called on production, and when last) would confirm it. Until then the openjoinand the reads ofstateandeventsstay on production as they are. The write of S3 can be closed regardless.
What is left on the server side, and what each point waits for
State after response 4 and the decisions of 2026-10-07. Two points can be started now: the write of S3 and item 6.
| Point | Waits for |
|---|---|
S3, the write: PUT …/state only with the owner's JWT | Nothing. No client writes state; see "Answered on our side afterwards" |
The release of L0, L2, 83a811f8, 13b7a267, af446f6e and 5cae8750 | backdev runs it as a whole, which it does since 14:25, and our tests pass on it; see above. R2, the sessions past their end date, is not named as open in response 4 any more; what settled it is not recorded here |
| Item 4, best before | A look at personal_bests on production |
| Item 6, the paired display | Nothing. D3 and D9 are decided (2026-10-07): yes to the paired display, as a page of the portal, in the same release as the rest; the standalone app is folded in and not updated. Questions 20 to 22 and 24 of the first document are open with it |
The open join, and state, events and leave of /competition_dashboard | The portal's display page reading with the pairing token on production, the portal's fallback gone, and comp.enode.ai pointed at that page (the address stays, the app of May behind it goes). Then they can go. Not before |
| The server's own bar path and peaks for the old board | A word of the product owner: retired with the old board, or still served. Only the old board reads them |
| S1, step 2 | A week of step 1 on production |
| Ending an open stream when its device is unlinked (question 41) | Our word, which is: yes, when it is cheap, and not before the release. A phone that was removed from a session should stop receiving its sets without having to cooperate. Proposed, not confirmed by the product owner |
| R3, the repair | Closed: decided against on 2026-10-07 |