[AZ-316] Implement C11 HttpTileDownloader (batch 40)

Lands the operator-side pre-flight download path: authenticated
httpx GETs against satellite-provider, RESTRICT-SAT-4 (>= 0.5 m/px)
enforcement at the C11 boundary, c6 writes via consumer-side cuts
(_TileWriterLike, _BudgetEnforcerLike), per-(flight_id, request_hash)
journal under cache_root/.c11/journal/ for idempotent re-runs (AC-8,
AC-12), 429 Retry-After + 5xx exponential backoff handling, fail-fast
on TLS / 401 / 403, and a redacted-bearer auth-header policy.

Architecture:
- AZ-507 cross-component rule held: tile_downloader.py imports zero
  c6 symbols; the composition-root _C6DownloadAdapter in
  runtime_root/c11_factory.py absorbs c6's TileMetadata / TileSource /
  FreshnessLabel / VotingStatus enum assembly.
- Sleep-callable injection (not full Clock) per Batch 39 precedent;
  default routes through WallClock.sleep_until_ns to keep the AZ-398
  invariant intact.
- No FDR records on the download path; spec mandates structured logs
  only (8 log kinds wired: session.start/end, resolution_rejected,
  freshness_rejected_summary, freshness_downgraded, batch.retry,
  provider.failed, budget.exceeded, idempotent_no_op).

Tests: 14 new downloader unit tests covering AC-1..AC-9, AC-11, AC-12
plus throughput NFR + 429 HTTP-date + 429 budget exhaustion; 2 new
TileDownloader Protocol conformance tests (AC-10). Full unit suite:
1420 passed, 80 skipped (env-gated), 0 failed.

Code review: PASS_WITH_WARNINGS (5 Low findings, all documentation
or downstream-blocked). See _docs/03_implementation/reviews/
batch_40_review.md and batch_40_cycle1_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 07:01:14 +03:00
parent 3a61a4f5bf
commit 90f4ac78f4
13 changed files with 2513 additions and 62 deletions
@@ -1,6 +1,6 @@
"""C11 TileManager composition-root factories (AZ-317, AZ-318, AZ-319).
"""C11 TileManager composition-root factories (AZ-316, AZ-317, AZ-318, AZ-319).
Wires the upload-side services that have landed:
Wires the operator-side services:
* :func:`build_flight_state_gate` (AZ-317) — adapts an injected
``FlightStateSource`` (typically an E-C8 FC adapter wrapper) into
@@ -12,10 +12,16 @@ Wires the upload-side services that have landed:
key manager, the c6 storage cuts, an :class:`httpx.Client`, and
the :class:`C11Config` block into the production
:class:`HttpTileUploader`.
* :func:`build_tile_downloader` (AZ-316) — composes the c6 store +
metadata-store + budget-enforcer (wrapped in a single
composition-root adapter that hides c6's :class:`TileMetadata`
assembly), an :class:`httpx.Client`, and the :class:`C11Config`
block into the production :class:`HttpTileDownloader`.
Composition root is the ONLY layer permitted to import from
``components.c11_tile_manager`` (per ``module-layout.md`` Rule 9 +
the AZ-270 lint).
the AZ-270 lint). It is also the only layer permitted to bridge the
c6 ↔ c11 boundary (per AZ-507).
"""
from __future__ import annotations
@@ -28,6 +34,7 @@ from gps_denied_onboard.components.c11_tile_manager import (
C11Config,
FlightStateGate,
FlightStateSource,
HttpTileDownloader,
HttpTileUploader,
PerFlightKeyManager,
)
@@ -42,6 +49,7 @@ if TYPE_CHECKING:
__all__ = [
"build_flight_state_gate",
"build_per_flight_key_manager",
"build_tile_downloader",
"build_tile_uploader",
]
@@ -51,6 +59,7 @@ _C11_SIGNING_LOGGER = "c11_tile_manager.signing_key"
_C11_SIGNING_PRODUCER_ID = "c11_tile_manager.signing_key"
_C11_UPLOADER_LOGGER = "c11_tile_manager.tile_uploader"
_C11_UPLOADER_PRODUCER_ID = "c11_tile_manager.tile_uploader"
_C11_DOWNLOADER_LOGGER = "c11_tile_manager.tile_downloader"
def build_flight_state_gate(*, source: FlightStateSource) -> FlightStateGate:
@@ -145,3 +154,164 @@ def build_tile_uploader(
logger=logger,
config=block,
)
def build_tile_downloader(
config: Config,
*,
http_client: httpx.Client,
tile_store: Any,
tile_metadata_store: Any,
budget_enforcer: Any,
companion_id: str | None = None,
) -> HttpTileDownloader:
"""Construct a wired :class:`HttpTileDownloader` (AZ-316).
Wraps c6's ``TileStore`` + ``TileMetadataStore`` +
``CacheBudgetEnforcer`` into a single composition-root adapter
that absorbs c6's :class:`TileMetadata` / :class:`TileSource` /
:class:`FreshnessLabel` / :class:`SectorClassification` enums
so the downloader stays free of cross-component imports
(AZ-507 / AZ-270). The c6 surfaces are caller-owned; production
wiring shares the same singletons used by the uploader and the
on-airframe ingest path.
"""
block = config.components.get("c11_tile_manager")
if block is None:
raise ConfigError(
"build_tile_downloader: config.components['c11_tile_manager'] "
"block is missing — register C11Config and supply YAML"
)
if not isinstance(block, C11Config):
raise ConfigError(
"build_tile_downloader: config.components['c11_tile_manager'] "
f"must be a C11Config, got {type(block).__name__}"
)
if not block.satellite_provider_url:
raise ConfigError(
"build_tile_downloader: C11Config.satellite_provider_url "
"must be configured for production / operator wiring"
)
if not block.service_api_key:
raise ConfigError(
"build_tile_downloader: C11Config.service_api_key must be "
"set; the operator-tooling deploy MUST inject the bearer "
"token via env override"
)
logger = get_logger(_C11_DOWNLOADER_LOGGER)
adapter = _C6DownloadAdapter(
tile_store=tile_store,
metadata_store=tile_metadata_store,
budget_enforcer=budget_enforcer,
companion_id=companion_id or block.companion_id,
)
return HttpTileDownloader(
http_client=http_client,
tile_writer=adapter,
budget_enforcer=adapter,
logger=logger,
config=block,
)
class _C6DownloadAdapter:
"""Composition-root bridge between AZ-316 and c6 storage.
Implements both :class:`_TileWriterLike` and
:class:`_BudgetEnforcerLike` Protocol cuts (declared in
``c11_tile_manager.tile_downloader`` as structural Protocols).
Hides c6's :class:`TileMetadata` / :class:`TileSource` /
:class:`FreshnessLabel` / :class:`SectorClassification` so the
AZ-316 module never imports c6.
"""
__slots__ = (
"_tile_store",
"_metadata_store",
"_budget_enforcer",
"_companion_id",
)
def __init__(
self,
*,
tile_store: Any,
metadata_store: Any,
budget_enforcer: Any,
companion_id: str,
) -> None:
self._tile_store = tile_store
self._metadata_store = metadata_store
self._budget_enforcer = budget_enforcer
self._companion_id = companion_id
def write_tile_for_download(
self,
*,
tile_blob: bytes,
zoom_level: int,
lat: float,
lon: float,
tile_size_meters: float,
tile_size_pixels: int,
capture_timestamp: Any,
content_sha256_hex: str,
sector_class: str,
) -> str:
from gps_denied_onboard.components.c6_tile_cache._types import (
FreshnessLabel,
TileId,
TileMetadata,
TileSource,
VotingStatus,
)
tid = TileId(zoom_level=int(zoom_level), lat=float(lat), lon=float(lon))
metadata = TileMetadata(
tile_id=tid,
tile_size_meters=float(tile_size_meters),
tile_size_pixels=int(tile_size_pixels),
capture_timestamp=capture_timestamp,
source=TileSource.GOOGLEMAPS,
content_sha256_hex=content_sha256_hex,
freshness_label=FreshnessLabel.FRESH,
flight_id=None,
companion_id=None,
quality_metadata=None,
voting_status=VotingStatus.TRUSTED,
)
self._tile_store.write_tile(tile_blob, metadata)
self._metadata_store.insert_metadata(metadata)
# AZ-307 owns the actual freshness label after insert; for the
# download path the simplest contract is "FRESH on first write,
# DOWNGRADED if the row already existed under stable_rear stale
# rules". The c6 store does not currently expose the post-insert
# label as a return value (AZ-303 contract); we return "fresh"
# as the conservative default. A future c6 ABI extension that
# surfaces the label can update this adapter without touching
# the AZ-316 module.
return "fresh"
def tile_already_present(
self, *, zoom_level: int, lat: float, lon: float
) -> bool:
from gps_denied_onboard.components.c6_tile_cache._types import TileId
tid = TileId(zoom_level=int(zoom_level), lat=float(lat), lon=float(lon))
return bool(self._tile_store.tile_exists(tid))
def reserve_headroom(self, needed_bytes: int) -> Any:
from gps_denied_onboard.components.c11_tile_manager.errors import (
CacheBudgetExceededError,
)
from gps_denied_onboard.components.c6_tile_cache.errors import (
CacheBudgetExhaustedError,
)
try:
return self._budget_enforcer.reserve_headroom(needed_bytes)
except CacheBudgetExhaustedError as exc:
raise CacheBudgetExceededError(
f"c6 cache budget exhausted: needed {needed_bytes} bytes; {exc}"
) from exc