---
phase: 01-ceiling-save-on-exit
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/lifx/devices/ceiling.py
  - tests/test_devices/test_state_ceiling.py
autonomous: true
requirements: [CEIL-01, CEIL-02, CEIL-03, CEIL-04, TEST-01, TEST-02, TEST-03]

must_haves:
  truths:
    - "Exiting `async with CeilingLight(..., state_file=path)` writes the device's current in-memory state to the file before the connection is closed (CEIL-01)"
    - "Exiting `async with CeilingLight(...)` with state_file=None creates no file and raises no error (CEIL-02)"
    - "When the async with body raises, the original exception propagates unchanged and any save-on-exit I/O failure is logged and swallowed (CEIL-03)"
    - "Existing __aenter__ (product-ID validation, load-from-file) and close() cleanup run unchanged; the full pytest suite stays green with pyright and ruff clean (CEIL-04)"
  artifacts:
    - path: "src/lifx/devices/ceiling.py"
      provides: "CeilingLight.__aexit__ override that saves state before super().__aexit__()"
      contains: "async def __aexit__"
    - path: "tests/test_devices/test_state_ceiling.py"
      provides: "TestCeilingLightSaveOnExit emulator test class (TEST-01/02/03)"
      contains: "class TestCeilingLightSaveOnExit"
  key_links:
    - from: "src/lifx/devices/ceiling.py::CeilingLight.__aexit__"
      to: "src/lifx/devices/ceiling.py::_save_state_to_file"
      via: "guarded synchronous call inside try/except"
      pattern: "if self\\._state_file"
    - from: "src/lifx/devices/ceiling.py::CeilingLight.__aexit__"
      to: "src/lifx/devices/base.py::Device.__aexit__"
      via: "unconditional super() chain so close() always runs"
      pattern: "super\\(\\)\\.__aexit__"
---

<objective>
Add a single `CeilingLight.__aexit__` override so that exiting an `async with CeilingLight(...)`
block persists the device's current in-memory state to `state_file` (when set) before the
inherited `close()` runs, then prove the behaviour with three emulator-backed tests covering the
happy path, the `state_file=None` no-op, and exit-during-exception semantics.

The change is purely additive: it reuses the existing `_save_state_to_file()` mechanism unchanged
(D-01), leaves the JSON schema and `__aenter__` behaviour untouched, and introduces no new
packages or persistence mixin (PERS-01 stays deferred).

Purpose: No uplight/downlight state is silently lost when a `CeilingLight` is used as an async
context manager — closing the block reliably saves state to disk.
Output: `CeilingLight.__aexit__` in `src/lifx/devices/ceiling.py` and a new
`TestCeilingLightSaveOnExit` class in `tests/test_devices/test_state_ceiling.py`.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-ceiling-save-on-exit/01-CONTEXT.md
@.planning/phases/01-ceiling-save-on-exit/01-RESEARCH.md
@.planning/phases/01-ceiling-save-on-exit/01-PATTERNS.md

# Source of truth — read before editing
@src/lifx/devices/ceiling.py
@src/lifx/devices/base.py
@tests/test_devices/test_state_ceiling.py
@tests/conftest.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add failing save-on-exit tests (RED)</name>
  <files>tests/test_devices/test_state_ceiling.py</files>
  <read_first>
    - tests/test_devices/test_state_ceiling.py — current imports (lines 1-9) and the existing
      emulator test style in `TestCeilingLightStateManagement` (lines 259-355) that uses the
      session-scoped `ceiling_device` fixture; the new class is appended here
    - tests/conftest.py — the `ceiling_device` fixture (lines 352-388): session-scoped, yields a
      `CeilingLight` with serial "d073d5000100", ip "127.0.0.1", port=<emulator port>, no state_file
    - tests/test_devices/test_ceiling.py — monkeypatch / MagicMock pattern for `_save_state_to_file`
      (around line 183) to mirror house style
    - .planning/phases/01-ceiling-save-on-exit/01-RESEARCH.md — the three worked test examples in the
      "Code Examples" section
  </read_first>
  <action>
    Append a new test class `TestCeilingLightSaveOnExit` to the end of
    `tests/test_devices/test_state_ceiling.py`, following the existing emulator test pattern (real
    `async with` blocks against the `ceiling_device` fixture, not mock-only) per D-05. Add a
    module-level `import json` at the top with the other stdlib imports if not already present.

    Construct fresh per-test devices with `CeilingLight(serial=ceiling_device.serial,
    ip=ceiling_device.ip, port=ceiling_device.port, state_file=...)` and use the function-scoped
    `tmp_path` fixture for the state file path. Add three `@pytest.mark.asyncio` methods:

    1. `test_save_on_exit_writes_state_file(self, ceiling_device, tmp_path)` — TEST-01/CEIL-01:
       enter `async with CeilingLight(..., state_file=str(tmp_path / "ceiling_state.json"))`, exit
       normally with an empty body, then assert the file exists and that
       `json.loads(state_file.read_text())` contains `ceiling_device.serial` as a key.
    2. `test_save_on_exit_no_op_without_state_file(self, ceiling_device, tmp_path)` — TEST-02/CEIL-02:
       enter `async with CeilingLight(...)` with NO state_file argument, exit normally, then assert
       `not any(tmp_path.iterdir())` (no file created, no error raised).
    3. `test_save_on_exit_body_exception_propagates(self, ceiling_device, tmp_path, caplog, monkeypatch)`
       — TEST-03/CEIL-03: per D-06, build the device, then inside the `async with` body use
       `monkeypatch.setattr(ceiling, "_save_state_to_file", ...)` to make it raise `OSError("disk full")`,
       then `raise RuntimeError("body error")`. Wrap the whole block in `pytest.raises(RuntimeError,
       match="body error")` to prove the body exception propagates unchanged, and assert that
       `caplog.records` contains a WARNING mentioning the save failure. Do NOT use an unwritable path —
       the helper would swallow it internally and the `__aexit__` boundary guard would go unexercised.

    These tests are RED until Task 2 implements `__aexit__`: tests 1 and 3 will fail (base
    `Device.__aexit__` only calls `close()`, never saves), test 2 passes trivially.
  </action>
  <verify>
    <automated>uv run pytest tests/test_devices/test_state_ceiling.py -k SaveOnExit -v</automated>
  </verify>
  <acceptance_criteria>
    - `grep -n "class TestCeilingLightSaveOnExit" tests/test_devices/test_state_ceiling.py` returns a match
    - The three methods `test_save_on_exit_writes_state_file`, `test_save_on_exit_no_op_without_state_file`,
      and `test_save_on_exit_body_exception_propagates` exist (grep each name)
    - `uv run pytest tests/test_devices/test_state_ceiling.py -k SaveOnExit -v` COLLECTS exactly 3 tests
    - RED state confirmed: `test_save_on_exit_writes_state_file` and
      `test_save_on_exit_body_exception_propagates` FAIL; `test_save_on_exit_no_op_without_state_file` PASSES
    - No mock-only setup: each test enters a real `async with CeilingLight(...)` block (D-05)
  </acceptance_criteria>
  <done>The TestCeilingLightSaveOnExit class with three emulator tests exists and is collected; the two implementation-dependent tests fail as expected (RED).</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Implement CeilingLight.__aexit__ (GREEN)</name>
  <files>src/lifx/devices/ceiling.py</files>
  <read_first>
    - src/lifx/devices/ceiling.py — `__aenter__` override (lines 191-205) as the in-file chaining
      template; the guarded save call-site idiom `if self._state_file: self._save_state_to_file()`
      (e.g. lines 498-499); `_save_state_to_file()` internal error handling (lines 1263-1316);
      `_LOGGER` declaration (line 34)
    - src/lifx/devices/base.py — `Device.__aexit__` (lines 644-652) for the exact signature and
      return type to match (`exc_type`, `exc_val`, `exc_tb` → `None`)
  </read_first>
  <behavior>
    - With state_file set and a normal exit: `_save_state_to_file()` is called once, then `close()` runs (TEST-01)
    - With state_file=None: `_save_state_to_file()` is NOT called from the call site; no file is written (TEST-02)
    - When `_save_state_to_file()` raises: the exception is caught, a WARNING is logged, and `super().__aexit__()` still runs (TEST-03)
    - The method returns None so any body exception re-raises automatically (TEST-03)
  </behavior>
  <action>
    Add an `async def __aexit__(self, exc_type, exc_val, exc_tb) -> None` method to `CeilingLight`,
    placed immediately after `__aenter__` (after line 205, before the next method). Match the parent
    signature in `src/lifx/devices/base.py` exactly: parameters `exc_type: type[BaseException] | None`,
    `exc_val: BaseException | None`, `exc_tb: object`, return annotation `-> None`.

    Body, in order:
    1. Guard with `if self._state_file:` at the call site (D-02), mirroring the 8 existing guarded
       call sites — even though the helper also early-returns on None, this makes CEIL-02 explicit.
    2. Inside the guard, call `self._save_state_to_file()` directly and synchronously (D-01) — no
       `asyncio.to_thread`. Wrap that call in `try/except Exception as e` (D-03) and, on failure, emit
       `_LOGGER.warning(...)` referencing `self.serial` so the boundary-guard warning is distinguishable
       from the helper's own warning (which references `self._state_file`). Use Australian English in
       the docstring and message. Choose the exact wording (Claude's Discretion); match the house
       `_LOGGER.warning` style used at line 1316.
    3. After the guard (unconditionally, even if the save attempt raised), `await
       super().__aexit__(exc_type, exc_val, exc_tb)` so `close()` always runs (D-04, CEIL-04).

    Save BEFORE `super().__aexit__()` — the opposite ordering from `__aenter__` (where `super()` runs
    first to open the connection). The save reads in-memory state and does not require an open
    connection. Do not return `True` and do not add a return statement other than the implicit `None`
    — returning a truthy value would suppress the body exception and violate CEIL-03. Do not touch
    `__aenter__`, `_save_state_to_file`, or the JSON schema.
  </action>
  <verify>
    <automated>uv run pytest tests/test_devices/test_state_ceiling.py -k SaveOnExit -v</automated>
  </verify>
  <acceptance_criteria>
    - `grep -n "async def __aexit__" src/lifx/devices/ceiling.py` returns a match inside the CeilingLight class
    - The method body contains the guard `if self._state_file:` and a call to `self._save_state_to_file()`
    - The method ends by awaiting `super().__aexit__(exc_type, exc_val, exc_tb)` (grep `super().__aexit__`)
    - The method has no `return True` / no truthy return (grep confirms only implicit None return)
    - All three SaveOnExit tests now PASS (GREEN): `uv run pytest tests/test_devices/test_state_ceiling.py -k SaveOnExit -v`
    - `uv run pyright src/lifx/devices/ceiling.py` reports no new errors on the file
  </acceptance_criteria>
  <done>CeilingLight.__aexit__ saves state (when state_file set) inside a try/except before unconditionally chaining to super().__aexit__(); all three SaveOnExit tests pass.</done>
</task>

<task type="auto">
  <name>Task 3: Phase quality gate (full suite + pyright + ruff)</name>
  <files>src/lifx/devices/ceiling.py, tests/test_devices/test_state_ceiling.py</files>
  <read_first>
    - CLAUDE.md — Essential Commands (Code Quality + Testing sections) for the exact gate commands
    - pyproject.toml — `[tool.pyright]` (typeCheckingMode = "standard", line ~82) and
      `[tool.pytest.ini_options]` (asyncio_mode, line ~95) so the gate runs in the project-configured mode
  </read_first>
  <action>
    Run the project quality gates and resolve any failures introduced by Tasks 1-2 (do not weaken
    tests or types to make them pass; fix the root cause). This proves CEIL-04 — that `__aenter__`,
    `close()`, and the rest of the suite are unaffected — and ROADMAP success criterion 4.

    1. Format and lint: `uv run ruff format .` then `uv run ruff check . --fix`; both must end clean.
    2. Type check: `uv run pyright` — must report no errors (run in the project-configured "standard"
       mode per pyproject.toml; do not invoke `--strict`, per the RESEARCH pyright-mode note).
    3. Full regression suite: `uv run --frozen pytest` — must be fully green, confirming the existing
       2425+ tests still pass alongside the three new SaveOnExit tests.

    If any pre-existing test fails for reasons unrelated to this change, investigate and report rather
    than silently skipping (per project rules: never ignore a failing test).
  </action>
  <verify>
    <automated>uv run ruff format . && uv run ruff check . && uv run pyright && uv run --frozen pytest</automated>
  </verify>
  <acceptance_criteria>
    - `uv run ruff format .` reports no files reformatted (or only the two touched files, already clean on rerun)
    - `uv run ruff check .` exits 0 with no remaining lint errors
    - `uv run pyright` exits 0 with 0 errors
    - `uv run --frozen pytest` exits 0 — full suite green including the three SaveOnExit tests
  </acceptance_criteria>
  <done>ruff (format + check), pyright, and the full pytest suite all pass with the new method and tests in place.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| application code → local filesystem | `__aexit__` triggers `_save_state_to_file()`, which writes the device's own in-memory state as JSON to the caller-supplied `state_file` path. The state is not externally-supplied input; the path is caller-controlled. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Information Disclosure / Tampering | state-file write at caller-supplied path in `_save_state_to_file()` | accept | Path handling is unchanged from the existing, already-shipped helper (`Path(...).expanduser()`); no new path parsing is introduced; the data written is the device's own state, not untrusted input. Out of scope per REQUIREMENTS (no schema/path changes). |
| T-01-02 | Denial of Service | `__aexit__` could raise from the save attempt and skip connection cleanup | mitigate | Belt-and-braces `try/except Exception` around the save (D-03) plus unconditional `await super().__aexit__()` (D-04) guarantee `close()` always runs; proven by `test_save_on_exit_body_exception_propagates`. |
| T-01-SC | Tampering | npm/pip/cargo installs | accept | No package installs this phase — purely additive code within the existing zero-dependency library; nothing to verify. |
</threat_model>

<verification>
Phase-level checks (also enforced by Task 3):
- `uv run pytest tests/test_devices/test_state_ceiling.py -k SaveOnExit -v` — three new tests green
- `uv run --frozen pytest` — full suite green (CEIL-04 regression gate)
- `uv run pyright` — clean (standard mode per pyproject.toml)
- `uv run ruff format .` and `uv run ruff check .` — clean
- `grep -n "async def __aexit__" src/lifx/devices/ceiling.py` — the override exists
- `grep -n "super().__aexit__" src/lifx/devices/ceiling.py` — chains to parent cleanup
</verification>

<success_criteria>
- CEIL-01: Exiting `async with CeilingLight(..., state_file=path)` writes current state to `path` before close — proven by `test_save_on_exit_writes_state_file` (TEST-01).
- CEIL-02: Exiting with `state_file=None` writes no file and raises no error — proven by `test_save_on_exit_no_op_without_state_file` (TEST-02).
- CEIL-03: A body exception propagates unchanged; a save I/O failure is logged and swallowed — proven by `test_save_on_exit_body_exception_propagates` (TEST-03).
- CEIL-04: `__aenter__` and `close()` behaviour unchanged; full suite green with pyright + ruff clean — proven by Task 3.
- All three success criteria are exercised through real emulator-backed `async with` blocks (D-05), not mocks.
</success_criteria>

<artifacts_produced>
## Artifacts this phase produces

Symbols created by this phase (newly introduced — not expected to exist in the current API surface):

- `CeilingLight.__aexit__` — async context-manager exit override in `src/lifx/devices/ceiling.py`
  (signature: `(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: object) -> None`)
- `TestCeilingLightSaveOnExit` — test class in `tests/test_devices/test_state_ceiling.py`
- `TestCeilingLightSaveOnExit.test_save_on_exit_writes_state_file` — test method (TEST-01)
- `TestCeilingLightSaveOnExit.test_save_on_exit_no_op_without_state_file` — test method (TEST-02)
- `TestCeilingLightSaveOnExit.test_save_on_exit_body_exception_propagates` — test method (TEST-03)

No new files, classes, functions, decorators, CLI flags, dataclass fields, or dependencies beyond
the above. Reuses existing `_save_state_to_file`, `_LOGGER`, `_state_file`, the `ceiling_device` /
`tmp_path` / `caplog` / `monkeypatch` fixtures, and `Device.__aexit__`.
</artifacts_produced>

<output>
Create `.planning/phases/01-ceiling-save-on-exit/01-01-SUMMARY.md` when done
</output>
