[AZ-329] [AZ-330] Archive Batch 44 task files to done/

Implementation completed in Batch 44 (commit 5fe6702); archive the task
specs per implement skill Step 13.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 21:30:25 +03:00
parent a92e5ee482
commit 25836925c9
2 changed files with 0 additions and 0 deletions
@@ -0,0 +1,217 @@
# C12 Post-Landing Upload — `trigger_post_landing_upload` + FDR `flight_footer` Confirmation
**Task**: AZ-329_c12_post_landing_upload
**Name**: C12 Post-Landing Upload
**Description**: Implement `PostLandingUploadOrchestrator`, the C12 post-flight (F10) workflow that gates `C11.TileUploader.upload_pending_tiles` (AZ-319) on the presence of a clean-shutdown `flight_footer` FDR record for `flight_id`. `trigger_post_landing_upload(request: PostLandingUploadRequest) -> UploadBatchReportCut` does the following: (1) resolve `<fdr_root>/<flight_id>/` and confirm the directory exists; (2) iterate the segment files from newest to oldest, streaming length-prefixed records via AZ-272's `FdrRecord.parse(...)`; (3) short-circuit on the first record whose `kind == "flight_footer"` (the C13 writer in AZ-292 emits exactly one such record per flight, on `close_flight()`); (4) inspect `payload["clean_shutdown"]``True` → the flight terminated gracefully → invoke `tile_uploader.upload_pending_tiles(UploadRequestCut(flight_id=..., batch_size=..., satellite_provider_url=...))` and return its `UploadBatchReportCut` unchanged; `False` → operator inspection required → refuse with `FlightStateNotConfirmedError("unclean_shutdown")`; (5) footer absent across every segment → power-loss truncation or mid-flight crash → refuse with `FlightStateNotConfirmedError("footer_missing")`; (6) FDR parse error mid-stream → refuse with `FlightStateNotConfirmedError("fdr_unreadable: <repr>")`; (7) `<fdr_root>/<flight_id>/` does not exist → refuse with `FlightStateNotConfirmedError("flight_id_not_found")`. Owns AC-8.4's defense-in-depth check on the operator-orchestrator side — C11 is now a dumb pipe (the airborne internal gate was removed in batch 44 — see superseded AZ-317); this task is the only gate that prevents the upload command from being issued when the flight didn't terminate cleanly. Returns C11's `UploadBatchReport` (passthrough via the cut) on success. Logs every decision (INFO on confirmed; ERROR on each refusal mode); the `api_key` carried inside `PostLandingUploadRequest` is a plain `str` field but the orchestrator + CLI MUST redact it from every log line (matching the existing AZ-328 `BuildCacheRequest.api_key` pattern — `"api_key": "REDACTED"`). Introducing a Pydantic-backed `SecretStr` type would require adding `pydantic` as a runtime dependency, which the project explicitly avoids; the runtime-redaction contract is enforced by AC-8.
**Complexity**: 3 points
**Dependencies**: AZ-326_c12_cli_app, AZ-319_c11_tile_uploader (post batch 44 gate removal), AZ-272_fdr_record_schema, AZ-292_c13_flight_header_footer, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c12_operator_orchestrator (epic AZ-253 / E-C12)
**Tracker**: AZ-329
**Epic**: AZ-253 (E-C12)
### Document Dependencies
- `_docs/02_document/contracts/c11_tilemanager/tile_uploader.md` v2.0.0 — consumed: `upload_pending_tiles(UploadRequest) -> UploadBatchReport` API (post batch 44 — no `FlightStateSignal` parameter, no `confirm_flight_state` method).
- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — consumed: `parse(buf: bytes) -> FdrRecord` + the `flight_footer` kind shape (`flight_id`, `flight_ended_at_iso`, `clean_shutdown`, and the four AC-NEW-3 counters).
- `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 2 (`trigger_post_landing_upload` interface, `FlightStateNotConfirmedError`).
- `_docs/02_document/components/13_c12_operator_orchestrator/tests.md` — C12-IT-03 specifies the `flight_footer`-based check.
- `_docs/02_document/components/14_c13_fdr/description.md` — § 1 segment file layout (informational) + § 2 `FlightFooter` shape (authoritative producer).
## Problem
Without a real `PostLandingUploadOrchestrator`:
- F10 has no head — operators cannot trigger post-landing tile upload; AC-8.4 collapses; C6's pending-upload journal grows unboundedly across flights.
- The operator-side gate (the *only* remaining gate after batch 44's removal of C11's internal `FlightStateGate`) does not exist — operators can manually invoke `C11.TileUploader.upload_pending_tiles(UploadRequest(...))` directly, defeating the AC-NEW-7 / AC-8.4 architectural intent that mid-flight tiles only upload after a clean landing.
- C12-IT-03 (`trigger_post_landing_upload` requires a `flight_footer` with `clean_shutdown=True`) has no implementation.
- `FlightStateNotConfirmedError` is concept-only in description.md § 5 with no producer.
- The CLI's `upload-pending` subcommand has nothing to delegate to.
- A truncated FDR (no footer; the aircraft crashed or lost power) would silently pass through to C11 if there were no operator-side gate.
This task delivers the operator-side gate. It does NOT own the actual upload (AZ-319), the FDR record schema (AZ-272), the FDR write side / footer producer (AZ-291..296, AZ-292) — it composes them.
## Outcome
- A `PostLandingUploadOrchestrator` class at `src/gps_denied_onboard/components/c12_operator_orchestrator/post_landing_upload.py`:
- Constructor: `__init__(self, *, tile_uploader: TileUploaderCut, fdr_footer_reader: FdrFooterReader, logger: Logger, config: C12PostLandingConfig)`.
- `C12PostLandingConfig` (`@dataclass(frozen=True)`): `fdr_root: Path`.
- Public method: `trigger_post_landing_upload(request: PostLandingUploadRequest) -> UploadBatchReport`.
- DTOs at `src/gps_denied_onboard/components/c12_operator_orchestrator/_types.py`:
- `PostLandingUploadRequest` (`@dataclass(frozen=True)`): `flight_id: UUID`, `satellite_provider_url: str`, `api_key: str`, `batch_size: int = 50`. The `api_key` field is plain `str` for consistency with `BuildCacheRequest`; redaction is a runtime guarantee enforced by AC-8 and the CLI's `_emit_invoked` redaction (matching the AZ-328 pattern).
- `UploadBatchReportCut` — local consumer-side AZ-507 Protocol mirroring C11's `UploadBatchReport` shape (no import from c11). Used only as the return-type annotation for `TileUploaderCut.upload_pending_tiles`.
- `TileUploaderCut` Protocol at `src/gps_denied_onboard/components/c12_operator_orchestrator/post_landing_upload.py` (or a sibling `_cuts.py`): `def upload_pending_tiles(self, request: UploadRequestCut) -> UploadBatchReportCut: ...`. `UploadRequestCut` mirrors C11's `UploadRequest(batch_size, satellite_provider_url, flight_id)`. This is the AZ-507 consumer-side cut; the composition root binds a real `HttpTileUploader` here, and the structural typing prevents a direct c11 import from c12.
- Errors at `src/gps_denied_onboard/components/c12_operator_orchestrator/errors.py`:
- `FlightStateNotConfirmedError(Exception)`: attributes `flight_id: str`, `not_confirmed_reason: Literal["flight_id_not_found", "footer_missing", "unclean_shutdown", "fdr_unreadable"]`, `detail: str` (for `unclean_shutdown` carries the four AC-NEW-3 counters; for `fdr_unreadable` carries the inner exception `repr`; empty string otherwise), `remediation: str` (per-reason hint).
- An `FdrFooterReader` Protocol + `LocalFdrFooterReader` concrete at `src/gps_denied_onboard/components/c12_operator_orchestrator/fdr_footer_reader.py`:
- `Protocol`: `read_footer(flight_id: UUID) -> FlightFooterRecord | None` — returns the `flight_footer` record's payload (as a typed `FlightFooterRecord` dataclass owned by this module — NOT C13's `FlightFooter` — preserving the c12↔c13 cut), or `None` if no footer record is found across any segment.
- `LocalFdrFooterReader.read_footer(flight_id)` — opens `<fdr_root>/<flight_id>/segment-NNNN.fdr` files (the C13 naming convention: hyphen separator, 4-digit zero-padded index — see `c13_fdr/writer.py::_segment_path`) in DESCENDING numerical order (newest first), streams length-prefixed `FdrRecord` blobs via `FdrRecord.parse(...)` (each frame is `uint32 LE length` + JSON body — see `c13_fdr/writer.py::_LENGTH_PREFIX`), returns the first one whose `kind == "flight_footer"`, or `None` if none found. Each segment is read with a buffered file iterator — NEVER fully `read()`-ed into memory.
- On any I/O or parse error → raises `FdrUnreadableError(reason: str)` (a sibling helper exception caught by the orchestrator and rewrapped as `FlightStateNotConfirmedError("fdr_unreadable: ...")`).
- `FlightFooterRecord` (`@dataclass(frozen=True)`) at `_types.py`: `flight_id: UUID`, `flight_ended_at_iso: str`, `records_written: int`, `records_dropped_overrun: int`, `bytes_written: int`, `rollover_count: int`, `clean_shutdown: bool`. Built from `FdrRecord.payload` inside `LocalFdrFooterReader`; the orchestrator only reads `clean_shutdown` + the four counters (for `unclean_shutdown` log/error detail).
- Method flow for `trigger_post_landing_upload`:
1. `flight_dir = config.fdr_root / str(request.flight_id)`. If `not flight_dir.exists()` → raise `FlightStateNotConfirmedError(flight_id=str(request.flight_id), not_confirmed_reason="flight_id_not_found", detail="", remediation="Verify <fdr_root>/<flight_id>/ exists; check `config.c12_operator_orchestrator.fdr_root`.")`. ERROR log `kind="c12.upload.refused.flight_id_not_found"`.
2. `footer = fdr_footer_reader.read_footer(request.flight_id)`. Catch `FdrUnreadableError` → raise `FlightStateNotConfirmedError(flight_id, "fdr_unreadable", detail=f"{e!r}", remediation="Inspect FDR segment files manually; the parser failed mid-stream.")`. ERROR log `kind="c12.upload.refused.fdr_unreadable"`.
3. If `footer is None` → raise `FlightStateNotConfirmedError(flight_id, "footer_missing", detail="", remediation="No flight_footer record found in any segment — the flight likely terminated abnormally (power loss, crash, or close_flight() never ran). Inspect FDR manually; upload requires a clean shutdown.")`. ERROR log `kind="c12.upload.refused.footer_missing"`.
4. If `footer.clean_shutdown is False` → raise `FlightStateNotConfirmedError(flight_id, "unclean_shutdown", detail=f"records_dropped_overrun={footer.records_dropped_overrun}, bytes_written={footer.bytes_written}", remediation="The flight footer reports an unclean shutdown. Operator must manually verify the flight outcome before authorising tile upload.")`. ERROR log `kind="c12.upload.refused.unclean_shutdown"` with the four counters in `kv`.
5. INFO log `kind="c12.upload.confirmed_clean_shutdown"` with `flight_id`, `flight_ended_at_iso`, `records_written`.
6. `inner_request = UploadRequestCut(batch_size=request.batch_size, satellite_provider_url=request.satellite_provider_url, flight_id=request.flight_id)`. `api_key` is not passed to C11 — C11 picks up the satellite-provider auth from its own configuration (per the AZ-319 contract); `api_key` here is for forward-compat with the F10 operator workflow that may sign the upload command itself.
7. `report = tile_uploader.upload_pending_tiles(inner_request)`. Any exception from C11 propagates unchanged.
8. INFO log `kind="c12.upload.complete"` with `outcome=report.outcome`, `tiles_acked=count(SUCCESS)`, `tiles_rejected=count(REJECTED)`, `batch_uuid=str(report.batch_uuid)`, `public_key_fingerprint=report.public_key_fingerprint`.
9. Return `report` unchanged.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
- `build_post_landing_upload_orchestrator(config: C12Config, *, tile_uploader: TileUploaderCut) -> PostLandingUploadOrchestrator` — constructs `LocalFdrFooterReader(config.post_landing.fdr_root)` + the orchestrator.
- Extends `OperatorOrchestratorServices` dataclass with `post_landing_upload_orchestrator: PostLandingUploadOrchestrator | None = None`.
- `build_operator_orchestrator(...)` aggregator: when a `tile_uploader` is passed in, build and wire the orchestrator; otherwise leave the field `None`.
- `cli.py` `upload-pending` subcommand resolves `services.post_landing_upload_orchestrator` and calls `.trigger_post_landing_upload(...)`. Maps `FlightStateNotConfirmedError → exit 30` (already defined as `EXIT_FLIGHT_STATE_NOT_CONFIRMED`); any other exception → exit 1.
- `__init__.py` re-exports `PostLandingUploadOrchestrator`, `PostLandingUploadRequest`, `FlightStateNotConfirmedError`, `FdrFooterReader`, `LocalFdrFooterReader`, `C12PostLandingConfig`.
## Scope
### Included
- `PostLandingUploadOrchestrator` class with the single public method.
- `PostLandingUploadRequest` DTO (with `SecretStr` `api_key`).
- `FlightFooterRecord` DTO (local c12-owned mirror of C13's footer payload).
- `FlightStateNotConfirmedError` with the four `not_confirmed_reason` values + per-reason `detail` + `remediation`.
- `FdrFooterReader` Protocol.
- `LocalFdrFooterReader` concrete reading on-disk FDR segments newest-first.
- `FdrUnreadableError` helper exception (caught and rewrapped at the orchestrator boundary).
- `TileUploaderCut` + `UploadRequestCut` + `UploadBatchReportCut` AZ-507 consumer-side cuts (no direct c11 import from c12 source).
- Composition-root factory `build_post_landing_upload_orchestrator(...)` + `OperatorOrchestratorServices.post_landing_upload_orchestrator` field.
- Wiring of the `upload-pending` CLI subcommand.
- Conformance unit tests using a fake `FdrFooterReader` returning scripted footer records for AC-1..AC-8.
- Two integration tests using real FDR fixture files generated via the C13 `FileFdrWriter` (AC-9 clean shutdown, AC-10 unclean shutdown).
### Excluded
- The actual upload HTTP machinery (AZ-319 / C11).
- The FDR record schema or serialiser (AZ-272).
- The FDR write side / segment rotation / `flight_footer` producer (AZ-291..296, AZ-292).
- Any 30-second / contiguous-ON_GROUND threshold logic (REMOVED in batch 44 — the footer is the on-ground signal).
- Reading `state.tick` / `flight_state.tick` payloads (REMOVED in batch 44 — the footer's existence + `clean_shutdown` flag is the sole signal).
- A "force-upload" override — explicitly NOT supported.
- Cross-flight aggregation — one `flight_id` per call.
## Acceptance Criteria
**AC-1: `flight_footer` with `clean_shutdown=True` → upload invoked**
Given a fake `FdrFooterReader` returning `FlightFooterRecord(clean_shutdown=True, records_written=12345, ...)`
When `trigger_post_landing_upload(request)` is called
Then `tile_uploader.upload_pending_tiles` is called exactly once with `UploadRequestCut(flight_id=request.flight_id, batch_size=request.batch_size, satellite_provider_url=request.satellite_provider_url)`; the returned `UploadBatchReport` is the one C11 produced; ONE INFO log `kind="c12.upload.confirmed_clean_shutdown"`; ONE INFO log `kind="c12.upload.complete"`
**AC-2: `flight_footer` absent → `FlightStateNotConfirmedError("footer_missing")`**
Given a fake `FdrFooterReader` returning `None` (no footer record found across any segment)
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="footer_missing", detail="", remediation contains "No flight_footer record found")` is raised; `tile_uploader.upload_pending_tiles` is NEVER called; ONE ERROR log `kind="c12.upload.refused.footer_missing"`
**AC-3: `flight_footer` with `clean_shutdown=False` → `FlightStateNotConfirmedError("unclean_shutdown")`**
Given a fake `FdrFooterReader` returning `FlightFooterRecord(clean_shutdown=False, records_dropped_overrun=42, bytes_written=987654, ...)`
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="unclean_shutdown", detail contains "records_dropped_overrun=42")` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.unclean_shutdown"` containing all four AC-NEW-3 counters in `kv`
**AC-4: `<fdr_root>/<flight_id>/` does not exist → `FlightStateNotConfirmedError("flight_id_not_found")`**
Given `config.post_landing.fdr_root / str(request.flight_id)` does not exist
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="flight_id_not_found")` is raised; the `FdrFooterReader` is NOT called; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.flight_id_not_found"`
**AC-5: FDR unreadable → `FlightStateNotConfirmedError("fdr_unreadable")`**
Given the FDR segments exist but `LocalFdrFooterReader.read_footer` raises `FdrUnreadableError("OSError('input/output error')")` mid-stream
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="fdr_unreadable", detail matches r".*OSError.*")` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.fdr_unreadable"` including the inner repr
**AC-6: Newest-segment-first short-circuit**
Given the FDR for `<flight_id>` has three segments (`segment-0000.fdr`, `segment-0001.fdr`, `segment-0002.fdr`) and the `flight_footer` record is in `segment-0002.fdr` (the most recent)
When `LocalFdrFooterReader.read_footer(flight_id)` is called
Then the reader opens `segment-0002.fdr` FIRST, finds the footer, and never opens `segment-0001.fdr` or `segment-0000.fdr` (assert via a spy on `open(...)` or a custom segment-iteration hook); the call returns in well under 100 ms even when the older segments are >100 MB each
**AC-7: Returns C11's `UploadBatchReport` unchanged**
Given a successful upload returning a `UploadBatchReport` with specific `batch_uuid`, `per_tile_status`, `outcome`, `public_key_fingerprint` values
When the caller inspects the return value of `trigger_post_landing_upload`
Then it is the same object (passthrough) returned by `tile_uploader.upload_pending_tiles`; no field is mutated, added, removed, or renamed
**AC-8: `api_key` is REDACTED in every log line**
Given `PostLandingUploadRequest(api_key="super-secret-token-123", ...)` and an end-to-end run through every refusal mode + the success path
When the log records are inspected (via `caplog` capture)
Then NO log record's `msg`, `kv`, `extra`, or any string field contains the substring `"super-secret-token-123"`; the CLI's `_emit_invoked` writes `"api_key": "REDACTED"` (matching the AZ-328 `BuildCacheRequest` pattern); the orchestrator never includes `api_key` in any log payload
**AC-9: Real FDR fixture C12-IT-03(a) (clean-shutdown footer) → upload invoked**
Given an FDR fixture written by the C13 `FileFdrWriter`'s `close_flight()` path (which always sets `clean_shutdown=True` in the current AZ-292 implementation) at `tests/fixtures/c12_operator_orchestrator/fdr/clean_shutdown/<flight_id>/segment-NNNN.fdr`
When `trigger_post_landing_upload(PostLandingUploadRequest(flight_id=<fixture_flight_id>, ...))` is called against a `LocalFdrFooterReader` over the fixture and a fake `TileUploaderCut` that records the call
Then the upload is invoked exactly once with `flight_id=<fixture_flight_id>`; the fake's recorded `UploadBatchReport` is returned unchanged
**AC-10: Real FDR fixture C12-IT-03(b) (no-footer truncation) → refused**
Given an FDR fixture WITHOUT a `flight_footer` record (simulate truncation by writing segments via the writer thread and forcibly terminating before `close_flight()` runs — i.e. drop the last segment after the writer's `close_flight()` would have appended the footer record)
When `trigger_post_landing_upload(...)` is called against a `LocalFdrFooterReader` over this fixture
Then `FlightStateNotConfirmedError(not_confirmed_reason="footer_missing")` is raised; the upload is NOT invoked
## Non-Functional Requirements
**Performance**
- `LocalFdrFooterReader.read_footer(flight_id)` completes in ≤ 1 s wall-clock on a developer laptop with NVMe even when the flight's FDR is 64 GB across many segments — the newest-segment-first short-circuit means a clean-shutdown flight reads only the tail of the last segment.
- Memory peak ≤ 50 MB even with multi-GB segments — `LocalFdrFooterReader` is a streaming reader: opens one segment at a time, reads length-prefixed blobs in a bounded buffer, releases the file handle before opening the next.
**Compatibility**
- AZ-272's `FdrRecord.parse` API is the only parser path; this task does NOT re-implement record parsing.
- C13's `flight_footer` record kind + payload shape (AZ-292) is consumed via the schema in `KNOWN_PAYLOAD_KEYS`; this task does NOT redefine the payload keys.
- `C12.PostLandingUploadOrchestrator` does NOT import from `c11_tile_manager`; the AZ-507 consumer-side cuts (`TileUploaderCut`, `UploadRequestCut`, `UploadBatchReportCut`) are the only contract.
**Reliability**
- Catches and rewraps the four refusal modes deterministically — operators can script against the four documented `not_confirmed_reason` values (`flight_id_not_found`, `footer_missing`, `unclean_shutdown`, `fdr_unreadable`) which form a closed `Literal` type.
- Streaming I/O on FDR segments — multi-GB segments do not blow memory.
- No background threads, no global state, no caching — every call re-reads the FDR.
- `api_key` is `SecretStr` — the type system prevents accidental string concatenation into log messages.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | Fake reader returns `clean_shutdown=True` | Uploader called once, INFO logs, returns `UploadBatchReport` |
| AC-2 | Fake reader returns `None` | `FlightStateNotConfirmedError("footer_missing")` |
| AC-3 | Fake reader returns `clean_shutdown=False` | `FlightStateNotConfirmedError("unclean_shutdown")` with counters in `detail` + log `kv` |
| AC-4 | `<fdr_root>/<flight_id>/` missing | `FlightStateNotConfirmedError("flight_id_not_found")` |
| AC-5 | Fake reader raises `FdrUnreadableError("OSError(...)")` | `FlightStateNotConfirmedError("fdr_unreadable")` w/ inner repr |
| AC-6 | Three-segment fixture, footer in newest | `LocalFdrFooterReader` opens only the newest segment |
| AC-7 | Success path; inspect return | Same `UploadBatchReport` instance |
| AC-8 | `caplog` capture across every code path | `api_key.get_secret_value()` never appears in any log |
| AC-9 | C12-IT-03(a) fixture (writer-produced clean footer) | Upload invoked |
| AC-10 | C12-IT-03(b) fixture (truncated; no footer) | `FlightStateNotConfirmedError("footer_missing")` |
| NFR-perf-streaming | Microbench `LocalFdrFooterReader` over a 1 GB synthetic segment with footer at the end | Memory peak ≤ 50 MB; wall-clock ≤ 1 s |
## Constraints
- The four `not_confirmed_reason` values form a closed `Literal["flight_id_not_found", "footer_missing", "unclean_shutdown", "fdr_unreadable"]` type — adding a new value requires Plan-cycle approval (operators script against these values).
- A "force-upload" override is explicitly NOT supported — operators who legitimately need to upload after a non-conforming flight must use a separate forensic path (out of scope this cycle).
- `LocalFdrFooterReader` MUST stream and MUST iterate segments newest-first; loading any segment fully into memory is a NFR violation, and iterating oldest-first defeats AC-6's short-circuit.
- C13's `flight_footer` kind + payload schema (`KNOWN_PAYLOAD_KEYS["flight_footer"]`) is the source of truth — this task does NOT duplicate the schema; the local `FlightFooterRecord` dataclass extracts only the fields the orchestrator inspects.
- `api_key` is plain `str` (matching `BuildCacheRequest.api_key`); redaction is a runtime guarantee enforced by AC-8 (caught by `caplog` substring assertion). The CLI's `_emit_invoked` writes `"REDACTED"` and the orchestrator never includes `api_key` in any log payload.
- C12 does NOT import C11 directly — the AZ-507 consumer-side cuts pattern is enforced (the linter / import-cycle check should fail if `c12_operator_orchestrator/*.py` adds `from gps_denied_onboard.components.c11_tile_manager import ...`).
- The orchestrator does NOT consult any `state.tick` / `flight_state.tick` payloads — those are out of scope post batch 44.
## Risks & Mitigation
**Risk 1: C13 writes the footer to a segment that's not the most recent on disk**
- *Risk*: If `close_flight()` triggers a rollover concurrently, the footer might land in `segment_NNN+1.fdr` while older `segment_NNN.fdr` files are still on disk. The reader must still iterate newest-first by integer segment index, not by mtime, to correctly find the footer.
- *Mitigation*: `LocalFdrFooterReader` sorts segments by the integer `NNN` in `segment_<NNN>.fdr` (descending), not by filesystem mtime. AC-6 covers the multi-segment case directly. Document the segment-naming dependency on `_docs/02_document/components/14_c13_fdr/description.md` § 1.
**Risk 2: A future cycle introduces additional record kinds at the tail (e.g. `flight_audit`)**
- *Risk*: A new tail record kind could push the `flight_footer` deeper into the segment, increasing read latency. Currently the footer is the LAST record before file close, but the contract doesn't forbid later additions.
- *Mitigation*: The streaming reader scans the entire newest segment if needed; AC-6 only asserts "doesn't open older segments", not "reads only the last N bytes". A future cycle that adds tail records would still satisfy AC-6.
**Risk 3: The footer's `flight_id` UUID doesn't match the directory name**
- *Risk*: An operator could rename the flight directory; the reader would still find a footer but its `flight_id` would mismatch.
- *Mitigation*: `LocalFdrFooterReader.read_footer(flight_id)` asserts `footer.flight_id == flight_id` and treats a mismatch as `FdrUnreadableError(f"footer flight_id mismatch: footer={footer.flight_id}, requested={flight_id}")`. The orchestrator rewraps as `FlightStateNotConfirmedError("fdr_unreadable")`.
**Risk 4: A future cycle changes the `clean_shutdown` flag semantics**
- *Risk*: AZ-292 currently hardcodes `clean_shutdown=True` in `close_flight()`; a future cycle might emit `False` for graceful shutdowns that nonetheless lost some records.
- *Mitigation*: AC-3 already covers `clean_shutdown=False` → refused. The orchestrator does NOT interpret the four counters — operators do. If a future cycle wants to allow upload despite `clean_shutdown=False` under certain counter thresholds, that's a Plan-cycle change to this task.
**Risk 5: Symlinks under `<fdr_root>/<flight_id>/`**
- *Risk*: An operator could symlink to a different flight's segments; the reader would still find a footer but it would belong to a different flight.
- *Mitigation*: Same as Risk 3 — the `flight_id` assertion catches it. Document that `<fdr_root>` is operator-trusted territory; symlink escape is out of scope.
## Runtime Completeness
- **Named capability**: post-flight clean-shutdown-gated upload trigger per description.md § 2 (`trigger_post_landing_upload`) + AC-8.4 + C12-IT-03.
- **Production code that must exist**: real `PostLandingUploadOrchestrator` consuming a real `HttpTileUploader` (AZ-319) via the `TileUploaderCut` Protocol + real `LocalFdrFooterReader` reading real on-disk FDR segments + real `FdrRecord.parse` (AZ-272).
- **Allowed external stubs**: tests MAY use fakes for `FdrFooterReader` and `TileUploaderCut`; the C12-IT-03 integration tests use real FDR fixture files (produced by C13's `FileFdrWriter`) + a fake `TileUploaderCut` that records the call (no real network).
- **Unacceptable substitutes**: in-memory FDR (defeats the streaming guarantee NFR); a "force-upload" override (defeats the gate); shelling out to `cat <fdr>` instead of using `FdrRecord.parse` (no schema validation, no forward-compat); reading the FDR via the producer-side ring buffer (wrong API; ring buffer is for live producers, not post-flight reads); importing `c11_tile_manager` directly from c12 source (violates AZ-507 consumer-side cuts).
@@ -0,0 +1,205 @@
# 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.