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

must_haves:
  truths:
    - "A 13×26 large tile prebakes 7 row-aligned Set64 templates (52×6 + 26 colours, y offsets 0,4,…,24, hsbk_start = y_offset × width) plus 1 CopyFrameBuffer — colours land on exactly the rows their rect covers"
    - "The 16×8 large-tile shape is byte-identical to before the fix (64 divisible by 16) — zero regression on the existing Ceiling coverage"
    - "FLAGS_OFFSET=22 and ACK_REQUIRED_FLAG=0x02 exist beside SEQUENCE_OFFSET; probe_template_index returns 0 for every generator except large-tile matrix mode, where it is the final CopyFrameBuffer (D4-04 decision recorded in the docstring)"
    - "Generators never set the ack flag themselves — all created templates keep flags byte 0 (the Animator bakes the probe once at init, D4-03)"
  artifacts:
    - path: "src/lifx/animation/packets.py"
      provides: "row-aligned _create_large_tile_templates, FLAGS_OFFSET/ACK_REQUIRED_FLAG, PacketGenerator.probe_template_index + MatrixPacketGenerator override"
  key_links:
    - from: "src/lifx/animation/packets.py"
      to: "src/lifx/devices/matrix.py"
      via: "mirrors the hardware-proven set_matrix_colors row-aligned batching (rows_per_batch = 64 // width) without touching the device layer (D4-05)"
      pattern: "rows_per_packet"
    - from: "src/lifx/animation/__init__.py"
      to: "src/lifx/animation/packets.py"
      via: "exports FLAGS_OFFSET alongside SEQUENCE_OFFSET"
      pattern: "FLAGS_OFFSET"
---

<objective>
Turn plan 04-01's RED suite green: fix the latent large-tile chunking bug (raw 64-pixel
colour slicing vs row-aligned rect offsets — garbled frames on any width that does not
divide 64, including the ANIM-04 target Ceiling 13×26) and add the probe-attachment
seam: FLAGS_OFFSET/ACK_REQUIRED_FLAG header constants and the probe_template_index
property that plan 04-04's animator wiring consumes (D4-03, D4-04).

Purpose: ANIM-04 cannot pass hardware UAT while the animation path paints shifted,
overlapping rows on 13-wide tiles; and the ack probe needs a single authoritative seam
saying which prebaked template carries it.
Output: corrected src/lifx/animation/packets.py; 04-01's suite green; scope limited to
src/lifx/animation/ per D4-05.
</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/packets.py
@tests/test_animation/test_packets.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Row-aligned large-tile chunking in MatrixPacketGenerator</name>
  <files>src/lifx/animation/packets.py</files>
  <read_first>
    - tests/test_animation/test_packets.py — `TestMatrixPacketGeneratorRowAlignedChunking` (plan 04-01): the exact geometry to satisfy (7 packets, counts 52×6+26, y offsets, row-aligned hsbk_start, 16×8 invariance, multi-tile)
    - src/lifx/devices/matrix.py lines 836-859 — the row-aligned batching this mirrors
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — "Row-aligned large-tile chunking" code example and Pitfall 1
  </read_first>
  <action>
    Edit `MatrixPacketGenerator` in src/lifx/animation/packets.py (per D4-05 this plan
    touches only the animation layer):

    1. `__init__`: keep `self._rows_per_packet = self._MAX_COLORS_PER_PACKET //
       tile_width`; replace the pixel-based `self._packets_per_tile` computation with
       row-aligned ceiling division: `-(-tile_height // self._rows_per_packet)`.
       Verify the formula preserves existing shapes: 8×8 → 1, 16×8 → 2 (unchanged);
       13×26 → 7 (the fix). Widths above 64 have no LIFX matrix product and are out of
       scope, matching the device layer's identical assumption — do not add a
       defensive clamp (it would create an uncoverable branch).
    2. `_create_large_tile_templates`: per pkt_idx compute
       `y_offset = pkt_idx * self._rows_per_packet`;
       `rows = min(self._rows_per_packet, tile_height - y_offset)`;
       `color_count = rows * self._tile_width`;
       `color_start = y_offset * self._tile_width` (row-aligned, replacing the raw
       `pkt_idx * 64` slice). Remove the now-dead zero-count guard and its pragma —
       ceiling division guarantees the final packet covers at least one row.
    3. Update the class docstring: correct the 16×8 example, add the 13×26 example
       (7 Set64 of 52×6+26 colours + CopyFrameBuffer = 8 packets/frame), and state the
       row-alignment rule (the device fills Set64 rects row-major from (x, y), so each
       packet's colours must start at y_offset × width).
    4. Run `uv run ruff format src/lifx/animation/packets.py` after editing.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_packets.py -q -k "RowAlignedChunking or MatrixPacketGenerator"</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_packets.py -q -k "RowAlignedChunking"` passes completely (all 11 tests from 04-01 Task 1)
    - Every pre-existing test in test_packets.py (including the 16×8 TestMatrixPacketGeneratorLargeTile class and duration tests) passes unmodified
    - `uv run pyright src/lifx/animation/packets.py` and `uv run ruff check src/lifx/animation/packets.py` are clean
  </acceptance_criteria>
  <done>Large-tile template geometry is row-aligned for any tile width, the 13×26 Ceiling produces 8 correct packets per frame, and the 16×8 shape is unchanged — the ANIM-04 correctness prerequisite is closed.</done>
</task>

<task type="auto">
  <name>Task 2: Header flag constants + probe_template_index seam</name>
  <files>src/lifx/animation/packets.py, src/lifx/animation/__init__.py</files>
  <read_first>
    - tests/test_animation/test_packets.py — `TestHeaderFlagConstants` + `TestProbeTemplateIndex` (plan 04-01): the exact values and index matrix to satisfy
    - src/lifx/animation/packets.py lines 42-52 (constants block) and lines 104-115 (`_build_header` flags packing at absolute offset 22)
    - src/lifx/animation/__init__.py lines 61-89 — current packets exports (SEQUENCE_OFFSET pattern to extend)
    - .planning/phases/04-animation-flow-control/04-RESEARCH.md — Pattern 2 (D4-04 rationale: CopyFB is the frame-commit packet; its ack RTT includes the device's drain of the frame's whole Set64 burst; Glowup field precedent; 15-byte payload adds no parse skew)
  </read_first>
  <action>
    1. In src/lifx/animation/packets.py, next to `SEQUENCE_OFFSET = 23`, add
       `FLAGS_OFFSET = 22` (comment: Frame Address flags byte — 8-byte Frame + 8-byte
       target + 6-byte reserved) and `ACK_REQUIRED_FLAG = 0x02` (comment: bit 1 of the
       flags byte, matching the `(ACK_REQUIRED & 0b1) << 1` packing in _build_header).
    2. Add a concrete `probe_template_index` property to `PacketGenerator` (the ABC)
       returning 0, with a docstring: the template index that carries the ack_required
       flow-control probe baked by the Animator at init — first packet of the frame,
       the exact arm spike 003 measured at 0.0% concurrent-query loss (D4-01); flow
       control applies uniformly to all generator families (no per-family carve-outs,
       D4-02).
    3. Override in `MatrixPacketGenerator`: return
       `self._tile_count * (self._packets_per_tile + 1) - 1` when `self._is_large_tile`
       else 0. Docstring records the D4-04 decision verbatim for the phase record: in
       large-tile mode the probe attaches to the FINAL CopyFrameBuffer — the
       frame-commit packet (nothing is visible until the swap), matching Glowup's
       proven behaviour on >64-zone ceilings; its ack RTT includes the device's drain
       of the preceding Set64 burst, a strictly better congestion signal than a
       first-Set64 ack; hardware-validated in the ANIM-04 UAT (plan 04-07), and this
       property is the one-line fallback seam to index 0 should hardware disagree.
    4. In src/lifx/animation/__init__.py, import and export `FLAGS_OFFSET` alongside
       `SEQUENCE_OFFSET` (consistency). Do NOT export AckGate-related names here or in
       any later plan — the gate is an internal facility (D4-02).
    5. `uv run ruff format` both files.
  </action>
  <verify>
    <automated>uv run pytest tests/test_animation/test_packets.py -q</automated>
  </verify>
  <acceptance_criteria>
    - `uv run pytest tests/test_animation/test_packets.py -q` passes completely — the entire 04-01 RED suite is green with zero test edits
    - `test_templates_flags_byte_zero_at_creation` still passes: generators do not set the ack flag (the Animator bakes it — plan 04-04)
    - `uv run pyright src/lifx/animation/ && uv run ruff check src/lifx/animation/` are clean
    - `git diff --stat` touches only src/lifx/animation/packets.py and src/lifx/animation/__init__.py (D4-05 scope guard)
  </acceptance_criteria>
  <done>The probe-attachment seam exists with the D4-04 decision encoded and documented, header flag constants are canonical in the animation layer, and the full test_packets.py suite is green.</done>
</task>

</tasks>

## Artifacts this plan produces

| Artifact | Kind | Location |
|----------|------|----------|
| Row-aligned `_create_large_tile_templates` + corrected `packets_per_tile` | Bug fix (ANIM-04 prerequisite) | `src/lifx/animation/packets.py` |
| `FLAGS_OFFSET` / `ACK_REQUIRED_FLAG` constants | Header canon | `src/lifx/animation/packets.py` |
| `PacketGenerator.probe_template_index` (+ MatrixPacketGenerator override, D4-04 decision recorded) | Probe-attachment seam | `src/lifx/animation/packets.py` |
| `FLAGS_OFFSET` export | Public constant | `src/lifx/animation/__init__.py` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Library → LIFX device | Outbound Set64/CopyFrameBuffer datagrams (device trusts rect geometry) |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-04 | Tampering (self-inflicted data corruption) | large-tile rect/colour alignment | medium | mitigate | Row-aligned chunking guarantees colours land on the rows their rect declares; pinned by the 04-01 geometry matrix so regressions fail CI |

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

<verification>
- Full test_packets.py green (04-01 suite + all pre-existing tests, zero test edits)
- pyright strict + ruff clean on src/lifx/animation/
- Diff confined to the two listed files
</verification>

<success_criteria>
- The 13×26 framebuffer path produces correct wire geometry (ANIM-04 prerequisite closed) and the probe seam plan 04-04 consumes is in place with the D4-04 attachment decision recorded in code
</success_criteria>

<output>
Create `.planning/phases/04-animation-flow-control/04-03-SUMMARY.md` when done, recording:
the geometry change (6→7 Set64 packets for 13×26), the D4-04 decision as encoded, and commit hashes.
</output>
