---
phase: 01-unify-duplicated-discovery-loops
plan: 02
type: execute
wave: 2
depends_on:
  - 01-01
files_modified:
  - src/lifx/network/discovery.py
autonomous: true
requirements:
  - D-01
  - D-02
  - D-03
  - D-04
  - D-05
user_setup: []

must_haves:
  truths:
    - "Serial validation (broadcast/multicast bit set, or all-0xff serial) is enforced unconditionally inside _discover_with_packet, so every discovery caller (including find_by_label and the label/colour enrichment paths) is hardened (D-01, D-03)"
    - "Rejected serials are skipped with a DEBUG log (not WARNING) following the dict-style structured log convention (D-02)"
    - "Per-serial deduplication lives in _discover_with_packet: the first response per serial wins, each device yielded at most once, fixing find_by_label's latent duplicate-yield (D-04)"
    - "The vestigial write-only `responses` dict in _discover_with_packet is removed (D-04)"
    - "discover_devices is a thin wrapper over _discover_with_packet(DevicePackets.GetService(), ...) that extracts the device port from response_payload['port'] and constructs DiscoveredDevice (D-05)"
    - "_parse_device_state_service is deleted and the now-unused `import struct` is removed; StateService.unpack() via the packet registry is the single source of truth (D-05)"
    - "_discover_with_packet uses IdleDeadline for its timeout arithmetic, resetting the idle timer on every valid protocol response before the dedup check so duplicate floods do not cause premature idle expiry (D-04, Pitfall 1)"
    - "Public behaviour of discover_devices is preserved for all existing callers (api.py discover/find_by_serial/find_by_ip, devices/base.py)"
  artifacts:
    - path: "src/lifx/network/discovery.py"
      provides: "Unified discovery: _discover_with_packet with hoisted validation+dedup+IdleDeadline; thin discover_devices wrapper; _parse_device_state_service deleted"
      contains: "async for resp in _discover_with_packet"
  key_links:
    - from: "src/lifx/network/discovery.py discover_devices"
      to: "_discover_with_packet"
      via: "async for resp in _discover_with_packet(DevicePackets.GetService(), ...)"
      pattern: "async for resp in _discover_with_packet"
    - from: "src/lifx/network/discovery.py discover_devices"
      to: "StateService payload port"
      via: "resp.response_payload['port']"
      pattern: "response_payload\\[.port.\\]"
    - from: "src/lifx/network/discovery.py _discover_with_packet"
      to: "lifx.network.utils.IdleDeadline"
      via: "import + instantiation"
      pattern: "IdleDeadline\\("
---

<objective>
Rebuild `src/lifx/network/discovery.py` so the two near-identical loops collapse into one. Hoist
serial DoS validation (D-01/D-02/D-03) and per-serial dedup (D-04) into `_discover_with_packet`,
replace its inline timeout arithmetic with the `IdleDeadline` helper from Plan 01, then thin
`discover_devices` down to a wrapper over `_discover_with_packet(DevicePackets.GetService(), ...)`
and delete the hand-rolled `_parse_device_state_service` in favour of the protocol layer's
`StateService.unpack()` (D-05).

Purpose: Remove ~150 lines of drifted duplication, extend the documented DoS protections to every
discovery caller (currently only `discover_devices` validates serials), and make the packet
registry the single source of truth for StateService parsing.

Output: Refactored `discovery.py` — public API of `discover_devices` unchanged for existing
callers, with the two deliberate hardening side effects from CONTEXT.md (D-03 invalid-serial
rejection now covers all paths; D-04 duplicates no longer double-yield).
</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
@.planning/phases/01-unify-duplicated-discovery-loops/01-01-SUMMARY.md
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Hoist serial validation + dedup into _discover_with_packet and adopt IdleDeadline</name>
  <files>src/lifx/network/discovery.py</files>
  <read_first>
    - src/lifx/network/discovery.py (full file — current _discover_with_packet at lines 150–364; the responses dict at lines 201/322; the loop arithmetic at 231–259; the LifxProtocolError handler at 335–344)
    - src/lifx/network/discovery.py discover_devices (lines 392–645 — the serial validation at 558–566, the dedup `seen_serials` at 578–579, the malformed-response log with exc_info=True at 608–620)
    - src/lifx/network/utils.py (the IdleDeadline class from Plan 01 — remaining()/mark_response()/idle_expired/overall_expired contract)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (Detailed Behavioural Difference Table lines 178–195; Pitfall 1 lines 331–347; serial validation pattern lines 491–507)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (discovery.py section lines 73–197 — exact hoist/dedup/log patterns and import changes)
    - CLAUDE.md (Discovery DoS Protection contract; structured dict-style log convention)
  </read_first>
  <behavior>
    - A StateService response whose header.target has the multicast bit set (target[0] & 0x01) is rejected by _discover_with_packet: nothing is yielded and a DEBUG log records the invalid serial.
    - A StateService response with an all-0xff target is rejected the same way.
    - Two valid responses with the same serial cause _discover_with_packet to yield exactly one DiscoveryResponse (first wins).
    - A device flooding duplicate (same-serial) responses resets the idle timer on each valid response, so discovery does not stop early before the overall/idle deadline (Pitfall 1).
    - A response whose source != discovery_source is still skipped (existing source-ID protection preserved).
  </behavior>
  <action>
    Refactor `_discover_with_packet` only (leave `discover_devices` for Task 2; the file must stay
    importable and Pyright-clean after this task).

    1. Add `IdleDeadline` to the existing `from lifx.network.utils import allocate_source` import
       so it reads `from lifx.network.utils import IdleDeadline, allocate_source`.
    2. Replace the inline `idle_timeout = ...` / `last_response_time = ...` / dual-condition loop
       header (current lines 231–259) with an `IdleDeadline(timeout, idle_timeout)` instance, where
       `idle_timeout = max_response_time * idle_timeout_multiplier`. Drive the loop with the helper:
       check `deadline.idle_expired` and `deadline.overall_expired` to emit the two existing
       distinct DEBUG log messages (`action: "idle_timeout"` and `action: "overall_timeout"`,
       `class: "_discover_with_packet"`), then compute `remaining = deadline.remaining()` and break
       if `remaining <= 0` before calling `transport.receive(timeout=remaining)`.
    3. After source validation (header.source check) and the pkt_type check, and BEFORE serial
       extraction/yield, insert the hoisted serial validation: if `header.target[0] & 0x01` or
       `header.target == b"\xff" * 8`, emit a DEBUG log (dict-style: keys `class`,`action:"invalid_serial"`,
       `serial: header.target.hex()`, `source_ip: addr[0]`) and `continue`. This is unconditional —
       no opt-out parameter (D-01); DEBUG level only (D-02). Exact wording is Claude's discretion but
       must follow the structured dict-style convention.
    4. Reset the idle timer by calling `deadline.mark_response()` on EVERY valid protocol response
       (valid source, valid pkt_type, valid serial) — place it BEFORE the dedup `continue` so a
       duplicate flood still resets the timer (Pitfall 1). Then apply first-wins dedup using a
       `seen_serials: set[str]` (declared before the loop): if `device_serial in seen_serials`,
       `continue`; otherwise `seen_serials.add(device_serial)` and yield the DiscoveryResponse.
    5. Delete the vestigial `responses: dict[str, DiscoveryResponse]` accumulator (current lines 201
       and the write at 322); the final completion DEBUG log uses `len(seen_serials)` for
       `devices_found`.
    6. Align the `LifxProtocolError` handler to use `exc_info=True` (matching the discover_devices
       handler that is about to disappear), keeping `class:"_discover_with_packet"`,
       `action:"malformed_response"`, `reason: str(e)`, `source_ip: addr[0]`.

    Preserve the `AsyncGenerator[DiscoveryResponse]` return annotation (single type arg). Use
    `time.monotonic()` for any remaining direct timing; do not reintroduce `time.time()`.
  </action>
  <verify>
    <automated>uv run pyright src/lifx/network/discovery.py && uv run ruff check src/lifx/network/discovery.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "IdleDeadline" src/lifx/network/discovery.py` returns >= 1 (helper imported + used).
    - `grep -c "responses\\[device_serial\\]" src/lifx/network/discovery.py` returns 0 (vestigial dict write removed).
    - `_discover_with_packet` contains `seen_serials` and a `deadline.mark_response()` call positioned before the dedup `continue`.
    - `_discover_with_packet` contains the serial validation guard `header.target[0] & 0x01` with a `_LOGGER.debug(` call (not `_LOGGER.warning(`) on that branch.
    - `uv run pyright src/lifx/network/discovery.py` reports 0 errors.
    - `uv run --frozen pytest tests/test_network/test_discovery_devices.py -q` still passes (existing emulator/behaviour coverage of the generator path; skips cleanly if emulator absent).
  </acceptance_criteria>
  <done>_discover_with_packet enforces serial validation (DEBUG) + first-wins dedup unconditionally, uses IdleDeadline, resets idle timer before dedup, and the responses dict is gone — Pyright clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Thin discover_devices to a wrapper and delete _parse_device_state_service</name>
  <files>src/lifx/network/discovery.py</files>
  <read_first>
    - src/lifx/network/discovery.py (post-Task-1 state — the refactored _discover_with_packet with validation+dedup+IdleDeadline; discover_devices at 392–645; _parse_device_state_service at 367–389; the top-of-file `import struct` at line 6)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-RESEARCH.md (DiscoveryResponse Contract lines 199–224; Pitfall 2 port trap lines 348–372; Pitfall 4 field-name case lines 396–407; Pitfall 6 struct import lines 431–437; thin wrapper pattern lines 459–489)
    - .planning/phases/01-unify-duplicated-discovery-loops/01-PATTERNS.md (thin discover_devices wrapper lines 152–181)
    - src/lifx/protocol/packets.py (Device.StateService — fields service: DeviceService, port: int; confirms as_dict key is lowercase "port")
  </read_first>
  <behavior>
    - discover_devices yields DiscoveredDevice instances whose `port` comes from the StateService payload (resp.response_payload["port"]), NOT the broadcast port parameter (Pitfall 2).
    - discover_devices preserves its full public signature (timeout, broadcast_address, port, max_response_time, idle_timeout_multiplier, device_timeout, max_retries) and AsyncGenerator[DiscoveredDevice, None] return type.
    - Existing callers (api.py discover/find_by_serial/find_by_ip; devices/base.py) observe unchanged behaviour, modulo the two CONTEXT.md hardening side effects.
    - _parse_device_state_service no longer exists in the module.
  </behavior>
  <action>
    Replace the body of `discover_devices` (current lines 431–645) with a thin wrapper that
    delegates to the now-unified generator (D-05):

    `async for resp in _discover_with_packet(DevicePackets.GetService(), timeout=timeout,
    broadcast_address=broadcast_address, port=port, max_response_time=max_response_time,
    idle_timeout_multiplier=idle_timeout_multiplier):` then extract
    `device_port: int = resp.response_payload["port"]` (lowercase key — Pitfall 4; this is the
    device's real UDP service port, NOT `resp.port` which is the broadcast port — Pitfall 2) and
    `yield DiscoveredDevice(serial=resp.serial, ip=resp.ip, port=device_port,
    response_time=resp.response_time, timeout=device_timeout, max_retries=max_retries)`.

    Keep the existing function signature and the `AsyncGenerator[DiscoveredDevice, None]` return
    annotation exactly. Keep the wrapper trivial — no transport, no `seen_serials`, no
    timeout arithmetic, no try/finally state (the `async with UdpTransport` teardown lives inside
    `_discover_with_packet`; Pitfall 3 — early break via find_by_serial is handled by that
    generator's `async with`).

    Delete the `_parse_device_state_service` function entirely (current lines 367–389) and remove
    the now-unused `import struct` from the top of the file (Pitfall 6). Update the
    `discover_devices` docstring if it references the deleted helper, keeping Australian-English
    spelling.
  </action>
  <verify>
    <automated>uv run pyright src/lifx/network/discovery.py && uv run ruff check src/lifx/network/discovery.py</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "_parse_device_state_service" src/lifx/network/discovery.py` returns 0.
    - `grep -c "^import struct" src/lifx/network/discovery.py` returns 0.
    - `grep -c "async for resp in _discover_with_packet" src/lifx/network/discovery.py` returns 1.
    - `grep -c "response_payload\\[.port.\\]" src/lifx/network/discovery.py` returns 1 (device port from StateService, not resp.port).
    - `uv run pyright src/lifx/network/discovery.py` reports 0 errors and `uv run ruff check src/lifx/network/discovery.py` is clean (proves no unused struct import).
    - `uv run --frozen pytest tests/test_network/test_discovery_devices.py -q` passes (or skips emulator tests cleanly); discovered-device ports match the StateService payload.
  </acceptance_criteria>
  <done>discover_devices is a trivial wrapper over _discover_with_packet using response_payload["port"]; _parse_device_state_service and `import struct` are gone; Pyright + ruff clean.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| external network → _discover_with_packet | Untrusted UDP broadcast responses from arbitrary hosts cross into the discovery generator |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-02 | Spoofing / Denial of Service | Forged StateService responses with broadcast/multicast or 0xff serials | mitigate | Hoisted unconditional serial guard (`header.target[0] & 0x01 or header.target == b"\xff"*8 → continue`) inside _discover_with_packet (D-01). Extends DoS protection to find_by_label and enrichment paths (D-03). Proven by Plan 05 generator-level rejection tests. |
| T-01-03 | Spoofing | Off-session responses with mismatched source ID | mitigate | Existing `header.source != discovery_source → continue` preserved in _discover_with_packet. |
| T-01-04 | Denial of Service | Hostile host flooding rejected-serial responses to spam logs | mitigate | Rejected serials logged at DEBUG only (D-02), not WARNING — avoids log-flooding amplification on hostile networks. |
| T-01-05 | Denial of Service | Same-serial response flood causing premature idle timeout (loss of legitimate discovery) | mitigate | mark_response() called on every valid response before the dedup check (Pitfall 1), so a flooding device cannot truncate discovery early; dedup still yields each serial once (D-04). |

ASVS L1: V5 Input Validation — serial guard is the L1 control; no HIGH-severity items outstanding.
No package installs (Package Legitimacy Gate N/A — stdlib + project-internal only).
</threat_model>

<verification>
- `uv run pyright` reports 0 errors (strict).
- `uv run ruff check .` reports no errors (no unused `struct` import).
- `uv run --frozen pytest tests/test_network/ -q` passes (note: `tests/test_network/test_discovery_errors.py` still references `_parse_device_state_service` until Plan 05 rewrites it — that file is owned by Plan 05 / Wave 3, so run the discovery-devices subset here and the full network suite after Wave 3).
</verification>

<success_criteria>
- _discover_with_packet enforces serial validation (DEBUG) + first-wins dedup + IdleDeadline; vestigial `responses` dict removed.
- discover_devices is a thin wrapper extracting the device port from `response_payload["port"]`.
- _parse_device_state_service and `import struct` deleted.
- D-01..D-05 satisfied; existing callers unaffected except the agreed D-03/D-04 hardening.
</success_criteria>

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

- Modified `src/lifx/network/discovery.py`:
  - `_discover_with_packet` now performs serial validation (D-01/D-02/D-03), first-wins dedup via `seen_serials` (D-04), and uses `IdleDeadline` for timeout arithmetic.
  - `discover_devices` reduced to a thin `async for resp in _discover_with_packet(...)` wrapper that reads `resp.response_payload["port"]` (D-05).
  - Deleted symbol: `_parse_device_state_service` (function removed).
  - Removed import: `import struct`.
  - New import: `IdleDeadline` from `lifx.network.utils`.
- No public API surface symbols added or renamed; `discover_devices`, `_discover_with_packet`, `DiscoveredDevice`, `DiscoveryResponse` signatures preserved.
</artifacts_produced>

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