[AZ-401] [AZ-400] Replay — compose_root replay-mode branch + transport seam

Wires the airborne composition root for replay-as-configuration (ADR-011):

- compose_root(config) branches on config.mode in {"live", "replay"}.
  Live behaviour is unchanged; replay builds ReplayInputAdapter,
  attaches JsonlReplaySink, and injects NoopMavlinkTransport.
- New private module runtime_root/_replay_branch.py holds the
  replay-only strategy graph + build-flag gate + calibration loader.
- Config gains Config.mode (Literal["live","replay"]) plus
  Config.replay sub-block with nested ReplayAutoSyncConfig that mirrors
  the AZ-405 AutoSyncConfig DTO; YAML loader + ENV map updated.

Absorbs the AZ-400 transport-seam retrofit that AZ-401 strictly
required but AZ-400 had not delivered:

- New MavlinkTransport Protocol (write/bytes_written/close).
- NoopMavlinkTransport (replay; build-flag gated, idempotent close,
  thread-safe byte counter).
- SerialMavlinkTransport (live, no-op restructure of existing pymavlink
  byte path; encoder retrofit to actually USE it is the AZ-558
  follow-up).

AZ-401 AC-9 (NoopMavlinkTransport.bytes_written > 0 after C8 encoders
run) is BLOCKED on AZ-558 — the encoder routing retrofit is out of
the AZ-401 task envelope (FORBIDDEN files: pymavlink_ardupilot_adapter,
msp2_inav_adapter). AZ-558 spec, batch_61_review.md, and the test's
@pytest.mark.skip rationale all carry the deferral reason.

Tests: 22 compose_root replay-branch tests + 17 transport tests.
Full regression: 2063 passed, 86 environment-skips, 1 documented
skip (AC-9 / AZ-558), 1 pre-existing flaky perf test deselected.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 11:55:33 +03:00
parent 8149083cac
commit 17a0d074af
19 changed files with 2156 additions and 45 deletions
+4 -3
View File
@@ -1,8 +1,8 @@
# Dependencies Table
**Date**: 2026-05-14 (refreshed after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling``Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
**Total Tasks**: 149 (108 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene
**Total Complexity Points**: 494 (361 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt
**Date**: 2026-05-14 (refreshed after Batch 61: AZ-558 follow-up added — routes C8 outbound encoder bytes through `MavlinkTransport` seam; closes AZ-401 AC-9 deferred during batch 61 due to encoder-side routing not being in the AZ-401 task envelope; earlier same-day after cumulative review batches 52-54: AZ-528 hygiene PBI added for c1_vio strategy facade orchestration-spine 3-way duplication (Medium); earlier same-day after Batch 53: AZ-333 VINS-Mono landed — first c1_vio strategy after the AZ-332 OKVIS2 production-default; consolidation hygiene for the strategy-facade duplication deferred to a post-AZ-334 PBI; earlier same-day after Batch 51: AZ-527 hygiene PBI added from cumulative review batches 49-51 F1; 2026-05-13: AZ-526 hygiene PBI added from cumulative review batches 46-48 F1+F3; same-day refresh after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling``Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
**Total Tasks**: 150 (109 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks; AZ-526 = 2pt clock-helper hygiene; AZ-527 = 2pt c2 engine-dim helper hygiene; AZ-528 = 3pt c1_vio facade-spine hygiene; AZ-558 = 3pt MavlinkTransport routing follow-up
**Total Complexity Points**: 497 (364 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt, AZ-526 = 2pt, AZ-527 = 2pt, AZ-528 = 3pt, AZ-558 = 3pt
Dependencies columns list only the tracker-ID portion (descriptive tail
text in each task spec is omitted here for table-readability). The
@@ -113,6 +113,7 @@ are all declared and documented below under **Cycle Check**.
| AZ-403 | (CANCELLED per ADR-011 — replay is a configuration of the airborne binary; no fourth image) | — | — | AZ-265 |
| AZ-404 | E2E replay fixture test — Derkachi 12 min clip + mode-agnosticism + operator workflow | 5 | AZ-402, AZ-401, AZ-405, AZ-263, AZ-269, AZ-266, AZ-272, AZ-273 | AZ-265 |
| AZ-405 | replay_input/ coordinator + auto-sync of video ↔ tlog via IMU take-off detection | 5 | AZ-399, AZ-398, AZ-263, AZ-269, AZ-266, AZ-272, AZ-279 | AZ-265 |
| AZ-558 | Route C8 outbound encoder bytes through MavlinkTransport seam (closes AZ-401 AC-9) | 3 | AZ-401, AZ-273, AZ-294, AZ-399 | AZ-265 |
| AZ-406 | Blackbox Test Infrastructure Bootstrap (Tier-1 + Tier-2 harness scaffold) | 5 | AZ-263 | AZ-262 |
| AZ-407 | Static fixture builders — tile-cache, age-injector, cold-boot, MAVLink passkey, CVE JPEG | 3 | AZ-406 | AZ-262 |
| AZ-408 | Runtime synthetic-injection fixture builders — outlier, blackout-spoof, multi-segment | 3 | AZ-406, AZ-407 | AZ-262 |
@@ -0,0 +1,73 @@
# Replay — route C8 outbound encoder bytes through MavlinkTransport seam (closes AZ-401 AC-9)
**Task**: AZ-558_mavlink_transport_routing
**Name**: Retrofit `PymavlinkArdupilotAdapter`, `Msp2InavAdapter`, and the replay FC adapter to write through `MavlinkTransport.write(bytes)` instead of calling pymavlink's `mav.*_send` helpers directly
**Description**: AZ-401 added the `MavlinkTransport` Protocol seam plus `NoopMavlinkTransport` (replay) and `SerialMavlinkTransport` (live). Both implementations are unit-tested and import-clean, but currently dormant — the live encoders bypass the seam by calling `pymavlink`'s `mavutil.mavlink_connection.mav.gps_input_send(...)` directly, and `TlogReplayFcAdapter` raises on every `emit_external_position()`. This task closes that gap: every outbound MAVLink byte from C8 flows through `MavlinkTransport.write()`, and AZ-401 AC-9 (`NoopMavlinkTransport.bytes_written() > 0`) becomes assertable.
**Complexity**: 3 points
**Dependencies**: AZ-401 (`MavlinkTransport` Protocol + impls + replay branch — already landed); AZ-273 (`PymavlinkArdupilotAdapter`); AZ-294 (`Msp2InavAdapter`); AZ-399 (`TlogReplayFcAdapter`).
**Component**: c8_fc_adapter (epic AZ-265 / E-DEMO-REPLAY)
**Tracker**: AZ-558
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — Invariant 5 (encoders produce identical byte streams in both modes; only the transport differs).
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md``MavlinkTransport` Protocol shape.
- `_docs/03_implementation/reviews/batch_61_review.md` — F1 / F2 (the open spec gap this task closes).
## Problem
AZ-401 wired `NoopMavlinkTransport` into `compose_root`'s replay-mode branch as a `mavlink_transport` slot. The slot is constructed; the value is plumbed into the `RuntimeRoot.components` dict. But no encoder consumes it. `PymavlinkArdupilotAdapter.__init__` accepts a pymavlink `mavutil.mavlink_connection` (not a `MavlinkTransport`) and calls `connection.mav.gps_input_send(...)` directly inside `emit_external_position()`. The same shape holds for `Msp2InavAdapter`. The result: AZ-401 AC-9 (`NoopMavlinkTransport.bytes_written() > 0` after the C8 encoders run in replay mode) is unsatisfiable as wired.
## Outcome
- `PymavlinkArdupilotAdapter.__init__` accepts a `mavlink_transport: MavlinkTransport` keyword argument (Protocol-typed). Every place inside the class that produces MAVLink bytes routes them through `mavlink_transport.write(payload)` instead of calling `connection.mav.gps_input_send(...)` (or any other `mav.*_send` helper).
- `Msp2InavAdapter` adopts the same retrofit.
- `TlogReplayFcAdapter` either: (a) is retrofitted to produce real bytes (preferred, mirrors live encoder shape so AC-9 holds), or (b) gains a thin "replay encoder" sibling that the replay branch instantiates instead. Decide during implementation; the AC-9 outcome is the same.
- `compose_root` (live mode) builds `SerialMavlinkTransport(connection)` from the existing pymavlink connection and injects it into the AP / iNav adapter constructors. Live mode wire-output bytes MUST be byte-identical before/after the retrofit (the new path is a no-op pass-through).
- AZ-401 AC-9 unskips and passes: drive a known `EstimatorOutput` sequence through replay-mode runtime → assert `NoopMavlinkTransport.bytes_written() > 0` AND no serial-port descriptor activity.
- INFO log on every `MavlinkTransport.write()` is **NOT** emitted (would flood the FDR at 10 Hz). The `MavlinkTransport.bytes_written()` counter is the diagnostic surface.
## Acceptance Criteria
**AC-1: AP / iNav adapter constructors accept `mavlink_transport`**`PymavlinkArdupilotAdapter.__init__` and `Msp2InavAdapter.__init__` accept a `mavlink_transport: MavlinkTransport` kwarg. Every outbound `mav.*_send(...)` call in those classes is replaced by `mavlink_transport.write(payload)` where `payload` is the bytes the helper would have sent. (Implementation hint: use `mav.gps_input_encode(...)` to get the message object, then `msg.pack(...)` → bytes, then `mavlink_transport.write(bytes)`. Verify the produced bytes are byte-identical to the prior `gps_input_send` wire output via a recorded fixture.)
**AC-2: Wire-byte equivalence (live mode)** — record the wire-output bytes from `PymavlinkArdupilotAdapter.emit_external_position(known_estimator_output)` before the retrofit (one-time fixture capture). After the retrofit, drive the same input through the retrofitted adapter wired to a `BytesCapturingTransport` (test-only `MavlinkTransport` impl that stores writes); assert the captured bytes are byte-identical to the recorded fixture. Same for `Msp2InavAdapter`.
**AC-3: Replay FC adapter produces bytes**`TlogReplayFcAdapter` (or its replay-encoder sibling) calls `mavlink_transport.write(payload)` from `emit_external_position()`. The exact byte content is whatever `pymavlink.mavutil.gps_input_encode(...).pack()` produces for the input — same as live, per replay protocol Invariant 5.
**AC-4: AZ-401 AC-9 unskips**`tests/unit/test_az401_compose_root_replay.py::test_ac9_noop_transport_bytes_written_after_runtime_drive` is no longer `@pytest.mark.skip`. The test drives 10 `EstimatorOutput` ticks through a replay-mode runtime; assertions: `NoopMavlinkTransport.bytes_written() > 0`, no serial descriptor opened, no `mav.gps_input_send` calls (mock-spec assertion).
**AC-5: `mav.*_send` is no longer called from C8 outbound code paths** — AST scan in a unit test asserts that no source file under `src/gps_denied_onboard/components/c8_fc_adapter/` contains the substring `.gps_input_send(` or `.mav.` (the latter scoped to method-call AST nodes, not type annotations). The Protocol seam is the only egress.
**AC-6: `compose_root` injects the transport** — live mode constructs `SerialMavlinkTransport(connection)` and passes it into the AP / iNav adapter constructors. Replay mode reuses the existing `NoopMavlinkTransport` slot. Unit test asserts the constructor kwargs match.
## Non-Functional Requirements
- Live mode wire-output bytes MUST be byte-identical before and after this retrofit (`SerialMavlinkTransport` is a no-op pass-through). AC-2 is the gate.
- The retrofit MUST NOT change the `MavlinkTransport` Protocol shape (locked by AZ-400 retrofit / AZ-401).
- `compose_root` startup time MUST stay within the AZ-401 NFR (`compose_root` p99 ≤ 1 s in either mode; the new transport construction is constant-time).
## Constraints
- `mavlink_transport` is a constructor kwarg, not a setter. The transport's lifecycle is owned by the composition root (per ADR-001 — composition root owns construction; ADR-009 — explicit ownership).
- `SerialMavlinkTransport` does NOT open the pymavlink connection; the AP / iNav adapters continue to own the connection lifecycle (open / signing handshake / reconnect on disconnect). The transport just wraps `connection.write` — it's a thin adapter.
- Keep the `connection` parameter on the AP / iNav adapter constructors: the connection is still needed for inbound parsing (`connection.recv_msg()`) and signing handshake; only the **outbound** path moves to the transport seam.
- `PymavlinkArdupilotAdapter` and `Msp2InavAdapter` MUST NOT import `SerialMavlinkTransport` or `NoopMavlinkTransport` directly — they accept the transport via the Protocol type. The composition root is the only place that names concrete transport classes (replay protocol Invariant 5 + AZ-401 AC-7).
## Risks & Mitigation
- **Risk: live MAVLink wire output drifts** — *Mitigation*: AC-2 (byte-equivalence fixture). Recorded once before the retrofit; checked once after.
- **Risk: pymavlink `gps_input_encode` API differs from `gps_input_send` in subtle ways (CRC, sequence numbers)** — *Mitigation*: capture both before and after; the byte-equivalence fixture is the spec, not the pymavlink source.
- **Risk: signing handshake is performed by `mavutil.mavlink_connection`, not by `connection.write`; bypassing `mav.*_send` could miss the signing wrap** — *Mitigation*: investigate during implementation; if signing happens at the `mav.*_send` level, the transport seam needs to either run signing itself or invoke a pymavlink helper that wraps `pack()` with signing. Scoped here as a known-unknown.
## Runtime Completeness
- **Named capability**: byte-routing seam for outbound MAVLink — every C8 outbound byte goes through `MavlinkTransport.write()`.
- **Production code**: real adapter retrofits, real wire-byte fixture, real composition-root injection.
- **Allowed external stubs**: `BytesCapturingTransport` (test-only) for AC-2 / AC-4; otherwise none.
- **Unacceptable substitutes**: leaving `mav.*_send` in place "for compatibility" — defeats the seam and re-opens AZ-401 AC-9.
## Contract
Implements `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) Invariant 5 and `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` `MavlinkTransport` shape. Closes the AZ-401 AC-9 deferral documented in `_docs/03_implementation/reviews/batch_61_review.md`.
@@ -0,0 +1,124 @@
# Code Review Report
**Batch**: 61 (AZ-401, with absorbed AZ-400 transport-seam retrofit)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS
## Findings
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| 1 | High | Spec-Gap | tests/unit/test_az401_compose_root_replay.py:526 | AC-9 (`NoopMavlinkTransport.bytes_written() > 0` after C8 encoders) is BLOCKED by AZ-399 design choice — test is `pytest.skip` with documented rationale |
| 2 | Medium | Scope | src/gps_denied_onboard/components/c8_fc_adapter/serial_mavlink_transport.py | Live transport seam introduced as no-op restructure; existing `PymavlinkArdupilotAdapter` / `Msp2InavAdapter` encoders do NOT yet route bytes through `MavlinkTransport.write()` |
| 3 | Low | Maintainability | src/gps_denied_onboard/runtime_root/__init__.py | New optional `replay_components_factory` kwarg (test-injection seam) added to `compose_root` |
| 4 | Low | Style | src/gps_denied_onboard/runtime_root/_replay_branch.py | Inline `_load_camera_calibration` helper duplicates the live-mode loader pattern (one-time tax acceptable; share if a third call site appears) |
### Finding Details
**F1: AC-9 is BLOCKED — `NoopMavlinkTransport.bytes_written() > 0`** (High / Spec-Gap)
- Location: `tests/unit/test_az401_compose_root_replay.py:526` (`test_ac9_noop_transport_bytes_written_after_runtime_drive`)
- Description: AC-9 demands that after the C8 outbound encoders run in replay mode, `NoopMavlinkTransport.bytes_written() > 0`. The intended path is: C5 emits an `EstimatorOutput` → C8 outbound encoder produces a MAVLink `GPS_INPUT` byte stream → `MavlinkTransport.write(bytes)` records the count. The blocker is at the C8 encoder layer: `TlogReplayFcAdapter` (AZ-399) raises `FcEmitError` on every `emit_external_position()` call rather than routing bytes through a transport seam, and the live-mode `PymavlinkArdupilotAdapter` / `Msp2InavAdapter` adapters call `pymavlink`'s `mavutil.mavlink_connection.mav.gps_input_send(...)` directly — they bypass `MavlinkTransport` entirely. Closing AC-9 requires retrofitting the AP / iNav / QGC encoder code paths to consume `MavlinkTransport`, which AZ-400's original spec scoped but did not deliver. The Protocol seam + both implementations (`NoopMavlinkTransport`, `SerialMavlinkTransport`) are present and unit-tested in `test_az400_mavlink_transport.py` (17 tests passing), so the architectural seam is in place.
- Suggestion: file a follow-up task `AZ-401-followup-mavlink-transport-routing` that retrofits `PymavlinkArdupilotAdapter` and `Msp2InavAdapter` (and the Replay FC adapter) to write through `MavlinkTransport.write()` instead of calling pymavlink's `mav.*_send` helpers directly. Keep the AC-9 skip in place with the same blocker reference until that task lands. The skip's `reason` text is the spec for the follow-up.
- Task: AZ-401 (deferred)
**F2: Live transport seam is a no-op restructure** (Medium / Scope)
- Location: `src/gps_denied_onboard/components/c8_fc_adapter/serial_mavlink_transport.py`
- Description: `SerialMavlinkTransport` wraps a pymavlink `mavlink_connection` and forwards bytes via `connection.write(bytes)`. The class is fully implemented and unit-tested (cumulative byte counting, error wrapping for `OSError`, idempotent close, write-after-close rejection). However, the existing live encoders (`PymavlinkArdupilotAdapter`, `Msp2InavAdapter`) still call `connection.mav.gps_input_send(...)` directly — they don't construct or use a `SerialMavlinkTransport`. So the class exists, conforms to the Protocol, and is import-clean — but it is **dormant** in the live path. This is an explicit, deliberate scope reduction: AZ-401's primary goal was the replay-mode branch in `compose_root`, and the AZ-400 retrofit was absorbed only to the minimum extent the replay branch needed. The full live-side retrofit is the same follow-up task as F1.
- Suggestion: same as F1 — track via `AZ-401-followup-mavlink-transport-routing`. The follow-up should also flip `SerialMavlinkTransport` from "constructed but never wired" to "the only path live bytes flow through".
- Task: AZ-401 (deferred)
**F3: New `replay_components_factory` kwarg on `compose_root`** (Low / Maintainability)
- Location: `src/gps_denied_onboard/runtime_root/__init__.py``compose_root(config, *, replay_components_factory=None)`
- Description: Adds an optional kwarg defaulting to `None`. When `None` (production), `compose_root` calls `_replay_branch.build_replay_components(config)`. When provided (tests), the factory is used instead. This mirrors the established pattern from `replay_input.tlog_video_adapter.ReplayInputAdapter.__init__` (`tlog_source_factory`, `video_frames_factory`, `video_timestamps_factory`) noted in batch 60 review as F3, and from `TlogReplayFcAdapter.__init__` (`source_factory`, AZ-399). Production callers (CLI entrypoint AZ-402, runtime root operator AZ-326) pass none of them.
- Suggestion: keep — the precedent is now present in three coordinators. If a fourth adopts the same shape, migrate to a shared `_TestFactories` Protocol.
- Task: AZ-401
**F4: `_load_camera_calibration` duplicates live-mode loader pattern** (Low / Style)
- Location: `src/gps_denied_onboard/runtime_root/_replay_branch.py``_load_camera_calibration(path: Path) -> CameraCalibration`
- Description: Reads a JSON calibration file, validates the required keys, and returns a `CameraCalibration`. The live-mode binary will need the same logic when AZ-263 / AZ-326 wire it. There are currently zero other callers of this exact loader (live mode reads its calib via the operator entry point, AZ-326), so the duplication is hypothetical until a second loader is written.
- Suggestion: keep inline. When AZ-326 / AZ-326-operator-orchestrator implements its calibration loader, factor into `gps_denied_onboard/_helpers/camera_calibration_loader.py` (Layer-2) and have both call sites reuse it.
- Task: AZ-401
## Phase Summary
### Phase 1 — Context Loading
Read inputs:
- `_docs/02_tasks/todo/AZ-401_replay_compose.md`
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0)
- `_docs/02_document/architecture.md` (ADR-011 — replay-as-configuration)
- `_docs/02_document/module-layout.md` (Layer 4 + Build-Time Exclusion Map)
- `_docs/02_document/epics.md` (E-DEMO-REPLAY ACs 1 / 5 / 9 / 11 / 12)
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` (the new `MavlinkTransport` Protocol seam)
### Phase 2 — Spec Compliance
| AC | Verdict | Test |
|----|---------|------|
| AC-1 (single composition root, `compose_replay` deleted) | PASS | `test_ac1_compose_replay_no_longer_exported` |
| AC-2 (live mode unchanged) | PASS | `test_ac2_live_default_mode_returns_runtime_root_with_no_replay_keys` + `test_ac2_live_explicit_mode_unchanged` |
| AC-3 (replay wires VideoFile / TlogReplay / Noop / JsonlReplaySink) | PASS | `test_ac3_replay_mode_wires_five_replay_strategies` |
| AC-4 (replay rejects each `BUILD_*` flag OFF; live unaffected) | PASS | `test_ac4_replay_rejects_each_build_flag_off` (parameterized × 3) + `test_ac4_live_with_replay_flag_off_succeeds` |
| AC-5 (pace → clock kind; same Clock instance across consumers) | PASS | `test_ac5_replay_pace_asap_uses_tlog_derived_clock` + `test_ac5_replay_pace_realtime_uses_wall_clock` + `test_ac5_clock_single_instance_id_equality` |
| AC-6 (JSONL sink emits per tick, 10 frames → 10 lines) | PASS | `test_ac6_jsonl_sink_emits_per_tick_when_runtime_drives_outputs` |
| AC-7 (no mode-aware imports outside runtime_root) | PASS | `test_ac7_no_component_imports_video_file_frame_source` + `test_ac7_only_runtime_root_imports_replay_strategies` |
| AC-8 (replay branch imports only public APIs / documented deep submodules) | PASS | `test_ac8_replay_branch_imports_only_public_apis` |
| AC-9 (NoopMavlinkTransport.bytes_written > 0) | **BLOCKED** | `test_ac9_noop_transport_bytes_written_after_runtime_drive` (skipped with documented reason — see F1) |
| AC-10 (replay does not alter C6 cache shape) | PASS (smoke) | `test_ac10_replay_does_not_alter_c6_cache_shape` (full E2E owned by AZ-404) |
AZ-400 retrofit ACs (Transport Protocol + impls) covered by 17 tests in `tests/unit/c8_fc_adapter/test_az400_mavlink_transport.py`.
Contract verification: `_docs/02_document/contracts/replay/replay_protocol.md` v2.0.0 §Composition Root + Invariants 1, 5, 9, 11, 12 all match the implementation. `MavlinkTransport` Protocol shape (`write(bytes) -> int`, `bytes_written() -> int`, `close()`) matches the contract documentation in `c8_fc_adapter/fc_adapter_protocol.md`.
### Phase 3 — Code Quality
- **SOLID**: `MavlinkTransport` Protocol cleanly separates the "byte sink" responsibility from the encoder; `NoopMavlinkTransport` and `SerialMavlinkTransport` are LSP-substitutable. `compose_root` delegates the replay-specific composition to `_replay_branch.build_replay_components` (single responsibility — `compose_root` just routes by mode).
- **Error handling**: every transport write goes through a lock; closed-state is checked; `OSError` from the underlying serial connection is wrapped in `MavlinkTransportError` with the original cause chained via `from exc`. `CompositionError` carries enough context (which flag is OFF, which path is empty) for an operator to diagnose without grepping source.
- **Naming**: `_replay_branch.py` is the established convention (private module under `runtime_root/`); `build_replay_components` is a verb-form factory; `REPLAY_BUILD_FLAGS` and `REPLAY_COMPONENT_KEYS` are uppercase constants.
- **Complexity**: longest function is `build_replay_components` at ~85 lines, all linear flow with explicit guard clauses. No cyclomatic-complexity-> 10 functions.
- **Test quality**: every AC test asserts a meaningful behavior (not just "no error thrown"). The two AST-scan tests (AC-7, AC-8) survive across files via `ast.parse` rather than substring-grep.
- **Dead code**: none introduced. The legacy `compose_replay` export was already deleted in a prior batch (greenfield iteration); this batch confirmed that via AC-1.
### Phase 4 — Security Quick-Scan
- No SQL strings, no shell-escapes, no `eval` / `exec` / `pickle.loads`.
- `_load_camera_calibration` reads operator-controlled JSON and validates keys; treats missing keys as a hard error rather than silently substituting.
- Replay paths come from operator-controlled config; no taint surface from external input.
- No hardcoded secrets, API keys, or credentials.
- No sensitive data logged: the `replay.compose_root.ready` log emits paths and pace, not auth keys.
### Phase 5 — Performance Scan
- `compose_root` live-mode path adds **one** `if config.mode == "replay"` check per startup — well under the 50 ms budget the task spec requires.
- `NoopMavlinkTransport.write` acquires a `threading.Lock` per call. This matches `SerialMavlinkTransport`'s contract (the live encoders write from a single thread today, but the seam is thread-safe by construction). At an expected emit rate of ≤ 10 Hz, lock contention is irrelevant.
- The `_validate_build_flags` helper iterates `REPLAY_BUILD_FLAGS` (length 3) and reads `os.environ` — constant-time at startup; not in any hot path.
- `_replay_branch` does no I/O on the live-mode path (it's never imported when `config.mode == "live"` triggers the early return in `compose_root`).
### Phase 6 — Cross-Task Consistency
- The AZ-400 retrofit (transport seam) is consistent with AZ-401's replay branch: `_replay_branch.build_replay_components` returns a `mavlink_transport: NoopMavlinkTransport` slot that the (future) C8 encoder retrofit will consume.
- `Config.replay.auto_sync` (added here) is a structural mirror of `replay_input.interface.AutoSyncConfig` (added in AZ-405 / batch 60). The two dataclasses have the same field names + defaults; `_replay_branch._build_auto_sync_config` translates between them. If they ever drift, the failure surface is `replay_input.tlog_video_adapter.ReplayInputAdapter.__init__` rejecting an unrecognised kwarg — caught at startup.
- No conflicting patterns introduced. The build-flag-gating convention (`os.environ[FLAG] == "ON"`) matches what `JsonlReplaySink.__init__` and `NoopMavlinkTransport.__init__` already do.
### Phase 7 — Architecture Compliance
- **Layer direction**: `runtime_root` (Layer-5 cross-cutting composition) imports from `components/*` (Layer-3) and `replay_input/` (Layer-4 cross-cutting coordinator). All Layer-5 → Layer-3/4 — correct direction.
- **Public API respect**: `_replay_branch.py` imports the noop transport + JSONL sink via deep paths (`gps_denied_onboard.components.c8_fc_adapter.noop_mavlink_transport`, `...replay_sink`). These are documented exceptions in `module-layout.md` (the strategy modules are owned by C8 but instantiated only in the composition root). The AC-8 test enforces this allowlist mechanically.
- **No new cyclic deps**: `_replay_branch.py` is leaf-imported only by `runtime_root/__init__.py`. The import graph stays a DAG.
- **Duplicate symbols**: `ReplayConfig` (in `config/schema.py`) and `AutoSyncConfig` (in `replay_input/interface.py`) are distinct DTOs with different responsibilities (config-schema vs. coordinator DTO); the duplication is intentional per the contracts and is mediated by `_build_auto_sync_config` in the replay branch.
- **Cross-cutting concerns**: build-flag check is local to `_replay_branch._validate_build_flags` and to each strategy's constructor. Could be factored into a `_helpers/build_flags.py` utility once a fourth call site appears.
## Verdict Reasoning
One **High** finding (AC-9 BLOCKED) — would normally drive FAIL. Downgrade to PASS_WITH_WARNINGS reasoning:
- The blocker is **architectural / scope-shape**, not a regression. The Protocol seam + both implementations are present and tested. The wiring gap (encoders → transport) is a separate retrofit that AZ-400 was supposed to deliver but did not — that gap is now visible (the AC-9 skip reason) instead of hidden.
- Closing AC-9 inside this batch would require modifying `pymavlink_ardupilot_adapter.py` and `msp2_inav_adapter.py` (FORBIDDEN per the AZ-401 task envelope — those files are owned by AZ-273 / AZ-294, not by AZ-401 or AZ-400's retrofit allowance).
- The recommended path is the follow-up task `AZ-401-followup-mavlink-transport-routing` (see F1 / F2). The AC stays open, the skip carries the spec for the followup, and the batch ships with the seam in place.
The other findings are all Medium / Low and reflect deliberate scope reductions that are documented in the spec's Excluded section.
+4 -4
View File
@@ -6,13 +6,13 @@ step: 7
name: Implement
status: in_progress
sub_step:
phase: 7
name: batch-loop
phase: 1
name: parse
detail: ""
retry_count: 0
cycle: 1
tracker: jira
last_completed_batch: 60
last_completed_batch: 61
last_cumulative_review: batches_58-60
current_batch: 61
current_batch: 62
current_batch_tasks: ""