Files
satellite-provider/_docs/02_document/contracts/api/tile-inventory.md
T
Oleksandr Bezdieniezhnykh c74a2339aa
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[AZ-505] AC-5 fix: enable TLS for HTTP/2 via ALPN
Kestrel with HttpProtocols.Http1AndHttp2 on a plaintext listener
silently downgrades to HTTP/1.1-only (logs "HTTP/2 is not enabled
... TLS is not enabled"), so AC-5's multiplexed-GET test failed
with HTTP_1_1_REQUIRED. ALPN cannot run over plaintext, so the
fix switches the dev listener to TLS on https://+:8080:

- scripts/run-tests.sh generates a self-signed dev cert idempotently
  (./certs/api.pfx + api.crt) via openssl in an alpine container;
  certs/ is gitignored.
- docker-compose.yml binds Kestrel to ASPNETCORE_URLS=https://+:8080
  with Kestrel__Certificates__Default__Path bound to the .pfx.
- docker-compose.tests.yml mounts api.crt into the integration-tests
  container's CA store and runs update-ca-certificates so HttpClient
  trusts the cert transparently; default API_URL is now https://api:8080.
- Drop the obsolete Http2UnencryptedSupport AppContext switch from
  Http2MultiplexingTests; ALPN over TLS handles negotiation.

Test-data fixes caught on the post-TLS rerun (independent of the TLS
switch but surfaced together):

- Http2MultiplexingTests: switch slippy coords from (154321, 95812)
  -- which Google Maps returns 404 for -- to (158485, 91707), the
  slippy projection of (47.461747, 37.647063) already exercised by
  JwtIntegrationTests.
- TileInventoryTests + LeafletPathIndexOnlyTests: SpecifyKind to
  Unspecified at the binding site for raw Npgsql seed paths writing
  into tiles.captured_at / created_at / updated_at (TIMESTAMP without
  tz). Npgsql v6+ refuses Kind=Utc into plain timestamp columns;
  production goes through Dapper and never hits this code path.
- MigrationTests Az503NewUniqueIndexCoversIntegerKeyAndFlightId:
  accept either idx_tiles_location_hash (migration 014) or its
  AZ-505 successor tiles_leaflet_path (migration 015) -- both have
  location_hash as the leading column, which is the AC-9 intent.

Docs updated to reflect the TLS+ALPN path: tile-inventory.md
Non-Goals, modules/api_program.md, module-layout.md, the AZ-505
task spec's Risk 3, and the cycle 6 implementation + completeness
reports. The full integration test suite passes (mode=full, exit 0).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 22:19:26 +03:00

9.8 KiB
Raw Blame History

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.

// 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

{
  "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 UDP verification that's deferred per AZ-505 scope.
  • Not covered: production deployment topology. Dev Kestrel runs Http1AndHttp2 directly over TLS on port 8080 with a self-signed cert (./certs/api.pfx, generated by scripts/run-tests.sh) so ALPN can advertise h2 — browsers and programmatic clients (httpx http2=True, .NET HttpClient with HttpVersionPolicy.RequestVersionExact) both multiplex over a single TLS connection. In production, TLS is expected to terminate at the ingress (Envoy / nginx / ALB) and Kestrel runs HTTP/2 cleartext behind it; AZ-505 verifies the protocol multiplexing semantics here, not the production termination layer.
  • 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)