# Open Sit 1.0 — an open format for meditation session history

*Status: 1.0, frozen 2026-09-10. License: CC0 1.0 (public domain dedication). Schema: `schema/1.0.json`. Fixtures: `fixtures/`. Changelog: `CHANGELOG.md`.*

Open Sit is a small JSON file that carries a person's meditation session history from one app to another. It exists so that practice history belongs to the practitioner, not to the app that happened to record it.

## Principles

1. **Small.** One document, one array of sessions, a handful of optional fields. A human can read it.
2. **A file, never an API.** No account, no server, no upload. Export writes a file; import reads one.
3. **Lossless for the generator.** An app that exports Open Sit and imports it back gets every field it wrote.
4. **Honest about loss.** Readers document what a source cannot carry; nothing is invented to fill a gap.
5. **Extensible without breaking.** Unknown keys are ignored. Vendor data lives under `extra`. Only a major version change may break readers.

## Document

```json
{
  "$schema": "https://timefully.co/open-sit/schema/1.0.json",
  "opensit": "1.0",
  "exported_at": "2026-09-10T20:15:00+02:00",
  "generator": { "name": "Timefully iOS", "version": "5.11.0" },
  "sessions": [ ]
}
```

| Field | Type | Required | Meaning |
|---|---|---|---|
| `$schema` | string (URI) | no | Points at the schema for editors and adopters. |
| `opensit` | string | yes | Format version, `MAJOR.MINOR`. This document describes `1.x`. |
| `exported_at` | string, RFC 3339 date-time with offset | yes | When the file was written. |
| `generator` | object `{ name, version? }` | yes | The app that wrote the file. |
| `sessions` | array of Session | yes | Zero or more sessions, any order. Readers should not assume sorting. |
| `extra` | object | no | Vendor-specific document data. |

### Versioning

- `1.x` is **additive only**. A `1.7` reader can read a `1.0` file; a `1.0` reader reads a `1.7` file and ignores what it does not know.
- A reader **refuses** a file whose major version it does not implement, with a message naming the version. It never guesses.
- A breaking change means `2.0`. Timefully commits to reading every `1.x` for as long as the apps exist.

## Session

| Field | Type | Required | Meaning |
|---|---|---|---|
| `id` | string, UUID | yes | Stable identity of the session in the generator. Used first for duplicate detection. |
| `started_at` | string, RFC 3339 date-time **with offset** | yes | Local wall-clock start with its UTC offset. |
| `timezone` | string, IANA zone id | no | The zone the session was recorded in (e.g. `Europe/Stockholm`). Omit when unknown — do not guess. |
| `duration_seconds` | number ≥ 1 | yes | **Total timed duration**, including warm-up and cool-down. Integers preferred; decimals tolerated on read. |
| `segments` | array of Segment | no | Breakdown of the total. When present, the segment durations **must sum** to `duration_seconds`. |
| `title` | string | no | A name the source gave the session (a preset, a course, a label). |
| `note` | string | no | Free-text reflection written by the practitioner. |
| `method` | string | no | Practice type as an open string. Documented values: `meditation`, `yoga`, `breathwork`, `walking`, `body_scan`. Omit rather than invent. |
| `mood` | Mood | no | Post-session mood. |
| `health` | Health | no | Biometrics recorded during the session. |
| `source` | Source | yes | Where this session originally came from. |
| `extra` | object | no | Vendor-specific session data. |

### Segment

| Field | Type | Required | Meaning |
|---|---|---|---|
| `kind` | string | yes | Documented values: `warmup`, `main`, `cooldown`. Readers fold unknown kinds into `main`. |
| `duration_seconds` | number ≥ 0 | yes | Length of the segment. |

A file with no `segments` puts the whole `duration_seconds` on `main`. Generators should omit `segments` when there is only a `main` segment.

### Mood

| Field | Type | Required | Meaning |
|---|---|---|---|
| `label` | string, `^[a-z0-9_]+$` | yes | The mood name in snake_case. |
| `intensity` | number 0–1 | no | How strongly the mood was felt. |
| `valence` | number −1–1 | no | Pleasant (+) to unpleasant (−). Derived; for tools that model mood on axes. |
| `arousal` | number −1–1 | no | Activated (+) to deactivated (−). Derived. |

Suggested vocabulary (Timefully's wheel, in wheel order): `happy`, `relaxed`, `calm`, `tired`, `bored`, `sad`, `angry`, `nervous`, `excited`, `elated`, and `neutral`. The vocabulary is **not closed**; readers keep labels they understand and ignore the rest.

Timefully writes `intensity` as `0.33` (slightly), `0.67` (medium), `1.0` (strongly) and reads it back to the nearest of those three. `neutral` carries no intensity. `valence` and `arousal` are exported as `intensity · cos θ` and `intensity · sin θ` with θ = (index − 1) · 36° over the wheel order above (`happy` = 0°); they are **ignored on import**.

### Health

| Field | Type | Required | Meaning |
|---|---|---|---|
| `average_hr_bpm` | number > 0 | no | Average heart rate over the session. |
| `average_hrv_sdnn_ms` | number > 0 | no | Average HRV as SDNN in milliseconds. |
| `source_device` | string | no | e.g. `Apple Watch`, `Pixel Watch`. |

### Source

| Field | Type | Required | Meaning |
|---|---|---|---|
| `name` | string, non-empty | yes | The originating app or system: `timefully`, `insight_timer`, `csv`, or a display name relayed from a health platform (`Calm`, `Headspace`, `Mindfulness`). |
| `id` | string | no | The session's id in the source, when the source has a stable one. |
| `via` | string | no | The relay a session travelled through: `apple_health` or `health_connect`. |

`source` is **provenance**: it tells the next reader where the session began, so that the same sit is never counted twice.

## Duplicate detection (normative for readers that merge)

A reader merging a file into an existing history checks, in this order, within the target profile:

1. `id` equals an existing session id.
2. `(source.name, source.id)` equals an existing session's provenance pair, when `source.id` is present.
3. Fallback: same calendar **minute** (evaluated in the session's own zone — `timezone`, else the offset in `started_at`) and same **total** duration in whole seconds.

Rule 3 also catches the same sit arriving from two directions (for example a CSV export and a health-platform relay). Readers present duplicates in a preview and skip them by default; a reader may offer to import them anyway.

Health-platform readers additionally skip records the reader itself wrote to that platform (Timefully: HealthKit metadata `session_id` or the Timefully bundle id; Health Connect `clientRecordId` matching a local session id or Timefully as the recording package).

## Validation on read

- Skip sessions with `duration_seconds < 1` and count them as skipped with a reason.
- Warn (never drop) on `started_at` more than 24 hours in the future or before 1990-01-01.
- If `segments` do not sum to `duration_seconds` (tolerance 1 second), trust `duration_seconds` and put the difference on `main`; count a warning.
- Every skipped row is a counted reason. Silent drops are a conformance failure.

## Timefully mapping

### Export (lossless for Timefully)

| Timefully | Open Sit |
|---|---|
| `sharedId` / Room `id` | `id` |
| `date` in `recordedTimeZone` | `started_at` with that zone's offset; `timezone` = the IANA id when stored, omitted for legacy sessions |
| `warmupDuration + duration + cooldownDuration` | `duration_seconds` |
| warm-up / main / cool-down | `segments` — only when warm-up or cool-down > 0 |
| `note` | `note` |
| profile type yoga | `method: "yoga"`; otherwise omitted |
| `sessionEmotion`, `sessionEmotionStrength` | `mood.label`, `mood.intensity` (+ derived `valence`, `arousal`) |
| `averageHeartRate` (> 0) | `health.average_hr_bpm` |
| `averageHRV` (> 0, iOS only) | `health.average_hrv_sdnn_ms` |
| entry format watch | `health.source_device: "Apple Watch"` / `"Wear OS"` |
| `sourceName`, `sourceId` (nil → `timefully`, own id) | `source.name`, `source.id` |
| entry format health | `source.via` = `apple_health` (iOS) / `health_connect` (Android) |

Timefully has no `title`; it never writes one. Filename: `Timefully-practice-YYYY-MM-DD.opensit.json`.

### Import

| Open Sit | Timefully |
|---|---|
| `id` | `sharedId` / Room `id` (kept when it does not collide) |
| `started_at` | `date`; `timeZoneIdentifier` = `timezone` when present, else the zone matching the offset is **not** guessed — the device zone is stored |
| `duration_seconds`, `segments` | warm-up / main / cool-down; no segments → all on main; unknown kinds → main |
| `title` | first line of `note` (note empty → title becomes the note; both present → title, blank line, note) |
| `note` | `note` |
| `method` | ignored (the active profile decides the type) |
| `mood.label`, `mood.intensity` | `sessionEmotion`, `sessionEmotionStrength` (nearest of 0.33 / 0.67 / 1.0; missing intensity → medium; `neutral` → neutral strength; unknown label → not provided) |
| `health.average_hr_bpm` | `averageHeartRate` |
| `health.average_hrv_sdnn_ms` | `averageHRV` on iOS; ignored on Android (no column) |
| `source.name`, `source.id` | `sourceName`, `sourceId` |
| `source.via` present | entry format health; otherwise entry format imported |

Import lands on the **active profile**. Imported sessions count for streaks and statistics; they do not grow trees or unlock species, and they are never written back to a health platform.

## Other sources Timefully reads (compatibility projections)

### Timefully CSV (unchanged, 12 columns)

`Date,Time,Profile name,Duration,Warmup,Cooldown,Entry Method,Note,Average HR,Activation,Positiveness,Session Mood`

`Duration` is **main only** (a deliberate difference from Open Sit). `Date`/`Time` are local wall-clock without a zone. Loses: id, timezone, HRV, source. Fixture: `fixtures/timefully-csv-v1.csv` → `fixtures/timefully-csv-v1.expected.json`.

### Insight Timer export (`Started At,Duration,Preset,Activity`)

Verified against a real export on 2026-09-10.

| Column | Format | Open Sit |
|---|---|---|
| `Started At` | `MM/dd/yyyy HH:mm:ss`, US order, 24-hour, no zone | `started_at` in the **device** zone; `timezone` omitted |
| `Duration` | unpadded `H:M:SS` (`0:0:23` = 23 s) | `duration_seconds` (all main) |
| `Preset` | text, may be empty or quoted | `title` when non-empty |
| `Activity` | text | `title` when `Preset` is empty; `method: "meditation"` only when the value is the generic `Meditation` |

No id, so `source.id` is absent and duplicates fall to rule 3 with **second** precision (the export has seconds). Loses: notes, mood, biometrics, segments, timezone. Parsers must use a fixed `en_US_POSIX` locale — the export is US-formatted regardless of the user's region. Fixture: `fixtures/insight-timer-v1.csv` → `fixtures/insight-timer-v1.expected.json`.

### Apple Health / Health Connect mindful sessions

| Platform field | Open Sit |
|---|---|
| sample start | `started_at` (device zone) |
| end − start | `duration_seconds` (all main) |
| contributing app name (`HKSource.name` / package name mapped to a display name) | `source.name` |
| sample UUID (`HKObject.uuid` / `metadata.id`) | `source.id` |
| — | `source.via` = `apple_health` / `health_connect` |

Loses: notes, mood, segments. Readers skip their own writes (see duplicate detection).

## Privacy

An Open Sit file is personal data: it may contain notes and biometrics. Generators should say so where the file is shared. Readers process the file on the device; nothing in this format requires a network.

## Conformance

A generator conforms when its output validates against `schema/1.0.json` and its round trip is lossless for the fields it writes. A reader conforms when it decodes every file in `fixtures/valid/`, refuses `fixtures/invalid/major-2.json`, handles the other invalid fixtures as this document describes, and produces the `*.expected.json` output for the compatibility fixtures (ignoring generated ids and honouring the `assumed_timezone` each expected file declares).

The fixtures are published beside this document under `fixtures/` (`FIXTURES_SHA` pins their contents). The validator script that checks them against the schema and the segment-sum rule ships with the specification's source tree.
