[AZ-489] [AZ-490] ADR-010 design pass: operator-mission as cold-start anchor

Architecture, contracts, and task amendments for the flight-route-driven
preflight + cold-start origin feature (ADR-010). No source code touched
in this commit; the implementation commits for AZ-489 / AZ-490 / AZ-419
land separately.

* architecture.md: ADR-010, new Principle #14, amended Principle #11,
  external systems gain flights service + Mission Planner UI, data
  model gains Flight / Waypoint / TakeoffOrigin.
* system-flows.md: F1 gains phase 0 (Flight resolve), F2 gains
  cold-start ladder, F7 gains mid-flight bounded-delta GPS gate.
* glossary.md: Flight, Flights API, Mid-flight bounded-delta GPS gate,
  Mission Planner UI, Takeoff origin, Waypoint.
* C10: description + cache_provisioner + manifest_verifier bumped to
  v1.1 carrying takeoff_origin + flight_id in the manifest hash.
* C12: description updated + new flights_api_client.md contract v1.0.
* C5: description + state_estimator_protocol bumped to v1.1 with
  set_takeoff_origin + 3-clause spoof-promotion gate.
* AZ-323/324/325/326/328/419 amended in place. AZ-490 spec created
  (C5 set_takeoff_origin entrypoint).
* Dependencies table: 142 tasks / 478 pts / 15 forward edges
  (2 new tasks, 2 backward deps, 2 forward deps from AZ-419).
* Leftovers cleared: 2026-05-11 Jira transition entries for AZ-355
  and AZ-386 are deleted (Jira reconnected; both already transitioned
  in their respective implementation commits).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 01:28:05 +03:00
parent db27e25630
commit e0be591b06
20 changed files with 875 additions and 221 deletions
+44 -7
View File
@@ -39,7 +39,8 @@ The system is a **Jetson Orin Nano Super-hosted onboard companion** that deliver
8. **D-CROSS-LATENCY-1 hybrid**: K=3 baseline auto-degrades to K=2 + Jacobian covariance under Jetson thermal throttle, preserving AC-4.1 at +50 °C ambient at the cost of ~510 % accuracy loss (still inside AC-NEW-4).
9. **Two execution tiers** (Tier-1 workstation Docker = fast/cheap; Tier-2 Jetson hardware = AC-bound) appear in the deployment plan and CI matrix per finding F6.
10. **Camera intrinsics and full-altitude footage are calibration prerequisites**, not implementation gaps. Production accuracy claims are gated on D-PROJ-1 closure (hybrid factory + checkerboard refinement). Test fixtures use `adti26` calibration sourced from public/factory references.
11. **Spoofed GPS never re-enters the estimator** unless FC GPS health is stable + non-spoofed for ≥10 s **AND** a visual/satellite consistency check has succeeded (AC-NEW-8).
11. **Spoofed GPS never re-enters the estimator** unless the FC GPS report passes a three-part gate (AC-NEW-8 + AZ-490 follow-up): (a) FC GPS health stable + non-spoofed for ≥ 10 s, (b) a visual/satellite consistency check has succeeded on the next anchor frame, AND (c) the FC's reported position is within ≤ 200 m of the companion's last emitted `PoseEstimate`. The third clause is the **mid-flight bounded-delta gate** — even a "stable, non-spoofed" GPS frame is rejected if it disagrees with the companion's posterior by more than the configurable budget. Real GPS that passes the gate is fused via `add_pose_anchor` with the FC's covariance (treated as one more anchor source, never overriding the visual pipeline without the gate).
14. **Operator-planned mission is the primary cold-start trust anchor**, not the FC EKF (AZ-490 follow-up). The operator authors the route in the parent-suite **Mission Planner UI** (`suite/ui`), the route persists in the parent-suite **`flights` REST service** (`suite/flights`), and C12 (operator tooling) reads the `Flight` from that service to: (a) derive the cache bbox as the envelope of the waypoint lat/lon plus a configurable buffer, (b) extract the first-ordered waypoint as the **takeoff origin** (lat / lon / alt), and (c) bake the takeoff origin into the C10 Manifest so the airborne C5 can warm-start from it via `set_takeoff_origin(origin, sigma_m)` **before** any FC IMU / VIO sample arrives. This unblocks the GPS-jammed-at-takeoff scenario the FC-EKF-only cold-start path (AZ-419 today) cannot handle. The FC EKF's last valid GPS becomes a **secondary** cold-start input — used only when the operator origin is missing from the Manifest OR when the FC EKF reading passes the same bounded-delta consistency check against the operator origin.
12. **AC-4.5 is internal smoothing only.** GTSAM iSAM2 retroactively refines past keyframes onboard and emits the corrected current frame; the FC log is forward-time only — neither ArduPilot nor iNav supports FC-side retroactive correction (Mode B Fact #107).
13. **Interface-first components with constructor-injected dependencies.** Every component is **defined as an interface (Python `Protocol` or `ABC`) before any concrete implementation exists**, lives in its **own folder under `src/components/<component>/`**, and is wired together via **constructor injection** at a single composition root. Components never reach out to a global registry, a singleton, or `import` a sibling component's concrete class directly — they receive their collaborators as `__init__` arguments typed against the sibling's interface. Multiple interchangeable implementations of the same interface MUST be supported by design (e.g., C1 has three `VioStrategy` implementations; C2 has UltraVPR + MegaLoc + MixVPR + … behind a single `VprStrategy`; C8 has two FC-adapter implementations behind a single `FcAdapter`). Selection happens once, at startup, by config; the composition root resolves config → concrete implementation → wires the graph; the rest of the runtime sees only interfaces. **Side benefit (NOTE)**: this design also gives the project **packaging optionality** — different combinations of `BUILD_*` flags can produce binaries tailored to specific deployment targets, customer bundles, or (if/when relevant later) end-product licensing strategies, **without any source-level change in application code**. That optionality is a *consequence* of the interface-first design, not a driver — the architectural decisions in this document are made on technical grounds; component licenses do not influence them. See ADR-002 § Consequences and ADR-009.
@@ -63,17 +64,21 @@ The system is a **Jetson Orin Nano Super-hosted onboard companion** that deliver
|---|---|
| Companion PC runtime (Jetson Orin Nano Super, JetPack 6.2) | Flight controller firmware (ArduPilot Plane, iNav) |
| All onboard pose-estimation logic (C1C8, C13) | Parent-suite `satellite-provider` (.NET 8 REST microservice) |
| Pre-flight cache artifact build (C10 — engines + descriptors + manifest) | GCS (QGroundControl) |
| Operator-side Tile Manager (C11 — pre-flight download + post-landing upload) | Nav camera hardware (`adti20`); AI-camera hardware |
| Operator pre-flight tooling (C12) | UAV airframe / FC IMU / sensors |
| FDR writer (C13) | Operator's workstation OS / authentication |
| Camera calibration artifact format + loader | The act of calibration itself (operator runs checkerboard rig) |
| Pre-flight cache artifact build (C10 — engines + descriptors + manifest) | Parent-suite `flights` REST service (.NET 8; owns the `Flight` + `Waypoint` DTOs) |
| Operator-side Tile Manager (C11 — pre-flight download + post-landing upload) | Parent-suite Mission Planner UI (`suite/ui` — where operators plan the route) |
| Operator pre-flight tooling (C12) | GCS (QGroundControl) |
| FDR writer (C13) | Nav camera hardware (`adti20`); AI-camera hardware |
| Camera calibration artifact format + loader | UAV airframe / FC IMU / sensors |
| | Operator's workstation OS / authentication |
| | The act of calibration itself (operator runs checkerboard rig) |
**External systems**:
| System | Integration Type | Direction | Purpose |
|---|---|---|---|
| `satellite-provider` (parent-suite .NET 8) | REST + filesystem (read), REST (post-landing write, D-PROJ-2) | Both | Pre-flight tile source; post-landing tile sink (planned) |
| `flights` REST service (parent-suite .NET 8) | REST (read) over HTTPS | Inbound to C12 | Source of the operator-planned `Flight` (waypoints, ordering, altitudes). C12 derives bbox + takeoff origin from the Flight. **Operator workstation only** — never reached from the airborne companion |
| Mission Planner UI (`suite/ui`) | Indirect via `flights` REST | Inbound (mediated) | Where the operator authors the route before C12 consumes it. Out of scope for this project, but the API contract it produces IS in scope |
| ArduPilot Plane FC | MAVLink 2.0 over UART/USB (signed) | Both | Inbound: external position via `GPS_INPUT`. Outbound: IMU, attitude, GPS health, EKF source-set commands |
| iNav FC | MSP2 over UART (unsigned), MAVLink outbound | Both | Inbound: external position via `MSP2_SENSOR_GPS` (companion is sole GPS source on iNav). Outbound: IMU/attitude/telemetry |
| QGroundControl (GCS) | MAVLink 2.0 (link-bandwidth-limited) | Both | 12 Hz downsampled summary out (AC-6.1); operator commands in (AC-6.2) |
@@ -197,9 +202,12 @@ source repo
| `EmittedExternalPosition` | WGS84 + honest `horiz_accuracy` + per-FC encoding (MAVLink `GPS_INPUT` for AP, MSP2 `MSP2_SENSOR_GPS` for iNav) | C8 |
| `FlightStateSignal` | `IN_AIR | ON_GROUND` boolean derived from FC `MAV_STATE` | C8 inbound side; published to C11 only post-landing |
| `FdrRecord` | Estimates + IMU traces + emitted MAVLink + system health + tiles + thumbnails (≤ 64 GB / flight) | C13 |
| `Manifest` | Hash of (model + calibration + corpus + sector classification) for D-C10-1 idempotence | C10 |
| `Manifest` | Hash of (model + calibration + corpus + sector classification + takeoff origin) for D-C10-1 idempotence | C10 |
| `EngineCacheEntry` | TRT engine + INT8 calibration cache keyed by SM/JP/TRT/precision tuple (D-C10-7) | C10, C7 |
| `SectorClassification` | `active_conflict | stable_rear` per area, drives freshness threshold | C12 (operator-set) → C6, C10 |
| `Flight` | Operator-planned mission: ordered `Waypoint` list + metadata, persisted in the parent-suite `flights` REST service. Read by C12 via `FlightsApiClient`; never reached from the airborne companion | External (`suite/flights`) → C12 |
| `Waypoint` | Ordered `(lat, lon, alt, objective, source)` entry inside a `Flight`. C12 envelopes waypoint lat/lon → bbox; first-ordered waypoint → takeoff origin | External (`suite/flights`) → C12 |
| `TakeoffOrigin` | `LatLonAlt` carried in the C10 Manifest; baked in by C12 at build time from `Flight.waypoints[0]`; consumed at boot by C5 via `set_takeoff_origin(origin, sigma_m)` (AZ-490) | C12 → C10 Manifest → C5 |
**Key relationships**:
@@ -601,3 +609,32 @@ This decision is made on **technical grounds only**. Component licenses (BSD/Apa
- Build-time exclusion (ADR-002) becomes architectural: the deployment composition root *cannot* `import` a strategy whose file is not part of the deployment binary's CMake target. The same property scales to any future packaging variant — including, if/when product licensing strategy is decided, license-driven bundles (Principle #13 NOTE), without any source-level change in application code.
- Per-component folders give each implementation a natural home for its own `tests/`, fixtures, and adapter-specific helpers — matching coderule.mdc's "logic specific to a platform, variant, or environment belongs in the class that owns that variant".
- Adding a new C2 VPR backbone (e.g., a future foundation-model retrieval backbone via D-C2-12) is a folder-add + interface-conformance change; no other component is touched.
### ADR-010 — Operator-planned mission is the cold-start trust anchor; FC GPS is secondary
**Context**: The original cold-start design (AZ-419 / FT-P-11) assumed the FC EKF's last valid GPS fix is available at takeoff to seed C5. Field reality contradicts this: a UAV operating in a contested-EW environment may have GPS jammed **before** takeoff (the jamming radius reaches the launch site, the unit launches under a jammer's umbrella, etc.). In that case the FC EKF has no GPS fix to give, and the companion has nothing to anchor the initial pose to — the entire downstream pipeline (VIO bootstrap, VPR retrieval scope, satellite anchoring) collapses or runs blind. At the same time, the parent suite already requires the operator to author a route in the **Mission Planner UI** (`suite/ui`) and persist it to the **`flights` REST service** (`suite/flights`) before any flight runs. The waypoint ordering is operationally meaningful: waypoint[0] is the planned takeoff point. The operator therefore already declares the takeoff position with operationally relevant accuracy (typically a few tens of metres) hours before launch, in a context that has no dependency on GPS at all. This information is the natural cold-start trust anchor.
**Decision**:
1. **`Flight` is read pre-flight, not in-flight.** C12 (the operator-side tool, separate binary from the airborne companion — per ADR-002) calls the parent-suite `flights` REST service via a typed client (AZ-489 `FlightsApiClient`) when the operator runs `gps-denied-cli build-cache --flight-id <Guid>`. An offline path (`--flight-file <path>`) reads the same DTO shape from a JSON export so the workflow survives operator workstations that have no path to the flights service. The companion binary **never** depends on the flights service at runtime (Principle #9 — denied-environment operation).
2. **C12 derives bbox + takeoff origin from the `Flight`.** The bbox is the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, AZ-489 AC-3). The takeoff origin is `Flight.waypoints[0].(lat, lon, alt)` — the operator's authored launch point.
3. **Both fields are baked into the C10 Manifest.** `BuildRequest` and `Manifest` carry `takeoff_origin: LatLonAlt | None` (AZ-323 / AZ-325 / AZ-324 amendments). The hash that drives D-C10-1 idempotence includes `takeoff_origin`, so a re-plan of the route produces a new cache identity and the verifier (AZ-324) rejects a mismatched cache at boot.
4. **C5 consumes the origin before any sensor sample.** The companion's composition root reads `takeoff_origin` from the cache manifest at boot and invokes `set_takeoff_origin(origin, sigma_m)` on the active `StateEstimator` (AZ-490) **before** the first `add_vio` / `add_fc_imu` call. Both `GtsamIsam2StateEstimator` and `EskfStateEstimator` accept the origin as a Bayesian prior — iSAM2 attaches a `PriorFactorPose3` on the initial pose key with covariance derived from `sigma_m` (default 50 m horizontal, 100 m vertical); ESKF seeds the nominal position and writes the position block of the error covariance to match `sigma_m^2`.
5. **FC GPS is a secondary, gated input.** If the FC EKF later produces a GPS reading (in-flight or at takeoff), it is fused through the existing `add_pose_anchor` machinery only after passing the three-part gate of Principle #11 — **including the ≤ 200 m bounded-delta check against the companion's last emitted `PoseEstimate`**. Real GPS that passes the gate is one more measurement, never an override.
6. **Failure modes.** If the Manifest has no `takeoff_origin` AND the FC EKF has no usable GPS at takeoff, C5 stays in `INITIALIZING` and the FC adapter (C8) emits a non-fused source label; the FT-P-11 takeoff-abort policy (AZ-419 amended) applies. If the Manifest has `takeoff_origin` AND the FC EKF GPS is wildly inconsistent with it at takeoff (e.g., > 200 m), the operator origin wins and the FC GPS is logged as suspect — this is the GPS-spoofed-at-takeoff case and is the entire point of this ADR.
**Alternatives considered**:
1. **Keep FC EKF as primary** (status quo of AZ-419) — rejected: cannot survive GPS-denied takeoff, which is in scope per Principles #1 and #9. Field reports of pre-launch jamming make this a realistic, not edge-case, failure mode.
2. **Operator types the origin into a CLI prompt at build-cache time** — rejected: duplicates information the Mission Planner UI already captures, drifts from the canonical route, and breaks if the operator re-plans without re-typing. The `Flight` DTO is the single source of truth.
3. **Pull `Flight` from the companion at runtime over a back-channel** — rejected: violates Principle #9 (denied-environment operation; no egress from the companion to anything other than the FC). The flights service is an **operator-workstation** concern only.
4. **Treat operator origin as a hard assignment instead of a prior** — rejected: a hard assignment cannot be fused with a later high-quality posterior, breaks ADR-003's "honest covariance" property, and prevents the `add_pose_anchor` fusion path from ever correcting the origin if it was authored with imprecision.
**Consequences**:
- AZ-419 (FT-P-11) is amended: the primary cold-start path is operator-origin-from-manifest; FC-EKF-GPS is the fallback path with its own sub-AC.
- C10 contracts gain a `takeoff_origin` field in `BuildRequest`, `Manifest`, and the verifier's validation set (AZ-323 / AZ-325 / AZ-324). Contract version bumps to v1.1.0.
- C5 gains a `set_takeoff_origin(origin, sigma_m)` method on the `StateEstimator` protocol (AZ-490). Protocol contract version bumps to v1.1.0.
- C12 gains the `FlightsApiClient` boundary + offline `--flight-file` path (AZ-489).
- Principle #11 (the spoofed-GPS gate) is extended with the bounded-delta clause; the gate now serves both takeoff and mid-flight.
- The companion binary's network surface is unchanged — only C12 (operator-side, separate binary) talks to the flights service.
@@ -22,6 +22,7 @@
| Method | Input | Output | Async | Error Types |
|--------|-------|--------|-------|-------------|
| `set_takeoff_origin` (AZ-490, ADR-010) | `origin: LatLonAlt, sigma_m: float` | `None` | No | `EstimatorConfigError`, `EstimatorFatalError` |
| `add_vio` | `VioOutput` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
| `add_pose_anchor` | `PoseEstimate` | `None` | No | `EstimatorDegradedError`, `EstimatorFatalError` |
| `add_fc_imu` | `ImuWindow` | `None` | No | `EstimatorDegradedError` |
@@ -76,7 +77,8 @@ C5 is bounded by design — no unbounded growth.
**State Management**:
- iSAM2 graph + Values + Marginals lifecycle for the flight.
- Source-label state machine: tracks the AC-NEW-2 / AC-NEW-8 spoofing-promotion gate (≥10 s + visual consistency check before re-promoting a previously-spoofed FC GPS source).
- Cold-start ladder (ADR-010, AZ-490): `set_takeoff_origin(origin, sigma_m)` MUST be invoked before any `add_vio` / `add_fc_imu` / `add_pose_anchor` call. iSAM2 attaches a `PriorFactorPose3` on the initial pose key with covariance derived from `sigma_m` (default 50 m horizontal, 100 m vertical); ESKF seeds the nominal position and writes the position-block of the error covariance to `sigma_m^2`. The method is idempotent within `INIT` state (re-invocation overwrites the prior); once the estimator transitions to `TRACKING`, further calls raise `EstimatorConfigError`.
- Source-label state machine: tracks the AC-NEW-2 / AC-NEW-8 spoofing-promotion gate (≥10 s + visual consistency check + ≤ 200 m bounded-delta before re-promoting a previously-spoofed FC GPS source).
- Last-anchor-age timer for AC-1.3 binning.
**Key Dependencies**:
@@ -88,9 +90,10 @@ C5 is bounded by design — no unbounded growth.
| Eigen | matches GTSAM | Lie-algebra math |
**Error Handling Strategy**:
- `EstimatorConfigError`: `set_takeoff_origin` called after `TRACKING` state, OR called with a malformed `LatLonAlt` / non-positive `sigma_m`. Caller must surface to operator; takeoff blocked.
- `EstimatorDegradedError`: factor add yielded poor convergence; covariance inflated; emit `EstimatorOutput` with degraded label.
- `EstimatorFatalError`: iSAM2 numerical failure, KEYFRAME_LIMIT exceeded, etc.; emit no `EstimatorOutput` for this tick. AC-5.2 fallback (3 s no estimate → FC IMU-only) applies.
- Spoof-promotion gate: never re-introduce a previously-spoofed FC GPS source until BOTH (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥ 10 s AND (ii) the next satellite-anchored frame agrees with the FC GPS within a configurable tolerance (AC-NEW-8). Document every reject in FDR + GCS STATUSTEXT.
- Spoof-promotion gate (Principle #11 amended, AZ-385 + AZ-490 follow-up): never re-introduce a previously-spoofed FC GPS source until ALL THREE hold — (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥ 10 s, (ii) the next satellite-anchored frame agrees with the FC GPS within a configurable tolerance, AND (iii) the FC's reported position is within ≤ 200 m of the companion's last emitted `PoseEstimate`. The same gate is applied at takeoff when a Manifest `takeoff_origin` is present: an FC GPS reading that disagrees with the operator origin by > 200 m is logged as suspect and the operator origin wins. Document every reject in FDR + GCS STATUSTEXT.
## 6. Extensions and Helpers
@@ -2,7 +2,7 @@
## 1. High-Level Overview
**Purpose**: build the **model-derived** pre-flight cache artifacts on top of an already-populated tile store, and verify them at takeoff. After C11 `TileDownloader` has fetched tiles into C6, C10 orchestrates: compile/deserialize TensorRT engines via C7 → batch each tile through C2's backbone for descriptors → atomically write FAISS HNSW index with SHA-256 sidecars (D-C10-3) → write Manifest with hash of (model + calibration + corpus + sector_class) for D-C10-1 idempotence. At F2 takeoff load, run `verify_manifest` (D-C10-3 SHA-256 content-hash gate) before allowing the system to arm.
**Purpose**: build the **model-derived** pre-flight cache artifacts on top of an already-populated tile store, and verify them at takeoff. After C11 `TileDownloader` has fetched tiles into C6, C10 orchestrates: compile/deserialize TensorRT engines via C7 → batch each tile through C2's backbone for descriptors → atomically write FAISS HNSW index with SHA-256 sidecars (D-C10-3) → write Manifest with hash of (model + calibration + corpus + sector_class **+ takeoff_origin**) for D-C10-1 idempotence. The `takeoff_origin` is supplied by C12 (derived from `Flight.waypoints[0]` via the `FlightsApiClient`, ADR-010 + AZ-489); C10 treats it as one more identity field and bakes it into both the Manifest body and the manifest-hash. At F2 takeoff load, run `verify_manifest` (D-C10-3 SHA-256 content-hash gate) before allowing the system to arm; the verifier also surfaces `takeoff_origin` so the companion's composition root can pass it to `C5.set_takeoff_origin(origin, sigma_m)` before any sensor sample (AZ-490).
**C10 does NOT touch `satellite-provider`.** Tile I/O — both download (F1 inbound) and post-landing upload (F10) — lives in C11 (Tile Manager). C10 reads tiles from C6, writes engines + descriptors + manifest to filesystem and Postgres. The split is operational: C11 carries the operator-side network identity (TLS API key for download, per-flight signing key for upload) and the airborne-exclusion property (ADR-004); C10 carries the model identity and the takeoff-load verifier — neither of which need to leave the workstation/companion enclave at runtime.
@@ -43,6 +43,8 @@ BuildRequest:
sector_class: enum {active_conflict, stable_rear} # baked into manifest
calibration_path: Path
cache_root: Path
takeoff_origin: LatLonAlt | None # ADR-010 / AZ-489; baked into manifest + hash
flight_id: UUID | None # ADR-010; pass-through provenance, baked into manifest
BuildReport:
engines_built: int
@@ -52,12 +54,14 @@ BuildReport:
outcome: enum {success, failure, idempotent_no_op}
failure_reason: string (optional)
Manifest: see data_model.md
Manifest: see data_model.md (carries takeoff_origin + flight_id when set; hash includes them)
EngineCacheEntry: see data_model.md
VerificationResult:
manifest_hash_match: bool
per_artifact_hash_match: dict[Path, bool]
takeoff_origin: LatLonAlt | None # passed through from manifest for C5 warm-start (AZ-490)
flight_id: UUID | None
outcome: enum {pass, fail}
fail_reasons: list[string]
```
@@ -2,14 +2,15 @@
## 1. High-Level Overview
**Purpose**: operator-facing tooling on the workstation that **sequences** the F1 cache-build workflow (calls C11 `TileDownloader` then C10 `CacheProvisioner`), drives sector classification, freshness pipeline, calibration loading, and (post-flight) triggers the C11 `TileUploader`. Provides the human-in-the-loop entry point for everything that needs operator judgement (active-conflict vs stable-rear sector classification, AC-3.4 ≥3-frame outage operator re-loc requests, network configuration to `satellite-provider`).
**Purpose**: operator-facing tooling on the workstation that **sequences** the F1 cache-build workflow (calls `FlightsApiClient` C11 `TileDownloader` C10 `CacheProvisioner`), drives sector classification, freshness pipeline, calibration loading, and (post-flight) triggers the C11 `TileUploader`. Provides the human-in-the-loop entry point for everything that needs operator judgement (active-conflict vs stable-rear sector classification, AC-3.4 ≥3-frame outage operator re-loc requests, network configuration to `satellite-provider`).
**Architectural Pattern**: Coordinator (single concrete `OperatorTooling` today). Two interfaces: `CacheBuildWorkflow` (pre-flight UX) and `OperatorReLocService` (mid-flight operator re-loc requests, AC-3.4). The latter is consumed via the GCS link by C8 (operator commands subscription).
**Architectural Pattern**: Coordinator (single concrete `OperatorTooling` today). Three interfaces: `CacheBuildWorkflow` (pre-flight UX), `FlightsApiClient` (read the operator-authored `Flight` from the parent-suite `flights` REST service or a local JSON export — AZ-489, ADR-010), and `OperatorReLocService` (mid-flight operator re-loc requests, AC-3.4). `OperatorReLocService` is consumed via the GCS link by C8 (operator commands subscription); `FlightsApiClient` is operator-workstation-only and never reaches the airborne companion (Principle #9).
**Upstream dependencies**:
- Operator (human input).
- C11 `TileDownloader` (operator-workstation-side) — invoked first in F1 to populate C6 from `satellite-provider`.
- C10 CacheProvisioner (companion-side) — invoked second in F1, over USB/Eth.
- Operator (human input — flight ID or flight file path, sector classification, calibration path).
- `flights` REST service (parent-suite `suite/flights/`) — read via `FlightsApiClient` to fetch the operator-authored `Flight` (waypoints + altitudes); offline alternative is a local JSON export in the same DTO shape.
- C11 `TileDownloader` (operator-workstation-side) — invoked second in F1 to populate C6 from `satellite-provider` for the bbox derived from the `Flight`.
- C10 CacheProvisioner (companion-side) — invoked third in F1, over USB/Eth, with `takeoff_origin = Flight.waypoints[0]` and the computed bbox.
- Camera calibration artifact (operator workstation filesystem).
**Downstream consumers**:
@@ -24,12 +25,21 @@
| Method | Input | Output | Async | Error Types |
|--------|-------|--------|-------|-------------|
| `build_cache` | `bbox, sector_class, calibration_path, satellite_provider_url, api_key` | `CacheBuildReport` (wraps C11 `DownloadBatchReport` + C10 `BuildReport`) | No (operator-facing; minutes) | `CacheBuildError` (wraps SatelliteProviderError, EngineBuildError, etc.) |
| `build_cache` | `flight_id` (online) OR `flight_file: Path` (offline), `sector_class`, `calibration_path`, `satellite_provider_url`, `api_key` | `CacheBuildReport` (wraps `FlightResolveReport` + C11 `DownloadBatchReport` + C10 `BuildReport`) | No (operator-facing; minutes) | `CacheBuildError` (wraps `FlightNotFoundError`, `FlightsApiUnreachableError`, `SatelliteProviderError`, `EngineBuildError`, etc.) |
| `trigger_post_landing_upload` | `flight_id` | C11 `UploadBatchReport` | No (operator-facing; minutes) | `CacheBuildError` wrapper around `FlightStateNotOnGroundError`, `SignatureRejectedError`, etc. |
| `verify_companion_ready` | `companion_address` | `ReadinessReport` | No | `CompanionUnreachableError`, `ContentHashMismatchError` |
| `set_sector_classification` | `area, sector_class` | `None` | No | — |
| `apply_freshness_threshold` | `sector_class` | `int (months)` | No | — |
### Interface: `FlightsApiClient` (AZ-489, ADR-010)
| Method | Input | Output | Async | Error Types |
|--------|-------|--------|-------|-------------|
| `fetch_flight` | `flight_id: UUID, base_url: str, auth_token: str` | `FlightDto` (with ordered `WaypointDto[]`) | No (HTTPS; seconds) | `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError` |
| `load_flight_file` | `path: Path` | `FlightDto` (same shape as `fetch_flight`) | No | `FlightFileNotFoundError`, `FlightsApiSchemaError` |
| `bbox_from_waypoints` | `waypoints: list[WaypointDto], buffer_m: float` | `BoundingBox` | No | `EmptyWaypointsError` |
| `takeoff_origin_from_flight` | `flight: FlightDto` | `LatLonAlt` (from `waypoints[0]`) | No | `EmptyWaypointsError` |
### Interface: `OperatorReLocService`
| Method | Input | Output | Async | Error Types |
@@ -38,11 +48,23 @@
**Input/Output DTOs**:
```
FlightDto: mirror of suite/flights/Database/Entities/Flight.cs (id, name, status, waypoints, ...)
WaypointDto: mirror of suite/flights/Database/Entities/Waypoint.cs (ordinal, lat, lon, alt, objective, source)
FlightResolveReport:
source: enum {flights_api, flight_file}
flight_id: UUID
waypoint_count: int
bbox: BoundingBox (computed envelope + buffer)
takeoff_origin: LatLonAlt (waypoints[0])
raw_flight_dto: FlightDto (preserved for FDR + debug)
CacheBuildReport:
flight_resolve_report: FlightResolveReport
download_report: DownloadBatchReport (see C11 spec)
build_report: BuildReport (see C10 spec)
outcome: enum {success, failure, idempotent_no_op}
failure_phase: enum {download, build, none}
failure_phase: enum {flight_resolve, download, build, none}
failure_reason: string (optional)
ReadinessReport:
@@ -50,6 +72,7 @@ ReadinessReport:
content_hashes_pass: bool
engines_present: bool
calibration_present: bool
takeoff_origin_in_manifest: bool # ADR-010: warn-but-not-fail if absent (FC-EKF fallback path remains)
outcome: enum {ready, not_ready}
not_ready_reasons: list[string]
@@ -63,7 +86,8 @@ ReLocHint:
C12 itself does NOT expose HTTP. It **consumes**:
- `satellite-provider`'s REST API **only via C11 `TileDownloader`** — C12 itself never holds the TLS / API-key credentials. The credential boundary is C11 (operator-workstation-side); C12 sequences and reports.
- `satellite-provider`'s REST API **only via C11 `TileDownloader`** — C12 itself never holds the TLS / API-key credentials for satellite tiles. The credential boundary is C11 (operator-workstation-side); C12 sequences and reports.
- The parent-suite `flights` REST service via its own `FlightsApiClient` (AZ-489, ADR-010). C12 holds the TLS + suite credentials for the flights service because the `Flight` shape is a C12-owned concern (bbox + takeoff-origin derivation). The companion never reaches the flights service.
- The GCS link's MAVLink path (operator commands path), for AC-3.4 re-loc requests.
C12 may eventually expose a thin local HTTP API for an operator GUI. **Plan-phase carryforward, deferred** — the current cycle ships only a CLI.
@@ -98,10 +122,14 @@ C12 holds the operator workstation's local cache staging area + per-area sector
| Click / Typer | per project pin | CLI framework |
| paramiko / ssh / fabric | per project pin | Companion bring-up over SSH (USB/Eth) |
| pymavlink | bundled per D-C8-3 | GCS-link operator commands (AC-3.4) |
| (httpx is NOT a direct C12 dep) | — | All `satellite-provider` HTTP traffic is owned by C11; C12 calls C11 in-process |
| httpx | per project pin | `FlightsApiClient` HTTPS calls to the parent-suite `flights` REST service (AZ-489) |
| pydantic | per project pin | `FlightDto` / `WaypointDto` validation in `FlightsApiClient` (online + offline paths) |
| (`satellite-provider` HTTP traffic stays owned by C11) | — | C12 calls C11 in-process |
**Error Handling Strategy**:
- `CacheBuildError`: wraps the underlying error from C11/C10/C7/C6 with operator-friendly text + remediation hint. Includes `failure_phase: download | build` so the operator knows which step to retry.
- `CacheBuildError`: wraps the underlying error from C11/C10/C7/C6/`FlightsApiClient` with operator-friendly text + remediation hint. Includes `failure_phase: flight_resolve | download | build` so the operator knows which step to retry.
- `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`: surfaced by `FlightsApiClient` (online path). Offline path raises `FlightFileNotFoundError` / `FlightsApiSchemaError`. Each maps to an actionable operator hint.
- `EmptyWaypointsError`: the resolved `Flight` carries zero waypoints — cannot derive bbox or takeoff origin; refuse build.
- `CompanionUnreachableError`: pre-flight bring-up failure; operator must check the wire/SSH config.
- `ContentHashMismatchError`: post-stage tampering detected; refuse to mark as ready; operator must re-run F1.
- `GcsLinkError` (AC-3.4 path): GCS link drop. Re-loc request is best-effort; operator may need to re-issue when link recovers.
@@ -66,6 +66,8 @@ class BuildRequest:
calibration_path: Path
cache_root: Path
key_path: Path # operator signing key per C10-ST-01
takeoff_origin: LatLonAlt | None = None # ADR-010 + AZ-489: planned takeoff position from Flight.waypoints[0]; baked into Manifest body + build-identity hash
flight_id: UUID | None = None # ADR-010: pass-through provenance of which Flight produced the build
@dataclass(frozen=True)
@@ -105,6 +107,8 @@ class BuildReport:
| CP-INV-5 | `cache_root` must already exist; `build_cache_artifacts` does NOT create the directory tree (operator workflow places it). | Avoids accidental builds in unintended paths. |
| CP-INV-6 | No network calls (no `satellite-provider`, no Postgres TLS to a remote DB beyond the local instance, no metric push). | Epic § Architecture notes: C10 is workstation-local. |
| CP-INV-7 | The operator key file at `request.key_path` is opened exactly once (via AZ-323's signer) and zeroized when out of scope; this contract does NOT cache the key in memory across calls. | Operator key hygiene. |
| CP-INV-8 | `takeoff_origin` is treated as one more identity field by the build-identity hash. If the prior Manifest carries `takeoff_origin=A` and a new request carries `takeoff_origin=B != A` (with all other fields equal), the build is NOT idempotent and proceeds; the verifier (AZ-324) at boot then refuses any cache whose manifest origin disagrees with the manifest-on-disk's origin. | ADR-010: cache identity must include the origin or boot-time consistency breaks. |
| CP-INV-9 | When `takeoff_origin` is None, the prior cold-start ladder (FC-EKF-GPS via AZ-419) remains the only origin source. C10 does not invent a default origin from the bbox; that decision is for C12. | Single-responsibility — C10 records, C12 decides. |
## Non-Goals
@@ -125,6 +129,7 @@ class BuildReport:
| Version | Date | Notes | Author |
|---------|------|-------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-325 (E-C10 decomposition) | autodev |
| 1.1.0 | 2026-05-11 | Additive: `BuildRequest.takeoff_origin` + `BuildRequest.flight_id` (defaults `None` for back-compat); CP-INV-8 + CP-INV-9. Consumer requires the Manifest hash to include `takeoff_origin` when set. ADR-010 + AZ-489. | autodev |
## Test Cases (consumer side)
@@ -143,3 +148,6 @@ class BuildReport:
| CP-TC-11 | `compile_engines_for_corpus` directly callable for re-compile-only flows | Returns `tuple[EngineCacheEntry, ...]`; no descriptor / Manifest work |
| CP-TC-12 | Cold build wall-clock benchmark on Tier-1 dev workstation, 1k tiles, 3 backbones | ≤ 12 min (NFR C10-PT-01) |
| CP-TC-13 | Warm idempotent re-run benchmark | ≤ 1 min (NFR C10-PT-01) |
| CP-TC-14 | Build with `takeoff_origin=A` → second build with same request + `takeoff_origin=A` | `outcome=IDEMPOTENT_NO_OP` |
| CP-TC-15 | Build with `takeoff_origin=A` → second build with same request + `takeoff_origin=B (B != A)` | `outcome=SUCCESS` (re-build); new Manifest hash differs from prior |
| CP-TC-16 | `BuildRequest.takeoff_origin=None` with no prior Manifest | `outcome=SUCCESS`; Manifest written without `takeoff_origin` field |
@@ -62,6 +62,8 @@ class VerifyFailReason(Enum):
ARTIFACT_HASH_MISMATCH = "artifact_hash_mismatch"
TILES_COVERAGE_MISMATCH = "tiles_coverage_mismatch"
MANIFEST_SELF_HASH_MISMATCH = "manifest_self_hash_mismatch"
TAKEOFF_ORIGIN_INVALID = "takeoff_origin_invalid" # ADR-010: schema check on the LatLonAlt block
TAKEOFF_ORIGIN_OUT_OF_BBOX = "takeoff_origin_out_of_bbox" # ADR-010: origin must lie inside the cache bbox
@dataclass(frozen=True)
@@ -79,6 +81,8 @@ class VerificationResult:
fail_details: tuple[str, ...] # human-readable diagnostic per reason
signing_public_key_fingerprint: str | None # populated when signature parses, even if untrusted
per_artifact_checks: tuple[ArtifactCheck, ...]
takeoff_origin: LatLonAlt | None # ADR-010 + AZ-490: passed through from Manifest body; None when Manifest carries no origin
flight_id: UUID | None # ADR-010: provenance of which Flight produced the build
elapsed_ms: int
```
@@ -93,6 +97,8 @@ class VerificationResult:
| MV-INV-5 | `tiles_coverage` mismatch is reported separately from `ARTIFACT_HASH_MISMATCH` because tiles are hashed in aggregate (per AZ-323). The verifier re-derives the aggregate hash from a `TileMetadataStore` query if available, OR (in airborne F2 mode) treats the recorded `tiles_coverage_sha256` as authoritative and only verifies the Manifest signature + non-tile artifacts. | Airborne C5 may not load 100k per-tile rows just to arm; the trust chain is signature → manifest_hash → tiles_coverage_sha256. C12 / operator mode does the full re-derivation. |
| MV-INV-6 | The verifier never writes to disk, never opens network sockets, never calls C13. Telemetry is the caller's responsibility. | Read-only contract — composable in airborne C5 + operator C12 contexts without side-effect surprise. |
| MV-INV-7 | `elapsed_ms` is recorded for every call (pass or fail) so operators and C5 can observe drift in verify cost on slow disks. | NFR for C10-PT-01's takeoff load budget. |
| MV-INV-8 | When the Manifest body carries a `takeoff_origin`, the verifier checks: (a) the LatLonAlt block is well-formed (`-90 ≤ lat ≤ 90`, `-180 ≤ lon ≤ 180`, `alt` finite) → `TAKEOFF_ORIGIN_INVALID` otherwise, (b) the lat/lon falls inside the Manifest's `bbox``TAKEOFF_ORIGIN_OUT_OF_BBOX` otherwise. When the Manifest body carries no `takeoff_origin`, the field is absent from `VerificationResult` (None) and no origin check runs. | ADR-010: garbage / out-of-bbox origin must not silently propagate to `C5.set_takeoff_origin`. |
| MV-INV-9 | `takeoff_origin` is surfaced on `VerificationResult` even on `FAIL` outcomes when the Manifest body parsed (so caller can inspect what was attempted), but the takeoff-arming gate only consumes it on `PASS`. | Diagnostics — operators can see "your origin was X and that's why we rejected it". |
## Non-Goals
@@ -112,6 +118,7 @@ class VerificationResult:
| Version | Date | Notes | Author |
|---------|------|-------|--------|
| 1.0.0 | 2026-05-10 | Initial contract — produced by AZ-324 (E-C10 decomposition) | autodev |
| 1.1.0 | 2026-05-11 | Additive: `VerificationResult.takeoff_origin` + `flight_id`; new `VerifyFailReason.TAKEOFF_ORIGIN_INVALID` + `TAKEOFF_ORIGIN_OUT_OF_BBOX`; MV-INV-8 + MV-INV-9. ADR-010 + AZ-490. | autodev |
## Test Cases (consumer side)
@@ -132,3 +139,7 @@ class VerificationResult:
| MV-TC-13 | Tier-2 Tile-coverage check (operator mode with TileMetadataStore) | If recomputed `tiles_coverage_sha256` differs → `TILES_COVERAGE_MISMATCH`; if matches → that part passes |
| MV-TC-14 | Empty `trusted_public_keys` | `outcome=FAIL`, `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` (every key is untrusted by definition) |
| MV-TC-15 | Pristine Manifest verified inside 100 ms on Tier-2 (excludes per-tile re-walk) | `elapsed_ms ≤ 100` for the signature + non-tile artifact path |
| MV-TC-16 | Manifest body carries no `takeoff_origin` | `outcome=PASS`; `VerificationResult.takeoff_origin is None` |
| MV-TC-17 | Manifest body carries a well-formed `takeoff_origin` inside bbox | `outcome=PASS`; `VerificationResult.takeoff_origin` populated as `LatLonAlt` |
| MV-TC-18 | Manifest body carries a malformed `takeoff_origin` (lat = 200) | `outcome=FAIL`, `fail_reasons` contains `TAKEOFF_ORIGIN_INVALID`; `takeoff_origin` field is populated for diagnostics |
| MV-TC-19 | Manifest body carries `takeoff_origin` outside the recorded `bbox` | `outcome=FAIL`, `fail_reasons` contains `TAKEOFF_ORIGIN_OUT_OF_BBOX` |
@@ -0,0 +1,162 @@
# Contract: flights_api_client
**Component**: c12_operator_tooling
**Producer task**: AZ-489 — `_docs/02_tasks/todo/AZ-489_c12_flights_api_client.md`
**Consumer tasks**: AZ-326 (CLI app — wires `--flight-id` / `--flight-file` flags), AZ-328 (build-cache orchestrator — calls `fetch_flight` / `load_flight_file`, then `bbox_from_waypoints` + `takeoff_origin_from_flight`)
**Version**: 1.0.0
**Status**: draft
**Last Updated**: 2026-05-11
## Purpose
Defines the operator-workstation ↔ parent-suite `flights` REST service boundary plus an offline fallback for the same DTO shape. C12 consumes a typed `FlightDto` (waypoints + altitudes) to derive the cache bbox and the takeoff origin per ADR-010. The companion never sees this contract; it lives entirely on the operator workstation.
The boundary is split into two sources that produce the **same DTO shape**:
- **Online**: `GET /flights/{id}` + `GET /flights/{id}/waypoints` against the parent-suite `flights` REST service.
- **Offline**: a local JSON export in the same DTO shape (for operators without a workstation-to-flights path, e.g., field-deployed laptops).
## Shape
### DTOs
```python
from dataclasses import dataclass
from enum import Enum
from typing import Protocol, runtime_checkable
from uuid import UUID
from pathlib import Path
from gps_denied_onboard._types.geo import LatLonAlt, BoundingBox
class WaypointObjective(Enum):
TAKEOFF = "takeoff"
WAYPOINT = "waypoint"
LOITER = "loiter"
LANDING = "landing"
OTHER = "other"
class WaypointSource(Enum):
OPERATOR = "operator"
IMPORT = "import"
OTHER = "other"
@dataclass(frozen=True, slots=True)
class WaypointDto:
ordinal: int # >= 0; defines the order of the waypoint inside the Flight
lat_deg: float # -90 <= lat <= 90
lon_deg: float # -180 <= lon <= 180
alt_m: float # WGS84 ellipsoidal height; finite
objective: WaypointObjective
source: WaypointSource
@dataclass(frozen=True, slots=True)
class FlightDto:
flight_id: UUID
name: str
waypoints: tuple[WaypointDto, ...] # ORDERED by ordinal ascending; non-empty
```
### Protocol
```python
@runtime_checkable
class FlightsApiClient(Protocol):
"""Read a Flight from the parent-suite flights REST service or a local JSON export.
Pure read; no side effects beyond logging. Caller (C12 CacheBuildWorkflow)
decides which source to use based on CLI flags (--flight-id vs --flight-file).
"""
def fetch_flight(
self,
*,
flight_id: UUID,
base_url: str,
auth_token: str,
timeout_s: float = 10.0,
) -> FlightDto: ...
def load_flight_file(self, *, path: Path) -> FlightDto: ...
def bbox_from_waypoints(
self,
waypoints: tuple[WaypointDto, ...],
*,
buffer_m: float = 1000.0,
) -> BoundingBox: ...
def takeoff_origin_from_flight(self, flight: FlightDto) -> LatLonAlt: ...
```
### Exceptions
| Exception | When raised | Caller action |
|-----------|-------------|---------------|
| `FlightsApiUnreachableError` | HTTPS timeout / connection refused / 5xx | Operator retries online when network recovers OR switches to `--flight-file` offline path |
| `FlightsApiAuthError` | HTTP 401 / 403 | Operator refreshes suite credentials; never silently fall back to offline |
| `FlightNotFoundError` | HTTP 404 for the given `flight_id` | Operator verifies the GUID in the Mission Planner UI |
| `FlightsApiSchemaError` | Response body fails `FlightDto` validation (online) OR JSON file fails the same validation (offline) | Operator re-exports from the Mission Planner UI; bug in the schema map is a release-blocking defect |
| `FlightFileNotFoundError` | `path` does not exist (offline) | Operator confirms the path |
| `EmptyWaypointsError` | Resolved `FlightDto` carries zero waypoints — `bbox_from_waypoints` / `takeoff_origin_from_flight` cannot proceed | Operator re-plans in Mission Planner UI |
| `WaypointSchemaError` | Individual `WaypointDto` is malformed (lat out of range, NaN alt, negative ordinal, gap in ordering) | Operator re-exports / re-plans |
The Protocol does NOT catch these inside the methods — it raises and lets `CacheBuildWorkflow` translate to `CacheBuildError` with the appropriate `failure_phase=flight_resolve`.
## Invariants
| ID | Invariant | Why |
|----|-----------|-----|
| FAC-INV-1 | The online (`fetch_flight`) and offline (`load_flight_file`) paths return DTOs with **the same shape and the same validation contract**. The only difference is where the bytes come from. | Caller can swap paths without conditional handling. |
| FAC-INV-2 | `FlightDto.waypoints` is non-empty and is ordered by ascending `ordinal`. Implementations MUST sort + validate the ordering. | `waypoints[0]` is the takeoff origin per ADR-010; ordering is operationally meaningful. |
| FAC-INV-3 | `bbox_from_waypoints` envelopes the lat/lon of every waypoint and inflates by the `buffer_m` parameter (default 1 km). The buffer is a horizontal-distance expansion, not a degree-space expansion — implementations use `WgsConverter` to inflate correctly at the Flight's latitude. | A degree-space buffer would be 1.5× too narrow at high latitudes and miss tiles near the poles. |
| FAC-INV-4 | `takeoff_origin_from_flight` returns `LatLonAlt(waypoints[0].lat_deg, waypoints[0].lon_deg, waypoints[0].alt_m)` — no rounding, no projection. | The operator authored this point; we pass it through. |
| FAC-INV-5 | `fetch_flight` issues at most ONE retry on transient 5xx (with 1 s backoff) and at most ONE retry on connection error. 401/403/404/schema failures are NOT retried. | Operator-visible failures should be loud; transient blips should not require operator intervention. |
| FAC-INV-6 | `fetch_flight` is the ONLY method that makes network calls. `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight` are pure / filesystem-only. | Composability — the offline path is fully usable on an air-gapped operator workstation. |
| FAC-INV-7 | `auth_token` is never logged; structured logs redact the field. | Operator credential hygiene. |
| FAC-INV-8 | No write methods. This client is strictly read-only against the flights service. | Single responsibility — C12 does not author flights. |
## Non-Goals
- Flight authoring / editing — the Mission Planner UI (`suite/ui`) owns that.
- Live updates / websockets — pre-flight only.
- Caching the `FlightDto` across runs — every `build-cache` invocation re-fetches OR re-reads the file.
- Posting build status back to the `flights` REST service — out of scope this cycle.
## Versioning
- v1.0.0 — initial Protocol surface (this document).
- Breaking changes (changing DTO shape, removing a method) — bump major.
- Additive changes (new optional kwarg, new enum value) — bump minor. Consumers MUST handle unknown enum values gracefully.
- Patch — clarifications, doc edits.
| Version | Date | Notes | Author |
|---------|------|-------|--------|
| 1.0.0 | 2026-05-11 | Initial contract — produced by AZ-489 (ADR-010 cold-start origin via operator-planned mission) | autodev |
## Test Cases (consumer side)
| ID | Scenario | Expected Outcome |
|----|----------|------------------|
| FAC-TC-1 | Online happy path: valid `flight_id`, reachable service, 3 waypoints | Returns `FlightDto` with 3 ordered waypoints |
| FAC-TC-2 | Online 404 (unknown flight_id) | Raises `FlightNotFoundError`; does NOT retry |
| FAC-TC-3 | Online 401 (bad auth_token) | Raises `FlightsApiAuthError`; does NOT retry; does NOT log token |
| FAC-TC-4 | Online 503 transient | Retries once with 1 s backoff; succeeds; returns DTO |
| FAC-TC-5 | Online 503 persistent | Raises `FlightsApiUnreachableError` after one retry |
| FAC-TC-6 | Online connection refused | Retries once; raises `FlightsApiUnreachableError` after one retry |
| FAC-TC-7 | Online schema drift (response missing `lat`) | Raises `FlightsApiSchemaError` with field reference |
| FAC-TC-8 | Offline happy path: well-formed JSON | Returns equivalent `FlightDto` |
| FAC-TC-9 | Offline file missing | Raises `FlightFileNotFoundError` |
| FAC-TC-10 | Offline JSON missing waypoints array | Raises `FlightsApiSchemaError` |
| FAC-TC-11 | Empty waypoints | `fetch_flight` / `load_flight_file` accept but downstream `bbox_from_waypoints` raises `EmptyWaypointsError` |
| FAC-TC-12 | Waypoint with `lat=200` | Raises `WaypointSchemaError` during DTO validation |
| FAC-TC-13 | Waypoints out of ordinal order | Implementation sorts + validates; returns `FlightDto` with sorted tuple |
| FAC-TC-14 | Waypoints with ordinal gap (0, 1, 3) | Raises `WaypointSchemaError` (gap implies missing waypoint, not robust to silent reorder) |
| FAC-TC-15 | `bbox_from_waypoints(buffer_m=1000)` at mid-latitudes (~50°N) | Returned bbox extends ~1 km horizontally on all sides, NOT 1° in degree space |
| FAC-TC-16 | `takeoff_origin_from_flight(flight)` with `waypoints[0] = (50.0, 36.2, 200.0)` | Returns `LatLonAlt(50.0, 36.2, 200.0)` exactly |
| FAC-TC-17 | Conformance: `isinstance(impl, FlightsApiClient)` | `True` |
| FAC-TC-18 | Online + Offline produce byte-identical `FlightDto` for the same source | `assert online_dto == offline_dto` |
@@ -2,8 +2,8 @@
**Owner**: c5_state (epic AZ-260 / E-C5)
**Producer task**: AZ-381 (Protocol + DTOs + factory + composition + concrete `ISam2GraphHandle`)
**Consumer tasks**: AZ-382 (iSAM2 + IncrementalFixedLagSmoother wiring), AZ-383 (Factor adds), AZ-384 (Marginals + outputs), AZ-385 (Source-label + spoof gate), AZ-386 (ESKF baseline), AZ-387 (Smoothed history → FDR), AZ-388 (AC-5.2 fallback), AZ-389 (Orthorectifier → C6).
**Version**: 1.0.0
**Consumer tasks**: AZ-382 (iSAM2 + IncrementalFixedLagSmoother wiring), AZ-383 (Factor adds), AZ-384 (Marginals + outputs), AZ-385 (Source-label + spoof gate), AZ-386 (ESKF baseline), AZ-387 (Smoothed history → FDR), AZ-388 (AC-5.2 fallback), AZ-389 (Orthorectifier → C6), AZ-490 (set_takeoff_origin — operator-provided warm-start).
**Version**: 1.1.0
**Status**: active
**Last Updated**: 2026-05-11
**Module-layout home**: `src/gps_denied_onboard/components/c5_state/interface.py`, `src/gps_denied_onboard/components/c5_state/__init__.py`, `src/gps_denied_onboard/runtime_root/state_factory.py`
@@ -23,6 +23,9 @@ The shared `ImuPreintegrator` (AZ-276), `SE3Utils` (AZ-277), and `WgsConverter`
```python
@runtime_checkable
class StateEstimator(Protocol):
# AZ-490 / ADR-010: operator-provided warm-start. MUST be called before any add_*.
def set_takeoff_origin(self, origin: LatLonAlt, sigma_m: float) -> None: ...
def add_vio(self, vio: VioOutput) -> None: ...
def add_pose_anchor(self, pose: PoseEstimate) -> None: ...
def add_fc_imu(self, imu_window: ImuWindow) -> None: ...
@@ -43,6 +46,8 @@ class StateEstimator(Protocol):
8. **Spoof-rejection events ALWAYS land in FDR + GCS STATUSTEXT** — never silent (R07; C5-ST-01).
9. **AC-5.2 fallback on 3 s no-estimate** — if `current_estimate()` would raise OR the keyframe window is empty for ≥3 s, downstream C8 emits FC IMU-only.
10. **`covariance_6x6` is always SPD** — both strategies enforce; on numerical failure raise `EstimatorFatalError`.
11. **`set_takeoff_origin(origin, sigma_m)` is a `INIT`-state-only entrypoint** (AZ-490, ADR-010). Calling it after the estimator has transitioned to `TRACKING` raises `StateEstimatorConfigError`. Inside `INIT` it is idempotent — re-invocation overwrites the prior with the new origin + sigma. `sigma_m` MUST be positive and finite; otherwise raise `StateEstimatorConfigError`. The origin is consumed as a Bayesian prior on the initial pose key (iSAM2: `PriorFactorPose3` with covariance = `diag(sigma_m^2, sigma_m^2, (2*sigma_m)^2, ...)` in ENU position + orientation order; ESKF: nominal-state seed + position-block covariance = `sigma_m^2 * I_3`).
12. **Spoof-promotion gate has THREE clauses, not two** — Principle #11 amended. Re-promote a previously-spoofed FC GPS source only when ALL of: (i) FC `gps_health == STABLE_NON_SPOOFED` for ≥ `spoof_promotion_min_stable_s`, (ii) the next satellite-anchored frame agrees with the FC GPS within `spoof_promotion_visual_consistency_tol_m`, AND (iii) the FC's reported position is within `spoof_promotion_bounded_delta_m` (default 200 m) of the companion's last emitted `PoseEstimate`. The bounded-delta clause also gates the takeoff path when a Manifest `takeoff_origin` is present.
### DTOs (in `_types/state.py`)
@@ -111,7 +116,10 @@ Config schema additions:
- `config.state.keyframe_window_size` (int, default 15) — D-C5-3 K=1020
- `config.state.spoof_promotion_min_stable_s` (float, default 10.0) — AC-NEW-2
- `config.state.spoof_promotion_visual_consistency_tol_m` (float, default 30.0) — AC-NEW-8
- `config.state.spoof_promotion_bounded_delta_m` (float, default 200.0) — Principle #11 amended, ADR-010
- `config.state.no_estimate_fallback_s` (float, default 3.0) — AC-5.2
- `config.state.default_takeoff_origin_sigma_horiz_m` (float, default 50.0) — AZ-490 default horizontal sigma when caller omits
- `config.state.default_takeoff_origin_sigma_vert_m` (float, default 100.0) — AZ-490 default vertical sigma when caller omits
## Test expectations summarised by Invariant
@@ -127,6 +135,11 @@ Config schema additions:
| 8 | Spoof-rejection logging | FDR + GCS STATUSTEXT both fire on every gate decision |
| 9 | AC-5.2 timeout | 3 s no estimate → fallback signal emitted |
| 10 | SPD covariance | every emitted `covariance_6x6` is SPD |
| 11a | `set_takeoff_origin` after `TRACKING` | raises `StateEstimatorConfigError` |
| 11b | `set_takeoff_origin` with `sigma_m <= 0` or non-finite | raises `StateEstimatorConfigError` |
| 11c | `set_takeoff_origin` twice in `INIT` | second call wins; covariance updated to new sigma |
| 11d | First `current_estimate` after `set_takeoff_origin` + no sensor samples | returns `EstimatorOutput` with `position_wgs84 == origin`, `covariance_6x6` reflecting `sigma_m^2` in the position block |
| 12 | Bounded-delta gate | FC GPS frame with |Δ| > 200 m vs last emitted `PoseEstimate` is rejected even when stable + non-spoofed for ≥ 10 s + visual-consistent |
## Producer-task / consumer-task split
+13 -1
View File
@@ -38,6 +38,10 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
**FDR / Flight Data Recorder** — Per-flight onboard NVM record (≤64 GB) of estimates, IMU traces, MAVLink stream, mid-flight tiles, system health, failed-tile thumbnails. Excludes raw nav/AI-camera frames. (source: AC-NEW-3)
**Flight** — Operator-authored mission persisted in the parent-suite `flights` REST service. Carries an ordered list of `Waypoint` entries (lat / lon / alt / objective / source). The DTO shape mirrors `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. C12 reads the `Flight` pre-flight to derive the cache bbox + takeoff origin (AZ-489); the companion never reaches the flights service. (source: ADR-010, AZ-489)
**Flights API / `flights` REST service** — Parent-suite .NET 8 REST microservice (`suite/flights/`) that owns `Flight` + `Waypoint` persistence. Read pre-flight by C12 `FlightsApiClient` over HTTPS. Operator-workstation-only — never reached from the airborne companion (Principle #9). (source: ADR-010, AZ-489)
**Flight state** — Boolean signal `IN_AIR | ON_GROUND` derived from FC `MAV_STATE` (MAVLink HEARTBEAT). Safety-critical: gates the post-landing upload path; `IN_AIR` forbids any outbound write to `satellite-provider`. Enforced primarily by process-level isolation — the Tile Manager (C11), which carries both the `TileDownloader` and the `TileUploader`, is not loaded in the airborne companion image. (source: user directive 2026-05-09)
**GCS / Ground Control Station** — QGroundControl. Mission Planner is out of scope. (source: `restrictions.md`)
@@ -52,15 +56,19 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
**Jetson Orin Nano Super** — Pinned companion compute: 67 TOPS sparse INT8, 8 GB shared LPDDR5, 25 W TDP, JetPack/CUDA/TensorRT. (source: `restrictions.md`)
**Mid-flight bounded-delta GPS gate** — Third clause of Principle #11. Even when FC GPS health is "stable + non-spoofed for ≥ 10 s" and the visual/satellite consistency check has succeeded, the FC's reported position must be within ≤ 200 m (configurable) of the companion's last emitted `PoseEstimate` before the FC GPS is fused via `add_pose_anchor`. Catches "FC reports stable GPS but the value is wrong". (source: Principle #11 amended, ADR-010)
**Mid-flight tile generation** — Companion orthorectifies nav-camera frames into basemap-projected tiles in flight, deduplicates, stores locally in `satellite-provider`-compatible format. NO outbound upload while airborne — upload happens post-landing only. (source: AC-8.4, user directive 2026-05-09)
**Mission Planner UI** — Parent-suite operator-facing web UI at `suite/ui/` where operators author flight routes (waypoints + altitudes + objectives) before C12 cache provisioning. Persists routes to the `flights` REST service. Out of scope for this project's deliverables, but the `Flight` DTO it produces IS in scope as an inbound boundary. Not to be confused with the GCS-side "Mission Planner" desktop tool — that is out of scope (only QGroundControl is the supported GCS). (source: ADR-010)
**Mission profile** — 8 h flight, ~150 km² operational sector + ~50 km² transit corridor, ≤400 km² total cached, ~60 km/h cruise, ≤1 km AGL, eastern/southern Ukraine. (source: `restrictions.md`)
**`MSP2_SENSOR_GPS`** — MSP2 message used as the per-frame FC delivery channel for iNav (iNav has no inbound MAVLink external-positioning handler). (source: `restrictions.md`, AC-4.3)
**Nav camera / Navigation camera** — The fixed-downward (no gimbal) camera on the UAV; pinned model is `adti20`. Distinct from the operator-controlled AI camera. (source: `restrictions.md` §Cameras)
**Operator** — Pre-flight and post-flight human role: classifies the operational area (active-conflict vs stable rear), drives the **Tile Manager** to download tiles from `satellite-provider`, stages calibration onto the companion before takeoff, and after landing triggers the **Tile Manager** upload run. (source: `problem.md`, AC-3.4 / AC-6.2, user confirmation 2026-05-09)
**Operator** — Pre-flight and post-flight human role: authors the flight route in the **Mission Planner UI** (`suite/ui`), classifies the operational area (active-conflict vs stable rear), drives C12 cache provisioning (which reads the `Flight` from the parent-suite `flights` REST service, downloads satellite tiles via the **Tile Manager** for the route bbox, and bakes the takeoff origin into the C10 Manifest), stages calibration onto the companion before takeoff, and after landing triggers the **Tile Manager** upload run. (source: `problem.md`, AC-3.4 / AC-6.2, ADR-010, user confirmation 2026-05-09 + 2026-05-11)
**Tile Manager** — Operator-side component (C11) that owns both directions of network I/O against `satellite-provider`: pre-flight download (F1) into the local C6 store via the `TileDownloader` interface, and post-landing upload (F10) from C6 to the parent-suite ingest endpoint via the `TileUploader` interface (gated on `flight state == ON_GROUND`). Implemented as a separate binary / image so neither network path is loaded in the airborne companion (ADR-004 process-level isolation). Replaces the earlier "post-landing upload tool" naming after Plan-cycle scope expansion 2026-05-09. (source: user directive 2026-05-09)
@@ -74,6 +82,8 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
**Suite Sat Service** — Synonym for `satellite-provider` used in earlier docs (problem.md, restrictions.md, solution_draft01/02). The actual implementation in the parent suite is the .NET 8 service; "Suite Sat Service" is the role name. (source: `restrictions.md`, parent-suite `satellite-provider/README.md`)
**Takeoff origin**`LatLonAlt` baked into the C10 Manifest by C12 at build time from `Flight.waypoints[0]`. Consumed at boot by C5 via `set_takeoff_origin(origin, sigma_m)` (AZ-490) as a Bayesian prior on the initial pose — iSAM2 attaches a `PriorFactorPose3`; ESKF seeds the nominal position + position-block covariance. Primary cold-start trust anchor per ADR-010; FC EKF GPS is secondary. (source: ADR-010, AZ-490)
**Tier-1 / Tier-2** — Testing-environment split: Tier-1 = workstation Docker (fast/cheap); Tier-2 = Jetson hardware (AC-bound). Both appear in the deployment plan and CI matrix per finding F6. (source: `_docs/02_document/tests/environment.md`)
**Tile** — Unit of persistent imagery on the companion; basemap-projected, deduplicated; the only persistent imagery format. Mid-flight-generated tiles use the same on-disk format as `satellite-provider` (`./{zoomLevel}/{x}/{y}.jpg` + matching metadata schema) so post-landing upload is byte-identical. (source: AC-8.4, AC-8.5, parent-suite `satellite-provider/README.md`, user confirmation 2026-05-09)
@@ -93,3 +103,5 @@ Terms are alphabetical. Each entry: one-line definition + parenthetical source.
**Visual propagated** — Source label `visual_propagated`: estimate produced by VIO frame-to-frame propagation with no fresh satellite anchor. Mid-confidence. (source: AC-1.4)
**VPR / Visual Place Recognition** — Descriptor-based retrieval of the nearest satellite tile to the current nav frame (component C2). (source: `solution.md` §C2)
**Waypoint** — Ordered `(lat, lon, alt, objective, source)` entry inside a `Flight`. Operationally meaningful ordering: `waypoints[0]` is the planned takeoff point and is extracted by C12 `FlightsApiClient` as the takeoff origin. C12 envelopes all waypoint lat/lon to derive the cache bbox. DTO shape mirrors `suite/flights/Database/Entities/Waypoint.cs`. (source: ADR-010, AZ-489)
+92 -42
View File
@@ -9,8 +9,8 @@
| # | Flow Name | Trigger | Primary Components | Criticality |
|---|-----------|---------|--------------------|-------------|
| F1 | Pre-flight cache provisioning | Operator runs C12 cache-build CLI on workstation | C12 (operator), C11 `TileDownloader`, [[`satellite-provider`]], C10, C6, C7 | High |
| F2 | Takeoff load | Companion boot detected by FC `MAV_STATE` ARMED OR companion process start with armed FC | C10, C7, C8 (signing handshake), C13 | High |
| F1 | Pre-flight cache provisioning | Operator runs C12 cache-build CLI on workstation with `--flight-id <Guid>` (online) or `--flight-file <path>` (offline). The flight was previously authored in the parent-suite Mission Planner UI (`suite/ui`) and persisted to the parent-suite `flights` REST service | C12 (operator), C12 `FlightsApiClient` (operator-side, AZ-489), [[`flights` REST service]], C11 `TileDownloader`, [[`satellite-provider`]], C10, C6, C7 | High |
| F2 | Takeoff load | Companion boot detected by FC `MAV_STATE` ARMED OR companion process start with armed FC | C10, C7, C8 (signing handshake), C5 `set_takeoff_origin` (operator-origin warm-start, AZ-490), C13 | High |
| F3 | Steady-state per-frame estimation | Nav camera frame received (3 Hz nominal) | C1, C2, C2.5, C3, C3.5, C4, C5, C8 (out), C13 | High |
| F4 | Mid-flight tile generation + local cache write | Successful satellite-anchored frame with quality metadata above threshold | C5, C6, C13 (no C8/C11 path — C11 `TileUploader` is not loaded in the airborne image) | High |
| F5 | Visual blackout + spoofed-GPS failsafe | Camera unusable AND/OR FC GPS reports denial/spoof | C1, C5, C8, C13 (degraded-mode escalation per AC-NEW-8) | High |
@@ -43,17 +43,19 @@
### Description
The operator builds (or refreshes) the per-mission cache before takeoff. F1 has **two phases** sequenced by C12 OperatorTool:
The operator builds (or refreshes) the per-mission cache before takeoff. F1 has **three phases** sequenced by C12 OperatorTool:
- **Phase 1Tile download (C11 `TileDownloader`)**: fetch tiles from `satellite-provider` for the operational area; apply sector-classified freshness rules (AC-NEW-6) and resolution gate (RESTRICT-SAT-4); write tile rows + JPEGs into C6.
- **Phase 2Cache artifact build (C10 CacheProvisioner)**: read the populated C6 store; compile/deserialize TRT engines via C7; batch-generate descriptors via the C2 backbone; atomically write the FAISS HNSW index with SHA-256 sidecars; write the Manifest hashing model + calibration + corpus + sector classification.
- **Phase 0Flight resolve (C12 `FlightsApiClient`, AZ-489)**: read the operator-authored `Flight` (ordered waypoints + altitudes) either from the parent-suite `flights` REST service (`--flight-id <Guid>`) or from a local JSON export (`--flight-file <path>`). Compute the bounding box as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km). Extract `Flight.waypoints[0].(lat, lon, alt)` as the **takeoff origin**. Both are passed downstream as `BuildRequest` fields.
- **Phase 1Tile download (C11 `TileDownloader`)**: fetch tiles from `satellite-provider` for the bbox computed in Phase 0; apply sector-classified freshness rules (AC-NEW-6) and resolution gate (RESTRICT-SAT-4); write tile rows + JPEGs into C6.
- **Phase 2 — Cache artifact build (C10 CacheProvisioner)**: read the populated C6 store; compile/deserialize TRT engines via C7; batch-generate descriptors via the C2 backbone; atomically write the FAISS HNSW index with SHA-256 sidecars; write the Manifest hashing model + calibration + corpus + sector classification **+ takeoff origin** (D-C10-1 idempotence; ADR-010).
This flow is offline and not time-critical. **Only Phase 1 reaches `satellite-provider`**and it runs on the operator workstation, which is the only host that holds the TLS + service-internal API key. The companion never reaches `satellite-provider` directly.
This flow is offline and not time-critical. **Only Phase 0 reaches `flights` REST and Phase 1 reaches `satellite-provider`**both run on the operator workstation, which is the only host that holds TLS + service-internal credentials. The companion never reaches either service directly (Principle #9 — denied-environment operation).
### Preconditions
- Operator workstation has network reach to `satellite-provider` (TLS + service-internal API key).
- Operator has classified the operational area (`active_conflict | stable_rear`) — drives the freshness threshold (AC-8.2 / AC-NEW-6).
- **Mission already authored in the parent-suite Mission Planner UI (`suite/ui`)** and persisted to the parent-suite `flights` REST service. Operator knows the `Flight` GUID (online path) OR has a JSON export of the same DTO shape on disk (offline path).
- Camera calibration JSON for the deployed unit is available (`adti20.<unit-id>.json` from D-PROJ-1 hybrid).
- Companion is connected to the operator workstation (USB or Ethernet) and writable.
- Available cache budget on the companion's NVM is ≥ the projected `≤ 10 GB` per AC-8.3.
@@ -64,6 +66,8 @@ This flow is offline and not time-critical. **Only Phase 1 reaches `satellite-pr
sequenceDiagram
participant Operator
participant C12OperatorTool as C12 Operator Tool (workstation)
participant FlightsClient as C12 FlightsApiClient (workstation, AZ-489)
participant FlightsApi as [[flights REST service]] (.NET 8)
participant C11TileDownloader as C11 TileDownloader (workstation)
participant SatelliteProvider as [[satellite-provider]] (.NET 8)
participant C6TileStore as C6 TileStore + DescriptorIndex (Postgres + filesystem + FAISS)
@@ -71,20 +75,31 @@ sequenceDiagram
participant C7Inference as C7 InferenceRuntime
participant C2Backbone as C2 VPR backbone (TensorRT)
Operator->>C12OperatorTool: build_cache(area, sector_class, calibration_file)
Operator->>C12OperatorTool: build_cache --flight-id GUID [--flight-file PATH] sector_class calibration_file
alt online (flight-id)
C12OperatorTool->>FlightsClient: fetch_flight(GUID)
FlightsClient->>FlightsApi: GET /flights/{id} + GET /flights/{id}/waypoints
FlightsApi-->>FlightsClient: Flight DTO (waypoints, altitudes)
else offline (flight-file)
C12OperatorTool->>FlightsClient: load_flight_file(PATH)
FlightsClient->>FlightsClient: parse JSON into FlightDto
end
FlightsClient->>FlightsClient: bbox = envelope(waypoints.lat, waypoints.lon) + buffer
FlightsClient->>FlightsClient: takeoff_origin = waypoints[0].(lat, lon, alt)
FlightsClient-->>C12OperatorTool: (bbox, takeoff_origin, flight_id)
C12OperatorTool->>C11TileDownloader: download_tiles_for_area(bbox, zooms, sector_class)
C11TileDownloader->>SatelliteProvider: GET /api/satellite/tiles?bbox=&zoom=
SatelliteProvider-->>C11TileDownloader: Tile blobs + metadata (paged)
C11TileDownloader->>C11TileDownloader: filter by AC-NEW-6 freshness + RESTRICT-SAT-4 resolution
C11TileDownloader->>C6TileStore: write tiles to ./tiles/{zoomLevel}/{x}/{y}.jpg + Postgres rows (source='googlemaps')
C11TileDownloader-->>C12OperatorTool: DownloadBatchReport (counts, freshness summary)
C12OperatorTool->>C10Provisioner: build_cache_artifacts(bbox, zooms, sector_class, calibration)
C12OperatorTool->>C10Provisioner: build_cache_artifacts(bbox, zooms, sector_class, calibration, takeoff_origin, flight_id)
C10Provisioner->>C7Inference: load VPR backbone ONNX
C7Inference-->>C10Provisioner: TRT engine compiled (cached per SM/JP/TRT/precision tuple)
C10Provisioner->>C2Backbone: per-tile descriptor generation (batched on Jetson, reads tiles from C6)
C2Backbone-->>C10Provisioner: descriptor matrix (FP16/INT8 per D-C7-1)
C10Provisioner->>C6TileStore: faiss.write_index (HNSW) + atomicwrites + SHA-256 content-hash
C10Provisioner->>C10Provisioner: write Manifest (hash of model + calibration + corpus + sector_class)
C10Provisioner->>C10Provisioner: write Manifest (hash of model + calibration + corpus + sector_class + takeoff_origin)
C10Provisioner-->>C12OperatorTool: BuildReport (counts, hashes)
C12OperatorTool-->>Operator: PASS / FAIL summary
```
@@ -93,8 +108,12 @@ sequenceDiagram
```mermaid
flowchart TD
Start([Operator invokes C12 build]) --> Classify[Operator classifies sector active_conflict OR stable_rear]
Classify --> InvokeC11[C12 invokes C11 TileDownloader]
Start([Operator invokes C12 build with --flight-id or --flight-file]) --> ResolveFlight[C12 FlightsApiClient fetches Flight by GUID or reads JSON export]
ResolveFlight --> FlightOk{Flight resolved + at least 1 waypoint?}
FlightOk -->|no| RefuseBuild[Refuse build with explicit error to operator]
FlightOk -->|yes| ComputeBbox[Compute bbox as envelope of waypoint lat/lon + buffer; take waypoints[0] as takeoff origin]
ComputeBbox --> Classify[Operator classifies sector active_conflict OR stable_rear]
Classify --> InvokeC11[C12 invokes C11 TileDownloader with computed bbox]
InvokeC11 --> Download[C11 GET /api/satellite/tiles for bbox + zoom]
Download --> FreshnessFilter{Freshness ok per AC-8.2 + AC-NEW-6?}
FreshnessFilter -->|stale and stable_rear| RejectOrDowngrade[Reject or downgrade tile]
@@ -112,40 +131,51 @@ flowchart TD
ReuseEngine --> Descriptors
BuildEngine --> Descriptors[C10 batches each tile through C2 backbone for descriptors]
Descriptors --> WriteIndex[faiss.write_index HNSW + atomicwrites + SHA-256 content-hash]
WriteIndex --> WriteManifest[Write Manifest with hash of model + calibration + corpus + sector_class]
WriteIndex --> WriteManifest[Write Manifest with hash of model + calibration + corpus + sector_class + takeoff_origin]
WriteManifest --> ManifestHashCheck{Idempotence check D-C10-1: same manifest hash as last build?}
ManifestHashCheck -->|same| SkipRebuild[Skip rebuild and emit no-op report]
ManifestHashCheck -->|different| Done([Provisioning complete; cache + engines + manifest staged])
SkipRebuild --> Done
RefuseBuild --> Done
```
### Data flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Operator | C12 | (`bounding_box`, `zoom_levels`, `sector_class`, `calibration_path`) | CLI args / GUI form |
| 2 | C12 | C11 `TileDownloader` | `DownloadRequest` | in-process call |
| 3 | C11 | `satellite-provider` REST | `GET /api/satellite/tiles?bbox=…&zoom=…` | HTTPS query |
| 4 | `satellite-provider` | C11 | Paged tile blobs + metadata rows | JPEG + JSON metadata |
| 5 | C11 | C6 filesystem (over USB/Eth) | Tile JPEG bodies | `./tiles/{zoomLevel}/{x}/{y}.jpg` |
| 6 | C11 | C6 PostgreSQL | Tile metadata rows (`source='googlemaps'`) | SQL INSERT (mirror of `satellite-provider`'s `tiles` table) |
| 7 | C12 | C10 `CacheProvisioner` | `BuildRequest` | in-process call (operator-tool side); RPC over USB/Eth to companion runner |
| 8 | C10 → C7 | TRT engine cache | TRT engines | `.engine` files keyed by `(SM, JP, TRT, precision)` (D-C10-7) |
| 9 | C2 backbone (driven by C10) | C6 FAISS index | Descriptor matrix | `.index` (FAISS HNSW), atomicwrites, SHA-256 sidecar |
| 10 | C10 | filesystem | Manifest | YAML or JSON; carries hashes |
| 0a | Operator | C12 | (`flight_id` OR `flight_file`, `zoom_levels`, `sector_class`, `calibration_path`) | CLI args / GUI form |
| 0b | C12 `FlightsApiClient` (online) | `flights` REST | `GET /flights/{id}` + `GET /flights/{id}/waypoints` | HTTPS GET |
| 0c | `flights` REST | C12 `FlightsApiClient` | `Flight` + ordered `Waypoint[]` (lat / lon / alt / objective / source) | JSON DTOs |
| 0d | C12 `FlightsApiClient` (offline) | filesystem | `flight_file` JSON in the same DTO shape | JSON read |
| 0e | C12 `FlightsApiClient` | C12 | `(bbox, takeoff_origin, flight_id)` | in-process |
| 1 | C12 | C11 `TileDownloader` | `DownloadRequest(bbox, zoom_levels, sector_class)` | in-process call |
| 2 | C11 | `satellite-provider` REST | `GET /api/satellite/tiles?bbox=…&zoom=…` | HTTPS query |
| 3 | `satellite-provider` | C11 | Paged tile blobs + metadata rows | JPEG + JSON metadata |
| 4 | C11 | C6 filesystem (over USB/Eth) | Tile JPEG bodies | `./tiles/{zoomLevel}/{x}/{y}.jpg` |
| 5 | C11 | C6 PostgreSQL | Tile metadata rows (`source='googlemaps'`) | SQL INSERT (mirror of `satellite-provider`'s `tiles` table) |
| 6 | C12 | C10 `CacheProvisioner` | `BuildRequest(bbox, zoom_levels, sector_class, calibration_path, takeoff_origin, flight_id)` | in-process call (operator-tool side); RPC over USB/Eth to companion runner |
| 7 | C10 → C7 | TRT engine cache | TRT engines | `.engine` files keyed by `(SM, JP, TRT, precision)` (D-C10-7) |
| 8 | C2 backbone (driven by C10) | C6 FAISS index | Descriptor matrix | `.index` (FAISS HNSW), atomicwrites, SHA-256 sidecar |
| 9 | C10 | filesystem | Manifest (carries `takeoff_origin` + hashes) | YAML or JSON |
### Error scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `satellite-provider` unreachable | Step 3 | HTTP timeout / 5xx | C11 `TileDownloader` fails with explicit error; operator retries when network is available; takeoff blocked |
| Tile fails freshness | Step 4 (C11) | `tile.capture_timestamp` vs `sector_class` threshold | Reject (active_conflict) or downgrade-no-`satellite_anchored`-label (rear), per AC-NEW-6; counts surface in `DownloadBatchReport` |
| Resolution below 0.5 m/px | Step 4 (C11) | Tile metadata GSD check (RESTRICT-SAT-4) | Reject; report; takeoff blocked |
| Insufficient cache budget | Step 5 (C11) | Filesystem free-space check pre-write | Fail fast with explicit budget delta; no partial write |
| C6 missing tiles for requested bbox/zoom | Step 7 (C10) | C10's pre-build scan finds < expected tile count | Surface as `BuildReport.failure` instructing operator to re-run C11 `TileDownloader`; do **not** trigger network fetch from C10 |
| Engine compile failure | Step 8 | Polygraphy / trtexec exit code; no output `.engine` | Surface error to operator; takeoff blocked; **never silently fall back** |
| Descriptor generation OOM on Jetson | Step 9 | CUDA OOM | Halve batch size and retry once; if still OOM, surface to operator |
| Atomic-write or SHA-256 mismatch | Step 9 | `atomicwrites` rollback or content-hash sidecar mismatch | Mark cache invalid; rebuild from staged tiles; if persistent, surface to operator |
| `flights` REST unreachable (online path) | Step 0b | HTTP timeout / connection refused | Fail explicitly; instruct operator to retry online or use `--flight-file` offline path; takeoff blocked |
| `flights` REST 401/403 (online path) | Step 0b | HTTP 401/403 | Fail with explicit error; instruct operator to refresh suite credentials; takeoff blocked. Never silently fall back |
| `flights` REST 404 (online path) | Step 0b | HTTP 404 | Fail with explicit message naming the unknown `flight_id`; takeoff blocked |
| Flight file malformed (offline path) | Step 0d | JSON parse failure / schema mismatch | Fail with line / field reference; instruct operator to re-export from Mission Planner UI; takeoff blocked |
| Flight has zero waypoints | Step 0e | Post-fetch validation | Fail explicitly; cannot derive bbox or takeoff origin; takeoff blocked |
| Flight bbox exceeds cache budget | Step 0e | Pre-Phase-1 bbox area vs AC-8.3 budget projection | Fail with budget delta; operator must re-plan a smaller route in Mission Planner UI; takeoff blocked |
| `satellite-provider` unreachable | Step 2 | HTTP timeout / 5xx | C11 `TileDownloader` fails with explicit error; operator retries when network is available; takeoff blocked |
| Tile fails freshness | Step 3 (C11) | `tile.capture_timestamp` vs `sector_class` threshold | Reject (active_conflict) or downgrade-no-`satellite_anchored`-label (rear), per AC-NEW-6; counts surface in `DownloadBatchReport` |
| Resolution below 0.5 m/px | Step 3 (C11) | Tile metadata GSD check (RESTRICT-SAT-4) | Reject; report; takeoff blocked |
| Insufficient cache budget | Step 4 (C11) | Filesystem free-space check pre-write | Fail fast with explicit budget delta; no partial write |
| C6 missing tiles for requested bbox/zoom | Step 6 (C10) | C10's pre-build scan finds < expected tile count | Surface as `BuildReport.failure` instructing operator to re-run C11 `TileDownloader`; do **not** trigger network fetch from C10 |
| Engine compile failure | Step 7 | Polygraphy / trtexec exit code; no output `.engine` | Surface error to operator; takeoff blocked; **never silently fall back** |
| Descriptor generation OOM on Jetson | Step 8 | CUDA OOM | Halve batch size and retry once; if still OOM, surface to operator |
| Atomic-write or SHA-256 mismatch | Step 8 | `atomicwrites` rollback or content-hash sidecar mismatch | Mark cache invalid; rebuild from staged tiles; if persistent, surface to operator |
| Tampered cache (post-write, pre-takeoff) | (caught at takeoff in F2, not here) | F2 SHA-256 content-hash gate | F2 refuses takeoff (IT-7) |
### Performance expectations
@@ -203,11 +233,19 @@ sequenceDiagram
end
Companion->>FC: subscribe to FC IMU + attitude + GPS health (telemetry)
FC-->>Companion: first telemetry frame
Companion->>FC: query FC EKF last valid GPS + IMU-extrapolated pose (AC-5.1)
FC-->>Companion: warm-start pose
Companion->>Pipeline: warm with calibration + warm-start pose
Note over Companion,Pipeline: Cold-start ladder (ADR-010, AZ-490). Operator-origin from Manifest is primary; FC EKF GPS is secondary
alt Manifest carries takeoff_origin (AZ-490 primary path)
Companion->>Pipeline: C5.set_takeoff_origin(manifest.takeoff_origin, sigma_m) BEFORE any add_vio / add_fc_imu
else Manifest has no takeoff_origin AND FC EKF GPS is valid (AZ-419 secondary path)
Companion->>FC: query FC EKF last valid GPS + IMU-extrapolated pose (AC-5.1)
FC-->>Companion: warm-start pose
Companion->>Pipeline: C5.set_takeoff_origin(fc_gps_origin, fc_gps_sigma)
else No origin available
Companion-->>Companion: stay INITIALIZING; FT-P-11 takeoff-abort policy (AZ-419 amended)
end
Companion->>Pipeline: warm pipeline with calibration
Pipeline-->>Companion: ready (no estimate emitted yet)
Companion->>Fdr: open per-flight FDR record; log signing key rotation event
Companion->>Fdr: open per-flight FDR record; log signing key rotation event + chosen cold-start origin source
Note over Companion: Wait for first nav frame (F3 entry)
```
@@ -227,11 +265,19 @@ flowchart TD
FcDetect -->|iNav| InavOpen[Open MSP2 channel unsigned residual risk]
ApSign --> SignOk{Signing handshake OK?}
SignOk -->|no| RefuseTakeoff
SignOk -->|yes| WarmStart
InavOpen --> WarmStart[Query FC EKF last valid GPS + IMU-extrapolated pose AC-5.1]
WarmStart --> WarmPipeline[Warm C1 + C2 + C2.5 + C3 + C3.5 + C4 + C5 with calibration + warm-start pose]
WarmPipeline --> OpenFdr[C13 opens per-flight FDR; logs signing key rotation event]
SignOk -->|yes| OriginGate
InavOpen --> OriginGate{Manifest carries takeoff_origin?}
OriginGate -->|yes ADR-010 AZ-490 primary| OperatorOrigin[C5.set_takeoff_origin manifest.takeoff_origin sigma_m]
OriginGate -->|no| FcEkfGate{FC EKF reports valid non-spoofed GPS?}
FcEkfGate -->|yes AZ-419 secondary| FcOrigin[C5.set_takeoff_origin fc_gps_origin fc_gps_sigma]
FcEkfGate -->|no| NoOrigin[Stay INITIALIZING and apply FT-P-11 takeoff-abort policy]
OperatorOrigin --> WarmPipeline
FcOrigin --> WarmPipeline
NoOrigin --> Refuse2[Refuse takeoff with FDR record of missing origin]
WarmPipeline[Warm C1 + C2 + C2.5 + C3 + C3.5 + C4 + C5 with calibration]
WarmPipeline --> OpenFdr[C13 opens per-flight FDR; logs signing key rotation event + chosen origin source]
OpenFdr --> Ready([Ready; awaiting first nav frame])
Refuse2 --> RefuseTakeoff
```
### Data flow
@@ -239,12 +285,13 @@ flowchart TD
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Companion | C10 | (`manifest_path`) | filesystem read |
| 2 | C10 | filesystem | content-hash sidecars | SHA-256 hex digests |
| 2 | C10 | filesystem | content-hash sidecars + `takeoff_origin` | SHA-256 hex digests + `LatLonAlt` in Manifest |
| 3 | Companion | FAISS | `.index` mmap pointer | C++ FAISS API |
| 4 | Companion | C7 / TensorRT | `.engine` deserialize | TensorRT IRuntime |
| 5 | Companion | FC (AP) | signing seed + handshake | MAVLink 2.0 signing |
| 6 | FC | Companion | warm-start pose + IMU/attitude/GPS health | MAVLink (AP) / MSP2 + MAVLink outbound (iNav) |
| 7 | Companion | C13 FDR | startup record (config snapshot, signing key rotation event, content-hash digests) | FDR record |
| 7 | Companion | C5 `StateEstimator` (AZ-490) | `set_takeoff_origin(origin, sigma_m)` with origin = `manifest.takeoff_origin` (primary) OR FC-EKF GPS (secondary) | in-process Protocol method |
| 8 | Companion | C13 FDR | startup record (config snapshot, signing key rotation event, content-hash digests, chosen cold-start origin source) | FDR record |
### Error scenarios
@@ -255,8 +302,9 @@ flowchart TD
| TRT deserialize failure | Step 4 | TensorRT API error | Refuse takeoff; report mismatched `(SM, JP, TRT, precision)` tuple to operator |
| Signing handshake fail (AP) | Step 5 | Handshake timeout / signed-message rejection | Refuse takeoff; clear-text reason via STATUSTEXT (handshake never succeeded → unsigned STATUSTEXT is acceptable for this case only) |
| FC unreachable | Step 6 | UART/USB read timeout | Retry with backoff; after `N` retries refuse takeoff |
| EKF returns no warm-start pose | Step 6 | Empty `GLOBAL_POSITION_INT` and no IMU prior | Defer pipeline warm-up until first valid prior; bound the wait by AC-NEW-1 budget; if exceeded, refuse takeoff |
| FDR open failure | Step 7 | Filesystem write error | Refuse takeoff (per AC-NEW-3 every payload class must be present from t=0) |
| Manifest has no `takeoff_origin` AND EKF returns no warm-start pose | Step 7 (ADR-010, AZ-490) | Both primary (manifest) and secondary (FC EKF) origin paths unavailable | Refuse takeoff; FDR records "no cold-start origin available"; FT-P-11 takeoff-abort policy applies. Bound the wait by AC-NEW-1 budget before final refusal |
| Manifest has `takeoff_origin` but FC EKF GPS disagrees by > 200 m at takeoff | Step 7 (ADR-010 Principle #11 bounded-delta) | Operator origin vs FC GPS comparison after first FC telemetry frame | Operator origin wins; FC GPS is logged as suspect (likely spoofed-at-takeoff); proceed to warm pipeline with the operator origin |
| FDR open failure | Step 8 | Filesystem write error | Refuse takeoff (per AC-NEW-3 every payload class must be present from t=0) |
### Performance expectations
@@ -698,6 +746,8 @@ When the FC reports GPS denial/spoof while the companion estimate is healthy, th
This flow is a **hot path**: AC-NEW-2 ≤ 3 s p95 from spoof onset to companion estimate becoming primary. Status: D-C8-2 = (b) is `Selected with runtime gate` — IT-3 SITL validation is the lock gate (Mode B Fact #111).
**Reverse path — mid-flight FC GPS re-promotion (ADR-010, Principle #11 amended)**: when the FC's GPS subsequently recovers in flight, the companion does **not** auto-yield. The FC GPS is fused back into C5 via `add_pose_anchor` **only after** the three-part gate fires: (a) FC GPS health stable + non-spoofed for ≥ 10 s, (b) a visual/satellite consistency check has succeeded on the next anchor frame, **AND** (c) the FC's reported position is within ≤ 200 m of the companion's last emitted `PoseEstimate`. The third clause is the bounded-delta gate — it catches "FC reports stable GPS but the value is wrong". When the gate passes, the FC GPS becomes one more anchor source, not an override. The source-set switch back to set 1 happens through the existing AC-NEW-8 path.
### Preconditions
- ArduPilot Plane FC (D-C8-2 only applies to AP path).