---
phase: 04-animation-flow-control
plan: 08
type: execute
wave: 6
depends_on:
  - "04-05"
  - "04-06"
files_modified:
  - .planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md
  - .planning/phases/04-animation-flow-control/uat_loss_investigation.py
  - .planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json
  - .planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION-EVENTS.jsonl
autonomous: true
gap_closure: true
requirements:
  - ANIM-03

must_haves:
  truths:
    - "Spike 003's actual photons-arm sample size is extracted from its raw JSONL and recorded: exactly one round, ~50 queries, 0 losses — with the exact binomial implications computed against the pooled 04-06 UAT loss rate (rule-of-three and P(0/50 | pooled rate)), so H2's calibration question is answered from evidence, not memory"
    - "A hardware investigation has produced per-arm evidence that discriminates H1 from H2: an ambient control (no streaming), a shipped-gate replication with per-query and per-frame timestamped events, a spike-003-faithful replica arm with >=150 queries, a 10/15/20 FPS load sweep, and second-device rounds on the fallback Tiles — every number recorded exactly as measured, FAIL-shaped or not"
    - "The investigation instrument contains no pass/fail thresholds and exits 0 on completed measurement — it is structurally incapable of threshold-tampering, and the 04-06 UAT artefacts (uat_ack_stream.py, 04-UAT-TILES.json) are byte-identical before and after this plan"
    - "No src/ file is modified by this plan — the shipped gate (100% animation-layer coverage, 2618 tests green) is driven, never edited"
  artifacts:
    - path: ".planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md"
      provides: "spike 003 raw-data forensics: sample sizes, per-arm losses, binomial bounds — the H2 evidence half"
    - path: ".planning/phases/04-animation-flow-control/uat_loss_investigation.py"
      provides: "threshold-free investigation instrument: control / shipped / replica / sweep / fallback arms with per-event JSONL output"
    - path: ".planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json"
      provides: "per-arm aggregate hardware evidence (loss, delivery, RTT) from both Tiles devices"
    - path: ".planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION-EVENTS.jsonl"
      provides: "per-query and per-frame timestamped raw events for the loss-clustering analysis in 04-09"
  key_links:
    - from: ".planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json"
      to: ".planning/phases/04-animation-flow-control/uat_loss_investigation.py"
      via: "written by the investigation instrument, one aggregate block per arm"
      pattern: "uat_loss_investigation.py --tiles-ip 192.168.19.243"
    - from: ".planning/phases/04-animation-flow-control/uat_loss_investigation.py"
      to: "src/lifx/animation/animator.py"
      via: "shipped arm drives Animator.for_matrix/send_frame exactly as 04-05's harness does — measures the real gate, never reimplements it"
      pattern: "Animator.for_matrix"
---

<objective>
Gather the evidence that discriminates the two hypotheses behind the reproducible
04-06 ANIM-03 FAIL (pooled ~9 lost of ~267 concurrent queries ≈ 3.4% vs the fixed 0%
threshold, evidence at commit 5de88f6):

- H1 — real residual starvation: the shipped gate genuinely leaves ~3-4% concurrent-query
  loss under 20 FPS Tiles streaming, either fixable (a mechanical difference from the
  spike arm) or a hardware floor.
- H2 — spike 003's 0.0% was a small-sample artefact: the 0% threshold was calibrated
  from one lucky round.

Three evidence streams: (1) forensics on spike 003's raw data (sample sizes and binomial
bounds), (2) a threshold-free hardware investigation with per-event timestamps, a
spike-faithful replica arm, an FPS sweep, an ambient control, and the fallback device,
(3) honest committed evidence for 04-09's analysis and operator routing decision.

Sequencing (non-negotiable): this investigation and 04-09's routing decision complete
BEFORE 04-06 Task 2 or 04-07 re-run — their gates may depend on the resolution. This
plan does not rewrite 04-06/04-07 and does not touch any threshold (UAT rule: thresholds
fixed pre-run; changes are operator decisions only).

Purpose: turn "the UAT failed twice" into "here is exactly why, with the evidence an
operator needs to route: fix, recalibrate, re-run, or investigate further" (ANIM-03).
Output: 04-GAP-SPIKE-FORENSICS.md + uat_loss_investigation.py + 04-GAP-INVESTIGATION.json
+ 04-GAP-INVESTIGATION-EVENTS.jsonl.
</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-UAT-TILES.json
@.planning/phases/04-animation-flow-control/04-UAT-TILES-run1-FAIL.json
@.planning/phases/04-animation-flow-control/uat_ack_stream.py
@.planning/spikes/003-ack-paced-frames/stream.py
@.planning/spikes/003-ack-paced-frames/results-20260716-210408.jsonl
@src/lifx/animation/flow.py
@src/lifx/animation/animator.py
</context>

<tasks>

<task type="auto">
  <name>Task 1: Spike 003 raw-data forensics — was the 0% threshold calibrated from an underpowered sample?</name>
  <files>.planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md</files>
  <read_first>
    - .planning/spikes/003-ack-paced-frames/results-20260716-210408.jsonl — the only complete spike run: per-arm query_n and query_loss_pct
    - .planning/spikes/003-ack-paced-frames/results-20260716-210318.jsonl — the aborted first run (run_start + baseline only)
    - .planning/spikes/003-ack-paced-frames/README.md — the recorded verdict the 0% threshold was derived from
    - .planning/phases/04-animation-flow-control/04-UAT-TILES.json and 04-UAT-TILES-run1-FAIL.json — the six UAT rounds this is compared against
  </read_first>
  <action>
    Write 04-GAP-SPIKE-FORENSICS.md (Australian English) computing every number from the
    raw files named above — never from memory. Required sections and exact computations:

    1. "## Spike 003 sample sizes" — extract from results-20260716-210408.jsonl: the
       photons arm ran exactly ONE round with query_n=50 and query_loss_pct=0.0; the
       no-stream baseline had n=20 with 0 losses (and the aborted 210318 run adds only
       another n=20 baseline). Also record blind (n=41, 14.6%) and glowup (n=49, 6.1%)
       for context, and the photons ack RTT block (median 98.3 / p95 148.8 ms).
    2. "## Pooled 04-06 UAT loss rate" — from the two UAT JSONs compute per-run and
       pooled totals: run 1 queries = sum of (queries_ok + queries_lost) per round with
       4 lost; run 2 likewise with 5 lost; pooled p = total lost / total queries
       (expected ≈ 9/267 ≈ 3.4% — compute the exact figures from the files).
    3. "## Binomial implications" — with the pooled p from step 2, compute and show
       working for: (a) rule-of-three 95% upper bound for the spike's 0/50 observation
       (3/50 = 6.0%) and the exact one-sided 97.5% bound (1 - 0.025**(1/50) ≈ 7.1%);
       (b) P(0 losses in 50 | p) = (1-p)**50 (≈ 0.18 at p≈0.034); (c) P(a clean
       ~45-query UAT round | p) = (1-p)**45 (≈ 0.21) versus the observed 2 clean rounds
       of 6; (d) the same bound for the spike baseline's n=20 (rule-of-three 15%) — the
       ambient floor was ALSO underpowered.
    4. "## Sample-size verdict" — state plainly what the arithmetic shows: spike 003's
       photons measurement could not distinguish a true 0% from the UAT-measured ~3.4%
       (its 0/50 had ≥ 6% upper bound and an ≈ 18% chance of occurring even if the UAT
       rate is the truth), so the 0% threshold was calibrated beyond the evidence's
       resolution. State explicitly that this settles the CALIBRATION question but NOT
       whether ~3.4% is fixable (H1-fixable) or a floor — that is what Tasks 2-3 measure.
    5. "## Methodology differences: spike arm vs shipped path" — enumerate, citing file
       and line ranges, the mechanical differences the hardware arms will test: (a) the
       spike streamed via an asyncio UdpTransport with a per-packet awaited send (an
       event-loop yield between packets of a frame; stream.py send_frame) while the
       shipped Animator.send_frame issues a synchronous back-to-back sendto burst
       (animator.py send loop); (b) the spike prober was a raw UDP socket with a 1.0 s
       timeout (stream.py query_prober) while the UAT prober is a DeviceConnection with
       max_retries=0 and a 2.0 s timeout (uat_ack_stream.py query_prober — MORE generous,
       so timeout width alone cannot explain the UAT losing more); (c) both attach the
       ack probe to the first packet per frame on Tiles — no difference. Cite D4-01 for
       the gate tuning both share (limit 2, ~1 s expiry).

    Show every formula inline with its inputs so a reader can recompute by hand.
    Commit with message prefix "docs(04-08):" using git commit -s.
  </action>
  <verify>
    <automated>test -f .planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md && grep -q "## Sample-size verdict" .planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md && grep -q "## Methodology differences" .planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md</automated>
  </verify>
  <acceptance_criteria>
    - All five required sections present; every statistic traceable to a named raw file (results-20260716-210408.jsonl, the two UAT JSONs) with the formula shown
    - Records that the spike's photons arm was exactly one round of 50 queries and the baseline n=20 — the load-bearing H2 facts
    - The pooled UAT rate is computed from the actual JSON fields (queries_ok, queries_lost), not copied from the gap briefing
    - The verdict distinguishes "0% threshold was calibrated beyond the spike's resolution" (settled here) from "the ~3.4% is fixable vs floor" (deferred to hardware arms)
    - No file outside 04-GAP-SPIKE-FORENSICS.md is modified
  </acceptance_criteria>
  <done>The H2 calibration question is answered from spike 003's raw data with reproducible arithmetic, and the mechanical spike-vs-shipped differences the hardware arms must test are enumerated with citations.</done>
</task>

<task type="auto">
  <name>Task 2: Build the threshold-free loss-investigation instrument (uat_loss_investigation.py)</name>
  <files>.planning/phases/04-animation-flow-control/uat_loss_investigation.py</files>
  <read_first>
    - .planning/phases/04-animation-flow-control/uat_ack_stream.py — the shipped-arm mechanics to reproduce faithfully (query_prober with DeviceConnection max_retries=0, stream_round scheduling, AckRttObserver, reachability guard, TIMEOUT_ERRORS usage, nosec precedents)
    - .planning/spikes/003-ack-paced-frames/stream.py — the replica-arm mechanics to reproduce faithfully (FrameStreamer, arm_photons, collect_acks, raw-socket query_prober with QUERY_TIMEOUT=1.0 / QUERY_INTERVAL=0.5)
    - src/lifx/animation/animator.py — AnimatorStats public fields (gated, acks_outstanding, packets_sent) used for frame events without private reach
    - src/lifx/animation/flow.py — ACK_EXPIRY_SECONDS import and AckGate._outstanding shape the observer snapshots (read-only, 04-05 precedent)
  </read_first>
  <action>
    Create uat_loss_investigation.py in the phase dir (never src/, never tests/, never
    shipped). It is an INVESTIGATION instrument, not a UAT gate: module docstring must
    state it contains no pass/fail thresholds, renders no verdict, and exits 0 on
    completed measurement / 2 on primary-device ENV-ERROR — so it is structurally
    incapable of the threshold-tampering the UAT rules forbid. There must be no "pass",
    "outcome", "FAIL" or threshold constant anywhere in it.

    argparse contract: --tiles-ip (default 192.168.19.243), --fallback-ip (default
    192.168.18.62), --skip-fallback (store_true), --json-out (required, Path),
    --events-out (required, Path), --arms (optional comma-separated subset of
    control,shipped,replica,sweep,fallback; default all five). --help exits 0 without
    sending a packet.

    Fixed protocol constants declared at module top with doc comments (fixed BEFORE the
    hardware run, honest-reporting precedent): ROUND_SECONDS=30.0, STREAM_FPS=20.0,
    SHIPPED_ROUNDS=4, REPLICA_ROUNDS=4, SWEEP_FPS=(10.0, 15.0), SWEEP_ROUNDS_PER_FPS=2,
    CONTROL_SECONDS=120.0, FALLBACK_CONTROL_SECONDS=60.0, FALLBACK_SHIPPED_ROUNDS=2,
    QUERY_RATE=2.0, QUERY_TIMEOUT=2.0, REPLICA_QUERY_TIMEOUT=1.0,
    REPLICA_QUERY_INTERVAL=0.5, INTER_ARM_REST_SECONDS=10.0.

    Five arms, run in this order:
    1. control — prober only, no streaming, CONTROL_SECONDS on the primary device.
       Prober mechanics identical to uat_ack_stream.py's query_prober (dedicated
       DeviceConnection, max_retries=0, single-shot GetColor at QUERY_RATE with
       QUERY_TIMEOUT). Establishes the ambient single-shot loss floor with ~240 queries
       (spike baseline had only n=20).
    2. shipped — the exact 04-06 protocol plus event capture: fresh Animator.for_matrix
       per round, hue-sweep frames (same _frame_hsbk shape), monotonic-deadline ticks at
       STREAM_FPS for ROUND_SECONDS, SHIPPED_ROUNDS rounds, concurrent DeviceConnection
       prober, AckRttObserver copied from uat_ack_stream.py (read-only snapshot diff of
       AckGate._outstanding; measurement-only private reach, 03-03/04-05 precedent).
    3. replica — spike 003's photons arm reproduced faithfully with provenance comments
       naming stream.py as the source of each copied function: a separate asyncio
       UdpTransport streaming socket, animator privates for templates/framebuffer/
       generator (spike licence; the shipped animator already bakes the ack flag on
       template 0, and the replica sets/clears the flags bit per packet exactly as the
       spike did, producing identical wire bytes on Tiles), per-packet awaited sends,
       collect_acks with PHOTONS_INFLIGHT_LIMIT=2 / PHOTONS_ACK_EXPIRY=1.0, and the
       spike's RAW-SOCKET prober (REPLICA_QUERY_TIMEOUT, REPLICA_QUERY_INTERVAL,
       source/sequence/pkt_type/addr matching). REPLICA_ROUNDS rounds of ROUND_SECONDS
       — target >= 150 total replica queries so a genuinely-0% methodology is
       distinguishable from the ~3.4% UAT rate (P(0 losses in 150 | p=0.034) < 1%).
    4. sweep — shipped-arm mechanics at each SWEEP_FPS value, SWEEP_ROUNDS_PER_FPS
       rounds each, on the primary device. Tests whether loss scales with offered load.
    5. fallback — on --fallback-ip unless --skip-fallback: its own reachability probe;
       if unreachable, record arm_status "env-error" for the fallback arm and CONTINUE
       (the primary evidence stands alone); otherwise FALLBACK_CONTROL_SECONDS of
       control then FALLBACK_SHIPPED_ROUNDS shipped rounds. Rules device-specific
       behaviour in or out.

    Event capture (the instrument's whole point — 04-06's JSON only had counts): append
    one JSON object per line to --events-out, flushed at least once per round. Query
    events carry arm, device ("primary"/"fallback"), fps, round, kind="query",
    t (seconds since round start, monotonic), outcome ("ok"/"lost"), latency_ms
    (null when lost). Frame events carry arm, device, fps, round, kind="frame", t,
    gated (bool), outstanding (int, from AnimatorStats.acks_outstanding for shipped/
    sweep arms; from len(outstanding) for the replica arm). Control arms emit query
    events only.

    Aggregate JSON to --json-out: timestamp, tiles_ip, fallback_ip, device labels via
    get_label(), chain dims via get_device_chain(), the fixed protocol constants block,
    a reference block naming the spike baseline (0/50) and the 04-06 pooled rate, and an
    "arms" object keyed control/shipped/replica/sweep/fallback where each arm records
    its per-round aggregates (offered/sent/gated/delivered_ratio where streaming,
    queries_ok/queries_lost/query_loss_pct/query_latency_median_ms, ack RTT median/p95/
    samples/expiries where observed) plus pooled queries_ok/queries_lost per arm. No
    verdict fields of any kind.

    Reachability: one generous GetColor probe on the primary device's normal retrying
    connection before any arm; on failure write an honest skipped JSON and exit 2
    (same guard shape as uat_ack_stream.py). Rest INTER_ARM_REST_SECONDS between arms.

    Style gates (04-05 precedent): imports at top; uv-run compatible; ruff format +
    check clean; pyright strict clean; carry over the spike's "# nosec B112" comments
    on the replica's skip-unparsable-datagram receive loops and use lifx.const
    TIMEOUT_ERRORS for the 3.10-compatible wait_for pattern. Commit the instrument
    alone with prefix "feat(04-08):" using git commit -s BEFORE any hardware run, so
    the protocol is provably fixed pre-run.
  </action>
  <verify>
    <automated>uv run python .planning/phases/04-animation-flow-control/uat_loss_investigation.py --help >/dev/null && uv run ruff check .planning/phases/04-animation-flow-control/uat_loss_investigation.py && uv run ruff format --check .planning/phases/04-animation-flow-control/uat_loss_investigation.py && uv run pyright .planning/phases/04-animation-flow-control/uat_loss_investigation.py</automated>
  </verify>
  <acceptance_criteria>
    - --help exits 0 without network traffic; ruff check, ruff format --check and pyright all clean
    - All five arms implemented with the exact fixed constants above; the replica arm's streaming and prober mechanics carry provenance comments naming stream.py sources
    - The script defines no pass/fail threshold constants and writes no verdict fields — its only exit codes are 0 (measured) and 2 (primary unreachable)
    - Fallback-device unreachability degrades to a recorded "env-error" arm status without aborting the primary arms
    - The instrument commit lands BEFORE Task 3's evidence commit (protocol fixed pre-run, verifiable in git history)
    - uat_ack_stream.py, 04-UAT-TILES*.json and everything under src/ are untouched
  </acceptance_criteria>
  <done>A committed, threshold-free investigation instrument exists that can replicate the 04-06 protocol with per-event timestamps, rerun spike 003's exact methodology at adequate sample size, sweep FPS, measure the ambient floor, and cross-check the fallback device.</done>
</task>

<task type="auto">
  <name>Task 3: Run the investigation headlessly on both Tiles and commit the evidence verbatim</name>
  <files>.planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json, .planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION-EVENTS.jsonl</files>
  <read_first>
    - .planning/phases/04-animation-flow-control/uat_loss_investigation.py — the instrument as committed in Task 2 (run it byte-identical; if it crashes, fix mechanism only, commit the fix, and note the deviation — 04-06's observer-fix precedent at commit 5f1d80b)
    - .planning/phases/04-animation-flow-control/04-06-PLAN.md — the honest-reporting rules this run inherits (record whatever it says; never re-run until a preferred shape appears)
  </read_first>
  <action>
    Run: uv run python .planning/phases/04-animation-flow-control/uat_loss_investigation.py
    --tiles-ip 192.168.19.243 --fallback-ip 192.168.18.62
    --json-out .planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json
    --events-out .planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION-EVENTS.jsonl

    Expected wall time ≈ 11-13 minutes (control 120 s + 4+4+4 streaming rounds of 30 s
    + fallback 60 s + 2 rounds + inter-arm rests). The devices are quiesced test units —
    the load is sanctioned.

    Handling, fixed in advance:
    - Exit 2 (primary unreachable): record the ENV-ERROR JSON, commit it, and stop —
      surface the blocker; do not substitute the fallback as primary (the comparison
      design needs .19.243 as the device 04-06 measured).
    - Exit 0: commit both evidence files exactly as written. This is an investigation:
      there is NO preferred result. A shipped arm at ~3-4% is as valid a finding as one
      at 0% — both discriminate. Never re-run an arm because its numbers look unlucky;
      re-run the WHOLE battery only if the instrument itself crashed mid-run, and keep
      any partial evidence files (rename with a -partial suffix) rather than deleting.
    - If the instrument crashes on hardware contact (04-06 hit exactly this with the
      slotted-AckGate observer): diagnose, apply a mechanism-only fix that changes no
      protocol constant, commit the fix separately with prefix "fix(04-08):", then run
      the full battery from the start.
    - In the summary, tabulate per-arm pooled queries_ok/queries_lost/loss % alongside
      the 04-06 pooled rate and spike 0/50 — numbers only, NO H1/H2 verdict here:
      interpretation is 04-09 Task 1's job against its pre-declared rules, kept separate
      so measurement and interpretation cannot contaminate each other.

    Commit evidence with prefix "test(04-08):" using git commit -s.
  </action>
  <verify>
    <automated>test -f .planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION-EVENTS.jsonl && uv run python -c "import json; d=json.load(open('.planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json')); assert d.get('skipped') or {'control','shipped','replica','sweep'} <= set(d['arms']); print('evidence-ok')"</automated>
  </verify>
  <acceptance_criteria>
    - 04-GAP-INVESTIGATION.json contains real measured per-round data for the control, shipped, replica and sweep arms on 192.168.19.243 (or a committed ENV-ERROR record and a surfaced blocker) — never placeholder numbers
    - The fallback arm contains either real rounds from 192.168.18.62 or an explicit recorded "env-error" status
    - 04-GAP-INVESTIGATION-EVENTS.jsonl holds per-query events for every arm and per-frame events for every streaming arm (query events ≈ several hundred; frame events ≈ thousands)
    - git shows the Task 2 instrument commit strictly before the evidence commit, and no commit in this plan touches uat_ack_stream.py, 04-UAT-TILES*.json, or src/
    - The summary reports per-arm numbers without an H1/H2 verdict
  </acceptance_criteria>
  <done>Both Tiles devices have been measured under the fixed investigation protocol and the raw evidence — aggregates plus per-event timestamps — is committed verbatim, ready for 04-09's pre-declared analysis.</done>
</task>

</tasks>

## Artifacts this plan produces

| Artifact | Kind | Location |
|----------|------|----------|
| `04-GAP-SPIKE-FORENSICS.md` — spike 003 sample sizes, binomial bounds, spike-vs-shipped methodology diff | Analysis evidence | `.planning/phases/04-animation-flow-control/04-GAP-SPIKE-FORENSICS.md` |
| `uat_loss_investigation.py` — threshold-free five-arm investigation instrument (control/shipped/replica/sweep/fallback) | Measurement script (phase dir, never shipped) | `.planning/phases/04-animation-flow-control/uat_loss_investigation.py` |
| `04-GAP-INVESTIGATION.json` — per-arm aggregate hardware evidence, both devices | Evidence artefact | `.planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION.json` |
| `04-GAP-INVESTIGATION-EVENTS.jsonl` — per-query and per-frame timestamped raw events | Evidence artefact | `.planning/phases/04-animation-flow-control/04-GAP-INVESTIGATION-EVENTS.jsonl` |

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Instrument → LAN Tiles ×2 | Outbound streaming + query load against two user-owned quiesced test devices (192.168.19.243, 192.168.18.62) |

## STRIDE Threat Register

| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-04-15 | DoS (outbound, self-inflicted) | ~12 streaming rounds + ~700 queries across two devices | low | mitigate | Fixed arm durations/rounds declared pre-run; 10 s inter-arm rest; reachability guards; devices are quiesced test units |
| T-04-16 | Repudiation | Investigation evidence | low | mitigate | Instrument committed before any run; no pass/fail thresholds exist to tamper with; raw per-event JSONL on disk; evidence committed verbatim whatever it shows |
| T-04-17 | Tampering | 04-06 UAT artefacts and shipped src/ | medium | mitigate | Acceptance criteria pin uat_ack_stream.py, 04-UAT-TILES*.json and src/ untouched; measurement-only private reach (read-only AckGate snapshot, 03-03/04-05 precedent) |

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

<verification>
- 04-GAP-SPIKE-FORENSICS.md computes its statistics from the named raw files with formulas shown
- uat_loss_investigation.py committed before the evidence, threshold-free, ruff/pyright clean
- 04-GAP-INVESTIGATION.json + EVENTS.jsonl on disk with real measured data (or honestly recorded ENV-ERROR)
- uat_ack_stream.py, 04-UAT-TILES*.json and src/ byte-identical to their pre-plan commits
</verification>

<success_criteria>
- The H2 calibration half is settled from spike 003's raw data, and the hardware half (fixable vs floor vs non-reproducing) has adequate-sample evidence on both devices with per-event timestamps — everything 04-09 needs to analyse and route, with zero threshold edits and zero src/ changes
</success_criteria>

<output>
Create `.planning/phases/04-animation-flow-control/04-08-SUMMARY.md` when done, recording:
the forensics verdict on spike 003's sample size, per-arm pooled loss numbers (no
interpretation), any instrument deviations (5f1d80b-style mechanism fixes), and commit hashes.
</output>
