Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkoutsWorkouts — behavior spec

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

ModeTier neededStorageUse
structuredworkout_builderworkouts row + workout_sections[] + workout_movements[] with prescription JSONBThe default builder output. Members see formatted sections; results may capture per-set data.
freeformbasic_workoutsworkouts row only; body in descriptionQuick 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, load min/max, and ordered set_progression entries (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.
  • roundsMax belongs to rounds/EMOM config; alternating belongs to EMOM config. Rounds with forTime: false are untimed repeats, rendered as 2× or 2–3× rather than “For Time”, and do not derive time scoring. An omitted forTime retains 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 return invalid_prescription. Quick edits merge existing advanced prescription fields rather than stripping them.
  • An externalRef upsert may update an existing structured workout, but cannot silently downgrade it to freeform: it returns structured_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-level coachNote stays the coach’s commentary.
  • Blocks are sections, parts group them. Each section may carry part (sectionPartSchema: key, title, optional note, optional, clockStartSeconds/clockEndSeconds). A part is the run of consecutive sections (by sortOrder) sharing part.key, and its metadata is repeated on each of them so every section read is self-contained. A block’s own note stays section.description; the part’s note, clock window and optional flag live on the part. Sections without part (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 400 invalid_section_parts when 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 through toWorkoutSectionResponse, agent read_workouts_get, assignments_export_range); deepCopyWorkout copies it to releases and personal forks; a part-only edit makes a program release stale (ProgramSessionsService.contentOf).
  • Group load. prescription.group_load: true says the movement’s load / set_progression belong to its whole superset group (a complex: “1 power snatch + 1 hang squat snatch @ 76–80%”). Section writes and the quick prescription edit reject (400 invalid_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), implement height, scaling levels, 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 (a percent_1rm load 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:

Stateis_snapshotforked_from_idVisibility
Library templatefalseNULLAppears in coach library lists; edited by coaches.
Snapshottruesource library idHidden 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 filters is_snapshot = false AND deleted_at IS NULL (apps/api/src/workouts/workouts.service.ts:236).
  • Snapshot rows cannot be soft-deleted. DB CHECK workouts_snapshot_immutable_chk enforces it; the service surfaces a clean 400 (workouts.service.ts:382).
  • Snapshot rows must carry forked_from_id. DB CHECK workouts_snapshot_provenance_chk.
  • Movement exerciseId must resolve to a canonical row (organization_id IS NULL) OR an org-owned row. Validated before insert in both create and setSections.
  • 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).
  • workoutId referenced by an assignment / result lives forever. Soft-delete on the workout is allowed only when is_snapshot=false. Assignments/results still load through snapshotWorkoutId/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:789 mapMovementToSnapshot).
  • Structured mode requires workout_builder tier at create + flip-from-freeform + setSections with non-empty sections (requireWorkoutBuilder).
  • A section’s source_text is stored as written, its marks are not. Every write funnel (create, setSections, and therefore the builder save and the parse commit) runs normalizeSourceMarks(sourceText, marks, that section's movement exerciseIds): blank text stores NULL with [] 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) copies source_text/source_marks alongside 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_sections refuses sourceText and scores, 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 verbatim sourceText and the score slots the coach wrote. Any channel may send part (the section’s part of the day), validated like every other write. MCP section writes (workouts_set_sections, workouts_upsert) first go through SectionFidelityService.fill: omitted score slots and movement marks are derived from sourceText, 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.scores through sectionScoreSlotsSchema (workouts.service.ts sectionScores): at most 4, keys matching /^[a-z0-9_-]{1,40}$/ and unique within the section, scoring never 'none'. deepCopyWorkout copies 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 single workout_results row for the assignment and leaves status/completed_at alone; only POST assignments/:id/complete (Finish) completes it, with or without a result row. The legacy POST workouts/:id/results keeps completing as before.
  • A part is re-pointed onto the personal fork. logPart resolves 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 by sortOrder, so the next save replaces them instead of duplicating.
  • Per-part writes touch one part. Slot keys must exist in that section’s scores, every setResults entry must name a movement of that section, and the replace deletes only that section’s movements’ sets. Empty scores with 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. findSectionPartIssues must be empty for every section write; the stored part is 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

  1. Coach navigates to /dashboard/workouts/new/builder.
  2. UI fetches exercise candidates via GET /organizations/:orgId/exercises/library (paginated, merged canonical+custom).
  3. Coach adds a title, picks scoring (time, rounds_reps, …), drags in sections (warmup / strength / metcon / cooldown), and adds movements with letter labels / supersets / prescriptions.
  4. UI calls POST /organizations/:orgId/workouts with the full sections[] payload in one request.
  5. Server validates exercise ids, opens a transaction, inserts the workout + sections + movements, returns the detail response.
  6. Background BullMQ job enqueues to workouts-enrichment for embedding. The write does not wait.
  7. Coach (or program template) later issues a POST /assignments/personal / POST /assignments/feed / POST /assignments/schedule referencing the workout.

Golden path — member runs and edits a snapshot

  1. Member opens /[lang]/(member)/whiteboard for today.
  2. Web fetches GET /organizations/:orgId/assignments/today; each row includes the full snapshot workout (sections + movements + exercise metadata).
  3. Member completes the workout, logs a result via POST /workouts/:workoutId/results with assignmentId. The result anchors to the snapshot id; the assignment auto-transitions assigned → 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

TriggerHandling
Create a structured workout on a Lite-tier org403 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 org400 “Program not found in this organization.”
Delete a snapshot row directly400 “Cannot delete a snapshot workout — it is referenced by historical results.”
Edit-from-cell with ?assignmentId of a rest/note assignment400 “Cannot fork a non-workout assignment” (from forkSnapshotIfNeeded).
Edit-from-cell with ?assignmentId of a soft-deleted assignment400 “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 snapshot404 “Assignment not found”; checked by WorkoutAssignmentsService.requireAssignmentForWorkout before any fork.
Concurrent edits to the same not-yet-forked assignmentSELECT … 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 attachments400 “Comment must have body or attachments.”
List / create comments with a movement id outside the URL workout404 “Movement not found.”
Reply whose parentCommentId is not a live comment on the same movement404 “Comment not found.”
Embedding enrichment enqueue failsLogged as a warning; write completes normally.
Section write whose part is split by another part, whose copies disagree, or whose clock ends before it starts400 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-enrichment BullMQ job on every create / update / setSections (best-effort enqueue, never blocks).
  • Deep-copy on first lazy-fork writes workouts + workout_sections + workout_movements + exercise_comments rows 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

ActionRequired role
Create / update / delete workoutowner, admin, coach
Set sectionsowner, admin, coach
Update movement prescriptionowner, admin, coach
List / read workoutsAny member of the org
List movement commentsAny member of the org
Create movement commentAny 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).