# Batch 76 Report — fc_proxy_runtime driver (cycle 1, batch 10 of test phase)

**Batch**: 76
**Date**: 2026-05-17
**Context**: Test implementation (greenfield Step 10 — Implement Tests)
**Tasks**: AZ-596 (2 cp) — 1 task (fc_proxy_runtime driver, FDR-replay mode)
**Cycle**: 1
**Verdict**: COMPLETE — PASS (self-reviewed; see `reviews/batch_76_review.md`)

## Summary

Final piece of the harness-stubs arc that started in batch 74. The
FT-N-04 (`test_ft_n_04_blackout_spoof`) scenario called a local
`_drive_fc_proxy` stub that raised
`NotImplementedError("FC-inbound spoof proxy driver is owned by
runner.helpers.fc_proxy_runtime")`. That module didn't exist. The
`BlackoutSpoofProxy` state machine (load schedule, activate, replace
inbound GPS frames inside the window) was already fully implemented
under AZ-406 in `fixtures/injectors/fc_proxy.py` — what was missing
was the scenario-facing wrapper.

This batch adds `runner/helpers/fc_proxy_runtime.py` (one function +
one dataclass) using the same **FDR-replay strategy** as AZ-595:
the runtime driver does not plumb into a live MAVLink router. It
loads the schedule, optionally activates the proxy against a
caller-supplied clock, and writes a small audit JSON
(`proxy_drive_report.json`) into `${E2E_SITL_REPLAY_DIR}` so the
downstream FDR evaluators can correlate. Live-mode driving (real
router + real FC) is explicitly a separate live-mode infrastructure
ticket.

### AZ-596 — fc_proxy_runtime driver (2 cp)

* **`runner/helpers/fc_proxy_runtime.py`** —
  `drive_fc_proxy(schedule_path, *, now_ms_provider=None, replay_dir=None)`:
  * Loads the schedule via
    `BlackoutSpoofProxy.from_schedule_file(schedule_path)`.
  * Wraps `json.JSONDecodeError` as `ValueError` with a file pointer
    (consistent with the rest of `e2e/runner/helpers/`).
  * When `now_ms_provider` is supplied, activates the proxy and
    records the resulting `alignment_err_ms`. When absent, sets
    `was_replay_mode=True`.
  * Resolves the write directory in this order: explicit
    `replay_dir` argument > `${E2E_SITL_REPLAY_DIR}` env var > no
    write. The chosen directory is created if missing.
  * Returns `ProxyDriveReport` (frozen dataclass with
    `schedule_path, window_start_ms, window_end_ms,
    spoof_frame_count, alignment_err_ms, was_replay_mode`).
* **`fixtures/injectors/fc_proxy.py`** — added three additive
  `@property` accessors (`window_start_ms`, `window_end_ms`,
  `spoof_frame_count`) so the runtime wrapper does NOT reach into
  private attributes. Existing callers unaffected.
* **FT-N-04 scenario** — local `_drive_fc_proxy` stub replaced
  with `from runner.helpers.fc_proxy_runtime import drive_fc_proxy;
  drive_fc_proxy(schedule_path)`. The scenario's b75
  `sitl_replay_ready` skip gate continues to govern when this
  code path actually runs.
* **Directory layout test** — registered the new
  `runner/helpers/fc_proxy_runtime.py` path.

## Out of scope (deferred)

* **Live MAVLink router + FC inbound transport** — the runtime
  driver currently does not wire `proxy.process_inbound_message`
  into a real router. A live-mode follow-up ticket will own the
  docker-compose-bound MAVLink router that plumbs in the
  per-message replace.
* **Other per-scenario `_resolve_*` / `_drive_*` stubs**
  (`_resolve_frame_sink`, `_resolve_fc_inbound_emitter`,
  `_resolve_outage_injection_frames`, `_resolve_gt_per_frame`,
  `_drive_imu_replay`, `_resolve_frame_period_ms`) — each will get
  its own follow-up ticket. They remain `NotImplementedError`
  stubs in their respective scenario files; the `sitl_replay_ready`
  skip gate ensures they're never reached in unit-test mode.

## Test Results

* New unit tests: **11** (3 schedule load/error, 2 activation, 6
  replay-dir write).
* Full `e2e/_unit_tests` suite: **608 passed in 124 s** (previous
  cumulative: 596 → +12 net = +11 new fc_proxy_runtime tests + 1
  new directory-layout parametrize entry).
* No new linter errors.

## State

* Spec moved: `_docs/02_tasks/todo/AZ-596_fc_proxy_runtime.md`
  → `_docs/02_tasks/done/`.
* `_docs/_autodev_state.md` advanced to `last_completed_batch: 76`.
* `last_cumulative_review` remains `batches_73-75`; next K=3
  cumulative review fires at the end of batch 78.
