Workouts — behavior spec
Open-ended EMOM
An EMOM may omit rounds to mean “until failure”. intervalSeconds remains required, and roundsMax still requires rounds. Web and mobile whiteboards render the open interval in the active locale. A count-up timer awaits the separate timer design.
Workout modes
| Mode | Tier needed | Storage | Use |
|---|---|---|---|
structured | workout_builder | workouts row + workout_sections[] + workout_movements[] with prescription JSONB | The default builder output. Members see formatted sections; results may capture per-set data. |
freeform | basic_workouts | workouts row only; body in description | Quick text post (legacy / Lite tier / spotter agent fallback). No sections. |
Flipping freeform → structured on an existing workout via PATCH /:id re-checks the tier gate. Flipping structured → freeform is allowed without tier check; sections persist but are ignored.
Freeform text can also be converted to a structured workout by the AI paste-to-structure transformer (flag-gated, FIT-245): /dashboard/workouts/new starts with freeform and its “Convert to structured workout” button parses the text into sections with a preview + adjustment step, then commits through the same create/setSections write path. See workout-parse.
Structured fidelity (TKN-355)
Structured prescriptions and their validation apply to every organization. Deploy compatible API/shared, web and native readers together; no separate feature flag enables advanced authoring.
- Canonical prescriptions retain
sets_max,per_side,optional, loadmin/max, and orderedset_progressionentries (from_set,to_set,load). A load range requires both bounds and cannot also specify a scalar or sex-labelled load. Progression bands cannot overlap and must fit an explicit movement set count or an applicable non-alternating section count. Intent loads remain text, not numeric ranges. roundsMaxbelongs to rounds/EMOM config;alternatingbelongs to EMOM config. Rounds withforTime: falseare untimed repeats, rendered as2×or2–3×rather than “For Time”, and do not derive time scoring. An omittedforTimeretains the legacy timed-round behavior. The builder, REST, MCP and shared codecs preserve these fields. No SQL migration is required: prescription/config storage is JSONB.- Create, update, section replacement and quick
prescription edits validate canonical structure. Invalid section config
returns
invalid_structure; invalid prescriptions returninvalid_prescription. Quick edits merge existing advanced prescription fields rather than stripping them. - An
externalRefupsert may update an existing structured workout, but cannot silently downgrade it to freeform: it returnsstructured_workout_requires_review(409). An explicit update by workout ID remains available. The existing upstream session splitter and stable occurrence identity remain authoritative: one workout is one session, not a whole training day. - Web and native scan views show operative section/movement instructions, optional movements, ranges, per-side volume and set progression without expanding commentary. Identical progression shared by a section’s movements appears once after the movements. A base load is labelled as applying to other sets only when bands do not cover every prescribed set.
- Workout-level commentary comes from
workout.coachNote, preserving paragraphs. Section instructions are not coach-avatar cards. Commentary and technique/demo details remain separately expandable; legacy movement notes are not hidden wholesale because they can contain required work. - Set logging resolves an exact load for the applicable band without mutating the prescription. A range or percentage is never invented as an exact absolute-weight default.
- Program releases retain these JSON fields and the existing identity and history protections. Re-publication is idempotent; started, completed, individualized and divergent snapshots are not overwritten.
Existing malformed releases are a separate, owner-approved repair. Do not pick a duplicate winner, replace missing source content, or repoint protected athlete history automatically. A repair needs coach-reviewed source and a fresh row-fingerprint check before any write.
Training-day parts (MCP day grain)
A coach programs one training day as one class: a brief plus parts (“Warm-up 0:00–11:00”, “Lift”, “Conditioning”, “Optional Successory”), where a part can hold several blocks (Warm-up = General / Activation / Movement pattern / Build). The stored shape:
- One workout per day. The day brief is
workout.description; the workout-levelcoachNotestays the coach’s commentary. - Blocks are sections, parts group them. Each section may carry
part(sectionPartSchema:key,title, optionalnote,optional,clockStartSeconds/clockEndSeconds). A part is the run of consecutive sections (bysortOrder) sharingpart.key, and its metadata is repeated on each of them so every section read is self-contained. A block’s own note stayssection.description; the part’s note, clock window and optional flag live on the part. Sections withoutpart(every workout written before parts) read as one unnamed group. - Part invariant. Every section write (
create,update,setSections, and so the builder save, parse commit and MCP upsert) rejects with 400invalid_section_partswhen a part’s sections are not contiguous, its copies disagree, or a part object is malformed (bad key, end clock not after start, end without start). The message names the section and the part. The validated copy is stored (title trimmed). - Parts travel and count as content. Every read returns
part(coach detail, member assignment/class/daily/course feeds throughtoWorkoutSectionResponse, agentread_workouts_get,assignments_export_range);deepCopyWorkoutcopies it to releases and personal forks; a part-only edit makes a program release stale (ProgramSessionsService.contentOf). - Group load.
prescription.group_load: truesays the movement’sload/set_progressionbelong to its whole superset group (a complex: “1 power snatch + 1 hang squat snatch @ 76–80%”). Section writes and the quick prescription edit reject (400invalid_prescription) a group load on a movement without a superset group, and a second group load in the same (section, group). - Day-grain prescriptions. Calories (
reps.kind: 'calories') and sex-labelled counts (reps.bySex), a percent of a named reference (load.kind: 'percent_reference'+load.reference, never resolved to kilograms), implementheight, scalinglevels,group_load, and an EMOM interval range (config.intervalSecondsMax) validate through the shared schemas on every write path, including the quick-edit DTO whitelist. - Agent writes are completed, not trusted blindly.
SectionFidelityService.fill(used by MCP section writes and the parse tool) fills only what the caller omitted: score slots from the section’s own “Score -” lines, movement marks anchored by the exercise’s names and aliases (the org’s renamed name and aliases included), and the workout scoring (first scored section’s first slot, else the shape default). It returns warnings and never fails the write:missing_prescription(a numbered line whose movement has no prescription),percent_base_mismatch(apercent_1rmload in text that names an N-RM, N>1),text_only_section(text with no movements),invented_score_slot(a caller’s slot whose label is not in the text).
Lazy-fork lifecycle (FIT-152)
Two states for any non-snapshot workout row:
| State | is_snapshot | forked_from_id | Visibility |
|---|---|---|---|
| Library template | false | NULL | Appears in coach library lists; edited by coaches. |
| Snapshot | true | source library id | Hidden from library lists; one per assignment-that-was-edited; immutable for deletion. |
State machine for (assignment, workout) pair after assignment is created:
assignment.workoutId = LibA
assignment.snapshotWorkoutId = LibA (pointer state — no snapshot row exists)
│
│ first edit-from-cell OR first result completion via assignmentId
▼
deepCopyWorkout(LibA) → SnapA
- SnapA.isSnapshot = true
- SnapA.forkedFromId = LibA
- movement comments copied LibA → SnapA
assignment.snapshotWorkoutId = SnapA (forked state)All subsequent edits with ?assignmentId=X land on SnapA. The library row LibA is never mutated by per-athlete edits.
Invariants
- Library list (
GET /organizations/:orgId/workouts) always filtersis_snapshot = falseANDdeleted_at IS NULL(apps/api/src/workouts/workouts.service.ts:236). - Snapshot rows cannot be soft-deleted. DB CHECK
workouts_snapshot_immutable_chkenforces it; the service surfaces a clean 400 (workouts.service.ts:382). - Snapshot rows must carry
forked_from_id. DB CHECKworkouts_snapshot_provenance_chk. - Movement
exerciseIdmust resolve to a canonical row (organization_id IS NULL) OR an org-owned row. Validated before insert in bothcreateandsetSections. - Cross-org movement IDs cannot leak through
updateMovementPrescription. Service joins through section → workout → org and re-checks the WHERE clause on UPDATE (defense in depth). workoutIdreferenced by an assignment / result lives forever. Soft-delete on the workout is allowed only whenis_snapshot=false. Assignments/results still load throughsnapshotWorkoutId/libraryWorkoutId.- Comments survive forking. On
deepCopyWorkout, every per-movement comment on the source movements is duplicated onto the snapshot’s sibling movements with reply threading preserved (workout-assignments.service.ts:938). - Snapshot id resolution by
(section.sortOrder, movement.sortOrder)— frontend may still hold library-row movement ids after a fork; service maps through (workouts.service.ts:789mapMovementToSnapshot). - Structured mode requires
workout_buildertier at create + flip-from-freeform +setSectionswith non-empty sections (requireWorkoutBuilder). - A section’s
source_textis stored as written, its marks are not. Every write funnel (create,setSections, and therefore the builder save and the parse commit) runsnormalizeSourceMarks(sourceText, marks, that section's movement exerciseIds): blank text storesNULLwith[]marks, score lines are recomputed from the text (a client cannot declare one), and a movement mark whose exercise the section no longer carries is dropped — removing a movement in the builder prunes its link by construction. - Copies carry the text.
deepCopyWorkout(release + personal fork) copiessource_text/source_marksalongside the movements, so the offsets stay valid; a source-text-only edit also makes a program release stale (ProgramSessionsService.contentOf). - Spotter writes structure, not words; MCP may carry the coach’s words. On Spotter,
workouts.set_sectionsrefusessourceTextandscores, so a Spotter-replaced section has none and members read the structured rendering rather than marks pointing into text the agent rewrote. Over MCP (runtime.channel === 'mcp') a section may carry the coach’s verbatimsourceTextand the score slots the coach wrote. Any channel may sendpart(the section’s part of the day), validated like every other write. MCP section writes (workouts_set_sections,workouts_upsert) first go throughSectionFidelityService.fill: omitted score slots and movement marks are derived fromsourceText, an unscored workout takes the scoring the text implies, and the response lists fidelity warnings. - Score slots are the coach’s, validated once. Every write funnel parses
section.scoresthroughsectionScoreSlotsSchema(workouts.service.tssectionScores): at most 4, keys matching/^[a-z0-9_-]{1,40}$/and unique within the section,scoringnever'none'.deepCopyWorkoutcopies them to the release and the personal fork, and a slot-only edit makes a program release stale (ProgramSessionsService.contentOf). - Saving a part never finishes the workout.
PUT organizations/:orgId/assignments/:assignmentId/parts/:sectionId(WorkoutResultsService.logPart) find-or-creates the member’s singleworkout_resultsrow for the assignment and leavesstatus/completed_atalone; onlyPOST assignments/:id/complete(Finish) completes it, with or without a result row. The legacyPOST workouts/:id/resultskeeps completing as before. - A part is re-pointed onto the personal fork.
logPartresolves the section against the workout the member is reading, then lazy-forks like result creation does; when that fork happens the part and its sets move to the copy’s section/movements bysortOrder, so the next save replaces them instead of duplicating. - Per-part writes touch one part. Slot keys must exist in that section’s
scores, everysetResultsentry must name a movement of that section, and the replace deletes only that section’s movements’ sets. Emptyscoreswith no sets clears the part (its slot rows and its section’s sets); other parts and the result row survive. - A part is one contiguous run with one set of details.
findSectionPartIssuesmust be empty for every section write; the storedpartis the schema-validated copy. - A superset group carries at most one group load, and only a grouped movement can carry it — on section writes and on
PATCH .../prescription.
Golden path — coach builds and publishes a workout
- Coach navigates to
/dashboard/workouts/new/builder. - UI fetches exercise candidates via
GET /organizations/:orgId/exercises/library(paginated, merged canonical+custom). - Coach adds a title, picks scoring (
time,rounds_reps, …), drags in sections (warmup / strength / metcon / cooldown), and adds movements with letter labels / supersets / prescriptions. - UI calls
POST /organizations/:orgId/workoutswith the fullsections[]payload in one request. - Server validates exercise ids, opens a transaction, inserts the workout + sections + movements, returns the detail response.
- Background BullMQ job enqueues to
workouts-enrichmentfor embedding. The write does not wait. - Coach (or program template) later issues a
POST /assignments/personal/POST /assignments/feed/POST /assignments/schedulereferencing the workout.
Golden path — member runs and edits a snapshot
- Member opens
/[lang]/(member)/whiteboardfor today. - Web fetches
GET /organizations/:orgId/assignments/today; each row includes the full snapshot workout (sections + movements + exercise metadata). - Member completes the workout, logs a result via
POST /workouts/:workoutId/resultswithassignmentId. The result anchors to the snapshot id; the assignment auto-transitionsassigned → completed.
If, between assignment and completion, the coach changed the snapshot prescription via PATCH .../movements/:movementId/prescription?assignmentId=..., the member sees the per-athlete edit; other athletes assigned the same library row do not.
Edge cases & error states
| Trigger | Handling |
|---|---|
| Create a structured workout on a Lite-tier org | 403 with message pointing at mode='freeform' or upgrade. |
Create with cross-org exerciseId in sections[].movements[] | 400 “One or more exercises not found in this organization or the canonical library.” |
Update with a programId not in this org | 400 “Program not found in this organization.” |
| Delete a snapshot row directly | 400 “Cannot delete a snapshot workout — it is referenced by historical results.” |
Edit-from-cell with ?assignmentId of a rest/note assignment | 400 “Cannot fork a non-workout assignment” (from forkSnapshotIfNeeded). |
Edit-from-cell with ?assignmentId of a soft-deleted assignment | 400 “Assignment has been deleted.” |
Edit-from-cell (update, setSections, prescription) with ?assignmentId from another org, or one that does not reference the URL workout as its library workout, issued release or current snapshot | 404 “Assignment not found”; checked by WorkoutAssignmentsService.requireAssignmentForWorkout before any fork. |
| Concurrent edits to the same not-yet-forked assignment | SELECT … FOR UPDATE on the assignment row serializes; only one snapshot is created. |
Update movement prescription with a movement id that lives in a different workout (and no ?assignmentId) | 404 “Movement not found.” |
| Comment with neither body nor attachments | 400 “Comment must have body or attachments.” |
| List / create comments with a movement id outside the URL workout | 404 “Movement not found.” |
Reply whose parentCommentId is not a live comment on the same movement | 404 “Comment not found.” |
| Embedding enrichment enqueue fails | Logged as a warning; write completes normally. |
| Section write whose part is split by another part, whose copies disagree, or whose clock ends before it starts | 400 invalid_section_parts, message names the section and part; nothing written. |
group_load: true on an ungrouped movement, or a second one in a superset group (section write or quick edit) | 400 invalid_prescription naming the group. |
Side effects
workouts-enrichmentBullMQ job on every create / update / setSections (best-effort enqueue, never blocks).- Deep-copy on first lazy-fork writes
workouts+workout_sections+workout_movements+exercise_commentsrows in a single transaction. - Push notification on per-movement comment reply (
category: 'newComment', route/(tabs)/workouts/{workoutId}/exercise/{movementId}) to the parent comment author when the author is not the replier. - R2 thumbnail generation for image attachments on comments (sharp resize to 400x400 JPEG; failures are non-fatal).
- R2 presigned URLs (1h TTL, cached) on every comment list response so attachments render.
Permissions
| Action | Required role |
|---|---|
| Create / update / delete workout | owner, admin, coach |
| Set sections | owner, admin, coach |
| Update movement prescription | owner, admin, coach |
| List / read workouts | Any member of the org |
| List movement comments | Any member of the org |
| Create movement comment | Any member of the org |
All routes are protected by the global AuthGuard. Tier-gated routes carry @RequiresFeature('basic_workouts') and structured-mode writes call requireWorkoutBuilder defensively inside the service.
Audit-relevant actions
The workouts module itself does not write to audit_logs. Assignment-level bulk operations (which can mutate a coach’s assignment grid) are audit-logged inside workout-assignments. Workout-level deletes / forks are recoverable via the snapshot chain (forkedFromId).