mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 09:01:14 +00:00
[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:
@@ -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 ~5–10 % 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 (C1–C8, 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 | 1–2 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=10–20
|
||||
- `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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 1 — Tile 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 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.
|
||||
- **Phase 0 — Flight 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 1 — Tile 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).
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Dependencies Table
|
||||
|
||||
**Date**: 2026-05-10 (refreshed after E-BBT decomposition)
|
||||
**Total Tasks**: 140 (99 product + 41 blackbox-test)
|
||||
**Total Complexity Points**: 472 (339 product + 133 blackbox-test)
|
||||
**Date**: 2026-05-11 (refreshed after AZ-489 + AZ-490 onboarding for ADR-010 operator-origin path)
|
||||
**Total Tasks**: 142 (101 product + 41 blackbox-test)
|
||||
**Total Complexity Points**: 478 (345 product + 133 blackbox-test)
|
||||
|
||||
Dependencies columns list only the tracker-ID portion (descriptive tail
|
||||
text in each task spec is omitted here for table-readability). The
|
||||
authoritative dependency narrative — including "co-developed", "forward
|
||||
dependency", and helper-vs-Protocol distinctions — lives in each task's
|
||||
own `Dependencies:` field. The graph is a strict DAG: a topological
|
||||
traversal visits all 140 tasks. The 13 forward edges (dep ID > task ID)
|
||||
traversal visits all 142 tasks. The 15 forward edges (dep ID > task ID)
|
||||
are all declared and documented below under **Cycle Check**.
|
||||
|
||||
| Task | Name | Complexity | Dependencies | Epic |
|
||||
@@ -61,9 +61,9 @@ are all declared and documented below under **Cycle Check**.
|
||||
| AZ-323 | C10 Manifest Builder | 3 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281, AZ-303 | AZ-252 |
|
||||
| AZ-324 | C10 ManifestVerifier | 3 | AZ-263, AZ-269, AZ-266, AZ-280, AZ-281 | AZ-252 |
|
||||
| AZ-325 | C10 CacheProvisioner | 3 | AZ-263, AZ-269, AZ-266, AZ-303, AZ-321, AZ-322, AZ-323 | AZ-252 |
|
||||
| AZ-326 | C12 CLI App | 3 | AZ-263, AZ-269, AZ-266 | AZ-253 |
|
||||
| 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-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-331 | C1 VioStrategy Protocol | 3 | AZ-263, AZ-269, AZ-266, AZ-270, AZ-272, AZ-276, AZ-277 | AZ-254 |
|
||||
@@ -126,7 +126,7 @@ are all declared and documented below under **Cycle Check**.
|
||||
| AZ-416 | FT-P-09-AP — ArduPilot Plane GPS_INPUT contract + MAVLink 2.0 signing handshake | 5 | AZ-406, AZ-407 | AZ-262 |
|
||||
| AZ-417 | FT-P-09-iNav — iNav MSP2_SENSOR_GPS contract conformance | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||
| AZ-418 | FT-P-10 — GTSAM smoothing-loop look-back accuracy | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||
| AZ-419 | FT-P-11 — Cold-start initialization from FC EKF | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||
| AZ-419 | FT-P-11 — Cold-start init (operator-manifest primary + FC EKF secondary + bounded-delta gate)| 3 | AZ-406, AZ-407, AZ-489 (forward), AZ-490 (forward) | AZ-262 |
|
||||
| AZ-420 | FT-P-12 + FT-P-13 — GCS downsample + GCS-originated re-loc command | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||
| AZ-421 | FT-P-15 + FT-P-16 + FT-P-18 — Tile cache + offline + no-raw-retention | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||
| AZ-422 | FT-P-17 + FT-N-06 — Mid-flight tile generation + freshness | 3 | AZ-406, AZ-407 | AZ-262 |
|
||||
@@ -154,6 +154,8 @@ are all declared and documented below under **Cycle Check**.
|
||||
| AZ-444 | Tier-2 Jetson harness wrapper — run-tier2.sh, ssh provisioning, systemd, ASan-fuzz | 5 | AZ-406 | AZ-262 |
|
||||
| AZ-445 | CSV reporter + evidence bundler — per-NFR machine-readable outputs + traceability-status.json | 2 | AZ-406 | AZ-262 |
|
||||
| AZ-446 | CSV reporter refinements — trend-line + acceptance-band annotations + Monte Carlo CI | 2 | AZ-406, AZ-445 | AZ-262 |
|
||||
| AZ-489 | C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback | 3 | AZ-263, AZ-269, AZ-266, AZ-279, AZ-280 | AZ-253 |
|
||||
| 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 |
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -189,6 +191,23 @@ are all declared and documented below under **Cycle Check**.
|
||||
`blackout_spoof.py`; NFT-RES-04 is the focused 35 s escalation
|
||||
scenario while FT-N-04 covers the 5 s / 15 s / 35 s ladder.
|
||||
- AZ-446 depends on AZ-445 — refinements layer over the bundler.
|
||||
- **ADR-010 operator-origin path** (added 2026-05-11):
|
||||
- **AZ-489 (C12 FlightsApiClient)** is the new read-only Flight
|
||||
resolver for C12; it has no consumers inside its own epic but
|
||||
feeds AZ-326 (CLI flags) and AZ-328 (orchestrator phase 0) — both
|
||||
declare a hard backward dep on AZ-489. The CLI's `--flight-id` /
|
||||
`--flight-file` flags + AZ-328's flight-resolve phase 0 cannot
|
||||
land without it.
|
||||
- **AZ-490 (C5 set_takeoff_origin)** extends the AZ-381 Protocol
|
||||
with the pre-takeoff entrypoint, amends the AZ-385 source-label
|
||||
state machine with the third bounded-delta clause, and depends
|
||||
on AZ-381..AZ-386 (Protocol + factor adds + marginals + source
|
||||
label gate + ESKF baseline) plus AZ-272/273/279 for FDR + Vincenty.
|
||||
All deps are backward; AZ-490 ships after the C5 epic core lands.
|
||||
- **AZ-419 (FT-P-11 cold-start)** carries forward deps on both
|
||||
AZ-489 + AZ-490 — the blackbox cold-start scenario now exercises
|
||||
the operator-manifest primary path (needs both) AND the FC EKF
|
||||
secondary fallback (back-compat).
|
||||
- **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).
|
||||
@@ -202,13 +221,13 @@ are all declared and documented below under **Cycle Check**.
|
||||
- C3 `CrossDomainMatcher` → AZ-344 (Protocol) + AZ-345/346/347 (concrete)
|
||||
- C3.5 `ConditionalRefiner` → AZ-348 (Protocol + Passthrough) + AZ-349 (AdHoP)
|
||||
- C4 `PoseEstimator` → AZ-355 (Protocol) + AZ-358/361 (concrete)
|
||||
- C5 `StateEstimator` → AZ-381 (Protocol) + AZ-382..AZ-389 (concrete)
|
||||
- C5 `StateEstimator` → AZ-381 (Protocol) + AZ-382..AZ-389 (concrete) + AZ-490 (`set_takeoff_origin` entrypoint + bounded-delta gate)
|
||||
- C6 `TileStore` / `DescriptorIndex` → AZ-303 (Interfaces) + AZ-304/305/306/307/308
|
||||
- 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
|
||||
- C12 Operator Tooling → AZ-326/327/328/329/330 + AZ-489 (FlightsApiClient)
|
||||
- C13 FDR Writer → AZ-291..AZ-296
|
||||
|
||||
- **Cross-cutting product modules**:
|
||||
@@ -244,7 +263,7 @@ are all declared and documented below under **Cycle Check**.
|
||||
## Cycle Check
|
||||
|
||||
A static dependency-graph traversal (Kahn topological sort) visits all
|
||||
140 nodes — no cycles. The 13 forward edges (dep ID > task ID) are all
|
||||
142 nodes — no cycles. The 15 forward edges (dep ID > task ID) are all
|
||||
declared, bounded, and documented:
|
||||
|
||||
- **AZ-267 → AZ-272** (FDR Log Bridge → FdrRecord Schema; shipped in
|
||||
@@ -261,6 +280,13 @@ declared, bounded, and documented:
|
||||
optionally for the ASan-fuzz mode). AZ-444 is therefore scheduled
|
||||
as the first Tier-2 E-BBT deliverable; the dependent scenarios land
|
||||
on top of it.
|
||||
- **AZ-326 → AZ-489, AZ-328 → AZ-489** (C12 CLI + orchestrator
|
||||
depend on the new C12 FlightsApiClient task added 2026-05-11; the
|
||||
client lands first inside the C12 epic and the CLI/orchestrator
|
||||
then plug it in).
|
||||
- **AZ-419 → AZ-489, AZ-419 → AZ-490** (blackbox cold-start scenario
|
||||
forward-depends on both the C12 client + the new C5 entrypoint;
|
||||
the scenario lands after both product tasks).
|
||||
|
||||
The graph is therefore a strict DAG once these documented forward
|
||||
edges are accounted for, and remains sortable by tracker ID modulo
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Task**: AZ-323_c10_manifest_builder
|
||||
**Name**: C10 Manifest Builder
|
||||
**Description**: Implement `ManifestBuilder`, the C10-internal phase that produces the signed cache Manifest covering EVERY shipped artifact (engines, FAISS index, calibration JSON, all tile hashes from C6) plus the build-identity tuple `(model_ids, calibration_sha256, sorted_tile_hashes, sector_class, bbox, zoom_levels)` whose canonical hash is `manifest_hash` — the D-C10-1 idempotence key. Serializes the Manifest as canonical JSON (sorted keys, no whitespace) at `cache_root/Manifest.json`, computes its own SHA-256 sidecar via AZ-280, and writes a detached Ed25519 signature at `cache_root/Manifest.json.sig` using the operator's signing key from `key_path`. Refuses to sign with a non-operator key when `config.c10.signing_mode = "operator"` (C10-ST-01). Emits the `signing_public_key_fingerprint` into the Manifest itself so verifiers can pin the trust root.
|
||||
**Description**: Implement `ManifestBuilder`, the C10-internal phase that produces the signed cache Manifest covering EVERY shipped artifact (engines, FAISS index, calibration JSON, all tile hashes from C6) plus the build-identity tuple `(model_ids, calibration_sha256, sorted_tile_hashes, sector_class, bbox, zoom_levels, takeoff_origin, flight_id)` whose canonical hash is `manifest_hash` — the D-C10-1 idempotence key. The `takeoff_origin` (`LatLonAlt`) and `flight_id` (`UUID`) are supplied by C12 from `Flight.waypoints[0]` via the `FlightsApiClient` (ADR-010, AZ-489); both are baked into the Manifest body **and** included in the manifest-hash so re-planning the flight produces a new cache identity. Serializes the Manifest as canonical JSON (sorted keys, no whitespace) at `cache_root/Manifest.json`, computes its own SHA-256 sidecar via AZ-280, and writes a detached Ed25519 signature at `cache_root/Manifest.json.sig` using the operator's signing key from `key_path`. Refuses to sign with a non-operator key when `config.c10.signing_mode = "operator"` (C10-ST-01). Emits the `signing_public_key_fingerprint` into the Manifest itself so verifiers can pin the trust root.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema, AZ-303_c6_storage_interfaces
|
||||
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
||||
@@ -34,7 +34,7 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
||||
- Constructor: `__init__(self, *, sidecar: Sha256Sidecar, signer: ManifestSigner, tile_metadata_store: TileMetadataStore, logger: Logger, clock: Clock, config: C10ManifestConfig)`.
|
||||
- `C10ManifestConfig` (`@dataclass(frozen=True)`): `signing_mode: enum {operator, dev}`, `allowed_operator_fingerprints: tuple[str, ...]`, `schema_version: str = "1.0"`.
|
||||
- Public method: `build_manifest(input: ManifestBuildInput) -> ManifestArtifact`.
|
||||
- `ManifestBuildInput` (`@dataclass(frozen=True)`): `cache_root: Path`, `bbox: Bbox`, `zoom_levels: tuple[int, ...]`, `sector_class: SectorClassification`, `engine_entries: tuple[EngineCacheEntry, ...]`, `descriptor_index_path: Path`, `calibration_path: Path`, `key_path: Path`.
|
||||
- `ManifestBuildInput` (`@dataclass(frozen=True)`): `cache_root: Path`, `bbox: Bbox`, `zoom_levels: tuple[int, ...]`, `sector_class: SectorClassification`, `engine_entries: tuple[EngineCacheEntry, ...]`, `descriptor_index_path: Path`, `calibration_path: Path`, `key_path: Path`, `takeoff_origin: LatLonAlt | None = None` (ADR-010 / AZ-489 — when set, baked into Manifest + hash), `flight_id: UUID | None = None` (ADR-010 — pass-through provenance).
|
||||
- `ManifestArtifact` (`@dataclass(frozen=True)`): `manifest_path: Path`, `signature_path: Path`, `manifest_hash: str`, `signing_public_key_fingerprint: str`, `total_artifacts_listed: int`.
|
||||
- A `ManifestSigner` Protocol at `src/gps_denied_onboard/components/c10_provisioning/interface.py`:
|
||||
```python
|
||||
@@ -54,10 +54,10 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
||||
- For descriptor index: call `sidecar.read_sidecar(input.descriptor_index_path)` → expect a 64-char hex digest.
|
||||
- For calibration JSON: `sha256_hex(open(calibration_path, 'rb').read())` — calibration is small (KB).
|
||||
- For tiles: call `tile_metadata_store.query_by_bbox(bbox, zoom_levels, sector_class)` → list of `TileMetadata` with `sha256_hex` field (set by AZ-316). Sort by `(zoom, lat, lon, source)` for determinism. Compute `tiles_coverage_sha256 = sha256(b"\n".join(f"{t.tile_id}:{t.sha256_hex}".encode() for t in sorted_tiles))`.
|
||||
5. Build the canonical Manifest dict:
|
||||
5. Build the canonical Manifest dict (ADR-010 adds `flight.takeoff_origin` + `flight.flight_id` blocks when supplied):
|
||||
```
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"schema_version": "1.1",
|
||||
"build": {
|
||||
"bbox": {...},
|
||||
"zoom_levels": [16, 17, 18],
|
||||
@@ -65,6 +65,14 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
||||
"built_at": "2026-05-10T12:00:00Z",
|
||||
"manifest_hash": "<sha256-hex>"
|
||||
},
|
||||
"flight": {
|
||||
"flight_id": "<uuid>", // null when ManifestBuildInput.flight_id is None
|
||||
"takeoff_origin": { // omitted when ManifestBuildInput.takeoff_origin is None
|
||||
"lat_deg": <float>,
|
||||
"lon_deg": <float>,
|
||||
"alt_m": <float>
|
||||
}
|
||||
},
|
||||
"artifacts": {
|
||||
"engines": [{"path": "engines/dinov2_vpr_sm87_jp62_trt103_fp16.engine", "sha256": "<hex>"}, ...],
|
||||
"descriptor_index": {"path": "descriptors/corpus.index", "sha256": "<hex>"},
|
||||
@@ -74,7 +82,7 @@ This task delivers the Manifest serialization + signing. It does NOT compile eng
|
||||
"signing_public_key_fingerprint": "<hex>"
|
||||
}
|
||||
```
|
||||
6. Compute `manifest_hash` as `sha256(canonical_json(build_identity_tuple))` where `build_identity_tuple = sorted({model_ids, calibration_sha256, tiles_coverage_sha256, sector_class, bbox, zoom_levels})`. This is the D-C10-1 idempotence key. Insert into the Manifest dict at `build.manifest_hash` AFTER computation.
|
||||
6. Compute `manifest_hash` as `sha256(canonical_json(build_identity_tuple))` where `build_identity_tuple = sorted({model_ids, calibration_sha256, tiles_coverage_sha256, sector_class, bbox, zoom_levels, takeoff_origin_tuple_or_none, flight_id_or_none})`. The takeoff origin is serialised as `(lat_deg, lon_deg, alt_m)` rounded to 9 decimal places (sub-millimetre, deterministic). This is the D-C10-1 idempotence key. Insert into the Manifest dict at `build.manifest_hash` AFTER computation. **Two builds with identical inputs but different `takeoff_origin` produce different `manifest_hash` values; this is the contract that lets `ManifestVerifier` reject a re-planned route at boot (AZ-324, MV-INV-8).**
|
||||
7. Serialize the Manifest dict as canonical JSON: `orjson.dumps(manifest, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2).decode()`. Append a trailing newline.
|
||||
8. Atomic-write the JSON via `sidecar.write_with_sidecar(cache_root / "Manifest.json", canonical_json_bytes)` — produces `Manifest.json` + `Manifest.json.sha256` (the latter is the Manifest's OWN sha256, used by T4).
|
||||
9. Sign the canonical JSON bytes: `signature_bytes = signer.sign(key, canonical_json_bytes)` (raw Ed25519 signature, 64 bytes).
|
||||
@@ -168,6 +176,26 @@ Given an input with N engines + 1 index + 1 calibration + tiles_coverage
|
||||
When `ManifestArtifact.total_artifacts_listed` is inspected
|
||||
Then it equals `N + 3` (engines + index + calibration + tiles_coverage); does NOT count the Manifest itself or the signature
|
||||
|
||||
**AC-13: `takeoff_origin` baked into Manifest body when supplied (ADR-010 / AZ-489)**
|
||||
Given a `ManifestBuildInput` with `takeoff_origin = LatLonAlt(50.0, 36.2, 200.0)` and `flight_id = some_uuid`
|
||||
When `build_manifest` is called
|
||||
Then the Manifest body contains a `flight` block with `flight_id` and `takeoff_origin` (`lat_deg=50.0`, `lon_deg=36.2`, `alt_m=200.0`); ZERO `built_at`-style timestamp inside `takeoff_origin`
|
||||
|
||||
**AC-14: `takeoff_origin` absent from Manifest body when not supplied**
|
||||
Given a `ManifestBuildInput` with `takeoff_origin = None` and `flight_id = None`
|
||||
When `build_manifest` is called
|
||||
Then the Manifest body has the `flight` block with `flight_id: null` and NO `takeoff_origin` key (use absence, not `null`, so AZ-324 can detect "field never set" vs "field invalid")
|
||||
|
||||
**AC-15: `manifest_hash` changes when only `takeoff_origin` differs**
|
||||
Given two `ManifestBuildInput`s identical except `takeoff_origin = A` vs `takeoff_origin = B` (B != A by ≥ 1 mm)
|
||||
When `build_manifest` is called twice
|
||||
Then the two `manifest_hash` values differ — D-C10-1 idempotence treats re-planned route as a new build
|
||||
|
||||
**AC-16: `manifest_hash` stable when only `flight_id` differs but `takeoff_origin` is the same**
|
||||
Given two `ManifestBuildInput`s identical except `flight_id`
|
||||
When `build_manifest` is called twice
|
||||
Then the two `manifest_hash` values **differ** — `flight_id` is provenance and is part of the build identity (operator may re-plan with the same takeoff position but a different mission; the cache identity must track that)
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
@@ -199,6 +227,10 @@ Then it equals `N + 3` (engines + index + calibration + tiles_coverage); does NO
|
||||
| AC-10 | Kill mid-write | No half-Manifest |
|
||||
| AC-11 | Verify Manifest's own sidecar | Hashes match |
|
||||
| AC-12 | Inspect total_artifacts_listed | Counts engines+index+calibration+tiles_coverage |
|
||||
| AC-13 | Build with takeoff_origin set | `flight.takeoff_origin` present in JSON; lat/lon/alt match |
|
||||
| AC-14 | Build with takeoff_origin=None | `flight.takeoff_origin` key absent from JSON |
|
||||
| AC-15 | Two builds, takeoff_origin differs | manifest_hash differs |
|
||||
| AC-16 | Two builds, only flight_id differs | manifest_hash differs |
|
||||
| NFR-perf | 100k-tile bench | ≤ 5 s wall clock |
|
||||
| NFR-reliability-fail-closed | Operator mode + unknown fp | Fail-closed; nothing written |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Task**: AZ-324_c10_manifest_verifier
|
||||
**Name**: C10 ManifestVerifier
|
||||
**Description**: Implement `ManifestVerifier` (per the contract `_docs/02_document/contracts/c10_provisioning/manifest_verifier.md`), the read-only validator that AC-NEW-1 places between F2 takeoff and any engine deserialization. Loads `Manifest.json`, verifies its sidecar SHA-256 matches the Manifest bytes, parses the Ed25519 detached signature at `Manifest.json.sig`, verifies it against the caller-supplied `trusted_public_keys` tuple, parses the Manifest schema (rejecting absolute paths and schema violations), and walks every per-artifact entry re-hashing it via AZ-280's sidecar pattern. Returns a `VerificationResult` with `outcome ∈ {PASS, FAIL}`, the union of all `VerifyFailReason` values that fired, the populated `per_artifact_checks` list, and `elapsed_ms`. Fail-closed: any deviation in signature, schema, key trust, or hashes yields `FAIL` with detailed reasons. Never raises on a verify failure — only on environment errors (Manifest.json missing → `MANIFEST_NOT_FOUND` is still `FAIL`, not raise).
|
||||
**Description**: Implement `ManifestVerifier` (per the contract `_docs/02_document/contracts/c10_provisioning/manifest_verifier.md` v1.1.0), the read-only validator that AC-NEW-1 places between F2 takeoff and any engine deserialization. Loads `Manifest.json`, verifies its sidecar SHA-256 matches the Manifest bytes, parses the Ed25519 detached signature at `Manifest.json.sig`, verifies it against the caller-supplied `trusted_public_keys` tuple, parses the Manifest schema (rejecting absolute paths and schema violations), validates the optional `flight.takeoff_origin` block (well-formed `LatLonAlt` + inside `build.bbox` per ADR-010 + AZ-490), and walks every per-artifact entry re-hashing it via AZ-280's sidecar pattern. Returns a `VerificationResult` with `outcome ∈ {PASS, FAIL}`, the union of all `VerifyFailReason` values that fired, the populated `per_artifact_checks` list, the pass-through `takeoff_origin` + `flight_id` (or `None` when absent from the Manifest body), and `elapsed_ms`. Fail-closed: any deviation in signature, schema, key trust, hashes, or origin validity yields `FAIL` with detailed reasons. Never raises on a verify failure — only on environment errors (Manifest.json missing → `MANIFEST_NOT_FOUND` is still `FAIL`, not raise).
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-280_sha256_sidecar, AZ-281_engine_filename_schema
|
||||
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
||||
@@ -56,9 +56,15 @@ This task delivers the verifier + its frozen contract. It does NOT compile engin
|
||||
- If `trusted_public_keys` is empty: append `UNTRUSTED_PUBLIC_KEY`; return `FAIL`.
|
||||
5. **Step C — Schema parse**:
|
||||
- `orjson.loads(manifest_bytes)` → dict.
|
||||
- Validate required keys: `schema_version`, `build` (with sub-keys `bbox`, `zoom_levels`, `sector_class`, `built_at`, `manifest_hash`), `artifacts` (with `engines`, `descriptor_index`, `calibration`, `tiles_coverage`), `signing_public_key_fingerprint`.
|
||||
- Validate required keys: `schema_version`, `build` (with sub-keys `bbox`, `zoom_levels`, `sector_class`, `built_at`, `manifest_hash`), `artifacts` (with `engines`, `descriptor_index`, `calibration`, `tiles_coverage`), `signing_public_key_fingerprint`. `flight` block is OPTIONAL (added in schema v1.1, ADR-010).
|
||||
- Validate types: `engines` is list of `{path: str, sha256: str}`; `descriptor_index`, `calibration` are `{path: str, sha256: str}`; `tiles_coverage` is `{sha256: str, tile_count: int}`.
|
||||
- Validate path-relative-only: every `path` value must be relative (no leading `/`, no `..` segments). Append `SCHEMA_VIOLATION` per offending field; if any, return `FAIL`.
|
||||
- **Flight block (ADR-010 / AZ-490)**:
|
||||
- If `flight` key absent → `takeoff_origin = None`, `flight_id = None`; continue.
|
||||
- If `flight` present → parse `flight_id` (`UUID` or `None`) and `takeoff_origin` (optional block).
|
||||
- If `flight.takeoff_origin` present → validate `lat_deg ∈ [-90, 90]`, `lon_deg ∈ [-180, 180]`, `alt_m` finite (no NaN/Inf). Append `TAKEOFF_ORIGIN_INVALID` to `fail_reasons` and the offending field name to `fail_details` if any check fails.
|
||||
- If `flight.takeoff_origin` is well-formed → check it falls inside `build.bbox` (`bbox.lat_min ≤ lat ≤ bbox.lat_max`, `bbox.lon_min ≤ lon ≤ bbox.lon_max`). Append `TAKEOFF_ORIGIN_OUT_OF_BBOX` if not.
|
||||
- The `takeoff_origin` is populated on `VerificationResult` whenever the block parsed (even on FAIL), per MV-INV-9, so operators see what was attempted.
|
||||
6. **Step D — Per-artifact hash walk** (only reached if Steps A–C all passed):
|
||||
- For each engine, descriptor_index, calibration entry:
|
||||
- Compute `actual_path = manifest_path.parent / entry.path`.
|
||||
@@ -166,6 +172,26 @@ Given `trusted_public_keys = ()`
|
||||
When verify runs
|
||||
Then `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` regardless of Manifest validity; per-artifact walk does NOT happen
|
||||
|
||||
**AC-14: Manifest with no `flight` block parses cleanly (back-compat)**
|
||||
Given a v1.0 Manifest (no `flight` block) that is otherwise valid + signed
|
||||
When verify runs
|
||||
Then `outcome=PASS`; `VerificationResult.takeoff_origin is None`; `VerificationResult.flight_id is None`
|
||||
|
||||
**AC-15: Well-formed in-bbox `takeoff_origin` passes through**
|
||||
Given a v1.1 Manifest with `flight.takeoff_origin = (50.0, 36.2, 200.0)` inside the recorded bbox
|
||||
When verify runs
|
||||
Then `outcome=PASS`; `VerificationResult.takeoff_origin == LatLonAlt(50.0, 36.2, 200.0)`
|
||||
|
||||
**AC-16: Malformed `takeoff_origin` (lat=200) fails closed**
|
||||
Given a Manifest with `flight.takeoff_origin.lat_deg = 200`
|
||||
When verify runs
|
||||
Then `outcome=FAIL`; `fail_reasons` contains `TAKEOFF_ORIGIN_INVALID`; `fail_details` names `lat_deg`; the `takeoff_origin` field on `VerificationResult` is still populated for diagnostics
|
||||
|
||||
**AC-17: Out-of-bbox `takeoff_origin` fails closed**
|
||||
Given a Manifest whose `flight.takeoff_origin = (10.0, 10.0, 0)` while `build.bbox` covers `(49.5..50.5, 35.5..36.5)`
|
||||
When verify runs
|
||||
Then `outcome=FAIL`; `fail_reasons` contains `TAKEOFF_ORIGIN_OUT_OF_BBOX`
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
@@ -197,6 +223,10 @@ Then `fail_reasons=(UNTRUSTED_PUBLIC_KEY,)` regardless of Manifest validity; per
|
||||
| AC-9 | Operator mode + drifted tile | TILES_COVERAGE_MISMATCH |
|
||||
| AC-10 | Airborne mode | tiles_coverage matched=True |
|
||||
| AC-11 | Conformance check | True |
|
||||
| AC-14 | v1.0 Manifest (no flight block) | PASS; takeoff_origin=None; flight_id=None |
|
||||
| AC-15 | v1.1 Manifest, valid in-bbox origin | PASS; takeoff_origin populated |
|
||||
| AC-16 | Malformed origin (lat=200) | FAIL; TAKEOFF_ORIGIN_INVALID; field name in details |
|
||||
| AC-17 | Out-of-bbox origin | FAIL; TAKEOFF_ORIGIN_OUT_OF_BBOX |
|
||||
| AC-12 | Inspect elapsed_ms | All non-negative; ordered as expected |
|
||||
| AC-13 | Empty trusted keys | FAIL; UNTRUSTED |
|
||||
| NFR-perf-airborne | 5 artifact bench, no tile re-walk | p99 ≤ 100 ms |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Task**: AZ-325_c10_cache_provisioner
|
||||
**Name**: C10 CacheProvisioner
|
||||
**Description**: Implement `CacheProvisioner` (per the contract `_docs/02_document/contracts/c10_provisioning/cache_provisioner.md`), the public top-level orchestrator that composes AZ-321 (EngineCompiler), AZ-322 (DescriptorBatcher), and AZ-323 (ManifestBuilder) into a single idempotent F1 build pipeline. Acquires a `cache_root/.c10.lock` filesystem lockfile to enforce CP-INV-4. Computes the build-identity hash from the same canonical inputs AZ-323 hashes (model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels) and compares to the existing `Manifest.json`'s `manifest_hash`; on match → `outcome=IDEMPOTENT_NO_OP`. On mismatch (or no prior Manifest) → run engine compile → descriptor population → Manifest build, then walk `cache_root` to confirm every file is listed in the new Manifest's `artifacts` section, raising `ManifestCoverageError` on orphans (with rollback to prior-good Manifest). Empty corpus → `BuildReport(outcome=FAILURE, failure_reason="run C11 TileDownloader first")` per description.md § 5.
|
||||
**Description**: Implement `CacheProvisioner` (per the contract `_docs/02_document/contracts/c10_provisioning/cache_provisioner.md` v1.1.0), the public top-level orchestrator that composes AZ-321 (EngineCompiler), AZ-322 (DescriptorBatcher), and AZ-323 (ManifestBuilder) into a single idempotent F1 build pipeline. Acquires a `cache_root/.c10.lock` filesystem lockfile to enforce CP-INV-4. Computes the build-identity hash from the same canonical inputs AZ-323 hashes (model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels **+ takeoff_origin + flight_id**) and compares to the existing `Manifest.json`'s `manifest_hash`; on match → `outcome=IDEMPOTENT_NO_OP`. On mismatch (or no prior Manifest) → run engine compile → descriptor population → Manifest build (passing `request.takeoff_origin` and `request.flight_id` to AZ-323), then walk `cache_root` to confirm every file is listed in the new Manifest's `artifacts` section, raising `ManifestCoverageError` on orphans (with rollback to prior-good Manifest). Empty corpus → `BuildReport(outcome=FAILURE, failure_reason="run C11 TileDownloader first")` per description.md § 5. **A request whose `takeoff_origin` differs from the prior Manifest's by ≥ 1 mm is treated as a new build identity (CP-INV-8) — this is the contract that lets `ManifestVerifier` reject a re-planned route at boot.**
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-303_c6_storage_interfaces, AZ-321_c10_engine_compiler, AZ-322_c10_descriptor_batcher, AZ-323_c10_manifest_builder
|
||||
**Component**: c10_provisioning (epic AZ-252 / E-C10)
|
||||
@@ -40,13 +40,13 @@ This task delivers the orchestrator + its frozen contract. It does NOT compile e
|
||||
2. **Tile gathering**: call `tile_metadata_store.query_by_bbox(bbox, zoom_levels, sector_class)`.
|
||||
- If empty → return `BuildReport(outcome=FAILURE, failure_reason="no tiles in C6 for the requested scope; run C11 TileDownloader first", engines_built=0, ...)`. ERROR log; release lock.
|
||||
3. **Build-identity hash for idempotence check**:
|
||||
- Compute `request_hash = sha256(canonical_json(model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels))`. The `model_ids` come from the configured backbone list; `calibration_sha256` from streaming the calibration_path; `tiles_coverage_sha256` from sorting the tile rows by `(zoom, lat, lon, source)` and hashing per AZ-323's algorithm.
|
||||
- Compute `request_hash = sha256(canonical_json(model_ids + calibration_sha256 + tiles_coverage_sha256 + sector_class + bbox + zoom_levels + takeoff_origin_tuple_or_none + flight_id_or_none))`. The `model_ids` come from the configured backbone list; `calibration_sha256` from streaming the calibration_path; `tiles_coverage_sha256` from sorting the tile rows by `(zoom, lat, lon, source)` and hashing per AZ-323's algorithm. `takeoff_origin_tuple_or_none` is `(lat_deg, lon_deg, alt_m)` rounded to 9 decimal places when `request.takeoff_origin is not None`, otherwise the JSON `null` sentinel (CP-INV-8). The hashing formula MUST match AZ-323 exactly so AZ-325's idempotence decision agrees with AZ-323's emitted `build.manifest_hash`.
|
||||
- Read existing `Manifest.json` if present; parse only the `build.manifest_hash` field (don't run full verification — that's AZ-324's job). If `existing.manifest_hash == request_hash` → return `BuildReport(outcome=IDEMPOTENT_NO_OP, manifest_hash=existing.manifest_hash, manifest_path=existing_path, engines_built=0, engines_reused=0, descriptors_generated=0, elapsed_s, failure_reason=None)`. INFO log; release lock.
|
||||
4. **Active build path**:
|
||||
- Snapshot prior-good Manifest (rename to `Manifest.json.prev` if present) for rollback.
|
||||
- Compose engine compile request from configured backbones; call `engine_compiler.compile_engines_for_corpus(...)` → `engine_entries`.
|
||||
- Compose descriptor populate request (filter, callback hooked to logger); call `descriptor_batcher.populate_descriptors(...)` → `DescriptorBatchReport`. If `outcome=failure` → restore prior Manifest, release lock, return `BuildReport(outcome=FAILURE, failure_reason=batch.failure_reason, ...)`.
|
||||
- Compose Manifest build input from engine entries + descriptor index path + calibration + key_path; call `manifest_builder.build_manifest(...)` → `ManifestArtifact`.
|
||||
- Compose Manifest build input from engine entries + descriptor index path + calibration + key_path **+ `request.takeoff_origin` + `request.flight_id`** (ADR-010); call `manifest_builder.build_manifest(...)` → `ManifestArtifact`. Both fields default to `None` when the caller did not supply them (e.g., legacy C12 invocation without `--flight-id`).
|
||||
5. **Coverage check** (CP-INV-3 / D-C10-3):
|
||||
- Walk `cache_root` recursively (`pathlib.Path.rglob`); collect every regular file path EXCLUDING `Manifest.json`, `Manifest.json.sha256`, `Manifest.json.sig`, `Manifest.json.prev`, `.c10.lock`, and any `.sha256` sidecar (sidecars are implicit per the AZ-280 pattern, paired with their primary).
|
||||
- Build expected set: every `path` in `manifest.artifacts.engines + descriptor_index + calibration` (resolved relative to `cache_root`).
|
||||
@@ -152,6 +152,21 @@ Given a populated cache and identical request
|
||||
When `build_cache_artifacts` runs
|
||||
Then wall-clock ≤ 1 min (CP-TC-13 / NFR C10-PT-01); the bound work is the build-identity hash computation, which is dominated by `tiles_coverage_sha256` over 1000 tiles (~5 ms hashing)
|
||||
|
||||
**AC-14: `takeoff_origin` mismatch triggers full rebuild (ADR-010 / CP-INV-8)**
|
||||
Given a prior Manifest built with `takeoff_origin = A`
|
||||
When `build_cache_artifacts` is called with the SAME bbox / zooms / sector / calibration / tiles, but `takeoff_origin = B (B ≠ A by ≥ 1 mm)`
|
||||
Then `outcome=SUCCESS` (NOT `IDEMPOTENT_NO_OP`); the new Manifest replaces the old; the new `manifest_hash` differs from the prior; the new Manifest's `flight.takeoff_origin` matches B
|
||||
|
||||
**AC-15: `takeoff_origin = None` propagates through with no flight block in Manifest (back-compat)**
|
||||
Given a `BuildRequest` with `takeoff_origin = None` and `flight_id = None`
|
||||
When `build_cache_artifacts` runs
|
||||
Then `outcome=SUCCESS`; the produced Manifest has no `flight.takeoff_origin` key (AZ-323's AC-14); idempotence still works for subsequent identical-without-origin invocations
|
||||
|
||||
**AC-16: `flight_id` participation in idempotence**
|
||||
Given a prior Manifest built with `flight_id = X, takeoff_origin = A`
|
||||
When `build_cache_artifacts` runs with `flight_id = Y, takeoff_origin = A` (only `flight_id` differs)
|
||||
Then `outcome=SUCCESS` (NOT `IDEMPOTENT_NO_OP`); `flight_id` is part of the build identity per CP-INV-8
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
@@ -177,6 +192,9 @@ Then wall-clock ≤ 1 min (CP-TC-13 / NFR C10-PT-01); the bound work is the buil
|
||||
| AC-2 | Warm re-run with identical request | IDEMPOTENT_NO_OP; zero phase calls |
|
||||
| AC-3 | Different bbox after prior build | SUCCESS; atomic replace; old Manifest gone |
|
||||
| AC-4 | Empty C6 query | FAILURE; hint string; lock released |
|
||||
| AC-14 | Warm re-run with different takeoff_origin | SUCCESS; new manifest_hash; phases called |
|
||||
| AC-15 | Build with takeoff_origin=None | SUCCESS; Manifest has no flight.takeoff_origin |
|
||||
| AC-16 | Warm re-run with different flight_id only | SUCCESS; new manifest_hash |
|
||||
| AC-5 | Pre-acquire lock externally; run | BuildLockHeldError |
|
||||
| AC-6 | Inject orphan file before coverage walk | ManifestCoverageError; prior Manifest restored |
|
||||
| AC-7 | Same as AC-6 with `coverage_strict=False` | SUCCESS; WARN log |
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
**Task**: AZ-326_c12_cli_app
|
||||
**Name**: C12 CLI App
|
||||
**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 (`build_cache`, `companion_bringup`, `post_landing_upload`, `operator_reloc_service` — all owned by sibling tasks 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.
|
||||
**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
|
||||
**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)
|
||||
**Tracker**: AZ-326
|
||||
**Epic**: AZ-253 (E-C12)
|
||||
@@ -42,12 +42,12 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
|
||||
- `src/operator_tool/freshness_table.py` — `freshness_threshold_months(sector_class: SectorClassification) -> int`:
|
||||
- Pure data: `active_conflict → 1 month`; `stable_rear → 12 months`. Documented inline as the AC-NEW-6 freshness budget per description.md § 1 + Plan-phase intent.
|
||||
- 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`. Sibling tasks may extend with documented additions.
|
||||
- `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.
|
||||
- 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` — delegates to `build_cache_orchestrator.build_cache(...)` (sibling T3). Maps `CacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE` (per `failure_phase`); `BuildLockHeldError → EXIT_LOCK_HELD`.
|
||||
- `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`.
|
||||
- `upload-pending` — delegates to `post_landing_upload.trigger_post_landing_upload(...)` (sibling T4). Maps `FlightStateNotConfirmedError → EXIT_FLIGHT_STATE_NOT_CONFIRMED`; `UploadGateBlockedError → EXIT_UPLOAD_FAILURE`.
|
||||
- `reloc-confirm` — delegates to `operator_reloc_service.request_reloc(...)` (sibling T5). Maps `GcsLinkError → EXIT_GCS_LINK_ERROR`.
|
||||
- `verify-ready` — delegates to `companion_bringup.verify_companion_ready(...)` (sibling T2). Maps `CompanionUnreachableError → EXIT_COMPANION_UNREACHABLE`; `ContentHashMismatchError → EXIT_CONTENT_HASH_MISMATCH`.
|
||||
@@ -131,6 +131,39 @@ Given `set-sector --area Derkachi --class active_conflict` was just run
|
||||
When the same command is run again
|
||||
Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the log, not in the data file); the operator sees the same exit code 0 and the same INFO log line
|
||||
|
||||
**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
|
||||
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
|
||||
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
|
||||
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)
|
||||
Then exit code is `EXIT_USAGE = 2`; stderr lists which flag must be supplied
|
||||
|
||||
**AC-15: `FlightNotFoundError` maps to `EXIT_FLIGHT_NOT_FOUND`**
|
||||
Given `fetch_flight` raises `FlightNotFoundError`
|
||||
When `build-cache --flight-id <unknown>` runs
|
||||
Then exit code is `62`; ERROR log carries the offending flight_id; ZERO calls to C11/C10
|
||||
|
||||
**AC-16: `FlightsApiAuthError` maps to `EXIT_FLIGHTS_API_AUTH`** (and never logs the auth token)
|
||||
Given `fetch_flight` raises `FlightsApiAuthError`
|
||||
When `build-cache --flight-id <id>` runs
|
||||
Then exit code is `61`; the structured log entry does NOT contain the `auth_token` value
|
||||
|
||||
**AC-17: `EmptyWaypointsError` maps to `EXIT_EMPTY_WAYPOINTS`**
|
||||
Given the fetched `FlightDto` has zero waypoints
|
||||
When `build-cache --flight-id <id>` runs (and the orchestrator calls `bbox_from_waypoints` → raises)
|
||||
Then exit code is `64`; the stderr message instructs the operator to re-plan in the Mission Planner UI
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
@@ -158,6 +191,13 @@ Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the
|
||||
| AC-8 | `subprocess.run(["operator-tool", "--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 |
|
||||
| AC-12 | `build-cache --flight-file` happy path | `load_flight_file` called; `fetch_flight` NOT called |
|
||||
| AC-13 | Both `--flight-id` and `--flight-file` | Exit 2; conflict message |
|
||||
| AC-14 | Neither flight source supplied | Exit 2; usage hint |
|
||||
| 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 |
|
||||
|
||||
## Constraints
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
**Task**: AZ-328_c12_build_cache_orchestrator
|
||||
**Name**: C12 Build-Cache Orchestrator
|
||||
**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: (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 `area`, `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, 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, ...)` because the artifacts the C11 step pushed to the companion did not survive the verification (the boundary case here is that the operator workstation may have ingested tiles into local C6 but the companion's pre-existing artifacts are stale); (5) SSH-invoke `C10.CacheProvisioner.build_cache_artifacts` (AZ-325) on the companion via the `RemoteCacheProvisionerInvoker` helper, streaming the C10 stdout/stderr lines back as DEBUG logs and parsing 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` (download phase → retry hint, key rotation hint; build phase → cache cleanup hint, GPU OOM mitigation hint). Surfaces a clear non-zero exit code via T1's `cli.py` mapping. Owns the operator-facing C12-IT-02 acceptance test contract (build_cache orchestrates C11 then C10; download failure aborts before C10; mixed reports surface in `CacheBuildReport`).
|
||||
**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-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
|
||||
**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)
|
||||
**Tracker**: AZ-328
|
||||
**Epic**: AZ-253 (E-C12)
|
||||
@@ -34,12 +34,13 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
|
||||
## Outcome
|
||||
|
||||
- A `BuildCacheOrchestrator` class at `src/operator_tool/build_cache.py`:
|
||||
- Constructor: `__init__(self, *, tile_downloader: TileDownloader, companion_bringup: CompanionBringup, remote_c10_invoker: RemoteCacheProvisionerInvoker, freshness_table: FreshnessTable, lock_factory: FileLockFactory, logger: Logger, clock: Clock, config: C12BuildCacheConfig)`.
|
||||
- `C12BuildCacheConfig` (`@dataclass(frozen=True)`): `cache_staging_root: Path`, `lock_filename: str = ".c12.lock"`, `lock_timeout_s: float = 5.0`, `companion_cache_root: PurePosixPath`.
|
||||
- Constructor: `__init__(self, *, flights_api_client: FlightsApiClient, tile_downloader: TileDownloader, companion_bringup: CompanionBringup, remote_c10_invoker: RemoteCacheProvisionerInvoker, freshness_table: FreshnessTable, lock_factory: FileLockFactory, logger: Logger, clock: Clock, config: C12BuildCacheConfig)`.
|
||||
- `C12BuildCacheConfig` (`@dataclass(frozen=True)`): `cache_staging_root: Path`, `lock_filename: str = ".c12.lock"`, `lock_timeout_s: float = 5.0`, `companion_cache_root: PurePosixPath`, `flight_bbox_buffer_m: float = 1000.0`, `flights_api_base_url: str`, `flights_api_auth_token: SecretStr`.
|
||||
- Public method: `build_cache(request: BuildCacheRequest) -> CacheBuildReport`.
|
||||
- DTOs at `src/operator_tool/_types.py`:
|
||||
- `BuildCacheRequest` (`@dataclass(frozen=True)`): `area: AreaIdentifier`, `bbox: Bbox`, `sector_class: SectorClassification`, `calibration_path: Path`, `satellite_provider_url: str`, `api_key: SecretStr`, `companion_address: CompanionAddress`, `expected_engines: tuple[str, ...]`.
|
||||
- `CacheBuildReport` (`@dataclass(frozen=True)`): `download_report: DownloadBatchReport | None`, `build_report: BuildReport | None`, `outcome: enum {success, failure, idempotent_no_op}`, `failure_phase: enum {none, download, build}`, `failure_reason: str | None`, `wall_clock_s: float`.
|
||||
- `BuildCacheRequest` (`@dataclass(frozen=True)`): `flight_source: FlightSource (one of `FlightById(flight_id: UUID)` or `FlightFromFile(path: Path)`)`, `sector_class: SectorClassification`, `calibration_path: Path`, `satellite_provider_url: str`, `api_key: SecretStr`, `companion_address: CompanionAddress`, `expected_engines: tuple[str, ...]`. **The legacy `bbox` field is removed — the orchestrator derives bbox from the resolved `FlightDto`.**
|
||||
- `FlightResolveReport` (`@dataclass(frozen=True)`): `source: enum {flights_api, flight_file}`, `flight_id: UUID`, `waypoint_count: int`, `bbox: Bbox`, `takeoff_origin: LatLonAlt`, `raw_flight_dto: FlightDto`.
|
||||
- `CacheBuildReport` (`@dataclass(frozen=True)`): `flight_resolve_report: FlightResolveReport | None`, `download_report: DownloadBatchReport | None`, `build_report: BuildReport | None`, `outcome: enum {success, failure, idempotent_no_op}`, `failure_phase: enum {none, flight_resolve, download, build}`, `failure_reason: str | None`, `wall_clock_s: float`.
|
||||
- Errors at `src/operator_tool/errors.py`:
|
||||
- `CacheBuildError(Exception)`: attributes `failure_phase: enum {download, build}`, `wrapped_exception_repr: str`, `remediation: str`. The `remediation` attribute is populated at construction time per `failure_phase` (download → "Re-run with same args; check `satellite_provider_url` and `api_key`."; build → "Inspect companion `~/.azaion/onboard/c10-build.log`; consider `rm -rf <companion_cache_root>/engines/` to force a clean rebuild.").
|
||||
- `BuildLockHeldError(CacheBuildError)`: subclass for the lock-held case with `remediation` = "Another `build-cache` is in progress; wait or kill the holding process and remove `<lock_path>`."
|
||||
@@ -59,14 +60,22 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
|
||||
```
|
||||
Concrete: `FilelockFileLockFactory` wrapping the `filelock` library per the project pin (already used by E-C13 per epics.md C13 section). NOT a custom implementation.
|
||||
- Method flow for `build_cache`:
|
||||
0. **Flight resolve phase** (ADR-010 / AZ-489) — runs BEFORE the lockfile is acquired:
|
||||
- Branch on `request.flight_source`:
|
||||
- `FlightById(flight_id)` → `flight = flights_api_client.fetch_flight(flight_id=..., base_url=config.flights_api_base_url, auth_token=config.flights_api_auth_token)`.
|
||||
- `FlightFromFile(path)` → `flight = flights_api_client.load_flight_file(path=path)`.
|
||||
- Compute `bbox = flights_api_client.bbox_from_waypoints(flight.waypoints, buffer_m=config.flight_bbox_buffer_m)`.
|
||||
- Compute `takeoff_origin = flights_api_client.takeoff_origin_from_flight(flight)`.
|
||||
- Build `FlightResolveReport(source=..., flight_id=flight.flight_id, waypoint_count=len(flight.waypoints), bbox, takeoff_origin, raw_flight_dto=flight)`.
|
||||
- Catch `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError` → wrap as `CacheBuildError(failure_phase=flight_resolve, ...)` and return `CacheBuildReport(outcome=failure, failure_phase=flight_resolve, flight_resolve_report=None, download_report=None, build_report=None, ...)`. INFO log `kind="c12.build_cache.flight_resolve.start"` before; ERROR log `kind="c12.build_cache.flight_resolve.failed"` on failure with the resolved error class name (auth_token NEVER logged).
|
||||
1. Compute `lock_path = config.cache_staging_root / config.lock_filename`. Ensure `config.cache_staging_root` exists (mkdir parents=True).
|
||||
2. Compute `freshness_threshold_months = freshness_table.threshold(request.sector_class)` (uses T1's helper).
|
||||
3. Acquire lock: `with lock_factory.try_lock(lock_path, timeout_s=config.lock_timeout_s) as lock:` — on timeout, raise `BuildLockHeldError(failure_phase=download, ...)`.
|
||||
4. Record `start_t = clock.monotonic()`.
|
||||
5. INFO log `kind="c12.build_cache.start"` with the request (api_key REDACTED).
|
||||
6. **Download phase**: `download_report = tile_downloader.fetch(DownloadRequest(area=request.area, bbox=request.bbox, freshness_threshold_months=freshness_threshold_months, url=request.satellite_provider_url, api_key=request.api_key))`. Catch `SatelliteProviderError`, `RateLimitedError`, `ResolutionRejectionError`, `CacheBudgetExceededError`, `TileManagerError` → wrap as `CacheBuildError(failure_phase=download, ...)`. If `download_report.outcome == failure` → return `CacheBuildReport(outcome=failure, failure_phase=download, download_report=..., build_report=None, failure_reason=download_report.failure_reason, wall_clock_s=...)`.
|
||||
7. **Verify-ready phase**: `readiness = companion_bringup.verify_companion_ready(request.companion_address)`. Catch `CompanionUnreachableError`, `ContentHashMismatchError` → wrap as `CacheBuildError(failure_phase=download, ...)` (the C11 download succeeded but the companion is not in a state to consume the new tiles; failure_phase is `download` because the operator's next action is to re-run the same `build-cache` command, not to clean the build). If `readiness.outcome == not_ready` → return `CacheBuildReport(outcome=failure, failure_phase=download, ..., failure_reason="companion not ready: " + ", ".join(readiness.not_ready_reasons))`.
|
||||
8. **Build phase**: open SSH session via `ssh_factory.open(request.companion_address, ...)`; call `remote_c10_invoker.invoke(session, RemoteBuildRequest(bbox=request.bbox, sector_class=request.sector_class, calibration_path=request.calibration_path, expected_engines=request.expected_engines, companion_cache_root=config.companion_cache_root))`; catch `EngineBuildError`, `CalibrationCacheError`, `ManifestSignatureError`, `ManifestCoverageError`, `BuildLockHeldError` (C10's lock, distinct from C12's) → wrap as `CacheBuildError(failure_phase=build, ...)`.
|
||||
5. INFO log `kind="c12.build_cache.start"` with the request (api_key + auth_token REDACTED) and the `flight_resolve_report` summary.
|
||||
6. **Download phase**: `download_report = tile_downloader.fetch(DownloadRequest(bbox=flight_resolve_report.bbox, freshness_threshold_months=freshness_threshold_months, url=request.satellite_provider_url, api_key=request.api_key))` — the bbox is the one derived in phase 0; the orchestrator no longer accepts a caller-supplied bbox. Catch `SatelliteProviderError`, `RateLimitedError`, `ResolutionRejectionError`, `CacheBudgetExceededError`, `TileManagerError` → wrap as `CacheBuildError(failure_phase=download, ...)`. If `download_report.outcome == failure` → return `CacheBuildReport(outcome=failure, failure_phase=download, flight_resolve_report=..., download_report=..., build_report=None, failure_reason=download_report.failure_reason, wall_clock_s=...)`.
|
||||
7. **Verify-ready phase**: `readiness = companion_bringup.verify_companion_ready(request.companion_address)`. Catch `CompanionUnreachableError`, `ContentHashMismatchError` → wrap as `CacheBuildError(failure_phase=download, ...)`. If `readiness.outcome == not_ready` → return `CacheBuildReport(outcome=failure, failure_phase=download, ..., failure_reason="companion not ready: " + ", ".join(readiness.not_ready_reasons))`.
|
||||
8. **Build phase**: open SSH session via `ssh_factory.open(request.companion_address, ...)`; call `remote_c10_invoker.invoke(session, RemoteBuildRequest(bbox=flight_resolve_report.bbox, zoom_levels=..., sector_class=request.sector_class, calibration_path=request.calibration_path, expected_engines=request.expected_engines, companion_cache_root=config.companion_cache_root, takeoff_origin=flight_resolve_report.takeoff_origin, flight_id=flight_resolve_report.flight_id))` — the orchestrator forwards `takeoff_origin` + `flight_id` to the remote C10 build entry point so AZ-325 / AZ-323 bake them into the Manifest (ADR-010, AZ-490 consumes them on the companion at boot). Catch `EngineBuildError`, `CalibrationCacheError`, `ManifestSignatureError`, `ManifestCoverageError`, `BuildLockHeldError` (C10's lock, distinct from C12's) → wrap as `CacheBuildError(failure_phase=build, ...)`.
|
||||
9. Aggregate: `build_report` from step 8. If `build_report.outcome == IDEMPOTENT_NO_OP` → return `CacheBuildReport(outcome=idempotent_no_op, failure_phase=none, download_report=..., build_report=..., failure_reason=None, wall_clock_s=...)`. Else if `build_report.outcome == FAILURE` → return `CacheBuildReport(outcome=failure, failure_phase=build, ..., failure_reason=build_report.failure_reason, ...)`.
|
||||
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=...)`.
|
||||
@@ -99,10 +108,10 @@ This task delivers the F1 orchestrator + the remote C10 invoker + the lockfile +
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Happy path — download → verify-ready → build → `success`**
|
||||
Given a fresh empty C6 + a clean companion + valid `BuildCacheRequest` + fakes that all return `success`
|
||||
**AC-1: Happy path — flight-resolve → download → verify-ready → build → `success`**
|
||||
Given a fresh empty C6 + a clean companion + valid `BuildCacheRequest(flight_source=FlightById(...))` + fakes that all return `success` (including a 3-waypoint `FlightDto`)
|
||||
When `build_cache(request)` is called
|
||||
Then the call sequence is `lock acquire → tile_downloader.fetch → companion_bringup.verify_companion_ready → remote_c10_invoker.invoke → lock release` (verifiable via spy on each fake); `CacheBuildReport(outcome=success, failure_phase=none, download_report=..., build_report=..., failure_reason=None)` is returned; ONE INFO log `kind="c12.build_cache.start"`; ONE INFO log `kind="c12.build_cache.success"`
|
||||
Then the call sequence is `flights_api_client.fetch_flight → bbox_from_waypoints → takeoff_origin_from_flight → lock acquire → tile_downloader.fetch (with derived bbox) → companion_bringup.verify_companion_ready → remote_c10_invoker.invoke (with takeoff_origin + flight_id) → lock release` (verifiable via spy on each fake); `CacheBuildReport(outcome=success, failure_phase=none, flight_resolve_report=..., download_report=..., build_report=..., failure_reason=None)` is returned; ONE INFO log `kind="c12.build_cache.flight_resolve.start"`; ONE INFO log `kind="c12.build_cache.start"`; ONE INFO log `kind="c12.build_cache.success"`
|
||||
|
||||
**AC-2: Download failure aborts before C10**
|
||||
Given a fake `tile_downloader.fetch` that raises `SatelliteProviderError("503 Service Unavailable")`
|
||||
@@ -144,10 +153,35 @@ Given a `BuildCacheRequest` with `api_key=SecretStr("super-secret-token")`
|
||||
When any log line is emitted by the orchestrator
|
||||
Then no log line contains the literal token; `api_key` field appears as `"REDACTED"` or is omitted entirely
|
||||
|
||||
**AC-10: Aggregated `CacheBuildReport` carries both sub-reports on success**
|
||||
**AC-10: Aggregated `CacheBuildReport` carries all sub-reports on success**
|
||||
Given a happy-path run
|
||||
When the caller inspects the returned `CacheBuildReport`
|
||||
Then `download_report` is a populated `DownloadBatchReport` from C11; `build_report` is a populated `BuildReport` from C10; `wall_clock_s` is a positive float; both sub-reports' fields are accessible (no truncation)
|
||||
Then `flight_resolve_report` is a populated `FlightResolveReport`; `download_report` is a populated `DownloadBatchReport` from C11; `build_report` is a populated `BuildReport` from C10; `wall_clock_s` is a positive float; all sub-reports' fields are accessible (no truncation)
|
||||
|
||||
**AC-11: Flight-resolve failure aborts BEFORE the lockfile (ADR-010)**
|
||||
Given `flights_api_client.fetch_flight` raises `FlightNotFoundError`
|
||||
When `build_cache(request)` is called
|
||||
Then `CacheBuildReport(outcome=failure, failure_phase=flight_resolve, flight_resolve_report=None, download_report=None, build_report=None, failure_reason="flight not found: <uuid>")` is returned; `lock_factory.try_lock` is NEVER called; `tile_downloader.fetch` is NEVER called; `companion_bringup.verify_companion_ready` is NEVER called; `remote_c10_invoker.invoke` is NEVER called; ONE ERROR log `kind="c12.build_cache.flight_resolve.failed"`
|
||||
|
||||
**AC-12: Offline flight-file path used when `FlightFromFile` source is passed**
|
||||
Given `BuildCacheRequest(flight_source=FlightFromFile(path=/tmp/flight.json))`
|
||||
When `build_cache(request)` is called
|
||||
Then `flights_api_client.load_flight_file(path=/tmp/flight.json)` is called once; `flights_api_client.fetch_flight` is NEVER called; the rest of the pipeline runs identically
|
||||
|
||||
**AC-13: `takeoff_origin` is forwarded to the remote C10 invoker**
|
||||
Given a fake `FlightDto` with `waypoints[0] = (50.0, 36.2, 200.0)`
|
||||
When `build_cache(request)` is called through to the build phase
|
||||
Then `remote_c10_invoker.invoke` is called with `RemoteBuildRequest.takeoff_origin == LatLonAlt(50.0, 36.2, 200.0)` and `RemoteBuildRequest.flight_id == flight.flight_id`
|
||||
|
||||
**AC-14: `EmptyWaypointsError` surfaces with `failure_phase=flight_resolve`**
|
||||
Given the resolved `FlightDto` has zero waypoints (so `bbox_from_waypoints` raises `EmptyWaypointsError`)
|
||||
When `build_cache(request)` is called
|
||||
Then `CacheBuildReport(outcome=failure, failure_phase=flight_resolve, ..., failure_reason="empty waypoints; re-plan in Mission Planner UI")` is returned; lockfile NOT acquired
|
||||
|
||||
**AC-15: `auth_token` is REDACTED in all log output (Phase 0)**
|
||||
Given `config.flights_api_auth_token = SecretStr("bearer-xyz")`
|
||||
When any log line is emitted by the flight-resolve phase
|
||||
Then no log line contains the literal `bearer-xyz`; the field appears as `"REDACTED"` or is omitted entirely (same convention as AC-9 for `api_key`)
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
@@ -178,13 +212,18 @@ Then `download_report` is a populated `DownloadBatchReport` from C11; `build_rep
|
||||
| AC-7 | Fake C10 returns `IDEMPOTENT_NO_OP` | `outcome=idempotent_no_op`, INFO log |
|
||||
| AC-8 | Construct each error type, inspect `remediation` | Matches documented text per phase |
|
||||
| AC-9 | Capture log output with `api_key="super-secret-token"` | Token not present in any log line |
|
||||
| AC-10 | Happy-path inspect returned report | Both sub-reports present, fields accessible |
|
||||
| AC-10 | Happy-path inspect returned report | All three sub-reports (flight_resolve + download + build) present, fields accessible |
|
||||
| AC-11 | Fake `fetch_flight` raises `FlightNotFoundError` | `failure_phase=flight_resolve`; lockfile NOT acquired; ZERO downstream calls |
|
||||
| AC-12 | `FlightFromFile` source | `load_flight_file` called; `fetch_flight` NOT called |
|
||||
| AC-13 | Inspect `RemoteBuildRequest` sent to invoker | `takeoff_origin` + `flight_id` forwarded |
|
||||
| AC-14 | `EmptyWaypointsError` from `bbox_from_waypoints` | `failure_phase=flight_resolve`; lockfile NOT acquired |
|
||||
| AC-15 | Capture log output with auth_token | Token not present |
|
||||
| NFR-perf-overhead | Microbench orchestrator-only path with all-fake collaborators × 100 | p99 ≤ 50 ms (excludes real network/SSH) |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Strict phase ordering is non-negotiable: download → verify-ready → build. Any reordering breaks AC-2/AC-3 and causes operators to chase phantom errors.
|
||||
- `failure_phase` is a closed set `{none, download, build}` — adding a new value requires Plan-cycle approval (operators script against these values).
|
||||
- Strict phase ordering is non-negotiable: flight_resolve → lock → download → verify-ready → build. Any reordering breaks AC-2/AC-3/AC-11 and causes operators to chase phantom errors. **The flight_resolve phase happens BEFORE the lockfile is acquired — a Flight that cannot be resolved is an operator-input error, not a contended-resource error, and should not block parallel builds.**
|
||||
- `failure_phase` is a closed set `{none, flight_resolve, download, build}` — adding a new value requires Plan-cycle approval (operators script against these values).
|
||||
- The lockfile lives in the operator workstation's cache staging area, NOT on the companion. Companion-side concurrent protection is C10's responsibility (CP-INV-4 in AZ-325).
|
||||
- `api_key` field uses `pydantic.SecretStr` (or equivalent) and MUST NOT be `repr()`-logged anywhere in the orchestrator.
|
||||
- The remote C10 invocation goes through the same `SshSessionFactory` as T2 — do NOT instantiate a second SSH client. Single composition root.
|
||||
|
||||
@@ -1,57 +1,67 @@
|
||||
# FT-P-11 — Cold-start initialization from FC EKF
|
||||
# FT-P-11 — Cold-start initialization from operator origin (primary) OR FC EKF (secondary)
|
||||
|
||||
**Task**: AZ-419_ft_p_11_cold_start_init
|
||||
**Name**: Cold-start initialization from FC EKF's last valid GPS + IMU-extrapolated position (AC-5.1)
|
||||
**Description**: Implement FT-P-11 — start SITL with `cold-boot-fixture` snapshot loaded; start SUT cold; push first nav-camera frame; assert first outbound estimate's lat/lon within ±50 m of the FC EKF snapshot pose.
|
||||
**Name**: Cold-start initialization — operator-origin-from-Manifest primary; FC EKF GPS secondary (ADR-010 + AC-5.1)
|
||||
**Description**: Implement FT-P-11 — exercise both cold-start paths defined by ADR-010. **Primary path (AZ-490)**: pre-bake a `takeoff_origin` into the C10 Manifest, start SUT cold, push first nav-camera frame, assert the first outbound estimate's lat/lon falls within ±50 m of the operator origin even when the SITL FC EKF reports NO valid GPS. **Secondary path (legacy AC-5.1)**: clear the Manifest's `takeoff_origin`, load a `cold-boot-fixture` snapshot into SITL, start SUT cold, push first nav-camera frame, assert the first outbound estimate's lat/lon falls within ±50 m of the FC EKF snapshot. The two paths share a single test module parameterised on `(origin_source ∈ {operator_manifest, fc_ekf})`. The test also exercises the bounded-delta gate (Principle #11 amended): set Manifest origin to A and SITL FC EKF to a position B with `|A − B| > 200 m`; assert the operator origin wins and the FC GPS is logged as suspect.
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-406, AZ-407 (cold-boot-fixture)
|
||||
**Dependencies**: AZ-406, AZ-407 (cold-boot-fixture), AZ-323 / AZ-325 (Manifest with takeoff_origin), AZ-490 (set_takeoff_origin), AZ-489 (FlightsApiClient — used by the test fixture builder to fabricate Manifests with a known origin)
|
||||
**Component**: Blackbox Tests / Positive / Startup (epic AZ-262)
|
||||
**Tracker**: AZ-419
|
||||
**Epic**: AZ-262 (E-BBT)
|
||||
|
||||
## Problem
|
||||
|
||||
Cold-start initialization is a critical path — if the SUT cannot bootstrap its prior from the FC's last valid GPS + IMU-extrapolated pose (AC-5.1), it cannot resume after companion reboot or after a cold boot in a flight-resume scenario. This must be measured.
|
||||
Cold-start initialization is a critical path. The original assumption that the FC EKF's last valid GPS fix is always available at takeoff (AC-5.1) does not hold under realistic EW conditions — a UAV can be jammed at the launch site before takeoff, leaving the FC EKF with no valid GPS. ADR-010 introduces the operator-planned mission as the **primary** cold-start trust anchor: the operator authors the route in the Mission Planner UI, C12 fetches the `Flight`, derives `takeoff_origin` from `waypoints[0]`, and bakes it into the C10 Manifest. The airborne C5's `set_takeoff_origin` (AZ-490) consumes it before any sensor sample. The FC EKF GPS becomes the **secondary** path used only when the Manifest carries no origin (back-compat). Both paths must be measured end-to-end, and the bounded-delta gate (the third clause of the spoof-promotion gate, Principle #11) must be exercised so an inconsistent FC GPS at takeoff does not silently override the operator origin.
|
||||
|
||||
## Outcome
|
||||
|
||||
- pytest scenario at `e2e/tests/positive/test_ft_p_11_cold_start_init.py`.
|
||||
- Loads `cold-boot-fixture` JSON pose into SITL (parameter-load path); starts SITL.
|
||||
- Starts SUT (cold — no prior state).
|
||||
- Pushes a single first nav-camera frame.
|
||||
- Reads the first outbound estimate; computes Vincenty distance to the FC-EKF snapshot pose.
|
||||
- Asserts distance ≤ 50 m.
|
||||
- Parameterised on `origin_source ∈ {operator_manifest, fc_ekf, bounded_delta_conflict}`:
|
||||
- **`operator_manifest`** (primary path, AZ-490): the test fixture builder writes a Manifest with `flight.takeoff_origin = A` (a known `LatLonAlt`); SITL starts with NO valid GPS (`GPS_TYPE = 0` or simulated denial); SUT cold-starts; the test asserts the first outbound estimate's lat/lon is within ±50 m of `A`.
|
||||
- **`fc_ekf`** (secondary path, legacy AC-5.1): Manifest has no `flight.takeoff_origin`; `cold-boot-fixture` JSON pose loaded into SITL (parameter-load path); SUT cold-starts; the test asserts the first outbound estimate's lat/lon is within ±50 m of the FC-EKF snapshot pose.
|
||||
- **`bounded_delta_conflict`** (ADR-010 Principle #11 amended): Manifest carries `takeoff_origin = A`; SITL FC EKF reports `B` with `vincenty(A, B) > 200 m`; the test asserts the first outbound estimate falls within ±50 m of `A` (operator origin wins), the source label on the first estimate is NOT `SATELLITE_ANCHORED` (no immediate spoof-promotion), and the FDR carries a `c5.gps_bounded_delta.reject` record naming both A and B.
|
||||
- Starts SUT (cold — no prior FDR, no in-memory state). Pushes a single first nav-camera frame. Reads the first outbound estimate; computes Vincenty distance to the expected origin.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- Cold-boot SITL parameter load.
|
||||
- Cold-boot SITL parameter load (secondary path).
|
||||
- Test fixture builder that produces a C10 Manifest with a known `flight.takeoff_origin` (primary path); reuses AZ-323's canonical JSON serialization.
|
||||
- SUT cold start (`docker compose up gps-denied-onboard` from clean state; OR `systemctl start` on Tier-2).
|
||||
- First-frame push and first-emission read.
|
||||
- Distance comparison.
|
||||
- FDR record assertions for the bounded-delta conflict scenario.
|
||||
|
||||
### Excluded
|
||||
- Cold-start TTFF latency — owned by NFT-PERF-03 (AZ-430).
|
||||
- Companion mid-flight reboot — owned by NFT-RES-02 (AZ-433).
|
||||
- Mid-flight bounded-delta gate (only the takeoff slice is covered here; mid-flight is part of AZ-385 follow-up).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: SITL reflects snapshot pose**
|
||||
Given `cold-boot-fixture` loaded
|
||||
Then the SITL EKF reports the snapshot pose (within ±1 m per fixture's load tolerance).
|
||||
**AC-1: Primary path (operator origin) — SUT cold-starts even when FC EKF has no GPS (ADR-010)**
|
||||
Given a C10 Manifest with `flight.takeoff_origin = LatLonAlt(50.0, 36.2, 200.0)` AND SITL configured with no valid GPS
|
||||
When SUT cold-starts and the first nav-camera frame is pushed
|
||||
Then the SUT emits its first outbound message within ≤30 s; `vincenty(estimate.position, manifest.takeoff_origin) ≤ 50 m`; the FDR carries a `c5.cold_start_origin.set` record with `source = "manifest"`
|
||||
|
||||
**AC-2: SUT initializes from FC EKF**
|
||||
Given SUT cold-started against the loaded SITL
|
||||
When the first nav-camera frame is pushed
|
||||
Then the SUT emits its first outbound message within ≤30 s (AC-NEW-1 budget — but FT-P-11 itself has a relaxed 60 s timeout).
|
||||
**AC-2: Secondary path (FC EKF) — Manifest has no origin (back-compat)**
|
||||
Given a C10 Manifest with no `flight.takeoff_origin` AND `cold-boot-fixture` JSON loaded into SITL
|
||||
When SUT cold-starts
|
||||
Then the first outbound estimate is within ±50 m of the FC EKF snapshot; the FDR carries a `c5.cold_start_origin.set` record with `source = "fc_ekf"`
|
||||
|
||||
**AC-3: first-emission position within budget**
|
||||
Given the first outbound estimate
|
||||
Then `vincenty(estimate_position, snapshot_position) ≤ 50 m`.
|
||||
**AC-3: No origin available — SUT refuses takeoff**
|
||||
Given a C10 Manifest with no `flight.takeoff_origin` AND SITL with no valid GPS
|
||||
When SUT cold-starts
|
||||
Then NO outbound `EmittedExternalPosition` is produced within the AC-NEW-1 30 s budget; the SUT logs `c5.cold_start_origin.unavailable` to FDR + GCS STATUSTEXT; the test asserts the FT-P-11 takeoff-abort policy fires
|
||||
|
||||
**AC-4: parameterization**
|
||||
Given conftest parameterization
|
||||
Then the scenario runs per `(fc_adapter, vio_strategy)`.
|
||||
**AC-4: Bounded-delta conflict — operator origin wins (ADR-010 Principle #11 amended)**
|
||||
Given Manifest `takeoff_origin = A` AND SITL FC EKF reports `B` with `vincenty(A, B) > 200 m`
|
||||
When SUT cold-starts and the first nav-camera frame is pushed
|
||||
Then the first outbound estimate is within ±50 m of `A`; the source label is NOT `SATELLITE_ANCHORED` (no immediate spoof-promotion); the FDR carries a `c5.gps_bounded_delta.reject` record naming both A and B and the computed distance
|
||||
|
||||
**AC-5: parameterization across FC adapters + VIO strategies**
|
||||
Given conftest parameterization on `(fc_adapter, vio_strategy, origin_source)`
|
||||
Then each combination listed in the test matrix runs the appropriate ACs (AC-1 / AC-2 / AC-3 / AC-4 per `origin_source`)
|
||||
|
||||
## System Under Test Boundary
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# C5 set_takeoff_origin entrypoint — accept operator origin from C10 Manifest
|
||||
|
||||
**Task**: AZ-490_c5_set_takeoff_origin
|
||||
**Name**: C5 set_takeoff_origin entrypoint — accept operator origin from C10 Manifest
|
||||
**Description**: Extend `StateEstimator` (Protocol + both concrete impls — `GtsamIsam2StateEstimator` and `EskfStateEstimator`) with a new pre-takeoff entrypoint `set_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None` that seeds the cold-start prior to the first frame. The composition root calls this method during F2 (Takeoff load) when the C10 ManifestVerifier reports a valid `flight.takeoff_origin`. With operator origin set, the FC-EKF cold-start path becomes the secondary fallback (ADR-010). The method also makes the spoof-promotion gate (AZ-385) consult a third bounded-delta clause: mid-flight FC GPS samples within 200 m of the current smoother estimate may be admitted as a soft constraint; samples > 200 m off are rejected and emit an FDR `c5.gps_bounded_delta.reject` record. The 200 m threshold is config-driven (`spoof_promotion_bounded_delta_m`, default 200.0). This task delivers the C5-side contract + the FDR record kind + the unit tests covering primary/secondary/conflict paths; downstream wiring into the composition root and the F2 sequence is the consumer's responsibility (AZ-381 owner already plumbs it).
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-381 (Protocol + DTOs + factory), AZ-383 (factor adds), AZ-384 (marginals + outputs), AZ-385 (source-label gate; bounded-delta is a new clause inside its state machine), AZ-386 (ESKF baseline must honour the same entrypoint), AZ-272 (FdrRecord Schema — new `c5.cold_start_origin.set` and `c5.gps_bounded_delta.{accept,reject}` kinds), AZ-273 (FdrClient), AZ-279 (WgsConverter for the bounded-delta Vincenty distance), AZ-269 (config), AZ-266 (logging), AZ-263 (initial structure)
|
||||
**Component**: c5_state (epic AZ-260 / E-C5)
|
||||
**Tracker**: AZ-490
|
||||
**Epic**: AZ-260 (E-C5)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/c5_state/state_estimator_protocol.md` § Invariants 11–12, § Config schema, § Test expectations.
|
||||
- `_docs/02_document/components/07_c5_state/description.md` § State management (cold-start ladder), § Spoof-promotion gate (3rd clause).
|
||||
- `_docs/02_document/architecture.md` ADR-010 (operator origin primary), Principle #11 (amended).
|
||||
- `_docs/02_document/system-flows.md` F2 (Takeoff load), F7 (spoof gate).
|
||||
|
||||
## Problem
|
||||
|
||||
Today, C5's `StateEstimator` Protocol has only one cold-start trust anchor: the FC EKF GPS snapshot consumed at first frame. ADR-010 makes the operator-planned mission the primary anchor and the FC EKF the secondary fallback — but the C5 Protocol carries no method to accept an external operator origin, so the composition root has nowhere to deliver the Manifest-resolved value.
|
||||
|
||||
Concretely:
|
||||
|
||||
- The C5 Protocol exposes `add_vio`, `add_pose_anchor`, `add_fc_imu`, `query` — none of which fit "pre-takeoff, set the absolute reference frame". Trying to overload `add_pose_anchor` would conflate "constant absolute origin from a trusted offline source" with "noisy per-frame satellite anchor"; the noise model, gating, and FDR record kind are all different.
|
||||
- AZ-385's spoof-promotion gate has two clauses (consistency-with-VPR-anchors + dwell-time). ADR-010 amends Principle #11 with a third clause: an FC GPS sample within `spoof_promotion_bounded_delta_m` of the current smoother estimate is admitted as a soft constraint, but samples outside that ring are rejected and counted against the spoof-promotion gate. Without this third clause, mid-flight FC GPS is either fully trusted or fully ignored — losing a legitimate fallback signal when GPS recovers.
|
||||
- The cold-start ladder is undocumented in the running code: operator origin should win when both are available, FC EKF should win when operator origin is absent, and the system should refuse takeoff when neither is available — but no method captures these states.
|
||||
- Two concrete estimators (iSAM2 + ESKF) need to honour the same entrypoint with the same semantics so the composition root can switch strategies without re-wiring F2.
|
||||
|
||||
This task lands the C5-side contract, the FDR records, and the spoof-gate amendment. It does NOT touch the C10 Manifest parsing (AZ-324), the C12 Flight resolution (AZ-489), or the composition-root F2 wiring (AZ-381 owner sequences that as a follow-up commit).
|
||||
|
||||
## Outcome
|
||||
|
||||
- **`StateEstimator` Protocol** (`src/gps_denied_onboard/components/c5_state/interface.py`):
|
||||
- New method `set_takeoff_origin(origin: LatLonAlt, sigma_horiz_m: float, sigma_vert_m: float) -> None`.
|
||||
- Contract: idempotent if called twice with identical args; raises `EstimatorAlreadyStartedError` if called after the first `add_vio` / `add_pose_anchor`; raises `EstimatorConfigError` on negative sigmas or on `LatLonAlt` outside WGS-84 bounds.
|
||||
- **`GtsamIsam2StateEstimator`** (`src/gps_denied_onboard/components/c5_state/gtsam_isam2_estimator.py`):
|
||||
- `set_takeoff_origin(...)` sets the local-ENU origin via `WgsConverter`, seeds the iSAM2 prior factor at `Pose3(Rot3.Identity(), Point3(0,0,0))` with a horizontal sigma of `sigma_horiz_m` and a vertical sigma of `sigma_vert_m`, and emits an FDR `c5.cold_start_origin.set` record (`source="manifest"`) with the origin lat/lon/alt.
|
||||
- Internal `_origin_source: Literal["manifest", "fc_ekf", None]` field tracks the cold-start path; first `add_vio` call without an origin set is allowed (FC EKF path stays); first `add_vio` call with an origin set fixes the cold-start ladder to "manifest".
|
||||
- **`EskfStateEstimator`** (`src/gps_denied_onboard/components/c5_state/eskf_baseline.py`):
|
||||
- Same method, same semantics; for ESKF the origin is set on the local-ENU converter; the state's nominal position prior is set to `(0,0,0)` with covariance `diag(sigma_horiz_m², sigma_horiz_m², sigma_vert_m²)`.
|
||||
- **Spoof-promotion gate amendment** (`src/gps_denied_onboard/components/c5_state/source_label_state_machine.py` — owned by AZ-385, this task patches it with one new clause):
|
||||
- When the gate processes an incoming FC GPS sample, it computes `vincenty(current_smoother_latlon, sample_latlon)` via `WgsConverter`.
|
||||
- If `distance ≤ spoof_promotion_bounded_delta_m`: sample is admitted as a `BOUNDED_DELTA_SOFT` source label; FDR `c5.gps_bounded_delta.accept` is emitted.
|
||||
- If `distance > spoof_promotion_bounded_delta_m`: sample is rejected; FDR `c5.gps_bounded_delta.reject` is emitted naming the sample, the current estimate, and the computed distance; the rejection counts against the existing dwell-time clause.
|
||||
- **Config schema additions** (`src/gps_denied_onboard/config/c5_state.py` via AZ-269's loader):
|
||||
- `spoof_promotion_bounded_delta_m: float` (default `200.0`).
|
||||
- `default_takeoff_origin_sigma_horiz_m: float` (default `5.0`) — used when the Manifest does not carry an explicit sigma.
|
||||
- `default_takeoff_origin_sigma_vert_m: float` (default `10.0`).
|
||||
- **FDR record kinds** (`src/gps_denied_onboard/fdr/record_schema.py` via AZ-272 schema extension):
|
||||
- `c5.cold_start_origin.set` — `{source: "manifest" | "fc_ekf", lat_deg, lon_deg, alt_m, sigma_horiz_m, sigma_vert_m}`.
|
||||
- `c5.cold_start_origin.unavailable` — emitted when neither anchor is available; carries a takeoff-abort reason code.
|
||||
- `c5.gps_bounded_delta.accept` — `{sample_lat, sample_lon, smoother_lat, smoother_lon, distance_m, threshold_m}`.
|
||||
- `c5.gps_bounded_delta.reject` — same shape as `accept`.
|
||||
- **Logging**:
|
||||
- INFO on every `set_takeoff_origin` call (`kind="c5.cold_start_origin.set"`).
|
||||
- WARN on every bounded-delta reject (`kind="c5.gps_bounded_delta.reject"`).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- Protocol method addition + both concrete impls (iSAM2 + ESKF).
|
||||
- WgsConverter integration for ENU origin + Vincenty distance in bounded-delta gate.
|
||||
- Spoof-gate's third clause + FDR record emission.
|
||||
- Config schema entries for the three new keys.
|
||||
- Error classes: `EstimatorAlreadyStartedError`, `EstimatorConfigError`.
|
||||
- Unit tests covering every AC: idempotency, post-start rejection, sigma validation, lat/lon bounds, bounded-delta accept/reject, source-label transition, FDR emission shape, both estimator impls.
|
||||
|
||||
### Excluded
|
||||
|
||||
- C10 Manifest schema changes (owned by AZ-323 / AZ-324).
|
||||
- C12 Flight resolution (owned by AZ-489).
|
||||
- Composition-root F2 wiring of `manifest.takeoff_origin → estimator.set_takeoff_origin(...)` (owned by AZ-381 owner as a follow-up commit, NOT this task).
|
||||
- Operator origin "refresh" mid-flight (out of scope — the cold-start anchor is set once at takeoff and not revised).
|
||||
- Changes to the AZ-385 source-label state-machine's first two clauses (consistency + dwell-time); only the third clause is added here.
|
||||
- ESKF deep-rewrite (the ESKF stays as the AZ-386 baseline; only `set_takeoff_origin` + bounded-delta admission are added).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Protocol-conformance — both impls expose `set_takeoff_origin`**
|
||||
Given `from c5_state import GtsamIsam2StateEstimator, EskfStateEstimator, StateEstimator`
|
||||
When `isinstance(impl, StateEstimator)` is checked on each
|
||||
Then both return `True`; both have a callable `set_takeoff_origin` with the documented signature.
|
||||
|
||||
**AC-2: Happy path — origin set before first VIO seeds the smoother prior (iSAM2)**
|
||||
Given a fresh `GtsamIsam2StateEstimator`
|
||||
When `set_takeoff_origin(LatLonAlt(50.0, 36.2, 200.0), sigma_horiz_m=5.0, sigma_vert_m=10.0)` is called
|
||||
Then the iSAM2 graph has exactly one prior factor at `Pose3.Identity()` with sigma diag matching `[deg2rad(5°)*3, deg2rad(5°)*3, deg2rad(5°)*3, 5.0, 5.0, 10.0]` (rotation sigma is the iSAM2 default Identity prior; translation sigmas come from the call); ONE FDR record `c5.cold_start_origin.set` is emitted with `source="manifest"`; ONE INFO log.
|
||||
|
||||
**AC-3: Happy path — origin set before first VIO seeds the ESKF state prior**
|
||||
Given a fresh `EskfStateEstimator`
|
||||
When `set_takeoff_origin(LatLonAlt(50.0, 36.2, 200.0), sigma_horiz_m=5.0, sigma_vert_m=10.0)` is called
|
||||
Then the ESKF's `P` matrix's position block is `diag(25.0, 25.0, 100.0)`; the ENU origin is set to the call's `LatLonAlt`; ONE FDR record + ONE INFO log as in AC-2.
|
||||
|
||||
**AC-4: Idempotent — calling twice with identical args is a no-op**
|
||||
Given the estimator after a first `set_takeoff_origin(A, s, s_v)` call
|
||||
When `set_takeoff_origin(A, s, s_v)` is called again with identical args
|
||||
Then no second FDR record is emitted; no second prior factor is added; no exception raised.
|
||||
|
||||
**AC-5: Conflict — calling twice with different args raises `EstimatorConfigError`**
|
||||
Given the estimator after `set_takeoff_origin(A, ...)`
|
||||
When `set_takeoff_origin(B, ...)` is called with `A != B`
|
||||
Then `EstimatorConfigError` is raised; message names both values; the estimator state is unchanged.
|
||||
|
||||
**AC-6: Late call — `set_takeoff_origin` after first `add_vio` raises `EstimatorAlreadyStartedError`**
|
||||
Given the estimator after one `add_vio(...)` call
|
||||
When `set_takeoff_origin(...)` is called
|
||||
Then `EstimatorAlreadyStartedError` is raised; the estimator state is unchanged.
|
||||
|
||||
**AC-7: Bounds — invalid `LatLonAlt` raises `EstimatorConfigError`**
|
||||
Given `LatLonAlt(lat_deg=95.0, ...)` (out of WGS-84 bounds)
|
||||
When `set_takeoff_origin(invalid_origin, ...)` is called
|
||||
Then `EstimatorConfigError` is raised; message names the violated bound.
|
||||
|
||||
**AC-8: Negative sigma raises `EstimatorConfigError`**
|
||||
Given `sigma_horiz_m=-5.0`
|
||||
When `set_takeoff_origin(...)` is called
|
||||
Then `EstimatorConfigError`; message names the violated invariant.
|
||||
|
||||
**AC-9: Bounded-delta accept — incoming GPS within 200 m of smoother estimate is admitted**
|
||||
Given a running smoother with current estimate at `LatLonAlt(50.000, 36.200, 200.0)`
|
||||
When a `GpsSample(LatLonAlt(50.0008, 36.2008, 200.0))` arrives via the existing AZ-391 inbound path (distance ≈ 110 m at 50° N)
|
||||
Then the gate admits the sample with source label `BOUNDED_DELTA_SOFT`; ONE FDR record `c5.gps_bounded_delta.accept` is emitted; the sample contributes to the iSAM2 graph as a soft factor with sigma per the config; the source-label state machine's first-two-clause behaviour is unchanged.
|
||||
|
||||
**AC-10: Bounded-delta reject — incoming GPS > 200 m off is rejected**
|
||||
Given the same smoother as AC-9
|
||||
When a `GpsSample(LatLonAlt(50.005, 36.205, 200.0))` arrives (distance ≈ 700 m)
|
||||
Then the gate rejects the sample; ONE FDR `c5.gps_bounded_delta.reject` record is emitted naming sample, smoother estimate, and distance; the sample is NOT added to the graph; the rejection increments the source-label state machine's dwell-time counter.
|
||||
|
||||
**AC-11: Threshold is config-driven — setting `spoof_promotion_bounded_delta_m=500.0` admits AC-10's sample**
|
||||
Given the config override `spoof_promotion_bounded_delta_m=500.0` is in effect (NOTE: 500 m is still < AC-10's 700 m, so the assertion is: the original AC-10 reject still happens, but with a 500 m threshold a 300 m offset now passes)
|
||||
Re-spec to:
|
||||
Given the config override `spoof_promotion_bounded_delta_m=1000.0` is in effect
|
||||
When the AC-10 sample arrives
|
||||
Then it is admitted as `BOUNDED_DELTA_SOFT` (it now sits within the relaxed ring).
|
||||
|
||||
**AC-12: FDR record kinds are registered in the AZ-272 schema**
|
||||
Given the AZ-272 schema after this task
|
||||
When `kind="c5.cold_start_origin.set"`, `kind="c5.cold_start_origin.unavailable"`, `kind="c5.gps_bounded_delta.accept"`, `kind="c5.gps_bounded_delta.reject"` are encoded
|
||||
Then each round-trips through serialization without raising; the schema contract test (AZ-268) covers all four.
|
||||
|
||||
**AC-13: No-op when no origin is set — FC EKF cold-start path unchanged**
|
||||
Given a fresh estimator
|
||||
When `add_vio(...)` is called WITHOUT a prior `set_takeoff_origin` call
|
||||
Then the estimator falls back to the legacy FC EKF cold-start path; the FDR record `c5.cold_start_origin.set` is emitted with `source="fc_ekf"` exactly once at first frame (this is the legacy path, just newly logged); no new behaviour beyond the FDR record kind.
|
||||
|
||||
**AC-14: Source-label state machine remains stable for AZ-385's first two clauses**
|
||||
Given the bounded-delta clause's introduction
|
||||
When the AZ-385 acceptance tests run unmodified
|
||||
Then they pass — the bounded-delta clause is additive, not replacing the existing two.
|
||||
|
||||
**AC-15: Vincenty distance is computed via `WgsConverter` (not naive haversine on the equirectangular projection)**
|
||||
Given a smoother estimate at high latitude (`60° N`) and a sample 200 m to the east
|
||||
When the gate computes the distance
|
||||
Then it uses `WgsConverter.vincenty_distance` and matches the documented ground-truth distance within ±0.5 m.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
- `set_takeoff_origin` wall-clock ≤ 5 ms (single prior factor insertion + one FDR emission).
|
||||
- Bounded-delta check on every inbound GPS sample ≤ 1 ms (single Vincenty call + threshold compare).
|
||||
|
||||
**Reliability**
|
||||
- Idempotency at AC-4 prevents accidental double-seeding on composition-root retries.
|
||||
- Late-call rejection at AC-6 prevents bricking an in-flight smoother by mistake.
|
||||
- All four FDR record kinds are part of the AZ-272 schema; AZ-268's contract test gates schema drift.
|
||||
|
||||
**Compatibility**
|
||||
- Both estimator impls (iSAM2 + ESKF) honour the same signature so the composition root can switch strategies without re-wiring F2.
|
||||
- The legacy FC EKF cold-start path stays as the secondary fallback (AC-13); existing AZ-419's old AC-3 still passes against the FC EKF path when no Manifest origin is present.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|-------------|------------------|
|
||||
| AC-1 | Protocol conformance both impls | `isinstance` True |
|
||||
| AC-2 | iSAM2 set-origin seeds prior | Prior factor + sigmas; FDR record |
|
||||
| AC-3 | ESKF set-origin seeds P | P block matches; ENU origin set; FDR record |
|
||||
| AC-4 | Idempotent double-call | No second FDR; no exception |
|
||||
| AC-5 | Conflict double-call | `EstimatorConfigError`; names both |
|
||||
| AC-6 | Late call after add_vio | `EstimatorAlreadyStartedError` |
|
||||
| AC-7 | Out-of-bounds lat | `EstimatorConfigError` |
|
||||
| AC-8 | Negative sigma | `EstimatorConfigError` |
|
||||
| AC-9 | Bounded-delta accept | `BOUNDED_DELTA_SOFT` label + FDR |
|
||||
| AC-10 | Bounded-delta reject | Sample dropped + FDR reject |
|
||||
| AC-11 | Threshold config override | AC-10 sample admitted at relaxed threshold |
|
||||
| AC-12 | FDR schema round-trip | All 4 kinds serialise; AZ-268 covers |
|
||||
| AC-13 | No origin → FC EKF path | Legacy path + new FDR record `source="fc_ekf"` |
|
||||
| AC-14 | AZ-385's first 2 clauses unchanged | All AZ-385 tests pass unmodified |
|
||||
| AC-15 | Vincenty at 60° N | Within ±0.5 m of ground truth |
|
||||
|
||||
## Constraints
|
||||
|
||||
- `set_takeoff_origin` is the ONLY supported pre-takeoff origin entrypoint; do NOT add `set_takeoff_origin_from_gps(...)` or similar convenience overloads — the FC EKF path stays purely inside the legacy first-frame logic.
|
||||
- The bounded-delta clause is the THIRD clause of the AZ-385 source-label state machine; do NOT replace AZ-385's existing consistency + dwell-time logic — just add the new clause and its FDR records.
|
||||
- No clock or `datetime.now()` calls in this code path — pass a `Clock` Protocol per the project's established pattern (used by AZ-273, AZ-382).
|
||||
- The 200 m threshold is configurable but NOT operator-tunable from the GCS — it's a deploy-time config.
|
||||
- No "auto-correct origin" or "average origins" logic — operator origin set once at takeoff or not at all.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Operator origin from a stale/wrong flight plan poisons the cold start**
|
||||
- *Risk*: Mission Planner export drift, manual file edits, or a wrong `--flight-id` flag selects the wrong route.
|
||||
- *Mitigation*: AZ-323's Manifest carries `flight_id` + `takeoff_origin`, both hashed into `manifest_hash`; AZ-324 validates origin is inside the bbox; this task validates `LatLonAlt` bounds (AC-7) but does NOT re-validate against the Manifest's bbox — the Manifest-level check is sufficient. AC-5's conflict-on-double-call surfaces drift if the composition root somehow re-calls with a different value.
|
||||
|
||||
**Risk 2: Bounded-delta gate admits spoofed GPS that happens to be < 200 m off**
|
||||
- *Risk*: A sophisticated spoofer reads the C5's smoother output and emits GPS within the ring.
|
||||
- *Mitigation*: Bounded-delta admission is `BOUNDED_DELTA_SOFT`, NOT `SATELLITE_ANCHORED`; AZ-385's other two clauses (consistency-with-VPR-anchors + dwell-time) remain authoritative for the final spoof-promotion decision. The bounded-delta channel is a soft constraint, not a hard reference.
|
||||
|
||||
**Risk 3: Composition root forgets to call `set_takeoff_origin` even though the Manifest carries one**
|
||||
- *Risk*: F2 wiring drift between AZ-381 and AZ-324 — the Manifest has the origin, but the estimator doesn't see it.
|
||||
- *Mitigation*: AZ-419's updated AC-1 / AC-4 (operator origin test cases) exercise the full F2 chain end-to-end; failure would be visible there. AC-13 here proves the no-origin path is still functional so this task does not break the legacy fallback during the transition.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: pre-takeoff operator origin acceptance + mid-flight bounded-delta GPS gate (ADR-010 Principle #11 amended).
|
||||
- **Production code that must exist**: real `set_takeoff_origin` on both `GtsamIsam2StateEstimator` and `EskfStateEstimator`; real `WgsConverter` for ENU origin + Vincenty distance; real FDR record emission with the four new kinds; real config wiring via AZ-269.
|
||||
- **Allowed external stubs**: tests use `FakeFdrSink` (AZ-275), a fake `Clock`, and a fixed `WgsConverter` instance with a known ENU origin.
|
||||
- **Unacceptable substitutes**: a "set-origin" convenience that just sets a class attribute without seeding the prior factor (would silently no-op); haversine instead of Vincenty (loses precision at high lat → AC-15 fails); skipping the FDR records "because they're operational telemetry" (the schema contract test AZ-268 would still flag them as missing).
|
||||
@@ -1,45 +0,0 @@
|
||||
# Jira transition for AZ-355 deferred — MCP "Not connected"
|
||||
|
||||
**Recorded**: 2026-05-11T10:50+03:00 (Europe/Kyiv)
|
||||
**Status**: deferred-non-user (replay on next autodev invocation when Jira MCP is connected)
|
||||
|
||||
## What is blocked
|
||||
|
||||
Status transition of `AZ-355` from `To Do` → `Done` to reflect that
|
||||
Batch 20 has landed the C4 `PoseEstimator` Protocol + factory + DTOs
|
||||
+ composition wiring (see
|
||||
`_docs/03_implementation/batch_20_cycle1_report.md`).
|
||||
|
||||
## Why
|
||||
|
||||
The Atlassian MCP server returned `"Not connected"` on
|
||||
`getAccessibleAtlassianResources` during the Batch 20 wrap-up — same
|
||||
failure mode that AZ-386 hit a few hours earlier. Per the
|
||||
Leftovers Mechanism the write is recorded here and the non-tracker
|
||||
work (commit, push) is allowed to proceed; the next autodev
|
||||
invocation will replay the transition.
|
||||
|
||||
## Replay payload
|
||||
|
||||
- **Tool**: `transitionJiraIssue`
|
||||
- **cloudId**: `denyspopov.atlassian.net`
|
||||
- **issueIdOrKey**: `AZ-355`
|
||||
- **target status**: `Done` (transition id is project-specific; resolve
|
||||
via `getTransitionsForJiraIssue` at replay time — Jira project `AZ`
|
||||
uses the standard "Software" workflow so the transition is `id: 31`
|
||||
in current Jira config; confirm at replay time).
|
||||
|
||||
## Acceptance check on replay
|
||||
|
||||
After the transition succeeds:
|
||||
|
||||
- `getJiraIssue(AZ-355)` returns `fields.status.name == "Done"`.
|
||||
- Delete this leftover file.
|
||||
|
||||
## Notes
|
||||
|
||||
- Code, tests, docs, and state file are all updated and committed. The
|
||||
only outstanding action is the tracker status transition; the
|
||||
AZ-355 task spec is already in `_docs/02_tasks/done/`.
|
||||
- The previous leftover for AZ-386 is still pending; replay both in
|
||||
one batch when the MCP comes back.
|
||||
@@ -1,44 +0,0 @@
|
||||
# Jira transition for AZ-386 deferred — MCP "Not connected"
|
||||
|
||||
**Recorded**: 2026-05-11T10:00+03:00 (Europe/Kyiv)
|
||||
**Status**: deferred-non-user (replay on next autodev invocation when Jira MCP is connected)
|
||||
|
||||
## What is blocked
|
||||
|
||||
Status transition of `AZ-386` from `To Do` → `Done` to reflect that
|
||||
Batch 19 has landed the `EskfStateEstimator` mandatory simple-baseline
|
||||
in code + tests + documentation (see
|
||||
`_docs/03_implementation/batch_19_cycle1_report.md`).
|
||||
|
||||
## Why
|
||||
|
||||
The Atlassian MCP server returned `"Not connected"` on both
|
||||
`transitionJiraIssue` and `getAccessibleAtlassianResources` during the
|
||||
Batch 19 wrap-up. This is the documented "MCP unavailable" failure
|
||||
mode in `.cursor/rules/tracker.mdc`. Per the Leftovers Mechanism the
|
||||
write is recorded here and the non-tracker work (commit, push) is
|
||||
allowed to proceed; the next autodev invocation will replay the
|
||||
transition.
|
||||
|
||||
## Replay payload
|
||||
|
||||
- **Tool**: `transitionJiraIssue`
|
||||
- **cloudId**: `denyspopov.atlassian.net`
|
||||
- **issueIdOrKey**: `AZ-386`
|
||||
- **target status**: `Done` (transition id is project-specific; resolve
|
||||
via `getTransitionsForJiraIssue` at replay time — Jira project `AZ`
|
||||
uses the standard "Software" workflow so the transition is `id: 31`
|
||||
in current Jira config; confirm at replay time).
|
||||
|
||||
## Acceptance check on replay
|
||||
|
||||
After the transition succeeds:
|
||||
|
||||
- `getJiraIssue(AZ-386)` returns `fields.status.name == "Done"`.
|
||||
- Delete this leftover file.
|
||||
|
||||
## Notes
|
||||
|
||||
- Code, tests, docs, and state file are all updated and committed. The
|
||||
only outstanding action is the tracker status transition; the
|
||||
AZ-386 task spec is already in `_docs/02_tasks/done/`.
|
||||
Reference in New Issue
Block a user