Exercises — behavior spec
Three row shapes in exercises
| Shape | organization_id | forked_from_id | Description |
|---|---|---|---|
| Canonical | NULL | NULL | Platform-seeded, slug-unique. Edited only by platform admins or seed jobs. |
| Org-custom | <org id> | NULL | Created by a coach via POST /exercises. Mutable via PATCH/DELETE. |
| Org-fork | <org id> | <canonical id> | Private copy created on canonical customization. Reused for later edits in that organization. |
The legacy override row in exercise_org_overrides lives separately: one per (org, canonical exercise) with a JSONB diff applied at read time. Those rows are preserved for existing references; new canonical customizations return an independent organization-owned exercise. See ownership.
Override allowlist
Defined in libs/shared/src/lib/exercise-overrides.ts (search results imply this — TODO: verify exact path) and enforced by exercise-overrides.service.ts:stripToAllowlist. The merged response carries customizedFields[] listing exactly which keys came from the override.
Overridable fields include: name, description, athleteNotes, category, kind, movementPattern, primaryMuscles, secondaryMuscles, equipment, aliases, difficulty, discipline, cues, commonFaults, scalingOptions, videoUrl, thumbnailUrl.
Fields NOT overridable: slug, id, embedding, searchTsv, video-moderation cols (videoStatus / vote counts), source/license metadata.
Effective-exercise read
mergeExerciseWithOverride(canonical, override) returns:
name,cues, etc. picked from the override when present, falling back to the canonical row.isOrgCustom= true iff the row is an org-custom (no override merge happens for these — they’re already org-owned).isCustomizedByOrg= true iff a canonical row has a non-empty override.customizedFields= sorted list of overridden keys (UI uses this to badge changed fields).
Effective movement media
Demo media is org-scoped. One API helper, loadExerciseMedia(db, orgId, ids) in apps/api/src/exercises/exercise-media.ts, decides what an org’s members see for an exercise; every read that serializes movements goes through it (two queries per payload, never per movement):
| Referenced row | Effective media | source |
|---|---|---|
| Org-custom (own org, not a fork) | its own video_url | org |
| Library row X | the org’s live fork of X when the fork’s video_status = 'manual' and video_url is set | org |
| Library row X | otherwise X’s video_url | library |
| Fork F of X, referenced directly | F’s video_url when manual and set; otherwise X’s live video_url | org / library |
null when the chosen url is null. Published workouts keep pointing at the library id, so an org override reaches them only through this lookup. Clearing an override sets the fork’s status to auto, which falls back to the current library video, not the copy taken when the fork was made. An explicit video write on a fork (upsertOverride with videoUrl, PATCH /exercises/:forkId, exercises_set_media) always sets manual (url) or auto (null), even when the url equals the copied one.
A movement can also carry its own workout_movements.media_url (per occurrence). It wins over the exercise media with source: 'workout'.
Responses:
- Section movements (member reads via
common/workout-section.ts, coachGET /workouts/:id, MCPworkouts_get) carrymediaUrl(stored per-occurrence value) andmedia: { url, kind, source } | null— what to play.exercise.videoUrlis the effective exercise-level url, so clients that predatemediastill get the org override. GET /organizations/:orgId/exercises/library/:idreturns the effectivevideoUrlplusmediaSource: 'org' | 'library' | null.kindcomes frommediaKindOf(libs/shared/src/lib/media-kind.ts):videofor YouTube (incl. Shorts), Vimeo and direct media files, otherwiseweb— a page clients open, never embed.
Every write accepts media only as an absolute https:// URL of at most 2048 characters; null clears.
Invariants
- Slug unique only among canonicals. Partial unique index:
WHERE slug IS NOT NULL AND organization_id IS NULL. Org rows intentionally reuse the slug of the canonical they specialize. - Override targets must be canonical.
upsertOverridecheckscanonical.organizationId === nulland rejects PATCH-via-override on org-custom rows (4xx with “PATCH org-custom rows directly”). - Movement workouts validation accepts canonical OR this org. See workouts/behavior.md.
- Difficulty 1..5. DB CHECK
exercises_difficulty_range_chk. - Movement pattern enum at DB level. CHECK accepts
'squat','hinge','push','pull','carry','locomotion','gymnastics','oly','conditioning','mobility','other'. - Video status enum at DB level. CHECK accepts
'auto','verified','demoted','manual'. searchTsvis a STORED generated column. Wraps a custom IMMUTABLE functionpublic.fk_array_to_text(introduced in migration 0038) becausearray_to_stringis STABLE in Postgres.
Golden path — coach customizes a canonical row
- Coach opens Movements at
/dashboard/movements(its own sidebar destination, next to Workouts). - Web calls
GET /organizations/:orgId/exercises/library?source=all. - Coach picks “Back Squat” (canonical). Detail sheet loads via
GET /exercises/library/:id— merged effective view, video first, no tabs. - Coach clicks “Customize”, edits the Hebrew name, and separately uses “Replace video” to swap the video URL.
- Web calls
PUT /exercises/:id/overridewith{ overrides: { name: 'סקוואט אחורי' } }(name/cues/faults/scaling/notes/description). exerciseOverridePayloadSchema.parsestrips non-allowlist keys. The service creates or reuses a private copy and returns its ID. The sheet retains that ID and subsequent edits, including video replacement, usePATCH /exercises/:forkId.- The library can show both the shared source and the private copy. Selection search prefers the organization’s fork.
Golden path — coach creates an org-custom
- Coach has a niche movement not in the canonical library.
POST /exercises{ name:'Bottoms-up Kettlebell Carry', category:'cardio', equipment:'kettlebell' }.- Row inserted with
organization_id=<org>. ThesplitToArrayhelper accepts comma/semicolon-separated strings forequipmentandaliases. - Workouts can now reference it.
Golden path — spotter searches
- Agent calls
GET /exercises/search?q=barbell hip thrust&mode=hybrid&orgId=<org>. ExerciseSearchServiceembeds the query (cached by hash) via Voyagevoyage-multilingual-2.- SQL runs three CTEs: full-text
ts_rankonsearch_tsv,pg_trgmsimilarity onname, vector cosine onembedding. Each contributes ranks up toPROBE_LIMIT=50. - Reciprocal Rank Fusion combines:
rrf_score = Σ 1 / (k + rank_i)withk=60. - The organization’s explicit fork replaces its canonical source in selection results.
- Returns up to
params.limitresults, each tagged withsource: 'canonical' | 'org' | 'customized'.
Edge cases & error states
| Trigger | Handling |
|---|---|
| Override on an org-custom row | 400 “Overrides can only target canonical exercises”. |
| Reset override on an org-custom row | 400 “Cannot reset an org-custom exercise; delete it instead”. |
| Override payload with unknown keys | Silently stripped to allowlist. |
Search without orgId query param | Globals only; no org-rows merged. |
Search with orgId for a non-member caller | Silently downgraded to globals only (no leak). |
| Difficulty outside 1..5 | DB CHECK rejects. |
| Slug collision on canonical seed | Partial unique index rejects the insert. |
Search with mode='semantic' and embedding service disabled | Falls back to lexical only. |
Search with mode='lexical' and empty q | TODO: verify — code says q.length > 0 is required for lexical. |
| Merge a pair whose loser is already soft-deleted | already_merged — no-op, writes nothing (idempotent re-run). |
| Merge a pair where either row is org-owned | error — “both rows must be canonical (organization_id IS NULL)”. |
Merge with winnerId === loserId | error — rejected before any DB read. |
| A loser’s user already holds an active PR on the winner | The loser’s personal_records row is retired (soft-deleted), not repointed — the partial unique index on (userId, exerciseId) forces the choice; the winner’s existing PR wins. |
| A loser’s org already holds an override on the winner | The loser’s exercise_org_overrides row is dropped (hard delete), not moved — the winner’s existing override wins. |
GET .../exercises/:id on a soft-deleted row with no alias trail to an active winner | 404, same as before this feature. |
Video moderation loop (TKN-318)
The demo video on a canonical row is never picked at seed time and left unreviewed. Three platform-admin BullMQ jobs (same queue/processor pattern as seed/enrich) plus a three-endpoint verify/demote/report loop keep it honest:
- Video discovery pass (
canonical-video-discovery.service.ts, queueexercises-canonical-video-discovery) — ranking only ever judges whatever is already invideo_candidates, and on most movements that list is seed-time junk or empty. Movements referenced by anyworkout_movementsrow are searched first, then the rest, stable by slug. By default, skips any movement that already has a candidate carryingsource: 'youtube-search'(the coverage marker written below) — passforce: trueto re-search a covered movement. For each remaining candidate row, runs YouTube Data API v3search.list(query templates"<name> demo", and — atqueriesPerMovement2 or 3 —"how to <name>"and the first alias; defaultqueriesPerMovementis 1, plus, whenVIDEO_TRUSTED_CHANNELS(video-trusted-channels.ts, empty by default, product-owner-filled) is non-empty, one extra"<name> <channel>"query) thenvideos.listfor duration/view/like counts, and appends new candidates (score: 0,modelScoreunset,source: 'youtube-search',discoveredAtset) — deduped against both existing candidate ids andvideo_rejected_ids, filtered to 15s–4min, never dropping anything already there. Candidates are always persisted, on every run — including a dry run. A candidate changes nothing a member sees, only what ranking has to choose from, so there’s nothing to protect by withholding the write.dryRunhere means only “don’t let a ranking job chained viaPOST .../video/refresh’sthenRankwrite its picks” — a plainPOST .../video/discovercall ignoresdryRunentirely. Goes through theVideoSearchProviderinterface (video-search-provider.ts, currently a plain-fetchYouTube client — nogoogleapisSDK). No-ops cleanly (logs once, reportsdisabled) whenYOUTUBE_API_KEYis unset, same contract asFAL_KEYinexercise-thumbnail.service.ts.search.listcosts 100 quota units against the default 10,000/day project quota — at thequeriesPerMovementdefault of 1,maxSearches(default 90) leaves headroom under the ~100/day ceiling, so a plain daily run covers ~90 new movements. On hittingmaxSearches, or on the provider signaling its quota is spent (HTTP 429, or 403 namingquotaExceeded— surfaced asVideoSearchQuotaExceededError), the job stops cleanly (never throws) and returnscovered(ids searched this run),remaining(ids not yet covered, in order — feed back asidsto resume),searchesUsed, andquotaExhausted(true only for the provider-quota case). SupportsdryRun,force,limit,slugs/ids,queriesPerMovement. - Video ranking pass (
canonical-video-ranking.service.ts, queueexercises-canonical-video-ranking) — for each canonical row (organizationId IS NULL, not deleted,videoStatus <> 'manual'), scores everyvideo_candidatesentry — the union of whatever the seed pipeline wrote and whatever discovery has since appended — against the movement name/aliases via the model (title match, is it a demo of the exact movement, is it short, does the channel read as coaching;VIDEO_TRUSTED_ CHANNELSis passed as a soft hint, never a hard filter). The score is persisted on the candidate’smodelScorefield (0..1) — the legacyscorefield (an unrelated, unbounded popularity scale some rows were seeded with offline) is overwritten with the same value so it stops diverging, but the pick rule (pickBestCandidate) readsmodelScoreonly; a candidate the model hasn’t scored this run or any prior one counts as 0, never falls back to legacyscore. The highest-modelScorecandidate at or aboveVIDEO_SCORE_THRESHOLD(0.6) becomesvideoUrl; below threshold,videoUrlis cleared tonull.video_statusis never touched here — it stays'auto'. Manual rows are excluded by the query filter and a defense-in-depth JS filter (excludeManualRows). Movements referenced by anyworkout_movementsrow are ranked first. SupportsdryRun(report only, no writes),limit, andslugs/idsfilters.POST /admin/exercises/canonical/video/refreshchains discover → rank for the same filter in one enqueue (the discovery processor enqueues the ranking job on completion whenthenRank: true); the chained rank job’s owndryRunis discovery’sdryRunpassed through, so a dry-run refresh still persists discovered candidates but scores/picks in report-only mode. - Cues pass (
canonical-cues.service.ts, queueexercises-canonical-cues) — generates/normalizescues(3),common_faults(2),scaling_options(2) in English. Mirrors the seed contract: withoutrebuild, only rows short of the required count are touched (needsCuesRebuild); withrebuild: true, every candidate row is regenerated, including already-complete ones. SupportsdryRun,limit,slugs/ids. - Verify (
POST /admin/exercises/canonical/:id/video/verify, platform admin) — setsvideoStatus='verified'. Leaves the candidate list and activevideoUrluntouched. - Demote (
POST /admin/exercises/canonical/:id/video/demote, platform admin) — rejects the current video: adds its candidate id tovideo_rejected_ids, falls through to the next scored candidate at or above threshold (via the samepickBestCandidaterule the ranking pass uses), or clearsvideoUrltonullif none qualifies. SetsvideoStatus='demoted'. - Report (
POST /organizations/:orgId/exercises/:id/video/report, any org member, library rows and the caller’s own org rows only) — records oneexercise_video_reportsrow per (exercise, user, current url); a repeat report by the same user changes nothing.video_negative_votesis the number of distinct reporters of the current url. AtVIDEO_REPORT_DEMOTE_THRESHOLD(3) reporters, on a row whose status isn’t already'verified'or'manual', auto-demotes (same fall-through as the admin demote endpoint) and resets both vote counters to 0. A library row additionally needs reporters from at leastLIBRARY_DEMOTE_MIN_ORGS(2) organizations, so one org can never change the shared video for everyone else. Reports on an org-owned row (custom or fork) only ever come from that org.
videoStatus and videoUrl were already exposed on the effective-exercise
merge (canonicalExerciseSchema → effectiveExerciseSchema) before this
loop existed — no read-side schema change was needed. A client renders
“verified” / “unverified” / “none” from videoStatus + whether videoUrl is
non-null.
Full prod procedure: runbook → Movements library update.
Duplicate detection and merge (TKN-328)
The canonical library can hold the same movement more than once (e.g.
“Pull-Up” and “Strict Pull-Up” each naming the other as an alias) — slug
dedup at seed time can’t catch this, since the two rows have different
slugs. A platform-admin dry-run report plus an explicit merge endpoint let a
human find and fold these together. No merged_into_id column exists —
the merge trail is recorded the only way available without a schema change:
the loser’s name and aliases are appended to the winner’s aliases before the
loser is soft-deleted.
-
Detection pass (
canonical-duplicates.service.ts, queueexercises-canonical-duplicates) — always a dry run, never writes. Compares every canonical row (organizationId IS NULL, not deleted) against every other on two independent signals:- Alias/name match — normalize each row’s name and every alias (lowercase, strip spaces/hyphens/apostrophes) and group rows sharing a normalized token. This is how the “Pull-Up” / “Strict Pull-Up” example is caught: “Strict Pull-Up” is listed as an alias on “Pull-Up” and vice versa, so the normalized tokens collide.
- Embedding similarity — a raw-SQL self-join on the pgvector
embeddingcolumn (1 - (a.embedding <=> b.embedding) >= DUPLICATE_EMBEDDING_THRESHOLD, starting at0.92), gated on both rows sharing the samemovement_patternso semantically-close-but-different movements in different patterns never pair.
Both signals feed one pairing pass;
alias_matchevidence wins overembeddingfor the same pair (an operator-maintained alias is stronger evidence than a distance threshold). Every candidate pair is then checked againstVARIANT_QUALIFIERS— a small constant list of name fragments (kipping,butterfly,ring,l-,wide grip,close grip,weighted,assisted,banded) that mark a movement as a distinct variant rather than a naming difference for the same movement. A pair is excluded when only one side carries a qualifier (e.g. “Pull-Up” vs. “Kipping Pull-Up” — different skills, never merge); sharing the same qualifier, or neither carrying one, is not a conflict.strictis deliberately absent from the list — “Strict Pull-Up” already IS the default/canonical form of “Pull-Up”, not a variant of it, and that pair is the actual prod duplicate this feature exists to catch.Each reported pair carries both rows (id, slug, name, aliases, and referencing counts across
workout_movements,workout_set_results,personal_records,goals,exercise_org_overrides), the evidence, and asuggestedWinnerId— moreworkout_movementsreferences first, then the shorter/more generic name, then the older row. -
Merge endpoint (
canonical-merge.service.ts,POST /admin/exercises/canonical/merge,{ pairs: [{ winnerId, loserId }], dryRun }) — validates both rows are canonical and distinct; a loser already soft-deleted is treated asalready_merged(idempotent no-op, no writes). Otherwise, in one transaction per pair:- Repoints every table with an
exercise_idFK onto the winner:workout_movements,workout_set_results,goalsunconditionally;personal_recordsconditionally — it carries a partial unique index on(userId, exerciseId) WHERE deletedAt IS NULL, so a loser PR whose user already holds an active winner PR is retired (soft-deleted) instead of repointed, mirroring the override rule below. Historical (already soft-deleted) loser PR rows carry no such conflict and are always repointed. - Moves
exercise_org_overridesrows from loser to winner where the winner has no override for that org (its own unique index is(organizationId, exerciseId)); where the winner already has one, the winner’s is kept and the loser’s is dropped (hard delete — it’s a JSONB diff, not audit history). - Appends the loser’s name and aliases to the winner’s aliases, deduped case-insensitively; keeps the winner’s existing aliases and casing untouched.
- Keeps the winner’s video unless the winner has none and the loser does,
in which case the loser’s
videoUrlis adopted. - Soft-deletes the loser (
deletedAt). - Enqueues the winner on the
exercise-enrichmentqueue (the same best-effort, non-blocking re-embed pathExercisesServiceuses after a name/category change) so its embedding reflects the newly-merged aliases.
dryRun: truereturns the same per-table plan (viaCOUNT, notUPDATE ... RETURNING) and writes nothing. - Repoints every table with an
-
Read-side resolution —
GET /organizations/:orgId/exercises/:idandGET /organizations/:orgId/exercises/library/:idused to 404 on a soft-deleted id. They now fall back toresolveMergedWinner: if the requested id is a soft-deleted canonical row, look for another active canonical row whose aliases contain that row’s name (case-insensitively) — the trail the merge step above just wrote — and serve that winner instead. A soft-deleted row with no such trail (never merged, or deleted before this feature existed) still 404s.
Full prod procedure — dedup runs before the video/cues passes so they never spend model or quota budget on a row about to be retired: runbook → Movements library update.
Side effects
- Canonical seed job (
canonical-seed.processor.ts) — pulls JSON from R2, upserts canonical rows, fires Voyage embeddings, writesembedding_hashto gate re-embed. - Enrichment job (
canonical-enrich.processor.ts) — Anthropic Haiku writes Hebrew + Russian aliases; bumps embedding when aliases change. - Video discovery job (
canonical-video-discovery.processor.ts) — YouTube search, appendsvideo_candidates; can chain into ranking. See “Video moderation loop” above. - Video ranking job (
canonical-video-ranking.processor.ts) — scoresvideo_candidates, picks/clearsvideoUrl. See “Video moderation loop” above. - Cues job (
canonical-cues.processor.ts) — writescues/commonFaults/scalingOptions. See “Video moderation loop” above. - Duplicate detection job (
canonical-duplicates.processor.ts) — read-only, always a dry run. See “Duplicate detection and merge” above. - Merge (
canonical-merge.service.ts, synchronous admin endpoint, not a queued job) — repoints referencing rows, soft-deletes the loser, enqueues the winner for re-embedding. See “Duplicate detection and merge” above. - Search embedding cache —
ai-cache.service.tskeyed byinputHash(model, q). The query embedding is reused across users.
Permissions
| Action | Required role |
|---|---|
| List, get, search exercises | Any member of the org (or none — search accepts no orgId) |
| Create / update / delete org-custom | owner, admin, coach (requireCoachAccess) |
| Upsert / reset override | owner, admin, coach |
| Canonical seed / enrich / video-discover / video-refresh / video-rank / cues / duplicates / merge / job listing / video verify / video demote | Platform admin only (@PlatformAdmin() guard on AdminCanonicalController) |
| Report a video as bad | Any member of the org (requireMembership, no role restriction) |