---
phase: 04-animation-flow-control
plan: 05
type: execute
wave: 4
depends_on:
  - "04-04"
files_modified:
  - .planning/phases/04-animation-flow-control/uat_ack_stream.py
autonomous: true
requirements:
  - ANIM-03
  - ANIM-04

must_haves:
  truths:
    - "A standalone headless harness streams via the SHIPPED Animator (for_matrix + send_frame) while a single-shot concurrent query prober measures loss — never a reimplementation of the gate or a retry-masked prober (Phase 2/3 harness rule)"
    - "Thresholds and the 0/1/2 exit-code contract are fixed in the file BEFORE any run and never adjusted afterwards; every run writes its actual numbers to JSON; an unreachable device exits 2 (ENV-ERROR) without hammering"
    - "The ceiling profile powers the device on before streaming (animation is invisible otherwise), verifies the reported tile chain dimensions, and records CopyFrameBuffer probe ack RTT samples as ANIM-04 evidence"
  artifacts:
    - path: ".planning/phases/04-animation-flow-control/uat_ack_stream.py"
      provides: "ack-gated streaming + concurrent-query-loss measurement harness with tiles/ceiling profiles and a fixed exit-code contract"
      min_lines: 150
  key_links:
    - from: ".planning/phases/04-animation-flow-control/uat_ack_stream.py"
      to: "src/lifx/animation/animator.py"
      via: "imports Animator and drives the shipped send_frame gate — measuring the shipped implementation, not a copy"
      pattern: "from lifx.animation import Animator"
---

<objective>
Build the hardware UAT harness for ANIM-03 (Tiles: 0% concurrent-query loss under
20 FPS streaming, vs the 14.6% blind-fire baseline) and ANIM-04 (Ceiling 13×26
framebuffer path with CopyFrameBuffer probe ack RTT evidence). The harness is a
standalone phase-dir script modelled on the spike 003 measurement design
(stream + prober) and the Phase 2/3 harness conventions (uat_rounds.py /
uat_zero_loss.py): argparse, reachability guard, honest JSON evidence, fixed exit
codes. Plans 04-06 and 04-07 run it against real devices with human visual
checkpoints.

Purpose: the emulator cannot model real ack RTT under load (CONTEXT constraint) —
these hardware measurements are the only evidence closing ANIM-03/ANIM-04's loss and
RTT criteria, and repeated rounds are mandatory for loss claims (STATE blocker).
Output: `uat_ack_stream.py`, ruff-clean, --help verified.
</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/phases/04-animation-flow-control/04-CONTEXT.md
@.planning/phases/04-animation-flow-control/04-RESEARCH.md
@.planning/phases/03-retry-schedule-reshape/uat_zero_loss.py
@.claude/skills/spike-findings-lifx-async/sources/003-ack-paced-frames/stream.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Build the ack-stream measurement harness (uat_ack_stream.py)</name>
  <files>.planning/phases/04-animation-flow-control/uat_ack_stream.py</files>
  <read_first>
    - .planning/phases/03-retry-schedule-reshape/uat_zero_loss.py — the harness conventions this mirrors: module docstring shape (what/why/run/exit codes/honest-reporting rules), argparse style, reachability guard, JSON evidence writing, drive-the-shipped-implementation rule
    - .claude/skills/spike-findings-lifx-async/sources/003-ack-paced-frames/stream.py — the spike's measurement design to adapt (photons arm gate loop, concurrent GetColor prober cadence, frames offered/sent accounting); note the shipped Animator now owns the gate, so the harness only ticks and observes
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — Validation Architecture ANIM-03/ANIM-04 rows, measured baselines table (blind 14.6% loss / ack-gated 0.0% / ~98 ms median RTT), Assumptions A1/A2
    - src/lifx/animation/animator.py — AnimatorStats fields (gated, acks_outstanding) and the internal _ack_gate the RTT observer wraps (post-04-04)
  </read_first>
  <action>
    Create `.planning/phases/04-animation-flow-control/uat_ack_stream.py`, a standalone
    phase-dir script (never imported from src/ or tests/, never shipped). Requirements:

    1. Module docstring: ANIM-03/ANIM-04 hardware evidence; run commands for both
       profiles; the exit-code contract; honest-reporting rules (thresholds fixed in
       this file BEFORE any run, never adjusted afterwards; actual numbers always
       written to JSON); and a note that the visual-smoothness verdict is a separate
       human checkpoint (plans 04-06/04-07), not something this script asserts.
    2. argparse: `--ip` (required), `--profile {tiles,ceiling}` (required), `--fps`
       (default 20.0), `--duration` (default 30.0 s per round ≈ 600 offered frames at
       20 FPS, matching spike 003), `--rounds` (default 3 — repeated rounds are
       mandatory for loss claims, STATE blocker), `--query-rate` (default 2.0/s, the
       spike prober cadence), `--query-timeout` (default 2.0 s), `--json-out`
       (required). `--help` must work without sending any packets.
    3. Device setup: `MatrixLight.from_ip(args.ip)` (both profiles — the ceiling is a
       matrix device and the harness needs only get_device_chain/set_power/get_power).
       Ceiling profile additionally: `await device.set_power(True)` plus a 1 s settle
       BEFORE streaming (D4-04 UAT prerequisite: animation is invisible on a powered-
       off ceiling; CONTEXT requires power-on first), and record the reported tile
       chain width×height in the JSON (assumption A2 verification — expected 13×26).
    4. Streaming arm (per round): `animator = await Animator.for_matrix(device)`; tick
       loop at `--fps` using a monotonic-deadline scheduler (no drift); per tick
       generate a moving colour-cycle frame (protocol-ready uint16 HSBK,
       `animator.pixel_count` entries) and call the SHIPPED `animator.send_frame` —
       the harness must never reimplement gating, flip header bytes, or touch
       templates. Count offered frames, sent frames (gated False), gated frames;
       sample `stats.acks_outstanding`. Ceiling profile also asserts per sent frame
       `packets_sent == 8` and records any deviation.
    5. Ack RTT observer (measurement-only, documented as such): wrap
       `animator._ack_gate.track` by plain attribute assignment to record
       {sequence: sent_at}; after each send_frame, diff the gate's outstanding keys —
       a sequence that disappeared with age ≤ 1.0 s is an ack (RTT sample = now −
       sent_at); age > 1.0 s is an expiry. Record RTT samples (median/p95 per round)
       and expiry count. RTT resolution is one frame tick (~50 ms at 20 FPS) — state
       this in the docstring. This private-reach is acceptable in a phase-dir
       measurement script (03-03 wrapped send_packet the same way); the library
       surface itself stays unreached-into for consumers.
    6. Concurrent query prober (per round, asyncio task on a SEPARATE connection):
       a dedicated `DeviceConnection` to the same device with `max_retries=0` —
       SINGLE-SHOT probes so Phase 3 retransmits cannot mask loss (the spike measured
       loss this way; ROADMAP notes Phase 3 is deliberately not a prerequisite).
       Issue `Light.GetColor()` every 1/query-rate seconds with `--query-timeout`;
       count ok/lost; record latency samples (median).
    7. Reachability guard: before round 1, one GetColor probe with a generous timeout
       on a normal (retrying) connection; on failure exit 2 (ENV-ERROR) immediately
       without streaming — an off-network run must never fabricate a pass or spray
       600 frames at nothing.
    8. Results JSON: per-round arrays (offered, sent, gated, delivered_ratio, queries
       ok/lost, loss_pct, query latency median, ack RTT median/p95, expiry count),
       profile, device chain dims, the fixed thresholds, the spike 003 baseline block
       (blind 14.6% loss / photons-gated 0.0% / delivered 530/600 / RTT ~98 ms median
       ~150 ms p95), a pass flag and the exit code.
    9. Exit-code contract (fixed here, before any run): 0 PASS — every round has
       query loss == 0 AND delivered_ratio ≥ 0.85; ceiling profile additionally: all
       sent frames were 8 packets AND ≥ 1 ack RTT sample per round (proves the CopyFB
       probe is being acked — A1 evidence). 1 FAIL — device reachable but any
       threshold missed (genuine signal: report, never massage). 2 ENV-ERROR —
       reachability probe failed.
    10. Style: all imports at top, stdlib + lifx only, Australian English, ruff-clean,
        asyncio.run entry point guarded by `if __name__ == "__main__":`.
  </action>
  <verify>
    <automated>uv run python .planning/phases/04-animation-flow-control/uat_ack_stream.py --help && uv run ruff check .planning/phases/04-animation-flow-control/uat_ack_stream.py</automated>
  </verify>
  <acceptance_criteria>
    - `uv run python .planning/phases/04-animation-flow-control/uat_ack_stream.py --help` exits 0 without sending any packets
    - `uv run ruff check .planning/phases/04-animation-flow-control/uat_ack_stream.py` passes
    - The harness imports and drives the shipped `Animator.for_matrix`/`send_frame` and `DeviceConnection` — no reimplemented gating, no header manipulation, no template access beyond the documented measurement-only RTT observer
    - The prober uses max_retries=0 single-shot requests — loss cannot be masked by Phase 3 retransmits
    - The thresholds and exit-code contract in the file match this plan exactly (set before the measurement runs and never changed after)
  </acceptance_criteria>
  <done>A standalone, ruff-clean harness exists that measures the shipped ack-gated streaming path (loss, delivery ratio, ack RTTs, ceiling packet shape) with a fixed, pre-declared pass contract for plans 04-06 and 04-07 to execute.</done>
</task>

</tasks>

## Artifacts this plan produces

| Artifact | Kind | Location |
|----------|------|----------|
| `uat_ack_stream.py` — ack-gated streaming + single-shot query-loss harness (tiles/ceiling profiles, power-on handling, CopyFB RTT observer, fixed 0/1/2 exit-code contract) | Measurement harness (phase-dir only, never shipped) | `.planning/phases/04-animation-flow-control/uat_ack_stream.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Harness → LAN devices | Outbound 20 FPS streaming + 2/s queries against user-owned devices on the user's own network |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-08 | DoS (outbound, self-inflicted) | streaming loop | low | mitigate | Fixed FPS/duration/rounds; the shipped gate itself throttles under congestion; reachability guard aborts (exit 2) instead of hammering an absent device |
| T-04-09 | Repudiation | measurement evidence | low | mitigate | Machine-readable JSON with pre-declared thresholds; the harness file must be byte-identical before and after runs (03-03 precedent) |

No packages are installed by this plan (zero-dependency project) — no supply-chain gate applies.
</threat_model>

<verification>
- `--help` exits 0 with no packets; ruff clean
- Shipped-implementation rule and single-shot-prober rule verifiable by reading the imports and connection construction
</verification>

<success_criteria>
- The ANIM-03/ANIM-04 measurement contract exists on disk, fixed before any hardware run, driving only shipped code paths
</success_criteria>

<output>
Create `.planning/phases/04-animation-flow-control/04-05-SUMMARY.md` when done, recording:
the harness contract (thresholds, exit codes, profiles) and the commit hash.
</output>
