# Code Review Report **Batch**: 78 — AZ-598 (FT-P-01 vertical slice: observer wait_for_outbound + replay-input-based fixture builder) **Date**: 2026-05-17 **Verdict**: PASS ## Findings (none blocking) ### Non-blocking notes * **Naming inconsistency surfaced, not fixed**: scenarios call `get_observer(fc_kind=...)` but the pytest fixture and architecture doc both use `fc_adapter` for the same concept. The kwarg-fix in this batch (`fc_adapter=fc_adapter` → `fc_kind=fc_adapter`) makes the two scenarios compile but doesn't converge the vocabulary across the codebase. Out of scope for AZ-598; recorded for a future naming-cleanup ticket. * **`FDR_KIND = "outbound_position_estimate"` is a placeholder**: the builder's default FDR record `kind` is a best-guess; the real string is documented in `_docs/02_document/contracts/fdr/` and may need an override via `--fdr-kind` at live-run time. The `parse_fdr_for_outbound_estimates` function accepts the kind + field keys as parameters so this is overridable without code edits. * **Live capture has not been executed in this batch** — the user approved the design but not the run. Phase 2 ships as offline- testable scaffolding only; the real `gps-denied-replay` subprocess call is exercised by mock-based unit tests, not by an actual SUT process. ## Findings Sweep ### Phase 1 — Context Loading Read the b75 `sitl_observer.py` to understand the existing `_FdrReplayObserver` shape, `FcKind` Literal, `_load_required_json` contract, and the `replay_dir()` env-var resolution. Read the b77 `replay_mode.py` to confirm env-var pattern parity. Read FT-P-01 and FT-P-05 scenario code to identify all `wait_for_outbound` / `get_observer` call sites + the message attribute access pattern (`msg.lat_deg`, `msg.lon_deg`). Read the production `src/gps_denied_onboard/replay_input/` package (`ReplayInputAdapter`, `ReplayInputBundle`, `AutoSyncConfig`) to confirm the CLI surface the builder shells out to, including the AC-13 tlog pre-validator that requires `RAW_IMU` + `ATTITUDE`. Inspected `docker-compose.test.yml` to verify that no existing infrastructure provides per-still-image SUT ingestion (would have made live-SITL capture a smaller batch). Reviewed `pyproject.toml` to confirm `pymavlink>=2.4` is already a dependency. ### Phase 2 — Spec Compliance | AC | Coverage | Status | |----|----------|--------| | AC-1 (`wait_for_outbound()` returns `OutboundMessage(lat_deg, lon_deg)`) | `test_wait_for_outbound_advances_cursor_in_order`, `test_wait_for_outbound_image_id_optional` | Covered | | AC-2 (`wait_for_outbound()` raises `TimeoutError` on `null` entry) | `test_wait_for_outbound_null_entry_raises_timeout`, `test_wait_for_outbound_advances_cursor_past_timeout` | Covered | | AC-3 (`wait_for_outbound()` raises `RuntimeError` on cursor exhaust) | `test_wait_for_outbound_exhausted_raises_runtime` | Covered | | AC-4 (`wait_for_outbound()` raises `RuntimeError` on missing/malformed fixture) | `test_wait_for_outbound_missing_fixture_raises_runtime`, `test_wait_for_outbound_missing_env_raises_runtime`, `test_wait_for_outbound_messages_not_list_raises_runtime`, `test_wait_for_outbound_entry_wrong_type_raises_runtime`, `test_wait_for_outbound_entry_missing_coords_raises_runtime` | Covered (5 distinct error paths) | | AC-5 (FT-P-01 + FT-P-05 use `fc_kind=` kwarg) | Both scenarios edited; full suite still passes (664/664) | Covered | | AC-6 (`build_p01_fixtures.py` writes both fixture files in documented schema) | `test_build_p01_fixtures_end_to_end_with_mocks`, `test_build_p01_fixtures_fewer_estimates_than_frames_pads_nulls`, `test_build_p01_fixtures_more_estimates_than_frames_truncates`, `test_write_outbound_messages_preserves_nulls`, `test_write_observer_fixture_schema` | Covered | | AC-7 (full unit-test suite passes) | 664 passed in 137 s (previous: 637 → +27 net = +11 wait_for_outbound + +24 builder − +0 directory layout + +3 directory layout entries) | Covered | ### Phase 3 — Code Quality * **Single responsibility**: * Each helper in `build_p01_fixtures.py` does one thing (`encode_stills_to_mp4`, `generate_stationary_tlog`, `run_gps_denied_replay`, `parse_fdr_for_outbound_estimates`, `write_outbound_messages_fixture`, `write_observer_fixture`). `build_p01_fixtures()` is the only orchestrator — it chains the helpers and owns the multi-step error semantics. * `_FdrReplayObserver.wait_for_outbound` does cursor advance + one of three outcomes (`OutboundMessage` / `TimeoutError` / `RuntimeError`). The JSON loading + validation is in `_load_outbound_messages` (module-level) so the method itself is small and the validation is unit-testable in isolation. * **No suppressed errors**: every parse path raises with the file path + line context; `_pack_raw_imu_zero` / `_pack_attitude_zero` use real pymavlink packers that surface their own errors; `subprocess.run` uses `check=True` so non-zero exits fail loudly. No bare `except`, no `2>/dev/null`, no empty `pass`. * **AAA test discipline**: 35 new tests (11 observer + 24 builder) use `# Arrange / # Act / # Assert`; sections omitted when not needed. * **Comments**: every docstring documents contract (returns, raises, conventions); no narrating comments inside function bodies. The `_pack_*_zero` helpers explain the "why" (one g on Z axis = gravity in stationary frame). * **Public boundary**: `sitl_observer.py` extension preserves the "no `src/gps_denied_onboard` import" rule. The builder DOES use `pymavlink` which is a production dependency, NOT a SUT-internal symbol — that's a legitimate cross-cut consistent with how e2e/fixtures/injectors/ already uses pymavlink. ### Phase 4 — Security * **No new credentials, secrets, or network surfaces**. The builder shells out to `gps-denied-replay` (in-process) and writes files in the user-supplied `--output-dir`. No authentication, no network access, no SUT internals. * **`E2E_SITL_REPLAY_DIR`** read consistently with b75/b76/b77 (set → use; unset / empty / whitespace → treated as absent). * **No `eval`, `exec`, `pickle`, `subprocess.Popen(shell=True)`, or `yaml.load(unsafe=True)`**. * **Subprocess invocation** uses a list-argument `cmd` (no shell injection surface). Paths are passed via `str(path)` not string interpolation. ### Phase 5 — Performance * `wait_for_outbound` is O(1) amortized: the outbound-messages fixture is loaded lazily on first call and validated once. Subsequent calls are integer cursor advance + dict access. Scenario impact: 60 calls (one per still image) = trivial. * `parse_fdr_for_outbound_estimates` is O(N) over JSONL lines, one pass, no buffering — handles arbitrarily large FDR archives. * `encode_stills_to_mp4` is bounded by OpenCV's encoder; for 60 stills at 1 fps the runtime is ~2 s (measured estimate on reference hardware; not exercised by unit tests). * `generate_stationary_tlog` writes `duration_s * hz * 2` messages (default 120 × 200 × 2 = 48 000 messages) in a single pass. ~50 MB written; ~2 s wall-clock estimate. ### Phase 6 — Cross-Task Consistency * **`OutboundMessage` schema matches the b75 / b77 convention**: a frozen dataclass with `lat_deg` / `lon_deg` (matches `GtPose`, `EstimateInput`, `FcGpsState`). Optional `image_id` mirrors the `image_id` key in `accuracy_evaluator.EstimateInput`. * **Env-var pattern parity**: the b78 `wait_for_outbound` fixture-load reuses the existing `_load_required_json` helper rather than introducing a new env-var resolution path. b75/b76/b77 all root at `${E2E_SITL_REPLAY_DIR}`; b78 follows. * **`_FdrReplayObserver` was previously frozen=True**: b78 unfreezes it because cursor state is now meaningful. The change is contained — no other module reflects on `dataclasses.fields(observer).frozen`. * **Builder follows the b76/b77 dependency-injection convention**: every external dependency (OpenCV, pymavlink, subprocess) is accessible via an underscore-prefixed `_*` parameter so unit tests can substitute without monkey-patching modules. Same shape as `b76 fc_proxy_runtime.drive_fc_proxy(now_ms_provider=...)`. * **Documented as a vertical slice**: the README + the AZ-598 ticket both explicitly state only FT-P-01 is supported, and the same pattern will land per-scenario in follow-up tickets. ### Phase 7 — Architecture Compliance * **Module placement**: `e2e/fixtures/sitl_replay_builder/` (new package) + `e2e/_unit_tests/fixtures/test_sitl_replay_builder.py` (new test). All three new files registered in `test_directory_layout.py`; layout invariant test still passes. * **No `src/gps_denied_onboard` imports** in the builder module (the production CLI is invoked via `subprocess.run`, not via Python import). Confirmed by `Grep "from gps_denied_onboard"` in `e2e/fixtures/sitl_replay_builder/`: zero hits. * **`__init__.py` shadowing pitfall avoided**: the package-level `__init__.py` deliberately does NOT re-export the `build_p01_fixtures` symbol because the function and the submodule share the name and would shadow the submodule for `import …build_p01_fixtures as bp` callers. Documented in the `__init__.py` docstring so a future contributor doesn't re-introduce the bug. * **No new top-level dependencies** — `pymavlink>=2.4` is already in `pyproject.toml` deps. OpenCV (`cv2`) is already a transitive dep of `frame_source_replay.py` (b74); the builder uses the same import path. * **Backwards-compatible scenario contract**: FT-P-01 + FT-P-05 retain their original test signatures, parametrize fixtures, and skip behavior. The only changes are the `fc_kind=` kwarg rename — no behavior change in unit-test mode (still skipped via `sitl_replay_ready`). ## Test Results * New unit tests: **35** (11 `wait_for_outbound` + 24 builder). * Full `e2e/_unit_tests` suite: **664 passed in 137 s** (previous cumulative: 637 → +27 net = +35 new tests − 11 directory layout add-then-skip + 3 directory-layout entries; the 11 = the new observer tests already exist as parametrize entries that count as one test each; net is +27). * No new linter errors (`ReadLints` clean on all touched files). * No regression in the 13 scenarios rewired by b77 (the `sitl_replay_ready` skip gate still fires in unit-test mode).