# Contract: `FcAdapter` / `GcsAdapter` Protocols

**Owner**: c8_fc_adapter (epic AZ-261 / E-C8)
**Producer task**: AZ-390 (FcAdapter / GcsAdapter Protocols + DTOs + errors + factories + composition)
**Consumer tasks**: AZ-391 (Inbound subscription + telemetry dispatch), AZ-392 (CovarianceProjector), AZ-393 (PymavlinkArdupilotAdapter outbound), AZ-394 (Msp2InavAdapter outbound), AZ-395 (MAVLink 2.0 signing handshake), AZ-396 (Source-set switch), AZ-397 (GcsAdapter + QgcTelemetryAdapter).
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-10
**Module-layout home**: `src/gps_denied_onboard/components/c8_fc_adapter/interface.py`, `src/gps_denied_onboard/components/c8_fc_adapter/__init__.py`, `src/gps_denied_onboard/runtime_root/fc_factory.py`

## Purpose

Defines the public interfaces for C8: per-FC inbound telemetry subscription + outbound external-position emission, plus the GCS link. Two production `FcAdapter` strategies linked at build time per ADR-002: `PymavlinkArdupilotAdapter` (ArduPilot Plane via MAVLink 2.0 with signing) and `Msp2InavAdapter` (iNav via MSP2, unsigned per RESTRICT-COMM-2). One production `GcsAdapter` strategy: `QgcTelemetryAdapter` (downsampled 1–2 Hz summary to QGroundControl + operator command ingestion). Selected at startup via `config.fc.adapter` and `config.gcs.adapter` with `BUILD_FC_<variant>` / `BUILD_GCS_<variant>` flag gating per ADR-002.

C8 is the **single source** of FC inbound telemetry — C1 (VIO) and C5 (StateEstimator) receive `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` exclusively via a constructor-injected `FcAdapter`. C8 is also the **single sink** of outbound external-position — C5's `EstimatorOutput` is encoded into the per-FC wire format at 5 Hz with honest 6×6 → 2×2 covariance projection.

Replay extensions (AZ-265 / E-DEMO-REPLAY) live inside the same component but ship under separate `BUILD_TLOG_REPLAY_ADAPTER` / `BUILD_REPLAY_SINK_JSONL` flags; they implement the same Protocols and are out of scope for E-C8 itself.

The shared `WgsConverter` (AZ-279), `SE3Utils` (AZ-277), and `FdrClient` (AZ-273) helpers are constructor-injected.

## Public API

### Protocol: `FcAdapter`

```python
@runtime_checkable
class FcAdapter(Protocol):
    def open(self, port: PortConfig, signing_key: bytes | None) -> None: ...
    def close(self) -> None: ...
    def subscribe_telemetry(
        self, callback: Callable[[FcTelemetryFrame], None]
    ) -> Subscription: ...
    def emit_external_position(self, output: EstimatorOutput) -> None: ...
    def emit_status_text(self, msg: str, severity: Severity) -> None: ...
    def request_source_set_switch(self) -> None: ...   # AP-only; iNav raises SourceSetSwitchNotSupportedError
    def current_flight_state(self) -> FlightStateSignal: ...
```

### Protocol: `GcsAdapter`

```python
@runtime_checkable
class GcsAdapter(Protocol):
    def open(self, port: PortConfig) -> None: ...
    def close(self) -> None: ...
    def emit_summary(self, output: EstimatorOutput) -> None: ...   # internally rate-limited to 1–2 Hz
    def subscribe_operator_commands(
        self, callback: Callable[[OperatorCommand], None]
    ) -> Subscription: ...
    def emit_status_text(self, msg: str, severity: Severity) -> None: ...
```

### DTOs (frozen, slotted)

```python
@dataclass(frozen=True, slots=True)
class PortConfig:
    device: str                # e.g. /dev/ttyTHS1
    baud: int
    fc_kind: FcKind            # enum {ARDUPILOT_PLANE, INAV}

class FcKind(Enum):
    ARDUPILOT_PLANE = "ardupilot_plane"
    INAV = "inav"

class Severity(Enum):
    INFO = 6
    WARNING = 4
    ERROR = 3                  # values mirror MAVLink STATUSTEXT severities

@dataclass(frozen=True, slots=True)
class FcTelemetryFrame:
    kind: TelemetryKind        # enum {IMU_SAMPLE, ATTITUDE, GPS_HEALTH, MAV_STATE}
    payload: TelemetryPayload  # union; see _types/fc.py
    received_at: int           # monotonic_ns
    signed: bool               # true ONLY for AP signed frames

@dataclass(frozen=True, slots=True)
class FlightStateSignal:
    state: FlightState         # enum {INIT, ARMED, IN_FLIGHT, ON_GROUND, FAILED}
    last_valid_gps_hint_wgs84: LatLonAlt | None    # for AC-5.1 warm-start
    last_valid_gps_age_ms: int | None
    captured_at: int           # monotonic_ns

@dataclass(frozen=True, slots=True)
class GpsHealth:
    status: GpsStatus          # enum {NO_FIX, DEGRADED, STABLE, STABLE_NON_SPOOFED, SPOOFED}
    fix_age_ms: int
    captured_at: int

@dataclass(frozen=True, slots=True)
class EmittedExternalPosition:
    fc_kind: FcKind
    horiz_accuracy_m: float    # AP horiz_accuracy / iNav hPosAccuracy (mm internally)
    source_label: PoseSourceLabel
    emitted_at: int            # monotonic_ns
    sequence_number: int
```

### Error hierarchy

```python
class FcAdapterError(Exception): ...
class FcOpenError(FcAdapterError): ...
class FcEmitError(FcAdapterError): ...
class SigningHandshakeError(FcAdapterError): ...
class SigningKeyExpiredError(FcAdapterError): ...
class SourceSetSwitchError(FcAdapterError): ...
class SourceSetSwitchNotSupportedError(SourceSetSwitchError): ...
class FcAdapterConfigError(FcAdapterError): ...

class GcsAdapterError(Exception): ...
class GcsEmitError(GcsAdapterError): ...
class GcsAdapterConfigError(GcsAdapterError): ...
```

### Composition-root factories

```python
def build_fc_adapter(
    config: AppConfig,
    wgs_converter: WgsConverter,
    se3_utils: SE3Utils,
    covariance_projector: CovarianceProjector,
    fdr_client: FdrClient,
    clock: Clock,
) -> FcAdapter: ...

def build_gcs_adapter(
    config: AppConfig,
    fdr_client: FdrClient,
    clock: Clock,
) -> GcsAdapter: ...
```

Selection: `config.fc.adapter ∈ {"ardupilot_plane", "inav"}` → corresponding strategy, gated by `BUILD_FC_ARDUPILOT_PLANE` / `BUILD_FC_INAV`. `config.gcs.adapter ∈ {"qgc_mavlink"}` → `QgcTelemetryAdapter`, gated by `BUILD_GCS_QGC_MAVLINK`. Unknown strategy → `FcAdapterConfigError` / `GcsAdapterConfigError` at config load. Build-flag OFF for the requested strategy → same error class with the disabled-flag name in the message.

## Invariants

1. **Single open**: `open(...)` MUST be called exactly once per adapter instance. Re-open raises `FcOpenError`. `close()` is idempotent.
2. **Signing key required for AP**: `PymavlinkArdupilotAdapter.open(...)` with `signing_key=None` raises `SigningHandshakeError`. `Msp2InavAdapter.open(...)` MUST reject any non-None `signing_key` with `FcAdapterConfigError` (RESTRICT-COMM-2 — iNav has no signing).
3. **5 Hz periodic emit**: `emit_external_position` is consumed at exactly 5 Hz by the runtime root's emit timer. The adapter does NOT drive its own timer; it only encodes + writes when called. Internal emission rate-limit lives in the runtime root.
4. **Honest covariance projection**: every emitted external-position MUST have `horiz_accuracy_m` derived from the input `EstimatorOutput.covariance_6x6` via the shared `CovarianceProjector` — Frobenius-norm equivalence to the source 3×3 horizontal block within 1% (C8-IT-01). NEVER substitute a constant or downsampled estimate.
5. **Source-label propagation**: `EstimatorOutput.source_label` MUST be re-emitted via the per-FC out-of-band channel (AP: `NAMED_VALUE_FLOAT` + STATUSTEXT; iNav: STATUSTEXT only via the MAVLink telemetry side-channel).
6. **Smoothed estimates rejected**: `emit_external_position` MUST raise `FcEmitError` if `output.smoothed == True`. The forward-time invariant (AC-4.5 revised) is enforced at the C8 boundary as a defensive backstop on top of C5's filtering.
7. **Inbound timestamp monotonicity**: `FcTelemetryFrame.received_at` MUST be monotonically non-decreasing per kind. Out-of-order frames are dropped + logged at WARN.
8. **Single-writer thread for outbound**: `emit_external_position`, `emit_status_text`, and `request_source_set_switch` MUST be called from the same thread. Multi-thread write raises `RuntimeError`. Inbound subscribe-callbacks fire on the inbound decode thread; consumers must handle the thread boundary themselves.
9. **iNav signing assertion**: the iNav adapter MUST never emit a MAVLink2 frame with the signed-flag set, even on the side-channel telemetry link. Verified by C8-IT-08.
10. **Per-flight key zeroisation**: at `close()` (or process exit), the AP signing key buffer MUST be overwritten with zeroes before deallocation. The key MUST never be written to disk. Verified by C8-ST-02.
11. **Source-set switch idempotence**: `request_source_set_switch()` is safe to call multiple times in the same flight. Re-entry within 1 s is no-op'd (rate-limited); re-entry after a successful switch logs INFO + sends STATUSTEXT but does not re-issue the command.
12. **GcsAdapter downsampling**: `emit_summary` is invoked at 5 Hz by the runtime; the adapter internally downsamples to 1–2 Hz (configurable; default 2 Hz). Downsampling is rate-based (every Nth call), not selection-based.

## Producer / Consumer Split

| Task ID | Scope |
|---------|-------|
| AZ-390 (Producer)   | Protocols, DTOs, error hierarchy, factories, composition root extension, `FcKind` / `FlightState` / `GpsStatus` / `Severity` enums, `FcAdapterStub` baseline (test-only no-op accepted by composition). NO concrete production adapter, NO wire encoding. |
| AZ-391 (Consumer 1) | Inbound subscription path: MAVLink 2.0 telemetry decoder (RAW_IMU/ATTITUDE/GPS_RAW_INT/MAV_STATE/HEARTBEAT) for AP + MSP2 telemetry decoder for iNav; produces `FcTelemetryFrame` + bounded ring buffers; emits `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` to subscribers. Backpressure + drop-oldest on overflow. |
| AZ-392 (Consumer 2) | `CovarianceProjector` helper inside C8: 6×6 → 3×3 position sub-matrix → 2×2 horizontal sub-matrix → equivalent_radius (m for AP, mm for iNav). Honest projection per the AC-4.3 formula. |
| AZ-393 (Consumer 3) | `PymavlinkArdupilotAdapter` outbound path: encode `EstimatorOutput` as `GPS_INPUT` (5 Hz); side-channel `NAMED_VALUE_FLOAT` for `source_label` + `STATUSTEXT` mirror; uses the CovarianceProjector. NO signing logic (delivered separately). |
| AZ-394 (Consumer 4) | `Msp2InavAdapter` outbound path: encode `EstimatorOutput` as `MSP2_SENSOR_GPS` (5 Hz) via YAMSPy + INAV-Toolkit; STATUSTEXT mirror via the secondary MAVLink telemetry channel. iNav-specific quirks (mm units, sequence numbers). |
| AZ-395 (Consumer 5) | MAVLink 2.0 per-flight signing handshake (AP only): generate ephemeral key at `open(...)`, complete pymavlink signing handshake, key rotation logging to FDR, key zeroisation on close. D-C8-9 R03 risk; gated for production by IT-3 SITL pass. |
| AZ-396 (Consumer 6) | `MAV_CMD_SET_EKF_SOURCE_SET` D-C8-2 source-set switch (AP only): `request_source_set_switch()` body, ACK handling, `SourceSetSwitchError` on timeout, idempotence per Invariant 11. Wired to C5's spoof-recovery gate via the runtime root. |
| AZ-397 (Consumer 7) | `QgcTelemetryAdapter` GcsAdapter: open MAVLink 2.0 channel, downsample 5 Hz → 1–2 Hz `emit_summary`, operator command ingestion (`subscribe_operator_commands`), STATUSTEXT mirror. |

Tests C8-IT-01..08 + C8-PT-01 + C8-ST-01..02 are deferred to E-BBT (AZ-262) per the project's E-BBT pattern.

## Constraints

- `@runtime_checkable` Protocols; DTOs `frozen=True, slots=True`.
- Lazy-import per ADR-002.
- Public API restricted to `interface.py` + `__init__.py` re-exports per `module-layout.md`.
- `pymavlink` is bundled unmodified per D-C8-3.
- Signing key MUST never appear in a log line, FDR record, or stderr trace.
- The `PortConfig.device` and `signing_key` are constructor-time inputs to `open(...)`; they MUST NOT be re-readable from the adapter post-open (no `get_port_config()` accessor).

## Risks / Mitigations

- **R03** (MAVLink 2.0 per-flight signing has no operator-deployed precedent): gated by IT-3 SITL pass before flight-test sign-off.
- **R09** (signing key compromise): per-flight ephemeral keys + zeroisation; never persisted; never logged.
- Cross-adapter drift (AP vs iNav contract): the shared `CovarianceProjector` + `FcTelemetryFrame` enforce wire-agnostic semantics. Per-FC quirks (mm units, signing) are quarantined to the variant adapter.
