[AZ-329] [AZ-330] [AZ-523] [AZ-524] Batch 44 atomic refactor

Implements two new C12 services and rebalances the C11/C12 boundary
in one atomic commit:

* AZ-329 PostLandingUploadOrchestrator — gates C11 upload on the
  `flight_footer` FDR record's `clean_shutdown` field; 4 refusal
  modes; new FdrFooterReader Protocol + LocalFdrFooterReader.
* AZ-330 OperatorReLocService — AC-3.4 visual-loss re-localization
  hint; reuses shared LatLonAlt; OperatorCommandTransport Protocol
  cut (E-C8 owns the future pymavlink concrete); new FDR record
  kind `c12.reloc.requested`; log redaction (lat/lon 5 decimals,
  reason 200 chars).
* AZ-523 C11 internal flight-state gate removed (SRP refactor):
  `confirm_flight_state` / `FlightStateSignal` use /
  `FlightStateNotOnGroundError` deleted from C11; TileUploader
  contract bumped to v2.0.0 (frozen) with migration note; AZ-317
  superseded.
* AZ-524 Package rename `c12_operator_tooling` →
  `c12_operator_orchestrator` across source, tests, pyproject,
  CMake, Dockerfile, compose, CI, runtime-root services class
  (`OperatorOrchestratorServices`) + factory function
  (`build_operator_orchestrator`), logger namespaces, config slug,
  docs, and the E-C12 epic title.

Tests: 1543 passed, 80 skipped (all environment gates). Targeted
AC suite (AZ-329 + AZ-330 + FdrFooterReader): 37 passed. Cold-start
NFR-perf still ≤ 500 ms p99.

Tracker: AZ-317 → Done (superseded); AZ-319 v2.0.0 contract bump
comment; AZ-329/AZ-330 → In Testing; AZ-253 epic renamed; AZ-523
+ AZ-524 created and closed as audit-trail tickets.

See `_docs/03_implementation/batch_44_cycle1_report.md`.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 19:42:46 +03:00
parent 2d88d3d674
commit 5fe67023b2
112 changed files with 3409 additions and 1311 deletions
+41 -9
View File
@@ -1,8 +1,8 @@
# Dependencies Table
**Date**: 2026-05-13 (refreshed after AZ-507 + AZ-508 hygiene-PBI onboarding from cumulative review batches 31-33; previously 2026-05-11 for AZ-489 + AZ-490 ADR-010 operator-origin path)
**Total Tasks**: 144 (103 product + 41 blackbox-test)
**Total Complexity Points**: 482 (349 product + 133 blackbox-test)
**Date**: 2026-05-13 (refreshed after Batch 44 SRP refactor: AZ-317 superseded; AZ-329 + AZ-330 specs rewritten; AZ-523 + AZ-524 audit-trail tickets added; E-C12 epic renamed `Operator Pre-flight Tooling``Operator Pre-flight Orchestrator`; earlier same-day refresh: AZ-507 + AZ-508 hygiene PBIs from cumulative review batches 31-33; 2026-05-11: AZ-489 + AZ-490 ADR-010 operator-origin path)
**Total Tasks**: 146 (105 product + 41 blackbox-test) — AZ-317 retained in the table marked SUPERSEDED for audit; AZ-523 (C11 gate removal) + AZ-524 (C12 rename) added as 2 closed audit-trail tasks
**Total Complexity Points**: 487 (354 product + 133 blackbox-test) — AZ-523 = 3pt, AZ-524 = 2pt
Dependencies columns list only the tracker-ID portion (descriptive tail
text in each task spec is omitted here for table-readability). The
@@ -52,9 +52,9 @@ are all declared and documented below under **Cycle Check**.
| AZ-307 | C6 Freshness Gate | 2 | AZ-303, AZ-304, AZ-305, AZ-263, AZ-269, AZ-266, AZ-273 | AZ-250 |
| AZ-308 | C6 Cache Budget Eviction | 3 | AZ-303, AZ-305, AZ-263, AZ-269, AZ-266, AZ-273 | AZ-250 |
| AZ-316 | C11 TileDownloader | 5 | AZ-263, AZ-269, AZ-266, AZ-303, AZ-305, AZ-307, AZ-308 | AZ-251 |
| AZ-317 | C11 Flight-State Gate | 2 | AZ-263, AZ-269, AZ-266 | AZ-251 |
| AZ-317 | C11 Flight-State Gate (SUPERSEDED by Batch 44 / AZ-523; gate moved to C12 AZ-329) | 2 | AZ-263, AZ-269, AZ-266 | AZ-251 |
| AZ-318 | C11 Per-Flight Signing Key | 3 | AZ-263, AZ-269, AZ-266, AZ-273 | AZ-251 |
| AZ-319 | C11 TileUploader | 5 | AZ-263, AZ-269, AZ-266, AZ-273, AZ-303, AZ-305, AZ-317, AZ-318 | AZ-251 |
| AZ-319 | C11 TileUploader (contract v2.0.0 — internal flight-state gate removed in Batch 44) | 5 | AZ-263, AZ-269, AZ-266, AZ-273, AZ-303, AZ-305, AZ-318 | AZ-251 |
| AZ-320 | C11 Idempotent Retry Decorator | 3 | AZ-263, AZ-269, AZ-266, AZ-273, AZ-303, AZ-319 | AZ-251 |
| AZ-321 | C10 Engine Compiler | 5 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281, AZ-298 | AZ-252 |
| AZ-322 | C10 Descriptor Batcher | 3 | AZ-263, AZ-269, AZ-266, AZ-303, AZ-306, AZ-321 | AZ-252 |
@@ -64,8 +64,8 @@ are all declared and documented below under **Cycle Check**.
| AZ-326 | C12 CLI App | 3 | AZ-263, AZ-269, AZ-266, AZ-489 | AZ-253 |
| AZ-327 | C12 Companion Bringup | 3 | AZ-263, AZ-269, AZ-266 | AZ-253 |
| AZ-328 | C12 Build-Cache Orchestrator | 5 | AZ-326, AZ-327, AZ-316, AZ-325, AZ-489, AZ-263, AZ-269, AZ-266 | AZ-253 |
| AZ-329 | C12 Post-Landing Upload | 3 | AZ-326, AZ-319, AZ-272, AZ-263, AZ-269, AZ-266 | AZ-253 |
| AZ-330 | C12 OperatorReLocService | 3 | AZ-326, AZ-273, AZ-263, AZ-269, AZ-266 | AZ-253 |
| AZ-329 | C12 PostLandingUploadOrchestrator (flight_footer FDR gate; Batch 44 design pivot) | 3 | AZ-326, AZ-319, AZ-272, AZ-273, AZ-292, AZ-263, AZ-269, AZ-266 | AZ-253 |
| AZ-330 | C12 OperatorReLocService | 3 | AZ-326, AZ-273, AZ-272, AZ-263, AZ-269, AZ-266 | AZ-253 |
| AZ-331 | C1 VioStrategy Protocol | 3 | AZ-263, AZ-269, AZ-266, AZ-270, AZ-272, AZ-276, AZ-277 | AZ-254 |
| AZ-332 | C1 OKVIS2 Strategy | 5 | AZ-331, AZ-263, AZ-269, AZ-266, AZ-276, AZ-277, AZ-272, AZ-273 | AZ-254 |
| AZ-333 | C1 VINS-Mono Strategy | 5 | AZ-331, AZ-263, AZ-269, AZ-266, AZ-276, AZ-277, AZ-272, AZ-273 | AZ-254 |
@@ -158,6 +158,8 @@ are all declared and documented below under **Cycle Check**.
| AZ-490 | C5 set_takeoff_origin entrypoint — accept operator origin from C10 Manifest | 3 | AZ-263, AZ-269, AZ-266, AZ-272, AZ-273, AZ-279, AZ-381, AZ-383, AZ-384, AZ-385, AZ-386 | AZ-260 |
| AZ-507 | Hygiene — align module-layout.md cross-component import rules with AZ-270 lint | 2 | AZ-263, AZ-270, AZ-321 | AZ-246 |
| AZ-508 | Hygiene — consolidate `_iso_ts_now` helpers into `helpers/iso_timestamps.py` | 2 | AZ-263 | AZ-264 |
| AZ-523 | Batch 44 — C11 internal flight-state gate removal (SRP refactor; audit-trail; closed) | 3 | AZ-317, AZ-319, AZ-329 | AZ-251 |
| AZ-524 | Batch 44 — C12 package rename: c12_operator_tooling → c12_operator_orchestrator (audit; closed)| 2 | AZ-263, AZ-326, AZ-327, AZ-328, AZ-329, AZ-330, AZ-489 | AZ-253 |
## Notes
@@ -213,6 +215,36 @@ are all declared and documented below under **Cycle Check**.
- **All E-BBT tasks depend on AZ-406 (test infrastructure)**; this is
by design — AZ-406 is the foundation every blackbox test depends on
(analogous to AZ-263 for the product side).
- **Batch 44 SRP refactor + C12 rename** (added 2026-05-13):
- **AZ-317 (C11 Flight-State Gate)** is **superseded**. The
C11-internal gate (`confirm_flight_state` /
`FlightStateSignal` / `FlightStateNotOnGroundError`) was removed
in Batch 44 Phase B; the post-landing safety responsibility
moved to C12's new `PostLandingUploadOrchestrator` (AZ-329).
The row is retained in the table for audit; the ticket is in
`_docs/02_tasks/done/` with a SUPERSEDED banner.
- **AZ-319 (C11 TileUploader)** lost its dependency on AZ-317
(gate removed) and the `TileUploader` Protocol contract was
bumped to **v2.0.0 (frozen)** with the gate parameters removed.
Migration note in
`_docs/02_document/contracts/c11_tilemanager/tile_uploader.md`.
- **AZ-329 (C12 PostLandingUploadOrchestrator)** specification
was rewritten in Phase C to gate on the `flight_footer` FDR
record's `clean_shutdown` field instead of counting consecutive
`FlightStateSignal` records. Added explicit dependency on
AZ-292 (C13 footer write) since the orchestrator reads the
footer record produced there.
- **AZ-330 (C12 OperatorReLocService)** added an explicit
dependency on AZ-272 (FDR schema) since the service emits a
new `c12.reloc.requested` FDR record kind.
- **AZ-523 (C11 gate removal audit-trail)** and **AZ-524 (C12
package rename audit-trail)** are post-hoc tickets closed
on creation. Their dependencies (AZ-317/319/329 for AZ-523;
the C12 task set for AZ-524) are listed for traceability;
these tickets are not gates on any future work.
- **E-C12 epic (AZ-253) summary renamed**:
`C12 Operator Pre-flight Tooling`
`C12 Operator Pre-flight Orchestrator`.
- **Hygiene PBIs from cumulative review batches 31-33** (added
2026-05-13):
- **AZ-507** (E-CC-CONF / AZ-246) — module-layout.md ↔ AZ-270 lint
@@ -240,8 +272,8 @@ are all declared and documented below under **Cycle Check**.
- C7 `InferenceRuntime` → AZ-297 (Protocol) + AZ-298/299/300/301/302
- C8 `FcAdapter` / `GcsAdapter` → AZ-390 (Protocols) + AZ-391..AZ-397
- C10 Provisioning → AZ-321/322/323/324/325
- C11 Tile Manager → AZ-316/317/318/319/320
- C12 Operator Tooling → AZ-326/327/328/329/330 + AZ-489 (FlightsApiClient)
- C11 Tile Manager → AZ-316/318/319/320 + AZ-523 (Batch 44 gate-removal audit; AZ-317 superseded)
- C12 Operator Pre-flight Orchestrator → AZ-326/327/328/329/330 + AZ-489 (FlightsApiClient) + AZ-524 (Batch 44 rename audit)
- C13 FDR Writer → AZ-291..AZ-296
- **Cross-cutting product modules**:
@@ -95,7 +95,7 @@ gps-denied-onboard/
│ ├── c8_fc_adapter/ # AZ-261: FcAdapter (PymavlinkArdupilotAdapter + Msp2InavAdapter) + GcsAdapter
│ ├── c10_provisioning/ # AZ-252: CacheProvisioner (engine compile + descriptors + manifest + content-hash)
│ ├── c11_tile_manager/ # AZ-251: TileDownloader + TileUploader (operator-side ONLY — excluded from airborne via CMake)
│ ├── c12_operator_tooling/ # AZ-253: CacheBuildWorkflow + OperatorReLocService (CLI; GUI deferred)
│ ├── c12_operator_orchestrator/ # AZ-253: CacheBuildWorkflow + OperatorReLocService (CLI; GUI deferred)
│ └── c13_fdr/ # AZ-248: FdrWriter (writer thread + segment rotation + ≤64 GB cap)
├── cpp/ # Native libraries linked from src/gps_denied_onboard/components/* via pybind11
@@ -193,7 +193,7 @@ Concrete implementations are NOT created here — they are the subject of Step 2
| C8 | `FcAdapter`, `GcsAdapter` | `components/10_c8_fc_adapter/description.md § 2` |
| C10 | `CacheProvisioner` | `components/11_c10_provisioning/description.md § 2` |
| C11 | `TileDownloader`, `TileUploader` | `components/12_c11_tilemanager/description.md § 2` |
| C12 | `CacheBuildWorkflow`, `OperatorReLocService` | `components/13_c12_operator_tooling/description.md § 2` |
| C12 | `CacheBuildWorkflow`, `OperatorReLocService` | `components/13_c12_operator_orchestrator/description.md § 2` |
| C13 | `FdrWriter` (consumer side) | `components/14_c13_fdr/description.md § 2` |
## CI/CD Pipeline
@@ -1,5 +1,7 @@
# C11 Flight-State Gate — ON_GROUND Defence-in-Depth for Upload
> **Status (2026-05-13): SUPERSEDED by Batch 44.** This task originally placed an `ON_GROUND` gate inside `HttpTileUploader` (C11). Batch 44's SRP refactor removed that gate — "upload bytes" and "decide when uploading is safe" are different responsibilities. The post-landing safety check now lives in C12's `PostLandingUploadOrchestrator` (AZ-329), which inspects the C13 `flight_footer` FDR record and refuses to invoke `TileUploader.upload_pending_tiles` unless `clean_shutdown=True` is recorded. The `FlightStateGate`, `FlightStateSource` Protocol, and `FlightStateNotOnGroundError` have been deleted from C11; this spec is kept here as historical record. See `_docs/03_implementation/batch_44_implementation_plan.md` Phase B for the deletion details.
**Task**: AZ-317_c11_flight_state_gate
**Name**: C11 Flight-State Gate
**Description**: Implement the `flight_state == ON_GROUND` precondition check that `TileUploader.upload_pending_tiles` calls before any network egress. Defines a thin C11-internal `FlightStateSource` Protocol with one method `current_flight_state() -> FlightStateSignal`; the concrete impl is supplied by E-C8 later (subscribes to the FC adapter's flight-state stream). The gate raises `FlightStateNotOnGroundError` if the current state is anything other than `ON_GROUND` (`IN_FLIGHT`, `UNKNOWN`, `TAKING_OFF`, `LANDING` all block). Logs an ERROR with the observed state and refuses to proceed; this is defence-in-depth atop ADR-004's process-level isolation, NOT the primary control.
+20 -20
View File
@@ -5,13 +5,13 @@
**Description**: Implement the operator-tooling CLI shell that operators run on the workstation. Wires Typer (per the Click/Typer project pin) into `operator_tool/__main__.py`, registers six subcommands (`download`, `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready`, `set-sector`), wires the E-CC-LOG (AZ-266) logger to a workstation-side structured-JSON log file (`~/.azaion/onboard/c12-tooling.log`), and ships the two trivial operator-side helpers from description.md § 2 — `set_sector_classification(area, sector_class)` (persists per-area classification to a local JSON file under the operator workstation's home directory) and `apply_freshness_threshold(sector_class) -> int (months)` (a pure-data lookup that maps the sector classification enum to the AC-NEW-6 months freshness budget). Each subcommand is a thin shell that resolves its service collaborator (`flights_api_client`, `build_cache`, `companion_bringup`, `post_landing_upload`, `operator_reloc_service` — all owned by sibling tasks AZ-489 / AZ-NNN T2..T5) from the composition root and delegates to it; on success returns 0; on a known error type maps to a documented non-zero exit code with a one-line operator-friendly message + remediation hint pulled from the underlying error's `remediation` attribute. The CLI app does NOT own any workflow logic itself — only command registration, argument parsing, logger wiring, exit-code mapping, and the two simple operator helpers. **ADR-010 amendment**: the `build-cache` subcommand accepts a mutually-exclusive pair `--flight-id <Guid> | --flight-file <Path>` and forwards the resolved `FlightDto` (via AZ-489 `FlightsApiClient`) to the orchestrator (AZ-328), which derives the bbox + takeoff origin from it. The legacy `--bbox` flag is dropped because the bbox is now derived; passing it is an error.
**Complexity**: 3 points
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-489_c12_flights_api_client (for the `FlightsApiClient` service collaborator + DTO definitions surfaced via `--flight-id` / `--flight-file`)
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Component**: c12_operator_orchestrator (epic AZ-253 / E-C12)
**Tracker**: AZ-326
**Epic**: AZ-253 (E-C12)
### Document Dependencies
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (`set_sector_classification`, `apply_freshness_threshold` from `CacheBuildWorkflow`), § 5 (logging strategy table), § 7 (CLI-only this cycle, GUI deferred).
- `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 2 (`set_sector_classification`, `apply_freshness_threshold` from `CacheBuildWorkflow`), § 5 (logging strategy table), § 7 (CLI-only this cycle, GUI deferred).
- `_docs/02_document/contracts/shared_logging/log_record_schema.md` — INFO/WARN/ERROR log shapes for operator events.
## Problem
@@ -23,7 +23,7 @@ Without a real CLI shell:
- Sector classification (active-conflict vs stable-rear) per description.md § 1 has no persistent surface; operator restarts lose all classifications.
- Logging from C12 is silent — without the wiring of E-CC-LOG to the workstation-side log file, every operator action is invisible during incident review.
- Sibling tasks T2..T5 have no consumer; their service classes ship but no end-to-end CLI flow exercises them.
- Exit codes are inconsistent across subcommands — operators script `operator-tool` runs and need `$?` to mean something specific per failure category.
- Exit codes are inconsistent across subcommands — operators script `operator-orchestrator` runs and need `$?` to mean something specific per failure category.
This task delivers the CLI shell + the two trivial operator helpers. It does NOT own `build_cache`, `verify_companion_ready`, `trigger_post_landing_upload`, or `OperatorReLocService` — those are sibling tasks invoked through the CLI.
@@ -31,7 +31,7 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
- A Typer-based CLI app at `src/operator_tool/`:
- `src/operator_tool/__main__.py` — module entry point: `from operator_tool.cli import app; app()`.
- `src/operator_tool/cli.py` — Typer `app = typer.Typer(name="operator-tool", help="GPS-denied onboard pre-flight tooling (operator workstation)")`. Registers six subcommands via `@app.command(...)`. Each subcommand opens a logging context, calls into its service collaborator, catches the documented exception family for that command, maps to the documented exit code, and `raise typer.Exit(code=N)`.
- `src/operator_tool/cli.py` — Typer `app = typer.Typer(name="operator-orchestrator", help="GPS-denied onboard pre-flight tooling (operator workstation)")`. Registers six subcommands via `@app.command(...)`. Each subcommand opens a logging context, calls into its service collaborator, catches the documented exception family for that command, maps to the documented exit code, and `raise typer.Exit(code=N)`.
- `src/operator_tool/sector_classification_store.py``SectorClassificationStore` class:
- Constructor: `__init__(self, *, store_path: Path, logger: Logger)`.
- `set_classification(area: AreaIdentifier, sector_class: SectorClassification) -> None` — persists `{area_id: sector_class}` mapping to `store_path` (default: `~/.azaion/onboard/sector-classifications.json`) using atomic write (`tempfile + os.replace`).
@@ -44,7 +44,7 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
- Module-level constant: `FRESHNESS_TABLE: dict[SectorClassification, int]`.
- `src/operator_tool/exit_codes.py` — module-level constants: `EXIT_OK = 0`, `EXIT_GENERIC_ERROR = 1`, `EXIT_USAGE = 2`, `EXIT_COMPANION_UNREACHABLE = 10`, `EXIT_CONTENT_HASH_MISMATCH = 11`, `EXIT_DOWNLOAD_FAILURE = 20`, `EXIT_BUILD_FAILURE = 21`, `EXIT_FLIGHT_STATE_NOT_CONFIRMED = 30`, `EXIT_UPLOAD_FAILURE = 31`, `EXIT_GCS_LINK_ERROR = 40`, `EXIT_LOCK_HELD = 50`, `EXIT_FLIGHTS_API_UNREACHABLE = 60`, `EXIT_FLIGHTS_API_AUTH = 61`, `EXIT_FLIGHT_NOT_FOUND = 62`, `EXIT_FLIGHT_SCHEMA = 63`, `EXIT_EMPTY_WAYPOINTS = 64`. Sibling tasks may extend with documented additions.
- A composition root entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
- `build_operator_tool(config: Config) -> OperatorToolServices` — pure factory that constructs the `SectorClassificationStore` + a logger configured to write to `~/.azaion/onboard/c12-tooling.log`. Returns a frozen dataclass aggregating the operator-tool service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it.
- `build_operator_tool(config: Config) -> OperatorOrchestratorServices` — pure factory that constructs the `SectorClassificationStore` + a logger configured to write to `~/.azaion/onboard/c12-tooling.log`. Returns a frozen dataclass aggregating the operator-orchestrator service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it.
- Subcommand surface (each subcommand body lives in `cli.py`; service implementations live in sibling task files):
- `download` — delegates to `tile_downloader.fetch(...)` (AZ-316). Maps `SatelliteProviderError → EXIT_DOWNLOAD_FAILURE`.
- `build-cache` — accepts a mutually-exclusive pair `--flight-id <Guid> | --flight-file <Path>` (Typer-enforced via a callback that rejects both-set / neither-set with `EXIT_USAGE`), plus `--sector-class`, `--calibration-path`. Delegates to `build_cache_orchestrator.build_cache(...)` (sibling AZ-328) passing the resolved `FlightDto` (the orchestrator computes bbox + takeoff origin from it via AZ-489 helpers). Maps `CacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE` (per `failure_phase`); `BuildLockHeldError → EXIT_LOCK_HELD`; `FlightsApiUnreachableError → EXIT_FLIGHTS_API_UNREACHABLE`; `FlightsApiAuthError → EXIT_FLIGHTS_API_AUTH`; `FlightNotFoundError → EXIT_FLIGHT_NOT_FOUND`; `FlightsApiSchemaError | FlightFileNotFoundError | WaypointSchemaError → EXIT_FLIGHT_SCHEMA`; `EmptyWaypointsError → EXIT_EMPTY_WAYPOINTS`.
@@ -54,7 +54,7 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
- `set-sector` — delegates to `SectorClassificationStore.set_classification(...)`.
- Each subcommand's `--help` includes a one-line summary + the AC IDs it supports (e.g. `build-cache: orchestrate F1 (AC-8.3, AC-NEW-1)`).
- Logging is wired at app startup: a single rotating file handler at `~/.azaion/onboard/c12-tooling.log`, structured JSON formatter from E-CC-LOG (AZ-266). Console (stderr) handler at WARN level for operator visibility.
- `pyproject.toml` registers `operator-tool` as a console script entry point pointing at `operator_tool.__main__:main`. The `main` function in `__main__.py` calls `app()`.
- `pyproject.toml` registers `operator-orchestrator` as a console script entry point pointing at `operator_tool.__main__:main`. The `main` function in `__main__.py` calls `app()`.
## Scope
@@ -82,8 +82,8 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
## Acceptance Criteria
**AC-1: All six subcommands register and appear in `--help`**
Given the `operator-tool` console script is installed
When the operator runs `operator-tool --help`
Given the `operator-orchestrator` console script is installed
When the operator runs `operator-orchestrator --help`
Then the listed subcommands include exactly `download`, `build-cache`, `upload-pending`, `reloc-confirm`, `verify-ready`, `set-sector`; no extras
**AC-2: Successful subcommand exits 0**
@@ -118,12 +118,12 @@ Then a `c12-tooling.log` file exists at `~/.azaion/onboard/`; its lines parse as
**AC-8: Console-script entry point is installed and runnable**
Given the package is installed via `pip install -e .`
When the shell runs `operator-tool --help`
When the shell runs `operator-orchestrator --help`
Then the help text is printed; the exit code is 0; the binary resolves through the entry-point declared in `pyproject.toml`
**AC-9: Subcommand `--help` references the relevant AC IDs**
Given any subcommand
When `operator-tool <subcommand> --help` is run
When `operator-orchestrator <subcommand> --help` is run
Then the help text body includes the AC IDs the subcommand supports (e.g. `build-cache` mentions `AC-8.3, AC-NEW-1`); operators reading `--help` can cross-reference to `acceptance_criteria.md`
**AC-10: `set-sector` is idempotent for the same input**
@@ -133,20 +133,20 @@ Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the
**AC-11: `build-cache --flight-id` happy path delegates to orchestrator with `FlightDto` (ADR-010)**
Given a fake `FlightsApiClient.fetch_flight` returns a 3-waypoint `FlightDto`
When `operator-tool build-cache --flight-id 00000000-0000-0000-0000-000000000001 --sector-class stable_rear --calibration-path /tmp/cal.json` runs
When `operator-orchestrator build-cache --flight-id 00000000-0000-0000-0000-000000000001 --sector-class stable_rear --calibration-path /tmp/cal.json` runs
Then `build_cache_orchestrator.build_cache(...)` is called once with the resolved `FlightDto` (or its `(flight_id, bbox, takeoff_origin)` projection per AZ-328 signature); ZERO calls to `--bbox` legacy parsing
**AC-12: `build-cache --flight-file` happy path uses offline loader**
Given a local JSON file in the documented schema is on disk
When `operator-tool build-cache --flight-file /tmp/flight.json --sector-class stable_rear --calibration-path /tmp/cal.json` runs
When `operator-orchestrator build-cache --flight-file /tmp/flight.json --sector-class stable_rear --calibration-path /tmp/cal.json` runs
Then `FlightsApiClient.load_flight_file(/tmp/flight.json)` is called once; `fetch_flight` is NOT called; the orchestrator receives the same DTO shape
**AC-13: `build-cache` with both `--flight-id` and `--flight-file` errors out**
When `operator-tool build-cache --flight-id 00000000-0000-0000-0000-000000000001 --flight-file /tmp/flight.json ...` runs
When `operator-orchestrator build-cache --flight-id 00000000-0000-0000-0000-000000000001 --flight-file /tmp/flight.json ...` runs
Then exit code is `EXIT_USAGE = 2`; stderr names the conflict; ZERO calls to either client method
**AC-14: `build-cache` with neither `--flight-id` nor `--flight-file` errors out**
When `operator-tool build-cache --sector-class stable_rear --calibration-path /tmp/cal.json` runs (no flight source)
When `operator-orchestrator build-cache --sector-class stable_rear --calibration-path /tmp/cal.json` runs (no flight source)
Then exit code is `EXIT_USAGE = 2`; stderr lists which flag must be supplied
**AC-15: `FlightNotFoundError` maps to `EXIT_FLIGHT_NOT_FOUND`**
@@ -167,7 +167,7 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
## Non-Functional Requirements
**Performance**
- CLI cold start (`operator-tool --help`) ≤ 500 ms on a developer laptop. The Typer app must avoid eager-importing heavy dependencies (httpx, pymavlink, paramiko) — sibling tasks expose lazy-import accessors used by their respective subcommands, not at module load time.
- CLI cold start (`operator-orchestrator --help`) ≤ 500 ms on a developer laptop. The Typer app must avoid eager-importing heavy dependencies (httpx, pymavlink, paramiko) — sibling tasks expose lazy-import accessors used by their respective subcommands, not at module load time.
**Compatibility**
- Click/Typer per the project pin (no version override).
@@ -181,14 +181,14 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | `operator-tool --help` output | All 6 subcommands listed |
| AC-1 | `operator-orchestrator --help` output | All 6 subcommands listed |
| AC-2 | Subcommand with success-returning fake service | Exit 0, INFO log, no stderr |
| AC-3 | Subcommand with raising fake (each documented exception family) | Exit code matches `exit_codes.py`; ERROR log; one-line stderr |
| AC-4 | Round-trip `SectorClassificationStore` set → read | Matches input |
| AC-5 | Patched `os.replace` to raise mid-write | Original file intact, no `*.tmp` lingers |
| AC-6 | `freshness_threshold_months` for both enums | `active_conflict → 1`, `stable_rear → 12` |
| AC-7 | Subcommand run, then read log file | Each line parses as JSON; required fields present |
| AC-8 | `subprocess.run(["operator-tool", "--help"])` after `pip install -e .` | Exit 0, help text printed |
| AC-8 | `subprocess.run(["operator-orchestrator", "--help"])` after `pip install -e .` | Exit 0, help text printed |
| AC-9 | Per-subcommand `--help` text | Includes documented AC IDs |
| AC-10 | Repeated `set-sector` for same area/class | On-disk JSON byte-identical |
| AC-11 | `build-cache --flight-id` happy path | Orchestrator called once with resolved DTO |
@@ -198,7 +198,7 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
| AC-15 | `FlightNotFoundError` | Exit 62; flight_id in log |
| AC-16 | `FlightsApiAuthError` | Exit 61; auth_token NOT in log |
| AC-17 | `EmptyWaypointsError` | Exit 64; Mission Planner UI hint |
| NFR-perf-cold-start | Microbench `operator-tool --help` × 10 | p99 ≤ 500 ms |
| NFR-perf-cold-start | Microbench `operator-orchestrator --help` × 10 | p99 ≤ 500 ms |
## Constraints
@@ -216,11 +216,11 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
- *Mitigation*: AC-NFR-perf-cold-start microbenches startup; CI hooks the test. If a regression appears, the offending import is surfaced by `python -X importtime`.
**Risk 2: Operator runs `set-sector` against a stale store path after upgrade**
- *Risk*: An operator upgrades the operator-tool tarball; the new version changes the default `store_path`; classifications appear lost.
- *Risk*: An operator upgrades the operator-orchestrator tarball; the new version changes the default `store_path`; classifications appear lost.
- *Mitigation*: The default path is fixed at `~/.azaion/onboard/sector-classifications.json` and treated as a stable contract. A future cycle that needs to migrate runs an explicit migration; this cycle does NOT change the path.
**Risk 3: Console script collides with another tool**
- *Risk*: The name `operator-tool` is generic; another package on the operator's workstation could shadow it.
- *Risk*: The name `operator-orchestrator` is generic; another package on the operator's workstation could shadow it.
- *Mitigation*: The package is shipped as part of the operator-tooling tarball with its own venv; no global install. README documents the tarball install procedure.
**Risk 4: Atomic-write corner case — disk full mid-tempfile**
@@ -5,13 +5,13 @@
**Description**: Implement `CompanionBringup`, the C12-internal helper that opens an SSH session against the companion (paramiko per project pin), inspects the companion-side filesystem for the four required pre-flight artifacts (Manifest.json, .engine files + AZ-280 sidecars, calibration JSON), runs sidecar verification on the engines via a remote `sha256sum` over the engine path (compared against the sidecar's hex digest), and returns a `ReadinessReport` per description.md § 2 (`manifest_present`, `content_hashes_pass`, `engines_present`, `calibration_present`, `outcome ∈ {ready, not_ready}`, `not_ready_reasons: list[str]`). Owns the two error families: `CompanionUnreachableError` (SSH session-open failure: TCP refused, auth failed, host key mismatch, socket timeout) and `ContentHashMismatchError` (sidecar verification fails on at least one engine — distinct from "engine missing", which is a not-ready signal not an exception). Public surface is one method `verify_companion_ready(companion_address: CompanionAddress) -> ReadinessReport`. SSH user, key file, host-key policy, connect-timeout, and the canonical companion-side cache root come from config (`config.c12.companion_ssh_user`, `config.c12.companion_ssh_keyfile`, `config.c12.companion_host_key_policy`, `config.c12.companion_connect_timeout_s`, `config.c12.companion_cache_root`) per AZ-269. The session is opened in a `try/finally` block; the connection is always closed even if the four checks raise. INFO log on every successful call (with the four boolean flags + outcome); WARN on degraded readiness (any 3-of-4); ERROR on the two error families.
**Complexity**: 3 points
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Component**: c12_operator_orchestrator (epic AZ-253 / E-C12)
**Tracker**: AZ-327
**Epic**: AZ-253 (E-C12)
### Document Dependencies
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (`verify_companion_ready` interface + `ReadinessReport` DTO shape), § 5 (`CompanionUnreachableError`, `ContentHashMismatchError`), § 7 (filesystem lockfile note — relevant for orchestrator T3 not this task).
- `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 2 (`verify_companion_ready` interface + `ReadinessReport` DTO shape), § 5 (`CompanionUnreachableError`, `ContentHashMismatchError`), § 7 (filesystem lockfile note — relevant for orchestrator T3 not this task).
- `_docs/02_document/contracts/shared_helpers/sha256_sidecar.md` — sidecar file format (this task verifies remotely; does not import the helper but reuses the schema).
- `_docs/02_document/contracts/shared_helpers/engine_filename_schema.md` — engine filename layout used to enumerate the expected engines list.
- `_docs/02_document/contracts/shared_logging/log_record_schema.md` — INFO/WARN/ERROR log shapes.
@@ -39,7 +39,7 @@ This task delivers the bring-up + verification layer. It does NOT orchestrate th
- `ReadinessReport` (`@dataclass(frozen=True)`): `manifest_present: bool`, `content_hashes_pass: bool`, `engines_present: bool`, `calibration_present: bool`, `outcome: enum {ready, not_ready}`, `not_ready_reasons: tuple[str, ...]`, `companion_cache_root: str`, `engines_inspected_count: int`.
- Errors at `src/operator_tool/errors.py`:
- `CompanionUnreachableError(Exception)`: attributes `host: str`, `port: int`, `reason: enum {connect_refused, auth_failed, host_key_mismatch, timeout, other}`, `underlying_exception_repr: str`. `remediation` attribute returns a one-line operator-friendly hint per `reason`.
- `ContentHashMismatchError(Exception)`: attributes `engine_path: str`, `expected_sha256_hex: str`, `actual_sha256_hex: str`. `remediation` attribute returns "Re-run the cache build (`operator-tool build-cache --area ...`) to repopulate the affected engine.".
- `ContentHashMismatchError(Exception)`: attributes `engine_path: str`, `expected_sha256_hex: str`, `actual_sha256_hex: str`. `remediation` attribute returns "Re-run the cache build (`operator-orchestrator build-cache --area ...`) to repopulate the affected engine.".
- A `SshSessionFactory` Protocol at `src/operator_tool/ssh_session.py`:
```python
@runtime_checkable
@@ -65,7 +65,7 @@ This task delivers the bring-up + verification layer. It does NOT orchestrate th
6. Compute `outcome`: `ready` iff all four booleans are `True`; `not_ready` otherwise.
7. Emit log: INFO `kind="c12.companion.ready"` with the four flags + outcome on success; WARN `kind="c12.companion.degraded"` if any check failed without raising (i.e. `outcome=not_ready` due to a missing artifact, not a hash mismatch).
8. Return the `ReadinessReport`.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorToolServices` dataclass with a `companion_bringup: CompanionBringup` field. The factory `build_companion_bringup(config) -> CompanionBringup` constructs the paramiko-backed session factory + remote sidecar verifier + logger.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorOrchestratorServices` dataclass with a `companion_bringup: CompanionBringup` field. The factory `build_companion_bringup(config) -> CompanionBringup` constructs the paramiko-backed session factory + remote sidecar verifier + logger.
## Scope
@@ -5,7 +5,7 @@
**Description**: Implement `BuildCacheOrchestrator`, the public top-level F1 (pre-flight cache build) workflow. `build_cache(request: BuildCacheRequest) -> CacheBuildReport` does the following sequenced work, with strict ordering: **(0) Flight-resolve phase (ADR-010, AZ-489)** — the orchestrator either calls `flights_api_client.fetch_flight(flight_id, base_url, auth_token)` (online) or `flights_api_client.load_flight_file(path)` (offline) per the resolved CLI flag, then `bbox = flights_api_client.bbox_from_waypoints(flight.waypoints, buffer_m=config.flight_bbox_buffer_m)` and `takeoff_origin = flights_api_client.takeoff_origin_from_flight(flight)`. The resolved `(bbox, takeoff_origin, flight_id, raw_flight_dto)` is captured into `FlightResolveReport` for FDR/debug and forwarded into the downstream phases; any `FlightsApiUnreachableError` / `FlightsApiAuthError` / `FlightNotFoundError` / `FlightsApiSchemaError` / `FlightFileNotFoundError` / `EmptyWaypointsError` / `WaypointSchemaError` is wrapped as `CacheBuildError(failure_phase=flight_resolve, ...)` and aborts BEFORE the lockfile is even acquired (no point holding the lock while diagnosing operator inputs). (1) acquire a filesystem lockfile at `<cache_staging_root>/.c12.lock` per description.md § 7 (prevents concurrent F1 runs from stomping each other); (2) call `tile_downloader.fetch(...)` (AZ-316) on the operator workstation with `bbox` (computed in phase 0), `sector_class`, `freshness_threshold_months`, `satellite_provider_url`, `api_key`; (3) on download `failure` outcome → wrap as `CacheBuildError(failure_phase=download, ...)` and return `CacheBuildReport(outcome=failure, failure_phase=download, flight_resolve_report=..., download_report=..., build_report=None)` WITHOUT invoking C10; (4) on download `success` → call `companion_bringup.verify_companion_ready(...)` (AZ-327) — if `not_ready` → wrap and return `CacheBuildReport(outcome=failure, failure_phase=download, ...)`; (5) SSH-invoke `C10.CacheProvisioner.build_cache_artifacts` (AZ-325) on the companion via the `RemoteCacheProvisionerInvoker` helper, **passing `takeoff_origin` + `flight_id` along with bbox/sector_class** so AZ-325 / AZ-323 bake them into the Manifest. Stream the C10 stdout/stderr lines back as DEBUG logs and parse the final `BuildReport` JSON document the C10 process emits on stdout; (6) aggregate into `CacheBuildReport`; (7) release the lockfile in `finally`. Wraps any underlying error from C11/C10/C7/C6 as `CacheBuildError` with a `remediation` attribute populated per `failure_phase`. Owns the operator-facing C12-IT-02 acceptance test contract.
**Complexity**: 5 points
**Dependencies**: AZ-326_c12_cli_app, AZ-327_c12_companion_bringup, AZ-316_c11_tile_downloader, AZ-325_c10_cache_provisioner, AZ-489_c12_flights_api_client (Flight resolve + bbox-from-waypoints + takeoff origin), AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Component**: c12_operator_orchestrator (epic AZ-253 / E-C12)
**Tracker**: AZ-328
**Epic**: AZ-253 (E-C12)
@@ -13,7 +13,7 @@
- `_docs/02_document/contracts/c11_tilemanager/tile_downloader.md` — consumed: `fetch` API + `DownloadBatchReport` shape.
- `_docs/02_document/contracts/c10_provisioning/cache_provisioner.md` — consumed: `build_cache_artifacts` API + `BuildReport` shape (this task invokes the contract over SSH; the contract values are passed back as a JSON document).
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 1 (Coordinator), § 2 (`build_cache`, `CacheBuildReport`), § 5 (`CacheBuildError`), § 7 (lockfile), § 8 (depends on C10 + C11).
- `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 1 (Coordinator), § 2 (`build_cache`, `CacheBuildReport`), § 5 (`CacheBuildError`), § 7 (lockfile), § 8 (depends on C10 + C11).
- `_docs/02_document/contracts/shared_logging/log_record_schema.md` — INFO/WARN/ERROR + DEBUG log shapes (DEBUG is used for streamed C10 progress).
- `_docs/_process_leftovers/2026-05-09_satellite-provider-design-tasks.md` — the parent-suite `satellite-provider` URL + auth surface this task wires through (informational, no direct dep).
@@ -80,7 +80,7 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
10. INFO log `kind="c12.build_cache.success"` with the aggregated counts (tiles_downloaded, engines_built, engines_reused, descriptors_generated).
11. Return `CacheBuildReport(outcome=success, failure_phase=none, download_report=..., build_report=..., failure_reason=None, wall_clock_s=...)`.
12. Lockfile released by `__exit__` of the `with` block.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorToolServices` dataclass with a `build_cache_orchestrator: BuildCacheOrchestrator` field. The factory `build_build_cache_orchestrator(config, services) -> BuildCacheOrchestrator` constructs the lock factory, the remote C10 invoker, and pulls T1's `freshness_table` + T2's `companion_bringup` from the existing services dataclass.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorOrchestratorServices` dataclass with a `build_cache_orchestrator: BuildCacheOrchestrator` field. The factory `build_build_cache_orchestrator(config, services) -> BuildCacheOrchestrator` constructs the lock factory, the remote C10 invoker, and pulls T1's `freshness_table` + T2's `companion_bringup` from the existing services dataclass.
- T1's `cli.py` `build-cache` subcommand resolves `services.build_cache_orchestrator` and calls `.build_cache(request)`. Maps `CacheBuildError(failure_phase=download) → exit 20`; `CacheBuildError(failure_phase=build) → exit 21`; `BuildLockHeldError → exit 50`.
## Scope
@@ -2,17 +2,17 @@
**Task**: AZ-489_c12_flights_api_client
**Name**: C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback
**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-tool build-cache --flight-id <Guid>`; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file <path>` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0).
**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-orchestrator build-cache --flight-id <Guid>`; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file <path>` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0).
**Complexity**: 3 points
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-279_wgs_converter (for the bbox buffer math)
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**Component**: c12_operator_orchestrator (epic AZ-253 / E-C12)
**Tracker**: AZ-489
**Epic**: AZ-253 (E-C12)
### Document Dependencies
- `_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases).
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies).
- `_docs/02_document/contracts/c12_operator_orchestrator/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases).
- `_docs/02_document/components/13_c12_operator_orchestrator/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies).
- `_docs/02_document/architecture.md` — ADR-010 (operator-planned mission as cold-start trust anchor).
- Parent-suite reference (read-only): `suite/flights/Database/Entities/Flight.cs`, `suite/flights/Database/Entities/Waypoint.cs`, `suite/flights/Controllers/FlightsController.cs`.
@@ -44,7 +44,7 @@ This task delivers the client + its frozen contract. It does NOT modify the CLI
- Error hierarchy at `src/operator_tool/flights_api_errors.py`:
- `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`.
- Composition-root factory entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
- Extend the `OperatorToolServices` dataclass with `flights_api_client: FlightsApiClient`.
- Extend the `OperatorOrchestratorServices` dataclass with `flights_api_client: FlightsApiClient`.
- `build_flights_api_client(config) -> FlightsApiClient` constructs the httpx client with TLS verify on (no `verify=False`), default timeout `10.0 s`, and the project's `WgsConverter`.
- Logging:
- INFO on every successful fetch (`kind="c12.flights.fetch.success"`) with `flight_id`, `waypoint_count`, `bbox` summary. NO `auth_token` in any log line.
@@ -1,216 +1,217 @@
# C12 Post-Landing Upload — `trigger_post_landing_upload` + FDR ON_GROUND Confirmation
# 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 a confirmed-ON_GROUND signal from the post-flight FDR. `trigger_post_landing_upload(request: PostLandingUploadRequest) -> UploadBatchReport` does the following: (1) locate the FDR segments for the given `flight_id` under `config.c12.fdr_root` (segment layout: `<fdr_root>/<flight_id>/segment_<NNN>.fdr` per the C13 conventions); (2) iterate the segments from newest to oldest, parsing records via AZ-272's `FdrRecord.parse(...)`; (3) collect all `state.tick` records carrying a `flight_state` payload field (or a dedicated `flight_state.tick` kind if the schema names it that way — defer to AZ-272's contract); (4) walking the collected records backwards from the most recent (chronologically), count contiguous `ON_GROUND` records and compute the contiguous ON_GROUND duration as `(latest_record.ts first_consecutive_on_ground_record.ts)` seconds; (5) compare against `config.c12.upload_min_on_ground_s` (default 30 s per description.md C12-IT-03); (6) on confirmed ≥ threshold → construct a `FlightStateSignal(state=ON_GROUND, since_ts=<first consecutive ts>)` and call `tile_uploader.upload_pending_tiles(flight_state=...)`; (7) on any refusal mode → raise `FlightStateNotConfirmedError(not_confirmed_reason=...)` with one of the four documented reason strings (`"never_landed"`, `"insufficient_duration: <X>s < <threshold>s"`, `"flight_id_not_found"`, `"fdr_unreadable: <repr>"`). Owns AC-8.4's defense-in-depth check on the operator-tooling side — the airborne C11 ALSO blocks via `UploadGateBlockedError` per AZ-319; this task is the operator-side gate that prevents the upload command from even being issued. Returns C11's `UploadBatchReport` unchanged on success. Logs every decision (INFO on confirmed; ERROR on each refusal mode) including the inferred contiguous ON_GROUND duration in seconds.
**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, AZ-272_fdr_record_schema, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
**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` — consumed: `upload_pending_tiles` API + `UploadBatchReport` shape + `FlightStateSignal` DTO.
- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — consumed: `parse(buf: bytes) -> FdrRecord` + the `state.tick` / `flight_state.tick` kind shape (defer to the contract for the exact `kind` name and `flight_state` field).
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (`trigger_post_landing_upload` interface, `FlightStateNotConfirmedError`).
- `_docs/02_document/components/13_c12_operator_tooling/tests.md` — C12-IT-03 specifies the 30-s ON_GROUND threshold.
- `_docs/02_document/components/14_c13_fdr/description.md` — § 1 segment file layout (informational).
- `_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 (mid-flight tile upload trigger, post-landing) collapses; the pending-upload journal in C6 grows unboundedly across flights.
- The operator-side ON_GROUND gate (defense-in-depth on top of C11's airborne gate) does not exist — operators can manually invoke `C11.TileUploader.upload_pending_tiles` with a fabricated `FlightStateSignal`, defeating the AC-NEW-7 / AC-8.4 architectural intent that mid-flight tiles only upload when the aircraft has landed.
- C12-IT-03 (`trigger_post_landing_upload` requires ≥ 30 s confirmed ON_GROUND in FDR) has no implementation.
- 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.
- An incomplete flight log (FDR ends with `IN_FLIGHT` because the aircraft crashed or never landed) silently passes through to C11 if there's no operator-side gate; the airborne gate is the last line of defense and may itself be unavailable on the operator workstation.
- 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), or the FDR write side (AZ-291..296) — it composes them.
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/operator_tool/post_landing_upload.py`:
- Constructor: `__init__(self, *, tile_uploader: TileUploader, fdr_segment_reader: FdrSegmentReader, logger: Logger, clock: Clock, config: C12PostLandingConfig)`.
- `C12PostLandingConfig` (`@dataclass(frozen=True)`): `fdr_root: Path`, `upload_min_on_ground_s: float = 30.0`, `flight_state_record_kind: str = "state.tick"`, `flight_state_payload_field: str = "flight_state"`.
- 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/operator_tool/_types.py`:
- `PostLandingUploadRequest` (`@dataclass(frozen=True)`): `flight_id: str`.
- Reuses C11's `UploadBatchReport`.
- Errors at `src/operator_tool/errors.py`:
- `FlightStateNotConfirmedError(Exception)`: attributes `flight_id: str`, `not_confirmed_reason: str` (one of the four documented strings), `inferred_on_ground_duration_s: float | None` (populated when the reason is `insufficient_duration`), `remediation: str` (per-reason hint, e.g. for `flight_id_not_found`: "Verify the flight ID matches the FDR directory name; check `<fdr_root>/<flight_id>/`.").
- An `FdrSegmentReader` Protocol + `LocalFdrSegmentReader` concrete at `src/operator_tool/fdr_segment_reader.py`:
- `Protocol`: `iter_records_for_flight(flight_id: str, *, kind_filter: str | None = None) -> Iterator[FdrRecord]` — yields records ordered by `ts` ASCENDING; the orchestrator reverses on its own. `kind_filter` if non-None restricts to that record kind for efficiency.
- `LocalFdrSegmentReader.iter_records_for_flight(...)` — opens `<fdr_root>/<flight_id>/segment_*.fdr` files in numerical order, reads each as a stream of length-prefixed `FdrRecord` blobs (per AZ-272's serialisation), parses via `FdrRecord.parse(...)`, optionally filters by `kind`, yields one record at a time. Files are mmap'd or buffered-iterated so the operator workstation does not load multi-GB segments fully into memory.
- 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 / request.flight_id`. If `not flight_dir.exists()` → raise `FlightStateNotConfirmedError(flight_id, "flight_id_not_found", remediation="Verify <fdr_root>/<flight_id>/ exists; check `config.c12.fdr_root`.")`.
2. Collect all `flight_state` records: `records = list(fdr_segment_reader.iter_records_for_flight(request.flight_id, kind_filter=config.flight_state_record_kind))`. Catch `FdrUnreadableError` → raise `FlightStateNotConfirmedError(flight_id, f"fdr_unreadable: {e!r}", ...)`.
3. If `not records` → raise `FlightStateNotConfirmedError(flight_id, "never_landed", remediation="No flight state records in FDR for this flight; check the flight produced state.tick records.")` (treat absence of any state record as never-landed since we have no positive ON_GROUND signal).
4. Walk `records` backward from the last (most recent `ts`):
- `latest = records[-1]`.
- If `latest.payload[config.flight_state_payload_field] != "ON_GROUND"` → raise `FlightStateNotConfirmedError(flight_id, "never_landed", remediation="Most recent flight_state in FDR is not ON_GROUND; the flight may have ended in IN_FLIGHT (e.g. crash, log truncation).")`.
- Walk backward through `records[:-1]` while `record.payload[...] == "ON_GROUND"`; the first non-`ON_GROUND` (or the start of the list) bounds the contiguous ON_GROUND run.
- `since = first_contiguous_on_ground_record.ts`; `duration_s = (parse_iso(latest.ts) - parse_iso(since)).total_seconds()`.
5. If `duration_s < config.upload_min_on_ground_s` → raise `FlightStateNotConfirmedError(flight_id, f"insufficient_duration: {duration_s:.1f}s < {config.upload_min_on_ground_s:.1f}s", inferred_on_ground_duration_s=duration_s, remediation="Wait for the aircraft to be confirmed ON_GROUND for the required duration, then re-run.")`.
6. INFO log `kind="c12.upload.confirmed_on_ground"` with `flight_id`, `inferred_on_ground_duration_s`.
7. Construct `flight_state = FlightStateSignal(state=ON_GROUND, since_ts=since)` (the DTO comes from C11 per AZ-319's contract).
8. Call `report = tile_uploader.upload_pending_tiles(flight_state=flight_state)`. Propagate `UploadGateBlockedError` (defense-in-depth on the airborne side; this should never happen if step 6 confirmed; if it does, log ERROR and re-raise as-is).
9. INFO log `kind="c12.upload.complete"` with `tiles_acked`, `tiles_rejected` from `report`.
10. Return `report` unchanged.
- Composition-root factory at `src/gps_denied_onboard/runtime_root/c12_factory.py` extends T1's `OperatorToolServices` dataclass with `post_landing_upload_orchestrator: PostLandingUploadOrchestrator`. The factory `build_post_landing_upload_orchestrator(config, services) -> PostLandingUploadOrchestrator` constructs the `LocalFdrSegmentReader` over `config.c12.fdr_root` and pulls C11's `tile_uploader` from the wider service registry.
- T1's `cli.py` `upload-pending` subcommand resolves `services.post_landing_upload_orchestrator` and calls `.trigger_post_landing_upload(...)`. Maps `FlightStateNotConfirmedError → exit 30`; `UploadGateBlockedError → exit 31`.
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.
- `FlightStateNotConfirmedError` with the four documented `not_confirmed_reason` strings + per-reason `remediation`.
- `FdrSegmentReader` Protocol.
- `LocalFdrSegmentReader` concrete reading on-disk FDR segments.
- `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).
- Composition-root factory.
- Wiring of T1's `upload-pending` subcommand to this service.
- Conformance unit tests using a fake `FdrSegmentReader` returning scripted record sequences for all 7 acceptance criteria.
- Two end-to-end integration tests using real FDR segment fixtures (one ending with confirmed ON_GROUND for 60 s, one ending with IN_FLIGHT) — these are the C12-IT-03 fixtures.
- `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).
- The actual upload HTTP machinery (AZ-319 / C11).
- The FDR record schema or serialiser (AZ-272).
- The FDR write side / segment rotation (AZ-291..296).
- A "force-upload" override flag to bypass the gate — explicitly NOT supported (defeats the operator-side gate's purpose).
- Reading mid-flight tile snapshots from FDR — the upload itself reads tiles from C6 per AZ-319.
- 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: ≥ 30 s confirmed ON_GROUND → upload invoked**
Given a fake `FdrSegmentReader` returning 60 records, the last 60 of them with `flight_state=ON_GROUND` spanning 60 s of timestamps
**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 `flight_state.state=ON_GROUND` and `flight_state.since_ts` equal to the first contiguous ON_GROUND record's ts; the returned `UploadBatchReport` is the one C11 produced; ONE INFO log `kind="c12.upload.confirmed_on_ground"` with `inferred_on_ground_duration_s ≈ 60.0`; ONE INFO log `kind="c12.upload.complete"`
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: Insufficient duration → `FlightStateNotConfirmedError("insufficient_duration: ...")`**
Given the FDR ends with 15 s contiguous ON_GROUND records (less than the 30 s threshold)
**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="insufficient_duration: 15.0s < 30.0s", inferred_on_ground_duration_s≈15.0)` is raised; `tile_uploader.upload_pending_tiles` is NEVER called; ONE ERROR log `kind="c12.upload.refused.insufficient_duration"`
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: Never-landed (last record is IN_FLIGHT) → `FlightStateNotConfirmedError("never_landed")`**
Given the FDR's most recent `state.tick` record has `flight_state=IN_FLIGHT`
**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="never_landed", inferred_on_ground_duration_s=None)` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.never_landed"`
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: `flight_id` not found in FDR → `FlightStateNotConfirmedError("flight_id_not_found")`**
Given `<fdr_root>/<flight_id>/` does not exist
**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; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.flight_id_not_found"`
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: <repr>")`**
Given the FDR segments exist but parsing raises `OSError("input/output error")` mid-stream
**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=re.compile(r"^fdr_unreadable: .*OSError.*"))` is raised; uploader NOT called; ONE ERROR log `kind="c12.upload.refused.fdr_unreadable"` including the inner repr
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: Threshold is configurable**
Given `config.c12.upload_min_on_ground_s = 5.0` (override) and the FDR ends with 6 s contiguous ON_GROUND records
When `trigger_post_landing_upload(request)` is called
Then the call succeeds (uploader invoked); the threshold is read from config, NOT a hardcoded literal
**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 `UploadBatchReport(tiles_acked=42, tiles_rejected=3, ...)`
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 byte-for-byte the `UploadBatchReport` C11 returned (same dataclass instance via passthrough); no field is added, removed, or renamed
Then it is the same object (passthrough) returned by `tile_uploader.upload_pending_tiles`; no field is mutated, added, removed, or renamed
**AC-8: Contiguous ON_GROUND counting starts from the most recent record only**
Given the FDR contains a sequence `IN_FLIGHT, ON_GROUND, IN_FLIGHT, ON_GROUND × 60s` (an aborted go-around landing)
When `trigger_post_landing_upload(request)` is called
Then the contiguous ON_GROUND block counted is the LAST one (60 s), not the earlier ON_GROUND record; the upload is invoked since 60 s ≥ 30 s
**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: Empty `flight_state` records → `never_landed`**
Given `iter_records_for_flight(...)` yields zero records (no `state.tick` records ever emitted)
When `trigger_post_landing_upload(request)` is called
Then `FlightStateNotConfirmedError(not_confirmed_reason="never_landed")` is raised (treated as "we have no positive ON_GROUND signal")
**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(a) (60 s confirmed) → upload invoked**
Given the C12-IT-03 fixture FDR with confirmed ON_GROUND for 60 s
When `trigger_post_landing_upload(request)` is called against the LocalFdrSegmentReader on the fixture
Then the upload is invoked; the returned `UploadBatchReport` matches the fixture's expected counts
**AC-11: Real FDR fixture C12-IT-03(b) (IN_FLIGHT, incomplete log) → refused**
Given the C12-IT-03 fixture FDR ending with IN_FLIGHT (truncated)
When `trigger_post_landing_upload(request)` is called against the LocalFdrSegmentReader on the fixture
Then `FlightStateNotConfirmedError(not_confirmed_reason="never_landed")` is raised; the upload is NOT invoked
**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**
- For an 8-hour flight (≤ 64 GB FDR per AC-NEW-3) the orchestrator's read of `state.tick` records completes in ≤ 30 s wall-clock on a developer laptop with NVMe (the records are sparse — `state.tick` is one of many record kinds; the `kind_filter` argument lets the reader skip non-state records cheaply).
- Memory peak ≤ 200 MB even with multi-GB FDR segments — `LocalFdrSegmentReader` is a streaming generator, NOT a list-in-memory.
- `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.
- C11's `FlightStateSignal` DTO is consumed unchanged; this task does NOT redefine it.
- 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` prefix strings.
- 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.
- The threshold default (30.0 s) matches description.md C12-IT-03 exactly.
- 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 with 60 ON_GROUND records spanning 60 s | Uploader called once, INFO logs, returns `UploadBatchReport` |
| AC-2 | Fake reader with 15 s ON_GROUND tail | `FlightStateNotConfirmedError("insufficient_duration: 15.0s < 30.0s")` |
| AC-3 | Fake reader whose last record is IN_FLIGHT | `FlightStateNotConfirmedError("never_landed")` |
| AC-4 | Path doesn't exist | `FlightStateNotConfirmedError("flight_id_not_found")` |
| AC-5 | Fake reader raises `FdrUnreadableError("OSError(...)")` | `FlightStateNotConfirmedError(re.match("^fdr_unreadable: .*"))` |
| AC-6 | Override `upload_min_on_ground_s=5.0` + 6 s ON_GROUND | Upload invoked |
| AC-7 | Successful upload, inspect return | Same `UploadBatchReport` instance/fields |
| AC-8 | Sequence with go-around (IN_FLIGHT in middle) | Contiguous count is the LAST run only |
| AC-9 | Empty `iter_records_for_flight` | `FlightStateNotConfirmedError("never_landed")` |
| AC-10 | C12-IT-03(a) fixture | Upload invoked |
| AC-11 | C12-IT-03(b) fixture | `FlightStateNotConfirmedError("never_landed")` |
| NFR-perf-streaming | Microbench `LocalFdrSegmentReader` over 1 GB synthetic segment | Memory peak ≤ 200 MB; parse rate ≥ 100 MB/s |
| 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` strings (`"never_landed"`, `"insufficient_duration: ..."`, `"flight_id_not_found"`, `"fdr_unreadable: ..."`) are a closed contract — adding a new value requires Plan-cycle approval (operators script against these prefixes).
- The threshold default 30.0 s matches description.md C12-IT-03 EXACTLY; changing it requires a spec amendment, not just a config change.
- The "contiguous ON_GROUND from most recent only" semantic (AC-8) is non-negotiable — counting the union of all ON_GROUND windows would defeat the gate by allowing an aborted-go-around aircraft to qualify based on the brief earlier landing.
- 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).
- `LocalFdrSegmentReader` MUST stream; loading a multi-GB segment fully into memory is a NFR violation (NFR-perf-streaming).
- C11's `FlightStateSignal` DTO is the source of truth for the gate signal — this task does NOT define a parallel C12-internal `FlightStateSignal`.
- The threshold is a `float`; comparison uses `>=` (so exactly 30.0 s qualifies).
- `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: AZ-272's record schema names the field something other than `flight_state`**
- *Risk*: AZ-272's contract may use `state` or `flight.state` instead of `flight_state`; this task hardcodes the field name in `config.c12.flight_state_payload_field`.
- *Mitigation*: The field name is a config knob (default `"flight_state"`); during integration with AZ-272, the default is updated to match AZ-272's actual contract. Tests use the default; integration tests against real FDR fixtures catch a mismatch immediately.
**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: The aircraft logs ON_GROUND briefly during taxi before takeoff**
- *Risk*: The flight starts ON_GROUND, transitions to IN_FLIGHT, lands ON_GROUND again. The "contiguous from most recent" semantic correctly handles this — but if the FDR is truncated mid-flight, the most recent record might be from the taxi phase, falsely suggesting a landed flight.
- *Mitigation*: The truncation case is captured by AC-3 / AC-11 — a truncated log ending in IN_FLIGHT correctly refuses. A truncated log ending in the early ON_GROUND taxi phase is indistinguishable from a real landing, but this is an FDR integrity concern out of scope; in practice the FDR writes are continuous.
**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: FDR segment file naming convention drift**
- *Risk*: C13 (AZ-291..296) may name segments differently than `segment_<NNN>.fdr`.
- *Mitigation*: The naming pattern is captured in `LocalFdrSegmentReader` with a `glob_pattern` constructor parameter (default `segment_*.fdr`); update the default if AZ-291 picks a different name. Tests cover both patterns.
**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: `parse_iso` timezone handling**
- *Risk*: Two records with the same wall-clock time but different timezones produce a wrong duration calculation.
- *Mitigation*: AZ-272's contract specifies all timestamps are ISO 8601 UTC microseconds; this task asserts UTC at parse time and raises `FdrUnreadableError("non-UTC timestamp in record")` otherwise. Defense-in-depth.
**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: A future cycle adds a third flight state value (e.g. `EMERGENCY`)**
- *Risk*: The contiguous-counting code treats anything other than `ON_GROUND` as breaking the run; a new `EMERGENCY` value during landing rollout could shorten the inferred duration spuriously.
- *Mitigation*: Acceptable for this cycle — emergency states should not allow upload anyway. A future cycle that introduces such states must update this task's logic explicitly via a Plan-cycle change.
**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 ON_GROUND-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 real `TileUploader` (AZ-319) + real `LocalFdrSegmentReader` reading real on-disk FDR segments + real `FdrRecord.parse` (AZ-272).
- **Allowed external stubs**: tests MAY use fakes for `FdrSegmentReader` and `TileUploader`; the C12-IT-03 integration tests use real FDR fixture files + a fake `TileUploader` 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).
- **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).
@@ -2,19 +2,19 @@
**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_tooling/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).
**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_tooling (epic AZ-253 / E-C12)
**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_tooling/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/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_tooling/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_tooling/tests.md` — C12-IT-01 (operator re-loc workflow returns SUT to satellite-anchored ≤ 30 s).
- `_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
@@ -58,8 +58,8 @@ This task delivers the C12 service surface + the Protocol contract + the FDR sid
- 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_tooling/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 `OperatorToolServices` 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).
- 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
@@ -70,7 +70,7 @@ This task delivers the C12 service surface + the Protocol contract + the FDR sid
- `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_tooling/operator_command_transport.md`.
- 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.
@@ -108,7 +108,7 @@ 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_tooling/operator_command_transport.md`
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
@@ -133,7 +133,7 @@ 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-tool starts up (T1's `cli.py` lazily resolves services)
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)
@@ -202,4 +202,4 @@ Then `OperatorCommandTransport` is NEVER instantiated (verifiable via spy on the
## Contract
This task produces/implements the contract at `_docs/02_document/contracts/c12_operator_tooling/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.
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.
+1 -1
View File
@@ -66,7 +66,7 @@ Without this task, the replay-only strategies (FrameSource + Clock + TlogReplayF
**AC-7: Composition uses Public APIs only** — assert that `compose_replay` imports ONLY `__init__.py` re-exports of each component (per `module-layout.md` Layer-3 / Layer-4 rules). CI-style check via AST scan in the unit test.
**AC-8: No C6/C10/C11/C12 imports** — assert that `compose_replay` does NOT import any symbol from `components.c6_tile_cache`, `components.c10_provisioning`, `components.c11_tilemanager`, `components.c12_operator_tooling` (per epic scope).
**AC-8: No C6/C10/C11/C12 imports** — assert that `compose_replay` does NOT import any symbol from `components.c6_tile_cache`, `components.c10_provisioning`, `components.c11_tilemanager`, `components.c12_operator_orchestrator` (per epic scope).
**AC-9: Configuration + calibration loading** — `compose_replay(config_with_invalid_calib_path)``ReplayCompositionError("camera-calibration not found at ...")`.
@@ -2,7 +2,7 @@
**Task**: AZ-403_replay_dockerfile_ci
**Name**: `gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff (excludes C6/C10/C11/C12)
**Description**: Add the fourth Docker image `gps-denied-replay-cli`: multi-stage build (Python + C1C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server). Add a GitHub Actions matrix entry building and pushing this image alongside the existing 3 images (live / research / operator). Add an **SBOM diff CI step** that builds the SBOM (via `syft` or the project's existing SBOM tooling), parses it, and asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_tooling` packages — verifies AC-4 of the epic. The SBOM diff fails the CI job if any excluded component leaks into the replay image. Image base: same Python + CUDA base as the live image (consistency with TensorRT engines from C7) but with `BUILD_C6=OFF`, `BUILD_C10=OFF`, `BUILD_C11=OFF`, `BUILD_C12=OFF`, `BUILD_VIDEO_FILE_FRAME_SOURCE=ON`, `BUILD_TLOG_REPLAY_ADAPTER=ON`, `BUILD_REPLAY_SINK_JSONL=ON` build args.
**Description**: Add the fourth Docker image `gps-denied-replay-cli`: multi-stage build (Python + C1C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server). Add a GitHub Actions matrix entry building and pushing this image alongside the existing 3 images (live / research / operator). Add an **SBOM diff CI step** that builds the SBOM (via `syft` or the project's existing SBOM tooling), parses it, and asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_orchestrator` packages — verifies AC-4 of the epic. The SBOM diff fails the CI job if any excluded component leaks into the replay image. Image base: same Python + CUDA base as the live image (consistency with TensorRT engines from C7) but with `BUILD_C6=OFF`, `BUILD_C10=OFF`, `BUILD_C11=OFF`, `BUILD_C12=OFF`, `BUILD_VIDEO_FILE_FRAME_SOURCE=ON`, `BUILD_TLOG_REPLAY_ADAPTER=ON`, `BUILD_REPLAY_SINK_JSONL=ON` build args.
**Complexity**: 3 points
**Dependencies**: AZ-402 (CLI entrypoint registered in pyproject); AZ-398 / AZ-399 / AZ-400 / AZ-401 (replay strategies); existing Dockerfile + CI plumbing for the live image (pattern to mirror); `module-layout.md` build-flag table; AZ-263, AZ-269, AZ-266
**Component**: replay-cicd (epic AZ-265 / E-DEMO-REPLAY) — Dockerfile at `docker/replay-cli/Dockerfile`; CI at `.github/workflows/build-images.yml` (or equivalent); SBOM-diff script at `ci/sbom_diff_replay.py`
@@ -27,7 +27,7 @@ Without this task, the replay binary cannot ship — there's no CI matrix entry
- Entrypoint: `gps-denied-replay`.
- No HTTP server (no exposed ports; CLI only).
- `.github/workflows/build-images.yml` matrix entry for `replay-cli` (image tag, build args, push to registry).
- `ci/sbom_diff_replay.py` — generates the SBOM via `syft packages dir:./ -o spdx-json` (or equivalent) on the built image, parses it, asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_tooling` Python packages. Exit 0 on clean SBOM; exit 1 on leak (with the leaking package name printed).
- `ci/sbom_diff_replay.py` — generates the SBOM via `syft packages dir:./ -o spdx-json` (or equivalent) on the built image, parses it, asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_orchestrator` Python packages. Exit 0 on clean SBOM; exit 1 on leak (with the leaking package name printed).
- CI step `replay-cli-sbom-diff` invokes the script after the image build; fails the job on script exit 1.
- Documentation: `docker/replay-cli/README.md` documents the image scope + build-args.
- Unit / smoke tests: `docker buildx build` of the Dockerfile succeeds locally; SBOM-diff script runs against a pre-built test image fixture.