---
phase: 01-unify-duplicated-discovery-loops
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/lifx/network/utils.py
  - tests/test_network/test_utils.py
autonomous: true
requirements:
  - D-06
user_setup: []

must_haves:
  truths:
    - "A shared IdleDeadline class exists in utils.py and is importable (D-06)"
    - "IdleDeadline.remaining() returns <= 0 once either the overall timeout or the idle timeout is exceeded (D-06)"
    - "IdleDeadline.mark_response() resets the idle timer so remaining() extends beyond the idle window (D-06)"
    - "IdleDeadline uses time.monotonic() exclusively, never time.time() (D-06)"
    - "Distinct idle vs overall expiry can be distinguished for the two existing per-type timeout log messages (D-06, Claude's discretion)"
  artifacts:
    - path: "src/lifx/network/utils.py"
      provides: "IdleDeadline class alongside existing allocate_source"
      contains: "class IdleDeadline"
    - path: "tests/test_network/test_utils.py"
      provides: "IdleDeadline unit tests (overall expiry, idle expiry, mark_response reset, overall caps idle)"
      contains: "from lifx.network.utils import IdleDeadline"
  key_links:
    - from: "tests/test_network/test_utils.py"
      to: "lifx.network.utils.IdleDeadline"
      via: "import + instantiation"
      pattern: "from lifx.network.utils import IdleDeadline"
---

<objective>
Extract the triplicated idle/overall-timeout arithmetic (present identically in
`_discover_with_packet`, `discover_devices`, and the mDNS `discover_lifx_services` loop) into a
single reusable `IdleDeadline` class in `src/lifx/network/utils.py`, with full unit-test coverage
(D-06). This plan is the foundation consumed by the discovery-core refactor (Plan 02) and the
mDNS adoption (Plan 03); both depend on the exact public surface defined here.

Purpose: Eliminate the third drift incident risk (the mDNS copy used `time.time()` until commit
`2f6404a`) by centralising the monotonic dual-deadline logic. Defining the contract first means
downstream plans build against a known interface rather than reverse-engineering it.

Output: `IdleDeadline` class + `tests/test_network/test_utils.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/phases/01-unify-duplicated-discovery-loops/01-CONTEXT.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md
@.planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add IdleDeadline class to network/utils.py</name>
  <files>src/lifx/network/utils.py</files>
  <read_first>
    - src/lifx/network/utils.py (current file — module docstring on line 1, `allocate_source` is the only existing member; preserve stdlib-only import style)
    - src/lifx/network/discovery.py (lines 231–259 — the exact timeout arithmetic IdleDeadline must encapsulate)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (IdleDeadline Design section, lines 259–312 — proposed interface and per-type log requirement)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (utils.py section, lines 22–69)
  </read_first>
  <behavior>
    - IdleDeadline(timeout=1.0, idle_timeout=10.0).remaining() returns a positive value close to timeout immediately after construction.
    - After the overall timeout elapses (mock time.monotonic to advance past `timeout`), remaining() returns <= 0.
    - After idle_timeout elapses with no mark_response() call (mock time.monotonic), remaining() returns <= 0.
    - Calling mark_response() resets the idle clock so a subsequent remaining() is bounded by the idle window measured from the mark, not from construction.
    - remaining() is always capped by the overall deadline: even after repeated mark_response() calls, remaining() never exceeds (timeout - elapsed_since_start).
    - The class exposes a way to distinguish idle expiry from overall expiry (e.g. `idle_expired` and `overall_expired` boolean properties) so callers can keep the two distinct DEBUG log messages.
  </behavior>
  <action>
    Add a `class IdleDeadline` to `src/lifx/network/utils.py` below `allocate_source`, adding only
    `import time` to the existing import block (keep stdlib-only — zero runtime dependencies).

    Constructor signature: `__init__(self, timeout: float, idle_timeout: float) -> None`. Capture
    the start instant with `time.monotonic()` and store `timeout`, `idle_timeout`, and a
    `last_response` instant initialised to the start.

    Public methods/properties (full type annotations for strict Pyright):
    - `remaining(self) -> float` — returns `min(overall_remaining, idle_remaining)` computed from a
      single `time.monotonic()` read per call; returns <= 0 when either deadline is exceeded
      (assumption A1: caching one monotonic read per call avoids intra-iteration skew).
    - `mark_response(self) -> None` — resets the idle clock to the current `time.monotonic()`.
    - `idle_expired` property (`-> bool`) — True when the idle window since `last_response` is
      exceeded.
    - `overall_expired` property (`-> bool`) — True when the overall window since start is exceeded.
    - `expired` property (`-> bool`) — True when `idle_expired or overall_expired` (convenience).

    Use `time.monotonic()` exclusively (never `time.time()`); attribute names are Claude's
    discretion per CONTEXT.md. Add an Australian-English class docstring describing the dual
    deadline. This is per D-06; the `idle_expired`/`overall_expired` split satisfies the
    Claude's-discretion item allowing the two distinct timeout log messages to be preserved.
  </action>
  <verify>
    <automated>uv run pyright src/lifx/network/utils.py && uv run ruff check src/lifx/network/utils.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "class IdleDeadline" src/lifx/network/utils.py` returns 1.
    - `src/lifx/network/utils.py` contains the methods `remaining`, `mark_response` and the properties `idle_expired`, `overall_expired`.
    - `grep -v '^#' src/lifx/network/utils.py | grep -c "time.time(")` returns 0 (no wall-clock usage).
    - `uv run pyright src/lifx/network/utils.py` reports 0 errors.
    - `python -c "from lifx.network.utils import IdleDeadline; d=IdleDeadline(1.0, 10.0); assert d.remaining() > 0"` succeeds under `uv run`.
  </acceptance_criteria>
  <done>IdleDeadline class exists in utils.py with remaining()/mark_response()/idle_expired/overall_expired, monotonic-only, 0 Pyright errors.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add IdleDeadline unit tests</name>
  <files>tests/test_network/test_utils.py</files>
  <read_first>
    - src/lifx/network/utils.py (the IdleDeadline class created in Task 1 — exact method/property names to assert against)
    - tests/test_network/test_discovery_errors.py (lines 1–17 for header/import style; pure-unit class structure)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (test_utils.py section, lines 346–386 — file header pattern and mock-time guidance)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (New IdleDeadline tests table, lines 577–584)
  </read_first>
  <behavior>
    - test_idle_deadline_remaining_positive_on_construction: remaining() > 0 right after IdleDeadline(timeout=5.0, idle_timeout=2.0).
    - test_idle_deadline_overall_expires: patching time.monotonic to advance past `timeout` makes remaining() <= 0 and overall_expired True.
    - test_idle_deadline_idle_expires: advancing past `idle_timeout` with no mark_response() makes remaining() <= 0 and idle_expired True.
    - test_idle_deadline_mark_response_resets_idle: mark_response() at an advanced time extends remaining() beyond the original idle window.
    - test_idle_deadline_overall_caps_idle: after mark_response() calls, remaining() never exceeds (timeout - elapsed_since_start).
  </behavior>
  <action>
    Create `tests/test_network/test_utils.py` with the standard project header
    (`"""Tests for IdleDeadline utility class."""`, `from __future__ import annotations`,
    `import pytest`, `from lifx.network.utils import IdleDeadline`). These are pure synchronous
    unit tests — do NOT use the emulator and do NOT use real sleeps. Use
    `unittest.mock.patch("lifx.network.utils.time.monotonic", ...)` (or patch the bound name the
    class actually reads) returning a controllable sequence of values so each deadline boundary is
    asserted deterministically. Cover the five behaviours listed above (D-06, D-11 unit-level
    coverage of the helper). Place under `tests/test_network/` per Claude's discretion on test
    placement; this is a NEW file (the existing `tests/test_utils.py` at the repo root is
    unrelated and must not be edited).
  </action>
  <verify>
    <automated>uv run --frozen pytest tests/test_network/test_utils.py -v</automated>
  </verify>
  <acceptance_criteria>
    - `tests/test_network/test_utils.py` exists and imports `IdleDeadline`.
    - `uv run --frozen pytest tests/test_network/test_utils.py -v` collects and passes at least 5 tests.
    - At least one test asserts `overall_expired` and at least one asserts `idle_expired`.
    - `grep -c "time.sleep" tests/test_network/test_utils.py` returns 0 (mocked time, no real sleeps).
    - `uv run ruff check tests/test_network/test_utils.py` reports no errors.
  </acceptance_criteria>
  <done>tests/test_network/test_utils.py passes with >=5 deterministic IdleDeadline tests covering overall/idle expiry, mark_response reset, and overall-caps-idle.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| (none new) | This plan adds a pure timing helper with no network or external-input surface |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01 | Denial of Service | IdleDeadline used as discovery loop terminator | accept | Helper enforces the overall + idle deadlines that bound every discovery loop; correctness is proven by unit tests asserting remaining() <= 0 on expiry. No external input reaches this class directly. |

ASVS L1: No HIGH-severity items. No package installs (Package Legitimacy Gate N/A — stdlib only).
</threat_model>

<verification>
- `uv run pyright` reports 0 errors (strict).
- `uv run ruff check .` reports no errors.
- `uv run --frozen pytest tests/test_network/test_utils.py -v` passes.
</verification>

<success_criteria>
- IdleDeadline class exists in `src/lifx/network/utils.py`, monotonic-only, fully typed.
- `idle_expired`/`overall_expired`/`expired`/`remaining()`/`mark_response()` surface present.
- New `tests/test_network/test_utils.py` passes with deterministic (mocked-time) tests.
- D-06 satisfied; interface ready for Plans 02 and 03 to consume.
</success_criteria>

<artifacts_produced>
## Artifacts this phase produces (Plan 01)

- New file: `tests/test_network/test_utils.py`
- New class: `lifx.network.utils.IdleDeadline`
  - Method `IdleDeadline.__init__(self, timeout: float, idle_timeout: float) -> None`
  - Method `IdleDeadline.remaining(self) -> float`
  - Method `IdleDeadline.mark_response(self) -> None`
  - Property `IdleDeadline.idle_expired -> bool`
  - Property `IdleDeadline.overall_expired -> bool`
  - Property `IdleDeadline.expired -> bool`
- New import in `src/lifx/network/utils.py`: `import time`
</artifacts_produced>

<output>
Create `.planning/phases/01-unify-duplicated-discovery-loops/01-01-SUMMARY.md` when done.
</output>
