mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 03:51:14 +00:00
Decompose Step 6 snapshot: 140 task specs + contract docs
Closes out greenfield Step 6 (Decompose) for all 14 components (C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446 plus the _dependencies_table.md and component contract documents. State file updated to greenfield Step 7 (Implement), not_started. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# 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) |
|
||||
Reference in New Issue
Block a user