---
phase: quick-260726-42c
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - scripts/serial_mac_audit.py
autonomous: false
requirements: [QUICK-260726-42c]

must_haves:
  truths:
    - "`uv run scripts/serial_mac_audit.py` sweeps the LAN via broadcast discover() and prints a Rich table with columns serial, derived_mac, real_mac, verdict, product_id, product_name, firmware (major.minor), ip, plus a summary panel grouping verdict counts by product + firmware"
    - "`--csv` mode emits ONLY CSV rows on stdout (stdlib csv.writer); all Rich chrome and progress lives on a Console(stderr=True) in both modes, so `--csv > file` pipes cleanly"
    - "Verdict classifies serial→real_mac as identical / off-by-one / mismatch / unknown, with BOTH MACs normalised to zero-padded lowercase octets before comparison (macOS arp emits unpadded octets)"
    - "The script imports lifx from the LOCAL checkout via PEP 723 [tool.uv.sources] editable path — never the PyPI release — and pyproject.toml is untouched"
    - "A device that fails to answer, or has no/incomplete ARP entry, produces a row with verdict unknown instead of aborting the sweep"
    - "`--self-test` exercises normalisation, ARP parsing, and verdict logic against fixtures (macOS arp line shapes, private-range addresses) with zero hardware, exiting 0 on success"
  artifacts:
    - scripts/serial_mac_audit.py
  key_links:
    - "PEP 723 block → [tool.uv.sources] lifx-async = { path = \"../\", editable = true } → local src/lifx (the code under test)"
    - "per-device probe → get_version() + get_host_firmware() (exactly 2 requests) → get_mac_address() (cached, no extra traffic) → derived_mac"
    - "single `arp -an` snapshot after all queries → parse → dict[ip, normalised_mac] → real_mac + verdict"
    - "Console(stderr=True) owns Progress in both modes; csv.writer(sys.stdout) is the only stdout writer in --csv mode"
---

<objective>
Create `scripts/serial_mac_audit.py`: a standalone PEP 723 diagnostic script that enumerates
every discoverable LIFX device's serial, derived MAC (from `Device.get_mac_address()`), real
MAC (from the OS ARP table), product type, and firmware version — building the ground-truth
correlation dataset that confirms, refines, or refutes the `version_major == 3` off-by-one
rule in `src/lifx/devices/base.py:666-683`.

Purpose: the library currently *derives* MAC from serial by assumption. Reporting only the
derived MAC would be circular; the ARP table is the independent observation (CONTEXT.md
## Implementation Decisions → MAC ground truth).

Output: `scripts/serial_mac_audit.py` (single file; no changes to pyproject.toml, uv.lock,
or library source).
</objective>

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

<context>
@.planning/quick/260726-42c-create-a-script-that-enumerates-mac-addr/260726-42c-CONTEXT.md
@.planning/quick/260726-42c-create-a-script-that-enumerates-mac-addr/260726-42c-RESEARCH.md
@scripts/mdns_probe.py
@src/lifx/devices/base.py
@src/lifx/api.py
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Pure correlation core + PEP 723 header + --self-test</name>
  <files>scripts/serial_mac_audit.py</files>
  <read_first>
    260726-42c-RESEARCH.md §"ARP lookup", §"PEP 723 inline metadata", §"Verdict computation",
    and §"Common Pitfalls" — every fact below is verified there; treat as authoritative.
    src/lifx/devices/base.py:666-683 (get_mac_address — the rule under test).
  </read_first>
  <behavior>
    Self-test assertions (all embedded in the script, run via `--self-test`, no network):
    - normalise_mac("00:00:5e:00:53:e") == "00:00:5e:00:53:0e" (unpadded-octet form macOS arp emits)
    - normalise_mac("0:0:5e:0:53:f8") == "00:00:5e:00:53:f8"; idempotent on already-padded input
    - serial_to_mac("d073d5010203") == "d0:73:d5:01:02:03"
    - parse_arp_darwin() on the verbatim 4-line arp -an block captured in RESEARCH.md §ARP:
      returns {"192.168.1.137": "00:00:5e:00:53:f8", "192.168.1.1": "00:00:5e:00:53:0e"};
      the `(incomplete)` entry (192.168.1.254) is absent; the duplicate 192.168.1.1
      (en0 + en1 lines) resolves first-parseable-entry-wins
    - classify_verdict("d073d5010203", "d0:73:d5:01:02:03") == "identical"
    - classify_verdict("d073d5010203", "d0:73:d5:01:02:04") == "off-by-one"
    - classify_verdict("d073d50102ff", "d0:73:d5:01:02:00") == "off-by-one" (wraparound (o5+1) % 256)
    - classify_verdict("d073d5010203", "d0:73:d5:01:02:05") == "mismatch" (octet 5 differs by +2 — off-by-one matches ONLY +1 mod 256)
    - classify_verdict("d073d5010203", "d0:73:d5:01:03:03") == "mismatch" (differs in a non-final octet)
    - classify_verdict("d073d5010203", None) == "unknown"
    - Path(lifx.__file__).resolve() == repo_root/"src"/"lifx"/"__init__.py" where repo_root =
      Path(__file__).resolve().parent.parent — proves the PEP 723 local-source pin resolved
      the checkout, not the PyPI release (RESEARCH Pitfall 2: without this the whole exercise
      is invalid)
  </behavior>
  <action>
    Create scripts/serial_mac_audit.py. Start with the PEP 723 block proven verbatim by
    RESEARCH.md Experiment 2 (CONTEXT.md ## Implementation Decisions → Dependencies):

    requires-python = ">=3.10"; dependencies = ["rich>=15.0.0", "lifx-async"]; and a
    [tool.uv.sources] table pinning lifx-async = { path = "../", editable = true }.
    The relative path resolves from the script's directory — "../" from scripts/ hits the
    repo root. Do NOT touch pyproject.toml or uv.lock (locked decision).

    All imports at the top of the file (CLAUDE.md rule) — no HAVE_LIFX try/except fallback:
    unlike mdns_probe.py, the PEP 723 block guarantees lifx is importable. Module docstring
    states purpose (ground-truth audit of the serial→MAC off-by-one rule), usage
    (`uv run scripts/serial_mac_audit.py [--csv] [--timeout N] [--self-test]`), and the two
    dataset caveats from RESEARCH: rows cover responders only, and a single discovery round
    under-counts (6-round median 48/73 in Spike 005) — re-runs enlarge the dataset. Repeat
    both caveats in the argparse epilog.

    Implement the pure core as small module-level functions (no I/O so they self-test cleanly):
    - normalise_mac(mac: str) -> str: ":".join(f"{int(o, 16):02x}" for o in mac.split(":")).
      Both derived and real MACs pass through this before ANY comparison — macOS arp emits
      non-zero-padded octets, and raw comparison silently mismatches every row (the
      highest-risk defect per RESEARCH Pitfall 1).
    - serial_to_mac(serial: str) -> str: 12-hex serial → colon-separated lowercase MAC.
    - parse_arp_darwin(text: str) -> dict[str, str]: parse `arp -an` lines with
      re.match(r"\S+ \((\d+\.\d+\.\d+\.\d+)\) at ([0-9a-fA-F:]+) ", line); `(incomplete)`
      fails the MAC group naturally; first parseable entry wins for duplicate IPs
      (multi-interface hosts); values stored normalised.
    - parse_arp_linux(text: str) -> dict[str, str]: bonus — /proc/net/arp whitespace columns,
      skip Flags 0x0 (incomplete); values normalised. Keep it simple; Linux is a bonus, not
      a requirement (CONTEXT.md Claude's Discretion).
    - classify_verdict(serial: str, real_mac: str | None) -> str: the locked verdict
      classifies serial → real_mac (RESEARCH §Verdict computation): "unknown" if real_mac is
      None; "identical" if normalise_mac(real) == serial_to_mac(serial); "off-by-one" if the
      first five octets match and real octet 5 == (serial octet 5 + 1) % 256; else "mismatch".
    - CSV_COLUMNS constant: ["serial", "derived_mac", "real_mac", "verdict", "product_id",
      "product_name", "firmware", "ip"] (locked column set).
    - An AuditRow dataclass mirroring CSV_COLUMNS (all str fields; real_mac empty-string when
      unknown in CSV output, rendered as a dim placeholder in the table).

    Add argparse with --self-test (plus --csv and --timeout stubs registered now, wired in
    Task 2). --self-test runs the assertions in <behavior> as plain asserts with messages,
    prints a pass summary to stderr, exits 0 on success / non-zero on the first failure.
    Embed the arp -an block from RESEARCH.md as the fixture string, with private-range placeholder addresses.

    Australian English throughout identifiers and prose (normalise_mac, colour-coded).
    Ruff gates apply to scripts/ (line-length 88, rules E/F/I/N/W/UP); pyright does not
    (pyproject include = ["src"]) — still write clean type hints per project convention.
  </action>
  <verify>
    <automated>uv run scripts/serial_mac_audit.py --self-test && uv run ruff format --check scripts/serial_mac_audit.py && uv run ruff check scripts/serial_mac_audit.py</automated>
  </verify>
  <done>
    --self-test exits 0 with every <behavior> assertion passing (including the lifx-resolves-
    to-local-checkout check), ruff format and lint are clean, and pyproject.toml/uv.lock have
    no diff.
  </done>
</task>

<task type="auto">
  <name>Task 2: Fleet sweep, ARP snapshot, Rich table + summary panel, --csv mode</name>
  <files>scripts/serial_mac_audit.py</files>
  <read_first>
    260726-42c-RESEARCH.md §"lifx-async API surface" (discover() signature, per-device fields,
    connection pattern), §"Rich patterns", §"Concurrency &amp; failure isolation".
  </read_first>
  <action>
    Wire the end-to-end sweep into main() using the researched lean pattern:

    Discovery + probe (CONTEXT.md ## Implementation Decisions → Discovery scope):
    - async for device in lifx.discover(timeout=args.timeout, device_timeout=5.0,
      max_retries=3): start a probe task immediately (overlap querying with the discovery
      window). Default --timeout 15.0 (library default). The lowered device_timeout/
      max_retries stop one dead device holding a slot ~16 s (RESEARCH Pitfall 6).
    - Bound concurrency with asyncio.Semaphore(16) around the probe body.
    - Probe body — do NOT use `async with device:` (7 requests instead of 2, RESEARCH
      Pitfall 3). Exactly: version = await device.get_version(); fw = await
      device.get_host_firmware(); derived = await device.get_mac_address() (cached after
      get_host_firmware — no extra packet). product name via lifx.products.get_product(
      version.product).name, guarded so an unknown product id yields "unknown" rather than
      raising. firmware column f"{fw.version_major}.{fw.version_minor}".
    - Failure isolation: wrap the probe body in try/except LifxError, producing an AuditRow
      with verdict "unknown" and the error noted on stderr; always await
      device.connection.close() in a finally block. Collect via asyncio.gather with
      return_exceptions=True as a backstop — one silent device must never abort the run
      (CONTEXT.md Claude's Discretion → failure handling).

    ARP snapshot (CONTEXT.md ## Implementation Decisions → MAC ground truth):
    - ONE snapshot after all probes complete — never per-IP invocations. Discovery unicast +
      the two probe requests already populated the entries. sys.platform gate: darwin →
      subprocess.run(["arp", "-an"], capture_output=True, text=True, check=False) parsed by
      parse_arp_darwin (fixed argv list, no shell; if bandit/prek flags B603/B607 add a
      targeted # nosec with justification, matching the mdns_probe.py # nosec precedent);
      linux → read /proc/net/arp via parse_arp_linux, falling back to
      ["ip", "-4", "neigh", "show"] if the proc file is missing; any other platform → empty
      dict plus a stderr warning that all verdicts will be unknown.
    - real_mac = arp_map.get(row.ip); verdict = classify_verdict(serial, real_mac).

    Output (CONTEXT.md ## Implementation Decisions → Output format, all locked):
    - err_console = Console(stderr=True) owns Progress(console=err_console, transient=True)
      in BOTH modes: add_task("Querying devices", total=None), bump total as devices arrive,
      advance per completed probe. stderr never contaminates a stdout pipe.
    - Human mode (default): stdout Console prints a Rich Table with the locked columns in
      CSV_COLUMNS order, verdict cell styled via {"identical": "green", "off-by-one":
      "yellow", "mismatch": "bold red", "unknown": "dim"}; rows sorted by (product_name,
      firmware, serial). Then a Panel titled "Correlation by product + firmware" containing
      a nested Table grouped by (product_name, firmware) with per-verdict counts — that
      grouping is the whole point of the exercise. Finish with a one-line responders-only /
      re-run reminder on stderr.
    - --csv mode: csv.writer(sys.stdout) writes the CSV_COLUMNS header row then data rows —
      no Rich object ever touches stdout in this mode; stdlib csv is the only stdout writer.
    - Rich renderables receive str fields only — device.serial and get_mac_address() are
      already str (never bytes), satisfying the CLAUDE.md user-visible-fields rule.

    Keep --self-test green: pure functions unchanged, and the sweep path fully separated
    from the self-test path.
  </action>
  <verify>
    <automated>uv run scripts/serial_mac_audit.py --help >/dev/null && uv run scripts/serial_mac_audit.py --self-test && uv run ruff format --check scripts/serial_mac_audit.py && uv run ruff check scripts/serial_mac_audit.py && git diff --quiet pyproject.toml uv.lock</automated>
  </verify>
  <done>
    --help exits 0 (proving the PEP 723 environment resolves rich 15.x plus the editable
    local lifx at import time), --self-test still passes, ruff format/lint are clean, and
    pyproject.toml/uv.lock are untouched. Hardware sweep is deliberately NOT part of this
    automated verify — it is Task 3's manual confirmation.
  </done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <what-built>
    scripts/serial_mac_audit.py — the serial/MAC correlation audit script. Automated
    verification (self-test, lint, PEP 723 resolution) already passed; this checkpoint
    confirms real-fleet behaviour, which cannot run in CI.
  </what-built>
  <how-to-verify>
    On the fleet network (~73 production devices):
    1. `uv run scripts/serial_mac_audit.py` — expect a progress bar on stderr during the
       sweep, then a colour-coded table (green identical / yellow off-by-one / red mismatch /
       dim unknown) and the "Correlation by product + firmware" summary panel. Spot-check a
       known gen3 device: derived_mac should differ from serial in the final octet by +1,
       and verdict should read off-by-one if the library rule is right for it.
    2. `uv run scripts/serial_mac_audit.py --csv > /tmp/audit.csv` — progress still appears
       on stderr; `head /tmp/audit.csv` shows ONLY the header row and CSV data rows (no Rich
       chrome, no progress fragments).
    3. Optional: re-run once or twice — single rounds under-count (median 48/73); rows should
       accumulate across runs when appended for analysis.
  </how-to-verify>
  <resume-signal>Type "approved" or describe issues (e.g. table rendering, CSV contamination, wrong verdicts)</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| LAN → script | Untrusted UDP discovery responses and ARP table contents enter here |
| script → OS | Subprocess invocation of `arp` / `ip` |
| PyPI → PEP 723 env | `rich` fetched into the ephemeral script environment |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-Q42C-01 | Tampering | arp/ip subprocess call | low | mitigate | Fixed argv list, no shell=True; output parsed with a strict IP/MAC regex that discards anything malformed |
| T-Q42C-02 | Spoofing | discovery responses / ARP entries | low | accept | Diagnostic tool on the operator's own L2 segment; library discovery already validates source ID and serial (DoS protection in discover_devices) |
| T-Q42C-03 | Information disclosure | CSV/table output (serials, MACs, IPs) | low | accept | Local diagnostic data written only where the operator pipes it; nothing committed or transmitted |
| T-Q42C-SC | Tampering | PyPI install of rich via PEP 723 | high | mitigate | RESEARCH.md Package Legitimacy Audit: rich 15.0.0 verified against PyPI JSON (Textualize) → OK; pinned `rich>=15.0.0`; confined to the inline block, never pyproject.toml |
</threat_model>

<verification>
- `uv run scripts/serial_mac_audit.py --self-test` exits 0 (hardware-free: normalisation,
  ARP parsing, verdict logic, local-checkout import proof)
- `uv run scripts/serial_mac_audit.py --help` exits 0 (PEP 723 env resolves rich + editable lifx)
- `uv run ruff format --check scripts/serial_mac_audit.py && uv run ruff check scripts/serial_mac_audit.py` clean
- `git diff --quiet pyproject.toml uv.lock` — zero-dependency surface untouched
- Task 3 human checkpoint: fleet table renders, verdicts sane, `--csv` pipe contains only CSV
</verification>

<success_criteria>
- scripts/serial_mac_audit.py exists, runs via `uv run`, and produces all eight locked
  columns in both Rich-table and CSV modes
- Verdict logic is proven by embedded self-test against the captured macOS arp line shapes,
  including the unpadded-octet and (incomplete)/duplicate-IP edge cases
- The script demonstrably tests the local checkout's get_mac_address(), not the PyPI release
- No dependency leaks into pyproject.toml; library source untouched
- Operator has approved a real-fleet run at the Task 3 checkpoint
</success_criteria>

<output>
Create `.planning/quick/260726-42c-create-a-script-that-enumerates-mac-addr/260726-42c-SUMMARY.md` when done
</output>
