Skip to content

Workout Draft Protection

Scope

An unfinished workout must survive token expiry, an app reload, and a temporary network failure without crossing account boundaries. This policy applies to the active workout record snapshot. Planned-session editing remains a separate flow.

<a id="workout-date-boundary"></a>

Workout Date Boundary (issue #1529, v0.17.10)

The workout date belongs to the draft, independently of the journal's selected date. A new workout started from Home or a group board fixes draft.date to today at the moment it starts. A past record or plan opened from the journal receives the selected date once when editing starts; editing an existing record retains that record's date. The board's source date remains provenance, not a replacement for the workout date.

Screen adapters preserve this date when constructing a workout draft. Automatic and explicit saves, local draft envelopes, server checkpoints, owner-change preservation, resume, and reload restoration all retain the same workout date. Restoration uses the stored draft/envelope date, never the journal selection. Crossing midnight does not re-date an existing workout.

Journal date selection and month navigation only change journal browsing state; they cannot change an active draft or another tab's date. Starting or resuming a workout likewise does not reset the journal selection. See the calendar date boundary for entry-date ownership. App PR #1539 integrated this contract into release/v0.17.10 at 4d9705c6 on 2026-09-10. Production deployment is not included; the implementation record distinguishes validation, release integration, and deployment.

Ownership

Every persisted draft has a non-empty Supabase user ID. IndexedDB v3 uses an owner-scoped key, so signing in as another account neither reads nor overwrites the previous account's draft. There is no localStorage mirror or dual-read path.

The app controller tracks two identities independently:

  • the currently authenticated user
  • the owner of the in-memory workout snapshot

When authentication changes, the latest snapshot is first saved for the old owner, then removed from in-memory screen state. A restore flushes the local draft debounce and reads only the newly authenticated user's key. Callbacks from the previous screen generation are ignored while the identity transition is in progress.

Drafts without an owner are rejected. Only the current v3 envelope is read or written; invalid or expired shapes are removed rather than guessed into the active editor.

Lifetime

The retention period is seven days from the latest successful local draft write. Each edit refreshes savedAt and expiresAt. Loading at or after the expiry boundary deletes only that user's expired slot and returns no draft.

Edits use a 250ms debounce and last-writer-wins. A matching discard or confirmed server save first cancels pending debounce work and waits for the current local write before deleting the slot. No cross-tab lock or compare-and-swap loop is used.

The fixed period prevents abandoned or sensitive workout details from staying in browser storage indefinitely while leaving enough time for ordinary session and connectivity recovery.

Deletion

A draft is deleted only in these cases:

  • the user explicitly discards the active workout
  • the matching workout is confirmed saved by the server
  • the seven-day retention period expires

A failed server write does not delete the draft.

Storage Boundary

This is browser-profile protection, not encryption against someone who already controls the same OS/browser profile. Native secure token storage remains separate from workout content storage.

Lifecycle and Ordering (issue #1326, v0.18.0 S08)

The draft has one typed lifecycle state (src/react/controllers/workoutDraftLifecycle.ts): the authenticated user, the owner of the in-memory snapshot, the restore request number, the user whose restore has settled, and the operation being finalized. Every change goes through a transition; the legacy *Ref.current handles read and write that same state. An owner may only be claimed by the authenticated user, and a finalizing fence is released only for the operation that raised it.

All timestamps (savedAt, expiresAt, archive time, upload throttle, checkpoint expiry) come from one injected clock (ClockPort). Tests inject a fixed clock; the app injects the system clock.

Order of a clear (save, discard, empty cleanup)

  1. Cancel the pending local debounce and wait for the in-flight local write.
  2. Archive the matching envelope, then delete it (same fence: operationId, optional sourceRef).
  3. Drop scheduled and queued checkpoint uploads for that operation.
  4. Enqueue the server clear behind whatever request the checkpoint lane is currently sending.

Checkpoint lane

Uploads, clears and fetches are sent one at a time in FIFO order. A request that does not answer within 15 seconds is abandoned and the lane moves on; its late answer is ignored and does not teach the client a sequence. The lane learns the server fence sequence from every fetch/save/clear answer and sends it as p_expected_sequence on the next upload. A stale rejection adopts the returned sequence and retries once; a retired rejection is dropped.

Server fence (workout_draft_checkpoint_fences)

One row per user: sequence (bumped by every applied save and by every clear or lazy delete) and retired_operation_id (the last cleared operation). save_workout_draft_checkpoint_v2 applies only when the operation is not retired and, if an expected sequence is given, when it equals the current one. This is what stops an abandoned upload of a cleared or superseded workout from resurrecting or overwriting the backup. The v1 functions are compatibility wrappers over v2, so an older bundle follows the same rule; a rejected v1 save returns without error. pgTAP workout_draft_checkpoint_fence_v2.test.sql is the server-side proof; tests/support/draftCheckpointServer.mjs mirrors the rules for client tests and is not the source of truth.

Owner change and restore

Leaving an owner saves that owner's last snapshot into their slot and clears the state. The preservation write is tracked per user; a restore for the same user waits for it before reading, so a quick re-login sees the last edit.

Background entry (pagehide)

The local write happens immediately; the checkpoint upload is sent but its completion is never assumed. A later boot restores the device envelope when it exists, otherwise the last checkpoint that actually reached the server.

Draft TTL and unsent mutations

The seven-day draft TTL archives an expired envelope; it never touches the pending save queue, which is the sole evidence of an unsent mutation. Auth expiry and an explicit logout both preserve the draft for the next login; only account deletion purges it. An invalid draft is archived with its rejection reason (unchanged), and a pending-save row of unknown shape is preserved by the queue codec, not by this policy.