---
phase: 04-animation-flow-control
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
  - tests/test_animation/conftest.py
  - tests/test_animation/test_flow.py
  - tests/test_animation/test_animator.py
autonomous: true
requirements:
  - ANIM-01
  - ANIM-02

must_haves:
  truths:
    - "A failing (RED) branch-matrix suite pins the AckGate contract per D4-01: sweep/track with explicit now values, gate at ACK_INFLIGHT_LIMIT=2 outstanding, ACK_EXPIRY_SECONDS=1.0 expiry, wrap-collision overwrite, untrusted-datagram triple validation"
    - "A failing (RED) suite pins the animator contract: ack flag baked once at init on the probe template, gated frames dropped (never queued) with packets_sent=0 and no sequence consumed, AnimatorStats grows additive gated/acks_outstanding fields, send_frame stays synchronous with no new public toggle (D4-02)"
    - "Deterministic emulator tests pin end-to-end gating via drop_packets {45: 1.0} scenarios and a 13×26 large-tile emulated device — no timing races"
    - "The six enumerated mocked-socket tests and three emulator loop tests are updated per the research's test-impact enumeration; existing behaviour tests stay green until they assert new fields"
  artifacts:
    - path: "tests/test_animation/test_flow.py"
      provides: "AckGate unit branch matrix (RED until flow.py exists)"
    - path: "tests/test_animation/conftest.py"
      provides: "mock_udp_socket fixture + make_ack_datagram helper + 13×26 emulated large-tile device fixture"
    - path: "tests/test_animation/test_animator.py"
      provides: "gating/probe-baking/stats RED tests + deterministic emulator gating tests + updated existing tests"
  key_links:
    - from: "tests/test_animation/test_flow.py"
      to: "src/lifx/animation/flow.py"
      via: "imports AckGate and constants that plan 04-04 creates"
      pattern: "from lifx.animation.flow import"
    - from: "tests/test_animation/test_animator.py"
      to: "src/lifx/animation/animator.py"
      via: "asserts gate-before-framebuffer ordering, probe tracking and additive stats fields"
      pattern: "stats.gated"
---

<objective>
Write the Wave-0 RED suite for the flow-control half of Phase 4: the `AckGate` facility
branch matrix (ANIM-01), the animator's gating/probe-baking/stats contract (ANIM-01,
ANIM-02, D4-01/D4-02/D4-03), the shared mocked-socket fixture that all animator socket
tests need once `send_frame` sweeps for acks (research Pitfall 2), and deterministic
emulator tests using ack-drop scenarios and a 13×26 emulated device. These tests define
GREEN for plan 04-04.

Purpose: spike 003 measured 14.6% concurrent-query loss from blind-fire streaming and
0.0% with the ack-gated arm; this suite pins the exact internal behaviour (probe on one
packet per frame, gate at 2 outstanding, ~1 s expiry, latest-frame-wins) before any
implementation exists.
Output: RED test_flow.py, RED gating tests in test_animator.py, green fixture plumbing.
</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
@tests/test_animation/test_animator.py
@tests/test_animation/conftest.py
@tests/conftest.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Shared fixtures — mock UDP socket with ack injection + 13×26 emulated device (stays green)</name>
  <files>tests/test_animation/conftest.py, tests/test_animation/test_animator.py</files>
  <read_first>
    - tests/test_animation/test_animator.py lines 92-197 and 283-299 — the six mocked-socket tests to migrate: `test_send_frame_sends_packets`, `test_send_frame_returns_stats`, `test_send_frame_reuses_socket`, `test_send_frame_sends_to_correct_address`, `test_close_closes_socket`, `test_for_light_sends_single_packet` (all patch `socket.socket` with a bare MagicMock today)
    - tests/conftest.py lines 352-388 — the `ceiling_device` fixture pattern to mirror for the 13×26 device (create_device + server.add_device + yield device + remove_device, `pytest.skip` when server is None/external)
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — Pitfall 2 (why bare MagicMock breaks the sweep loop) and Pitfall 4 lever 1 (crafted 36-byte datagrams through recvfrom_into.side_effect)
  </read_first>
  <action>
    In tests/test_animation/conftest.py add (all fully typed for pyright strict, imports
    at top):

    1. `make_ack_datagram(source: int, sequence: int, *, pkt_type: int = 45, size: int = 36) -> bytes` —
       module-level helper building a wire-accurate Acknowledgement: bytearray(size);
       `struct.pack_into("<H", buf, 0, size)`; `struct.pack_into("<I", buf, 4, source)`;
       `buf[23] = sequence`; `struct.pack_into("<H", buf, 32, pkt_type)`. Parameters
       allow the flow tests to craft wrong-type/wrong-source/runt datagrams.
    2. `mock_udp_socket` fixture — patches `socket.socket` (patch target: the `socket`
       module object, same as today's tests) and yields a small typed helper object
       exposing: `.sock` (the MagicMock socket instance), `.socket_class` (the patched
       class mock, for call-count assertions), and `.queue_datagram(data: bytes) -> None`.
       Implementation: an internal `deque[bytes]`; `sock.recvfrom_into.side_effect` is a
       function that pops the left datagram, copies it into the caller's buffer via
       slice assignment, and returns `(len(datagram), ("127.0.0.1", 56700))` — raising
       `BlockingIOError` when the deque is empty. This is the exact contract the future
       sweep loop drains.
    3. `large_tile_matrix_device` fixture (session scope, from `emulator_server`) —
       mirrors `ceiling_device`: `create_device(product_id=201, serial="d073d5000201",
       tile_width=13, tile_height=26, scenario_manager=scenario_manager)`, add to the
       running server, yield a `CeilingLight(serial="d073d5000201", ip="127.0.0.1",
       port=port, timeout=2.0, max_retries=2)`, remove the device on teardown. Skip when
       the server is None (external emulator). Imports from `lifx_emulator.factories`
       mirror tests/conftest.py.

    In tests/test_animation/test_animator.py migrate ONLY the six enumerated tests to
    use `mock_udp_socket` (drop their inline `with patch.object(socket, "socket")`
    blocks; assert against `mock_udp_socket.sock` / `.socket_class`). Do not change any
    assertion values — the fixture is behaviour-neutral today because the current
    send_frame never calls recvfrom_into, so the whole file must remain green after
    this task.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_animator.py -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_animator.py -q` passes completely (fixture migration is behaviour-neutral)
    - The six enumerated tests no longer contain inline `patch.object(socket, "socket")` blocks — they consume the shared fixture
    - `mock_udp_socket` defaults to raising BlockingIOError from recvfrom_into and supports queueing crafted datagrams that get copied into the caller's buffer
    - `uv run ruff check tests/test_animation/` and `uv run pyright tests/test_animation/conftest.py` are clean
  </acceptance_criteria>
  <done>All animator socket mocking flows through one sweep-compatible fixture, ack datagrams can be injected deterministically, and an emulated 13×26 large-tile device fixture exists — with the whole existing suite still green.</done>
</task>

<task type="auto">
  <name>Task 2: RED — AckGate unit branch matrix (test_flow.py)</name>
  <files>tests/test_animation/test_flow.py</files>
  <read_first>
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — "AckGate facility" code example (the API being pinned: gated property, track(sequence, now), sweep(sock, source, now), reset; constants ACK_PKT_TYPE=45, ACK_INFLIGHT_LIMIT=2, ACK_EXPIRY_SECONDS=1.0) and "Branch matrix for 100% branch patch coverage"
    - tests/test_animation/conftest.py — `make_ack_datagram` + `mock_udp_socket` from Task 1
  </read_first>
  <action>
    Create tests/test_animation/test_flow.py with a top-of-file
    `from lifx.animation.flow import ACK_EXPIRY_SECONDS, ACK_INFLIGHT_LIMIT, ACK_PKT_TYPE, AckGate`
    import (module does not exist yet — the file fails at collection, which is this
    plan's intended RED; it must not disturb any other file's collection). The suite
    also pins an `outstanding_count: int` read-only property (pyright-clean access for
    the animator and UAT harness — research Open Question 2 accepted). All timing uses
    explicit `now` float arguments — no sleeps anywhere (research Pitfall 4, lever 1).

    Branch matrix (one test each, using mock_udp_socket.sock + make_ack_datagram;
    SOURCE = an arbitrary uint32 like 0x12AB34CD):

    Constants and state:
    1. constants have the spike-measured values: ACK_PKT_TYPE == 45, ACK_INFLIGHT_LIMIT == 2, ACK_EXPIRY_SECONDS == 1.0 (D4-01)
    2. fresh gate: `gated` False, `outstanding_count` 0
    3. `track(5, now)` → outstanding_count 1, gated False; `track(6, now)` → 2, gated True (gate at ≥ limit)
    4. wrap-collision: `track(5, t0)` then `track(5, t1)` → outstanding_count still 1 (overwrite, errs towards sending — D4-01 latest-frame-wins recovery)
    5. `reset()` clears everything → gated False, outstanding_count 0

    Sweep drain loop:
    6. empty socket (immediate BlockingIOError) → no change, no raise
    7. one matching ack (correct source + tracked seq) → popped; gated reopens when count drops below limit
    8. multiple queued datagrams drained in a single sweep call
    9. ack for an untracked sequence → ignored, tracked entries unchanged
    10. runt datagram (< 36 bytes, e.g. make_ack_datagram(..., size=20)) → ignored, loop continues to a following valid ack in the same sweep
    11. wrong pkt_type (e.g. 3, StateService) → ignored
    12. wrong source (matching seq, different uint32) → ignored — another client's traffic must never unlatch the gate
    13. `recvfrom_into` raising `OSError` (e.g. Windows WSAECONNRESET after ICMP unreachable — research Pitfall 6) → sweep returns cleanly without raising; outstanding unchanged

    Expiry (explicit now, no sleeping):
    14. entry tracked at t0, sweep at t0 + 1.5 (> ACK_EXPIRY_SECONDS) → pruned, gate slot freed (zero retransmits by design — an expired probe just frees the slot)
    15. entry tracked at t0, sweep at t0 + 0.5 → retained
    16. mixed: one expired + one fresh entry → only the expired one pruned, gated becomes False

    All typed, docstrings naming the branch each test covers (the plan-04-04 coverage
    audit maps this matrix to `--cov-branch` partials).
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_flow.py -q; test $? -ne 0</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_flow.py -q` fails (collection error on the missing lifx.animation.flow module — the intended RED)
    - `uv run pytest tests/test_animation/test_packets.py tests/test_animation/test_framebuffer.py tests/test_animation/test_orientation.py -q -k "not RowAlignedChunking and not ProbeTemplateIndex and not HeaderFlagConstants"` still passes — the RED file is isolated
    - The 16-row branch matrix above is present, each row a distinct test with explicit-now timing and zero sleeps
    - `uv run ruff check tests/test_animation/test_flow.py` is clean
  </acceptance_criteria>
  <done>The complete AckGate behavioural contract (D4-01 tuning, untrusted-datagram validation, expiry, wrap-overwrite) is pinned by a deterministic RED suite defining GREEN for plan 04-04 Task 1.</done>
</task>

<task type="auto">
  <name>Task 3: RED — animator gating, probe baking, additive stats + deterministic emulator gating</name>
  <files>tests/test_animation/test_animator.py</files>
  <read_first>
    - src/lifx/animation/animator.py — `__init__` (lines 93-133), `send_frame` (lines 311-359: current apply → update_colors → socket → send-loop order the gate must precede), `AnimatorStats` (lines 53-64), `close` (lines 361-368)
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — "send_frame gating" code example (target ordering: length check → socket → sweep → gate early-return → apply/send with probe track), Pitfall 3 (emulator loop tests must tolerate/observe gating), Pitfall 4 lever 2 (drop_packets {45: 1.0} determinism), Pitfall 7 (length check must precede the gate)
    - tests/conftest.py lines 526-580 — `emulator_server_with_scenarios` usage (apply scenario to a serial, get server port)
    - tests/test_animation/test_animator.py lines 326-487 — the emulator classes and the three loop/integration tests to update: `TestAnimatorForMatrixIntegration.test_send_frame_sends_packets`, `TestAnimatorForMatrixIntegration.test_animation_loop_simulation`, `TestAnimatorForMultizoneIntegration.test_animation_loop_simulation`
  </read_first>
  <action>
    Extend tests/test_animation/test_animator.py. Access new AnimatorStats fields
    directly (`stats.gated`) — AttributeError failures are the intended RED; keep all
    imports resolvable today so the file still collects.

    A. `TestAnimatorStatsFlowFields` (unit):
    1. `AnimatorStats(packets_sent=1, total_time_ms=0.5)` constructs (additive fields
       have defaults) and `stats.gated is False`, `stats.acks_outstanding == 0`. RED.

    B. `TestAnimatorProbeBaking` (unit, uses the standard 8×8 fixture animator plus a
    13×26 animator built directly from `FrameBuffer(pixel_count=338)` +
    `MatrixPacketGenerator(1, 13, 26)`):
    2. standard matrix: `_templates[0].data[22] & 0x02` is set after construction —
       the ack flag is baked ONCE at init on the probe template (D4-01/D4-03); RED.
    3. large tile 13×26: only the FINAL template (the CopyFrameBuffer,
       `probe_template_index` 7) carries the flag; every other template's byte 22 is 0
       (D4-04). RED.
    4. for_light: the single template carries the flag. RED.

    C. `TestAnimatorGating` (unit, mock_udp_socket with no acks queued; the 8×8
    fixture animator = 1 packet/frame so frame N tracks probe seq N−1):
    5. frames 1 and 2 send (`gated is False`, packets_sent 1, acks_outstanding 1 then
       2); frame 3 returns `gated is True`, `packets_sent == 0`, and
       `sock.sendto.call_count` is unchanged — dropped, never queued. RED.
    6. gated frames skip framebuffer work: wrap `animator._framebuffer.apply` in a
       MagicMock spy (side-effect-preserving) and assert it is NOT called for the gated
       frame (research: gate check before any framebuffer work). RED.
    7. wrong-length input raises ValueError matching "must match pixel_count" EVEN when
       the gate is saturated (length check first — research Pitfall 7). RED.
    8. gated frames consume no sequence numbers: capture `data[23]` from each sendto
       call; after a gated frame, the next successful frame's sequence continues
       contiguously. RED.
    9. queueing `make_ack_datagram(animator._source, tracked_seq)` for one tracked
       probe reopens the gate — the next send_frame sweeps it and sends. RED.
    10. a foreign-source ack (different uint32, same seq) does NOT reopen the gate. RED.
    11. expiry reopens the gate without any acks: patch
        `lifx.animation.animator.time.monotonic` (patch the module attribute, matching
        the project's runtime-read idiom) to return t0, t0, t0+1.5 across calls —
        after >1.0 s the third frame sends. RED.
    12. `close()` resets the gate: saturate (2 outstanding), close, then send_frame —
        it creates a fresh socket and sends (`gated is False`). RED.
    13. sent frames report observability: `stats.acks_outstanding` equals the
        outstanding count including the frame's own probe (1 after first frame, 2
        after second). RED.
    14. D4-02 guard: `send_frame` accepts exactly one positional argument (hsbk) —
        `inspect.signature` has no flow-control toggle parameter and
        `lifx.animation.__init__.__all__` does not gain an AckGate export (assert via
        `import lifx.animation` + `"AckGate" not in lifx.animation.__all__`). Passes
        today and must stay green — the no-toggle invariant.

    D. `TestAnimatorFlowControlIntegration` (@pytest.mark.emulator, deterministic —
    zero reliance on ack RTT races):
    15. `test_gating_deterministic_when_acks_dropped`: apply
        `emulator_server_with_scenarios(device_type="tile", serial="d073d5000007",
        scenarios={"drop_packets": {45: 1.0}})`; build a MatrixLight at
        127.0.0.1:port, `Animator.for_matrix`; frames 1 and 2 → gated False, frame 3 →
        gated True (emulator drops ALL acks; outstanding deterministically reaches 2).
        RED.
    16. `test_gate_reopens_after_expiry`: continue from a saturated gate, single
        `await asyncio.sleep(1.05)` (just past ACK_EXPIRY_SECONDS — the one
        deterministic-direction sleep research allows), next frame → gated False. RED.
    17. `test_acks_collected_on_healthy_emulator`: no scenario; stream 5 frames with
        `await asyncio.sleep(0.1)` between them; every frame `gated is False` and each
        sent frame's `acks_outstanding == 1` from frame 2 onward (prior probe swept,
        own probe tracked) — proves ack collection through the animator's own socket.
        RED.
    18. `test_large_tile_13x26_framebuffer_path`: with `large_tile_matrix_device`,
        `Animator.for_matrix` → `pixel_count == 338`; first frame `packets_sent == 8`
        (7 row-aligned Set64 + CopyFB — needs plans 04-03 AND 04-04, say so in the
        docstring); after `await asyncio.sleep(0.1)` a second frame has
        `gated is False` and `acks_outstanding < 2` (the CopyFB probe was acked by the
        emulator and swept). RED.

    E. Update the three existing emulator tests per research Pitfall 3 (they become RED
    until 04-04):
    - both `test_animation_loop_simulation` tests: lengthen the inter-frame sleep to
      0.05 s (localhost ack drain) and assert per-frame `stats.gated is False`, keeping
      `total_packets >= 5` — observing gating rather than masking it.
    - `TestAnimatorForMatrixIntegration.test_send_frame_sends_packets`: add
      `stats.gated is False` (a single frame can never gate at limit 2).

    All new tests typed and docstringed; run `uv run ruff format` on the file.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_animator.py -q -k "FlowFields or ProbeBaking or Gating"; test $? -eq 1</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_animator.py -q -k "FlowFields or ProbeBaking or Gating"` exits 1 with test failures (not collection errors)
    - Test 14 (no-toggle D4-02 guard) passes today
    - `uv run pytest tests/test_animation/test_animator.py -q -m emulator -k "FlowControlIntegration or loop_simulation"` fails on the new gated-field assertions (emulator available locally)
    - Tests 15-18 contain no ack-RTT-race timing: gating is forced only via drop_packets scenarios; the only sleeps are the 1.05 s expiry sleep and fixed localhost drain sleeps
    - `uv run ruff check tests/test_animation/test_animator.py` is clean
  </acceptance_criteria>
  <done>The full animator flow-control contract — probe baked once at init, gate-before-framebuffer, latest-frame-wins drops, additive stats, deterministic end-to-end gating incl. the 13×26 framebuffer path — is pinned RED, defining GREEN for plan 04-04.</done>
</task>

</tasks>

## Artifacts this plan produces

| Artifact | Kind | Location |
|----------|------|----------|
| `mock_udp_socket` fixture + `make_ack_datagram` helper | Test infrastructure | `tests/test_animation/conftest.py` |
| `large_tile_matrix_device` — emulated 13×26 product-201 device fixture | Test infrastructure | `tests/test_animation/conftest.py` |
| `test_flow.py` — AckGate 16-row branch matrix | RED test suite | `tests/test_animation/test_flow.py` |
| Animator gating/probe/stats suite + deterministic emulator gating tests | RED test suite | `tests/test_animation/test_animator.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Crafted UDP datagrams → AckGate sweep | Tests deliberately inject runt/wrong-type/wrong-source datagrams — the untrusted-input surface plan 04-04 must validate |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-02 | Tampering/DoS | AckGate.sweep datagram parsing | medium | mitigate | Tests 10-12 pin the V5 validation posture: nbytes ≥ 36 guard, pkt_type + source + tracked-seq triple match before any state change — mirrors the discovery layer's source-validation DoS posture |
| T-04-03 | DoS (of pacing) | gate unlatching | low | mitigate | Test 12 pins that foreign-source acks cannot unlatch the gate; worst case equals today's blind-fire baseline |

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

<verification>
- Task 1 leaves the entire animation suite green (fixture-only change)
- Tasks 2-3 produce RED confined to test_flow.py (collection) and the new/updated animator assertions (test failures)
- No timing-race tests: gating determinism comes from ack-drop scenarios and explicit-now unit APIs
</verification>

<success_criteria>
- ANIM-01's gate semantics and ANIM-02's facility/no-toggle/zero-alloc observability contracts are fully pinned by failing tests that plan 04-04 must turn green without editing any test
</success_criteria>

<output>
Create `.planning/phases/04-animation-flow-control/04-02-SUMMARY.md` when done, recording:
RED counts per class, the fixture contracts added to conftest, and commit hashes.
</output>
