# C12 OperatorReLocService — AC-3.4 Re-localization Request via GCS Link

**Task**: AZ-330_c12_operator_reloc_service
**Name**: C12 OperatorReLocService
**Description**: Implement `OperatorReLocService`, the C12 operator-side of AC-3.4 (operator-relocalization on visual loss; the SUT requests a position hint from the operator after losing satellite anchoring; the operator confirms a candidate; the system re-anchors). Owns: (a) the `ReLocHint` DTO (`approximate_position_wgs84: LatLonAlt`, `confidence_radius_m: float`, `reason: str`) per description.md § 2; (b) the `OperatorCommandTransport` Protocol that E-C8 (a future task in AZ-261) will implement against pymavlink for the actual GCS-link MAVLink encoding + transmission; (c) the `request_reloc(reloc_hint: ReLocHint) -> None` public method that validates the hint at the C12 boundary, calls `transport.send_reloc_hint(...)`, catches the transport's `GcsLinkError` and re-raises with C12-specific context (operator action label, monotonic timestamp, hint summary as a redacted log line), emits an FDR record `kind="c12.reloc.requested"` via the AZ-273 FDR client so the post-flight log carries the operator's action chronologically, and writes an INFO log on success / ERROR log on failure. Best-effort semantics per description.md § 7 — if the GCS link is degraded the operator may need to re-issue manually; this task does NOT auto-retry. Publishes the Protocol contract at `_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md` so a future E-C8 task implements the same shape against pymavlink without re-negotiating fields. The pattern matches AZ-322's `BackboneEmbedder` Protocol (C10 owns the Protocol; C2 implements it later).
**Complexity**: 3 points
**Dependencies**: AZ-326_c12_cli_app, AZ-273_fdr_client_ringbuf, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c12_operator_orchestrator (epic AZ-253 / E-C12)
**Tracker**: AZ-330
**Epic**: AZ-253 (E-C12)

### Document Dependencies

- `_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md` — produced by this task (frozen Protocol + DTO shape, invariants, test cases for E-C8 to implement against).
- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — consumed: the `c12.reloc.requested` record envelope.
- `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 2 (`OperatorReLocService` interface, `ReLocHint` DTO), § 5 (`GcsLinkError` best-effort), § 7 (best-effort semantics; operator may re-issue).
- `_docs/02_document/components/13_c12_operator_orchestrator/tests.md` — C12-IT-01 (operator re-loc workflow returns SUT to satellite-anchored ≤ 30 s).

## Problem

Without a real `OperatorReLocService`:

- AC-3.4 (operator-relocalization on visual loss) collapses on the operator side — the airborne SUT can publish a re-loc request via FDR + GCS STATUSTEXT (per the C12-IT-01 description), but the operator workstation has no surface to send the operator's confirmed candidate back to the companion.
- The C12 ↔ C8 contract is undefined — without a frozen Protocol document, E-C8's MAVLink implementation might use a wire shape C12 cannot construct.
- `GcsLinkError` is concept-only in description.md § 5 with no producer.
- The CLI's `reloc-confirm` subcommand has nothing to delegate to.
- The post-flight FDR has no record of operator re-loc actions, breaking the C12-IT-01 assertion that "the confirmation event lands in FDR".
- The `ReLocHint` DTO is not defined anywhere; sibling code that wants to construct one has no canonical type.

This task delivers the C12 service surface + the Protocol contract + the FDR side effect. It does NOT own MAVLink encoding (E-C8 will), does NOT own the GCS-link transport layer (E-C8), and does NOT own the airborne side that consumes the inbound MAVLink message (E-C8 / E-C5 / E-C2 chain).

## Outcome

- An `OperatorReLocService` class at `src/operator_tool/operator_reloc_service.py`:
  - Constructor: `__init__(self, *, transport: OperatorCommandTransport, fdr_client: FdrClient, logger: Logger, clock: Clock)`.
  - Public method: `request_reloc(reloc_hint: ReLocHint) -> None`.
- DTOs at `src/operator_tool/_types.py`:
  - `LatLonAlt` (`@dataclass(frozen=True)`): `latitude_deg: float`, `longitude_deg: float`, `altitude_m: float`. Range checks at construction (`-90 ≤ lat ≤ 90`, `-180 < lon ≤ 180`, no altitude bound). NOTE: if the project already has a shared `LatLonAlt` (likely under `shared_helpers` since C4/C5/C6/C8/C10 all use WGS84) — REUSE it; do NOT redefine. The check is: if `_docs/02_document/contracts/shared_helpers/wgs_converter.md` defines `LatLonAlt`, import from there. Otherwise add to `_types.py`.
  - `ReLocHint` (`@dataclass(frozen=True)`): `approximate_position_wgs84: LatLonAlt`, `confidence_radius_m: float`, `reason: str`. Validates `confidence_radius_m > 0` at construction (`__post_init__`); validates `reason` is non-empty.
- An `OperatorCommandTransport` Protocol at `src/operator_tool/operator_command_transport.py`:
  ```python
  @runtime_checkable
  class OperatorCommandTransport(Protocol):
      def send_reloc_hint(self, hint: ReLocHint) -> None: ...
  ```
  This task ships the Protocol; E-C8 ships the concrete `MavlinkOperatorCommandTransport` (a future task referenced as a forward dep).
- Errors at `src/operator_tool/errors.py`:
  - `GcsLinkError(Exception)`: attributes `reason: str` (operator-friendly), `wrapped_exception_repr: str | None`, `remediation: str = "Check GCS link signal strength; re-issue the re-loc command when the link recovers."`. Note: this exception is RAISED by the transport (E-C8's concrete impl); C12 catches and re-raises with added context (the original `GcsLinkError` is preserved as `__cause__`).
- Method flow for `request_reloc`:
  1. Validate `reloc_hint.confidence_radius_m > 0` (already validated at DTO construction; defense-in-depth check here).
  2. Validate `reloc_hint.reason` is non-empty.
  3. Compute a redacted hint summary for logging: `{lat: <X>, lon: <Y>, radius_m: <R>, reason: "<truncated to 200 chars>"}` — altitude included unredacted; lat/lon to 5 decimals only (~1 m granularity, sufficient for log forensics, avoids logging full operator-confidential GPS).
  4. Try block:
     - `transport.send_reloc_hint(reloc_hint)`.
     - INFO log `kind="c12.reloc.sent"` with the redacted summary.
     - `fdr_client.enqueue(FdrRecord(kind="c12.reloc.requested", payload={"hint": <full hint dict>, "outcome": "sent", "ts_monotonic": clock.monotonic()}))`.
  5. Except `GcsLinkError as e`:
     - ERROR log `kind="c12.reloc.failed"` with the redacted summary + `e.reason`.
     - `fdr_client.enqueue(FdrRecord(kind="c12.reloc.requested", payload={"hint": <full hint dict>, "outcome": "failed", "failure_reason": e.reason, "ts_monotonic": clock.monotonic()}))` — the FDR record carries BOTH the attempt and the failure so the post-flight log shows the operator tried.
     - Re-raise `GcsLinkError(reason=f"C12 reloc-confirm: {e.reason}", wrapped_exception_repr=repr(e), remediation=e.remediation)` — wrap with C12 prefix in `reason`.
- The Protocol contract published at `_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md` per `templates/api-contract.md`. Includes Shape, Invariants, Non-Goals, Versioning Rules, and at least 3 Test Cases that E-C8's implementer can run against `MavlinkOperatorCommandTransport`.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorOrchestratorServices` dataclass with `operator_reloc_service: OperatorReLocService`. The factory `build_operator_reloc_service(config, services) -> OperatorReLocService` constructs the service; the `OperatorCommandTransport` is resolved from a wider service registry that includes E-C8's `MavlinkOperatorCommandTransport` (or a fake `LoggingOnlyOperatorCommandTransport` until E-C8 is implemented — fake declared in tests, NOT in production wiring).
- T1's `cli.py` `reloc-confirm` subcommand resolves `services.operator_reloc_service` and calls `.request_reloc(...)`. The CLI subcommand parses CLI flags `--lat`, `--lon`, `--alt`, `--radius`, `--reason` into a `ReLocHint`. Maps `GcsLinkError → exit 40`; `ValueError → exit 2 (usage)`.

## Scope

### Included

- `OperatorReLocService` class with the single public method.
- `LatLonAlt` and `ReLocHint` DTOs (or import from `shared_helpers` if WgsConverter already defined `LatLonAlt`).
- `OperatorCommandTransport` Protocol.
- `GcsLinkError` error type with `reason`, `wrapped_exception_repr`, `remediation`.
- The Protocol contract document at `_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md`.
- FDR record emission via `fdr_client.enqueue` (both success and failure cases).
- Composition-root factory.
- Wiring of T1's `reloc-confirm` subcommand to this service.
- Conformance unit tests using a fake `OperatorCommandTransport` covering all 5 acceptance criteria.

### Excluded

- The MAVLink encoding (E-C8 owns).
- The actual GCS-link transmission (E-C8 owns).
- The airborne side that decodes the inbound MAVLink message and feeds it to the C5 state estimator (E-C5 / E-C8 chain).
- A retry loop or backoff (best-effort per description.md § 7; operator re-issues manually).
- A "broadcast" mode that sends to multiple companions (out of scope; one companion per operator session).
- An ack mechanism (the airborne side may publish a re-loc-applied event via FDR + STATUSTEXT, but this task does NOT wait for or process such an ack).

## Acceptance Criteria

**AC-1: Successful send → transport called once + INFO log + FDR record**
Given a fake `OperatorCommandTransport.send_reloc_hint` that returns successfully and a valid `ReLocHint`
When `request_reloc(hint)` is called
Then the transport is called exactly once with the hint (verifiable via spy); ONE INFO log `kind="c12.reloc.sent"` emitted with `reason`, `confidence_radius_m`, `position_lat` (5 decimals), `position_lon` (5 decimals); ONE FDR record `kind="c12.reloc.requested"` enqueued with `payload.outcome == "sent"` and the full hint

**AC-2: Transport raises `GcsLinkError` → re-raise + ERROR log + FDR record carries failure**
Given a fake `OperatorCommandTransport.send_reloc_hint` that raises `GcsLinkError(reason="link signal lost", wrapped_exception_repr="SerialTimeout(...)")`
When `request_reloc(hint)` is called
Then `GcsLinkError(reason="C12 reloc-confirm: link signal lost", ...)` is raised; the original `GcsLinkError` is preserved as `__cause__`; ONE ERROR log `kind="c12.reloc.failed"` with the redacted summary + `e.reason`; ONE FDR record `kind="c12.reloc.requested"` enqueued with `payload.outcome == "failed"` and `payload.failure_reason == "link signal lost"`

**AC-3: `confidence_radius_m ≤ 0` → `ValueError` at DTO construction**
Given attempting to construct `ReLocHint(approximate_position_wgs84=..., confidence_radius_m=0.0, reason="...")` or with a negative value
When the constructor is invoked
Then `ValueError("confidence_radius_m must be > 0; got 0.0")` is raised; the transport is NEVER called; no log or FDR record is emitted (the DTO never reached the service)

**AC-4: `reason` is preserved verbatim through the transport call**
Given a `ReLocHint(reason="lost track at waypoint 3, terrain features ambiguous due to seasonal foliage change")`
When `request_reloc(hint)` is called
Then the transport's `send_reloc_hint` receives the hint with `reason` byte-for-byte equal to the input (no truncation, no normalization); the FDR record's `payload.hint.reason` is the same; the INFO log truncates the displayed reason to 200 chars (display-only) but the underlying transport call is unmodified

**AC-5: Protocol contract document exists with the exact method signature**
Given the published contract at `_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md`
When E-C8's implementer reads the contract to build `MavlinkOperatorCommandTransport`
Then the contract specifies the exact Protocol shape (`def send_reloc_hint(self, hint: ReLocHint) -> None`), the `ReLocHint` field shape, the documented `GcsLinkError` raise behaviour, the Versioning Rules, and at least 3 Test Cases

**AC-6: Empty `reason` → `ValueError` at DTO construction**
Given attempting to construct `ReLocHint(reason="")`
When the constructor is invoked
Then `ValueError("reason must be non-empty")` is raised

**AC-7: Latitude / longitude out-of-range → `ValueError` at LatLonAlt construction**
Given attempting to construct `LatLonAlt(latitude_deg=91.0, ...)` or `longitude_deg=181.0`
When the constructor is invoked
Then `ValueError` is raised with the offending value in the message

**AC-8: FDR enqueue is non-blocking even if the FDR client is overrun**
Given a fake `FdrClient` whose `enqueue` returns `EnqueueResult.OVERRUN` (per AZ-273)
When `request_reloc(hint)` is called
Then `request_reloc` does NOT raise; the transport call is unaffected; the operator's re-loc request still reaches the companion; the OVERRUN result is observable in the test (via the spy) but the operator action proceeds (FDR is best-effort logging)

**AC-9: Position is logged at 5 decimals (not full precision)**
Given a `LatLonAlt(latitude_deg=49.99876543, longitude_deg=36.12345678, altitude_m=...)`
When `request_reloc(hint)` is called
Then the INFO log line shows `position_lat: 49.99877` and `position_lon: 36.12346` (rounded to 5 decimals); the underlying transport receives the full-precision value (no rounding before transport)

**AC-10: Composition-root factory does not eager-construct the transport**
Given the operator-orchestrator starts up (T1's `cli.py` lazily resolves services)
When the operator does NOT use the `reloc-confirm` subcommand in this session
Then `OperatorCommandTransport` is NEVER instantiated (verifiable via spy on the factory); pymavlink is NEVER imported (NFR-perf-cold-start from T1 holds)

## Non-Functional Requirements

**Performance**
- `request_reloc` overhead in this task (validation + log + FDR enqueue) ≤ 1 ms wall-clock; the transport call is the dominant time.
- FDR `enqueue` is non-blocking per AZ-273.

**Compatibility**
- Reuses `LatLonAlt` from `shared_helpers/wgs_converter.md` if it exists there (per the cross-cutting rule); otherwise defines it locally and a future cycle migrates.
- The Protocol contract is forward-compatible: adding a new method to `OperatorCommandTransport` is allowed; renaming or removing `send_reloc_hint` is a breaking change requiring a new Protocol version.

**Reliability**
- Transport failure does NOT corrupt the FDR — the FDR record is enqueued with `outcome=failed` so the post-flight log shows the operator tried.
- DTO validation rejects malformed input at the boundary (AC-3, AC-6, AC-7).

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Fake transport returning success + valid hint | Transport called once, INFO log, FDR record `outcome="sent"` |
| AC-2 | Fake transport raising `GcsLinkError` | Re-raise with C12 prefix, ERROR log, FDR record `outcome="failed"` |
| AC-3 | `ReLocHint(confidence_radius_m=0.0)` and `=-1.0` | `ValueError` at construction |
| AC-4 | Long reason string (300 chars) | Transport receives full string; log truncates to 200 chars |
| AC-5 | File exists check + parse | Contract file exists; required sections present |
| AC-6 | `ReLocHint(reason="")` | `ValueError` at construction |
| AC-7 | `LatLonAlt(latitude_deg=91.0)`, `longitude_deg=181.0` | `ValueError` with offending value |
| AC-8 | Fake `FdrClient.enqueue` returns OVERRUN | `request_reloc` succeeds; transport unaffected |
| AC-9 | Hint with high-precision lat/lon | Log shows 5-decimal rounding; transport sees full precision |
| AC-10 | Lazy resolution test — never call `reloc-confirm` | `OperatorCommandTransport` not constructed; pymavlink not imported |

## Constraints

- The Protocol shape (`def send_reloc_hint(self, hint: ReLocHint) -> None`) is the C12 ↔ C8 contract — E-C8 MUST implement this exact signature against pymavlink. Renaming the method requires a Plan-cycle change.
- `ReLocHint.confidence_radius_m > 0` and `reason != ""` are non-negotiable invariants — caller must validate before constructing the DTO; construction-time check is defense-in-depth.
- Best-effort semantics: this task does NOT auto-retry on `GcsLinkError`. Operators are responsible for re-issuing.
- `LatLonAlt` reuse: if `shared_helpers/wgs_converter.md` defines `LatLonAlt`, this task imports it; if not, defines it in `_types.py` and a future cycle migrates to shared. DO NOT silently duplicate.
- The FDR record's `payload.hint` carries the FULL `ReLocHint` (no redaction) — operators inspecting the post-flight log need the exact action they took. The redaction is for the live log file only.
- pymavlink MUST NOT be imported in this task's modules — that's E-C8's concern. The transport is consumed via the Protocol.

## Risks & Mitigation

**Risk 1: E-C8's concrete transport diverges from the Protocol**
- *Risk*: A future E-C8 task implements `MavlinkOperatorCommandTransport` with a different signature (e.g. an extra `companion_id` parameter), breaking the C12 ↔ C8 boundary.
- *Mitigation*: AC-5 + the published contract document fix the Protocol shape; E-C8's unit tests assert the implementation satisfies the Protocol via `runtime_checkable`. Catches divergence at E-C8's PR review.

**Risk 2: GCS link is degraded but the operator wants to re-issue rapidly**
- *Risk*: The operator hits `reloc-confirm` repeatedly during a degraded link; each call hits `GcsLinkError`; FDR fills with `outcome=failed` records.
- *Mitigation*: Acceptable — the FDR is bounded at 64 GB (AZ-291..296 enforce); a flood of `c12.reloc.requested` records is a bounded-size anomaly, not unbounded. A future cycle MAY add operator-side rate-limiting, but this cycle's best-effort semantics align with description.md § 7.

**Risk 3: Operator's `reason` field contains sensitive info (e.g. tactical context)**
- *Risk*: The full `reason` lands in FDR (which is post-flight retrievable); the truncated 200-char log is also persisted.
- *Mitigation*: The 5-decimal lat/lon rounding in the log is the only redaction this task applies; full hint persistence in FDR is an intentional product decision per description.md § 5 (post-flight forensics needs the full action). Operators who need to redact must use shorter `reason` strings.

**Risk 4: The Protocol contract becomes stale as E-C8 evolves**
- *Risk*: E-C8's implementation needs additional context (e.g. a `correlation_id` for ack matching).
- *Mitigation*: The Protocol's Versioning Rules (in the contract) document how to extend (add new method, OR add optional kwarg with default); breaking changes require a new Protocol version. E-C8 negotiates via the contract document, not by editing this task.

## Runtime Completeness

- **Named capability**: AC-3.4 operator-relocalization — operator-side request channel per description.md § 2 (`OperatorReLocService.request_reloc`) + § 5 (`GcsLinkError` semantics) + § 7 (best-effort).
- **Production code that must exist**: real `OperatorReLocService` with real `OperatorCommandTransport` (E-C8's `MavlinkOperatorCommandTransport`) injected; real `FdrClient` (AZ-273) emitting the `c12.reloc.requested` record.
- **Allowed external stubs**: tests MAY use a fake `OperatorCommandTransport`; production wiring uses E-C8's pymavlink-backed concrete impl. A `LoggingOnlyOperatorCommandTransport` MAY be used in development environments where no companion is wired (CLI-driven smoke test) — declared in tests / dev composition root, NOT in the production composition root.
- **Unacceptable substitutes**: a no-op transport in production (defeats AC-3.4); shelling out to `mavlink_router` (security + reliability); an internal queue stub instead of the real GCS-link transport (defeats the C12-IT-01 30-s round-trip assertion); skipping FDR record emission (defeats post-flight forensics).

## Contract

This task produces/implements the contract at `_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md`. Consumers (specifically the future E-C8 task implementing `MavlinkOperatorCommandTransport`) MUST read that file — not this task spec — to discover the interface.
