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

7.9 KiB

Batch 10 — Cycle 1 Implementation Report

Batch: 10 of N Tasks landed: AZ-393 (C8 AP outbound) + AZ-394 (C8 iNav outbound) + AZ-395 (C8 AP MAVLink 2.0 per-flight signing) Cycle: 1 Date: 2026-05-11

Scope

Task Component Purpose
AZ-393 C8 FC adapter (AP outbound) PymavlinkArdupilotAdapter.emit_external_position body: GPS_INPUT encoder via pymavlink.mav.gps_input_send; per-frame NAMED_VALUE_FLOAT(name="src_lbl") provenance side-channel; transition-only STATUSTEXT with 1 Hz per-severity hard cap. Single-writer thread invariant, smoothed-output guard, SPD-violation propagation.
AZ-394 C8 FC adapter (iNav outbound) Msp2InavAdapter — hand-encoded MSP2_SENSOR_GPS (code 0x1F03, 52-byte LE payload) over YAMSPy's send_RAW_msg; transition-only STATUSTEXT on the unsigned secondary MAVLink channel. Signing-key rejection (Invariant 2), source-set-switch unsupported, signing-asymmetry assertion (Invariant 9).
AZ-395 C8 FC adapter (AP signing) Per-flight ephemeral signing key via secrets.token_bytes(32) + setup_signing handshake; BUILD_DEV_STATIC_KEY=ON enables repeatable dev path from FcConfig.dev_static_signing_key; mid-flight signing-failure detection (no-raise + ERROR log + WARNING STATUSTEXT); in-place key zeroisation in bytearray on close(); FDR rotation event with NO key bytes.
Shared _types/pose.py Added EstimatorOutput.smoothed: bool = False to enable Invariant 6 gate at the C8 boundary; C5 sets to True on smoothed-history output (forward action).

Files added / modified

Added (prod)

  • src/gps_denied_onboard/components/c8_fc_adapter/_outbound_provenance.py — canonical SOURCE_LABEL_TO_FLOAT table + source_label_to_float(...) + StatusTextTransitionRateLimiter.
  • src/gps_denied_onboard/components/c8_fc_adapter/pymavlink_ardupilot_adapter.pyPymavlinkArdupilotAdapter (full outbound + signing path).
  • src/gps_denied_onboard/components/c8_fc_adapter/_msp2_sensor_gps_encoder.pyMSP2_SENSOR_GPS wire encoder/decoder (52-byte LE payload).
  • src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.pyMsp2InavAdapter (MSP2 primary + unsigned MAVLink secondary).

Added (tests)

  • tests/unit/c8_fc_adapter/test_az393_ardupilot_outbound.py — 11 AC tests (10 ACs + adapter-fixture).
  • tests/unit/c8_fc_adapter/test_az394_inav_outbound.py — 11 AC tests (10 ACs + iNav config rejection cross-check).
  • tests/unit/c8_fc_adapter/test_az395_mavlink_signing.py — 11 AC tests covering ephemeral keys, FDR no-leak, log no-leak, mid-flight no-raise, zeroisation, BUILD_DEV_STATIC_KEY path, severity mapping, AZ-393 placeholder tightening.

Modified

  • src/gps_denied_onboard/_types/pose.py — added EstimatorOutput.smoothed: bool = False (additive default).
  • src/gps_denied_onboard/config/schema.py — extended FcConfig with dev_static_signing_key: str = "" + signing_failure_threshold: int = 3; allow "dev_static" as a third valid signing_key_source; cross-field validation (adapter='inav', signing_key_source!='none') → reject; (signing_key_source='dev_static', dev_static_signing_key=='') → reject; signing_failure_threshold < 1 → reject.
  • src/gps_denied_onboard/config/loader.py — wired the two new fields into ENV_KEY_MAP (FC_DEV_STATIC_SIGNING_KEY, FC_SIGNING_FAILURE_THRESHOLD) and _FIELD_COERCIONS.

Contract changes

  • _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.mdunchanged at v1.0.0. Surface implemented in this batch was declared in v1.0.0 / AZ-390 (batch 8).
  • _docs/02_document/contracts/shared_config/composition_root_protocol.mdunchanged at v1.2.0. The two new FcConfig fields are additive defaults; no behavioural change for existing callers.

Test counts

Metric Before After Delta
Tests passing 443 476 +33
Tests skipped 2 2 0
Tests failing 0 0 0

Architectural notes

  • Lazy import of pymavlink / yamspy: both adapter classes import their heavy wire dependencies only at _connect / _connect_msp time. Tests inject a connect_factory / msp_connect_factory, so the AC suite runs without the C-extension transports. Production builds load pymavlink (AP) or yamspy (iNav) only when the matching BUILD_FC_* flag links the respective adapter into the binary — consistent with ADR-002.
  • WGS84 source-of-truth: both adapters require the composition root to pre-attach the WGS84 fix to EstimatorOutput.extras["wgs84"] (a LatLonAlt). If the enricher is missing the key, _extract_wgs84 raises FcEmitError rather than guessing — a missing enricher is a composition bug, not an emit-time degraded mode. The WgsConverter dependency is still injected so a future refactor can move the enrichment inside the adapter without a public-API change.
  • Single-writer thread: 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 second thread raises RuntimeError. Combined with the composition-root-level bind_outbound_emit_thread from batch 8, this gives two-tier defence-in-depth against the multi-writer race.
  • Signing-failure poll: _poll_signing_failure_counter runs once per emit. It reads connection.mav.signing.sig_count (provided by pymavlink), compares against _signing_failure_threshold (default 3, configurable via FcConfig.signing_failure_threshold), and logs + sends WARNING STATUSTEXT only on the transition past the threshold (tracked by _signing_failure_logged_at_count). It NEVER raises — the FC ignores unsigned messages and AC-5.2 fallback (AZ-388) takes over downstream.
  • Key zeroisation: the signing key is stored as a bytearray so we can overwrite it in place inside close() via for i in range(len(buf)): buf[i] = 0. pymavlink.setup_signing receives bytes(key) (a copy), so our buffer is the authoritative one we control. AC-7 verifies the buffer is zero post-close by capturing a direct reference to the bytearray before close.
  • MSP2 wire format: MSP2_SENSOR_GPS (code 0x1F03) is iNav-specific and absent from YAMSPy's MSPCodes table. We hand-roll the 52-byte LE payload (struct.pack/unpack) and ship through YAMSPy's lower-level send_RAW_msg(code, data). The unit test exercises the round-trip via decode_msp2_sensor_gps. An IT-tier test against real iNav firmware will exercise the on-target decode.

Dependencies introduced

  • None. pymavlink>=2.4 and yamspy>=0.3.3,<0.4 were already pinned in pyproject.toml (batch 9). This batch consumes those dependencies via the adapter shells.

Known forward-actions

  1. AZ-389 (smoothed flag at C5 source) — C5's smoothed-history output (AZ-387) must set EstimatorOutput.smoothed=True so the Invariant 6 gate at C8 fires correctly. The default-False field landed in this batch; C5 wiring lands in the C5 batch.
  2. Msp2InavAdapter.current_flight_state() currently returns a FlightState.INIT default because the iNav inbound decoder (AZ-391) is not yet composed INTO the adapter; the composition root reads decoder.state_ring directly. Wiring the inbound decoder INTO the adapter shell (parallel to the AP adapter's _inbound field) is the next composition refinement — does not affect the AC surface but tightens the producer/consumer model.
  3. StatusTextTransitionRateLimiter._min_interval_s (1 s default) is a constructor default. Promotion to FcConfig.statustext_transition_min_interval_s is a forward-action contract bump.
  4. Source-set switch (AZ-396) replaces the NotImplementedError("Owned by source-set task; install AZ-396 to enable") raise; AP adapter's request_source_set_switch is the integration point.
  5. C8-ST-01 SITL signing handshake test — gated by IT-3 / ADR-008. The wire surface delivered here is the input to that gate.