mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 10:21:13 +00:00
1e0be08e8a
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>
17 KiB
17 KiB
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 newFcConfigfields (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 whensigning_key_source="dev_static"is selected._types/pose.pyEstimatorOutput.smoothed— additive default-Falsefield. C5 callers that produce smoothed estimates will set this toTrue; 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 theruntime_root.fc_factoryregistry (batch 8). The lazyfrom pymavlink import mavutil/from yamspy import MSPyinside the adapter's_connect/_connect_mspkeeps the heavy wire dependencies out of the binary's import graph until the correspondingBUILD_FC_*is ON. Tests injectconnect_factory/msp_connect_factoryso 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, optionalclock, optional factory) as ctor arguments; nothing reaches out to globals. TheSubscriptionBusandStatusTextTransitionRateLimiterfollow 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 byc8_fc_adapter/__init__.__all__; only the two concrete adapter classes are public surface. - Single-writer outbound thread (Invariant 8) — enforced inside
emit_external_positionandemit_status_textfor both adapters via_enforce_single_writer. The first-emit thread becomes the binding for the lifetime ofopen(); a different thread raisesRuntimeError. Theruntime_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__blocksinav+ signing) + adapter-open gate (Msp2InavAdapter.openrejectssigning_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_FLOATtable 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_positiondoes onebytes.encode("utf-8")for the NAMED_VALUE_FLOAT name (constant), onestruct.packfor MSP2 (52 bytes), and zero per-emit dynamic dispatch. TheStatusTextTransitionRateLimitershort-circuits on the same-label path under the lock without invokingsend_statustext. - Rate-limiter lock scope: the lock is held ONLY during the transition + last-emit-time update; the
send_statustextcall 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] = 0on thebytearraybuffer, inclose(), before GC-eligible. AC-7 verifies the buffer is zero post-close. The buffer is the SAME object pymavlink received viabytes(key)— pymavlink copies into its own buffer atsetup_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.enqueuefailures 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 bydecode_msp2_sensor_gpson 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 usesall(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
PymavlinkArdupilotAdapterlog 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).Msp2InavAdapterlog 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 onclose()+ key never written to disk, never logged, never serialised. AC-4 / AC-5 verify the no-leak invariant by hex scan. BUILD_DEV_STATIC_KEYis OFF by default and rejected at runtime when the source isdev_staticwithout the build flag —test_ac8_dev_static_key_blocked_without_build_flagenforces this. The dev path is intentionally restricted to repeatable test environments.FcConfig.__post_init__enforces three cross-field constraints: (1)inav+ non-nonesigning → rejected (Invariant 2); (2)dev_staticsource requires non-emptydev_static_signing_key; (3)signing_failure_threshold≥ 1. All three have test coverage.- iNav signing asymmetry (Invariant 9) — the iNav adapter NEVER calls
setup_signingon the secondary MAVLink channel (AC-5 verified). The secondary channel is intentionally unsigned per RESTRICT-COMM-2. EstimatorOutput.smoothedInvariant 6 gate — both adapters checkoutput.smoothedBEFORE 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)
- 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_countpoll thread is a forward action and would require its own thread-safety review. STATUSTEXTtransition rate-limiter's 1 s per-severity cap is a hard-coded constructor default. Promotion toFcConfig.statustext_transition_min_interval_sis a forward-action contract bump if operator feedback indicates spam at 1 Hz under pathological label-flapping.- AZ-394 secondary MAVLink channel does not check signed-flag explicitly — we assume the constructor of the secondary connection uses
mavlink_version=2.0withoutsetup_signing(...). The_SecondaryMavStubtest verifiessetup_signing_calls == [], but a production wiring that erroneously callssetup_signingon 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. EstimatorOutput.smootheddefaults toFalse— existing C5 callers continue working. C5 must set this toTrueon its smoothed-history output (AZ-387 follow-up); the gate at the C8 boundary is now in place.current_flight_state()onMsp2InavAdaptercurrently returns a defaultFlightState.INITsignal because the inbound iNav decoder (AZ-391) is not wired into this adapter shell — the composition root readsdecoder.state_ringdirectly. Wiring the decoder INTO the adapter (so a singlesubscribe_telemetrycall 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.