[AZ-507] [AZ-323] [AZ-324] C10 Manifest build + verify + AZ-270 hygiene

AZ-507: codify cross-component import rule. Added
_types/inference_errors.py shim re-exporting EngineBuildError +
CalibrationCacheError from c7_inference; narrowed C10
EngineCompiler's except Exception to the two typed errors so unknown
exceptions propagate (AC-3). Rewrote module-layout.md "Imports from"
sections for 9 components + added Rule 9; appended an
architecture.md ADR-009 note explaining why components must go
through _types/*.

AZ-323: ManifestBuilder + Ed25519ManifestSigner. Canonical JSON via
orjson OPT_SORT_KEYS+OPT_INDENT_2, atomic-write Manifest.json + sha
sidecar + .sig via AZ-280, operator-key fingerprint allowlist gate
(C10-ST-01), ADR-010 takeoff_origin + flight_id baked into Manifest
AND manifest_hash so re-planned routes change the cache identity
(AC-15/AC-16). 20 unit tests cover all 16 ACs.

AZ-324: ManifestVerifierImpl. Fail-closed Steps A-D: Manifest.json
sidecar self-hash, Ed25519 trust-key set, schema parse with
absolute/.. path rejection + takeoff_origin in-bbox check, stream
SHA-256 per artifact with multi-failure accumulation. Operator mode
re-derives tiles_coverage_sha256 from C6; airborne mode trusts the
signed aggregate. 19 unit tests cover all 17 ACs.

Composition root: c10_factory.build_manifest_builder +
build_manifest_verifier + c6_tile_metadata_store_to_tiles_query
adapter (the one place that legitimately imports both C6 and C10
without violating the AZ-270 lint).

Dependency: pinned cryptography>=43.0,<46.0 in pyproject.toml.

Tests: 1300 passed, 80 skipped (env-only), ruff clean for all
AZ-323/324 files.

AZ-306 (FAISS) intentionally deferred to batch 35 — needs C++
pybind11 toolchain not present in this environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 02:37:14 +03:00
parent 6ca8d78190
commit e2bebefdfc
20 changed files with 3406 additions and 26 deletions
@@ -0,0 +1,675 @@
"""C10 ManifestBuilder + Ed25519ManifestSigner (AZ-323).
Produces the signed cache Manifest covering every shipped artifact
plus the build-identity tuple whose canonical hash (``manifest_hash``)
is the D-C10-1 idempotence key. Implements the AC-NEW-1 trust chain
(takeoff arming refuses to deserialize engines before a Manifest
verify succeeds — AZ-324 owns the verify; this task owns the build).
Cross-component DTOs (``LatLonAlt``, ``BoundingBox``) come from
``_types/geo.py``; engine entries from ``_types/inference.py``;
the ``Manifest`` placeholder DTO from ``_types/manifests.py``. No
direct ``components.X`` imports — the AZ-507 lint forbids it.
"""
from __future__ import annotations
import hashlib
import logging
import time
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol, runtime_checkable
from uuid import UUID
import orjson
from cryptography.exceptions import UnsupportedAlgorithm
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
from gps_denied_onboard._types.inference import EngineCacheEntry
from gps_denied_onboard.clock import Clock
from gps_denied_onboard.components.c10_provisioning.config import (
C10ManifestConfig,
SigningMode,
)
from gps_denied_onboard.components.c10_provisioning.errors import (
ManifestWriteError,
)
from gps_denied_onboard.components.c10_provisioning.interface import (
ManifestSigner,
SigningKeyHandle,
)
from gps_denied_onboard.helpers.sha256_sidecar import (
Sha256Sidecar,
Sha256SidecarError,
)
__all__ = [
"VALID_SECTOR_CLASSES",
"Ed25519ManifestSigner",
"ManifestArtifact",
"ManifestBuildInput",
"ManifestBuilder",
"TileHashRecord",
"TilesByBboxQuery",
]
_BUILD_LOG_KIND_PREFIX = "c10.manifest"
_TAKEOFF_ORIGIN_DECIMALS = 9
_MANIFEST_FILENAME = "Manifest.json"
_SIGNATURE_FILENAME = "Manifest.json.sig"
_ED25519_PUBKEY_BYTES = 32
_ED25519_SIG_BYTES = 64
VALID_SECTOR_CLASSES: frozenset[str] = frozenset(
{"active_conflict", "stable_rear"}
)
@dataclass(frozen=True)
class TileHashRecord:
"""Consumer-side DTO carrying the four sort keys + the per-tile digest.
AZ-323 only needs ``(zoom, lat, lon, source)`` for canonical
ordering and ``sha256_hex`` for the aggregate hash. The
composition-root adapter wraps C6's ``TileMetadata`` rows into
this shape so the AZ-270 lint stays green (no
``components.c6_tile_cache`` import from C10).
"""
zoom: int
lat: float
lon: float
source: str
sha256_hex: str
@runtime_checkable
class TilesByBboxQuery(Protocol):
"""Consumer-side structural cut over C6's ``TileMetadataStore``.
The composition root adapts
:class:`gps_denied_onboard.components.c6_tile_cache.TileMetadataStore`
by translating its ``query_by_bbox`` return value into a tuple of
:class:`TileHashRecord`. C10 depends on THIS Protocol so
``components/c10_provisioning/*`` never imports ``components.c6_*``
(AZ-270 + AZ-507 boundary).
"""
def query_by_bbox(
self,
*,
bbox: BoundingBox,
zoom_levels: tuple[int, ...],
sector_class: str,
) -> Iterable[TileHashRecord]: ...
@dataclass(frozen=True)
class ManifestBuildInput:
"""Frozen call argument for :meth:`ManifestBuilder.build_manifest`.
Per the AZ-323 spec ``sector_class`` is the c6 enum's ``.value``
string ('active_conflict' / 'stable_rear'); the composition root
translates the C6 enum to its string form before injecting so
C10 stays free of the C6 import.
``takeoff_origin`` + ``flight_id`` are the ADR-010 / AZ-489
pass-through fields — when supplied they are both baked into the
Manifest body AND fed into the ``manifest_hash`` so a re-planned
flight produces a fresh cache identity (AC-15 / AC-16).
"""
cache_root: Path
bbox: BoundingBox
zoom_levels: tuple[int, ...]
sector_class: str
engine_entries: tuple[EngineCacheEntry, ...]
descriptor_index_path: Path
calibration_path: Path
key_path: Path
takeoff_origin: LatLonAlt | None = None
flight_id: UUID | None = None
@dataclass(frozen=True)
class ManifestArtifact:
"""Return value of :meth:`ManifestBuilder.build_manifest`.
``manifest_hash`` is the D-C10-1 idempotence key — derived from
the build identity tuple, NOT from the Manifest file bytes (which
include ``built_at`` and so differ across runs). The Manifest
file's own SHA-256 lives on disk as ``Manifest.json.sha256`` per
AC-11.
"""
manifest_path: Path
signature_path: Path
manifest_hash: str
signing_public_key_fingerprint: str
total_artifacts_listed: int
@dataclass(frozen=True)
class _Ed25519SigningKey(SigningKeyHandle):
"""Opaque handle wrapping a ``cryptography`` Ed25519 private key.
Frozen so callers cannot mutate the key in flight; the underlying
``Ed25519PrivateKey`` object stays in the dataclass field but is
not exposed by name on :class:`SigningKeyHandle`.
"""
private_key: Ed25519PrivateKey = field(repr=False)
public_key_raw: bytes
fingerprint_hex: str
class Ed25519ManifestSigner:
"""Default :class:`ManifestSigner` impl backed by ``cryptography``.
Loads PEM-encoded PKCS8 Ed25519 private keys per AZ-323 Risk 4 —
other formats (OpenSSH, raw 32-byte) raise
:class:`ManifestWriteError` with the underlying ``cryptography``
exception chained via ``__cause__`` (AC-9).
"""
def load_signing_key(self, key_path: Path) -> SigningKeyHandle:
try:
pem_bytes = key_path.read_bytes()
except (OSError, FileNotFoundError) as exc:
raise ManifestWriteError(
f"operator signing key load failed: cannot read {key_path}: {exc}"
) from exc
try:
private_key = load_pem_private_key(pem_bytes, password=None)
except (ValueError, TypeError, UnsupportedAlgorithm) as exc:
raise ManifestWriteError(
f"operator signing key load failed: malformed PEM at {key_path}: {exc}"
) from exc
if not isinstance(private_key, Ed25519PrivateKey):
raise ManifestWriteError(
"operator signing key load failed: not an Ed25519 private key "
f"(got {type(private_key).__name__}); AZ-323 supports Ed25519 only"
)
public_key = private_key.public_key()
public_raw = _ed25519_public_raw(public_key)
return _Ed25519SigningKey(
private_key=private_key,
public_key_raw=public_raw,
fingerprint_hex=hashlib.sha256(public_raw).hexdigest(),
)
def sign(self, key: SigningKeyHandle, payload_bytes: bytes) -> bytes:
handle = _require_ed25519_handle(key)
signature = handle.private_key.sign(payload_bytes)
# Defensive: Ed25519 signatures are always 64 bytes — fail fast on
# a library upgrade that changes the contract.
if len(signature) != _ED25519_SIG_BYTES:
raise ManifestWriteError(
f"Ed25519 signer produced {len(signature)} bytes; expected "
f"{_ED25519_SIG_BYTES}"
)
return signature
def public_key_fingerprint(self, key: SigningKeyHandle) -> str:
return _require_ed25519_handle(key).fingerprint_hex
def _ed25519_public_raw(public_key: Ed25519PublicKey) -> bytes:
from cryptography.hazmat.primitives.serialization import (
Encoding,
PublicFormat,
)
raw = public_key.public_bytes(
encoding=Encoding.Raw,
format=PublicFormat.Raw,
)
if len(raw) != _ED25519_PUBKEY_BYTES:
raise ManifestWriteError(
f"Ed25519 public key has unexpected length: {len(raw)} != "
f"{_ED25519_PUBKEY_BYTES}"
)
return raw
def _require_ed25519_handle(key: SigningKeyHandle) -> _Ed25519SigningKey:
if not isinstance(key, _Ed25519SigningKey):
raise ManifestWriteError(
"Ed25519ManifestSigner received a foreign SigningKeyHandle "
f"({type(key).__name__}); only handles produced by load_signing_key "
"are accepted"
)
return key
class ManifestBuilder:
"""Build a signed cache Manifest at ``cache_root/Manifest.json``.
Atomic-write contract: Manifest body + ``.sha256`` sidecar +
``.sig`` are all written via :class:`Sha256Sidecar.write_atomic*`,
so a kill mid-build leaves either the previous-good triple or
the new triple — never a partial Manifest (AC-10).
"""
def __init__(
self,
*,
sidecar: Sha256Sidecar,
signer: ManifestSigner,
tile_metadata_store: TilesByBboxQuery,
logger: logging.Logger,
clock: Clock,
config: C10ManifestConfig,
) -> None:
self._sidecar = sidecar
self._signer = signer
self._tiles = tile_metadata_store
self._log = logger
self._clock = clock
self._config = config
def build_manifest(self, request: ManifestBuildInput) -> ManifestArtifact:
self._validate_request(request)
key = self._load_and_gate_key(request.key_path)
fingerprint = self._signer.public_key_fingerprint(key)
self._gate_operator_mode(fingerprint)
calibration_sha256 = self._sha256_file(request.calibration_path)
descriptor_index_sha256 = self._read_descriptor_index_sidecar(
request.descriptor_index_path
)
sorted_tiles = self._fetch_sorted_tiles(
bbox=request.bbox,
zoom_levels=request.zoom_levels,
sector_class=request.sector_class,
)
tiles_coverage_sha256 = _aggregate_tile_hash(sorted_tiles)
engine_artifacts = tuple(
{
"path": str(entry.engine_path),
"sha256": entry.sha256_hex,
}
for entry in request.engine_entries
)
manifest_hash = _compute_manifest_hash(
engine_entries=request.engine_entries,
calibration_sha256=calibration_sha256,
descriptor_index_sha256=descriptor_index_sha256,
tiles_coverage_sha256=tiles_coverage_sha256,
sector_class=request.sector_class,
bbox=request.bbox,
zoom_levels=request.zoom_levels,
takeoff_origin=request.takeoff_origin,
flight_id=request.flight_id,
)
built_at_iso = _ns_to_iso_utc(self._clock.time_ns())
manifest_body = self._assemble_manifest_dict(
schema_version=self._config.schema_version,
bbox=request.bbox,
zoom_levels=request.zoom_levels,
sector_class=request.sector_class,
built_at_iso=built_at_iso,
manifest_hash=manifest_hash,
flight_id=request.flight_id,
takeoff_origin=request.takeoff_origin,
engine_artifacts=engine_artifacts,
descriptor_index_path=request.descriptor_index_path,
descriptor_index_sha256=descriptor_index_sha256,
calibration_path=request.calibration_path,
calibration_sha256=calibration_sha256,
tiles_coverage_sha256=tiles_coverage_sha256,
tiles_count=len(sorted_tiles),
fingerprint=fingerprint,
)
canonical_bytes = _canonical_json_with_trailing_newline(manifest_body)
manifest_path = request.cache_root / _MANIFEST_FILENAME
signature_path = request.cache_root / _SIGNATURE_FILENAME
request.cache_root.mkdir(parents=True, exist_ok=True)
self._atomic_write_manifest(manifest_path, canonical_bytes)
signature_bytes = self._signer.sign(key, canonical_bytes)
self._atomic_write_signature(signature_path, signature_bytes)
total_artifacts = len(engine_artifacts) + 3 # descriptor_index + calibration + tiles_coverage
self._log.info(
f"{_BUILD_LOG_KIND_PREFIX}.build.success",
extra={
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.success",
"kv": {
"manifest_hash": manifest_hash,
"total_artifacts_listed": total_artifacts,
"signing_public_key_fingerprint": fingerprint,
"tiles_count": len(sorted_tiles),
"schema_version": self._config.schema_version,
},
},
)
return ManifestArtifact(
manifest_path=manifest_path,
signature_path=signature_path,
manifest_hash=manifest_hash,
signing_public_key_fingerprint=fingerprint,
total_artifacts_listed=total_artifacts,
)
def _validate_request(self, request: ManifestBuildInput) -> None:
if request.sector_class not in VALID_SECTOR_CLASSES:
raise ManifestWriteError(
f"sector_class={request.sector_class!r} not in "
f"{sorted(VALID_SECTOR_CLASSES)}"
)
if not request.zoom_levels:
raise ManifestWriteError(
"zoom_levels must be a non-empty tuple of ints"
)
if request.takeoff_origin is not None:
origin = request.takeoff_origin
if not (-90.0 <= origin.lat_deg <= 90.0):
raise ManifestWriteError(
f"takeoff_origin.lat_deg={origin.lat_deg} out of [-90, 90]"
)
if not (-180.0 <= origin.lon_deg <= 180.0):
raise ManifestWriteError(
f"takeoff_origin.lon_deg={origin.lon_deg} out of [-180, 180]"
)
def _load_and_gate_key(self, key_path: Path) -> SigningKeyHandle:
try:
return self._signer.load_signing_key(key_path)
except ManifestWriteError:
# Already logged at the call site below; the signer raises with
# an actionable diagnostic. We must still emit the ERROR record
# so operators see a single structured "build.error" entry.
self._log.error(
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
extra={
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
"kv": {
"phase": "load_signing_key",
"key_path": str(key_path),
},
},
)
raise
def _gate_operator_mode(self, fingerprint: str) -> None:
allowlist = self._config.allowed_operator_fingerprints
if self._config.signing_mode is SigningMode.OPERATOR:
if fingerprint not in allowlist:
self._log.error(
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
extra={
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
"kv": {
"phase": "operator_mode_gate",
"offered_fingerprint": fingerprint,
"allowed_fingerprints": list(allowlist),
},
},
)
raise ManifestWriteError(
"signing key fingerprint not in allowed_operator_fingerprints: "
f"offered={fingerprint!r}, allowed={sorted(allowlist)!r}"
)
elif self._config.signing_mode is SigningMode.DEV:
if fingerprint in allowlist:
self._log.warning(
f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key",
extra={
"kind": f"{_BUILD_LOG_KIND_PREFIX}.dev_mode_with_operator_key",
"kv": {
"offered_fingerprint": fingerprint,
},
},
)
def _sha256_file(self, path: Path) -> str:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except (OSError, FileNotFoundError) as exc:
raise ManifestWriteError(
f"manifest build: cannot hash artifact at {path}: {exc}"
) from exc
def _read_descriptor_index_sidecar(self, descriptor_index_path: Path) -> str:
sidecar_path = Path(str(descriptor_index_path) + ".sha256")
try:
text = sidecar_path.read_text(encoding="ascii").strip()
except (OSError, FileNotFoundError) as exc:
raise ManifestWriteError(
"manifest build: descriptor_index sidecar missing at "
f"{sidecar_path}: {exc}"
) from exc
if len(text) != 64:
raise ManifestWriteError(
"manifest build: descriptor_index sidecar at "
f"{sidecar_path} is not 64 hex chars (got {len(text)})"
)
try:
int(text, 16)
except ValueError as exc:
raise ManifestWriteError(
"manifest build: descriptor_index sidecar at "
f"{sidecar_path} is not hex: {exc}"
) from exc
if text.lower() != text:
raise ManifestWriteError(
"manifest build: descriptor_index sidecar at "
f"{sidecar_path} must be lowercase hex"
)
return text
def _fetch_sorted_tiles(
self,
*,
bbox: BoundingBox,
zoom_levels: tuple[int, ...],
sector_class: str,
) -> tuple[TileHashRecord, ...]:
raw = tuple(
self._tiles.query_by_bbox(
bbox=bbox,
zoom_levels=zoom_levels,
sector_class=sector_class,
)
)
return tuple(
sorted(raw, key=lambda r: (r.zoom, r.lat, r.lon, r.source))
)
def _assemble_manifest_dict(
self,
*,
schema_version: str,
bbox: BoundingBox,
zoom_levels: tuple[int, ...],
sector_class: str,
built_at_iso: str,
manifest_hash: str,
flight_id: UUID | None,
takeoff_origin: LatLonAlt | None,
engine_artifacts: tuple[dict[str, str], ...],
descriptor_index_path: Path,
descriptor_index_sha256: str,
calibration_path: Path,
calibration_sha256: str,
tiles_coverage_sha256: str,
tiles_count: int,
fingerprint: str,
) -> dict[str, object]:
flight_block: dict[str, object] = {
"flight_id": str(flight_id) if flight_id is not None else None,
}
if takeoff_origin is not None:
flight_block["takeoff_origin"] = {
"lat_deg": takeoff_origin.lat_deg,
"lon_deg": takeoff_origin.lon_deg,
"alt_m": takeoff_origin.alt_m,
}
return {
"schema_version": schema_version,
"build": {
"bbox": {
"min_lat_deg": bbox.min_lat_deg,
"min_lon_deg": bbox.min_lon_deg,
"max_lat_deg": bbox.max_lat_deg,
"max_lon_deg": bbox.max_lon_deg,
},
"zoom_levels": list(zoom_levels),
"sector_class": sector_class,
"built_at": built_at_iso,
"manifest_hash": manifest_hash,
},
"flight": flight_block,
"artifacts": {
"engines": [dict(e) for e in engine_artifacts],
"descriptor_index": {
"path": str(descriptor_index_path),
"sha256": descriptor_index_sha256,
},
"calibration": {
"path": str(calibration_path),
"sha256": calibration_sha256,
},
"tiles_coverage": {
"sha256": tiles_coverage_sha256,
"tile_count": tiles_count,
},
},
"signing_public_key_fingerprint": fingerprint,
}
def _atomic_write_manifest(self, path: Path, payload: bytes) -> None:
try:
self._sidecar.write_atomic_and_sidecar(path, payload)
except Sha256SidecarError as exc:
self._log.error(
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
extra={
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
"kv": {"phase": "write_manifest", "path": str(path)},
},
)
raise ManifestWriteError(
f"manifest build: atomic write failed at {path}: {exc}"
) from exc
def _atomic_write_signature(self, path: Path, payload: bytes) -> None:
try:
self._sidecar.write_atomic(path, payload)
except Sha256SidecarError as exc:
self._log.error(
f"{_BUILD_LOG_KIND_PREFIX}.build.error",
extra={
"kind": f"{_BUILD_LOG_KIND_PREFIX}.build.error",
"kv": {"phase": "write_signature", "path": str(path)},
},
)
raise ManifestWriteError(
f"manifest build: atomic write failed at {path}: {exc}"
) from exc
def _aggregate_tile_hash(records: tuple[TileHashRecord, ...]) -> str:
hasher = hashlib.sha256()
for r in records:
hasher.update(
(
f"z{r.zoom}|lat{r.lat:.9f}|lon{r.lon:.9f}|src{r.source}"
f":{r.sha256_hex}\n"
).encode("ascii")
)
return hasher.hexdigest()
def _canonical_json_with_trailing_newline(payload: dict[str, object]) -> bytes:
body = orjson.dumps(
payload,
option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2,
)
if not body.endswith(b"\n"):
body += b"\n"
return body
def _compute_manifest_hash(
*,
engine_entries: tuple[EngineCacheEntry, ...],
calibration_sha256: str,
descriptor_index_sha256: str,
tiles_coverage_sha256: str,
sector_class: str,
bbox: BoundingBox,
zoom_levels: tuple[int, ...],
takeoff_origin: LatLonAlt | None,
flight_id: UUID | None,
) -> str:
# Engine identity is `(model_name, precision, sm, jetpack, trt, sha256)`
# so a stale-host fp16 build never collides with a fresh int8 build —
# this matches the AZ-281 filename schema fields modulo the precision
# axis (which fp16 vs int8 makes load-bearing).
model_ids = sorted(
(
str(entry.engine_path),
entry.sha256_hex,
)
for entry in engine_entries
)
origin_tuple: tuple[float, float, float] | None
if takeoff_origin is not None:
origin_tuple = (
round(takeoff_origin.lat_deg, _TAKEOFF_ORIGIN_DECIMALS),
round(takeoff_origin.lon_deg, _TAKEOFF_ORIGIN_DECIMALS),
round(takeoff_origin.alt_m, _TAKEOFF_ORIGIN_DECIMALS),
)
else:
origin_tuple = None
build_identity = {
"model_ids": [list(entry) for entry in model_ids],
"calibration_sha256": calibration_sha256,
"descriptor_index_sha256": descriptor_index_sha256,
"tiles_coverage_sha256": tiles_coverage_sha256,
"sector_class": sector_class,
"bbox": [
bbox.min_lat_deg,
bbox.min_lon_deg,
bbox.max_lat_deg,
bbox.max_lon_deg,
],
"zoom_levels": sorted(zoom_levels),
"takeoff_origin": list(origin_tuple) if origin_tuple is not None else None,
"flight_id": str(flight_id) if flight_id is not None else None,
}
canonical = orjson.dumps(build_identity, option=orjson.OPT_SORT_KEYS)
return hashlib.sha256(canonical).hexdigest()
def _ns_to_iso_utc(time_ns: int) -> str:
"""Format ns-since-epoch as RFC 3339 UTC with second precision.
Second precision suffices for ``built_at`` — operators inspect the
Manifest at hour / minute granularity, and the build-identity
hash deliberately excludes ``built_at`` so the AC-2 byte-for-byte
determinism check works only by redacting this exact field.
"""
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time_ns / 1_000_000_000))