> ## Documentation Index
> Fetch the complete documentation index at: https://docs.synheart.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Session Lifecycle and State

> Session state machine, ownership, and watch / phone fallback semantics

The session SDK runs a small, deterministic state machine. The same machine runs on the phone (Dart `LiveSessionEngine`, Kotlin `SessionEngine`, Swift `SessionEngine`) and on the watch (watchOS / Wear OS companions). The wire protocol's `SessionState` is authoritative for cross-platform tooling.

## States

From `synheart-session/protos/session.proto`:

```text theme={null}
SESSION_STATE_IDLE       → SESSION_STATE_STARTING
SESSION_STATE_STARTING   → SESSION_STATE_ACTIVE       (or → TERMINATED on error)
SESSION_STATE_ACTIVE     → SESSION_STATE_STOPPING
SESSION_STATE_STOPPING   → SESSION_STATE_TERMINATED
SESSION_STATE_TERMINATED → SESSION_STATE_IDLE          (next session)
```

| State        | Meaning                                                                                                                                | Allowed inputs             |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `IDLE`       | No active session.                                                                                                                     | `StartSession`             |
| `STARTING`   | Provider warm-up: HealthKit `HKWorkoutSession.startActivity`, Health Services `ExerciseClient.startExercise`, BLE GATT subscribe, etc. | `StopSession` (cancels)    |
| `ACTIVE`     | Frames flowing. `seq` increases on every emit.                                                                                         | `StopSession`, `GetStatus` |
| `STOPPING`   | Wind-down: drain final window, compute summary, persist.                                                                               | (none)                     |
| `TERMINATED` | Session ended; `SessionSummary` or `SessionError` already delivered.                                                                   | (next `StartSession`)      |

## Phone-side state at the Dart layer

`SynheartSession` does not expose the proto enum directly. Instead it offers `SessionStatus`:

```dart theme={null}
class SessionStatus {
  final String sessionId;
  final bool active;     // true between SessionStarted and SessionSummary/Error
  final int lastSeq;
}
```

`active = true` corresponds to wire `STARTING ∪ ACTIVE`. The Dart layer collapses these because the engine emits `SessionStarted` only after the provider is hot — there is no public `STARTING` state for stream consumers.

The Kotlin and Swift SDKs match: their `SessionStatus` carries `(sessionId, active, lastSeq)`.

## Event-stream lifecycle

The Dart `Stream<SessionEvent>` lifecycle is fixed:

```text theme={null}
SessionStarted
  → SessionFrame (seq=1) → SessionFrame (seq=2) → ...
    [optionally interleaved BiosignalFrame events when includeRawSamples]
  → SessionSummary           (closes the stream)
        OR
  → SessionError             (closes the stream)
```

`SessionSummary` and `SessionError` close the controller automatically. The mock engine matches this contract.

## Who drives transitions

| Transition                            | Driver                                                                                                                                                                                                          |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `IDLE → STARTING`                     | `SynheartSession.startSession(config)` (phone). When `WatchBiosignalProvider` is in use, `SessionChannel.startSession(config)` also fires, sending `StartSession` over WatchConnectivity / Wearable Data Layer. |
| `STARTING → ACTIVE`                   | Provider's first sample is admitted. Engine emits `SessionStarted` then begins the periodic `Timer.periodic(emitIntervalSec)`.                                                                                  |
| Engine timer tick                     | `LiveSessionEngine` checks `elapsed >= durationSec` first; if exceeded, transitions to `STOPPING` immediately and emits final summary. Otherwise emits `SessionFrame`.                                          |
| `ACTIVE → STOPPING`                   | Either `SynheartSession.stopSession(id)` (host call) or duration elapsed. The watch companion runs the same logic against its local timer.                                                                      |
| `STOPPING → TERMINATED`               | Engine computes metrics, emits `SessionSummary`, closes controller.                                                                                                                                             |
| Any state → `TERMINATED` (error path) | Engine emits `SessionError` and closes; provider failures or panic surface here.                                                                                                                                |

## Phone-watch dual lifecycle

When `WatchBiosignalProvider` is configured, **two engines run simultaneously**:

* **Watch engine**: hot path. Reads sensors directly (HealthKit / Health Services / Samsung), runs windowing on-device, sends typed `SessionFrame` / `BiosignalFrame` over WatchConnectivity.
* **Phone engine** (Dart `LiveSessionEngine`): fed by `WatchBiosignalProvider` which pulls watch events through `SessionChannel.events` and re-emits them as `BiosignalSample`s.

The phone-side `LiveSessionEngine` re-windows the watch samples and emits its own `SessionFrame` events to the host. This double-windowing is intentional — it gives the host a single `Stream<SessionEvent>` regardless of source.

The phone subscribes via `provider.startStreaming()` before issuing `SessionChannel.startSession(config)`, so the first `SessionStarted` event from the watch is not missed.

## Fallback semantics

When the watch is unreachable but a `WatchBiosignalProvider` is configured, the SDK does **not** automatically fall back to a different provider. The phone-side engine still emits `SessionStarted` (the provider reports `isAvailable = true` optimistically) but the HR buffer stays empty until samples arrive. Frames will fire on schedule with zero `sample_count` and `hr_mean_bpm = 0.0`.

For real fallback, hosts must construct a different `SynheartSession` instance with a non-watch provider (e.g. `MockBiosignalProvider`, BLE HRM, `HealthConnectBiosignalProvider`).

The `WatchBiosignalProvider` does have a synthesis path: when only `SessionFrame` is forwarded (no raw samples), it derives a synthetic `BiosignalSample` from `metrics.hr_mean_bpm` with `rrIntervalMs = 60000.0 / bpm`. This keeps the phone engine's buffer non-empty even when the watch isn't streaming raw biosignals.

## Reconnect behaviour

If the watch disconnects mid-session:

* Watch engine continues running locally (it doesn't observe the connectivity drop synchronously).
* Phone-side `WatchBiosignalProvider` swallows channel errors as non-fatal.
* Buffered events on the watch may flush when the channel comes back, depending on the OS transport's queueing semantics.

The session does **not** auto-stop on disconnect. Hosts that want strict reachability behavior must call `getWatchStatus` and stop manually.

## Frame sequencing

`seq` is monotonic per session, starting at `1` for the first frame after `SessionStarted`. `BiosignalFrame.seq` and `SessionFrame.seq` are independent counters in the wire spec; the Dart SDK uses a single counter today.

`emittedAtMs` is wall-clock epoch ms at the producer (watch or phone). Hosts should treat watch and phone timestamps as on the same clock — both come from the OS — but cannot assume sub-second sync.

## Disposal

`SynheartSession.dispose()`:

1. Disposes both mock and live engines.
2. Closes all open `StreamController`s.
3. Sets the session as disposed.

After dispose, every public method throws `SessionInvalidStateError("SynheartSession has been disposed")`.

The watch-side companion is **not** stopped on `dispose()` — only on explicit `stopSession(id)`. If the host disposes mid-session without stopping, the watch keeps running until its own duration timer fires (worst case: `durationSec` seconds of orphaned battery use).

## Related

* [Providers](/synheart-session/providers) — what feeds the engine.
* [Watch Protocol](/synheart-session/watch-protocol) — the proto envelope.
* [Errors](/synheart-session/errors) — the closed-stream paths.
