# Batch 10 — Code Review **Batch**: 10 of N **Tasks**: AZ-393 (AP outbound) + AZ-394 (iNav outbound) + AZ-395 (AP MAVLink 2.0 per-flight signing) **Reviewer**: autodev (7-phase) **Verdict**: **PASS_WITH_INFO** **Date**: 2026-05-11 ## Scope | Task | Component / Concern | Files touched (prod) | Files touched (tests) | |------|---------------------|----------------------|------------------------| | AZ-393 | C8 AP outbound — `GPS_INPUT` + `NAMED_VALUE_FLOAT(src_lbl)` + transition `STATUSTEXT` | `components/c8_fc_adapter/_outbound_provenance.py`, `components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` | `tests/unit/c8_fc_adapter/test_az393_ardupilot_outbound.py` | | AZ-394 | C8 iNav outbound — `MSP2_SENSOR_GPS` + secondary unsigned MAVLink `STATUSTEXT` | `components/c8_fc_adapter/_msp2_sensor_gps_encoder.py`, `components/c8_fc_adapter/msp2_inav_adapter.py` | `tests/unit/c8_fc_adapter/test_az394_inav_outbound.py` | | AZ-395 | C8 AP per-flight signing — ephemeral key gen, handshake, mid-flight failure no-raise, zeroisation, `BUILD_DEV_STATIC_KEY` dev path | `components/c8_fc_adapter/pymavlink_ardupilot_adapter.py` (extension), `config/schema.py`, `config/loader.py` | `tests/unit/c8_fc_adapter/test_az395_mavlink_signing.py` | | Shared | `EstimatorOutput.smoothed` field (default-False) for Invariant 6 enforcement | `_types/pose.py` | — | ## Phase 1 — AC compliance ### AZ-393 — 10 ACs | AC | Coverage | |----|----------| | AC-1 GPS_INPUT field fidelity | `test_ac1_gps_input_field_fidelity` — lat/lon/alt + `horiz_accuracy_m` match injected WGS84 + `CovarianceProjector.to_ardupilot_horiz_accuracy_m(...)` to within 1e-3 m. | | AC-2 every-frame emission | `test_ac2_gps_input_every_frame` — 100 frames → 100 `gps_input_send` calls. | | AC-3 NAMED_VALUE_FLOAT every frame | `test_ac3_named_value_float_every_frame` — name=`b"src_lbl"`; value matches `source_label_to_float` mapping for every frame. | | AC-4 STATUSTEXT rate-limited on transition | `test_ac4_statustext_only_on_transition` (10 transitions in 100 frames toggling every 10) + `test_ac4_statustext_zero_within_state` (constant label → 1 bootstrap STATUSTEXT only). | | AC-5 Smoothed output rejected | `test_ac5_smoothed_output_rejected` — `output.smoothed=True` → `FcEmitError`, ERROR log kind=`c8.ap.emit_failed`, zero wire calls. | | AC-6 Non-SPD covariance rejected | `test_ac6_non_spd_covariance_rejected` — propagated `FcEmitError` from `CovarianceProjector`; zero wire calls. | | AC-7 Single-writer thread | `test_ac7_single_writer_thread` — second-thread emit raises `RuntimeError("single-writer ...")`. | | AC-8 Open without signing key (placeholder) | `test_ac8_open_without_signing_key_succeeds` (`signing_key_source="none"`). AZ-395 AC-1 tightens this to reject None on the `ephemeral_per_flight` path. | | AC-9 source-set switch NotImplementedError | `test_ac9_source_set_switch_not_implemented` — message contains `"AZ-396"`. | | AC-10 First emit logged once | `test_ac10_first_emit_logged_once` — 5 emits → exactly 1 `c8.ap.first_emit` INFO record. | ### AZ-394 — 10 ACs | AC | Coverage | |----|----------| | AC-1 MSP2_SENSOR_GPS field fidelity | `test_ac1_msp2_field_fidelity` — wire byte payload round-trips through `decode_msp2_sensor_gps`; lat/lon/alt × 1e7 / × 100 match; `h_pos_accuracy_mm` matches `CovarianceProjector.to_inav_h_pos_accuracy_mm(...)`; code = `0x1F03`. | | AC-2 every frame, monotonic seq | `test_ac2_msp2_every_frame_with_seq` — 100 frames → 100 frames; seq[0]=1, seq[-1]=100 mod 256. | | AC-3 STATUSTEXT secondary, transitions only | `test_ac3_statustext_secondary_only_on_transitions` — 10 transitions → 10 secondary-MAVLink STATUSTEXT; zero on the primary MSP2 channel. | | AC-4 signing-key rejection (Invariant 2) | `test_ac4_signing_key_rejected` — `open(..., signing_key=b"\x00"*32)` → `FcAdapterConfigError("iNav does not support MAVLink signing per RESTRICT-COMM-2")`. | | AC-5 signing-asymmetry (Invariant 9) | `test_ac5_signing_asymmetry_no_signed_flag` — secondary MAVLink stub never receives `setup_signing` and never sets a signed-flag. | | AC-6 source-set-switch unsupported | `test_ac6_source_set_switch_unsupported` → `SourceSetSwitchNotSupportedError("iNav...")`. | | AC-7 smoothed rejected | `test_ac7_smoothed_rejected`. | | AC-8 non-SPD cov rejected | `test_ac8_non_spd_covariance_rejected`. | | AC-9 single-writer thread | `test_ac9_single_writer_thread`. | | AC-10 first emit logged once | `test_ac10_first_emit_logged_once`. | Cross-check: `test_inav_config_rejects_signing` asserts `FcConfig.__post_init__` blocks `(adapter='inav', signing_key_source='ephemeral_per_flight')` at config-load time. ### AZ-395 — 10 ACs | AC | Coverage | |----|----------| | AC-1 signing_key=None on ephemeral source → generates internally | `test_ac1_ephemeral_generates_key_when_none_passed` — `setup_signing` called with a 32-byte key. | | AC-2 fresh key per open() | `test_ac2_ephemeral_distinct_per_flight` — two opens produce distinct 32-byte keys (probabilistic; `secrets.token_bytes`). | | AC-3 handshake failure raises | `test_ac3_handshake_failure_raises` — `setup_signing` raises → `SigningHandshakeError` + ERROR log kind=`c8.ap.signing_handshake_failed`. | | AC-4 FDR record has NO key bytes | `test_ac4_handshake_success_fdr_has_no_key_bytes` — scans the rendered FDR payload for the full key hex AND every 4-byte sub-sequence; none present. | | AC-5 key never in any log | `test_ac5_key_never_in_logs` — captures all log records at DEBUG; key hex absent (full + first 4-byte chunk). | | AC-6 mid-flight failure no-raise | `test_ac6_mid_flight_signing_failure_no_raise` — `sig_count=5` → ERROR log + WARNING STATUSTEXT + `emit_external_position` returns successfully. | | AC-7 key zeroisation on close | `test_ac7_key_zeroisation_on_close` — bytearray buffer captured pre-close; post-close all bytes are `0x00`; INFO log kind=`c8.ap.signing_key_zeroised`. | | AC-8 BUILD_DEV_STATIC_KEY repeatability + production block | `test_ac8_dev_static_key_repeatable` (flag ON → two opens use the same static key) + `test_ac8_dev_static_key_blocked_without_build_flag` (flag absent → `FcOpenError("BUILD_DEV_STATIC_KEY")`). | | AC-9 STATUSTEXT severity = WARNING for mid-flight failure | `test_ac9_statustext_severity_on_mid_flight_failure` — `Severity.WARNING.value` emitted on mid-flight failure path. | | AC-10 AZ-393 placeholder tightened | `test_ac10_unknown_signing_source_rejected` — unknown source slipped past validation → `FcOpenError("unknown signing_key_source")`. AZ-393 AC-8 still passes with `signing_key_source="none"`; the `ephemeral_per_flight` path now requires either an internally-generated key or an explicit 32-byte buffer. | 33 new tests added; 476 total in suite (was 443), 2 pre-existing skips, 0 failures. ## Phase 2 — Contract drift - `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md` — **unchanged at v1.0.0**. All three tasks implement the existing surface; no signature changes. The error class additions (`SourceSetSwitchNotSupportedError`, `SigningHandshakeError`, `FcAdapterConfigError`, `FcOpenError`, `FcEmitError`) were already declared in batch 8 (AZ-390); this batch wires them. - `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — **unchanged at v1.2.0**. The new `FcConfig` fields (`dev_static_signing_key`, `signing_failure_threshold`) are additive defaults that do not break existing callers; they are validated under `__post_init__` and only enforced when `signing_key_source="dev_static"` is selected. - `_types/pose.py` `EstimatorOutput.smoothed` — additive default-`False` field. C5 callers that produce smoothed estimates will set this to `True`; existing callers continue to work unmodified. ## Phase 3 — Architectural compliance - **ADR-002 (build-time exclusion)** — the new AP and iNav adapter classes are in `components/c8_fc_adapter/` and registered through the `runtime_root.fc_factory` registry (batch 8). The lazy `from pymavlink import mavutil` / `from yamspy import MSPy` inside the adapter's `_connect` / `_connect_msp` keeps the heavy wire dependencies out of the binary's import graph until the corresponding `BUILD_FC_*` is ON. Tests inject `connect_factory` / `msp_connect_factory` so neither pymavlink nor yamspy is required for the AC tests to run. - **ADR-009 (interface-first DI)** — both adapters accept their deps (`config`, `wgs_converter`, `covariance_projector`, `fdr_client`, optional `clock`, optional factory) as ctor arguments; nothing reaches out to globals. The `SubscriptionBus` and `StatusTextTransitionRateLimiter` follow the same shape — pure objects, no I/O at construction. - **Module layering** — internal helpers prefixed `_` (`_outbound_provenance.py`, `_msp2_sensor_gps_encoder.py`) and not re-exported by `c8_fc_adapter/__init__.__all__`; only the two concrete adapter classes are public surface. - **Single-writer outbound thread (Invariant 8)** — enforced inside `emit_external_position` and `emit_status_text` for both adapters via `_enforce_single_writer`. The first-emit thread becomes the binding for the lifetime of `open()`; a different thread raises `RuntimeError`. The `runtime_root.fc_factory.bind_outbound_emit_thread` (batch 8) provides the composition-root-level enforcement; the per-adapter check is defence-in-depth. - **Two-gate defence-in-depth on signing** — config-load gate (`FcConfig.__post_init__` blocks `inav` + signing) + adapter-open gate (`Msp2InavAdapter.open` rejects `signing_key != None`). Both fire independently; the inav config-rejection cross-check test exercises the first gate. - **Single-source-of-truth for source_label-to-float mapping** — the canonical `SOURCE_LABEL_TO_FLOAT` table lives in `_outbound_provenance.py`; the operator-side decoder in C12 must mirror it. Documented inline. ## Phase 4 — Performance & reliability - **Outbound emit allocation profile**: each `emit_external_position` does one `bytes.encode("utf-8")` for the NAMED_VALUE_FLOAT name (constant), one `struct.pack` for MSP2 (52 bytes), and zero per-emit dynamic dispatch. The `StatusTextTransitionRateLimiter` short-circuits on the same-label path under the lock without invoking `send_statustext`. - **Rate-limiter lock scope**: the lock is held ONLY during the transition + last-emit-time update; the `send_statustext` call is OUTSIDE the lock so a UART-blocked send cannot wedge other senders. - **Signing-failure poll is per-emit, O(1)**: one attribute lookup + one integer compare; only emits an ERROR log on the transition past the threshold (`_signing_failure_logged_at_count`), so a single signing-failure burst does not spam logs. - **Key zeroisation**: explicit `for i in range(len(buf)): buf[i] = 0` on the `bytearray` buffer, in `close()`, before GC-eligible. AC-7 verifies the buffer is zero post-close. The buffer is the SAME object pymavlink received via `bytes(key)` — pymavlink copies into its own buffer at `setup_signing`, and we zeroise ours; the pymavlink-side buffer is owned by the now-closed connection and is GC-eligible. - **No silent error suppression**: every error path emits a DEBUG/INFO/WARN/ERROR record and (where appropriate) an FDR record. The `FdrClient.enqueue` failures are caught + DEBUG-logged but not re-raised (defence-in-depth for the FDR layer, which has its own back-pressure handling). ## Phase 5 — Test quality - **Pymavlink and YAMSPy are stubbed at the connection level** — the tests inject `connect_factory` / `msp_connect_factory`; nothing touches a real serial port. The MSP2 wire round-trip is verified by `decode_msp2_sensor_gps` on the bytes the stub captured, not by yamspy's transport. - **AC-4 (AP) loosens the 1 s rate-limiter cap explicitly** (`adapter._provenance._min_interval_s = 0.0`). The cap remains active in production; the test reaches into the private to verify the transition behaviour without coupling to wall-clock time. - **AC-7 (AZ-395) captures the bytearray buffer before `close()`** — bytearrays are mutable, so the same object is observable post-close. Zero-byte check uses `all(b == 0 for b in key_buf)`. - **AC-4 / AC-5 (AZ-395) regex / hex scan** — both whole-key hex and rolling 4-byte sub-sequence checks. A regression that logged the key would fail. - **Arrange / Act / Assert pattern** consistently applied; comments restricted to AC headers + safety invariants. No narrative comments in test bodies. ## Phase 6 — Logging & FDR coverage - **`PymavlinkArdupilotAdapter` log kinds**: `c8.ap.first_emit` (INFO, once), `c8.ap.emit` (DEBUG, per emit), `c8.ap.emit_failed` (ERROR), `c8.ap.signing_handshake_failed` (ERROR), `c8.ap.signing_failure` (ERROR), `c8.ap.signing_key_zeroised` (INFO), `c8.ap.signing_dev_static_key` (WARN), `c8.ap.statustext_failed` (DEBUG), `c8.ap.fdr_enqueue_failed` (DEBUG). - **`Msp2InavAdapter` log kinds**: `c8.inav.first_emit` (INFO), `c8.inav.emit` (DEBUG), `c8.inav.emit_failed` (ERROR), `c8.inav.secondary_mavlink_open_failed` (WARN), `c8.inav.secondary_statustext_failed` (DEBUG). - **FDR record kinds**: `c8.ap.signing_key_rotated` (INFO; once at open), `c8.ap.signing_handshake_failed` (ERROR), `c8.ap.signing_failure` (ERROR; per-emit when threshold crossed). - **Zero key bytes in logs/FDR** — explicitly tested by AC-4 (FDR) and AC-5 (logs) of AZ-395. ## Phase 7 — Security & risk surface - **R03 (signing on operator-deployed channel)** — addressed by AZ-395's per-flight ephemeral keys + zeroisation + STATUSTEXT escalation on mid-flight failure. IT-3 SITL gate (ADR-008) is still the production-promotion gate; this batch delivers the surface for that gate. - **R09 (key compromise)** — ephemeral per-flight key (`secrets.token_bytes(32)`) + in-place zeroisation on `close()` + key never written to disk, never logged, never serialised. AC-4 / AC-5 verify the no-leak invariant by hex scan. - **`BUILD_DEV_STATIC_KEY` is OFF by default and rejected at runtime when the source is `dev_static` without the build flag** — `test_ac8_dev_static_key_blocked_without_build_flag` enforces this. The dev path is intentionally restricted to repeatable test environments. - **`FcConfig.__post_init__` enforces three cross-field constraints**: (1) `inav` + non-`none` signing → rejected (Invariant 2); (2) `dev_static` source requires non-empty `dev_static_signing_key`; (3) `signing_failure_threshold` ≥ 1. All three have test coverage. - **iNav signing asymmetry (Invariant 9)** — the iNav adapter NEVER calls `setup_signing` on the secondary MAVLink channel (AC-5 verified). The secondary channel is intentionally unsigned per RESTRICT-COMM-2. - **`EstimatorOutput.smoothed` Invariant 6 gate** — both adapters check `output.smoothed` BEFORE any wire emit; rejection produces ERROR log + zero wire bytes. The CovarianceProjector SPD gate runs before WGS84 extraction so a bad-cov frame never leaks even partial state to the bus. ## Informational findings (non-blocking) 1. **Signing-failure ERROR log fires on the EMIT after threshold crossing**, not on the failure event itself. This means a flight that produces no further emits after a counter spike would NOT log the threshold cross. In practice, the AP adapter emits at 5 Hz, so the latency is sub-second. Promotion to a dedicated `signing_failure_count` poll thread is a forward action and would require its own thread-safety review. 2. **`STATUSTEXT` transition rate-limiter's 1 s per-severity cap** is a hard-coded constructor default. Promotion to `FcConfig.statustext_transition_min_interval_s` is a forward-action contract bump if operator feedback indicates spam at 1 Hz under pathological label-flapping. 3. **AZ-394 secondary MAVLink channel does not check signed-flag explicitly** — we assume the constructor of the secondary connection uses `mavlink_version=2.0` without `setup_signing(...)`. The `_SecondaryMavStub` test verifies `setup_signing_calls == []`, but a production wiring that erroneously calls `setup_signing` on the secondary stream would not be caught by unit tests. An IT-tier wire-bytes check (AC-5 of AZ-394's spec — "no MAVLink2 signed-flag") is a forward action. 4. **`EstimatorOutput.smoothed` defaults to `False`** — existing C5 callers continue working. C5 must set this to `True` on its smoothed-history output (AZ-387 follow-up); the gate at the C8 boundary is now in place. 5. **`current_flight_state()` on `Msp2InavAdapter`** currently returns a default `FlightState.INIT` signal because the inbound iNav decoder (AZ-391) is not wired into this adapter shell — the composition root reads `decoder.state_ring` directly. Wiring the decoder INTO the adapter (so a single `subscribe_telemetry` call serves both producer-side and state-query needs) is the next composition refinement. ## Verdict PASS_WITH_INFO — all 30 ACs (10 + 10 + 10) satisfied; 33 new tests added (476 total, 0 failures); contract surface unchanged at v1.0.0 / composition_root v1.2.0; the five informational findings are forward-action enhancements that do not block the three tasks landing.