# SCHEMA.md The on-disk JSON document schemas — the app's source of truth. The canonical definitions are the Codable structs in `Shared/Model/Documents.swift` (enums in `Shared/Model/Enums.swift`); this file is a readable summary. If they disagree, the code wins. Encoding (IndieSync `DocumentCoder`): pretty-printed JSON with sorted keys; dates as ISO-8601 strings. One file per aggregate root: | Aggregate | Path in iCloud container | |---|---| | Split (embeds its exercises) | `Splits/.json` | | Workout (embeds its logs) | `Workouts/YYYY/MM/.json` (local-calendar month of `start`) | | Tombstone (soft-delete stub) | `Stubs/` | `schemaVersion` gates forward compatibility (`VersionedDocument`): files written by a newer app version are quarantined, not rewritten. Fields marked *not schema-bumped* were added as optionals without a version bump — an older app silently drops them on rewrite, which is accepted as preferable to quarantine. The same shapes are also the iPhone↔Watch wire format, and each has a mirrored SwiftData cache entity (`Shared/Model/Entities.swift`) kept in sync by `Mappers.swift`. --- ## SplitDocument — `currentSchemaVersion: 3` | Property | Type | Optional | Notes | |---|---|---|---| | `schemaVersion` | Int | | Forward-compatibility gate. Bumped 1→2 when the weight-reminder fields and `category` were removed from `ExerciseDocument` and `machineSettings` was added — removing required fields means older apps can't decode new files, so they must quarantine, not rewrite. Bumped 2→3 when `ExerciseDocument.weight` went Int → Double — an older app decoding a fractional weight into `Int` fails, so it must quarantine | | `id` | String | | ULID | | `name` | String | | | | `color` | String | | Theme color name | | `systemImage` | String | | SF Symbol name | | `order` | Int | | Sort position in the splits list | | `createdAt` | Date | | | | `updatedAt` | Date | | | | `exercises` | [ExerciseDocument] | | Embedded aggregate members | | `activityType` | Int | ✓ | Raw `WorkoutActivityType`; nil → `traditionalStrength`. *Not schema-bumped* | | `restSeconds` | Int | ✓ | Per-routine rest length; nil → the Settings default applies. *Not schema-bumped* | | `autoAdvance` | Bool | ✓ | Flow mode: finishing an exercise rests, then auto-advances into the next; nil → off. *Not schema-bumped* | | `targetHeartRate` | Int | ✓ | Target bpm for endurance routines (`WorkoutActivityType.supportsHeartRateTarget`) — drives the live too-low/too-high indicator during a run; nil → no target. *Not schema-bumped* | ## ExerciseDocument (embedded in SplitDocument) | Property | Type | Optional | Notes | |---|---|---|---| | `id` | String | | ULID | | `name` | String | | Also keys the motion rig / picker catalog | | `order` | Int | | Sort position within the split | | `sets` | Int | | | | `reps` | Int | | | | `weight` | Double | | Unit-less; `WeightUnit` only relabels display. Int → Double in schema v3 (old integer values decode natively) | | `loadType` | Int | | Raw `LoadType` | | `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise | | `machineSettings` | [MachineSetting] | ✓ | Ordered machine comfort settings; nil → not a machine exercise, empty → machine exercise with nothing recorded. Doubles as the machine-based flag | | `libraryName` | String | ✓ | The library exercise `name` was customized from (e.g. one Treadmill segment renamed "Brisk Walk 10 min"); nil → `name` IS the library name. Figure/info/cue lookups resolve `libraryName ?? name`, never `name` directly. *Not schema-bumped* | ## WorkoutDocument — `currentSchemaVersion: 5` | Property | Type | Optional | Notes | |---|---|---|---| | `schemaVersion` | Int | | Bumped 1→2 when `metrics` was added (captured HR data is irreplaceable, so older apps must quarantine, not strip). Bumped 2→3 when the derived `completed` flag was removed from `WorkoutLogDocument` and `machineSettings` was added — dropping a required field means older apps can't decode new files, so they must quarantine. Bumped 3→4 when `WorkoutLogDocument.weight` went Int → Double and per-set actuals (`setEntries`) were added — a fractional weight fails an older decode, and a rewrite would strip the irreplaceable actuals. Bumped 4→5 when per-log `updatedAt` began being written and `deletedLogIDs` was added for the per-log merge — a rewrite by an older app would strip the deletion tombstones and resurrect a deleted exercise, so older apps must quarantine | | `id` | String | | ULID (chronological — drives month bucketing and sorting) | | `splitID` | String | ✓ | Denormalized reference; no live relationship | | `splitName` | String | ✓ | Denormalized snapshot of the split's name | | `start` | Date | | Also determines the file's `YYYY/MM` bucket (never re-bucketed after write) | | `end` | Date | ✓ | Stamped by `recomputeStatusFromLogs()` when all logs resolve | | `status` | String | | Raw `WorkoutStatus`; derived from logs, never set directly | | `createdAt` | Date | | | | `updatedAt` | Date | | | | `logs` | [WorkoutLogDocument] | | Embedded aggregate members | | `metrics` | WorkoutMetrics | ✓ | Nil until the workout completes and a writer fills it in | | `deletedLogIDs` | [String: Date] | ✓ | Per-log deletion tombstones (`logID → when deleted`), phone-authored. Disambiguate an absent log during the per-log merge so a stale watch push can't resurrect a removed exercise; pruned after a 30-day grace. Added in schema v5 (`WorkoutMergePlanner`) | | `restSeconds` | Int | ✓ | Plan-time snapshot of the routine's rest length (a running workout has no live link to its routine); nil → the Settings default. *Not schema-bumped* | | `autoAdvance` | Bool | ✓ | Plan-time snapshot of the routine's flow-mode flag; nil → off. *Not schema-bumped* | | `targetHeartRate` | Int | ✓ | Plan-time snapshot of the routine's target bpm; nil → no target. *Not schema-bumped* | ## WorkoutLogDocument (embedded in WorkoutDocument) | Property | Type | Optional | Notes | |---|---|---|---| | `id` | String | | ULID | | `exerciseName` | String | | Snapshot by name — no reference to the exercise's ULID | | `order` | Int | | Sort position within the workout | | `sets` | Int | | Planned sets (snapshot from the exercise) | | `reps` | Int | | Planned reps (snapshot) | | `weight` | Double | | Snapshot; unit-less. Int → Double in schema v4 (old integer values decode natively) | | `loadType` | Int | | Raw `LoadType` (snapshot) | | `durationSeconds` | Int | | Total seconds; 0 when not a timed exercise | | `currentStateIndex` | Int | | Progress through the set sequence | | `status` | String | | Raw `WorkoutStatus`; mutate only via `transition(to:)`. Completion is derived: `completed == (status == "completed")` | | `notes` | String | ✓ | | | `date` | Date | | | | `machineSettings` | [MachineSetting] | ✓ | Snapshot of the exercise's machine settings at plan time; nil → not a machine exercise, empty → nothing recorded | | `startedAt` | Date | ✓ | First move to `.inProgress`. *Not schema-bumped* | | `completedAt` | Date | ✓ | Last move to `.completed`. *Not schema-bumped* | | `setEntries` | [SetEntry] | ✓ | Per-set actuals in set order; nil → legacy file / nothing recorded. Appended as sets complete (pre-filled from the plan); `transition(to:)` fills the missing tail on `.completed` and clears on `.notStarted`; `.skipped` keeps partials. Added in schema v4 | | `updatedAt` | Date | ✓ | Per-log modification time driving the per-log merge (`WorkoutMergePlanner`); stamped by `transition(to:)` on status flips and `touch()` at content-only edit sites. Nil in files written before v5. Added (unused) in schema v4, written since v5 | | `libraryName` | String | ✓ | Plan-time snapshot of the exercise's `libraryName` (like sets/reps/weight); nil → `exerciseName` IS the library name. *Not schema-bumped* | ## MachineSetting (embedded in ExerciseDocument and WorkoutLogDocument) A single free-form comfort setting for a machine-based exercise. `value` is a string, not a number, because machine dials aren't uniformly numeric. | Property | Type | Optional | Notes | |---|---|---|---| | `name` | String | | e.g. "Seat Height", "Back Rest Incline" | | `value` | String | | Free-form, e.g. "4", "3rd hole", "45°" | ## SetEntry (embedded in WorkoutLogDocument, schema v4) One performed set. Which fields are set follows the log's `LoadType`: weight → `reps` + `weight`; none (bodyweight) → `reps`; duration → `seconds`. | Property | Type | Optional | Notes | |---|---|---|---| | `reps` | Int | ✓ | | | `weight` | Double | ✓ | Unit-less, like the plan snapshot | | `seconds` | Int | ✓ | Held/worked seconds for duration exercises | | `completedAt` | Date | | When the set finished | ## WorkoutMetrics (embedded in WorkoutDocument, schema v2) | Property | Type | Optional | Notes | |---|---|---|---| | `activeEnergyKcal` | Double | ✓ | | | `avgHeartRate` | Double | ✓ | bpm; watch sessions only | | `maxHeartRate` | Double | ✓ | bpm; watch sessions only | | `minHeartRate` | Double | ✓ | bpm; watch sessions only | | `totalVolume` | Double | ✓ | Σ sets×reps×weight across weighted logs | | `hrZoneSeconds` | [Double] | ✓ | Seconds in each of 5 HR zones, low→high | | `healthKitWorkoutUUID` | String | ✓ | Presence is the phone/watch Health-write dedupe signal | | `source` | String | | Raw `MetricSource` | | `recordedAt` | Date | | | ## Tombstone (IndieSync, minimal stub) A delete writes a stub to `Stubs/…`, then removes the live file. A **full stub** is a verbatim copy of the record's JSON plus an injected `deletedAt`; the **minimal stub** below is the fallback when the source is unreadable. Stubs are pruned after a 30-day grace period, except starter-seed stubs (exempt, and they veto seed resurrection forever). | Property | Type | Optional | Notes | |---|---|---|---| | `id` | String | | The deleted record's ULID | | `deletedAt` | Date | | | | `kind` | String | ✓ | App-defined category | --- ## Enum raw values **WorkoutStatus** (String — on `WorkoutDocument.status`, `WorkoutLogDocument.status`): `notStarted`, `inProgress`, `completed`, `skipped` **LoadType** (Int — on `ExerciseDocument.loadType`, `WorkoutLogDocument.loadType`): `0` none · `1` weight · `2` duration **WorkoutActivityType** (Int — on `SplitDocument.activityType`): `0` traditionalStrength · `1` functionalStrength · `2` hiit · `3` coreTraining · `4` cardio · `5` cycling · `6` warmUp · `7` stretching · `8` mindAndBody **MetricSource** (String — on `WorkoutMetrics.source`): `watch`, `phoneEstimate` **WeightUnit** (`lb`/`kg`) is a display setting only — it is never persisted in documents, and stored weight values are never rewritten when it changes.