AZ-794: rename inventory wire fields tileZoom/tileX/tileY -> z/x/y to match the slippy-map URL convention. Contract bumped to v2.0.0. AZ-795: shared validation infrastructure -- FluentValidation + ValidationEndpointFilter + GlobalValidatorConfig (camelCase paths). GlobalExceptionHandler now converts JsonException (UnmappedMember + JsonRequired) into RFC 7807 ValidationProblemDetails. JSON layer hardened with UnmappedMemberHandling.Disallow + camelCase naming policy. New error-shape.md contract. AZ-796: InventoryRequestValidator covers 9 rules (XOR tiles vs locationHashes, cap 1000, z 0..22, x/y in slippy bounds, hash length/charset). 16 unit tests + 16 integration tests + a manual curl probe script. Adjacent fixes uncovered by the new strict layer: - IdempotentPostTests RoutePoint payload corrected to lat/lon (the DTO has used JsonPropertyName for ages; previously silently ignored under PascalCase fallback). - TileInventoryTests slippy x/y reduced to fit z=18 bounds. - docker-compose.yml host port for Postgres moved 5432 -> 5433 to avoid sibling-project conflict; appsettings.Development + README + AGENTS + architecture + containerization docs aligned. New coderule (suite + repo): API consumer-facing OpenAPI descriptions must not contain task IDs, contract filenames, or version-bump history -- internal change tracking belongs in commits/contract docs/changelogs. Existing offending descriptions in Program.cs cleaned up. Co-authored-by: Cursor <cursoragent@cursor.com>
14 KiB
Contract: tile-inventory
Component: WebApi (SatelliteProvider.Api) producing rows via TileDownloader (SatelliteProvider.Services.TileDownloader)
Producer task: AZ-505 — _docs/02_tasks/done/AZ-505_tile_inventory_http2_leaflet_index.md (initial); AZ-794 — _docs/02_tasks/done/AZ-794_inventory_field_rename_osm.md (v2.0.0 wire-format rename); AZ-796 — _docs/02_tasks/done/AZ-796_inventory_endpoint_validation.md (FluentValidation + ProblemDetails wiring)
Consumer tasks: gps-denied-onboard AZ-316 (c11_tile_downloader), future mission-planner UI cache-sizing flows
Version: 2.0.0
Status: frozen
Last Updated: 2026-05-22
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 (validation enforced by InventoryRequestValidator per AZ-796).
// Form A — coord-keyed (v2.0.0; AZ-794 renamed tileZoom/tileX/tileY → z/x/y)
{
"tiles": [
{ "z": 18, "x": 154321, "y": 95812 },
{ "z": 18, "x": 154322, "y": 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 z, x, y. |
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).
Strict parsing: unknown fields at root or nested under any tile entry are rejected with HTTP 400 by JsonSerializerOptions.UnmappedMemberHandling.Disallow (AZ-795). The error body conforms to error-shape.md v1.0.0.
TileCoord (per entry under tiles)
| Field | Type | Required | Description | Range |
|---|---|---|---|---|
z |
integer | yes | Slippy-map zoom level | 0–22 (matches tile_zoom schema constraint) |
x |
integer | yes | Slippy-map tile column | 0 ≤ x < 2^z |
y |
integer | yes | Slippy-map tile row | 0 ≤ y < 2^z |
Response body
// v2.0.0 — coord triple uses z/x/y (AZ-794)
{
"results": [
{
"z": 18,
"x": 154321,
"y": 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
},
{
"z": 18,
"x": 154322,
"y": 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 |
|---|---|---|---|
z |
integer | always (Form A); zeroed (Form B) | Echoes the request entry's z when input was tiles; 0 when input was locationHashes (caller already knows the cell). |
x |
integer | always (Form A); zeroed (Form B) | Same as z. |
y |
integer | always (Form A); zeroed (Form B) | Same as z. |
locationHash |
UUIDv5 | always | UUIDv5(TileNamespace, "{z}/{x}/{y}"). 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 |
Error shape
All 400 responses conform to _docs/02_document/contracts/api/error-shape.md v1.0.0. Both wire-format failures (unknown fields, type mismatches; via the JSON deserializer) and business-rule failures (XOR violation, missing z/x/y, out-of-range zoom; via InventoryRequestValidator) emit a ValidationProblemDetails body with an errors map keyed by JSON-path-style field names. Example:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"tiles[0].z": ["The z field is required."],
"tiles[1]": ["The JSON property 'tileZoom' could not be mapped to any .NET member contained in type 'TileCoord'."]
}
}
The first key shows a FluentValidation rule miss; the second shows a deserializer rejection of the old v1.x field name (callers still on the old shape get an explicit failure, not a silent 200 with zeroed coordinates — exactly the behaviour AZ-794 + AZ-796 were filed to enforce after the AZ-777 Phase 1 Jetson probe).
Invariants
- Inv-1: Exactly one of
request.tilesandrequest.locationHashesis populated and non-empty. Both-populated → 400; both-empty → 400. - Inv-2:
len(response.results) == len(request.tiles)ORlen(request.locationHashes)— never less, never more. - Inv-3:
response.results[i].locationHashis deterministic fromrequest.tiles[i](UUIDv5 over"{z}/{x}/{y}"withUuidv5.TileNamespace) when Form A is used, or equalsrequest.locationHashes[i]when Form B is used. - Inv-4:
response.results[i].present == trueiff a row exists intileswithlocation_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 asITileRepository.GetByTileCoordinatesAsyncpertile-storagev2.0.0. - Inv-6: When
present=false,id/capturedAt/source/flightId/resolutionMPerPxare allnull. - Inv-7:
request.tiles.lengthandrequest.locationHashes.lengthMUST be ≤TileInventoryLimits.MaxEntriesPerRequest(5000); over the cap → 400. - Inv-8 (AZ-795 / AZ-796): Each
tiles[i].zMUST satisfy0 ≤ z ≤ 22. Eachtiles[i].xandtiles[i].yMUST satisfy0 ≤ value < 2^z. Out-of-range → 400 witherrors["tiles[i].z|x|y"]populated. - Inv-9 (AZ-795): Unknown fields at root or in any nested object are rejected with HTTP 400; the error key names the offending JSON path.
Non-Goals
- Not covered: byte-size hints (
estimatedBytes). Deferred until production profiling justifies the per-filestat()cost. - Not covered: voting / trust-promotion filtering. The
voting_statusfilter is part of the future voting layer (gps-denied-onboardDesign 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
Http1AndHttp2directly over TLS on port 8080 with a self-signed cert (./certs/api.pfx, generated byscripts/run-tests.sh) so ALPN can advertiseh2— browsers and programmatic clients (httpxhttp2=True, .NETHttpClientwithHttpVersionPolicy.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.mdv1.1.0). - Not covered: backward-compatibility shim for v1.0.0 (
tileZoom/tileX/tileY) field names. Per AZ-794 (Option 1 hard switch — single known consumer), v2.0.0 is the only accepted body shape; v1.x consumers receive HTTP 400 witherrors[*]: ["could not be mapped"]. There is no transitional accept-both period.
Versioning Rules
- Patch (2.0.x): Documentation clarifications, additional invariants that do not change wire behavior.
- Minor (2.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 (3.0.0): Changing the response ordering rule; removing
present; lowering the entry cap; makingflightIdrequired; adding voting / trust filtering to the read path; renaming thez/x/ytriple again.
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 + ValidationProblemDetails | Inv-1 + AZ-796 |
| validation-neither-populated | Empty body or body with both fields empty | HTTP 400 + ValidationProblemDetails | Inv-1 + AZ-796 |
| validation-over-cap | 5001 entries | HTTP 400 + ValidationProblemDetails | Inv-7 + AZ-796 |
| validation-missing-z | tiles: [{ x: 1, y: 1 }] |
HTTP 400 + errors["tiles[0].z"] populated |
Inv-8 + AZ-796 |
| validation-out-of-range-z | tiles: [{ z: 30, x: 1, y: 1 }] |
HTTP 400 + errors["tiles[0].z"] mentioning range |
Inv-8 + AZ-796 |
| validation-out-of-range-x | tiles: [{ z: 0, x: 5, y: 0 }] (2^0 = 1) |
HTTP 400 + errors["tiles[0].x"] populated |
Inv-8 + AZ-796 |
| validation-unknown-root-field | Body with unknownField: 42 plus tiles: [...] |
HTTP 400 + errors["unknownField"] |
Inv-9 + AZ-795 |
| validation-unknown-nested-field | tiles: [{ z: 18, x: 1, y: 1, foo: 42 }] |
HTTP 400 + errors["tiles[0].foo"] |
Inv-9 + AZ-795 |
| validation-old-field-name-tileZoom | tiles: [{ tileZoom: 18, tileX: 1, tileY: 1 }] (v1.x shape) |
HTTP 400 + errors["tiles[0].tileZoom"] ("could not be mapped") |
AZ-794 + Inv-9 |
| 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 |
|---|---|---|---|
| 2.0.0 | 2026-05-22 | BREAKING: per-entry coord triple renamed tileZoom/tileX/tileY → z/x/y (request tiles[i] and response results[i]) to align with the URL slippy-map convention already used by GET /tiles/{z}/{x}/{y}. AZ-794 ships as Option 1 hard switch; v1.x clients receive HTTP 400 with explicit "could not be mapped" errors. Adds Inv-8 (range constraints on z/x/y) + Inv-9 (unknown-field rejection); references error-shape.md v1.0.0 for the uniform 400 body shape. AZ-796 wires InventoryRequestValidator for Inv-1 / Inv-7 / Inv-8 enforcement. Cycle 7 — autodev Step 10. |
autodev (Step 10, cycle 7) |
| 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) |