Sharing
Coaches can publish two things to other people: workout templates and exercise descriptions (their naming of a movement). Both use the same ownership model, the same three access levels, and the same icon vocabulary, in both apps.
This page is the mental model. It exists mainly to record the traps — the
things that look like a one-line change and are not. The predicates themselves
live in @enode/core/sharing/ and are unit-tested there; don't re-derive them
at a call site.
The one rule: ownership decides, visibility does not
There are two independent dimensions, and conflating them is the mistake this feature is designed to prevent:
| Dimension | Field | Answers |
|---|---|---|
| Ownership | sharingUserID vs. the viewer | May I change this? |
| Reach | sharingVisibility | Who else can see it? |
Read-only comes from ownership only. A public template of your own is fully
editable; an internal template of a colleague's is read-only even if the backend
grants you write. Everything routes through one function:
sharedEntityScope(entity, myUserId) // → "mine" | "internal" | "public"
with the named wrappers templateScope / descriptionScope, and the derived
isForeignTemplate / canEditTemplate. Never decide ownership from which
list segment a row was rendered in, and never compare sharingVisibility at a
call site to work out whether something is editable — a mis-scoped row would
then hand a foreign template to the editor.
Three degradation rules are baked into that function, and each is there because of a specific failure:
- No owner ⇒
mine. Legacy definitions written beforesharingUserIDexisted, and the window before the viewer's id resolves, stay editable. Guessing "foreign" would silently lock a coach out of their own templates. - Foreign and not literally
sharedPublic⇒internal. The conservative reading. A value from a newer backend must under-promise its reach, never over-state it. - An unrecognised visibility is not discoverable at all. It appears in no browse segment rather than being folded into "internal". Under-showing a row is recoverable; over-sharing one is not.
The scope predicates take structural string | null, not the
SharingVisibility union, precisely so an unknown wire value is representable
and therefore degradable. Widening it is deliberate — don't narrow it back.
One transition gets its own predicate: revokesSharing(from, to) is true only
when a level others could reach falls back to sharedPrivate. Un-sharing takes
every appointment planned from the template with it, so that is the one
visibility change that destroys other people's data — the template drawer
states the cost in the level's hint and confirms it before saving. A level this
build does not recognise never raises the warning: the sentence states a
certainty, so it must not be raised on a guess.
The icon vocabulary
One symbol and one word per access level, in both apps, forever. Established by
the portal's tag drawer, now shared in @enode/ui/sharing/visibility:
| Level | Glyph | Word |
|---|---|---|
sharedPrivate | LockFillIcon (lock.fill) | Private |
sharedInternal | Building2FillIcon (building.2.fill) | Organisation |
sharedPublic | GlobeIcon (globe) | Public |
The words are display only. The wire values in the left column are unchanged
and unchanging — every DTO, store, filter and test still says sharedInternal.
Only the noun the reader sees moved from "Internal", which is jargon nobody
outside the codebase uses, to "Organisation", the word the product already
speaks at registration (auth/org-name-step.tsx collects an organisation name).
So useVisibilityLabel() is the only place that translates one to the other;
never rename a scope key, a segment id or a DTO field to match a label.
Use visibilityIcon(v) and useVisibilityLabel() — including for picker option
labels. An unknown level yields no glyph and the raw string, never a
best-guess icon. Person3FillIcon already means athletes/participants; it is
not an "internal" symbol.
Three limits on that vocabulary:
- The label names a BUCKET; it does not promise an audience. The backend
resolves
sharedInternalagainst the owner's accepted relations — one hop, so two athletes of the same coach do not reach each other (server handoff §2.5). For the dominant case, a coach publishing to their own athletes, "Organisation" is exactly right; an athlete publishing is where the bucket name is wider than the real audience. That is why every sentence that states reach —useVisibilityHint(), the catalogue's "People you work with" heading — says who actually reads it instead of repeating the label. Keep it that way: a label may be a friendly approximation, a promise may not. - It names an access LEVEL, not a content source. The browse segments on the
portal Workouts page and in the tracking Library mean "templates shared with
me", which is a different grammatical role from "Visibility: Organisation".
They use context-split sources —
t({ text: "Organisation", context: "Template source segment: …" })— so a translator sees the role and the two never collapse to one key. - A lone glyph must name itself.
useVisibilityLabel()is the right accessible name only where a column header or a labelled row already supplies the context (the Tags table's "Access" column, the drawer's "Visibility" row). A bare glyph in a name cell uses theProvenanceBadgesentences instead ("Shared publicly" / "Shared in your organisation"), so a screen reader never hears a context-free adjective.
Every "Access" column is the same cell. AccessCell
(@enode/ui/sharing/sharing-line) is what the Tags table, the Workouts table
and the catalogue drawer's own-descriptions table all render — one word
("Access"), one sentence per row, so a table can no longer spell out "yours,
shared" differently from its neighbour. It shares useAccessSentence with
SharingLine, the byline the same file exports for rows that are allowed to
stay quiet; the column can't, so it falls back to the bare level (AccessCell)
where the byline would render nothing (SharingLine). Own-and-shared reads
"Shared by you" everywhere, not the bare level "Organisation" — a bare level
means opposite things depending on which side of it you are on (see above), and
a table with a foreign row reaching it (the Tags table groups by owner, same as
Workouts) must not say "Organisation" on a row that was in fact given TO the
viewer.
For attribution of content the viewer does not own, use ProvenanceBadge
(@enode/ui/sharing/provenance-badge) rather than a visibility icon. Reach and
provenance are different statements: "this is public" ≠ "this came from Jonas".
Author names: one accessor, and it may be absent
The backend field carrying the author's display name is not confirmed. Every
read goes through templateAuthorName() / descriptionAuthorName() (both thin
wrappers over sharedAuthorName), so a rename is one line in
packages/core/src/sharing/scope.ts.
It returns null for absent or blank. Every surface must degrade to
icon-only attribution — never an empty byline, never "Shared by ". Today the
list subtitles show no author, badges are glyph-only, and the read-only drawer's
"Source" row falls back to the whole sentence ("Shared publicly") instead of a
lone glyph.
The naming chain, and why stage 3 exists
A shared template references exercise definitions. The viewer owns no
Exercise row for a movement they have never trained, so the ordinary
exerciseDisplayName chain resolves to nothing and every line of a colleague's
template reads "Exercise". workoutItemDisplayName adds two stages around it:
item.exerciseName— the naming in force for the reader, resolved by the server (see the measurement below).nullwhenever the reader has no naming of their own, so it falls through rather than blanking the row.exerciseDisplayName(exercise)— the viewer's own rename → their catalogue entry → the localized stock text content.- the published exercise description for
exerciseDefinitionID— someone's shared naming, from the enrichment cache. - the caller's translated fallback.
Stage 1 resolves PER READER — measured, not assumed
tests/live/sharing/30-naming-inheritance.live.test.ts settles the question the
chain could only guess at: a colleague shares a workout containing a movement I
have my own name for — do I read their word or mine?
Author and reader published different namings for the same definition and both read the same template item:
| Reader's situation | exerciseName delivered | exerciseDescriptionID |
|---|---|---|
| author, on their own template | ZZ-Author-Naming | the author's row |
| reader with their own naming | ZZ-Reader-Naming | the reader's row |
| reader without one | null | absent |
Two conclusions, and they are the load-bearing ones:
- You are never given someone else's word. The server resolves the field against the reader, so the same item reads differently for two people. A shared workout arrives in your vocabulary, not the author's.
- The author's naming does not travel over this field at all. When the reader has nothing of their own the item comes back naked, and the client decides — stage 2 (their own exercise, then the stock catalogue text), and only then stage 3.
So the precedence a reader actually experiences is:
- my own naming — server, stage 1
- (my organisation's naming — does not exist; see the catalogue handoff)
- (a catalogue I follow — does not exist; same)
- my own exercise / the stock catalogue name — client, stage 2
- whatever anyone published for this definition — client, stage 3, and the only rung on which a foreign word is ever shown
- the caller's fallback
Rungs 2 and 3 are the whole of the missing work. Everything else is built.
The author's coaching cue — stage 0 of the DETAIL chain
The rule above is about the name. The detail goes the other way, and the distinction is the whole design:
The name is identity — the reader's wins, silently. The detail is instruction — the author's travels, visibly.
"3s pause at the chest, our standard" is a prescription attached to that workout. A reader recognises the movement by their own word and still needs what the coach asked for.
The author's word leads, and yours is stated under it. The backend resolves
a shared item's exerciseName against the description the item points at — the
AUTHOR's — because a template is the author's programming. The product decision
that follows: the row is titled with their word, and the reader's own word for
the same movement renders directly beneath it as Your “…”, so nobody has to
guess which movement they are reading. workoutItemDetailLines computes it by
comparing the reader's own resolution against item.exerciseName — the only
stage that can carry a foreign word — so it needs no flag and is correct on both
sides of that backend change: while the server still resolves per reader the two
words agree and the line simply does not appear.
Both detail lines, not one. workoutItemDetailLines returns
{ yourName, own, author, authorName } and every surface renders them: the
reader's own qualifier (or the equipment subtitle standing in for it) and,
beneath it, the author's cue introduced by From {name}. The cue used to replace the reader's
line, on the argument that the equipment is readable off the name anyway. That
holds right up to the point where the two are in different languages — an author
writing in Spanish then costs a reader who has none the only line they could
read. It also fixes a second defect: an unattributed foreign cue rendered in the
same muted line as an own qualifier and was indistinguishable from it, so a
reader could not tell whose instruction they were following.
Two rules keep the pair honest. A cue that says the same thing as the own line
(ignoring case and spacing — the normal case for a reader who adopted the
author's own naming) drops to one line, because one sentence under two labels is
noise rather than attribution. And an author the backend never named gets From
the author, never an empty byline. Rendered by ItemDetailLinesView
(@enode/ui/sharing/author-cue), so the compact WorkoutCard line and the full
SlotCard row introduce the cue with the same words — the card joins them onto
one line, identity first.
Why this is safe, when the plain description stage is not. The winner index
cannot say whose row it holds, which is why stage 3 stays suppressed for a
viewer who resolves a name of their own (see the trap below). The author's cue
has no such problem: authorItemNaming joins on identity — a template's
sharingUserID is a description's ownerUserID, verified live in
tests/live/sharing/40-author-detail-reach.live.test.ts, where the two came
back byte-identical. There is no guessing left to do.
Plumbing. authorUserID flows SharedTemplateItems → SlotCard → WorkoutItemContent; WorkoutCard derives it from the row it already holds,
gated on the provenance prop it already takes, so no caller passes anything
new. Supplying it also makes useWorkoutItemDetail fetch — a reader who
resolves their own name never triggers the name hook's lazy load, so nothing
else would bring the author's row in.
One consequence for authors. Sharing a workout does not share the
namings inside it: a sharedPrivate description stays invisible to recipients
(measured in the same probe). An author whose namings are private shares a
workout whose cues reach nobody, and neither side is told.
The share-time reach check
So the author is told, at the moment they pick the level. namingsBelowReach
(packages/core/src/sharing/naming-reach.ts) selects the author's own
namings, used by the draft's items, that reach fewer people than the level being
set. The template drawer renders that as one row under the access-level picker,
pre-selected, and raises them on save with PUT /exercise_descriptions/{id} —
which the same probe confirmed answers 200, after which the reader sees the
naming with its detail.
Four rules the selection follows, each of them a way of not guessing:
- Only the author's own rows. Widening someone else's writing is not ours to do, and a recipient who can see the template may already see theirs.
- Only levels this build ranks.
reachRankreturnsnullfor anything unrecognised, and an unrankable level on either side means the row stays silent. Every comparison here decides whether to widen who can read somebody's writing; a guess is the one mistake it must not make. - Nothing below
sharedPrivate, so a private template reports no gap. - Stated, never silent. The toggle defaults to on because that is nearly always the intent, and exists because raising a naming's reach has effects beyond this one workout.
The raise runs after the template saved — a failed save must never widen anything — and sequentially, one PUT per naming, failures counted rather than thrown. A note that lagged is reported on its own ("the template was saved, but…") so it never reads as a failed save.
What a copy keeps
While reading someone else's template the cue is joined live from their naming. A copy is the reader's own, so that join stops applying and the instruction would simply vanish — "I saved it and the coach's notes are gone".
copyTemplate(id, { authorUserID }) therefore moves each cue into the copied
item's own note, which is exactly what that field is for: an instruction
attached to this prescription rather than to the movement. Nothing enters
the reader's catalogue — no naming adopted, no description created. Importing
a catalogue stays a separate, explicit act.
- Only an empty note is filled. An item note the author already wrote is the sharper instruction and wins.
- Best-effort. The copy already exists and is usable, so a failure in this second step returns it unchanged rather than throwing. A "Save a copy" that reports failure for a copy that plainly succeeded is the worse outcome, and the caller cannot retry only this half.
- No write when there is nothing to carry.
withCarriedNotesreturns the very same array, and the identity check skips the request.
The tracking app's Library detail has no copy action at all — there a template is only ever a starting point — so this runs on the portal's two copy paths.
Stage 3 is the reason exercise descriptions exist as a shared entity. It is what lets a coach read, and search, a template written in someone else's vocabulary before they have ever owned that exercise. Without it, contained- exercise search finds nothing on foreign rows, which is the only usable way into a template whose title is in another language.
Three traps:
useWorkoutItemDisplayNamefetches lazily;useWorkoutItemDetaildoes not. Pair them on the same row, and pass BOTH the sameexercise. The name hook decides whether a description is worth loading at all; the detail hook is a pure cache read.- Stage 3 only fires when stages 1–2 came up empty — for the detail line
too. The cache is keyed by
exerciseDefinitionIDalone, so an entry for a stock definition is whatever some other user published for it. Warming the cache for one shared list (requestExerciseDescriptionsin the Library) would otherwise relabel the viewer's OWN rows for the rest of the session. BothworkoutItemDisplayNameandworkoutItemDetailtherefore skip the description once the viewer's own exercise resolves. - The description cache is enrichment, not view data. It has no
store-kitstatus, never reaches the error presenter, and caches "nobody described this" as an explicitnullso a miss is requested once per session and not on every mount. A view must never gate its render on it — a missing description degrades one line, it does not fail a screen.
Never gate an exercise row on the viewer owning the exercise. Everything that carries meaning (name, blocks, tags, note) comes off the item. Only three things need the viewer's own catalogue entry, and each degrades on its own: the animation (falls back to the order number at the same 48px, so row height never moves), the muscle glyph (omitted), and the equipment subtitle (falls back to the item's own qualifier).
Edit copies first
Editing someone else's content starts by making it yours. canEditTemplate is
ownership-only and deliberately ignores sharingPrivilege === "write".
- Portal: a foreign row opens the read-only
SharedTemplateView; "Save a copy" →POST /workout_definitions/{id}/copy→ the drawer swaps onto the normal editor for the copy, and the scope segment flips to Mine. - Tracking: the Library's detail view offers only "Use template" — no copy.
Using one already clones the row into a local, fully editable session draft
(
startLibraryTemplatere-ids the row before it reaches the prep drawer), so the copy-then-edit dance has nothing left to add on a training device; managing your own template list is portal work.
The read-only body is not a new "read-only mode" on the workout builder.
SlotCard with editing={false} and no onMemberClick is already inert, so a
shared template renders through the exact component an owned one does — one
visual truth, no second code path.
/copy takes only the id; the server reads the tree and creates no
exercises (they are minted lazily on open/plan/train). It does not promise to
inline the item tree in its response, so the planner reads the copy back when it
comes back bare — planning against the source's item ids would attach the
schedule to another definition's rows.
Follow vs. copy, and its relationship to HTTP 313
The one predicate that does honour write is canEditSharedDefinition, and it
feeds exactly one thing: the canEditShared option of the 313 conflict
dialog. 313 is the backend's answer to "you are saving a change that would
alter a definition other schedules depend on"; it is deliberately excluded from
errors/action-table.ts because it is a question, not a failure.
When a planner picks a foreign template, the drawer asks once, at pick time, before anything lands on the draft:
- Follow the original — the schedule references the author's definition. Their edits show up; if they stop sharing or delete it, the appointment disappears.
- Make my own copy —
copyTemplate, then the draft is seeded from the copy. Saving no longer trips 313, and the copy's movements are adopted into the copier's own catalogue so the plan is fully theirs.
This is a UX pre-empt, not a replacement. sharingPrivilege is not a
reliable predictor of whether a save will 313, so the save-time loop stays in
place as the backstop. Cancel re-opens the picker (the decision was declined,
not the pick) and leaves the draft untouched.
A followed plan is read-only — "Copy to edit" is the way in
A schedule that follows a foreign template renders its exercise list inert
(FollowingTemplateNotice above it, plain SlotCards below, no "Add exercise"
and no select mode). The one control is Copy to edit, which mints the
viewer's own copy and, by doing so, stops the schedule following the original.
Only the plan is locked. Date, time, recurrence, athletes and the schedule's
own name and tags stay editable while following — those live on the SCHEDULE,
not on the author's definition (toWorkoutScheduleCreateDto), and a coach who
only moves a session to Thursday has no reason to fork anything.
This replaced an earlier model in which the exercises stayed editable and the fork was resolved on save (or offered once the plan had been touched). That let a coach rebuild somebody else's session and only then meet the question, and it needed a second visual state — an amber "your changes need your own copy" — for a situation the user should never have been walked into. Locking removes the state and the question with it: you decide to own the plan before editing it, which is the same decision asked at the moment it is cheap.
The HTTP 313 dialog stays as the backstop for what the client cannot see (a
definition shared with other schedules). Without sharingPrivilege: "write" it
offers only "save as a private copy", so the author's definition is unreachable.
Two server shapes, one fork
Copy-on-edit is disabled server-side for this rollout — a save must not silently
return a different id — so a read-only recipient's PUT is refused rather
than answered with 313. saveSchedule translates both into the same
shared-definition-conflict, and the difference is carried in copyOnly:
| Server answers | copyOnly | The dialog may offer |
|---|---|---|
313 | false | the copy and "edit for everyone" |
403 + reason: "read_only_share_requires_copy" | true | the copy only |
The match is on the reason, never on the bare status: a 403 without it is an
ordinary permission error and must reach the error engine. Offering "edit for
everyone" against the server's refusal would walk the user into a second one —
so the server's verdict overrides the privilege, not the other way round. Pinned
by api/schedule-conflict.test.ts, including both pass-through cases.
Live status (backdev, re-probed 2026-08-28): the 403 branch is now the
one that fires. A stranger reading a public template receives
sharingPrivilege: "read", and their PUT is refused with
reason: "read_only_share_requires_copy" without persisting — the shape this
table's second row describes, which was unreachable code until that day. The
313 branch stays as the backstop for the other case.
Who may share at all: one privilege per domain
Three RBAC keys decide whether a user is offered ANY sharing, one per shareable
domain — canShareExercises, canShareWorkouts, canShareTags. They live in
the Privilege catalogue and are mapped to their surfaces in exactly one place,
packages/core/src/sharing/share-privileges.ts.
Without a domain's key, everything the user authors there is Private and no
sharing option is drawn. That is not a client-side allowlist: useHasPrivilege
denies any key the server-merged profile does not carry, so an account on a
backend that has not granted these simply never sees the controls. It is also
denied while the profile is still loading, so a surface starts hidden and only
appears on a proven grant — never the other way round.
Two boundaries the module is deliberate about:
- These gate publishing, not reading. They are
canShare…keys. Following a catalogue, browsing shared templates and reading a coach's wording are inbound and stay available, because the case the whole feature exists for is an athlete — who may well not publish — RECEIVING their coach's names. - A missing key never restates stored data as Private. It stops you raising
a level; it cannot un-say what is already published. So an entity that IS
shared keeps showing its true level and gains a way DOWN
(
sharingLockState→lockedShared), because taking a share down must never be the thing a user cannot do — and drawing "Private" over a live share is the one error here with a real cost.
sharingLockState(canShare, visibility) returns the three states a surface can
be in (unlocked / locked / lockedShared) so a call site cannot express the
fourth by accident, and visibilityForWrite(canShare, chosen) re-asserts Private
on the write path — a level that only a hidden picker keeps private is one
refactor away from being published. Pinned by
share-privileges.test.ts.
Gated surfaces: the exercise-creation wizard's reach row, the exercise drawer's naming section, the workout-template access card, the tag drawer's access section, and the catalogue browser's "Your catalogue" half (a reach-management table, so it is the outbound one — "Other catalogues" is not gated).
Public is readable, but not authorable
Publishing to every enode user is not open yet. No picker in either app
offers sharedPublic; both derive their options from the single gate
SELECTABLE_VISIBILITIES in
packages/ui/src/sharing/visibility.tsx,
and re-enabling it is a one-line change there.
This is a product gate, not a capability gate. sharedPublic stays fully
modelled everywhere else — scope, icons, labels, provenance, the Public discover
segment, read-only viewing and copy all keep working. Public content that
already exists (published from the native app, or seeded on the backend) must
stay discoverable; a level nobody may author is not the same as a level that
may not exist, and collapsing the two would hide real content.
The consequence that needed designing: an entity may already sit at a level the
build will not author. A picker whose options exclude the current value marks no
pill active, which reads as "nothing is set" about world-visible content — the
exact opposite of the truth. So both sections branch on
isSelectableVisibility(value) and, when it is false, state the real level and
offer only the way down (an explicit Organisation / Private pair). Choosing one
lands on a selectable level and the normal picker takes over from the next
render. Taking a share down must never be the thing you cannot do.
Pinned by visibility.test.ts,
which is written to fail loudly when public authoring returns — update it then,
do not loosen it.
The safety reason for keeping it closed is gone — the product one is not.
Probing backdev in August showed a sharedPublic template reaching unrelated
accounts with sharingPrivilege: "write", and such an account's PUT
succeeding and persisting: a stranger renamed another user's template. That is
fixed (re-probed 2026-08-28: read, and a 403 that does not persist), so
the client gate no longer stands between a coach and a defect.
What it still is: a product decision about whether publishing to every enode
user is something this build offers. Re-enabling remains one line in
SELECTABLE_VISIBILITIES — but it is now a call to make, not a fix to wait for.
The row IS the definition — read sharing through the accessor
GET /workout_definitions/templates answers with definitions shaped as
workouts. On that feed the sharing fields sit flat on the row
(sharingUserID, sharingVisibility, sharingPrivilege at the top level) and
there is no workoutDefinition at all — the row is the definition. The
schedule family nests them instead. Both shapes are real; neither is legacy.
This is the trap the whole page exists for, because it fails silently and in
the safe-looking direction. Reading row.workoutDefinition on the flat feed
yields undefined → no owner → the "no owner ⇒ mine" degradation fires → every
shared template scores mine. The symptom is not an error: it is a Library with
no Mine/Organisation/Public picker, no provenance badge, and no author byline,
which is indistinguishable from "nobody has shared anything with me".
So there are two accessors, and call sites use them instead of reaching into the row:
| Accessor | Answers |
|---|---|
templateSharing(row) | the four sharing fields, wherever they sit |
templateDefinitionID(row) | the id /workout_definitions/{id}/copy takes |
Both resolve nested first, flat as the fallback — the same precedence
workoutItems() / workoutName() already use in workouts/derive.ts, for the
same reason. templateSharing falls back per field, not per object: a
workoutDefinition that simply omits the sharing keys must not mask a row that
has them one level up.
templateDefinitionID matters just as much as the scope: the portal's copy path
keys on it, so reading only the nested id both hid "Save a copy" (the button
is gated on having an id) and would have made it inert. A missing id returns
null and means cannot act on this row — never substitute the row id blindly
there, since on a schedule row that id belongs to the schedule, not the
definition.
Never write templateScope(row.workoutDefinition, …) at a call site. Pass
templateSharing(row). The predicates keep taking a bare definition-like object
so they stay pure and testable — the accessor is what knows about wire shapes.
Starting a foreign template: whose exercises resolve
"Use template" in tracking is a client-side copy — startLibraryTemplate
re-ids the row and hands it to the prep drawer. No server copy, no exercise
created. The server only gets involved at Start, where
resolveUserExercises asks POST /exercises/exercise_for_user/workout for one
instance per (definition × athlete). Which definition id it sends comes from
getItemDefinitionID — item.exercise?.exerciseDefinitionID first, then the
FLAT item.exerciseDefinitionID that a foreign row is the whole reason for.
Live-verified on backdev (tests/live/sharing/10-foreign-definition-mint.live.test.ts):
- Stock exercises resolve for everyone. The seeded catalogue is global — two unrelated accounts carry the same 187 definition ids — so a shared template built from stock exercises starts normally, and the athlete's instance is minted on the spot even if they never trained it.
- An exercise the author created themselves does too, since 2026-08-28. The
endpoint used to mint only for definitions the caller owned and answered a
foreign one with
200 + []; it now returns the athlete's instance. The backend gained foreign minting (server handoff §1.2), and the probe asserts the new behaviour so a regression fails loudly. - A made-up UUID still answers
200 + []— the one case left where the client cannot tell "nothing to mint" from "bad id".
The failure mode this section documents is therefore historical, but the client
still handles it, because an empty answer is still possible: summarizeMissing
marks the athlete uncovered, createInitialSessions skips that (item × athlete)
with a console.warn, and — this part was the actual defect — because
prepareError is null the training view used to show the offline-coverage line
("can't be started offline — connect to load their exercises") to a coach who
was online. serverLacksInstances fixed that; see below.
Two further degradations on the same root cause — the viewer has no own
exercise object for a foreign definition, and
ExerciseForChildUserReturnDto carries no exerciseBaseID to stand in:
ensureItemExerciseBasesreadsitem.exercise?.exerciseBaseID, so no base is loaded — no animation, no technique data.seedFocusMetricIDtherefore can't see that the exercise is a jump / flywheel / weightlifting movement and seeds the rm1 placeholder anyway — the exact "10000 cm jump height" defect its own comment warns about.
The repair: adopt the definition, then mint as usual
Not redundant, and no longer a composition. Foreign minting works as of
2026-08-28, so this path does not carry the start any more — but it is still
what makes the recipient own the movement, which the animation, the technique
data and seedFocusMetricID all need and minting alone does not give
(ExerciseForChildUserReturnDto carries no exerciseBaseID).
It is one request: POST /exercises/from_definition names the definition and
lets the server resolve it, so the created exercise sits on the id that was asked
for. Re-measured on backdev 2026-09-03: it is idempotent — a second call for
a definition the caller already owns answers 200 with the existing row and the
exercise count does not move — so the path needs no "do I own it yet" guard, and
createExerciseFromDefinition's duplicate branch is a fallback for a server that
answers 400 rather than the behaviour observed here.
What this replaced, and why it had to. It used to read the composition
(GET /exercise_definitions/{id}) and recreate it through the ordinary
POST /exercises, resting on one claim:
A definition is global, keyed by (base, equipment). Two unrelated accounts that create the same composition get the very same
exerciseDefinitionID.
That claim is false. Measured on backdev 2026-09-03, the composition
(DD26AF39, 94D47C73) exists as two definitions: D81B46A9 "New Pec Deck"
and 215F9DD5 "Pec Deck" — the second one minted by this very repair. So the old
path never landed on the shared definition. It created a near-duplicate exercise
in the recipient's own catalogue, returned "adopted", and left every one of the
three things it exists for still broken: the row read "Exercise", no
animation, no technique data. Pressing Use template again then answered
"alreadyOwned" — success, forever, for something that never happened.
Two lessons worth keeping:
- A composition does not identify a definition; only an id does. Any future "recreate it on my side" should name the row, not describe it.
- The probe logged the thing that mattered.
tests/live/sharing/10-foreign-definition-mint.live.test.tsasserted that the adoption produced an exercise, and onlylog()gedlandedOnTheForeignDefinition. It now asserts that the adopted exercise sits on the requested definition id — the one assertion that fails when this regresses. (The old scenario also adopted a definition the coach already owned, which is why "did it land" was true no matter what happened.)
Still true, and still what makes reading a foreign definition possible at all:
GET /exercise_definitions/{id} answers 200 for a definition you do not own
(the unfiltered GET /exercise_definitions list is 403), and it carries the
definition's own name — which is why a row whose naming chain comes up empty
does not have to read "Exercise".
Where each step belongs — and where it is implemented. The adoption runs at
Use template (useExerciseAdoption, mounted by the prep drawer, over
@enode/core/exercises/adopt), not at Start:
which definitions are missing is answerable with no request at all (the viewer's
own catalogue is already loaded), the Library only exists online anyway
(templates are not in the offline snapshot), and Start commits the training view
behind the closing drawer — too late to explain anything. The per-athlete mint
stays where it is, at Start, because only there are the participants known.
It does not block: the prep drawer opens immediately, stays fully usable, and the adoption runs behind it — silent while it works, because it normally settles before the athletes are picked. Two cases speak up, neither of them gating Start:
- No connection — the exercise can't be fetched at all, so the drawer says it
needs a network once. Coming back online re-runs the adoption on its own
(the hook watches
useBrowserOnline). - Online but failed — a warning with a retry.
Should a coach start anyway, TrainingSession's uncovered banner distinguishes
the same two causes through serverLacksInstances: a server that answered and
had nothing is NOT an offline problem and must not tell the coach to connect.
The item still has no exercise — resolve it by DEFINITION
Owning the movement is not the same as the item knowing about it. A shared
template's item carries no nested exercise object at all, only a flat
exerciseDefinitionID, and every consumer used to key on item.exercise?.id.
So the movement was anonymous to the whole training view, and it failed
silently in three expensive ways:
- The sensor was never configured.
useTrackingSourceSettingis keyed on the exercise's base and equipment, so it missed, the sensor mode stayed null, and every concentric rep was discarded. The coach trained an exercise that recorded nothing and was told nothing. - A fictional 1RM was uploaded.
seedFocusMetricIDcould not see that a movement is a jump or a flywheel lift, so it seeded the 100 kg rm1 placeholder onto it — the "10000 cm jump height" its own comment warns about, written into the athlete's history. - No name, no animation, no technique data. The row read "Exercise", or the coach's note, while the Library card behind it showed the real thing.
One function answers it now — itemExercise
(packages/core/src/exercises/item-exercise.ts):
the viewer's own row for item.exercise.id, then whatever is inlined on the
item, then their row for the item's definition. Both tracking resolvers
(TrainingSession, WorkoutEditingFlow) call it, so they cannot drift, and the
focus-metric seed and the base preload read the same rule through
itemExerciseBaseID.
The last rung only works because the adoption lands on the right definition
— while it recreated a (base, equipment) composition the viewer's row sat on a
definition of its own and this lookup missed too. The two changes are one repair.
byDefinition being null (catalogue still loading) resolves to undefined
rather than a guess, so "not loaded" never reads as "not owned" and the seed
keeps its rm1 fallback.
One feed, partitioned the same way in both apps
The tracking app's Library drawer and the portal's schedule template picker list
the same feed. They therefore share the partition itself, not just its look:
@enode/core/sharing/template-library holds availableSegments,
templatesForSegment, matchesTagFilter and groupByAuthor, and both views
render them through @enode/ui/sharing/template-source-chips and
@enode/ui/sharing/author-section.
- Source chips (Mine · Organisation · Public) narrow additively — nothing pressed shows everything — and the strip is hidden whenever the loaded feed can fill only one scope, because a lone chip is not a choice.
- One collapsible section per author, the viewer's own first, keyed by owner id and labelled by name so an author the backend never named still gets their own section instead of joining a nameless heap. A single group renders with no heading at all: a heading over the only section on screen labels nothing.
- Grouping happens after ranking and preserves it inside each run, so a search still puts its best hit at the top of the group it belongs to without reshuffling the section order on every keystroke.
What stays app-local is what genuinely differs: the tracking Library also
searches contained exercise names (library-filter.ts), which a foreign
template written in someone else's vocabulary needs and the portal's name-only
picker does not.
The self-hiding discover rule
There is no discover endpoint. GET /workout_definitions/templates is the only
source, it is a flat unpaginated array, and the sharing dimension is a purely
client-side partition of that one feed — no second store, no second request.
A scope segment is offered only when the loaded feed can fill it. If nothing is shared with you, the Mine/Organisation/Public picker is not rendered at all and both surfaces look exactly as they did before sharing existed. Two consequences worth knowing:
- No regression risk, but no signal either. If the backend returns only the caller's own rows, the whole inbound half is simply invisible. "The segments never appear" is the symptom of a server gap, not a client bug.
- Segments decide synchronously. Because the partition is over already-loaded data, a tab can never pop in a moment after the list renders.
isDiscoverableTemplateRow also requires the row to have items: an empty
shared template is unusable, and copying it would produce an empty template.
Search ranking comes from @enode/core/workouts/template-search
(rankTemplates) in both apps, over name + tag names + resolved item
names, so "bench" orders identically in the portal list and the tracking
Library. Tag reads go through workoutTags() — the backend attaches tags to the
workout on the schedule family and to the definition on the definition family,
and reading only one field silently drops every tag of a row shaped the other
way.
One template entry, not two
The tracking FAB briefly offered both a flat "Select workout template" picker and the Library. It no longer does: the Library is the only template entry in the start flow, in the FAB menu, the sidebar row and the sidebar spine alike.
The reason is that the two lists were never disjoint. Once the start picker also showed shared templates (own first, badged), it held a subset of what the Library holds — same feed, fewer affordances — so the menu asked the coach to guess which of two lists a template was in, and the answer was "both". A start from the Library costs one extra tap through the detail view, and that view is where read-only-ness and provenance are stated, so a foreign template is never started without the coach having seen whose it is.
There is now exactly ONE templates surface. The retrospective source picker ("Log past workout → From template") opens the same Library, so choosing a template to log looks and filters identically to choosing one to start — it used to be a thinner list that quietly lacked the source chips and the author sections. If a new flow needs to pick a template, mount the Library; do not add a second, leaner list.
No tombstones
Nothing tells the client that content was un-shared. It simply drops out of the list, and a direct GET 404s. Two mechanisms cover this:
- The tracking detail view re-looks-up its row by id in the live feed each render. It renders the pushed snapshot so it can never blank, and flips to a "No longer shared" notice (the action disabled) the moment the row leaves the feed. A pull-to-refresh of the Library is what revalidates that feed.
- The portal reads the 404 directly in two commented places (the read-only
hydrate and "Save a copy"). This is the documented exception to
react-to-a-disposition-never-a-status: 404-means-gone is an identity fact
about the entity, not an error policy, and it matches the portal's existing
vanished-entity precedent (
workout-template-drawer,schedule-drawer,chart-builder). Do not generalise it to other statuses.
Deliberately not built
| Not built | Why |
|---|---|
| Adopting someone's exercise description | The backend duplicates a description on adopt instead of linking, and there is no un-adopt. Taking a name is therefore a plain rename of the viewer's own exercise — staged, saved by the normal exercise PUT, no server link, nothing to undo. |
| "Reset to catalogue name" | Clearing the Name field already falls back to the catalogue name through the pre-existing name: null path. A second control for the same thing would imply the server has an un-adopt it does not have. |
| Unpublish / delete a published naming | deleteExerciseDescription exists in core, but the cascade semantics are unknown. The only un-share exposed is setting visibility back to Private via the same PUT. |
| Bulk adopt on the exercise list | Superseded by following a catalogue (ADR 0010). Two mechanisms for one act, and the selection-bar one could not express the thing users actually mean — "I use their words", not "these rows adopt". |
| Client-side dedupe of duplicate descriptions | Would hide the adopt-duplication defect and guess at server ownership rules. The store prefers the active row and the UI shows the rest; a coach sees the mess, which is the honest state. |
| A share preview ("what will others see?") | There is no server-side render of a foreign view, so a preview would be a client-side guess that can drift from what the recipient actually gets. |
| Offline templates | Templates are not in the offline snapshot. The dataless-offline state says so plainly rather than implying a cached library exists. |
| A read-only mode on the workout builder | SlotCard editing={false} is already inert. See Edit copies first. |
Exercising it without shared backend content
Nothing here is visible until somebody on the backend actually shares a
template or publishes a naming, so both apps carry a debug surface that installs
a fixture matrix instead. The same seven cases are declared in both apps —
own, internal with and without an author, public, an empty share, a level this
build has never heard of (carrying write), and a legacy row with no owner.
| App | Click path | Fixtures |
|---|---|---|
| Tracking | Sidebar → bug icon → Sharing tab (also: Globals → Developer → Template sharing → Open workbench) | apps/tracking/src/app/workouts/today/library/dev-fixtures.ts |
| Portal | /dashboard/debug → UI Sandbox → Sharing (templates + exercise naming); the store-level switch is Config → Mock data → Shared workout templates | apps/portal/src/app/dashboard/debug/mock-entities.ts |
Two rules keep these surfaces honest, and both are enforced by a test:
- Every answer on screen comes from the shipped predicate. The tables call
templateScope/isDiscoverableTemplateRow/canEditSharedDefinition, the naming rows calluseWorkoutItemDisplayName, the demos mount the real drawers and sections. A surface that re-implements the rule it demonstrates stops agreeing with the app and then quietly lies about it. - Each fixture row declares what it must resolve to, and
dev-fixtures.test.ts/mock-entities.test.tsrun the real predicates over the matrix and compare. A failure there means the mock and production have diverged — one of the two is a bug, not a test to adjust.
The tracking workbench also flags a mismatch live: in "Me" mode a computed cell that disagrees with its declared expectation turns red-orange.
Fixture ids are inert against a real backend. The portal's fetch stub answers
only requests whose ids all carry MOCK_SHARING_PREFIX, and
setTemplatesDevOverride is a no-op outside a dev build.
Exercise namings: the other shareable thing
An exercise is not shareable. ExerciseMappedReturnDto carries no
visibility and no sharing owner, only userID. What is shared is the
description — someone's naming of a movement. So on an exercise surface the
question is never "whose row is this", it is "whose words am I using", and
the two must not be conflated: the row is yours either way.
namingOrigin(exercise, descriptions, myUserId) answers it in three states:
| Origin | Meaning |
|---|---|
catalogue | nobody published a naming — the stock name stands |
mine | the viewer's own published naming |
adopted | someone else's naming, linked by adopt |
A definition that was never asked about and one that nobody described both
resolve to catalogue. That is deliberate: claiming a naming we have not read
would put a byline on a row that has none.
Adopt LINKS. Renaming AUTHORS.
POST /exercise_descriptions/{id}/adopt points the viewer's own exercise row at
an existing description and creates nothing. Copying the text into the
exercise's own name fields instead looks identical on screen but authors a new
description owned by the renamer — which is exactly how a second, private,
identically-named row appears beside the original.
This build did that for a while, on the belief that adopt duplicated. It does
not; the workaround was the duplication. unadopt clears only the viewer's own
link (never the description, which others may have adopted too) and is what
makes offering adopt safe at all — there is a way back.
Consuming and authoring are mutually exclusive
The naming card shows one of them. While a foreign naming is in force there is nothing of the viewer's to publish, so the publish half is absent, not disabled: offering it would invite authoring a second description with the same words. Reset first; then the exercise is named by the viewer and publishing means something.
A disabled control says "not right now" and invites hunting for why. When an action is categorically wrong for a state, remove it and say what to do instead.
Bulk adopt never chooses for the user
soleAdoptableNaming offers a row only where the choice is not a choice: not
already adopted, exactly one foreign naming available, and that naming
carries text. More than one author for the same movement excludes the row —
"adopt 40 exercises" must not silently contain forty author preferences. Those
rows keep the per-exercise picker, where the alternatives sit side by side.
The bulk action states how many of the selected rows it will change before it runs, is offered only while that number is non-zero, and runs sequentially: one request per exercise against an endpoint with no batch form, where firing forty at once turns a partial success into an unexplainable one. Failures are counted, not thrown — a bulk action that stops halfway has to say how far it got.
That per-row cost is also the argument in handoffs/SHARING_SERVER_HANDOFF.md
§2.4: a dozen requests is honest, a school's catalogue times thirty athletes is
not.
The exercise drawer: description first, and no publish step
The portal's exercise drawer leads with Description — the name and the detail — and puts everything that merely identifies the movement (movement, equipment, rating, tags) under General below it. That is the order the drawer is actually opened in: the two text fields are what a coach came to change, and they are also the two fields that get shared.
So the reach control is one row inside that same card, directly under the fields it applies to, and it is a setting rather than an action. There is no publish button:
- The name and detail are staged fields — publishing them before Save would share text the exercise itself does not carry yet.
- It matches the creation wizard, where choosing a level is part of creating, not a second step.
useExerciseNaming().commit()runs from the drawer's Save, after the exercise PUT, and decides create-vs-update-vs-nothing from the samenamingPublishDtothe wizard uses (private still publishes nothing; an unchanged row is not re-written). A failure there never fails the save — the exercise is already written, so it closes with a sticky toast saying the sharing part did not go through.
What others call this movement sits below the card, collapsed, with its count on the header: it is reference material for a decision most opens are not about, and the count already says whether it is worth opening.
One read serves both — the hook owns the fetch and the writes, and the two views
(ExerciseNamingRows, ExerciseNamingAlternatives) are placed by the drawer.
Mounting one component twice would read the endpoint twice.
Choosing the level while creating an exercise
The creation wizard (packages/ui/src/exercise-create/, shared by the portal
drawer and the tracking exercise picker) carries the same picker on its Review
step, directly under the Name field it applies to. Same two levels, same words,
same one-line hints — both surfaces read SELECTABLE_VISIBILITIES and
useVisibilityHint() from @enode/ui/sharing/visibility, so the vocabulary is
stated once and re-enabling public stays a one-line change there.
Both say it the same way: the heading names the thing ("Exercise description") and the row only asks the reach ("Who can see it"). "Share this exercise" would be the wrong promise — nobody receives the exercise, only the author's words for its movement — and "share this name" understates it, because the instructions go out with the name. The hints therefore name both halves: your name and instructions for this movement.
Private publishes nothing. It is the default, and it means the create stays
a single POST /exercises: the exercise row is already only its owner's, and a
private description would add a row that changes nothing for anybody — which is
precisely the per-movement duplication this feature exists to keep down. Only a
level beyond private is followed by POST /exercise_descriptions.
namingPublishDto() (packages/core/src/sharing/naming-publish.ts) makes that
call — it also returns null for a blank name and for a create response with no
exerciseDefinitionID, so the decision is one tested pure function rather than
conditions spread through the wizard.
The publish is the second request, and the exercise exists by the time it runs. A failure there therefore never fails the create: the wizard closes with the exercise made and says, in a sticky toast, that the name was not shared and where to publish it. Staying silent would be the actual defect — the user asked for it to be shared and it is not.
Naming is a curation capability
Everything above is gated on Privilege.writeExercise — the publish card, the
provenance line in the exercise list, and the bulk action. An athlete has no
catalogue to curate and no reason to learn that naming is shareable at all; for
them the shared names simply resolve and the list stays a list.
The gate also skips the description sweep entirely. A hidden control that still fetches its data is not a gate.
Where it shows, and where it stays quiet
The exercise LIST says nothing about naming at all. No provenance line, no reach, no chrome — it is a list of the viewer's exercises. Whose words a movement wears is stated only where it can also be changed: the exercise drawer, and the Catalogues drawer.
That is a removal, and the reason is worth keeping. The line used to sit under
the name in the table, and it read a different flag than the drawer did
(isActive vs. the server's in-force answer). So a coach could see "Named by
Team Enode" on a row and, one tap later, an offer to publish those same words
as their own. Two surfaces answering one question is how they came to disagree;
now one answers it.
What the table still does with descriptions is search them:
matchesExerciseSearch(…, extraTerms) takes everyone's published names, so a
coach who knows a lift by a colleague's word finds it — without that word
costing a pixel of the list. That is the whole reason the description sweep on
that page still exists.
Where provenance IS shown, the old rule holds: only somebody else's naming produces a line, and only while it is actually in force. Your own — published or not — says nothing.
That second half was a defect for a while: the description cache is keyed by
exerciseDefinitionID alone and its winner falls back to first-seen when
nothing is active, so a stranger's row could become the entry for a movement the
viewer had never taken — and the list printed "Named by Jonas" under a row
showing the plain catalogue name. It also made the list disagree with the
drawer, which has always branched on isActive. namingOrigin now requires
isActive === true. A backend that stops sending the flag makes every
provenance line disappear rather than mis-attribute one.
The video is READ where the athlete trains, not only where a coach edits it
A description's videoLink has one author and two audiences. The portal is where
it is written — inside the Description card, gated on writeExercise, because a
video is part of a description and you cannot amend somebody else's. But the
person the clip is for is the athlete standing at the machine, so the tracking
app plays it back during the live training: an "Instructions" chip (play glyph
when a video exists) leading the tag row under the header card of the exercise
being tracked, opening a sheet with the video and the movement's cues
(see tracking app features §2.7).
Three properties carry over from the portal and are not re-decided there:
- Only the naming IN FORCE counts.
namingInForce, not the description cache's per-definition winner — that winner falls back to first-seen and can be a stranger's row the viewer never took, which would put an unknown author's video on an athlete's training screen. - Click-to-play.
VideoEmbedPlayerdraws its own resting card and mounts the frame on the first press, so nothing reaches YouTube or Vimeo until somebody asks. That matters more here than in the portal: this is a screen a gym leaves open all day. - Read-only, and offered to everyone. No privilege gate — an athlete has no catalogue to curate, which is a reason to hide the editor, never the clip.
The portal's own player is still inside the writeExercise gate, so an athlete
looking at the same exercise there sees no video. That is an inconsistency, not a
decision.
Two ways a naming comes into force, and only one of them is flagged
This is the trap that made three surfaces lie at once, and it only appeared once subscriptions went live on backdev.
| How the naming reaches the viewer | What the row says | What the exercise says |
|---|---|---|
| they adopted it | isActive: true | name/detail carry the text |
| they follow its publisher | isActive: **false** — nothing at all | name/detail carry the text |
The server resolves a followed publisher's wording with no link: it writes
the words onto the viewer's own exercise, and the description row stays
untouched. From the client, that is indistinguishable from a rename the viewer
typed — the exercise's name is a bare string that records nothing about where
it came from (confirmed: the exercise row carries no description FK).
Reading only isActive therefore produced, for every followed naming:
- an exercise list with no attribution — a colleague's word with no sign of whose it was;
- a drawer offering "Who can see it: Private" + publish, i.e. "this name is yours, share it" about words the viewer never chose. Publishing would have authored a second description saying exactly what the publisher already says — the duplication this whole feature exists to prevent;
- the publisher's row listed under "What others call this movement", invited to be taken, while it was already the name on screen.
namingInForce (packages/core/src/exercises/naming-origin.ts) answers it by
walking the same ladder the server resolves, instead of reading one flag:
- the viewer's own naming;
- one they explicitly adopted;
- one from a publisher they follow — earliest in subscription order, which is precedence order;
- otherwise the catalogue name.
It needs two things the old accessor did not: every row for the movement
(the cache's single winner cannot answer step 3) and the subscription ranks
(subscriptionRank). Where subscriptions are unknown, pass () => null and it
degrades to steps 1, 2 and 4 — exactly what the client could always answer.
Four origins, one flag — inForceForCaller
A naming reaches a reader by one of four routes, and the row now says which:
inForceVia | Means | The way out |
|---|---|---|
own | the reader wrote it | rename |
adopted | they took it deliberately | unadopt |
subscription | they follow its publisher | unfollow, or rename this one |
coach | it is their coach's wording | rename this one — there is no relationship to drop |
An explicit adopt outranks the reader's own description — measured,
tests/live/sharing/90-pick-one-movement.live.test.ts. With their own row
published and in force, adopting somebody else's moved inForceForCaller to the
foreign row (inForceVia: "adopted"); the exercise's name and detail
followed it; unadopt put their own back (inForceVia: "own"). So rung 1 is
not "my own description" but "the description I last chose": an adopt is
newer information than a name typed once, and the server treats it that way.
That is what makes a per-movement pick possible at all — see Picking one
person's wording for one movement below.
inForceForCaller marks exactly one row per movement, whatever put it
there, which is the part isActive never covered: that flag means an explicit
adopt and stays false for both of the bottom two rows. isActive keeps its
old meaning, so nothing built against it changed.
namingInForce prefers the flag and falls back to walking the ladder
client-side (own → adopted → earliest publisher in subscription order) for
deployments that do not send it yet. That fallback is scheduled for deletion
— with it goes the subscriptions read the exercise list currently needs for a
provenance line.
One list, one selection
The drawer's naming section is a single-select list and nothing else. Every named description of the movement is a row, at most one is marked, and picking another is the only verb:
Description
Name [ Abduction ]
Details
Video
Who can see it ← only once there is something of yours to share
▸ Description in use: Enode 2
Abduciton renamed From Team Enode, in your organisation
● Abduction From Enode — the standard wording
What it replaced, and why each piece went:
- A status row above the fields ("Using Team Enode's description"). It vanished on the first keystroke, taking the attribution with it. The list's header carries the same answer permanently — "Description in use: Enode" — without a second row.
- Reset. It promised "your own wording back" and, for a reader who has none, landed on their coach's — often the words they had just reset. There is nothing to reset to that is not already a row: going back to your own is picking your own row, going back to your coach's is picking theirs.
- Rename as a button. It focused a field three rows down and, if you typed nothing, did nothing. Renaming creates a new option rather than choosing between existing ones, so it belongs in the field, and the header says so.
- The chip beside the row. Selection is a property of the row, not a badge at its edge: the row itself is the control, so there is one target instead of a repeated "Use this one" on every line.
Two endpoints under one gesture. Picking somebody else's is adopt; picking
your own row is unadopt, the only thing that clears a link and the only
way back to rung 1. Both measured in
tests/live/sharing/90-pick-one-movement.live.test.ts.
Typing beats every row. A name of the viewer's own outranks the whole list, so from the first keystroke no row is marked, the header says "your new name", and the list folds shut — you have stopped choosing between wordings and started authoring one.
Nothing moves that the user did not move
Switching descriptions used to make the card jump three ways, and each had the same root: a derived boolean that is briefly true for reasons the user did not cause.
- The list emptied mid-switch. The keyed read made
statusfall out of a key mismatch — right on open, wrong on a re-read after a write. Rows of the same movement now survive a refresh, and the selection moves optimistically before the request so the radio does not wait for a round trip. A failed write re-reads rather than un-guessing by hand. - The reach row slid in and out. For the moment between the optimistic move
and the server's answer there is no description in force, which read as "you
are writing your own". Both it and the picker take a
settlingflag while the by-id refetch is in flight. - The list folded on every switch. The auto-collapse keyed off "the field
differs from the wording in force", which is true for exactly one render after
a switch — the server-side rename lands a render before the reseed does. It
now keys off
nameEdited, set only by a keystroke in the Name field and cleared by every reseed. No derived version of that question is safe.
Measured in the portal against backdev: 16 frames at 200 ms across a switch in either direction, one distinct state, drawer height constant.
isActive means EXPLICITLY ADOPTED — measured
Our own two comments described that flag two different ways: the DTO called it
"superseded rows stay readable; prefer an active one", which is version
semantics, and adoptedNaming called it "the row currently in force for the
caller", which is caller semantics. They cannot both be true, and three
surfaces branch on it.
tests/live/sharing/60-in-force-signal.live.test.ts settled it. A reader adopts
a foreign description and the same read is taken before and after:
isActive on the author's row, as the reader sees it | |
|---|---|
| before the adopt | false |
| after the adopt | true |
| after the unadopt | false |
So it is per caller — but it covers the adopt path only, which is why the section above exists. Two consequences the same probe pins:
- Only ONE row per movement can be active. A fixture showing several was
teaching a state the backend cannot produce;
mock-entities.tsnow marks exactly one, and a test holds it there. - The row in force is not an "alternative".
namingAlternativesdrops it — you cannot take what you already use, and offering it invites adopting a naming you are already reading. Its state is stated above the list instead, with the way back.
The probe also answered a question that would have made all of this simpler:
the exercise row carries no description FK. After an adopt the server copies
the description's name/detail onto the exercise, so from the exercise alone
"my own rename" and "somebody else's naming" are indistinguishable — the
description rows are the only place that knows. A catalogue of a few hundred rows that are
almost all yours would otherwise carry a line under every one, which teaches the
reader to skip that spot, and then the handful that do carry another author's
words get skipped too.
Search reads the published names as extra terms
(matchesExerciseSearch(…, extraTerms)), so a coach who knows a lift by another
author's word finds it. The page reads the cache only — typing never triggers a
request.
Catalogues: a publisher is a user
Everything above operates on one naming at a time. A user thinks about our
vocabulary — one thing — so the browse surfaces group the discovery feed into
catalogues: GET /exercise_descriptions with no parameters returns every
naming the server is willing to show this account, and cataloguesByPublisher
keys that list by ownerUserID.
A publisher is a user. There is no organisation entity in this product — every role is the same object — so a catalogue is titled with a person's name, and an unnamed publisher gets a stated sentence rather than a raw uuid. The "is a publisher an organisation?" question in the server handoff (§2.7) is therefore answered on our side: it is not, and no surface waits for one.
The portal mounts CatalogueBrowser (@enode/ui/sharing/), and only the
portal — from a Catalogues action on the exercise list, gated on
Privilege.writeExercise, the same divider the rest of naming uses.
The tracking app used to carry it too, on a Settings row. It was removed on purpose: curating whose vocabulary an account speaks is a decision made once, sitting down, over a list — which is portal work. The tracking app is use and quick adjustment, and every surface in it has to survive being operated in a gym between sets. Nothing was lost on the receiving side, which is the half that matters there: a subscription resolves server-side, so an athlete reads their coach's wording in the Library and the training view with no screen to visit and no button to press. What the drawer offered beyond that — reach changes, deletes, following and unfollowing publishers — is management, and it now lives in exactly one place.
The one thing tracking keeps is the part that costs no chrome: published names
are searchable in the exercise picker (extraTerms on
matchesExerciseSearch), so a coach who knows a lift by a colleague's word
still finds it. Same rule as the portal's Exercises page, stated once in
packages/ui/src/exercise-picker.tsx.
The surface is segmented, because its two halves answer different questions:
-
Your catalogue — what you publish: a table of your namings with multiselect, a reach filter whose pills carry the counts (
Private (12)·Shared (3)), and counted bulk actions in both directions — Share # with your organisation and Make # private. Taking a share down must never be the thing the surface cannot do; both run through the same sequential PUT. Beside them sits Delete #, the only way OUT of the catalogue: reach changes just move a naming between levels, so a row published by mistake — or a second row for a movement already named — previously had nowhere to go, andownNamingreads only the FIRST of your rows per movement, which made the duplicate unreachable from the exercise drawer too. Deleting is irreversible (no tombstones) and, for a shared row, other people stop reading it, so the host confirms first and the dialog states the shared count when there is one. The header checkbox selects the filtered rows, so "Private → select all → share internally" is a three-tap sweep — the actions ride in the same floating selection pill the tables and the workout builder use, anchored above the drawer's bottom edge. Rows whose level this build cannot rank are offered neither move. Every row is joined to the movement it names (resolveExercise, the viewer'sexercisesByDefinition): animation, muscle group, equipment, variations and tags — all of which live on the exercise, not the description, and each degrades independently when the join finds nothing. -
Other catalogues — what you can take: one card per publisher, split into People you work with and Everyone else. The split is proven, not guessed:
GET /exercise_descriptionsreturns internal rows only from the caller's accepted relations, so asharedInternalrow reaching the viewer is the server saying the two are directly connected. Only that literal level counts — the deliberate opposite ofdescriptionScope's degradation, which under-promises reach where this must under-promise familiarity. The two headings render only when both groups exist.One hop. Confirmed by the backend: an internal row proves a direct accepted relation between reader and owner — two athletes of the same coach are not in each other's scope. So a heading that groups PEOPLE says "People you work with", and the
sharedInternalhint says "the people you work with" rather than "everyone in your organisation", because that is the level's actual audience. The one-word LABEL is "Organisation" (see The icon vocabulary) — a bucket name, not an audience promise; the distinction is the reason both wordings coexist on purpose. What Follow and Add DO is stated once, in one paragraph above the cards — the cards themselves only report state, which is what keeps them scannable. "Their descriptions" expands a card to the actual words — applicable rows only, each shown against the viewer's own movement (a description for a movement the viewer lacks has nothing to answer against, so those stay in the missing-movements count): following is a decision about vocabulary, and a count is not a vocabulary. A row currently in force carries an "In use" chip; movements the viewer lacks route through Add (create the exercise) → follow.
The segment picker renders only when both segments exist (a lone pill is not a choice), and the "others" segment is gone — not empty — while nobody has published anything the viewer can see.
Two mechanisms, one probe, and the buttons follow whichever is live
Following a publisher is a subscription on the server — one write, links
derived, several publishers at once, most recently followed wins (handoff
§2.3/§2.3a). Where those endpoints are not deployed yet, the same intent is
materialised as one adopt per description and read back out of isActive.
Both paths are real, so the store asks once — GET /exercise_descriptions/subscriptions, any failure counting as "not supported" —
and everything downstream branches on the answer instead of guessing per action.
It is deliberately not re-probed within a session: a surface that changed
mechanism mid-use would change what its buttons mean while somebody is reading
them. The capability belongs to the deployment, not the account, so it also
survives a logout.
What the two modes mean on screen:
| Subscriptions | Materialised (today) | |
|---|---|---|
| Followed? | the subscription exists | some of their rows are isActive |
| Partial follow | cannot happen — links are derived | real, and Catch up is what fixes it |
| Second press on a followed card | Prefer these — moves precedence | Catch up |
| Order of the followed list | the server's, as it arrived | usefulness |
| Set by the account you belong to | mandatory — stated, and no button at all | does not exist |
| A coach's own wording reaches their athletes | yes, unless the athlete named it themselves | no — the card says so |
You cannot unfollow the account you belong to
CatalogueSubscriptionReturnDto.mandatory says the subscription is not the
caller's to change. The card then renders "Set for you" where Follow/Unfollow
would be, and unsubscribeFromCatalogue throws rather than issuing a DELETE
the server would refuse — the rule was documented at that function since the
endpoints landed and enforced nowhere, which is how an athlete came to be
offered Unfollow on their own coach.
The server decides it; the client neither derives nor overrides it. That
matters because the client has no honest way to derive it. The assignment is
stored on the MANAGER (User.accessToUsers, facility→coach→athlete) and
nothing on the managed user points back up: an athlete's own record carries
no manager id, and no license (verified on backdev — license is null for an
athlete, and populated with the org's id for a coach). The only client-side
signal was inForceVia: "coach" on the description rows, which exists solely
while one of that coach's namings is actually winning — it would vanish for an
athlete who renamed everything themselves.
Absent reads as false. A deployment without the flag behaves exactly as
before, every subscription changeable. That is the safe direction and the reason
the client could ship ahead of the server: an unchangeable subscription drawn as
changeable fails loudly on the DELETE, whereas a changeable one drawn as fixed
removes a capability silently. Pinned in
catalogue-store.test.ts.
mandatory supersedes inherited, which answered the same question for the
removed scope: "managed" and is dead on the wire (always false). The mapping
still ORs it in, so a lagging deployment locks rather than offers a broken
button.
The sort trap. GET returns newest-first and that order is the precedence
order, so orderBySubscription puts subscribed publishers first in exactly the
order they arrived. cataloguesByPublisher's usefulness sort must never be
applied on top: it would display a ranking that is not the one resolving names,
and the UI states no rule a user could use to notice. That sort is a discovery
affordance and stays right for the unfollowed half.
No rename preview in subscription mode. The confirm dialog exists because the materialised path rewrites N of the reader's movements in place; a subscription is one write against derived links, so the press goes straight through — with the Undo still on the toast.
There is no subscription endpoint (server handoff §2.4), so "I follow this
catalogue" is not persisted anywhere — not on the server and deliberately not in
local storage either. It is read back out of the data: a catalogue is followed
to the extent that its rows are isActive for the viewer, which is exactly what
adopt sets. Nothing can go stale, drift between devices, or come back wrong
after a failed write.
The price is stated rather than hidden. catalogueFollowState has three values,
and partial is the interesting one: it means the publisher has added namings
since, and the card offers Catch up beside Unfollow. A real subscription
would make that row disappear; until then, pretending it is not there would be
the lie.
Follow, unfollow, catch-up and the reach change are all one request per row, run sequentially, with failures counted rather than thrown: an action that stops halfway has to say how far it got.
Counted is not the same as reasonless. BatchOutcome carries the FIRST
failure alongside the counts, and the surface splits on how far it got: nothing
applied → presentError, so a missing privilege, an offline device and a 500
stop arriving as the same shrug; partly applied → the count sentence (the thing
only the loop knows) plus a Try again, with the error still going to
reportError. Only the first error is kept — these loops hit one endpoint, so a
later failure is almost always the first one again, and letting it overwrite
would make which reason is shown depend on row order. Delete is the one that
offers no retry: it is irreversible and the rows that did delete are gone, so
replaying the set would ask the server for ids that no longer exist.
The reach PUT carries videoLink back, though it turned out not to have to.
The endpoint is a merge, measured on backdev 2026-09-03: a PUT that omits
videoLink leaves the video intact, so the bulk reach change never destroyed
one. The field is echoed anyway because the DTO documents videoLink: null as
clearing the video — replace-shaped language on a merge-shaped endpoint — and a
control that only claims to change who may read must not depend on which of the
two readings holds. Sending the row's current value is correct under both.
Following states its work, and is undoable
Following is the only write in this feature that changes text the user did not
type — a bulk rename of their own vocabulary. So it is the one that shows what
it will do first: a confirm dialog lists every rename as your word → their word (NamingChangePreview), built from exactly the rows the write will
touch, so the list and the action can never disagree. A row whose word the
viewer already uses renders without an arrow — Bench Press → Bench Press
reads like a bug.
Unfollow shows what stops applying and promises nothing about the result: the server decides what a movement falls back to once the link is cleared, and naming a result we have not read would be a guess about the user's own data.
Unfollow restores the viewer's own description. unadopt returns a movement
to its CATALOGUE name, not to whatever the caller had before they followed — so
a coach who published "Bankdrücken", followed somebody, then unfollowed was left
reading the stock name, their own words gone with nothing said. unfollowNamings
therefore re-adopts the viewer's own description for the same movement right
after (adopt moves the link and takes any description id, their own included).
Best-effort: the foreign link is already cleared, which is what was asked for,
so a failed restore leaves the catalogue name — recoverable in the exercise
drawer — rather than a half-followed state. The toast says which of the two
happened.
Both writes then re-read the feed instead of modelling the outcome: WHICH description is in force for a movement is the server's answer, and adopting one may retire another in a way a local patch cannot know about.
A single description can be taken on its own. Every row in the expanded list
carries a Use / In use toggle, so a coach who wants their colleague's word
for the bench press and nothing else does not have to open that exercise's
drawer. One row gets no confirm dialog — the before/after is already on the row
the button sits on, which is the whole thing the catalogue-level dialog exists
to show — but the toast still carries the Undo.
The success toast then carries Undo, which re-runs the opposite write over
the same rows. That is safe to replay because adopt moves a link and
unadopt is idempotent, so a partly-failed batch still lands where the user
asked. The dialog is deliberately not styled destructive: the whole point
is that this is reversible.
One way to take someone's words
Taking other people's namings in bulk lives only in Catalogues, per
publisher. The exercise list's selection bar used to carry a second "use shared
naming" action over selected rows; it is gone, along with soleAdoptableNaming
that fed it. Two mechanisms for one act is what made this feature hard to
explain — a coach thinks "I use Jonas's words", not "these 12 rows adopt" —
and ADR 0010 had already superseded the bulk action with following. The
per-exercise picker in the drawer stays: that is a different question (this
one movement, with the alternatives side by side).
Two verbs, and why there used to be eight
The unit is the PERSON. You follow someone — and then you read their wording for every movement you share, including whatever they publish next — or you name the movement yourself, which outranks everything (rung 1) and is the per-movement way out.
That is the whole vocabulary:
| Follow / Unfollow a publisher | read their wording, take their movements |
| Add a movement you don't have | POST /exercises/from_definition, so the naming can resolve at all |
| (plus, for your own catalogue) | move the reach of your descriptions, or delete them |
A naming you cannot read: the movement gap, and Add
inForceForCaller says which description would resolve for the caller — it
does not say the caller owns the movement, and the server sets it without
consulting that. So a follower can hold a catalogue of rows the server calls "in
force" that appear nowhere: measured on backdev, an athlete had 16 of 16 rows
in force and exactly one movement in their list.
Reporting the raw flag as "in use" therefore told them they were reading sixteen namings while fifteen resolved to nothing, and the card offered no way to fix it. Three things changed:
- The count is joined.
inUsecounts only rows the viewer owns an exercise for (resolveExercise), and the card names the remainder — "1 of 16 in use — 15 movements aren't in your list". Without the join nothing is claimed. - The row offers the verb. A row whose movement is missing carries Add instead of an In use / Not in use chip; a chip there would be false.
- The card offers the sweep. A followed publisher with missing movements gets "Add N movements to your list".
Why it has to be its own gesture. The subscription mints movements when it is
made and reconciles later publications against a watermark
(last_reconciled_at), so a movement deleted after that point never returns on
its own — and a mandatory subscription shows no re-follow to force it. Without
Add, deleting a received movement was a one-way door.
Add is deliberately not gated on canShareExercises: it is the inbound half,
and an athlete who may publish nothing still has to be able to take the movements
their coach named. addMovementsForNamings dedupes by definition (two publishers
naming one movement is one exercise) and counts the server's duplicate refusal as
done — the goal is "I have this movement", which is already true.
The endpoint is POST /exercises/from_definition, shipped as the backend's
answer to §2.6; the ordinary create takes a composition (base / equipment /
variations) that a description does not carry. Our request shape is a reading
of that answer, pinned in
exercises.test.ts rather than
live-probed — if the server names the field differently, that test is the one
place to change.
One verb, not one-and-a-half. Adding the publisher's movements to your own list used to be a second, off-by-default toggle on the subscription, on the argument that "I read your words" and "put your programme in my library" are different consents. Two facts retired it:
- Without it a follow only covers the intersection of two libraries. A coach publishing forty movements to a follower who owns twelve of them renames twelve things and does nothing for the other twenty-eight — while the card says "reading their 40 descriptions". The default follow was the one that mostly did not pay out, so the feature's first impression was that it does not work.
- Unfollow reverses it. The server removes the movements the follow minted that were never trained, and keeps the ones that were (live-verified: 187 → 188 → 187). So it is not the one-way bulk import the separate consent was guarding against — and where it is not reversible, it is because the follower has their own sets on the row, which is their data, not the publisher's.
Nobody follows a catalogue in order not to have its movements. What the toggle really carried was disclosure, and that stayed: an unfollowed card counts the movements a follow would add before it is pressed ("40 descriptions · 28 movements join your list"), and the one explanation above the cards states both halves and what unfollowing gives back.
It used to be eight verbs — per-description Use and stop using, per-movement Add and Add all, Catch up, Prefer these — each defensible on the screen it lived on, and together a concept nobody could hold: seven states a naming could be in for you, four routes it could arrive by, and two different ways back out depending on which. The tell was internal: we needed a four-row table to explain to ourselves why one row said "Reset" and the next said "Use my own".
What made the rest removable is that renaming is already the escape hatch. Every argument for a finer control was a variant of "but what if I want only this one word from them" — and the answer is that you type it, which lands on rung 1 and beats the subscription by the server's own rule.
What that cost, stated honestly: taking one person's word for one movement without following them now means writing it yourself, so you no longer ride along on their later corrections to it. That is a real loss for a rare case, traded for a concept that fits in one sentence.
Removed with it: the applicable / "movements you don't have" split (it only ever
described a constraint of adopt), the confirm dialog and its rename preview
(one write needs no preview of N), and the per-row Use/In-use toggle in the
catalogue drawer (now a read-only status chip).
Picking one person's wording for one movement
The drawer's "What others call this movement" list went with the rest, on the
grounds that a list with no verb is not a feature. It is back, with the verb
— ExerciseNamingAlternatives, collapsed, at the bottom of the Description
card.
What the removal missed is that following answers "whose vocabulary do I read", which is the right unit for a vocabulary and the wrong one for a single movement. A coach who named forty movements themselves and wants a colleague's word for the forty-first had nothing to press. Renaming is not the same move: it authors their own words, so they stop receiving the author's later corrections, and it cannot pick up a video or a cue written in a language they do not speak.
One adopt, and every part of it is measured
(tests/live/sharing/90-pick-one-movement.live.test.ts): it outranks the
viewer's own description, the exercise's name/detail follow it, and
unadopt — the Reset row already above it — puts their own back. The list
holds only foreign, named rows that are not in force, so it is empty for the
common case and renders nothing at all.
Removed: "Create it for your athletes too"
The exercise wizard used to offer a switch that sent createForSubUsers, minting
the new movement straight into the library of everyone the author manages.
It is gone. Pushing content into other people's libraries is the opposite of
the model the rest of this page describes: a coach publishes, and a follower
takes. Two mechanisms for the same outcome, one of them bypassing the follower's
consent, is one mechanism too many — and the pushed copy was the one nobody could
refuse or undo.
The reach picker is now the wizard's only sharing decision. createForSubUsers
survives on ExerciseNewDto because the endpoint still accepts it; no client
sends it. The create is a single request again, so the wizard holds until it
returns and the fire-and-forget close (with its toast and its Try again
payload) went with the switch.
Raising the private exercises is disclosed, not offered
Sharing a workout does not share the namings inside it, so a template can be published with its coaching instructions reaching nobody. The access card used to solve that with a second switch — share them too, yes/no — pre-selected yes.
That switch is gone; the row that replaces it only informs. Withheld, they
leave the recipient with the catalogue name and none of the instructions the
workout was written around, which is not a template anyone asked to receive — so
raising them is part of sharing rather than a choice beside it. The row states
how much is about to travel, at the moment the level is chosen, and the author's
control over it is the level itself. namingReach is therefore { count }
alone: no include, no onIncludeChange.
It counts EXERCISES, and says what actually travels. The row used to be
labelled Exercise notes, which named a thing the product does not otherwise
talk about — an author thinks in movements they added, not in a separate noun for
the text attached to one. It now reads "You used 3 private exercises in this
workout." The second sentence carries the precision the label gave up: what is
shared is their names and instructions, never the exercises themselves. An
exercise is not shareable at all (ExerciseMappedReturnDto carries no
visibility, see Exercise namings),
so "your exercises are shared" would promise the recipient a row they never
receive — the same trap the creation wizard's wording avoids.
They are still raised only after the template saves, so a failed save never
widens who can read the author's writing, and a partial failure is reported with
the template's own success (# of your exercises stayed private).
Walk and Angular walk are never shown, descriptions included
filterVisibleExercises keeps the two gait/calibration bases out of the
exercises store, and that used to be enough. Descriptions arrive on their own
feed (GET /exercise_descriptions) and are keyed by definition, not base —
so a published Walk description walked straight back into a publisher card and
into the viewer's own catalogue table.
isHiddenExerciseDefinition / filterVisibleByDefinition
(packages/core/src/training/exercise-visibility.ts) close that door, applied
once in loadCatalogues at the boundary rather than at each render, so the rows
are absent from the counts too.
The definition ids are global, measured: two independent backdev accounts
return different per-user exerciseIDs for these movements and the same
exerciseDefinitionID — 72B4EA57… under base 8CDAD23F… (Walk) and
0A391709… under base 2EAEDFE3… (Angular walk). That is what makes them
constants beside the base ids rather than a per-account lookup.
Why a description is not in use
A followed publisher's row that shows no chip was being read as broken. It usually is not: the viewer named that movement themselves, or somebody ranked above the publisher did — the ladder working exactly as designed, and silence cannot say so.
Every row carries one of two chips — In use or Not in use — and no verb. Two, not three: a row that names its rival ("Your own", "Ana instead") answers a question nobody asked at the row, and the answer is the same sentence every time. It is stated once, above the cards: "A description shows as not in use when something outranks it: your own name for that movement, or someone you followed more recently."
Read-only, deliberately. A button per row is what the simplification removed — forty controls doing one person's job, in a list you are still deciding about — and the per-movement exception lives in the exercise drawer, where the movement is and the consequence is visible.
Unfollowing does clear it, measured —
tests/live/sharing/91-unfollow-clears-in-force.live.test.ts: while following,
3 of 3 rows carry inForceForCaller: true / inForceVia: "subscription"; after
DELETE /subscriptions/{ownerUserID}, 3 of 3 are false. So a chip that
survives an unfollow is not a stale subscription — it is a row in force by
another route, and the only one unfollowing leaves standing is an explicit
adopt, whose way out is Reset in that movement's drawer.
What the catalogue surface can NOT do, and says so
Push — solved, see the section above. What follows is the state before the subscription endpoints shipped, kept because it explains why the surface is shaped the way it is.
A coach could not make their athletes read their descriptions. That is rung 2 of the
ladder and it needs the server — the concrete endpoint shape, inheritance rule
and reassign case are written up as the one blocking ask in
handoffs/SHARING_SERVER_HANDOFF.md §2.3: adopt writes one link for the
caller, and a per-athlete loop is 30 × 200 = 6 000 writes against an endpoint
that does not exist. So the own segment states it in one sentence under the
table — "Sharing your names doesn't apply them to your athletes — they have to
follow your catalogue. Setting it for everyone you manage isn't available
yet." — and offers the one thing that IS real: moving your own namings'
reach in bulk, which is what puts a whole vocabulary within reach of the
people who may follow it.
Everything else on this page is unaffected. A catalogue is a view over the same descriptions; there is no second entity, no second store, and no client state that can disagree with the server.
Organisation is a reach, not yet a delivery route
Worth stating plainly, because it is the gap the whole feature reads around.
Creating an exercise at Organisation (sharedInternal) publishes the
naming immediately
(namingPublishDto → POST /exercise_descriptions) — but it publishes nothing
that makes a colleague see it:
- an exercise is not shareable at all (
ExerciseMappedReturnDtocarries no visibility), so nothing appears in anyone else's exercise list; - their exercise page only ever asks about the definitions they own
(
namingDefinitionIDs(exercises)), so a definition they have no exercise for is never even requested; - and a naming for a movement they do not own could not be adopted anyway (§2.6).
So an internal naming reaches a colleague through exactly two routes: a shared template that references the movement (stage 3 of the naming chain, plus the author's cue), or the catalogue surface above, where they follow you deliberately. Rungs 2 and 3 of the ladder are what would make it arrive without anyone acting, and neither exists yet.
Where the code lives
| Concern | Module |
|---|---|
| Ownership predicates | packages/core/src/sharing/{scope,template-scope,description-scope}.ts |
| Naming selection (own vs. alternatives) | apps/portal/src/app/dashboard/exercises/exercise-naming.ts |
| Publish-on-create decision | packages/core/src/sharing/naming-publish.ts |
| Naming chain | packages/core/src/exercises/item-display-name.ts + useWorkoutItemDisplayName / useWorkoutItemDetail |
| Description cache | packages/core/src/exercise-descriptions/store.ts (cleared by logout()) |
| The video on a description | packages/core/src/sharing/description-video.ts + packages/ui/src/sharing/video-embed.tsx |
| Guidance during a live training | packages/core/src/training/exercise-guidance.ts + apps/tracking/src/app/workouts/today/{use-exercise-guidance.ts,exercise-guidance-pill.tsx,ExerciseGuidanceSheet.tsx} |
| Wire layer | packages/core/src/api/{exercise-descriptions,workouts}.ts |
| Search ranking | packages/core/src/workouts/template-search.ts |
| Tag placement | workoutTags() in packages/core/src/workouts/derive.ts |
| Shared UI | packages/ui/src/sharing/{visibility,sharing-line,provenance-badge,shared-template-detail,template-detail-body,author-section,author-cue,shared-browse-segments}.tsx |
| Feed partition (both apps) | packages/core/src/sharing/template-library.ts |
| Catalogues (grouping, follow state) | packages/core/src/sharing/catalogue.ts |
| Catalogue feed + sequential writes | packages/core/src/sharing/{catalogue-store,use-catalogues}.ts |
| Catalogue UI (portal only) | packages/ui/src/sharing/{catalogue-browser,catalogue-list}.tsx |
| The author's cue, rendered | packages/ui/src/sharing/author-cue.tsx |
| Follow / copy-to-edit / preview | apps/portal/src/app/dashboard/planner/{following-template-notice,template-preview-view}.tsx |
| Shared template browse | apps/portal/src/components/template-browser.tsx (planner picker + Explore drawer) |
| Definition → base resolution | packages/core/src/exercises/{presentation,presentation-hooks}.ts, packages/core/src/exercise-definitions/store.ts |
| Portal surfaces | apps/portal/src/app/dashboard/workouts/, .../planner/foreign-template-plan-dialog.tsx, .../exercises/exercise-naming-section.tsx |
| Tracking Library | apps/tracking/src/app/workouts/today/library/ |
| Exercise creation wizard (both apps) | packages/ui/src/exercise-create/exercise-creation-drawer.tsx |
| Adopting a foreign exercise | packages/core/src/exercises/adopt.ts + apps/tracking/src/app/workouts/today/use-exercise-adoption.ts |
| Resolving an item's exercise by definition | packages/core/src/exercises/item-exercise.ts |
| Start-time exercise resolve | apps/tracking/src/app/workouts/today/resolve-user-exercises.ts + use-training-sessions.ts |
| Live server-contract probe | tests/live/sharing/10-foreign-definition-mint.live.test.ts (npm run test:live:auth) |
| Debug surfaces | apps/tracking/src/app/debug/sharing/, apps/portal/src/app/dashboard/debug/sharing-{debug.tsx,mock-api.ts} |
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /workout_definitions/templates | The one templates feed — own and shared. Flat, unpaginated. |
| POST | /workout_definitions/{id}/copy | Copy a template into the caller's own. Id only, no body. |
| GET | /exercise_descriptions?exerciseDefinitionIDs=a,b,c | Batch read. Comma-joined — the client's QueryValue has no array form. Chunked at 50 by the store. |
| GET | /exercise_descriptions | Discovery — every naming the caller may see, with ownerUserID / ownerUserName. Unbounded and unfiltered; visibility rule unconfirmed. |
| POST | /exercises/from_definition | Create the caller's exercise for a movement they only know by definition. Idempotent; lands on the id passed. |
| POST | /exercise_descriptions/{id}/adopt | Take one naming — LINKS the caller's own exercise row, creates nothing. |
| POST | /exercise_descriptions/{id}/unadopt | Give it back. Clears only the caller's link. |
| POST | /exercise_descriptions | Publish a naming. |
| PUT | /exercise_descriptions/{id} | Replace one (also the only un-share: set visibility back to Private). |
| DELETE | /exercise_descriptions/{id} | Delete one. Not surfaced — cascade semantics unknown. |
Related: requirements — tracking (TRK-LIB) ·
requirements — portal (POR-WKT, POR-EXER) ·
tracking app features ·
error handling · i18n