[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 04:47:44 +03:00
parent a61d2d3f4b
commit 1e0be08e8a
16 changed files with 2198 additions and 4 deletions
@@ -0,0 +1,70 @@
# 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.py``PymavlinkArdupilotAdapter` (full outbound + signing path).
- `src/gps_denied_onboard/components/c8_fc_adapter/_msp2_sensor_gps_encoder.py``MSP2_SENSOR_GPS` wire encoder/decoder (52-byte LE payload).
- `src/gps_denied_onboard/components/c8_fc_adapter/msp2_inav_adapter.py``Msp2InavAdapter` (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.md`**unchanged 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.md`**unchanged 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.
@@ -0,0 +1,126 @@
# 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.
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step:
phase: 6
name: implement-tasks
detail: "batch 9 of N committed (AZ-391 c8 inbound: MAVLink + MSP2 decoders + rings + bus + warm-start hint)"
detail: "batch 10 of N committed (AZ-393 ap outbound + AZ-394 inav outbound + AZ-395 ap mavlink signing)"
retry_count: 0
cycle: 1
tracker: jira