---
phase: 02-discovery-rebroadcast
plan: 02
type: execute
wave: 2
depends_on:
  - "02-01"
files_modified:
  - .planning/phases/02-discovery-rebroadcast/uat_rounds.py
  - .planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json
autonomous: true
requirements:
  - DISC-03
user_setup: []

must_haves:
  truths:
    - "A repeatable 6-round measurement of the real discover_devices() implementation against the production fleet exists and has been run (DISC-03)"
    - "Median per-round coverage equals the union roster across rounds (expected 73/73; recorded single-broadcast baseline: median 48/73)"
    - "The harness drives the shipped implementation, not a hand-rolled schedule — sweep.py's regime arms cannot measure the real code path"
    - "The measurement is recorded on disk with per-round counts, roster size, median, and a machine-readable pass flag"
  artifacts:
    - path: ".planning/phases/02-discovery-rebroadcast/uat_rounds.py"
      provides: "DISC-03 measurement harness (6 x discover_devices rounds, baseline comparison, exit-code contract)"
      contains: "discover_devices"
    - path: ".planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json"
      provides: "Recorded 6-round measurement with pass flag"
      contains: "median"
  key_links:
    - from: ".planning/phases/02-discovery-rebroadcast/uat_rounds.py"
      to: "src/lifx/network/discovery.py"
      via: "imports and calls the real discover_devices() generator"
      pattern: "from lifx.network.discovery import discover_devices"
    - from: ".planning/phases/02-discovery-rebroadcast/uat_rounds.py"
      to: ".planning/spikes/005-discovery-regimes/summary-20260716-211339.json"
      via: "loads the recorded spike 005 baseline for the printed comparison"
      pattern: "summary-20260716-211339"
---

<objective>
Measure DISC-03 against the 73-device production fleet: over 6 repeated rounds of
`discover_devices(timeout=10.0)`, the median per-round coverage must equal full coverage
(the union roster), against the recorded single-broadcast baseline of min/med/max =
27/48/73 (spike 005). Repeated rounds are mandatory — single rounds mislead (the spike
shakedown found 71/73 by luck; STATE.md blocker note).

The emulator cannot model per-AP broadcast delivery, WiFi loss, or power-save, so this
measurement runs against the real network. It is headless (no human eyes needed — only
the production network), so it executes autonomously; if the fleet is not visible the run
STOPS and surfaces rather than recording a false pass.

Purpose: closes DISC-03 (the phase's success criterion 4) by proving the plan 02-01
schedule delivers full-fleet coverage in practice.
Output: `uat_rounds.py` harness (phase dir, never shipped) and the recorded measurement
`02-UAT-RESULTS.json`.
</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/spikes/005-discovery-regimes/summary-20260716-211339.json
@.claude/skills/spike-findings-lifx-async/sources/005-discovery-regimes/sweep.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Build the DISC-03 UAT measurement harness</name>
  <files>.planning/phases/02-discovery-rebroadcast/uat_rounds.py</files>
  <read_first>
    - .planning/phases/02-discovery-rebroadcast/02-RESEARCH.md — §DISC-03 UAT Tooling (Question 6): the harness sketch, the pass criterion, and why sweep.py cannot measure the implementation directly
    - .claude/skills/spike-findings-lifx-async/sources/005-discovery-regimes/sweep.py — round loop shape, INTER_ROUND_GAP=3.0, and `summarise()`'s missed-devices-per-round diagnostic worth mirroring
    - .planning/spikes/005-discovery-regimes/summary-20260716-211339.json — baseline JSON structure (top-level `roster_size`; per-regime objects keyed `lifx-async`/`photons` with `found_min_med_max`)
  </read_first>
  <action>
    Create `.planning/phases/02-discovery-rebroadcast/uat_rounds.py` — a standalone
    measurement script for DISC-03. It lives in the phase directory, is NEVER imported
    from src/ or tests/, and is not shipped. Requirements:

    1. Module docstring: purpose (DISC-03 hardware UAT — 6-round median fleet coverage vs the spike 005 baseline) and the run command `uv run python .planning/phases/02-discovery-rebroadcast/uat_rounds.py`.
    2. All imports at the top (project rule): `argparse`, `asyncio`, `json`, `statistics`, `time`, `pathlib.Path`, and `from lifx.network.discovery import discover_devices` — the harness MUST drive the real implementation (research: sweep.py's regime arms hand-roll schedules on a raw transport and cannot measure the shipped code path).
    3. argparse flags: `--rounds` (int, default 6 — never fewer for a coverage claim), `--window` (float, default 10.0 — matches the spike's 10 s windows for an apples-to-apples baseline comparison), `--gap` (float, default 3.0 — matches sweep.py's INTER_ROUND_GAP), `--json-out` (Path, default `.planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json`).
    4. Per round: collect `{d.serial async for d in discover_devices(timeout=window)}`, print the round index and count, then `await asyncio.sleep(gap)` between rounds (not after the last).
    5. After all rounds compute: `roster` = union of all round sets; `median` = statistics.median of per-round counts; `missed_by_round` = per-round list of roster serials not found that round (diagnostic, mirrors sweep.py's devices_missed_in_any_round).
    6. Baseline comparison: attempt to load `.planning/spikes/005-discovery-regimes/summary-20260716-211339.json` and print the `lifx-async` regime's `found_min_med_max` (recorded 27/48/73) next to this run's numbers. A missing/unreadable baseline file prints a note and continues — it must not fail the run.
    7. Write the results JSON to --json-out: `{"timestamp", "rounds" (per-round counts), "roster_size", "median", "missed_by_round", "baseline_found_min_med_max" (or null), "pass"}`. Serials are already strings (never bytes — project rule).
    8. Verdict + exit-code contract (print a single PASS/FAIL/ENV-ERROR line): exit 0 when `median == roster_size` AND `roster_size >= 60` (DISC-03 PASS); exit 1 when `roster_size >= 60` but `median < roster_size` (measured FAIL — the number is the finding, never massage it); exit 2 when `roster_size < 60` (sanity guard: the production fleet is not visible from this machine — a tiny or empty roster must not produce a trivial median==roster pass). Write the JSON in all three cases.
    9. Style: Australian English in strings/comments; ruff-clean (repo-wide `ruff check .` picks this file up); no new dependencies.
  </action>
  <verify>
    <automated>uv run python .planning/phases/02-discovery-rebroadcast/uat_rounds.py --help && uv run ruff check .planning/phases/02-discovery-rebroadcast/uat_rounds.py</automated>
  </verify>
  <acceptance_criteria>
    - `uv run python .planning/phases/02-discovery-rebroadcast/uat_rounds.py --help` exits 0 and sends no packets
    - `uv run ruff check .planning/phases/02-discovery-rebroadcast/uat_rounds.py` exits 0
    - `grep -c "from lifx.network.discovery import discover_devices" .planning/phases/02-discovery-rebroadcast/uat_rounds.py` reports 1 (drives the real implementation)
    - The exit-code contract (0 pass / 1 fail / 2 environment guard) and the roster>=60 sanity guard are implemented as specified
    - `git status --porcelain src/ tests/` prints nothing (harness is phase-dir tooling only)
  </acceptance_criteria>
  <done>The harness runs the real discover_devices() for N rounds, compares against the recorded spike 005 baseline, writes machine-readable results, and cannot report a trivial pass off-network.</done>
</task>

<task type="auto">
  <name>Task 2: Run the 6-round production-fleet measurement (DISC-03)</name>
  <files>.planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json</files>
  <read_first>
    - .planning/phases/02-discovery-rebroadcast/02-RESEARCH.md — §DISC-03 UAT Tooling (pass criterion: median == roster; >99% per-round is the physical ceiling, so an occasional single-device per-round miss is tolerable if the median holds)
    - .planning/spikes/005-discovery-regimes/summary-20260716-211339.json — the baseline this run is compared against
    - .planning/ROADMAP.md — Phase 2 success criterion 4 (the exact claim this run substantiates)
  </read_first>
  <action>
    Execute the measurement on the machine attached to the production network (the same
    network spike 005 ran on, 2026-07-16):

    1. Run `uv run python .planning/phases/02-discovery-rebroadcast/uat_rounds.py --rounds 6 --window 10 --json-out .planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json`. Expected wall time ≈ 78 s (6 × 10 s windows + 5 × 3 s gaps); use a Bash timeout of at least 180000 ms.
    2. Record the printed per-round counts, roster size, and median in the task notes, alongside the baseline comparison line (single-broadcast baseline min/med/max = 27/48/73).
    3. Interpret strictly by exit code: 0 → DISC-03 PASS (median == roster; expected 73/73). 1 → measured FAIL: re-run ONCE to rule out a transient network event (e.g. an AP mid-reboot); if it fails again, DISC-03 is not met — report the real numbers and surface the gap for verification follow-up. Do NOT adjust thresholds, rounds, or window to manufacture a pass. 2 → environment problem (fleet not visible from this machine): STOP and surface; do not record a pass and do not substitute a different network.
    4. Never reduce --rounds below 6: repeated rounds are mandatory for discovery coverage claims (STATE.md blocker note; the spike shakedown's single round found 71/73 by luck against a true median of 48).
    5. Leave `02-UAT-RESULTS.json` in the phase directory as the DISC-03 evidence artefact for /gsd-verify-work.
  </action>
  <verify>
    <automated>test -s .planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json && uv run python -c "import json,sys; d=json.load(open('.planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json')); sys.exit(0 if d['pass'] else 1)"</automated>
  </verify>
  <acceptance_criteria>
    - The harness run exits 0 (PASS by its own contract: median == roster_size and roster_size >= 60)
    - `.planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json` exists, is non-empty, and contains `"pass": true` with 6 per-round counts
    - The SUMMARY records per-round counts, roster, median, and the improvement over the 48/73 baseline median
    - If the run cannot pass honestly (exit 1 twice, or exit 2), the plan is reported blocked/failed with the real numbers — no threshold was altered
  </acceptance_criteria>
  <done>DISC-03 is measured and recorded: 6-round median fleet coverage equals the full roster, with the evidence artefact on disk.</done>
</task>

</tasks>

## Artifacts this phase produces (plan 02)

| Artifact | Kind | Location |
|----------|------|----------|
| `uat_rounds.py` — DISC-03 measurement harness (argparse: --rounds/--window/--gap/--json-out; exit codes 0/1/2) | Phase-dir tooling script (not shipped) | `.planning/phases/02-discovery-rebroadcast/uat_rounds.py` |
| `02-UAT-RESULTS.json` — 6-round measurement record (rounds, roster_size, median, missed_by_round, baseline comparison, pass flag) | Evidence artefact | `.planning/phases/02-discovery-rebroadcast/02-UAT-RESULTS.json` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Harness → production LAN | The script broadcasts GetService to the real network (read-only discovery traffic) |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-04 | DoS (outbound) | uat_rounds.py broadcast rounds | low | accept | 6 rounds × up to 6 sends = ≤36 broadcasts over ~78 s — well under the load spike 005 already exercised on this network; inter-round gap of 3 s bounds burstiness |
| T-02-05 | Information Disclosure | 02-UAT-RESULTS.json (device serials) | low | accept | Serials are local-network device identifiers stored in a local planning artefact of the fleet owner's own repository; no credentials or user data involved |
</threat_model>

<verification>
- `uv run python .planning/phases/02-discovery-rebroadcast/uat_rounds.py --help` exits 0
- `uv run ruff check .planning/phases/02-discovery-rebroadcast/uat_rounds.py` clean
- Measurement run exits 0 with `02-UAT-RESULTS.json` showing `"pass": true`
- Manual re-run instruction (mirrors 02-VALIDATION.md Manual-Only Verifications): the user can re-run `uv run python .planning/phases/02-discovery-rebroadcast/uat_rounds.py` at any time to re-measure on the production network
</verification>

<success_criteria>
- Over ≥6 rounds against the production fleet, median per-round coverage equals full coverage (union roster; expected 73/73 vs the 48/73 single-broadcast baseline) — ROADMAP Phase 2 success criterion 4, DISC-03
- The measurement drives the real `discover_devices()` implementation shipped by plan 02-01
- The evidence artefact is on disk and machine-readable for /gsd-verify-work
</success_criteria>

<output>
Create `.planning/phases/02-discovery-rebroadcast/02-02-SUMMARY.md` when done, recording:
the per-round counts, roster size, median, baseline delta, and the verdict (or the honest
failure/blocked report if the fleet was not reachable or coverage fell short).
</output>
