Skip to content

DailyExecution / Client Workout Session / Offline Set Logging

Workout Logging captures what the client actually performed without mutating assigned program snapshots.

The V1 baseline logging contract starts from a materialized workout task, renders the assigned program day snapshot, lets the client record set-level performance, supports skips and coach-approved substitutions, works offline through stable client-generated IDs, and publishes clean completion, adherence, PR-candidate, and coach-review events after sync.

Tags: DailyExecution, Set-level metrics, Offline sync, Adherence events, Coach review

Boundary decision

Program Builder owns templates, assignments, prescriptions, and immutable assigned snapshots. Workout Logging owns execution sessions, completed sets, skipped exercises, client notes, offline merge behavior, and submitted performance history.

V1 baseline decisions

clients can correct a submitted workout until the end of the same local day; default canonical units are kilograms, meters or kilometers, and seconds; clients can add extra sets to assigned exercises but cannot add unplanned exercises; offline mode is limited to one or two sessions using cached snapshots and last synced values; pain notes create a coach task immediately while remaining client-editable until the coach responds.

Note

Workout execution defines session, exercise, set, and pain-note payloads locally. Shared mobile sync rules for local outbox storage, replay-safe receipts, canonical id mapping, conflict handling, and pruning follow the Offline Sync Protocol.

Presentation decision (July 2026)

The client execution surface is a guided session: a full-screen mode of Today that walks the trainee through the assigned day one set at a time, with a deferral queue the app owns. This changes how logging is presented, not what is captured — set-level logging, skips with reasons, partial sessions, and offline drafts in this doc are unchanged (background: the guided Today-first direction). See the Client Mobile section for the presentation rules.

Caution

Exercise Library data is read through the exercise snapshots embedded in the assigned program. Logging must not depend on live provider search, live catalog state, or mutable custom exercise rows while the client is training.

Workout Logging Flows

The client flow must stay fast in a weak-signal gym while still producing reliable data for coaches and analytics.

Client Starts Assigned Workout

  1. Client opens Today view and selects an assigned workout task for the current local date.
  2. Client mobile reads the cached assigned program day snapshot, exercise snapshots, prescription, alternatives, and previous-session hints.
  3. App creates a local WorkoutSessionDraft with a stable client_session_id before the first server write.
  4. Server creates or resumes the matching WorkoutSession when online, using the client idempotency key to avoid duplicates.
  5. Client logs set completion, reps, load, duration, distance, effort, and notes according to the exercise prescription and modality.
  6. Metric values are stored canonically as weight_kg, distance_m, and duration_sec. V1 baseline defaults display and entry to kg, meters/kilometers, and seconds unless a later profile preference says otherwise.
  7. Submit freezes the client revision, queues sync if offline, and marks the task complete only after the backend accepts the submission.

Offline Session And Sync

  1. All in-session edits write to SQLite first and are mirrored into a local outbox with monotonic client revision numbers.
  2. Client can close the app, lose network, and resume the same workout from local state without server availability.
  3. Offline cache is intentionally small: current assigned workout, one next materialized workout, local drafts for up to two unsynced sessions, and last accepted values for exercises in those cached workouts when already synced.
  4. Bulk sync sends session, exercise, and set records with stable client ids, assignment snapshot checksums, timestamps, and operation metadata.
  5. Backend merges by workspace_id, relationship_id, and client_log_id. Exercise and set rows carry no client ids yet: an accepted write replaces the log's whole set tree from the payload.
  6. Server returns one receipt per operation — accepted, rejected, or conflicted — with the canonical log id mapped from the client log id plus a rejection or conflict code. Replays are recognized by client operation id and payload checksum; accepted revision numbers and Today projection refresh hints are not part of the shipped contract.
  7. Client replaces local temporary ids with canonical ids but keeps the stable client ids for future idempotent retries.

Skips, Substitutions, And Partial Completion

  1. Client can mark an exercise skipped and choose a lightweight reason such as pain, no equipment, time, or other.
  2. If the assigned snapshot includes approved alternatives, the client can switch to one and log sets against that alternative snapshot.
  3. Client can add extra sets only to exercises already present in the assigned workout or approved alternative. V1 baseline does not allow adding unplanned extra exercises from the workout screen.
  4. Unapproved substitutions are saved as client notes or a pending coach-review item, not silently treated as prescribed completion.
  5. A pain skip reason or pain note creates a coach task immediately when online, or queues that task for sync when offline. The client can modify or remove the pain note until the coach replies, reviews, or otherwise acknowledges the task.
  6. Partial sessions remain valid: completed sets contribute to volume and adherence while skipped or untouched sets remain visible. The guided session UI asks the client to resolve each exercise before submit, but that is a presentation gate, not an API constraint.
  7. Submitted sessions emit review context for high effort, volume drops, skipped exercises, and any active pain alerts already raised during logging.

Coach Review And Corrections

  1. Coach sees submitted workouts on client timeline, task inbox, and progress views with prescription versus actual values.
  2. Coach can mark reviewed, add coach-only notes, message the client, or create a follow-up task.
  3. Client corrections are allowed until the end of the workout's same local day and create a new session revision instead of overwriting history.
  4. Coach corrections to raw performance are not part of Public V1; coaches can annotate, request clarification, or ask the client to resubmit.
  5. Analytics consumes accepted revisions only and can rebuild projections from append-only revision records.

Domain Model

Workout Logging belongs to DailyExecution and references Program Builder assignments by stable external ids and snapshots.

Aggregates & Entities

  • DailyWorkoutTask: materialized task for a relationship, assigned program, program day, local date, timezone, status, and snapshot checksum.
  • WorkoutSession: aggregate root for one client execution attempt, including relationship, assigned program, workout task, local date, session status, source device, and revision.
  • WorkoutExerciseLog: one prescribed exercise or selected alternative inside a session with exercise snapshot, order, skip state, and client notes.
  • WorkoutSetLog: one prescribed or ad hoc set with target snapshot, actual metric values, completion state, effort, rest, and timestamps.
  • WorkoutSessionRevision: append-only accepted submission or correction payload for audit, sync replay, and projection rebuilds.
  • WorkoutReview: coach review marker, coach note, follow-up state, and review metadata.
  • WorkoutPainAlert: client-reported pain note or pain skip task that appears in the coach queue immediately and remains client-editable until coach response.
  • WorkoutAdherenceEvent: normalized event consumed by task completion, risk flags, streaks, and progress analytics.

Value Objects & Rules

  • ClientGeneratedId: stable UUID generated on mobile for sessions, exercise logs, set logs, and outbox operations.
  • WorkoutMetricType: reps, weight_kg, duration_sec, distance_m, rpe, rir, heart_rate_bpm, and future wearable-derived metrics.
  • UnitPolicy: canonical storage uses kilograms for load, meters for distance, and seconds for duration. V1 baseline display defaults to kg, meters for short distances, kilometers for long distances, and seconds for time-based set entry; any future lb input converts to kg at the API boundary while preserving the display unit snapshot.
  • WorkoutInputSchema: derived from assignment snapshot prescription and exercise modality; UI must not hardcode a single weighted-set shape.
  • SessionStatus: IN_PROGRESS, COMPLETED, and SKIPPED today; CORRECTED, REVIEWED, and VOIDED arrive with the correction and review sub-features. A log carries a completion timestamp only once it leaves IN_PROGRESS.
  • SetCompletionState: COMPLETED and SKIPPED are the states the sync contract accepts today; a set the client never touched is omitted from the payload rather than sent as PLANNED. PLANNED, FAILED, and ADDED are V1 targets.
  • Workout logs store assignment, exercise, prescription, and alternative snapshots needed to render history even after a program is superseded.
  • Added sets must be attached to an assigned exercise or approved alternative. V1 baseline rejects unplanned extra exercises in workout logging.
  • Submitting a session is idempotent by client session id and revision. Retrying the same revision must not duplicate sets or adherence events.
  • A retry must carry the same payload checksum as the operation it repeats. An identical retry replays the retained receipt without duplicating the log or its sets; a divergent retry is rejected as PAYLOAD_INVALID and the retained receipt stays intact.
  • One canonical workout log exists per relationship, assigned program, program day, and local date. A second log claiming an occupied day — including an in-progress log retargeted onto one — is conflicted as SESSION_ALREADY_SUBMITTED.
  • A submission is accepted only against an ACTIVE assignment of the same relationship. An assignment that changed state while the client was offline conflicts as SUPERSEDED_ASSIGNMENT; one that does not resolve for the relationship is rejected as ASSIGNMENT_NOT_FOUND.
  • A newer accepted same-day correction supersedes projections but does not erase previous submitted revision payloads.
  • Offline previous-session hints are limited to last accepted values already synced for the exercises in the cached workout. PR hints are not generated offline before first sync; backend projections evaluate PR candidates after accepted sync.
ModelOwned ByImportant MethodsEmits
DailyWorkoutTaskDailyExecutionmaterialize(), startSession(), markCompleted(), markMissed(), refreshFromAssignment()DailyWorkoutTaskMaterialized, DailyWorkoutTaskCompleted
WorkoutSessionDailyExecutionstart(), resume(), logSet(), skipExercise(), selectAlternative(), submit(), correct(), void()WorkoutSessionStarted, WorkoutSetLogged, WorkoutSessionSubmitted, WorkoutSessionCorrected
WorkoutExerciseLogDailyExecutionfromPrescription(), useAlternative(), markSkipped(), addClientNote()WorkoutExerciseSkipped, WorkoutAlternativeUsed
WorkoutSetLogDailyExecutioncomplete(), skip(), fail(), recordMetric(), recordEffort()WorkoutSetLogged
WorkoutPainAlertDailyExecutioncreateOrUpdateFromClient(), withdrawBeforeCoachResponse(), lockAfterCoachResponse()WorkoutPainAlertRaised, WorkoutPainAlertUpdated, WorkoutPainAlertWithdrawn
WorkoutReviewDailyExecutionmarkReviewed(), addCoachNote(), requestClarification()WorkoutSessionReviewed, WorkoutClarificationRequested

Table Structure

Persist execution records relationally, keep immutable snapshots in JSON, and use stable client ids for idempotent mobile sync.

TablePurposeImportant ColumnsConstraints & Indexes
daily_workout_tasksMaterialized Today-view workout task from an active assigned program day.id, workspace_id, relationship_id, assigned_program_id, program_day_key, local_date, timezone, status, assignment_snapshot_checksum, workout_snapshot jsonb, generated_at, completed_at, missed_atUnique (relationship_id, assigned_program_id, program_day_key, local_date); index (relationship_id, local_date, status); task snapshot is read-only after completion. Not persisted yet — the shipped workout log carries the task identity itself.
workout_logsOne client execution attempt for a workout task.id, workspace_id, relationship_id, assignment_id, program_day_id, client_log_id, local_date, status, started_at, completed_at, source_device_id, deleted_atUnique (workspace_id, relationship_id, client_log_id) and (workspace_id, relationship_id, assignment_id, program_day_id, local_date), both ignoring soft-deleted rows; index (workspace_id, relationship_id, local_date desc). Session timezone, revision counter, unit-policy snapshot, and correction deadline are not persisted yet.
workout_exercise_logsExercise-level execution record inside a session.id, client_exercise_log_id, workout_session_id, program_exercise_key, exercise_catalog_item_id, exercise_snapshot jsonb, prescription_snapshot jsonb, alternative_source_exercise_key, status, sort_order, skip_reason, client_note, completed_atUnique (workout_session_id, client_exercise_log_id); index (workout_session_id, sort_order); use snapshot fields for history rendering. Not persisted yet — the exercise identity rides program_exercise_entry_id on the set row.
workout_log_setsSet-level completed or skipped performance values, hanging directly off the log.id, workspace_id, workout_log_id, program_exercise_entry_id, set_number, reps, weight_kg numeric(6,2), completion_stateUnique (workout_log_id, program_exercise_entry_id, set_number); composite FK (workspace_id, workout_log_id) cascading from the log; every set must reference a program exercise entry of the submitted program day. Load is stored as kilograms in a typed column; target, metric, and display-unit snapshots stay V1 targets.
workout_session_revisionsAppend-only accepted submission and correction payloads.id, workout_session_id, revision_number, client_revision, revision_type, submitted_by_user_id, payload_checksum, payload jsonb, accepted_at, supersedes_revision_idUnique (workout_session_id, revision_number); unique (workout_session_id, client_revision); append-only with retention or archival.
workout_reviewsCoach review state for submitted sessions.id, workout_session_id, reviewed_by_user_id, review_status, coach_note, follow_up_task_id, reviewed_at, updated_atUnique active review per session; index (reviewed_by_user_id, reviewed_at desc); coach notes are not visible to client unless product explicitly exposes them.
workout_pain_alertsCoach-facing task created from a client pain note or pain skip reason.id, workout_session_id, workout_exercise_log_id, nullable workout_set_log_id, relationship_id, client_note, severity, status, coach_task_id, created_by_user_id, created_at, client_editable_until, coach_responded_at, withdrawn_atIndex (relationship_id, status, created_at desc); client can update or withdraw while status is client-editable and no coach response exists.
workout_adherence_eventsNormalized append-only facts for Today completion, streaks, risk flags, and analytics.id, workout_session_id, relationship_id, event_type, local_date, score, metadata jsonb, occurred_atUnique idempotency key from session id, accepted revision, and event type; index (relationship_id, local_date desc).
sync_operation_receiptsShared server receipt for every Execute sync operation, accepted or not.id, workspace_id, relationship_id, device_id, client_operation_id, operation_type, operation_version, status, canonical_record_id, rejection_code, conflict_code, message, payload_checksum, request_payload jsonb, created_atUnique (workspace_id, relationship_id, device_id, client_operation_id); rejections and conflicts are receipted as well, so a retry replays the same outcome.

Implementation note (M1)

The tables persisted today are workout_logs, workout_log_sets, sync_operation_receipts, and the shared devices registry. workout_logs deliberately collapses the workout task and the execution session into one row, and sets hang straight off it. The remaining rows above — task materialization, exercise-level logs, revisions, reviews, pain alerts, and adherence events — stay V1 targets and arrive with their sub-features.

Offline storage

SQLite mirrors only the subset needed for one or two offline sessions: current and next assigned workout task cache, local session drafts (including the guided-session position and come-back queue), exercise logs, set logs, pain-alert outbox operations, sync receipts, last accepted values for cached exercises, and media-free exercise snapshots. Server PostgreSQL remains the source of truth after sync acceptance.

Lifecycle Policy

Workout execution data is client history. Prefer corrections, voiding, archival, and anonymization over destructive edits.

Task And Session Lifecycle

  • DailyWorkoutTask starts as PENDING, moves to IN_PROGRESS when a session starts, and becomes COMPLETED, MISSED, or CANCELED.
  • WorkoutSession can be IN_PROGRESS until submitted, then becomes SUBMITTED, CORRECTED, REVIEWED, or VOIDED.
  • Missed-task jobs should not mark a workout missed while a synced or unsynced in-progress session exists inside the grace window.
  • Assigned program cancellation stops future task materialization but does not hide already submitted workout sessions.

Corrections And Retention

  • Client corrections are allowed until the end of the workout's same local day in the session timezone. Each accepted correction creates a new revision and updates current set/exercise rows transactionally.
  • Corrections outside the same-day window require a coach clarification or resubmission flow; support/admin overrides stay audited and out of the normal client UI.
  • Voided sessions remain hidden from normal analytics but visible to audited support/admin views.
  • Historical workout rows retain snapshots while the relationship exists; account deletion follows privacy anonymization rules before hard purge.

Implementation note (M1)

A closed log — COMPLETED or SKIPPED — accepts no further changes today: a later operation against it is conflicted as LIFECYCLE_CLOSED. The same-local-day correction window and the append-only revision record above are the V1 target and arrive with the correction sub-feature.

Offline Cache Policy

  • Client mobile keeps enough execution data for the current workout and one next workout, plus local drafts for up to two unsynced sessions.
  • Offline previous-session hints show only last accepted values that were already synced before going offline.
  • No new PR hints are generated before first sync. After reconnect, backend projection can mark PR candidates from accepted revisions.

Pain Alert Lifecycle

  • Pain notes and pain skip reasons create a coach task immediately when online and queue one for sync when offline.
  • Client can edit or withdraw the pain note until the coach replies, marks reviewed, or creates a follow-up.
  • After coach response, the original pain alert is locked for audit; later client changes become a new note or follow-up message.

Caution

Do not rewrite workout logs when an exercise provider updates content, a coach edits a template, or an assigned program is superseded. Historical rendering uses stored snapshots and accepted revision payloads.

API Contracts

Client endpoints optimize for offline-friendly upsert and sync. Coach endpoints optimize for review and timeline reading.

EndpointPurposeRequestResponse
GET /api/v1/client/today?date=YYYY-MM-DDReturn Today tasks including workout task summaries and sync hints.Local date, timezone, optional since cursor.Task list, workout task ids, status, assignment snapshot checksums, offline cache version.
GET /api/v1/client/workout-tasks/{taskId}Fetch full workout snapshot for execution.Task id and optional cached checksum.Workout task, assigned program day snapshot, exercise snapshots, prescription, alternatives, default unit policy, and last accepted previous values when available.
POST /api/v1/coach-client-relationships/{relationshipId}/workout-logsOnline single-operation submit of a whole workout log, started or finished.Device id, client operation id, payload checksum, and the log payload with client_log_id, assignment id, program day id, local date, status, timestamps, and sets.Receipt id and status, plus the accepted workout log with its sets or a rejection/conflict code. 201 new accept, 200 replay, 409 conflict, 422 invalid payload.
POST /api/v1/client/workout-sessions/{sessionId}/correctSubmit a same-day correction after submission.Client revision, corrected exercise logs and set logs, correction reason, idempotency key.Corrected session dto or CORRECTION_WINDOW_CLOSED; updated projection refresh hints.
PATCH /api/v1/client/workout-pain-alerts/{alertId}Modify or withdraw a pain note before coach response.Client note, severity, status ACTIVE or WITHDRAWN, expected revision.Updated pain alert or COACH_ALREADY_RESPONDED.
POST /api/v1/coach-client-relationships/{relationshipId}/syncBulk offline sync push; started, submitted, and skipped log operations today, with edited, corrected, and pain-alert operations joining their sub-features.Device id, optional client cursor, and up to 50 ordered operations with client operation id, operation type, client ids, checksums, and payload.Per-operation results with receipt id, status ACCEPTED, REJECTED, or CONFLICT, canonical id map, server cursor, and relationship scope state.
GET /api/v1/coach-client-relationships/{relationshipId}/syncPull half: the client's current assigned program tree, recent workout logs, and device receipts.Device id, cursor, limit.Current active assignment with its program tree, workout logs with sets, newest receipts for the device, and scope state.
GET /api/v1/coach-client-relationships/{relationshipId}/workout-logsCoach reads workout history for one client relationship.Pagination cursor and limit; filters by date, status, assigned program, and review state are not in the shipped contract yet.Workout logs with their set rows, newest local date first; adherence, volume, effort, skip flags, and review state follow later.
GET /api/v1/coach/workout-sessions/{sessionId}Coach reads detailed prescription versus actual performance.Session id.Session detail, exercise logs, set logs, snapshots, client notes, revision history summary.
POST /api/v1/coach/workout-sessions/{sessionId}/reviewMark reviewed or request clarification.Review status, coach note, optional follow-up task command.Updated review dto and emitted follow-up metadata; any linked pain alerts become locked from client withdrawal.

Implementation note (M1)

The shipped Execute routes are relationship-scoped: one whole-log upsert plus the sync push and its pull half. Separate start/resume, incremental save (PATCH /api/v1/client/workout-sessions/{sessionId}), and a dedicated submit route stay V1 targets — M1 replaces the whole log and its set tree on every accepted operation.

Every Execute sync call names a registered device. A missing or revoked device fails the whole batch with DEVICE_REVOKED before any operation runs, and blocks the pull half the same way; device registration and revocation follow the Offline Sync Protocol.

Structural validation is batch-level: a malformed request is a single 422 VALIDATION_FAILED, while unresolvable ids and lifecycle refusals produce per-operation receipts instead. A batch carries at most 50 operations and a log at most 500 sets, unique by program exercise entry and set number.

Key Code Snippets

Implementation sketches show the persistence shape, idempotent submit boundary, and access policy without turning this page into source code.

Drizzle schema shape

apps/api/src/daily-execution/db/workout-logging.schema.ts

ts
export const workoutSessions = pgTable("workout_sessions", {
  id: uuid("id").primaryKey().defaultRandom(),
  clientSessionId: uuid("client_session_id").notNull(),
  workspaceId: uuid("workspace_id").notNull(),
  relationshipId: uuid("relationship_id").notNull(),
  dailyWorkoutTaskId: uuid("daily_workout_task_id").notNull(),
  assignedProgramId: uuid("assigned_program_id").notNull(),
  programDayKey: text("program_day_key").notNull(),
  localDate: date("local_date").notNull(),
  timezone: text("timezone").notNull(),
  status: text("status").$type<WorkoutSessionStatus>().notNull(),
  currentRevision: integer("current_revision").notNull().default(0),
  startedAt: timestamp("started_at", { withTimezone: true }).notNull(),
  submittedAt: timestamp("submitted_at", { withTimezone: true }),
  correctionDeadlineAt: timestamp("correction_deadline_at", { withTimezone: true }),
  unitPolicySnapshot: jsonb("unit_policy_snapshot").$type<WorkoutUnitPolicy>().notNull(),
  sourceDeviceId: text("source_device_id"),
}, (table) => ({
  clientSessionUnique: uniqueIndex("workout_sessions_client_session_unique")
    .on(table.relationshipId, table.clientSessionId),
  relationshipDateIdx: index("workout_sessions_relationship_date_idx")
    .on(table.relationshipId, table.localDate),
}));

export const workoutSetLogs = pgTable("workout_set_logs", {
  id: uuid("id").primaryKey().defaultRandom(),
  clientSetLogId: uuid("client_set_log_id").notNull(),
  workoutExerciseLogId: uuid("workout_exercise_log_id").notNull(),
  prescribedSetKey: text("prescribed_set_key"),
  setNumber: integer("set_number").notNull(),
  completionState: text("completion_state").$type<SetCompletionState>().notNull(),
  targetSnapshot: jsonb("target_snapshot").$type<SetTargetSnapshot>().notNull(),
  actualMetrics: jsonb("actual_metrics").$type<WorkoutMetricValue[]>().notNull(),
  displayUnitSnapshot: jsonb("display_unit_snapshot").$type<WorkoutDisplayUnitSnapshot>().notNull(),
  rpe: numeric("rpe", { precision: 3, scale: 1 }),
  rir: numeric("rir", { precision: 3, scale: 1 }),
  completedAt: timestamp("completed_at", { withTimezone: true }),
}, (table) => ({
  clientSetUnique: uniqueIndex("workout_set_logs_client_set_unique")
    .on(table.workoutExerciseLogId, table.clientSetLogId),
}));

export const workoutPainAlerts = pgTable("workout_pain_alerts", {
  id: uuid("id").primaryKey().defaultRandom(),
  workoutSessionId: uuid("workout_session_id").notNull(),
  workoutExerciseLogId: uuid("workout_exercise_log_id").notNull(),
  workoutSetLogId: uuid("workout_set_log_id"),
  relationshipId: uuid("relationship_id").notNull(),
  clientNote: text("client_note").notNull(),
  severity: text("severity"),
  status: text("status").$type<"ACTIVE" | "WITHDRAWN" | "LOCKED">().notNull(),
  coachTaskId: uuid("coach_task_id"),
  clientEditableUntil: timestamp("client_editable_until", { withTimezone: true }),
  coachRespondedAt: timestamp("coach_responded_at", { withTimezone: true }),
  withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }),
}, (table) => ({
  relationshipStatusIdx: index("workout_pain_alerts_relationship_status_idx")
    .on(table.relationshipId, table.status),
}));

Submit session use case

apps/api/src/daily-execution/use-cases/submit-workout-session.ts

ts
@Injectable()
export class SubmitWorkoutSessionUseCase {
  constructor(
    private readonly sessions: WorkoutSessionRepository,
    private readonly tasks: DailyWorkoutTaskRepository,
    private readonly policy: WorkoutLoggingPolicy,
    private readonly tx: TransactionRunner,
    private readonly outbox: OutboxPort,
  ) {}

  async execute(command: SubmitWorkoutSessionCommand, actor: Actor): Promise<WorkoutSessionDto> {
    return this.tx.run(async () => {
      const session = await this.sessions.getByClientIdOrThrow(
        command.relationshipId,
        command.clientSessionId,
      );

      this.policy.assertClientCanSubmit(actor, session);

      if (session.hasAcceptedClientRevision(command.clientRevision)) {
        return WorkoutSessionDto.fromDomain(session);
      }

      const task = await this.tasks.getForUpdate(session.dailyWorkoutTaskId);
      const payload = command.toSubmissionPayload({
        unitPolicy: WorkoutUnitPolicy.mvpDefault(),
        allowUnplannedExercises: false,
        allowAddedSetsForAssignedExercises: true,
      });

      session.assertOnlyAssignedExercisesOrAlternatives(payload);
      session.normalizeMetrics(payload);
      session.submit(payload, task.workoutSnapshot);
      session.raisePainAlertsForClientEditableNotes();
      task.markCompletedFromSession(session);

      await this.sessions.save(session);
      await this.tasks.save(task);
      await this.outbox.publish(session.pullDomainEvents());

      return WorkoutSessionDto.fromDomain(session);
    });
  }
}

Access policy

apps/api/src/daily-execution/policies/workout-logging.policy.ts

ts
export class WorkoutLoggingPolicy {
  assertClientCanSubmit(actor: Actor, session: WorkoutSession): void {
    actor.requireRole("CLIENT");
    actor.requireRelationshipParticipant(session.relationshipId);
    session.assertMutableByClient();
  }

  assertCoachCanReview(actor: Actor, session: WorkoutSession): void {
    actor.requireRole("COACH");
    actor.requireCoachRelationshipAccess(session.relationshipId);
    session.assertSubmitted();
  }

  assertCanReadHistory(actor: Actor, relationshipId: string): void {
    actor.requireAny([
      () => actor.requireRelationshipParticipant(relationshipId),
      () => actor.requireCoachRelationshipAccess(relationshipId),
      () => actor.requirePermission("support.workout_history.read"),
    ]);
  }
}

Events & Background Jobs

Workout submissions feed Today completion, coach review, analytics, risk flags, and progress projections.

Events

  • WorkoutSessionStarted: optional task status update and sync projection refresh.
  • WorkoutSetLogged: in-session autosave event for local projections; backend can coalesce noisy events.
  • WorkoutExerciseSkipped: creates coach review signal when reason is equipment or repeated skip; pain reason raises a mutable pain alert immediately.
  • WorkoutAlternativeUsed: links actual work to the approved alternative snapshot and analytics metadata.
  • WorkoutPainAlertRaised: creates or updates the coach task inbox item while the client can still edit or withdraw before coach response.
  • WorkoutPainAlertWithdrawn: removes or resolves the coach task when the client withdraws before coach response.
  • WorkoutSessionSubmitted: completes Today task, emits adherence event, schedules coach review projection, and triggers PR-candidate evaluation.
  • WorkoutSessionCorrected: rebuilds adherence, volume, effort, PR-candidate, and risk projections for the affected date.
  • WorkoutSessionReviewed: resolves review task, locks linked pain alerts from client withdrawal, and can create communication or follow-up tasks.

Implementation note (M1)

Shipped outbox events are daily_execution.workout_log.started.v1, .completed.v1, and .skipped.v1 — one per accepted operation, carrying the client operation id as idempotency key. The finer-grained set, skip, alternative, pain-alert, and review events above follow their sub-features.

Jobs

  • Workout task materializer consumes ProgramAssigned, ProgramPaused, ProgramResumed, and ProgramSuperseded.
  • Missed workout job marks overdue pending tasks after timezone-aware grace windows.
  • Mobile sync reconciliation job retries failed outbox operations and emits dead-letter diagnostics.
  • Coach review projection groups notable sessions by skipped exercises, active pain alerts, PR candidates, and adherence drops.
  • Progress analytics projector computes volume, intensity, effort, completion, and exercise trend rows from accepted revisions.
  • Retention/anonymization job handles closed relationships, deleted accounts, support visibility, and archival windows.

Permissions & Access Rules

Workout logs are relationship-scoped health and performance data. Treat reads, edits, review notes, and support access explicitly.

Client

  • Client can start, edit, submit, and correct only workout sessions for their own active or historically permitted relationship.
  • Client can read their own submitted history and visible coach feedback.
  • Client cannot edit workout metrics after the same-day correction window unless the product opens a clarification/resubmission flow.
  • Client can edit or withdraw a pain note until the linked coach task has a coach response, review, or follow-up.
  • Client cannot mark a coach review state, create coach-only notes, or edit assignment snapshots.

Coach And Support

  • Coach can read workout history for relationships they are authorized to manage.
  • Coach can review, annotate, message, and create follow-up tasks, but cannot silently rewrite client performance values in Public V1.
  • Coach access to ended relationships follows CoachClientManagement history policy and must be visible in audit logs where required.
  • Support/admin can inspect voided sessions and sync receipts only with explicit permission and audited access reason.

Implementation note (M1)

A client can push or pull only their own relationship, and only while it is neither ended nor anonymized; every sync response echoes the relationship scope state. Coach reads are authorized by workspace-owner membership rather than a per-relationship coach grant — the finer coach and support permissions above arrive with the roles work.

Web, Coach Mobile, Client Mobile

Each surface consumes the same contracts but optimizes for its own workflow.

Coach Web

  • Client timeline shows workout summaries with completion, skipped exercises, volume, effort, notes, and PR candidates.
  • Detailed review compares prescription and actual values by exercise and set.
  • Task inbox highlights active pain alerts immediately, plus sessions needing review because of missed volume, repeated skips, or effort outliers.

Coach Mobile

  • Same review states as web, adapted to a compact session summary and set-detail drilldown.
  • Coach can mark reviewed, message client, or add follow-up without needing the desktop workspace.
  • Offline coach reads can use cached summaries, but review mutations should queue with clear pending state.

Client Mobile

Client mobile renders execution as a guided session — a full-screen mode of Today that runs for the duration of the workout and collapses back when it ends or is minimized.

  • The session shows one exercise, one set at a time.
  • Timeline is the default presentation. The current set is the hero, framed by a thin progress strip ("exercise 3 of 7") and a peek at the next exercise on deck.
  • Focus is a manual toggle that strips the frame down to the current set. The current-set block is the same element in both, so toggling never moves the set; Focus is sticky across sets and sessions until switched back, and a short pull briefly reveals progress, on-deck, and the deferral queue before auto-collapsing.
  • Marking a set done, or entering what was actually performed, is the log. There is no separate post-session logging form.
  • "Not now" defers instead of skipping. A deferral moves the exercise into an app-owned come-back queue and advances to the next exercise; after the linear pass, deferred exercises re-present one at a time behind a quiet "coming back to" banner. Partial progress survives deferral — set 2 of 3 resumes, not restarts. Deferral is presentation state persisted in the SQLite session draft: the come-back queue, per-exercise partial progress, and the current exercise/set pointer survive app close and restart. It is never sent in sync operations — the statuses the server sees stay the committed set completion states and explicit exercise skips, never a deferred one.
  • The session closes honestly. The guided UI offers submit only once every assigned exercise is resolved — all its sets logged, or the exercise dropped with "skip for today" plus one of the committed reasons (pain, no equipment, time, other). Untouched sets inside a dropped exercise sync as SKIPPED or are omitted from the payload — an explicit PLANNED row stays a V1 target — and the submit endpoint still accepts partial sessions — the gate is presentation, not an API constraint. Drops ride the existing skip routing: a pain reason raises a mutable pain alert immediately, equipment or repeated skips create a coach review signal, and the rest stay timeline and adherence facts.
  • A client-facing cue carried on the assigned exercise snapshot (Program Builder's client_cues) surfaces quietly on its set; coach-only review notes (workout_reviews.coach_note) are never shown in-session, and chat messages never interrupt mid-set. In Focus, only the coming-back banner, the current set's client-facing cue, and the done screen pierce.
  • Minimizing the session returns to Today, where it persists as the resumable-session dock (see Today View); resuming restores the exact set from the SQLite draft.
  • A conversational, coach-led presentation of the same guided flow is reserved for a trainee's first sessions during onboarding, then graduates to Timeline.
  • Workout screen is usable entirely from cached assigned snapshots and local SQLite session drafts.
  • Inputs are generated from prescription and modality: weighted, bodyweight, timed, distance, effort-only, and mixed sets.
  • Large tap targets support set completion, copy previously synced values, add extra sets to assigned exercises, skip, use approved alternative, notes, and submit.
  • Offline cache supports the current workout and one next workout with up to two unsynced sessions. PR hints wait for backend sync.
  • Pain notes show clear pending coach-task state and can be modified or removed until coach response.
  • Sync state is visible per workout: local draft, queued, syncing, accepted, needs attention, or conflict.

Test Scenarios & Confirmed Decisions

Workout logging needs strong idempotency, offline, permission, unit, and projection coverage because duplicate or lost logs damage trust quickly.

Tests

  • Domain unit tests: start/resume, set completion, partial completion, skips, alternatives, submit, correction, void, and state transitions.
  • Domain unit tests: same-local-day correction is accepted, correction after the local-day deadline is rejected, and accepted corrections create append-only revisions.
  • Domain unit tests: added sets are accepted only under assigned exercises or approved alternatives; unplanned extra exercises are rejected in Public V1.
  • Domain unit tests: load, distance, and duration inputs normalize to weight_kg, distance_m, and duration_sec while preserving display unit snapshots.
  • API integration tests: idempotent start and submit, bulk sync retry, stale revision rejection, canonical id mapping, and task completion projection.
  • API integration tests: pain note creates a coach task immediately, client can modify or withdraw before coach response, and mutation is rejected after coach response.
  • Access tests: client cannot submit another relationship's workout, coach cannot review unauthorized relationship, support access requires explicit permission.
  • Mobile E2E: start online, go offline, log sets, add extra sets to assigned exercises, close app, resume, submit offline, reconnect, sync once, and verify coach timeline.
  • Mobile E2E: offline cache supports current and next workout with last synced values only; no PR hint is shown before first accepted sync.
  • Mobile E2E: defer two exercises, force-close the app, resume, and verify the come-back queue order, current set position, and partial set progress are intact.
  • Projection tests: adherence, missed workouts, PR candidates, risk flags, and corrected-session rebuilds.
  • Localization tests: Arabic/English labels, RTL set rows, metric units, and long exercise names without layout breakage.

Confirmed Decisions

  • Client correction window is the same local day as the workout submission.
  • V1 baseline default units are kilograms for load, meters or kilometers for distance, and seconds for time-based workout values. Canonical storage normalizes to kg, meters, and seconds; lb input can be supported later by converting at the API boundary and preserving the display unit snapshot.
  • Clients can add extra sets to assigned exercises or approved alternatives in Public V1. They cannot add unplanned extra exercises from the workout screen.
  • Offline mode is intentionally small: current workout, one next workout, up to two unsynced session drafts, and last accepted values that were already synced. PR hints wait for backend sync.
  • Pain notes create a coach task immediately. The client can modify or remove the pain note until the coach answers, reviews, or creates a follow-up.
  • Guided-session presentation: one set at a time, Timeline by default, a manual sticky Focus toggle, Conversational reserved for onboarding; automatic phase-switching between Timeline and Focus is deferred. Decided July 2026 with the guided Today-first client direction.
  • "Not now" deferral and the come-back queue are client-side draft state persisted in SQLite, never a server status; the sync contract is unchanged. The guided UI gates submit on every exercise being resolved — logged, or skipped with one of the committed reasons; coach routing follows the existing WorkoutExerciseSkipped rules.

CoachMe internal planning documentation.