[AZ-505] Tile inventory endpoint + HTTP/2 + Leaflet covering index

Production code:
- POST /api/satellite/tiles/inventory (XOR body, 5000-cap,
  most-recent-per-location_hash select, present/absent shaping).
- Kestrel HttpProtocols.Http1AndHttp2 on every listener (AC-5).
- Migration 015 creates tiles_leaflet_path covering index over
  (location_hash, captured_at DESC, updated_at DESC, id DESC)
  INCLUDE (file_path, source); drops superseded idx_tiles_location_hash.
- TileRepository.GetByTileCoordinatesAsync rewired to filter by
  location_hash (Index Only Scan via tiles_leaflet_path).
- TileRepository.GetTilesByLocationHashesAsync added with Npgsql-
  direct ANY($1::uuid[]) binding (Dapper IEnumerable expansion is
  incompatible with the array form).
- Uuidv5.LocationHashForTile centralises the UUIDv5(TileNamespace,
  "{z}/{x}/{y}") formula — single source of truth for the cross-repo
  invariant (gps-denied-onboard parity).

Contracts:
- New: contracts/api/tile-inventory.md v1.0.0.
- Bumped: contracts/data-access/tile-storage.md to v2.0.0 (joint
  ownership by AZ-503-foundation + AZ-505: schema + covering index +
  GetByTileCoordinatesAsync rewrite).

Tests:
- TileInventoryTests covers AC-1, AC-2 (DB-level), AC-4, AC-6.
- Http2MultiplexingTests covers AC-5 (20 concurrent multiplexed GETs
  over h2c via SocketsHttpHandler + AppContext Http2Unencrypted switch).
- LeafletPathIndexOnlyTests covers AC-3 (EXPLAIN (ANALYZE, BUFFERS)
  asserts Index Only Scan over tiles_leaflet_path with heap_blocks=0).

Docs:
- architecture.md, system-flows.md, data_model.md, module-layout.md,
  glossary.md, modules/api_program.md, modules/dataaccess_tile_repository.md,
  components/02_data_access/description.md all updated to reference the
  v2.0.0 tile-storage contract + new tile-inventory contract + AC-7.

Reports:
- batch_01_cycle6_report.md, batch_01_cycle6_review.md,
  implementation_completeness_cycle6_report.md (PASS),
  implementation_report_tile_inventory_cycle6.md.

Task spec moved todo/ -> done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 21:16:37 +03:00
parent 3c7cd4e56b
commit 909f69cb3a
26 changed files with 1780 additions and 65 deletions
@@ -0,0 +1,166 @@
# Contract: tile-inventory
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via TileDownloader (`SatelliteProvider.Services.TileDownloader`)
**Producer task**: AZ-505 — `_docs/02_tasks/todo/AZ-505_tile_inventory_http2_leaflet_index.md`
**Consumer tasks**: `gps-denied-onboard` AZ-316 (`c11_tile_downloader`), future mission-planner UI cache-sizing flows
**Version**: 1.0.0
**Status**: frozen
**Last Updated**: 2026-05-12
## Purpose
Defines the HTTP contract for the `POST /api/satellite/tiles/inventory` bulk-lookup endpoint. Callers submit either a list of slippy-map coords or a list of pre-computed `location_hash` UUIDs and receive one response entry per input — in the same order — telling them whether each tile is already cached server-side and (if so) which row to expect on a subsequent `GET /tiles/{z}/{x}/{y}`.
The endpoint is the consumer-facing payload that justifies the AZ-503-foundation schema work (the deterministic `location_hash` column) plus the AZ-505 `tiles_leaflet_path` covering index. It is designed for pre-flight cache sizing on the gps-denied-onboard side.
## Endpoint
```
POST /api/satellite/tiles/inventory
Content-Type: application/json
Authorization: Bearer <JWT>
```
The request MUST carry a valid JWT (AZ-487). No `permissions` claim is required — inventory is a metadata-only read; the GPS permission gate only applies to UAV writes. Anonymous requests are rejected with HTTP 401.
## Shape
### Request body
Exactly one of `tiles` OR `locationHashes` MUST be populated. Sending both, or neither, is HTTP 400.
```jsonc
// Form A — coord-keyed
{
"tiles": [
{ "tileZoom": 18, "tileX": 154321, "tileY": 95812 },
{ "tileZoom": 18, "tileX": 154322, "tileY": 95812 }
]
}
// Form B — hash-keyed
{
"locationHashes": [
"ad8c1c4c-2b27-5af4-902f-9c8baeed1e84",
"5b8d0c2e-7f1a-5d3b-9c5e-1f3a8e7d2b6c"
]
}
```
Per-field constraints:
| Field | Type | Required | Description | Constraints |
|-------|------|----------|-------------|-------------|
| `tiles` | `TileCoord[]` | yes (XOR `locationHashes`) | Slippy-map tile coords | Up to 5000 entries per request. Each entry MUST have all three of `tileZoom`, `tileX`, `tileY`. |
| `locationHashes` | `UUID[]` | yes (XOR `tiles`) | Pre-computed UUIDv5 `location_hash` values | Up to 5000 entries per request. Each entry MUST be RFC 4122 UUID. |
Hard cap: **5000 entries per request** (`SatelliteProvider.Common.DTO.TileInventoryLimits.MaxEntriesPerRequest`). Anything larger → HTTP 400. The cap is 2× the AC-4 perf gate (2500 tiles).
### `TileCoord` (per entry under `tiles`)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tileZoom` | integer | yes | Slippy-map zoom level |
| `tileX` | integer | yes | Slippy-map tile column |
| `tileY` | integer | yes | Slippy-map tile row |
### Response body
```jsonc
{
"results": [
{
"tileZoom": 18,
"tileX": 154321,
"tileY": 95812,
"locationHash": "ad8c1c4c-2b27-5af4-902f-9c8baeed1e84",
"present": true,
"id": "5d83…",
"capturedAt": "2026-05-12T13:24:50.123456Z",
"source": "uav",
"flightId": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"resolutionMPerPx": 0.78125
},
{
"tileZoom": 18,
"tileX": 154322,
"tileY": 95812,
"locationHash": "5b8d0c2e-7f1a-5d3b-9c5e-1f3a8e7d2b6c",
"present": false,
"id": null,
"capturedAt": null,
"source": null,
"flightId": null,
"resolutionMPerPx": null
}
]
}
```
Per-entry fields:
| Field | Type | Present when... | Description |
|-------|------|-----------------|-------------|
| `tileZoom` | integer | always (Form A); zeroed (Form B) | Echoes the request entry's `tileZoom` when input was `tiles`; `0` when input was `locationHashes` (caller already knows the cell). |
| `tileX` | integer | always (Form A); zeroed (Form B) | Same as `tileZoom`. |
| `tileY` | integer | always (Form A); zeroed (Form B) | Same as `tileZoom`. |
| `locationHash` | UUIDv5 | always | `UUIDv5(TileNamespace, "{tileZoom}/{tileX}/{tileY}")`. Populated even when `present=false` so callers can persist the deterministic hash. |
| `present` | bool | always | `true` iff a row exists in `tiles` with this `location_hash`. |
| `id` | UUID | present=true | Most-recent row's `tiles.id`. Deterministic UUIDv5 for AZ-503+ rows; random for legacy rows. |
| `capturedAt` | ISO-8601 UTC | present=true | `tiles.captured_at`. |
| `source` | string enum | present=true | `tiles.source` wire value (`"google_maps"` or `"uav"`). |
| `flightId` | UUID | present=true (may be null) | `tiles.flight_id`; null for `google_maps` rows and pre-AZ-503 legacy UAV rows. |
| `resolutionMPerPx` | number | present=true | `tile_size_meters / tile_size_pixels`. Derived; not a stored column. |
Order invariant: `results[i]` corresponds to `request.tiles[i]` (or `request.locationHashes[i]`). Always. Even when entries are absent. Even when the request contains duplicates (each duplicate yields its own response entry).
### Endpoint summary
| Method | Path | Request body | Response | Status codes |
|--------|------|--------------|----------|--------------|
| `POST` | `/api/satellite/tiles/inventory` | `TileInventoryRequest` | `TileInventoryResponse` | 200, 400, 401 |
## Invariants
- **Inv-1**: Exactly one of `request.tiles` and `request.locationHashes` is populated and non-empty. Both-populated → 400; both-empty → 400.
- **Inv-2**: `len(response.results) == len(request.tiles)` OR `len(request.locationHashes)` — never less, never more.
- **Inv-3**: `response.results[i].locationHash` is deterministic from `request.tiles[i]` (UUIDv5 over `"{zoom}/{x}/{y}"` with `Uuidv5.TileNamespace`) when Form A is used, or equals `request.locationHashes[i]` when Form B is used.
- **Inv-4**: `response.results[i].present == true` iff a row exists in `tiles` with `location_hash = response.results[i].locationHash`.
- **Inv-5**: When `present=true`, the returned row is the most-recent across sources/flights ordered by `(captured_at DESC, updated_at DESC, id DESC)` — same rule as `ITileRepository.GetByTileCoordinatesAsync` per `tile-storage` v2.0.0.
- **Inv-6**: When `present=false`, `id` / `capturedAt` / `source` / `flightId` / `resolutionMPerPx` are all `null`.
- **Inv-7**: `request.tiles.length` and `request.locationHashes.length` MUST be ≤ `TileInventoryLimits.MaxEntriesPerRequest` (5000); over the cap → 400.
## Non-Goals
- **Not covered**: byte-size hints (`estimatedBytes`). Deferred until production profiling justifies the per-file `stat()` cost.
- **Not covered**: voting / trust-promotion filtering. The `voting_status` filter is part of the future voting layer (`gps-denied-onboard` Design Task #2); inventory always returns the most-recent row regardless of any future trust state.
- **Not covered**: tile body download. This endpoint returns metadata only; callers fetch bodies via `GET /tiles/{z}/{x}/{y}`.
- **Not covered**: HTTP/3 / QUIC. Kestrel is set to `Http1AndHttp2`; the HTTP/3 plumbing requires ALPN + UDP verification that's deferred per AZ-505 scope.
- **Not covered**: browser-side multiplexing. h2c (HTTP/2 over plaintext) is not supported by mainstream browsers; only programmatic clients (httpx http2=True, .NET HttpClient with `HttpVersionPolicy.RequestVersionExact`) realize the HTTP/2 multiplexing benefit. Browser Leaflet wins come from the covering-index hot path, not multiplexing.
- **Not covered**: PMTiles or tar/multipart bundle endpoints. Rejected by AZ-503 parent rationale (HTTP/2 multistream is sufficient).
- **Not covered**: write operations. Inventory is read-only; UAV writes go through `POST /api/satellite/upload` (`uav-tile-upload.md` v1.1.0).
## Versioning Rules
- **Patch (1.0.x)**: Documentation clarifications, additional invariants that do not change wire behavior.
- **Minor (1.x.0)**: Adding an optional response field that consumers may safely ignore (e.g., the future `estimatedBytes`); raising the entry cap; adding a third request form alongside the current two.
- **Major (2.0.0)**: Changing the response ordering rule; removing `present`; lowering the entry cap; making `flightId` required; adding voting / trust filtering to the read path.
## Test Cases
| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| ordering-mixed-present-absent | 25 coords, 12 seeded + 13 absent, interleaved | 25 entries in request order; 12 present (id/capturedAt/source populated), 13 absent (only locationHash populated) | AC-1 |
| most-recent-across-sources | Cell with `google_maps captured_at=T1` and `uav captured_at=T2 > T1`; coord request | `present=true`, `source='uav'`, `id` = UAV row's id | Inv-5 |
| validation-both-populated | Body with both `tiles` and `locationHashes` | HTTP 400 | Inv-1 |
| validation-neither-populated | Empty body or body with both fields empty | HTTP 400 | Inv-1 |
| validation-over-cap | 5001 entries | HTTP 400 | Inv-7 |
| auth-anonymous | No Bearer token | HTTP 401 | Standard `.RequireAuthorization()` baseline |
| perf-2500-tiles | 2500-entry request against populated DB | p95 ≤ 1000 ms over 20 calls | AC-4 |
| http2-multiplexing | 20 concurrent `GET /tiles/{z}/{x}/{y}` over a single H2 connection | All 20 responses `HttpResponseMessage.Version == 2.0`; ETag + Cache-Control preserved | AC-5; cross-references `tile-inventory.md` because Kestrel H2 is configured in the same PBI |
## Change Log
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-12 | Initial contract — `POST /api/satellite/tiles/inventory` with Form A (coords) / Form B (hashes) XOR validation, 5000-entry cap, most-recent-across-sources selection rule, ordering invariant. Produced by AZ-505. | autodev (Step 10, cycle 6) |