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 (rare) | <org id> | <canonical id> | Hard fork of a canonical row. Schema supports it; no current write surface — the override system is preferred. |
The override row in exercise_org_overrides lives separately: one per (org, canonical exercise) with a JSONB diff applied at read time.
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).
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 the library at
/dashboard/workouts→ exercises tab (TODO: verify route — search hitsexercise-library-browser.tsx). - Web calls
GET /organizations/:orgId/exercises/library?source=all. - Coach picks “Back Squat” (canonical). Detail loads via
GET /exercises/library/:id— merged effective view. - Coach edits the Hebrew name + swaps video URL.
- Web calls
PUT /exercises/:id/overridewith{ overrides: { name: 'סקוואט אחורי', videoUrl: '...' } }. exerciseOverridePayloadSchema.parsestrips non-allowlist keys; service merges with any existing override row; upsert inexercise_org_overrides.- Subsequent
GET /exercises/libraryshows the merged view withisCustomizedByOrg=true,customizedFields=['name','videoUrl'].
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. - Org overrides on the same slug dedup against globals (org wins).
- 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. |
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. - 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 / job listing | Platform admin only (@PlatformAdmin() guard on AdminCanonicalController) |