Skip to main content

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:

DimensionFieldAnswers
OwnershipsharingUserID vs. the viewerMay I change this?
ReachsharingVisibilityWho 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 before sharingUserID existed, 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:

LevelGlyphWord
sharedPrivateLockFillIcon (lock.fill)Private
sharedInternalBuilding2FillIcon (building.2.fill)Organisation
sharedPublicGlobeIcon (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 sharedInternal against 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 the ProvenanceBadge sentences 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:

  1. item.exerciseName — the naming in force for the reader, resolved by the server (see the measurement below). null whenever the reader has no naming of their own, so it falls through rather than blanking the row.
  2. exerciseDisplayName(exercise) — the viewer's own rename → their catalogue entry → the localized stock text content.
  3. the published exercise description for exerciseDefinitionID — someone's shared naming, from the enrichment cache.
  4. 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 situationexerciseName deliveredexerciseDescriptionID
author, on their own templateZZ-Author-Namingthe author's row
reader with their own namingZZ-Reader-Namingthe reader's row
reader without onenullabsent

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:

  1. my own naming — server, stage 1
  2. (my organisation's naming — does not exist; see the catalogue handoff)
  3. (a catalogue I follow — does not exist; same)
  4. my own exercise / the stock catalogue name — client, stage 2
  5. whatever anyone published for this definition — client, stage 3, and the only rung on which a foreign word is ever shown
  6. 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. reachRank returns null for 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. withCarriedNotes returns 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:

  • useWorkoutItemDisplayName fetches lazily; useWorkoutItemDetail does not. Pair them on the same row, and pass BOTH the same exercise. 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 exerciseDefinitionID alone, so an entry for a stock definition is whatever some other user published for it. Warming the cache for one shared list (requestExerciseDescriptions in the Library) would otherwise relabel the viewer's OWN rows for the rest of the session. Both workoutItemDisplayName and workoutItemDetail therefore skip the description once the viewer's own exercise resolves.
  • The description cache is enrichment, not view data. It has no store-kit status, never reaches the error presenter, and caches "nobody described this" as an explicit null so 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 (startLibraryTemplate re-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 answerscopyOnlyThe dialog may offer
313falsethe copy and "edit for everyone"
403 + reason: "read_only_share_requires_copy"truethe 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:

AccessorAnswers
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:

  • ensureItemExerciseBases reads item.exercise?.exerciseBaseID, so no base is loaded — no animation, no technique data.
  • seedFocusMetricID therefore 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.ts asserted that the adoption produced an exercise, and only log()ged landedOnTheForeignDefinition. 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. useTrackingSourceSetting is 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. seedFocusMetricID could 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 builtWhy
Adopting someone's exercise descriptionThe 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 namingdeleteExerciseDescription 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 listSuperseded 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 descriptionsWould 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 templatesTemplates 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 builderSlotCard 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.

AppClick pathFixtures
TrackingSidebar → 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 templatesapps/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 call useWorkoutItemDisplayName, 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.ts run 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:

OriginMeaning
cataloguenobody published a naming — the stock name stands
minethe viewer's own published naming
adoptedsomeone 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.

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 same namingPublishDto the 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. VideoEmbedPlayer draws 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 viewerWhat the row saysWhat the exercise says
they adopted itisActive: truename/detail carry the text
they follow its publisherisActive: **false** — nothing at allname/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:

  1. the viewer's own naming;
  2. one they explicitly adopted;
  3. one from a publisher they follow — earliest in subscription order, which is precedence order;
  4. 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:

inForceViaMeansThe way out
ownthe reader wrote itrename
adoptedthey took it deliberatelyunadopt
subscriptionthey follow its publisherunfollow, or rename this one
coachit is their coach's wordingrename 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 status fall 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 settling flag 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 adoptfalse
after the adopttrue
after the unadoptfalse

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.ts now marks exactly one, and a test holds it there.
  • The row in force is not an "alternative". namingAlternatives drops 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, and ownNaming reads 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's exercisesByDefinition): 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_descriptions returns internal rows only from the caller's accepted relations, so a sharedInternal row reaching the viewer is the server saying the two are directly connected. Only that literal level counts — the deliberate opposite of descriptionScope'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 sharedInternal hint 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:

SubscriptionsMaterialised (today)
Followed?the subscription existssome of their rows are isActive
Partial followcannot happen — links are derivedreal, and Catch up is what fixes it
Second press on a followed cardPrefer these — moves precedenceCatch up
Order of the followed listthe server's, as it arrivedusefulness
Set by the account you belong tomandatory — stated, and no button at alldoes not exist
A coach's own wording reaches their athletesyes, unless the athlete named it themselvesno — 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 publisherread their wording, take their movements
Add a movement you don't havePOST /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. inUse counts 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 (ExerciseMappedReturnDto carries 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​

ConcernModule
Ownership predicatespackages/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 decisionpackages/core/src/sharing/naming-publish.ts
Naming chainpackages/core/src/exercises/item-display-name.ts + useWorkoutItemDisplayName / useWorkoutItemDetail
Description cachepackages/core/src/exercise-descriptions/store.ts (cleared by logout())
The video on a descriptionpackages/core/src/sharing/description-video.ts + packages/ui/src/sharing/video-embed.tsx
Guidance during a live trainingpackages/core/src/training/exercise-guidance.ts + apps/tracking/src/app/workouts/today/{use-exercise-guidance.ts,exercise-guidance-pill.tsx,ExerciseGuidanceSheet.tsx}
Wire layerpackages/core/src/api/{exercise-descriptions,workouts}.ts
Search rankingpackages/core/src/workouts/template-search.ts
Tag placementworkoutTags() in packages/core/src/workouts/derive.ts
Shared UIpackages/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 writespackages/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, renderedpackages/ui/src/sharing/author-cue.tsx
Follow / copy-to-edit / previewapps/portal/src/app/dashboard/planner/{following-template-notice,template-preview-view}.tsx
Shared template browseapps/portal/src/components/template-browser.tsx (planner picker + Explore drawer)
Definition → base resolutionpackages/core/src/exercises/{presentation,presentation-hooks}.ts, packages/core/src/exercise-definitions/store.ts
Portal surfacesapps/portal/src/app/dashboard/workouts/, .../planner/foreign-template-plan-dialog.tsx, .../exercises/exercise-naming-section.tsx
Tracking Libraryapps/tracking/src/app/workouts/today/library/
Exercise creation wizard (both apps)packages/ui/src/exercise-create/exercise-creation-drawer.tsx
Adopting a foreign exercisepackages/core/src/exercises/adopt.ts + apps/tracking/src/app/workouts/today/use-exercise-adoption.ts
Resolving an item's exercise by definitionpackages/core/src/exercises/item-exercise.ts
Start-time exercise resolveapps/tracking/src/app/workouts/today/resolve-user-exercises.ts + use-training-sessions.ts
Live server-contract probetests/live/sharing/10-foreign-definition-mint.live.test.ts (npm run test:live:auth)
Debug surfacesapps/tracking/src/app/debug/sharing/, apps/portal/src/app/dashboard/debug/sharing-{debug.tsx,mock-api.ts}

Endpoints​

MethodPathPurpose
GET/workout_definitions/templatesThe one templates feed — own and shared. Flat, unpaginated.
POST/workout_definitions/{id}/copyCopy a template into the caller's own. Id only, no body.
GET/exercise_descriptions?exerciseDefinitionIDs=a,b,cBatch read. Comma-joined — the client's QueryValue has no array form. Chunked at 50 by the store.
GET/exercise_descriptionsDiscovery — every naming the caller may see, with ownerUserID / ownerUserName. Unbounded and unfiltered; visibility rule unconfirmed.
POST/exercises/from_definitionCreate the caller's exercise for a movement they only know by definition. Idempotent; lands on the id passed.
POST/exercise_descriptions/{id}/adoptTake one naming — LINKS the caller's own exercise row, creates nothing.
POST/exercise_descriptions/{id}/unadoptGive it back. Clears only the caller's link.
POST/exercise_descriptionsPublish 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