# Contract: operator_command_transport

**Component**: c12_operator_tooling
**Producer task**: AZ-330 — `_docs/02_tasks/todo/AZ-330_c12_operator_reloc_service.md`
**Consumer tasks**: TBD — a future E-C8 (AZ-261) task implements `MavlinkOperatorCommandTransport` against pymavlink
**Version**: 1.0.0
**Status**: frozen
**Last Updated**: 2026-05-10

## Purpose

Defines the operator-workstation ↔ companion command channel for AC-3.4 operator-relocalization. C12 owns the Protocol shape; E-C8 (AZ-261) ships the pymavlink-backed concrete implementation that encodes the hint into a MAVLink message and transmits it over the GCS link to the airborne companion. Decoupling the two sides through this Protocol prevents C12 from having to know MAVLink details, and prevents E-C8 from having to know operator-tool internals — they meet at this contract.

## Shape

### DTOs

```python
@dataclass(frozen=True)
class LatLonAlt:
    latitude_deg: float                     # -90 ≤ value ≤ 90
    longitude_deg: float                    # -180 < value ≤ 180
    altitude_m: float                       # WGS84 ellipsoidal height; no documented bound
    # If shared_helpers/wgs_converter.md already defines LatLonAlt, this contract REUSES that definition. The shape above is the canonical fallback if no shared definition exists.

@dataclass(frozen=True)
class ReLocHint:
    approximate_position_wgs84: LatLonAlt   # operator's best guess of current aircraft position
    confidence_radius_m: float              # > 0; operator's uncertainty radius around the position
    reason: str                             # non-empty; free-text operator note for forensics
    # Validates `confidence_radius_m > 0` and `reason != ""` in __post_init__.
```

| Field | Type | Required | Description | Constraints |
|-------|------|----------|-------------|-------------|
| `LatLonAlt.latitude_deg` | `float` | yes | WGS84 latitude in degrees | `-90 ≤ x ≤ 90` |
| `LatLonAlt.longitude_deg` | `float` | yes | WGS84 longitude in degrees | `-180 < x ≤ 180` |
| `LatLonAlt.altitude_m` | `float` | yes | WGS84 ellipsoidal altitude in metres | no bound |
| `ReLocHint.approximate_position_wgs84` | `LatLonAlt` | yes | Operator's best guess | per `LatLonAlt` constraints |
| `ReLocHint.confidence_radius_m` | `float` | yes | Operator's uncertainty radius | `> 0` strictly |
| `ReLocHint.reason` | `str` | yes | Free-text operator note | non-empty; no length cap; no charset restriction |

### Protocol

```python
@runtime_checkable
class OperatorCommandTransport(Protocol):
    def send_reloc_hint(self, hint: ReLocHint) -> None: ...
```

| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `send_reloc_hint` | `(hint: ReLocHint) -> None` | `GcsLinkError` (any failure to transmit: signal lost, link timeout, framing error, mavlink encode error) | sync |

### Errors

```python
class GcsLinkError(Exception):
    reason: str                             # operator-friendly one-line description (e.g. "link signal lost")
    wrapped_exception_repr: str | None      # repr() of the underlying transport exception, if any
    remediation: str = "Check GCS link signal strength; re-issue the re-loc command when the link recovers."
```

The transport implementation MUST raise `GcsLinkError` (and only `GcsLinkError`) on any failure to transmit. C12's `OperatorReLocService` catches and re-raises with C12-specific context.

## Invariants

- **INV-1 (validation already done)**: when `send_reloc_hint(hint)` is called, the `hint` is already validated (`confidence_radius_m > 0`, `reason != ""`, lat/lon in range). The transport MAY skip re-validation but MUST NOT perform a different validation pass that rejects values C12 considers valid.
- **INV-2 (single transmission attempt)**: `send_reloc_hint` MUST attempt transmission exactly once. The transport MUST NOT retry internally — best-effort semantics per description.md § 7 are enforced at the C12 / operator level, not at the transport layer.
- **INV-3 (no return value contract)**: `send_reloc_hint` returning normally means the transport believes the hint left the operator workstation; it does NOT mean the airborne companion received or processed it (no ack mechanism in v1.0.0).
- **INV-4 (preserve `reason` byte-for-byte)**: the transport MUST encode `reason` such that the airborne side decodes the identical UTF-8 byte sequence, up to the MAVLink message's documented field-length limit. If `reason` exceeds the MAVLink message capacity, the transport MUST raise `GcsLinkError(reason="reason field exceeds MAVLink encoding capacity: <N> bytes > <max> bytes")` rather than silently truncate.
- **INV-5 (no side effects beyond transmission)**: `send_reloc_hint` MUST NOT write to the local filesystem, emit FDR records, or change any operator-workstation state beyond the network transmission. C12 owns side effects (FDR record, log).
- **INV-6 (thread-safety)**: a single `OperatorCommandTransport` instance MAY be called from at most one thread per session. Concurrent calls from multiple threads are undefined behaviour and MAY raise `GcsLinkError(reason="concurrent send")`.

## Non-Goals

- **Acknowledgement / round-trip** — v1.0.0 is fire-and-forget. A future v2.0.0 may add an ack channel via FDR + STATUSTEXT; out of scope here.
- **Encryption / signing of the re-loc payload** — covered by the MAVLink 2.0 message-signing on the wired channel per ADR-009 / D-C8-9; this Protocol does not re-specify it.
- **Multiple companions** — one transport instance addresses one companion; multi-companion broadcast is out of scope.
- **Retry / backoff** — best-effort per description.md § 7. The operator decides when to re-issue.
- **Backpressure / flow control** — `send_reloc_hint` is sync and unbounded; if the operator issues 100 re-loc commands in 1 s, the transport sends 100 messages. The MAVLink physical layer's bandwidth is the natural bound.
- **GCS-link health probing** — this Protocol does NOT expose a `is_link_healthy()` method. Liveness is observed via `GcsLinkError` raised by `send_reloc_hint`.

## Versioning Rules

- **Breaking changes** (renaming `send_reloc_hint`, removing it, changing its signature, changing `ReLocHint` field types or names, removing `confidence_radius_m`, etc.) require a new major version (v2.0.0). The producer (this contract owner, AZ-330's owner) bumps the version, updates the Change Log, and notifies all consumers via the autodev tracker leftovers mechanism.
- **Non-breaking additions** (new optional kwarg with default, new method on the Protocol that consumers don't need to implement, new optional field in `ReLocHint` with a documented default) require a minor version bump (v1.1.0). Existing implementations remain valid.
- **Patch changes** (clarifying invariants, adding test cases, fixing typos) require a patch version bump (v1.0.1).
- A breaking change requires a deprecation period of at least one Plan cycle (one major release) before consumers may stop supporting the old version.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| TC-1 valid-minimal | `ReLocHint(LatLonAlt(49.99, 36.12, 100.0), confidence_radius_m=50.0, reason="lost track at WP3")` + healthy transport | `send_reloc_hint` returns `None`; airborne side decodes `reason="lost track at WP3"` and `confidence_radius_m=50.0` byte-identical | minimal happy path; verifies INV-4 |
| TC-2 invalid-radius | `ReLocHint(..., confidence_radius_m=0.0, ...)` constructed first (raises `ValueError` at DTO `__post_init__`); the transport is NEVER called | `ValueError` at construction; transport spy shows zero calls | producer-side validation (INV-1) — transport is not the gatekeeper |
| TC-3 link-failure | Healthy hint + transport whose underlying link drops mid-encode | `send_reloc_hint` raises `GcsLinkError(reason="link signal lost", wrapped_exception_repr="...")` | INV-2 (single attempt, no internal retry); INV-3 (return semantics) |
| TC-4 reason-too-long | `ReLocHint(..., reason="x" * 10000)` against a transport whose MAVLink encoding capacity is, say, 2000 bytes | `send_reloc_hint` raises `GcsLinkError(reason="reason field exceeds MAVLink encoding capacity: 10000 bytes > 2000 bytes")` | INV-4 enforcement; no silent truncation |
| TC-5 lat-lon-out-of-range | `LatLonAlt(latitude_deg=91.0, ...)` constructed first | `ValueError` at construction; transport never reached | producer-side validation; transport never called |
| TC-6 concurrent-call | Two threads calling `send_reloc_hint` on the same instance simultaneously | EITHER both succeed in some order, OR one raises `GcsLinkError(reason="concurrent send")` | INV-6 — undefined-behaviour-with-bounds; either outcome is contract-conformant; deterministic single-threaded use is the recommended pattern |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — frozen Protocol shape + DTO + error type + 6 test cases. | autodev (AZ-330 decompose) |
