Step 12 (Test-Spec Sync): adds BT-27 for the AZ-796 9-rule validation surface and 12 cycle-7 AC rows + Coverage Summary update to traceability-matrix.md. Step 13 (Update Docs): module-layout + module docs for the new SatelliteProvider.Api/Validators namespace + GlobalExceptionHandler + updated TileInventory DTO; tests_unit + tests_integration document the new InventoryRequestValidatorTests (16 unit tests covering all 9 rules) + TileInventoryValidationTests (16 integration tests) + ProblemDetailsAssertions support; glossary entries for Validation Problem Details / FluentValidation / Unmapped Member Handling; system-flows F8 (Tile Inventory Bulk Lookup) expanded with deserializer + validator gates and a 13-row Validation Surface table; data_parameters § Tile Inventory documents the v2 input schema + constraints; ripple_log_cycle7 captures the doc-side ripple decisions. Step 14 (Security Audit): 5-phase audit ran; verdict PASS_WITH_WARNINGS (3 Low findings — D-AZ795-1 FluentValidation 12.0.0 -> 12.1.1 recommended bump, F-AZ795-1 JsonException.Message leak in 400 detail, F-AZ795-2 BadHttpRequestException.Message leak). No Critical / High; auth runs before validation (confirmed in Program.cs); two NuGet additions (FluentValidation 12.0.0 + .DependencyInjectionExtensions 12.0.0) both CVE-clean. Per-phase reports plus consolidated security_report_cycle7.md. Step 15 (Performance Test): docker compose stack used for perf run, scripts/run-performance-tests.sh exited 0 with 8/8 scenarios PASS (second consecutive clean exit-0); added PT-09 cycle-7 smoke probe (v2 z/x/y schema, 2500-tile all-miss batch) measuring min=27ms median=44ms p95=73ms max=86ms (13.7x under AZ-505 AC-4 1000ms budget). PT-07/08 improvements traced to the cycle-6 TLS handshake-overhead identification, not application-side change. Co-authored-by: Cursor <cursoragent@cursor.com>
17 KiB
Satellite Provider — System Flows
Flow Inventory
| # | Flow Name | Trigger | Primary Components | Criticality |
|---|---|---|---|---|
| F1 | Single Tile Download | HTTP GET /api/satellite/tiles/latlon | WebApi, TileDownloader, DataAccess | High |
| F2 | Region Request | HTTP POST /api/satellite/request | WebApi, RegionProcessing, TileDownloader, DataAccess | High |
| F3 | Region Processing | Queue dequeue (background) | RegionProcessing, TileDownloader, DataAccess | High |
| F4 | Route Creation | HTTP POST /api/satellite/route | WebApi, RouteManagement, DataAccess | High |
| F5 | Route Map Processing | Queue dequeue (background) | RouteManagement, RegionProcessing, TileDownloader, DataAccess | Medium |
| F6 | Status Query | HTTP GET /api/satellite/region/{id} or /route/{id} | WebApi, DataAccess | Low |
| F7 | Leaflet Tile Serving | HTTP GET /tiles/{z}/{x}/{y} | WebApi, TileService, DataAccess, FileSystem | High |
| F8 | Tile Inventory Bulk Lookup | HTTP POST /api/satellite/tiles/inventory | WebApi, TileService, DataAccess | High |
Flow Dependencies
| Flow | Depends On | Shares Data With |
|---|---|---|
| F1 | — | F3 (tile cache) |
| F2 | — | F3 (triggers it) |
| F3 | F2 enqueues work | F1 (shares tile cache), F5 |
| F4 | — | F5 (triggers it) |
| F5 | F4 must create route first | F3 (submits region requests) |
| F6 | F2/F4 must exist | — |
| F7 | F1 or F3 must have populated the tile (else 404) | F1, F3, F8 (shares tiles.location_hash) |
| F8 | — | F1, F3, F7 (shares tiles.location_hash) |
Flow F1: Single Tile Download
Description
Client requests a single satellite tile by geographic coordinates and zoom level. The service checks the cache (DB), downloads from Google Maps if not cached, stores it, and returns metadata.
Preconditions
- Valid latitude, longitude, and zoom level provided
- Google Maps session token configured
Sequence Diagram
sequenceDiagram
participant Client
participant WebApi
participant TileService
participant TileRepo
participant GoogleMaps
participant FileSystem
Client->>WebApi: GET /api/satellite/tiles/latlon?lat&lon&zoom
WebApi->>TileService: DownloadTileAsync(lat, lon, zoom)
TileService->>TileRepo: FindByCoordinates(lat, lon, zoom)
alt Tile exists in cache
TileRepo-->>TileService: TileEntity
TileService-->>WebApi: TileMetadata (cached)
else Not cached
TileService->>GoogleMaps: Download tile image
GoogleMaps-->>TileService: JPEG bytes
TileService->>FileSystem: Save to ./tiles/{zoom}/{x}/{y}.jpg
TileService->>TileRepo: Insert(TileEntity)
TileService-->>WebApi: TileMetadata (new)
end
WebApi-->>Client: JSON response
Error Scenarios
| Error | Where | Detection | Recovery |
|---|---|---|---|
| Google Maps timeout | Download step | HttpClient timeout | Return error to caller |
| Duplicate download race | Concurrent requests | ConcurrentDictionary check | Await existing download |
| Disk full | File save | IOException | Exception propagated, region fails |
Flow F2: Region Request
Description
Client submits a region definition (center point, size, zoom). The request is persisted and queued for asynchronous processing.
Preconditions
- Valid region parameters (lat, lon, size_meters, zoom_level)
Sequence Diagram
sequenceDiagram
participant Client
participant WebApi
participant RegionService
participant RegionRepo
participant Queue
Client->>WebApi: POST /api/satellite/request {lat, lon, size, zoom}
WebApi->>RegionService: CreateRegionRequest(dto)
RegionService->>RegionRepo: Insert(RegionEntity status=pending)
RegionRepo-->>RegionService: region_id
RegionService->>Queue: Enqueue(region_id)
RegionService-->>WebApi: region_id
WebApi-->>Client: 200 OK {region_id}
Error Scenarios
| Error | Where | Detection | Recovery |
|---|---|---|---|
| Queue full | Enqueue step | Channel at capacity | Return 503 / reject request |
| DB insert failure | Persist step | Exception | Return 500 |
Flow F3: Region Processing (Background)
Description
Background service dequeues region IDs, calculates tile grid, downloads all tiles (with concurrency control), optionally stitches them, and produces output files (CSV, summary, stitched image).
Preconditions
- Region exists in DB with status "pending"
- Google Maps session token configured
Sequence Diagram
sequenceDiagram
participant Queue
participant RegionProcessor
participant RegionService
participant TileService
participant GoogleMaps
participant RegionRepo
participant FileSystem
Queue->>RegionProcessor: Dequeue region_id
RegionProcessor->>RegionRepo: GetById(region_id)
RegionProcessor->>RegionRepo: UpdateStatus(processing)
loop For each tile in grid
RegionProcessor->>TileService: DownloadTileAsync(lat, lon, zoom)
TileService->>GoogleMaps: Download (if not cached)
end
RegionProcessor->>FileSystem: Write CSV (tile manifest)
RegionProcessor->>FileSystem: Write summary file
opt stitch_tiles = true
RegionProcessor->>FileSystem: Stitch tiles into composite image
end
RegionProcessor->>RegionRepo: UpdateStatus(completed, file paths)
Data Flow
| Step | From | To | Data | Format |
|---|---|---|---|---|
| 1 | Queue | RegionProcessor | region_id | int |
| 2 | RegionProcessor | TileService | lat, lon, zoom per tile | method call |
| 3 | TileService | FileSystem | JPEG image | file |
| 4 | RegionProcessor | FileSystem | tile manifest | CSV |
| 5 | RegionProcessor | FileSystem | region summary | TXT |
| 6 | RegionProcessor | FileSystem | composite image | JPEG |
Error Scenarios
| Error | Where | Detection | Recovery |
|---|---|---|---|
| Tile download failure | Per-tile loop | Exception from TileService | Log, continue with remaining tiles |
| All tiles fail | After loop | Zero tiles downloaded | Mark region as "failed" |
| Stitch failure | Image processing | ImageSharp exception | Mark region failed, tiles still available |
Flow F4: Route Creation
Description
Client submits a route (ordered waypoints + optional geofence polygons). The service interpolates intermediate points every ~200m and persists the full point set.
Preconditions
- At least 2 waypoints provided
- Valid geofence polygons (if provided)
Sequence Diagram
sequenceDiagram
participant Client
participant WebApi
participant RouteService
participant RouteRepo
participant GeoUtils
Client->>WebApi: POST /api/satellite/route {points, geofences, options}
WebApi->>RouteService: CreateRoute(request)
RouteService->>GeoUtils: Interpolate points between waypoints
GeoUtils-->>RouteService: All points (original + intermediate)
RouteService->>RouteRepo: InsertRoute(RouteEntity)
RouteService->>RouteRepo: InsertPoints(RoutePointEntities)
RouteService-->>WebApi: RouteResponse
WebApi-->>Client: 200 OK {route_id, total_points, total_distance}
Error Scenarios
| Error | Where | Detection | Recovery |
|---|---|---|---|
| Invalid points (< 2) | Validation | Count check | Return 400 |
| DB insert failure | Persist step | Exception | Return 500 |
Flow F5: Route Map Processing (Background)
Description
When a route requests map tiles (request_maps = true), a background service creates region requests for each route point, optionally filtered by geofence, then waits for all regions to complete and produces a ZIP archive.
Preconditions
- Route exists with
request_maps = true - Route points already interpolated and persisted
Sequence Diagram
sequenceDiagram
participant RouteProcessor
participant RouteRepo
participant RegionService
participant Queue
participant RegionProcessor
participant FileSystem
RouteProcessor->>RouteRepo: GetRouteWithPoints(route_id)
loop For each route point
RouteProcessor->>RouteProcessor: Check geofence (point-in-polygon)
opt Point inside geofence (or no geofence)
RouteProcessor->>RegionService: CreateRegionRequest(point)
RegionService->>Queue: Enqueue(region_id)
end
end
RouteProcessor->>RouteProcessor: Wait for all regions to complete
opt create_tiles_zip = true
RouteProcessor->>FileSystem: Create ZIP of all tiles (max 50MB)
RouteProcessor->>RouteRepo: Update tiles_zip_path
end
Error Scenarios
| Error | Where | Detection | Recovery |
|---|---|---|---|
| Region processing timeout | Wait loop | Polling timeout | Mark route partially complete |
| ZIP exceeds 50MB | ZIP creation | Size check during write | Truncate or skip |
| Geofence calculation error | Point-in-polygon | Exception | Include point (fail-open) |
Flow F6: Status Query
Description
Client polls for the status of a region or route by ID. Returns current processing state and output file paths when complete.
Sequence Diagram
sequenceDiagram
participant Client
participant WebApi
participant DataAccess
Client->>WebApi: GET /api/satellite/region/{id}
WebApi->>DataAccess: GetRegionById(id)
DataAccess-->>WebApi: RegionEntity (status, file paths)
WebApi-->>Client: JSON {status, files}
Flow F7: Leaflet Tile Serving (added AZ-310, rewired AZ-505)
Description
Leaflet (or any HTTP/2-capable client) requests a single tile body by slippy (z, x, y). The handler resolves the most-recent variant across sources/flights by location_hash and streams the JPEG body back. AZ-505 rewired the lookup predicate from (tile_zoom, tile_x, tile_y) to location_hash = Uuidv5(TileNamespace, "{z}/{x}/{y}") so the read hits the tiles_leaflet_path covering index as an Index Only Scan; the selection rule (captured_at DESC, updated_at DESC, id DESC LIMIT 1) is unchanged from AZ-484 / AZ-503-foundation. Kestrel runs Http1AndHttp2 over TLS (https://+:8080 in dev) so ALPN multiplexes many concurrent leaflet requests on a single TLS connection.
Sequence Diagram
sequenceDiagram
participant Client
participant Kestrel
participant ServeTile
participant TileService
participant TileRepo
participant FileSystem
Client->>Kestrel: GET /tiles/{z}/{x}/{y} (HTTP/2 via TLS+ALPN)
Kestrel->>ServeTile: route match
ServeTile->>TileService: GetOrDownloadTileAsync(z, x, y)
TileService->>TileRepo: GetByTileCoordinatesAsync(z, x, y)
Note over TileRepo: WHERE location_hash = $1<br/>ORDER BY captured_at DESC, updated_at DESC, id DESC<br/>LIMIT 1<br/>Index Only Scan: tiles_leaflet_path
alt Cached
TileRepo-->>TileService: TileEntity
TileService->>FileSystem: read file_path
FileSystem-->>TileService: JPEG bytes
TileService-->>ServeTile: TileBytes (file_path, ETag, Cache-Control)
ServeTile-->>Client: 200 OK, JPEG body
else Miss
TileService->>TileService: download via GoogleMapsDownloaderV2
Note over TileService: persists row + on-disk path, falls through to ServeTile
TileService-->>ServeTile: TileBytes
ServeTile-->>Client: 200 OK, JPEG body
end
Error Handling
| Failure | Detection | Handling |
|---|---|---|
| Tile not present and downloader rejects (404 from Google Maps) | TileService.GetOrDownloadTileAsync propagates the downloader's HttpRequestException |
Returns 500; ServeTile does NOT translate this to 404 because the predicate matched (path-traversal cases below 404 earlier in routing) |
Path traversal in /tiles/... segment |
ASP.NET Core route binding | 400/404 before the handler runs (covered by SEC-02) |
Flow F8: Tile Inventory Bulk Lookup (added AZ-505; renamed + strict-validated AZ-794+AZ-795+AZ-796, cycle 7)
Description
Programmatic clients (httpx http2=True, .NET HttpClient, onboard cross-repo callers) post a batch of up to 5000 {z, x, y} triples (Form A; the wire field names were renamed from tileZoom/tileX/tileY by AZ-794, cycle 7) or up to 5000 pre-computed location_hash UUIDs (Form B) and get one inventory entry per input slot, in the same order. Each entry says whether the cell is present and — when present — the most-recent row's id, capturedAt, source, flightId, and resolutionMPerPx. No tile bodies are returned; the caller subsequently fetches bodies via F7. This is the read-half of the bulk-list contract that the onboard gps-denied-onboard workspace consumes to decide which Google-Maps cells it needs and which UAV variants are already on the server.
Sequence Diagram
sequenceDiagram
participant Client
participant Kestrel
participant GetTilesInventory
participant TileService
participant TileRepo
Client->>Kestrel: POST /api/satellite/tiles/inventory (JWT, Form A or B)
Kestrel->>GetTilesInventory: route match
Note over Kestrel,GetTilesInventory: AZ-795 deserializer guards (UnmappedMemberHandling.Disallow + [JsonRequired])<br/>catch unknown / missing / type-mismatched fields → 400 ValidationProblemDetails<br/>via GlobalExceptionHandler (cycle 7)
GetTilesInventory->>GetTilesInventory: ValidationEndpointFilter<TileInventoryRequest><br/>(InventoryRequestValidator — AZ-796 cycle 7)
Note over GetTilesInventory: 9 business rules: XOR / non-empty / per-array cap / Z range / X+Y range
GetTilesInventory->>TileService: GetInventoryAsync(request)
Note over TileService: Form A: compute location_hash per coord<br/>via Uuidv5.LocationHashForTile<br/>Form B: echo caller-supplied hashes
TileService->>TileRepo: GetTilesByLocationHashesAsync(hashes)
Note over TileRepo: NpgsqlCommand:<br/>SELECT DISTINCT ON (location_hash) ...<br/>WHERE location_hash = ANY($1::uuid[])<br/>ORDER BY location_hash, captured_at DESC, updated_at DESC, id DESC<br/>(bypasses Dapper IEnumerable expansion)
TileRepo-->>TileService: IReadOnlyDictionary<Guid, TileEntity>
TileService->>TileService: shape into TileInventoryEntry[] in request order
TileService-->>GetTilesInventory: TileInventoryResponse
GetTilesInventory-->>Client: 200 OK, JSON (results in input order)
Validation Surface (post-cycle 7 — AZ-795 + AZ-796)
| Input | Detection | Response |
|---|---|---|
| Empty / missing body | System.Text.Json ([JsonRequired] on Tiles/LocationHashes covered indirectly via InventoryRequestValidator) |
400 + ValidationProblemDetails |
Both tiles and locationHashes populated |
InventoryRequestValidator .Must(...) (Rule 1, XOR — tile-inventory.md v2.0.0 Inv-1) |
400 + ValidationProblemDetails, key "" |
| Neither populated | InventoryRequestValidator .Must(...) (Rule 1, XOR) |
400 + ValidationProblemDetails, key "" |
tiles or locationHashes array is empty |
InventoryRequestValidator .Must(...) (Rule 1, XOR — non-empty arm) |
400 + ValidationProblemDetails, key "" |
tiles.Count > 5000 (TileInventoryLimits.MaxEntriesPerRequest) |
InventoryRequestValidator .Must(t => t.Count <= 5000) (Rule 6 — Inv-7) |
400 + ValidationProblemDetails, key tiles |
locationHashes.Count > 5000 |
InventoryRequestValidator .Must(h => h.Count <= 5000) (Rule 7 — Inv-7) |
400 + ValidationProblemDetails, key locationHashes |
tiles[i].z missing OR out of range (must be 0..22 inclusive) |
[JsonRequired] on Z (deserializer) + TileCoordValidator Rule 4 |
400 + ValidationProblemDetails, key tiles[i].z |
tiles[i].x missing OR < 0 OR >= 2^z |
[JsonRequired] on X (deserializer) + TileCoordValidator Rule 5 |
400 + ValidationProblemDetails, key tiles[i].x |
tiles[i].y missing OR < 0 OR >= 2^z |
[JsonRequired] on Y (deserializer) + TileCoordValidator Rule 5 |
400 + ValidationProblemDetails, key tiles[i].y |
Legacy tileZoom/tileX/tileY field names |
UnmappedMemberHandling.Disallow (deserializer; AZ-794 + AZ-795) |
400 + ValidationProblemDetails, key tiles[i].tileZoom (etc.) |
| Unknown root or nested field | UnmappedMemberHandling.Disallow (deserializer; AZ-795) |
400 + ValidationProblemDetails, key on the unknown path |
Wrong JSON type (e.g. "z": "18") |
System.Text.Json type-mismatch (deserializer; AZ-795) | 400 + ValidationProblemDetails, key on the offending path |
No Authorization: Bearer … header |
.RequireAuthorization() |
401 before handler runs |
All 4xx bodies conform to error-shape.md v1.0.0. The same ValidationProblemDetails shape is emitted whether the failure was caught by the FluentValidation business-rule layer (InventoryRequestValidator) or by the deserializer layer (via GlobalExceptionHandler). Both layers are unit + integration tested in SatelliteProvider.Tests/Validators/InventoryRequestValidatorTests and SatelliteProvider.IntegrationTests/TileInventoryValidationTests.
Performance
p95 ≤ 1000 ms for 2500-coord batches (AZ-505 AC-4). Cycle-6 measured: p95=66ms — well under budget. The covering index (tiles_leaflet_path) supplies the leading location_hash lookup; the projection's columns beyond the INCLUDE list (id, captured_at, flight_id, ...) trigger a bounded heap fetch which is documented and accepted per the AZ-505 NFR.