---
phase: 04-animation-flow-control
plan: 04
type: execute
wave: 3
depends_on:
  - "04-02"
  - "04-03"
files_modified:
  - src/lifx/animation/flow.py
  - src/lifx/animation/animator.py
autonomous: true
requirements:
  - ANIM-01
  - ANIM-02

must_haves:
  truths:
    - "send_frame gates on outstanding ack probes: ack flag baked once at init on the probe template, sweep at frame top, ≥2 outstanding drops the frame (packets_sent=0, no sequence consumed, framebuffer untouched), entries expire after ~1 s, latest-frame-wins — all internal, no consumer-facing toggle (D4-01, D4-02)"
    - "The zero-allocation prebaked-template hot path is preserved: per sent frame the only additions are one non-blocking sweep on the animator's existing socket and one dict write for the probe (D4-03); send_frame remains synchronous"
    - "Ack collection is a proper animator facility (AckGate in flow.py) — no reaching into transport or connection internals, no event-loop coupling, scope confined to src/lifx/animation/ (D4-05)"
    - "AnimatorStats gains additive gated/acks_outstanding fields with defaults — every existing construction site and test passes unmodified"
    - "New/changed lines in flow.py, animator.py and packets.py have 100% branch patch coverage; the full suite is green"
  artifacts:
    - path: "src/lifx/animation/flow.py"
      provides: "AckGate + ACK_INFLIGHT_LIMIT/ACK_EXPIRY_SECONDS/ACK_PKT_TYPE constants"
    - path: "src/lifx/animation/animator.py"
      provides: "ack-gated send_frame, probe baking at init, additive AnimatorStats fields, gate reset in close()"
  key_links:
    - from: "src/lifx/animation/animator.py"
      to: "src/lifx/animation/flow.py"
      via: "Animator owns an AckGate; send_frame sweeps then gates before framebuffer work"
      pattern: "self._ack_gate"
    - from: "src/lifx/animation/animator.py"
      to: "src/lifx/animation/packets.py"
      via: "bakes ACK_REQUIRED_FLAG into templates[probe_template_index].data[FLAGS_OFFSET] once at __init__"
      pattern: "probe_template_index"
---

<objective>
Turn plan 04-02's RED suite green: implement the `AckGate` facility in
src/lifx/animation/flow.py and wire ack-gated flow control into `Animator.send_frame`
per D4-01 — probe on one packet per frame (the template chosen by
`probe_template_index`), gate while ≥2 probe acks are outstanding, ~1 s expiry,
latest-frame-wins, zero retransmits. Then audit branch patch coverage across the whole
phase diff and run the full-suite regression (Phase 3's 03-02 cadence).

Purpose: closes ANIM-01 and ANIM-02 — spike 003 measured this exact design at 0.0%
concurrent-query loss (vs 14.6% blind-fire) while remaining the visually smoothest arm.
The animator's existing socket already receives the acks (devices reply to the probe's
source port), so a synchronous non-blocking sweep needs no transport, no event loop,
and no API change (D4-02, D4-03, D4-05).
Output: flow.py, gated animator, 100% branch patch coverage, full suite green.
</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
@src/lifx/animation/animator.py
@src/lifx/animation/packets.py
@tests/test_animation/test_flow.py
@tests/test_animation/test_animator.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: AckGate facility (src/lifx/animation/flow.py)</name>
  <files>src/lifx/animation/flow.py</files>
  <read_first>
    - tests/test_animation/test_flow.py — the 16-row branch matrix this must satisfy exactly (API: gated, outstanding_count, track(sequence, now), sweep(sock, source, now), reset; constant values)
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — "AckGate facility" code example (the verified reference implementation) and Pitfall 6 (Windows OSError handling)
    - .claude/skills/spike-findings-lifx-async/references/animation-flow-control.md — spike tuning provenance (~98 ms median / ~150 ms p95 ack RTT under load; limit 2; expiry 1 s)
  </read_first>
  <action>
    Create src/lifx/animation/flow.py per the research reference implementation
    (verified against spike 003's hardware-proven collect_acks), adapted to the pinned
    test API. Requirements:

    1. Module-level `Final` constants with rationale comments in the project's
       const.py doc-comment style: `ACK_PKT_TYPE: Final[int] = 45` (Device
       Acknowledgement), `ACK_INFLIGHT_LIMIT: Final[int] = 2` and
       `ACK_EXPIRY_SECONDS: Final[float] = 1.0` (both spike-003-measured on Tiles under
       20 FPS load, D4-01). Read them via module-attribute lookup inside methods (the
       project's runtime-read idiom) rather than binding as defaults.
    2. `AckGate` with `__slots__ = ("_outstanding", "_buf")`: `_outstanding:
       dict[int, float]` (probe sequence → sent monotonic time) and one preallocated
       `bytearray(64)` receive buffer (acks are exactly 36 bytes) — zero per-datagram
       allocation.
    3. `gated` property (`len >= ACK_INFLIGHT_LIMIT`), `outstanding_count` property,
       `track(sequence: int, now: float)` (plain dict write — a wrap collision
       overwrites the stale entry, erring towards sending; document this in the method
       docstring per the research wrap analysis), `reset()`.
    4. `sweep(sock: socket.socket, source: int, now: float) -> None`: drain loop of
       `sock.recvfrom_into(self._buf)`; `except BlockingIOError: break` (normal empty
       exit); `except OSError: break` (Windows WSAECONNRESET after ICMP unreachable —
       next frame sweeps again); skip datagrams with nbytes < 36; peek pkt_type via
       `buf[32] | (buf[33] << 8)`, source via `struct.unpack_from("<I", buf, 4)[0]`,
       sequence via `buf[23]` — fixed-offset peeks only, no parse_message, no object
       allocation (V5 untrusted-input posture: validate length, type, source before
       any state change). Pop matching sequences. After draining, prune entries where
       `now - t > ACK_EXPIRY_SECONDS` (zero retransmits by design — an expired probe
       only frees a gate slot).
    5. Class docstring covers: what the gate is (D4-01 internal flow control), the
       latest-frame-wins recovery model, the wrap-collision overwrite semantics, and
       the no-ack degradation floor (if acks never arrive, throughput degrades to
       ACK_INFLIGHT_LIMIT / ACK_EXPIRY_SECONDS = 2 frames/s — intended throttling,
       research Pitfall 5).
    6. Imports at top (socket, struct, typing.Final); pyright strict clean; Australian
       English; `uv run ruff format`.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_flow.py -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_flow.py -q` passes completely (all 16 branch-matrix tests) with zero test edits
    - AckGate performs no allocation per swept datagram (preallocated buffer, fixed-offset peeks)
    - `uv run pyright src/lifx/animation/flow.py && uv run ruff check src/lifx/animation/flow.py` are clean
  </acceptance_criteria>
  <done>The AckGate facility exists as a self-contained, fully tested animation-layer module with spike-measured tuning constants.</done>
</task>

<task type="auto">
  <name>Task 2: Wire ack-gated flow control into Animator</name>
  <files>src/lifx/animation/animator.py</files>
  <read_first>
    - tests/test_animation/test_animator.py — TestAnimatorStatsFlowFields, TestAnimatorProbeBaking, TestAnimatorGating, TestAnimatorFlowControlIntegration and the updated loop tests (plan 04-02): the exact contract to satisfy
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — "send_frame gating" code example (target ordering) and concrete edit point 3 (init baking, close reset, stats fields)
    - src/lifx/animation/animator.py — current __init__ (93-133), send_frame (311-359), close (361-368), AnimatorStats (53-64), module + class docstrings
  </read_first>
  <action>
    Edit src/lifx/animation/animator.py:

    1. `AnimatorStats`: add `gated: bool = False` and `acks_outstanding: int = 0`
       (frozen dataclass, additive defaults — existing constructions unaffected).
       Document both: gated means the frame was dropped by flow control
       (latest-frame-wins — resend the NEXT frame, never queue this one);
       acks_outstanding is the post-send outstanding probe count for observability.
    2. `__init__`: import AckGate from lifx.animation.flow and
       FLAGS_OFFSET/ACK_REQUIRED_FLAG from lifx.animation.packets (top-of-file
       imports). Create `self._ack_gate = AckGate()`; compute `self._probe_index =
       packet_generator.probe_template_index`; bake the flag exactly once:
       `self._templates[self._probe_index].data[FLAGS_OFFSET] |= ACK_REQUIRED_FLAG`.
       The hot path never touches the flags byte again (D4-03).
    3. `send_frame` — reorder to: (a) `start_time = time.perf_counter()`; (b) explicit
       length check `len(hsbk) != self._framebuffer.canvas_size` raising ValueError
       with the existing "must match pixel_count" wording — the validation contract
       must hold even for gated frames (research Pitfall 7); (c) lazy socket creation
       (unchanged); (d) `now = time.monotonic()`;
       `self._ack_gate.sweep(self._socket, self._source, now)`; (e) if
       `self._ack_gate.gated`: return AnimatorStats(packets_sent=0, total_time_ms=…,
       gated=True, acks_outstanding=self._ack_gate.outstanding_count) — nothing sent,
       no framebuffer work, no sequence consumed; (f) otherwise the existing apply →
       update_colors → send loop, unchanged except the loop enumerates templates and
       when the index equals `self._probe_index` calls
       `self._ack_gate.track(self._sequence, now)` BEFORE incrementing the sequence
       (the probe's stamped sequence is what the device echoes back); (g) the success
       return adds `acks_outstanding=self._ack_gate.outstanding_count`.
    4. `close()`: also call `self._ack_gate.reset()`.
    5. Docstrings: update the module docstring, class docstring and send_frame
       docstring — they currently describe a pure fire-and-forget sender with no
       receive path; describe instead the internal ack-gated pacing (probe on one
       packet per frame, gate at 2 outstanding, ~1 s expiry, latest-frame-wins, the
       ~2 frames/s no-ack floor) and that consumers keep calling the same API with no
       flow-control options (D4-02). send_frame remains synchronous — state that the
       sweep is non-blocking on the animator's own socket.
    6. Do NOT export AckGate or flow constants from lifx.animation.__init__ (D4-02
       internal facility). `uv run ruff format` the file.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/ -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/ -q` passes completely — the entire 04-01 + 04-02 RED surface is green with zero test edits, including the deterministic emulator gating tests and the 13×26 framebuffer-path test
    - `uv run pytest tests/test_animation/test_animator.py -q -k "test_send_frame_is_synchronous"` passes — send_frame is still not a coroutine
    - `! grep -v '^\s*#' src/lifx/animation/animator.py | grep -qi 'no waiting'` exits 0 (i.e. no non-comment "no waiting" text remains) — stale fire-and-forget docstring claims are gone
    - `uv run pyright src/lifx/animation/ && uv run ruff check src/lifx/animation/` are clean
    - `git diff --stat` for this task touches only src/lifx/animation/animator.py (D4-05 scope guard)
  </acceptance_criteria>
  <done>ANIM-01's gate semantics and ANIM-02's facility contract are implemented and green: probe baked once, sweep-then-gate before framebuffer work, latest-frame-wins drops, additive observability, unchanged public API.</done>
</task>

<task type="auto">
  <name>Task 3: Branch-coverage audit + full-suite regression</name>
  <files>src/lifx/animation/flow.py, src/lifx/animation/animator.py</files>
  <read_first>
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — "Branch matrix for 100% branch patch coverage" (the checklist to audit against) and "Sampling Rate"
    - .planning/phases/03-retry-schedule-reshape/03-02-SUMMARY.md — the Phase 3 audit precedent: check branch partials within the phase's edited ranges only; narrow tests for real gaps; a documented `# pragma: no branch` ONLY for structurally unreachable arcs, never control-flow distortion
  </read_first>
  <action>
    1. Run `uv run pytest tests/test_animation/ --cov=lifx.animation --cov-report=term-missing -q`
       (branch mode comes from addopts). Audit every missing line/branch that falls
       inside this phase's edited ranges (flow.py entirely; animator.py init/send_frame/
       close edits; packets.py 04-03 edits) against the research branch matrix:
       sweep exits (empty/OSError), runt/wrong-type/wrong-source/unknown-seq skips,
       expiry pruned/not-pruned, gated early return, probe tracked at correct index,
       length-mismatch raise, close reset, probe_template_index arms, large-tile
       divisible/non-divisible/final-partial-row.
    2. Close any genuine gap with a narrow test in the matching test file (project
       rule: 100% BRANCH patch coverage — check branch partials, not just lines). If
       an arc is structurally unreachable, mark it `# pragma: no branch` with an
       explanatory comment, following the 03-02 precedent — never add dead branches to
       force coverage.
    3. Full regression: `uv run --frozen pytest` — entire suite green (2500+ tests),
       runtime comparable to the ~112 s Phase 3 baseline.
    4. Quality gates: `uv run pyright` (whole project) and `uv run ruff check .` clean.
    5. Scope guard: `git status --porcelain` shows only this phase's files touched —
       nothing under src/lifx/network/, src/lifx/devices/, src/lifx/effects/ (D4-05).
  </action>
  <verify>
    <automated>uv run --frozen pytest -q && uv run pyright && uv run ruff check .</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/ --cov=lifx.animation --cov-report=term-missing` shows no missing lines or branch partials within the phase's edited ranges of flow.py, animator.py and packets.py
    - Full suite passes with no regressions outside the animation module
    - pyright strict and ruff are clean project-wide
    - Any pragma added is documented with the structural-unreachability argument in the code comment and the summary
  </acceptance_criteria>
  <done>The phase's automated surface independently satisfies CI's 100% branch patch coverage gate, with the full suite green — ANIM-01/ANIM-02 closed pending hardware evidence.</done>
</task>

</tasks>

## Artifacts this plan produces

| Artifact | Kind | Location |
|----------|------|----------|
| `AckGate` + `ACK_INFLIGHT_LIMIT`/`ACK_EXPIRY_SECONDS`/`ACK_PKT_TYPE` | Flow-control facility (internal, unexported) | `src/lifx/animation/flow.py` |
| Ack-gated `send_frame`, probe baked at init, gate reset in `close()` | Animator wiring | `src/lifx/animation/animator.py` |
| `AnimatorStats.gated` / `AnimatorStats.acks_outstanding` | Additive observability fields | `src/lifx/animation/animator.py` |
| Branch-coverage audit vs the research matrix | Quality evidence | plan summary |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| LAN → animator ephemeral port | The sweep parses untrusted UDP datagrams arriving at the animator's socket |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-05 | Tampering/DoS | AckGate.sweep | medium | mitigate | nbytes ≥ 36 guard before any offset read; fixed-offset peeks only; pkt_type + source + tracked-sequence triple match before state change; sweep bounded by the kernel recv buffer per frame — mirrors the discovery layer's source-validation posture (V5) |
| T-04-06 | DoS (of pacing) | ack flood attempting to unlatch the gate | low | accept | Worst case equals today's shipped blind-fire baseline (gate never closes) — no new failure mode below current behaviour |
| T-04-07 | DoS (self-inflicted stall) | gate never opening | low | mitigate | Expiry prunes at 1 s: floor of 2 frames/s, never a deadlock; surfaced via AnimatorStats.gated/acks_outstanding |

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

<verification>
- Entire tests/test_animation/ suite green with zero test edits in this plan (except narrow coverage-gap tests from Task 3)
- 100% branch patch coverage within the phase's edited ranges; full suite + pyright + ruff green
- Diff confined to src/lifx/animation/ (D4-05)
</verification>

<success_criteria>
- ANIM-01: gate at 2 outstanding / 1 s expiry / latest-frame-wins implemented as internal behaviour, deterministically proven end-to-end against the emulator (ack-drop gating, expiry reopen, 13×26 framebuffer path)
- ANIM-02: prebaked zero-allocation path preserved (flag baked once; per-frame additions are one sweep + one dict write), AckGate is a proper unexported animator facility, public API unchanged
</success_criteria>

<output>
Create `.planning/phases/04-animation-flow-control/04-04-SUMMARY.md` when done, recording:
the coverage audit table, full-suite result, any pragmas with their justification, and commit hashes.
</output>
