mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 07:21:13 +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:
@@ -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 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user