Co-authored-by: Cursor <cursoragent@cursor.com>
11 KiB
Contract: route_client
Component: c11_tilemanager
Producer task: AZ-838_satellite_provider_route_client (Epic AZ-835 C2)
Consumer tasks: AZ-839 (operator_pre_flight_setup real fixture, Epic AZ-835 C3); AZ-840 (E2E orchestrator test, Epic AZ-835 C4); future C12 production binding (deferred — see § Non-Goals).
Version: 1.0.0
Status: stable
Last Updated: 2026-05-26
Purpose
The SatelliteProviderRouteClient is C11's operator-side route-onboarding interface. Given a RouteSpec (a coarsened, tlog-derived flight corridor produced by replay_input.tlog_route.extract_route_from_tlog — AZ-836), it registers the corridor with the parent-suite satellite-provider Route API, polls until materialisation completes, and verifies coverage via the inventory contract.
The route-driven seeding flow lets the operator pre-commit the C6 cache to the precise corridor the drone actually flew rather than a coarse bounding box — typically ~100× more tile-efficient on long, narrow flights.
C11 is operator-side ONLY; ADR-004 forbids the airborne companion image from importing this module.
Upstream API (cycle 3 — AZ-838): POST /api/satellite/route (corridor onboarding; body shape per CreateRouteRequest.cs / RoutePoint.cs / GeoPoint.cs DTOs; query requestMaps=true&createTilesZip=false) + GET /api/satellite/route/{id} (status polling; terminal-success when mapsReady=true; terminal-failure when status ∈ {failed, error, rejected}) + POST /api/satellite/tiles/inventory (post-materialisation coverage verification, shared with tile_downloader). Authentication: Authorization: Bearer ${SATELLITE_PROVIDER_API_KEY}; the dev-only SATELLITE_PROVIDER_TLS_INSECURE=1 env knob accepts the self-signed dev cert.
Shape
Function / method API
import uuid
from gps_denied_onboard._types.route import RouteSpec # AZ-845 canonical home
class SatelliteProviderRouteClient:
def __init__(
self,
base_url: str,
jwt: str,
*,
tls_insecure: bool = False,
request_timeout_s: float = 30.0,
poll_interval_s: float = 5.0,
poll_max_attempts: int = 60,
) -> None: ...
def seed_route(
self,
spec: RouteSpec,
*,
name: str | None = None,
) -> RouteSeedResult: ...
| Name | Signature | Throws / Errors | Blocking? |
|---|---|---|---|
seed_route |
(spec: RouteSpec, *, name: str | None = None) -> RouteSeedResult |
RouteValidationError, RouteTransientError, RouteTerminalFailureError (all under SatelliteProviderRouteError) |
sync; poll loop bounded by poll_max_attempts × poll_interval_s (default 60 × 5 s = 5 min ceiling) |
Data DTOs
@dataclass(frozen=True, slots=True)
class RouteSpec: # _types/route.py (AZ-845)
waypoints: tuple[tuple[float, float], ...] # (lat, lon)
suggested_region_size_meters: float # per-waypoint coverage radius
source_tlog: Path # provenance
source_segment: tuple[int, int] # (start_idx, end_idx) into tlog GPS rows
total_distance_meters: float # along-track distance of active segment
@dataclass(frozen=True, slots=True)
class RouteSeedResult: # c11_tile_manager/route_client.py
route_id: uuid.UUID
terminal_status: str # e.g. "completed", "done", "succeeded"
maps_ready: bool # True on terminal success
tile_count: int # present=true entries from inventory verify
elapsed_ms: int # POST → terminal-status wall time
submitted_payload_sha256: str # provenance for the inventory verify step
| Field | Type | Required | Description | Constraints |
|---|---|---|---|---|
RouteSpec.waypoints |
tuple[tuple[float, float], ...] |
yes | Ordered list of (lat, lon) waypoints | 2 ≤ len(waypoints) ≤ 500 (AZ-809 validator); each lat ∈ [-90, 90], lon ∈ [-180, 180] |
RouteSpec.suggested_region_size_meters |
float |
yes | Per-waypoint coverage radius | 100.0 ≤ value ≤ 10_000.0 (AZ-809 validator) |
RouteSpec.source_tlog |
Path |
yes | Provenance — which tlog produced this spec | filesystem path |
RouteSeedResult.route_id |
uuid.UUID |
yes | Server-assigned route id | non-zero |
RouteSeedResult.terminal_status |
str |
yes | Last status observed from GET /api/satellite/route/{id} |
one of {"completed", "failed", "error", "done", "succeeded", "rejected"} |
RouteSeedResult.maps_ready |
bool |
yes | True iff parent suite reported mapsReady=true (terminal success) |
True on success; False if poll budget exhausted before terminal |
RouteSeedResult.tile_count |
int |
yes | Inventory present=true count over the route's enumerated coverage |
≥ 0 (lower bound — server may interpolate between waypoints) |
Invariants
- I-1: Pre-emptive validation rejects obviously-bad input as
RouteValidationErrorBEFORE the HTTP POST. The client mirrors the AZ-809CreateRouteRequestValidatorbounds (points2..500;regionSizeMeters100..10 000;zoomLevel0..22; lat/lon ranges;name/descriptionmax lengths). The list MUST stay in sync withSatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs(parent suite source). - I-2: The client POSTs the wire shape exactly per
CreateRouteRequest.cs+RoutePoint.cs+GeoPoint.cs(note:RoutePointuseslat/lonJSON property names for both input and output; the input/output naming asymmetry flagged in AZ-809 AC-10 is a parent-suite concern, not a client adaptation). - I-3: Poll cadence MUST respect
poll_interval_s(lower bound between successiveGET /api/satellite/route/{id}calls) andpoll_max_attempts(upper bound on attempt count). The client logs every poll tick at INFO with the observed status. - I-4: Terminal-success is exactly
mapsReady=true. Terminal-failure is exactlystatus ∈ {"failed", "error", "rejected"}. Any other status is treated as "still processing" and triggers the next poll. If the poll budget is exhausted without terminal status,RouteTransientErroris raised with the last observed status. - I-5: 4xx responses with RFC 7807
ProblemDetails→RouteValidationError;field_errorsis populated from theerrorsdict so the caller can render per-field rejections. - I-6: 5xx / network / timeout →
RouteTransientErrorwith__cause__set to the underlyinghttpxexception. The retry semantics are caller-driven — the route client itself does NOT retry the POST, leaving the policy to the fixture / CLI (e.g.,tests/e2e/replay/conftest.py::operator_pre_flight_setupretries up to 3 times using C11's_DEFAULT_BACKOFF_SCHEDULE_S = (1, 2, 4, 8)). - I-7: The inventory verify step uses
POST /api/satellite/tiles/inventory(≤ 5000 entries / request) and enumerates the route's tile coverage locally from(waypoints, suggested_region_size_meters)using the parent suite's web-Mercator math (_EARTH_EQUATORIAL_CIRCUMFERENCE_M = 40 075 016.686). The result is a lower bound on actual server coverage — the server may interpolate intermediate corridor tiles that the local enumeration misses; this is documented and acceptable as a sanity-check signal, not a coverage proof.
Non-Goals
- Not covered: producing the
RouteSpec— owned byreplay_input.tlog_route.extract_route_from_tlog(AZ-836). - Not covered: orchestration of when the operator runs the seed — owned by C12 (production binding deferred; cycle-3 e2e fixture
operator_pre_flight_setupis the current driver — AZ-839). - Not covered: FAISS index construction over the populated cache — owned by C10
DescriptorBatcher. - Not covered: bbox-based seeding — handled by
tile_downloader.download_tiles_for_area(and bytests/fixtures/derkachi_c6/seed_region.pyfor the e2e fixture). - Not covered: multi-route batching — one
RouteSpecperseed_routecall. Multi-flight aggregate corridors are an operator-workflow concern.
Versioning Rules
- Breaking changes (renamed method, removed required field, changed return type, parent-suite Route API contract break) require a major version bump. Coordinate with the C3 fixture (AZ-839) and any future C12 production binding via Choose A/B/C/D before bumping.
- Non-breaking additions (new optional constructor kwarg, new field on
RouteSeedResult, new error variant the consumer catches viaSatelliteProviderRouteError) require a minor version bump. - The pre-emptive validation bounds (I-1) MUST track the parent-suite
CreateRouteRequestValidator.csexactly. Drift between client and server validators is a defect, not a version concern — fix the client to match the server.
Test Cases
| Case | Input | Expected | Notes |
|---|---|---|---|
| route-happy-path | RouteSpec for Derkachi tlog (2-waypoint corridor, region_size=500m) against a stubbed satellite-provider returning mapsReady=true on the 2nd poll |
RouteSeedResult with maps_ready=True, tile_count > 0, terminal_status="completed", elapsed_ms reflects 2 polls |
AZ-838 AC-1, AC-2 |
| validation-empty-points | RouteSpec(waypoints=(), …) |
RouteValidationError raised BEFORE HTTP POST |
I-1, AZ-838 AC-6 |
| validation-too-many-points | RouteSpec with 501 waypoints |
RouteValidationError raised BEFORE HTTP POST |
I-1, AZ-838 AC-6 |
| validation-region-too-large | RouteSpec(suggested_region_size_meters=10_001.0, …) |
RouteValidationError raised BEFORE HTTP POST |
I-1, AZ-838 AC-6 |
| 4xx-problem-details | server returns 400 + RFC 7807 errors dict |
RouteValidationError with field_errors populated from the response |
I-5, AZ-838 AC-3 |
| 5xx-transient | server returns 503 | RouteTransientError with __cause__ set to the underlying httpx exception |
I-6, AZ-838 AC-4 |
| terminal-failure | server reports status="failed" mid-poll |
RouteTerminalFailureError; .detail carries the response JSON |
I-4, AZ-838 AC-5 |
| poll-budget-exhausted | server stays in status="processing" past 60 attempts |
RouteTransientError referencing the last observed status |
I-3, I-4 |
| inventory-verify-counts-present | mapsReady=true then inventory POST returns mixed present=true/false entries |
tile_count equals the count of present=true entries |
I-7 |
| integration-derkachi | RouteSpec from real Derkachi tlog, against the Jetson satellite-provider (gated by RUN_E2E=1 + SATELLITE_PROVIDER_URL) |
tile_count > 0, maps_ready=True, completes in ≤ 15 s on the 2-waypoint reference route |
AZ-838 AC-10 (Jetson-only, Tier-2) |
Change Log
| Version | Date | Change | Author |
|---|---|---|---|
| 1.0.0 | 2026-05-26 | Initial contract — produced by AZ-838 (Epic AZ-835 C2). Cycle-3 addition; consumed by AZ-839 (operator_pre_flight_setup real fixture) and AZ-840 (E2E orchestrator test). |
autodev |