Files
Oleksandr Bezdieniezhnykh 1e0be08e8a [AZ-393] [AZ-394] [AZ-395] C8 outbound chain + AP MAVLink2 signing
AZ-393 ArduPilot outbound: PymavlinkArdupilotAdapter encodes
EstimatorOutput to MAVLink2 GPS_INPUT via gps_input_send; emits
NAMED_VALUE_FLOAT(name="src_lbl") every frame and STATUSTEXT on
source_label transition (1 Hz per-severity cap). Smoothed-output
guard (Invariant 6), single-writer thread (Invariant 8), SPD
propagation. Shared helper _outbound_provenance.py owns the
canonical source-label-to-float table + transition rate-limiter.

AZ-394 iNav outbound: Msp2InavAdapter encodes EstimatorOutput to
hand-rolled MSP2_SENSOR_GPS (0x1F03, 52-byte LE payload via
_msp2_sensor_gps_encoder.py + YAMSPy send_RAW_msg). Secondary
unsigned MAVLink channel for STATUSTEXT transitions. open()
rejects non-None signing_key (RESTRICT-COMM-2 / Invariant 2);
request_source_set_switch raises SourceSetSwitchNotSupportedError
(Invariant 9 verified: never calls setup_signing on secondary).

AZ-395 AP MAVLink2 signing: ephemeral per-flight 32-byte key
from secrets.token_bytes; pymavlink setup_signing handshake at
open(); in-place bytearray zeroisation on close(); mid-flight
signing-failure detection (ERROR log + WARNING STATUSTEXT + no
raise; threshold configurable). Key never logged / persisted /
serialised (regex-scanned by AC-4/AC-5). BUILD_DEV_STATIC_KEY=ON
enables repeatable static-key dev path; rejected at open() when
the build flag is absent.

Shared: EstimatorOutput.smoothed (default False) added for the
Invariant 6 gate at the C8 boundary; FcConfig extended with
dev_static_signing_key + signing_failure_threshold (additive
defaults; cross-field validation in __post_init__).

Tests: 33 new AC tests (11 + 11 + 11) covering all 30 ACs; full
suite 476 passing / 2 skipped / 0 failing (was 443). Contract
surfaces unchanged at fc_adapter_protocol v1.0.0 and
composition_root v1.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 04:47:44 +03:00

17 KiB
Raw Permalink Blame History

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_rejectedoutput.smoothed=TrueFcEmitError, 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_rejectedopen(..., 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_unsupportedSourceSetSwitchNotSupportedError("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_passedsetup_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_raisessetup_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_raisesig_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_failureSeverity.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.mdunchanged 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.mdunchanged 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 flagtest_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.