---
phase: 02-discovery-rebroadcast
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/lifx/const.py
  - src/lifx/network/discovery.py
  - tests/test_network/test_discovery_rebroadcast.py
autonomous: true
requirements:
  - DISC-01
  - DISC-02
user_setup: []

must_haves:
  truths:
    - "Within one discovery window, GetService is re-broadcast on the Photons-shaped escalating schedule (gaps 0.6, 1.2, 1.8, 2.0, 2.0 s from first send), capped by the discovery window (D2-01, DISC-01)"
    - "Every discovered device is yielded exactly once even when the same serial answers multiple broadcasts (D2-02, DISC-02)"
    - "Existing callers work unmodified: serial validation, first-wins dedup, and IdleDeadline semantics are byte-for-byte preserved; a send never resets the idle window (D2-02, D2-03)"
    - "Every re-broadcast reuses the identical message bytes (same source, same sequence) so the source-validation guard accepts responses to any broadcast in the session"
    - "A receive-slice timeout re-enters the loop instead of terminating discovery; the idle and overall deadlines are the only loop exits"
  artifacts:
    - path: "src/lifx/const.py"
      provides: "DISCOVERY_REBROADCAST_GAPS schedule constant (Photons-shaped gaps)"
      contains: "DISCOVERY_REBROADCAST_GAPS"
    - path: "src/lifx/network/discovery.py"
      provides: "send/receive interleave in _discover_with_packet with continue-on-receive-timeout and rebroadcast_sent debug logging"
      contains: "rebroadcast_sent"
    - path: "tests/test_network/test_discovery_rebroadcast.py"
      provides: "branch-matrix tests for schedule mechanics, message reuse, and dedup across re-broadcasts"
      min_lines: 150
  key_links:
    - from: "src/lifx/network/discovery.py"
      to: "src/lifx/const.py"
      via: "runtime read of the module attribute DISCOVERY_REBROADCAST_GAPS inside the generator body (not a def-time default)"
      pattern: "DISCOVERY_REBROADCAST_GAPS"
    - from: "tests/test_network/test_discovery_rebroadcast.py"
      to: "src/lifx/network/discovery.py"
      via: "patching lifx.network.discovery.DISCOVERY_REBROADCAST_GAPS proves the runtime read and enables fast schedule-exhaustion tests"
      pattern: "lifx\\.network\\.discovery\\.DISCOVERY_REBROADCAST_GAPS"
    - from: "tests/test_network/test_discovery_rebroadcast.py"
      to: "tests/test_network/test_discovery_errors.py"
      via: "imports the shared _build_state_service_packet helper instead of copying it"
      pattern: "from tests.test_network.test_discovery_errors import"
---

<objective>
Add an escalating Photons-shaped `GetService` re-broadcast schedule inside
`_discover_with_packet()`'s receive loop (D2-01), tests-first, so a single
`discover_devices()` call can find the entire fleet on multi-AP networks where any one
broadcast is best-effort per AP (baseline: median 48/73 devices from a single broadcast).

Everything from the Phase 1 rework is preserved unchanged (D2-02): serial validation,
first-wins per-serial dedup, IdleDeadline semantics, and the thin `discover_devices()`
wrapper. The public API is unchanged (D2-03): the schedule lives as one module-level
constant in `const.py`, not as new kwargs. The mDNS path is untouched (D2-04).

Purpose: closes DISC-01 and DISC-02, and delivers the implementation DISC-03 measures in
plan 02-02.
Output: `DISCOVERY_REBROADCAST_GAPS` in `const.py`, the send/receive interleave in
`discovery.py`, and `tests/test_network/test_discovery_rebroadcast.py` covering the full
behavioural branch matrix from 02-RESEARCH.md §Validation Architecture.
</objective>

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

<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/phases/02-discovery-rebroadcast/02-CONTEXT.md
@.planning/phases/02-discovery-rebroadcast/02-RESEARCH.md
@.planning/phases/02-discovery-rebroadcast/02-PATTERNS.md
@.planning/phases/02-discovery-rebroadcast/02-VALIDATION.md
@.claude/skills/spike-findings-lifx-async/references/discovery.md
@src/lifx/network/discovery.py
@src/lifx/const.py
@tests/test_network/test_discovery_errors.py
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Write the RED re-broadcast branch-matrix tests</name>
  <files>tests/test_network/test_discovery_rebroadcast.py</files>
  <read_first>
    - .planning/phases/02-discovery-rebroadcast/02-RESEARCH.md — §Validation Architecture (the branch matrix these tests must cover), §Code Examples (recording-send mock, patched-gap pattern, dedup-across-broadcasts sketch), §Common Pitfalls (Pitfall 6: quiet mocks must sleep)
    - tests/test_network/test_discovery_errors.py — `_build_state_service_packet` (module-level helper, ~L96), the AsyncMock transport convention in `TestDiscoverWithPacketSerialValidation` (~L430-472), and the narrow-branch style of `TestRemainingNonPositiveGuard` (~L741-775)
    - tests/test_network/test_discovery_devices.py (~L24-25) — emulator marker stack convention (`@pytest.mark.emulator` + `@pytest.mark.flaky(retries=2, delay=1, condition=sys.platform.startswith("win32"))`)
    - .claude/skills/spike-findings-lifx-async/sources/005-discovery-regimes/sweep.py — `run_round()` (~L106-147), the proven send/receive interleave the implementation will follow
    - src/lifx/network/discovery.py — `_discover_with_packet` (~L152-437), current single-broadcast loop
  </read_first>
  <behavior>
    Every behavioural branch listed in 02-VALIDATION.md gets a distinct test. All unit
    tests drive `_discover_with_packet(DevicePackets.GetService(), ...)` (or
    `discover_devices(...)`) with `patch("lifx.network.discovery.UdpTransport")` and
    `patch("lifx.network.discovery.allocate_source", return_value=42)` per the existing
    convention:
    - Test 1 `test_two_sends_at_first_gap_within_window`: real (unpatched) gaps, `timeout=1.0`, quiet network → exactly 2 sends; gap between the two recorded send timestamps within [0.4, 0.9] (≈0.6 with CI tolerance). (DISC-01 schedule timing)
    - Test 2 `test_window_caps_schedule_single_send`: `timeout=0.3` → exactly 1 send (first offset 0.6 exceeds the window). (schedule capped by window)
    - Test 3 `test_schedule_exhaustion_falls_back_to_remaining`: gaps patched to `(0.05, 0.05)`, `timeout=0.5` → exactly 3 sends (t=0, 0.05, 0.10), generator completes at ~0.5 s via the overall deadline; after exhaustion the receive slice falls back to `deadline.remaining()`. (iterator-exhausted branch)
    - Test 4 `test_quiet_slice_rebroadcast_then_idle_exit`: gaps patched to `(0.1,)`, `max_response_time=0.2`, `idle_timeout_multiplier=2.0` (idle 0.4 s), `timeout=5.0` → 2 sends, zero yields, generator exits via the idle deadline; elapsed wall time in [0.4, 1.0). (quiet-slice continue + idle exit branch)
    - Test 5 `test_quiet_slice_rebroadcast_then_overall_exit`: gaps patched to `(0.1,)`, default idle, `timeout=0.5` → 2 sends, zero yields, exits via the overall deadline; elapsed in [0.5, 1.0). (overall exit branch)
    - Test 6 `test_send_does_not_reset_idle_window`: gaps patched to `(0.35,)`, `max_response_time=0.25`, `idle_timeout_multiplier=2.0` (idle 0.5 s), `timeout=5.0` → 2 sends; elapsed in [0.45, 0.75) — an idle reset caused by the t=0.35 send would move exit to ~0.85, which the upper bound excludes. (no mark_response on send)
    - Test 7 `test_multiple_sends_due_in_one_loop_pass`: gaps patched to `(0.05, 0.05)`; the FIRST receive call ignores its requested slice and sleeps ~0.3 s before raising LifxTimeoutError (simulating a slow wake-up past both offsets); subsequent receives sleep their requested slice; `timeout=0.6` → 3 sends total, and the 2nd and 3rd send timestamps within 0.05 s of each other (both fired in one due-send pass). (multi-send loop pass)
    - Test 8 `test_rebroadcast_reuses_identical_message`: gaps patched to `(0.1, 0.1)`, `timeout=0.5`, `send` as a recording AsyncMock → `send.call_count == 3` and every entry in `send.call_args_list` identical to the first (same message bytes, same destination) — proves same source + sequence across sends. (message/source reuse; VALIDATION.md secure-behaviour row)
    - Test 9 `test_same_serial_across_broadcasts_yields_once` (class TestRebroadcastDedup): gaps patched to `(0.15,)`, `timeout=0.8`; receive side-effect sequence: (a) return a valid `_build_state_service_packet(source=42, target=b"\xd0\x73\xd5\x01\x02\x03\x00\x00")` immediately, (b) sleep ~0.2 s (crossing the 0.15 offset so a re-send fires) then return the SAME packet again, (c) sleep the requested slice and raise LifxTimeoutError → exactly 1 yield and exactly 2 sends. (DISC-02 dedup across re-broadcasts)
    - Test 10 `test_emulator_dedup_across_rebroadcast_window` (class TestRebroadcastEmulator, emulator + flaky markers, `emulator_port` fixture): `discover_devices(timeout=2.0, broadcast_address="127.0.0.1", port=emulator_port)` — the 2.0 s window spans sends at t=0, 0.6, 1.8; assert at least one device found and no serial yielded twice. (DISC-02 integration)
  </behavior>
  <action>
    Create `tests/test_network/test_discovery_rebroadcast.py` implementing the behaviour
    matrix above, honouring DISC-01/DISC-02 and D2-01/D2-02. Hard requirements:

    1. Import `_build_state_service_packet` with `from tests.test_network.test_discovery_errors import _build_state_service_packet` (tests are packages — `__init__.py` exists). Do NOT copy the helper and do NOT modify test_discovery_errors.py.
    2. All imports at the top of the file (project rule): `asyncio`, `sys`, `time`, `unittest.mock` (`AsyncMock`, `patch`), `pytest`, `lifx.exceptions.LifxTimeoutError`, `lifx.network.discovery` (`_discover_with_packet`, `discover_devices`), `lifx.protocol.packets.Device as DevicePackets`, and the helper import.
    3. Quiet receive mocks MUST `await asyncio.sleep(<the requested timeout argument>)` BEFORE raising LifxTimeoutError. With the new continue-on-timeout loop, an instantly-raising receive hot-spins the loop (02-RESEARCH.md Pitfall 6 / Code Examples note). Build one shared quiet-receive factory and one recording-send factory (appending `time.monotonic()` to a list) at module level for reuse.
    4. Patch the schedule via `patch("lifx.network.discovery.DISCOVERY_REBROADCAST_GAPS", <tuple>)` — this is the module attribute of `lifx.network.discovery`, and it only works because Task 2 reads the constant at runtime. Tests 1 and 2 use the real (unpatched) schedule.
    5. Timing assertions use generous CI-tolerant bounds as specified per test in the behaviour block; never assert elapsed-time windows narrower than 0.25 s and never assert below 50 ms granularity.
    6. Class layout: `TestRebroadcastSchedule` (tests 1-8), `TestRebroadcastDedup` (test 9), `TestRebroadcastEmulator` (test 10, with the class-level `@pytest.mark.emulator` and win32 flaky markers copied from tests/test_network/test_discovery_devices.py).
    7. This is the RED half of a tests-first sequence: on THIS commit the unit tests in TestRebroadcastSchedule and TestRebroadcastDedup MUST fail (the constant and the interleave do not exist yet — the schedule patch raises AttributeError and send counts come back as 1). Do not weaken any assertion to make them pass, and do not mark them xfail. The commit message follows the TDD convention: test(02-01): add failing re-broadcast schedule tests. Test 10 may already pass (first-wins dedup exists today) — that is expected.
  </action>
  <verify>
    <automated>uv run pytest tests/test_network/test_discovery_rebroadcast.py --collect-only -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/test_discovery_rebroadcast.py --collect-only -q` exits 0 and collects at least 10 tests across TestRebroadcastSchedule, TestRebroadcastDedup, and TestRebroadcastEmulator
    - `uv run pytest tests/test_network/test_discovery_rebroadcast.py -q` exits NON-zero at this commit (RED: implementation absent) — record the failure count in the task notes
    - `git status --porcelain tests/test_network/test_discovery_errors.py` prints nothing (helper imported, not copied or edited)
    - `uv run ruff format . && uv run ruff check .` report no issues for the new file
  </acceptance_criteria>
  <done>The full behavioural branch matrix from 02-VALIDATION.md exists as distinct, collectable tests that fail RED against the current single-broadcast implementation.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Implement the Photons-shaped re-broadcast interleave (GREEN)</name>
  <files>src/lifx/const.py, src/lifx/network/discovery.py</files>
  <read_first>
    - .planning/phases/02-discovery-rebroadcast/02-RESEARCH.md — §Concrete Edit Points (the authoritative edit table with current line numbers), §Architecture Patterns (Patterns 1-3), §Common Pitfalls (all five)
    - .planning/phases/02-discovery-rebroadcast/02-PATTERNS.md — const.py block convention, structured dict logging convention, monotonic-time rules
    - .claude/skills/spike-findings-lifx-async/sources/005-discovery-regimes/sweep.py — `run_round()` (~L106-147): the proven interleave shape (adapt: perf_counter → monotonic; window check → existing IdleDeadline checks; drop the 0.001 floor clamp)
    - src/lifx/network/discovery.py — `_discover_with_packet` in full, plus the `DiscoveredDevice` and `DiscoveryResponse` dataclass docstrings and the `discover_devices` docstring
    - src/lifx/const.py — lines 26-41 (discovery constants block the new constant joins)
    - src/lifx/network/utils.py — IdleDeadline semantics (mark_response contract, remaining() composition)
    - tests/test_network/test_discovery_rebroadcast.py — the RED tests from Task 1 that define GREEN
  </read_first>
  <action>
    Make the Task 1 tests pass with the smallest possible diff to the existing loop
    (D2-01, D2-02, D2-03). Verify research line numbers against the current file before
    editing. Eight edits, nothing else:

    1. `src/lifx/const.py`: after the IDLE_TIMEOUT_MULTIPLIER block (~L34) and before the DEFAULT_REQUEST_TIMEOUT block, add `DISCOVERY_REBROADCAST_GAPS: Final[tuple[float, ...]] = (0.6, 1.2, 1.8, 2.0, 2.0)` with a leading one-line comment stating: Photons-shaped gaps in seconds between successive discovery re-broadcasts after the first send (cumulative offsets 0.6, 1.8, 3.6, 5.6, 7.6), capped by DISCOVERY_TIMEOUT. Follow the existing `Final[...]` + comment convention (D2-03: a module-level constant, not a kwarg).
    2. `src/lifx/network/discovery.py` imports: add `DISCOVERY_REBROADCAST_GAPS` to the existing `from lifx.const import (...)` block (alphabetically before DISCOVERY_TIMEOUT) and add `from itertools import accumulate` to the stdlib import group at the top of the file.
    3. Inside `_discover_with_packet`, immediately after `deadline = IdleDeadline(timeout, idle_timeout)` (~L236): build the offsets iterator by reading the module attribute AT RUNTIME inside the generator body — `tx_offsets = accumulate(DISCOVERY_REBROADCAST_GAPS)` then `next_tx: float | None = next(tx_offsets, None)`. It must NOT be a def-time parameter default (Pitfall 3), otherwise the Task 1 patched-gap tests cannot patch it and schedule-exhaustion tests slow to 7.6 s+.
    4. At the top of the `while True` loop, AFTER the `deadline.idle_expired` and `deadline.overall_expired` checks and BEFORE the `remaining = deadline.remaining()` line (~L259): insert the due-send loop. Set `now = time.monotonic()`; while `next_tx is not None and now - request_time >= next_tx`: emit a dict-style `_LOGGER.debug` with keys class="_discover_with_packet", method="discover", action="rebroadcast_sent", offset=next_tx, broadcast_address, port (mirroring the existing broadcast_sent entry at ~L222-232, plain dict literal, no f-strings); `await transport.send(message, (broadcast_address, port))` reusing the SAME `message` object built before the loop — same source and same sequence, because response correlation is source-based and a fresh source would make the guard at ~L287 reject responses to earlier broadcasts; then `next_tx = next(tx_offsets, None)` and refresh `now = time.monotonic()`. Placement after the expiry checks guarantees no send ever happens once either deadline has fired.
    5. Between the `if remaining <= 0: break` guard (~L260-261) and the `transport.receive` call: cap the receive slice so the loop wakes at each send boundary — if `next_tx is not None`, set `remaining = min(remaining, request_time + next_tx - now)`. Do NOT add a floor clamp or an extra non-positive guard here: `remaining > 0` was just checked and the due-send loop only exits when `request_time + next_tx > now`, so both operands are provably positive — a clamp creates an unreachable branch that fails CI's 100% branch patch coverage gate (Pitfall 4; check branch partials, not just line coverage).
    6. In the `except LifxTimeoutError:` handler for the receive call (~L266-267): replace the loop-terminating statement with `continue`. This is the single most important edit (Pitfall 1): a receive-slice timeout now means "slice ended — maybe time to re-broadcast"; the top-of-loop idle/overall expiry checks are the sole exit authority. sweep.py uses exactly this shape.
    7. Docstrings (Australian English): update `_discover_with_packet` ("Broadcasts the specified packet...") and `discover_devices` ("Sends a broadcast DeviceGetService packet...") to describe the escalating re-broadcast schedule driven by DISCOVERY_REBROADCAST_GAPS and capped by the discovery window. In `discover_devices`, add the completion-shift note (Pitfall 2/A1): on a populated network the generator now typically completes near ~11.6 s with default arguments (last re-send at t=7.6 s plus the ~4 s idle window) instead of ~5 s, still inside DISCOVERY_TIMEOUT; streaming consumers see first devices at unchanged latency — only generator completion moves later. Add one sentence to BOTH the `DiscoveredDevice.response_time` and `DiscoveryResponse.response_time` attribute docs: the value is anchored at the first broadcast (time since discovery began), so devices answering a later re-broadcast report proportionally larger values (Open Question 1 resolution: anchor unchanged).
    8. Preserve untouched (D2-02, D2-04): the serial-validation guards, the first-wins `seen_serials` dedup, every existing `deadline.mark_response()` call site (and never call mark_response on a send — sends must not defeat the idle early-exit on quiet networks), the defensive `remaining <= 0` break, the `request_time` anchor, and the entire `src/lifx/network/mdns/` module.
  </action>
  <verify>
    <automated>uv run pytest tests/test_network/test_discovery_rebroadcast.py tests/test_network/test_discovery_errors.py tests/test_network/test_discovery_devices.py -x</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/test_discovery_rebroadcast.py tests/test_network/test_discovery_errors.py tests/test_network/test_discovery_devices.py -x` exits 0 (Task 1 tests GREEN; all existing discovery tests pass)
    - `git status --porcelain tests/test_network/test_discovery_errors.py tests/test_network/test_discovery_devices.py` prints nothing (existing tests pass UNMODIFIED — the regression gate for IdleDeadline semantics)
    - `git status --porcelain src/lifx/network/mdns/` prints nothing (D2-04: mDNS path untouched)
    - `grep -c "DISCOVERY_REBROADCAST_GAPS" src/lifx/const.py` reports at least 1, and `grep -c "DISCOVERY_REBROADCAST_GAPS" src/lifx/network/discovery.py` reports at least 2 (import + runtime read)
    - `uv run ruff format .`, `uv run ruff check . --fix`, and `uv run pyright` all pass clean
  </acceptance_criteria>
  <done>All Task 1 tests pass, every existing discovery test passes without modification, and the diff touches only const.py and discovery.py.</done>
</task>

<task type="auto">
  <name>Task 3: Branch-coverage audit and full-suite regression</name>
  <files>tests/test_network/test_discovery_rebroadcast.py</files>
  <read_first>
    - .planning/phases/02-discovery-rebroadcast/02-RESEARCH.md — §Common Pitfalls (Pitfall 4: dead branches; Pitfall 6: no raw-count asserts against the emulator) and §Validation Architecture (sampling-rate table)
    - tests/test_network/test_discovery_errors.py — TestRemainingNonPositiveGuard (~L741-775): the style for narrow, purpose-built branch tests
    - src/lifx/network/discovery.py — the modified loop as landed in Task 2
  </read_first>
  <action>
    Prove the new code meets CI's 100% BRANCH patch coverage gate locally before the
    phase leaves this plan (project constraint from CLAUDE.md / 02-CONTEXT.md):

    1. Run `uv run pytest tests/test_network/ --cov-report=term-missing` (branch coverage is already enabled via addopts `--cov=lifx --cov-branch`). Inspect the report row for `src/lifx/network/discovery.py`.
    2. Every line AND every branch partial (arrow entries such as `NNN->MMM` in the Missing column) within the ranges edited in Task 2 must be absent from the Missing column. Pay particular attention to: both outcomes of the due-send `while` condition (iterator exhausted vs offset not yet due vs send fired), both outcomes of the slice-cap `if next_tx is not None`, and the continue path of the LifxTimeoutError handler.
    3. If any partial remains, add a narrow purpose-built unit test to `tests/test_network/test_discovery_rebroadcast.py` in the TestRemainingNonPositiveGuard style — do not widen or restructure existing tests, and do not add clamps or guards to the production code to dodge a branch.
    4. Run the full suite: `uv run --frozen pytest` must be green (this is also the wave-merge gate from 02-VALIDATION.md).
    5. Confirm no test anywhere asserts raw send or response COUNTS against the emulator (Pitfall 6) — send-count assertions belong exclusively in the mocked-transport tests. Do not add such an emulator test.
  </action>
  <verify>
    <automated>uv run --frozen pytest</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_network/ --cov-report=term-missing` output shows no missing lines and no branch partials for src/lifx/network/discovery.py within the Task 2 edit ranges
    - `uv run --frozen pytest` exits 0 (full suite green, including emulator-marked tests where the embedded emulator is available)
    - `uv run ruff check .` and `uv run pyright` remain clean after any coverage-driven test additions
  </acceptance_criteria>
  <done>Branch patch coverage for the new discovery code is complete locally and the entire test suite passes.</done>
</task>

</tasks>

## Artifacts this phase produces (plan 01)

| Artifact | Kind | Location |
|----------|------|----------|
| `DISCOVERY_REBROADCAST_GAPS: Final[tuple[float, ...]] = (0.6, 1.2, 1.8, 2.0, 2.0)` | New constant | `src/lifx/const.py` |
| Re-broadcast interleave in `_discover_with_packet` (due-send loop, receive-slice cap, continue-on-timeout, `rebroadcast_sent` debug log) | Modified generator | `src/lifx/network/discovery.py` |
| Updated docstrings: `_discover_with_packet`, `discover_devices` (completion-shift note), `DiscoveredDevice.response_time`, `DiscoveryResponse.response_time` (anchor note) | Docs-in-code | `src/lifx/network/discovery.py` |
| `TestRebroadcastSchedule` (8 tests), `TestRebroadcastDedup` (1 test), `TestRebroadcastEmulator` (1 emulator test) | New test file | `tests/test_network/test_discovery_rebroadcast.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| LAN → discovery socket | Untrusted UDP datagrams (any host can answer a broadcast) cross into the receive loop |
| Library → LAN | Outbound broadcast load is now multiplied by the schedule (up to 6 sends per discovery) |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-01 | Spoofing | `_discover_with_packet` response handling | medium | mitigate | Re-broadcasts reuse the same source, so the existing source-ID + serial validation path handles every response identically — no new acceptance surface (asserted by test_rebroadcast_reuses_identical_message) |
| T-02-02 | DoS (inbound) | discovery receive loop | medium | mitigate | Overall deadline (unchanged) hard-caps the window regardless of response volume; first-wins dedup bounds yields; ~2× responses per broadcast absorbed by existing dedup (test_same_serial_across_broadcasts_yields_once) |
| T-02-03 | DoS (outbound, self-inflicted) | re-broadcast schedule | low | mitigate | Photons schedule locked by D2-01 specifically to bound load (~615 vs ~2,146 responses/round on a 73-device fleet vs the Glowup hammer); at most 6 sends per discovery, capped by the window |
</threat_model>

<verification>
- `uv run pytest tests/test_network/test_discovery_rebroadcast.py -q` — all schedule/dedup tests green
- `uv run pytest tests/test_network/ -x` — no regression in any existing discovery/transport/mDNS test, with those files unmodified
- `uv run --frozen pytest` — full suite green
- `uv run pytest tests/test_network/ --cov-report=term-missing` — no missing lines or branch partials in src/lifx/network/discovery.py for the edited ranges
- `uv run ruff format . && uv run ruff check . && uv run pyright` — clean
</verification>

<success_criteria>
- GetService is re-broadcast at cumulative offsets 0.6/1.8/3.6/5.6/7.6 s within the discovery window, observable in mocked-transport tests at the expected gaps (DISC-01, D2-01)
- Duplicate StateService responses across broadcasts never cause duplicate yields (DISC-02, D2-02)
- Existing callers (`discover_devices`, `find_by_serial`, `find_by_label`, `find_by_ip`) work unmodified; no new public parameters (D2-03); mDNS untouched (D2-04)
- Quiet-network exit time is unchanged (~idle window), proving sends never reset the idle deadline
- 100% branch patch coverage on the new/changed lines, locally verified
</success_criteria>

<output>
Create `.planning/phases/02-discovery-rebroadcast/02-01-SUMMARY.md` when done, recording:
the final edit shape (line numbers), test count added, the coverage audit result, and the
RED→GREEN commit hashes.
</output>
