Replaces the 501 stub at POST /api/satellite/upload with a multipart
batch endpoint that ingests UAV-captured tiles, runs each item through
a 5-rule quality gate, and persists accepted tiles via the AZ-484
multi-source storage path with source='uav'.
Quality gate (in fixed order, first failure wins): JPEG format
(content-type + magic), size band 5 KiB-5 MiB, exact 256x256
dimensions, captured-at age (no future >30 s skew, no older than
7 days), luminance variance on 32x32 downsample. Closed reject-reason
enumeration in v1.0.0 contract.
Authorization: custom PermissionsRequirement / PermissionsAuthorization
Handler that reads the JWT `permissions` claim (tolerates both
repeated-string and JSON-array shapes). Endpoint protected by
RequiresGpsPermission policy; 401 without token, 403 without GPS perm.
Persistence: file-first to ./tiles/uav/{z}/{x}/{y}.jpg, then
ITileRepository.InsertAsync UPSERT (per-source UPSERT contract from
AZ-484). Per-item failures reported in response without aborting the
batch. Kestrel MaxRequestBodySize and FormOptions limits set to
MaxBatchSize x MaxBytes (default 100 x 5 MiB = 500 MiB).
New frozen contract: _docs/02_document/contracts/api/uav-tile-upload.md
v1.0.0. PT-08 NFR added to performance-tests.md as Deferred (harness
work tracked in PT-07 leftover, per AZ-488 § Risk 4).
Tests: 11 quality-gate unit tests, 5 handler unit tests, 3 file-path
unit tests, 12 permission-handler unit tests, 7 integration tests
(AC-1..AC-6, AC-8). All 253 unit tests + smoke integration suite
green.
Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
Contract: uav-tile-upload
Component: WebApi (SatelliteProvider.Api) producing rows via TileDownloader (SatelliteProvider.Services.TileDownloader)
Producer task: AZ-488 — _docs/02_tasks/done/AZ-488_uav_tile_upload.md
Consumer tasks: gps-denied-onboard, mission planner UI, any future UAV-equipped client
Version: 1.0.0
Status: frozen
Last Updated: 2026-05-11
Purpose
Defines the HTTP contract for the POST /api/satellite/upload batch endpoint that ingests UAV-captured satellite tiles, runs each item through a 5-rule quality gate, and persists accepted rows under the tile-storage v1.0.0 data contract with source='uav'.
Endpoint
POST /api/satellite/upload
Content-Type: multipart/form-data
Authorization: Bearer <JWT>
The request MUST carry a valid JWT (AZ-487) AND the permissions claim MUST contain GPS. Anonymous requests are rejected with HTTP 401; authenticated requests without GPS are rejected with HTTP 403.
Request shape
Multipart form fields (case-sensitive part names):
| Part | Type | Required | Description |
|---|---|---|---|
metadata |
JSON string | yes | Document of shape { "items": [ <UavTileMetadata>, ... ] }. See the table below for the per-item schema. |
files |
binary (repeating) | yes | One JPEG file per metadata item. Files are correlated to metadata entries by ordinal index (file index i ↔ metadata.items[i]). Each part MUST be sent under the form field name files. |
UavTileMetadata (per item)
| Field | Type | Required | Description | Constraints |
|---|---|---|---|---|
latitude |
number | yes | Geographic latitude of the tile center | WGS-84 decimal degrees |
longitude |
number | yes | Geographic longitude of the tile center | WGS-84 decimal degrees |
tileZoom |
integer | yes | Slippy Map zoom level | Must satisfy the same zoom-level policy as the existing tile pipeline (see MapConfig.AllowedZoomLevels) |
tileSizeMeters |
number | yes | Tile size in meters at the captured latitude | Producer-supplied |
capturedAt |
string (ISO-8601 UTC) | yes | Moment of UAV image capture | Must satisfy the captured-at rule (see Quality Gate, Rule 4) |
Field names are camelCase. Property-name matching is case-insensitive on read.
Constraints
- The number of
filesMUST equal the number ofmetadata.itemsentries (1:1 correlation by ordinal index). Mismatch → HTTP 400. metadata.items.lengthMUST NOT exceedUavQualityConfig.MaxBatchSize(default100). Oversize → HTTP 400.- The total request body size is capped at
MaxBatchSize × MaxBytesby Kestrel'sMaxRequestBodySizeand the formMultipartBodyLengthLimit. Requests beyond this cap are rejected at the framework layer (HTTP 413/400).
Quality Gate (5 rules)
Each item is evaluated in this fixed order. The first failing rule produces the reported reason; subsequent rules are not evaluated for that item.
| # | Rule | Failure condition | Reason code |
|---|---|---|---|
| 1 | Format | Content-Type of the file part is not image/jpeg (case-insensitive, allowing trailing parameters) OR the file's first 3 bytes are not FF D8 FF |
INVALID_FORMAT |
| 2 | Size band | bytes.length is outside [UavQualityConfig.MinBytes, UavQualityConfig.MaxBytes] (defaults: 5 KiB … 5 MiB) |
SIZE_OUT_OF_BAND |
| 3 | Dimensions | Image width OR height ≠ MapConfig.TileSizePixels (default 256). Strict equality, no tolerance |
WRONG_DIMENSIONS |
| 4a | Captured-at future | capturedAt > now + UavQualityConfig.CapturedAtFutureSkewSeconds (default 30s) |
CAPTURED_AT_FUTURE |
| 4b | Captured-at age | capturedAt < now - UavQualityConfig.MaxAgeDays (default 7 days) |
CAPTURED_AT_TOO_OLD |
| 5 | Blank / uniform | Pixel-luminance variance on a downsampled (default 32×32, configurable via UavQualityConfig.LuminanceSampleSize) version of the image is below UavQualityConfig.MinLuminanceVariance (default 10.0) |
IMAGE_TOO_UNIFORM |
If the file decode itself fails for the variance check, the item is rejected with INVALID_FORMAT (the file is not a real JPEG even though the header bytes matched).
Storage failures while persisting an accepted item (e.g., disk full, unwritable path) are surfaced as STORAGE_FAILURE so the client can retry that specific item without re-uploading the whole batch.
Reject-reason enumeration (closed)
| Code | Source rule | When the reason fires |
|---|---|---|
INVALID_FORMAT |
Rule 1 + Rule 5 decode safety net | Wrong content-type, wrong magic bytes, or undecodable bytes |
SIZE_OUT_OF_BAND |
Rule 2 | Byte length outside [MinBytes, MaxBytes] |
WRONG_DIMENSIONS |
Rule 3 | Width or height ≠ MapConfig.TileSizePixels |
CAPTURED_AT_FUTURE |
Rule 4a | capturedAt is more than CapturedAtFutureSkewSeconds in the future |
CAPTURED_AT_TOO_OLD |
Rule 4b | capturedAt is older than MaxAgeDays |
IMAGE_TOO_UNIFORM |
Rule 5 | Luminance variance below MinLuminanceVariance |
METADATA_MISSING |
Validation gate | Per-item metadata could not be matched (reserved; not currently surfaced because batch mismatch is reported as an envelope error) |
STORAGE_FAILURE |
Persistence path | The image bytes could not be written to disk or the repository insert raised an IO error |
Adding a new code is a minor contract version bump per the Versioning Rules below. Removing or renaming a code is major.
Response shape
HTTP 200 — per-item results
{
"items": [
{ "index": 0, "status": "accepted", "tileId": "11111111-...", "rejectReason": null, "rejectDetails": null },
{ "index": 1, "status": "rejected", "tileId": null, "rejectReason": "WRONG_DIMENSIONS", "rejectDetails": null }
]
}
| Field | Type | Description |
|---|---|---|
items |
array | One result per input item, in the same order as metadata.items |
items[].index |
integer | Ordinal index of the item in the request batch |
items[].status |
string | "accepted" or "rejected" (closed enumeration) |
items[].tileId |
UUID or null | When accepted, the persisted tiles.id value; null when rejected |
items[].rejectReason |
string or null | Reason code (see table above) when rejected; null when accepted |
items[].rejectDetails |
string or null | Optional human-readable detail; MUST NOT leak server paths, exception types, or internal identifiers |
HTTP 400 — envelope error (RFC 7807 application/problem+json)
Returned when the request itself is malformed:
metadatafield absent, empty, or not valid JSONmetadata.itemsempty or nullmetadata.items.length≠files.lengthmetadata.items.length>MaxBatchSize
The 5-rule per-item quality gate never produces a 400; per-item failures always surface in the HTTP 200 response array.
HTTP 401 — missing or invalid JWT (from AZ-487)
HTTP 403 — JWT present but permissions claim does not include GPS
Persistence semantics
- Accepted items are persisted via
ITileRepository.InsertAsync(the per-source UPSERT path established in AZ-484). Thetilesrow carriessource='uav'andcaptured_atfrom the request. - A UAV upload for a cell that already has a
google_mapsrow coexists with that row (pertile-storage.mdInv-3). The most-recent row across sources wins on read. - A second UAV upload for the same cell UPSERTs the existing
uavrow, updatingfile_path,captured_at,updated_atand overwriting the JPEG bytes on disk.
File-path layout
- UAV files:
{StorageConfig.TilesDirectory}/uav/{tile_zoom}/{tile_x}/{tile_y}.jpg - Google Maps files: unchanged from the pre-AZ-488 layout (grandfathered, no migration ships with this contract)
tile_x and tile_y are derived server-side from (latitude, longitude, tile_zoom) via GeoUtils.WorldToTilePos; the client cannot influence the on-disk path beyond providing valid coordinates.
Concurrency
- Per-source UPSERT in the DB is the authoritative serialization point for the same cell.
- Two concurrent UAV uploads for the exact same
(tile_zoom, tile_x, tile_y)cell may race on the on-disk bytes; the final file is whichever upload wrote last, and the final DB row is whichever upload UPSERTed last. Per-sourcefile_pathis identical in this race so the file and row remain self-consistent — there is no orphan reference even under contention.
Invariants
- Inv-1: Status strings are limited to
"accepted"and"rejected". New status values require a contract minor bump. - Inv-2: Reject reasons are drawn from the closed enumeration above. New reasons require a contract minor bump.
- Inv-3: Persisted rows are inserted only via
ITileRepository.InsertAsync. No new write path is introduced by this contract. - Inv-4:
rejectDetailsMUST NOT contain server-side file paths, .NET exception type names, or internal identifiers. Operators consume these messages directly. - Inv-5: Item ordering in the response matches item ordering in
metadata.items.
Non-Goals
- Not covered: Asynchronous or queued processing — the batch is synchronous. Larger batches than
MaxBatchSizerequire a new contract (likely async + status-poll) and a major version bump. - Not covered: Geofence filtering. UAV uploads outside any operational area still succeed; geofence enforcement is a follow-up PBI.
- Not covered: Per-tile photogrammetry metadata (altitude, focal length, sensor dimensions). Excluded from v1.0.0 by user choice during planning.
- Not covered: Streaming uploads. The endpoint reads each
IFormFileinto memory before validation. - Not covered: Image storage outside the local filesystem (S3, GCS). Matches the existing Google Maps producer behavior.
- Not covered: Compression or re-encoding of accepted JPEGs. Stored as received.
Versioning Rules
- Patch (1.0.x): Documentation clarifications; expanded test cases; tightening of
rejectDetailscontent; tuning default thresholds without changing the reject-reason enum. - Minor (1.x.0): Adding a new
rejectReasoncode; adding optional metadata fields with backward-compatible defaults; relaxing rule thresholds in a backward-compatible way; introducing a new permission (e.g.,SAT). - Major (2.0.0): Changing the request envelope shape; removing or renaming a reject-reason code; changing the persistence path; switching to async/status-poll; changing the
permissionsclaim required.
Each version bump requires updating the Change Log and notifying every consumer listed in the header.
Test Cases
| Case | Inputs | Expected | Reference |
|---|---|---|---|
| happy-1-item | 1× valid 256×256 JPEG, captured_at = now, GPS token | HTTP 200, accepted, row with source='uav', file at ./tiles/uav/{z}/{x}/{y}.jpg |
AC-1 |
| mixed-batch | 3 items: valid, 512×512, non-JPEG bytes | HTTP 200, results = [accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT], 1 new row |
AC-2 |
| multi-source | Pre-seeded google_maps row at T1; UAV upload for same cell at T2 > T1 |
HTTP 200, both rows persist, subsequent read returns the UAV row | AC-3 |
| same-source-upsert | UAV row at T1; second UAV upload at T2 > T1 |
HTTP 200, exactly one uav row remains, refreshed captured_at and bytes |
AC-4 |
| no-token | No Authorization header |
HTTP 401 | AC-5 |
| no-permission | Token with permissions=["FL"] |
HTTP 403 | AC-6 |
| oversized | metadata.items.length = MaxBatchSize + 1 |
HTTP 400 envelope error | AC-8 |
Change Log
| Version | Date | Change | Author |
|---|---|---|---|
| 1.0.0 | 2026-05-11 | Initial contract — batch UAV upload endpoint, 5-rule quality gate, per-source UPSERT, closed reject-reason enum, GPS-permission requirement. Produced by AZ-488. | autodev (cycle 2 step 10) |