# UAV tile upload endpoint with batch + 5-rule quality gate

**Task**: AZ-488_uav_tile_upload
**Name**: UAV tile upload endpoint
**Description**: Replace the 501 stub at `POST /api/satellite/upload` with a JWT-authenticated multipart batch endpoint that ingests UAV-captured satellite 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'`.
**Complexity**: 8 points (OVER 5 SP CAP — explicit user override accepted during planning)
**Dependencies**:
- AZ-487 (JWT validation baseline) — must merge first; this task adds permission-claim enforcement on top
- AZ-484 (Multi-source tile storage schema) — already merged; consumes the frozen `tile-storage` v1.0.0 contract
**Component**: WebApi (`SatelliteProvider.Api`) + TileDownloader (`SatelliteProvider.Services.TileDownloader` — for the quality gate + storage helper) + DataAccess (consumer only)
**Tracker**: AZ-488
**Epic**: AZ-483 (Multi-source tile storage + UAV upload)

## Problem

The Architecture Vision in `_docs/02_document/architecture.md` commits to a multi-producer tile model where Google Maps satellite imagery and UAV-captured imagery coexist per cell, with the most-recent across sources winning on read. AZ-484 delivered the storage half of that vision (schema + repository + Google Maps producer side). What's missing is the second producer: an HTTP endpoint that lets `gps-denied-onboard` and other UAV-equipped clients push freshly-captured tiles into the same `tiles` table. Without this endpoint, the multi-source design is theoretical — only Google Maps writes today, so the AZ-484 work has no second producer to validate against in production.

The endpoint must also prevent the obvious failure mode: a UAV upload with a blank, wrong-size, stale, or corrupt image silently displaces the better Google Maps imagery for that cell on the next read. That's what the quality gate is for.

## Outcome

- `POST /api/satellite/upload` accepts a multipart batch of UAV tiles in a single request, runs each item through a quality gate, persists the accepted ones via `ITileRepository.InsertAsync` with `source='uav'`, and returns per-item accept/reject results in a single HTTP 200 response.
- A UAV upload for a cell that already has a Google Maps row coexists with that row (per the AZ-484 contract Inv-3); subsequent reads return whichever has the higher `captured_at`.
- A second UAV upload for the same cell (same source) UPSERTs — exactly one `source='uav'` row per cell, refreshed `captured_at` and `file_path`.
- An unauthenticated request returns 401 (from AZ-487). A request with a valid JWT but missing the `GPS` permission claim returns 403.
- All five quality-gate rules are enforced; rejected items appear in the response with a machine-readable reason code so the client can log/retry.
- UAV files land at `./tiles/uav/{tile_zoom}/{tile_x}/{tile_y}.jpg`; existing Google Maps files stay at `./tiles/{tile_zoom}/{tile_x}/{tile_y}.jpg` (grandfathered — no migration).
- The frozen `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0 documents the request/response shape, status codes, and reject reason codes for downstream consumers.

## Scope

### Included

- New batch request DTO replacing `SatelliteProvider.Api.DTOs.UploadImageRequest`:
  - `UavTileBatchUploadRequest`: a multipart form binding with a JSON `metadata` field carrying an array of per-tile metadata records (`Latitude`, `Longitude`, `TileZoom`, `TileSizeMeters`, `CapturedAt`) plus an `IFormFileCollection` of the corresponding image files. Per-item correlation by ordinal index (file `[i]` corresponds to metadata `[i]`).
  - The pre-existing `UploadImageRequest` (single-image with photogrammetry fields like `Height`, `FocalLength`, `SensorWidth`, `SensorHeight`) is removed; nothing currently consumes it (the endpoint was a 501 stub).
- New response DTO `UavTileBatchUploadResponse`:
  - `Items: UavTileUploadResultItem[]` — per-item result.
  - `UavTileUploadResultItem`: `Index: int`, `Status: "accepted" | "rejected"`, `TileId: Guid?` (set when accepted), `RejectReason: string?` (set when rejected, machine-readable code), `RejectDetails: string?` (human-readable extra info; never leaks server internals).
- New handler `UavTileUploadHandler` (or equivalent service) in `SatelliteProvider.Services.TileDownloader`:
  - Iterates the batch.
  - Runs each item through the quality gate (see below) — short-circuits on the first violation, records the reason.
  - For accepted items: writes the JPEG to `./tiles/uav/{TileZoom}/{TileX}/{TileY}.jpg` (the directory is created on demand), constructs a `TileEntity` with `Source = TileSourceConverter.ToWireValue(TileSource.Uav)`, `CapturedAt` from the request, `FilePath` set to the new path; calls `ITileRepository.InsertAsync` (which UPSERTs per the 5-column unique key from AZ-484).
  - Returns `UavTileBatchUploadResponse`.
- Quality gate `UavTileQualityGate` in `SatelliteProvider.Services.TileDownloader`, exposed via interface for testability:
  - **Rule 1 (Format)**: image content-type is `image/jpeg` AND magic bytes (`FF D8 FF`) confirm JPEG. Reject reason: `INVALID_FORMAT`.
  - **Rule 2 (Size band)**: `5 KiB ≤ image bytes ≤ 5 MiB`. Bounds configurable via `UavQualityConfig.MinBytes` / `MaxBytes`. Reject reason: `SIZE_OUT_OF_BAND`.
  - **Rule 3 (Dimensions)**: image width AND height equal `MapConfig.TileSizePixels` (default 256). Strict equality; no tolerance. Reject reason: `WRONG_DIMENSIONS`.
  - **Rule 4 (Captured-at age)**: `captured_at` is not in the future (allow ≤ 30s clock skew) AND not older than 7 days from `DateTime.UtcNow` (configurable via `UavQualityConfig.MaxAgeDays` default 7). Reject reasons: `CAPTURED_AT_FUTURE`, `CAPTURED_AT_TOO_OLD`.
  - **Rule 5 (Blank/uniform heuristic)**: compute pixel luminance variance via ImageSharp on a downsampled (e.g., 32×32) version of the image. Reject if variance < threshold (`UavQualityConfig.MinLuminanceVariance` default ~10 on 0-255 scale). Reject reason: `IMAGE_TOO_UNIFORM`.
  - Rule order: 1, 2, 3, 4, 5. Rule 1 (format) runs first because it's cheapest and gates the others; Rule 5 runs last because it's the most expensive (decode + downsample).
- Permission enforcement on the endpoint: `.RequireAuthorization(policy => policy.RequireClaim("permissions", "GPS"))` (or equivalent — match the suite-wide claim shape; if `permissions` is a string-array claim, use a `ClaimsAuthorizationRequirement` that checks array membership).
- New configuration class `SatelliteProvider.Common.Configs.UavQualityConfig`:
  - `MinBytes` (int, default `5 * 1024`)
  - `MaxBytes` (int, default `5 * 1024 * 1024`)
  - `MaxAgeDays` (int, default `7`)
  - `MinLuminanceVariance` (double, default `10.0`)
  - `MaxBatchSize` (int, default `100` — see Constraints)
  - Wired in `Program.cs` via `builder.Services.Configure<UavQualityConfig>(builder.Configuration.GetSection("UavQuality"));`.
- `Program.cs` updates:
  - Replace the 501 `UploadImage` handler with the new handler invocation.
  - Update endpoint mapping: `app.MapPost("/api/satellite/upload", UploadUavTileBatch).Accepts<UavTileBatchUploadRequest>("multipart/form-data").RequireAuthorization(policy => policy.RequireClaim("permissions", "GPS")).WithOpenApi(...)`.
  - Keep `.DisableAntiforgery()` (matches existing endpoint stub; multipart batch with JWT auth doesn't need antiforgery).
- Per-source file-path strategy:
  - UAV: `./tiles/uav/{TileZoom}/{TileX}/{TileY}.jpg` — new sub-tree, created on demand.
  - Google Maps: stays at `./tiles/{TileZoom}/{TileX}/{TileY}.jpg` — grandfathered, no file migration. The path itself implicitly identifies the source for legacy rows; the `tiles.source` column is the authoritative source marker for new code.
  - `TileService.BuildTileEntity` (Google Maps producer side, AZ-484) requires no change — it already writes to the bare `./tiles/{z}/{x}/{y}.jpg` path.
- Documentation:
  - `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0 — new contract file. Status `frozen` upon implementation completion. Includes: request shape, response shape, reject reason codes (closed enumeration), HTTP status codes, auth + permission requirements, file-path layout, per-source UPSERT semantics referenced from `tile-storage.md`.
  - `_docs/02_document/architecture.md` § Architecture Vision: brief mention that UAV ingestion is now live as the second producer.
  - `_docs/02_document/glossary.md`: add `UAV Tile Upload`, `Quality Gate`, and the 5 reject-reason codes as defined terms.
  - `_docs/02_document/components/01_web_api/description.md`: document the new endpoint + permission requirement.
  - `_docs/02_document/components/03_tile_downloader/description.md`: document the new `UavTileQualityGate` and the per-source file-path layout.
  - `_docs/02_document/data_model.md`: note that `file_path` semantics now depend on `source` (`uav` rows live under `./tiles/uav/`, `google_maps` rows live at the bare `./tiles/` root).
- Tests — Unit:
  - Each quality-gate rule in isolation (1 happy path + ≥1 reject path per rule = 10+ test methods).
  - Quality gate rule ordering test (when multiple rules would reject, the first applicable reason is reported).
  - `UavTileBatchUploadRequest` DTO model-binding round-trip (multipart parse + JSON metadata parse).
  - File-path construction test (UAV path matches `./tiles/uav/{z}/{x}/{y}.jpg` format; safe against path-traversal in tile coordinates — coordinates are typed `int` so this is a smoke test, not a deep injection test).
- Tests — Integration:
  - `UavUploadTests.HappyPath_BatchOfTwoTiles_Returns200_PersistsRows`: upload a 2-item batch with all-good tiles; assert HTTP 200, both items accepted, both rows present in `tiles` with `source='uav'`, file paths exist on disk.
  - `UavUploadTests.MixedBatch_PartialReject_Returns200_WithPerItemResults`: upload a 3-item batch where item 1 is good, item 2 fails dimensions, item 3 fails JPEG magic; assert HTTP 200, results array has correct per-item statuses + reason codes.
  - `UavUploadTests.MultiSourceCoexistence_AZ484_Cycle2`: pre-seed the cell with a `google_maps` row; upload a `uav` tile for the same cell with later `captured_at`; assert both rows exist in `tiles`, the `uav` row wins on subsequent `GetByTileCoordinatesAsync` (validates AZ-484 selection rule under live multi-source load).
  - `UavUploadTests.SameSourceUpsert_AZ484_Cycle2`: upload a UAV tile for a cell, then upload a second UAV tile for the same cell with later `captured_at`; assert exactly one `source='uav'` row remains, `captured_at` and `file_path` updated, file on disk overwritten.
  - `UavUploadTests.NoToken_Returns401`: unauthenticated upload returns 401 (validates AZ-487 coverage extends to this endpoint).
  - `UavUploadTests.ValidTokenWithoutGpsPermission_Returns403`: JWT with `permissions: ["FL"]` (no GPS) returns 403.
  - `UavUploadTests.ValidTokenWithGpsPermission_Returns200`: JWT with `permissions: ["GPS"]` proceeds normally.
  - `UavUploadTests.OversizedBatch_Returns400`: batch with > `MaxBatchSize` items returns 400 with envelope error (not a per-item reject — the envelope itself is malformed).

### Excluded

- Geofence-whitelist quality rule — explicitly removed during planning ("remove this gate for now"). May re-emerge as a follow-up PBI if operations finds UAV uploads landing in unexpected regions.
- Async / queued processing — the batch is sync. If batch-size limits become painful, async + status-poll is a follow-up redesign, not part of this PBI.
- File-path migration for existing `google_maps` tiles — explicit user choice (grandfather); no migration ships in this PBI.
- Multi-image tile fusion (averaging UAV + Google Maps for the same cell) — out of scope; the AZ-484 selection rule (most-recent winner) remains the only resolution.
- New `permissions` claim values — uses existing `GPS`. If `SAT` (or similar) is needed later, it's a coordination task with the admin team, not a code change here.
- UAV-specific photogrammetry metadata (Height, FocalLength, SensorWidth, SensorHeight from the old `UploadImageRequest`) — explicitly removed from scope by user during planning ("not necessary for now"). The legacy DTO is deleted with the stub.
- Image storage outside the local filesystem (S3, GCS, etc.) — out of scope; matches existing google_maps behavior.
- Compression / re-encoding of accepted UAV JPEGs — stored as-received.

## Acceptance Criteria

**AC-1: Happy-path single-item batch persists with source='uav'**
Given an authenticated request with a valid `GPS` permission claim
And a 1-item batch with a 256×256 JPEG, captured_at = now, size 50 KB
When the request is sent to `POST /api/satellite/upload`
Then the response is HTTP 200, the item is `accepted`, `tile_id` is returned, the `tiles` table has a new row with `source='uav'`, `captured_at` matches the request, and the file exists at `./tiles/uav/{z}/{x}/{y}.jpg`.

**AC-2: Multi-item batch with partial reject returns per-item results**
Given an authenticated request with a valid `GPS` permission claim
And a 3-item batch where: item 1 is valid; item 2 has dimensions 512×512 (wrong); item 3 has bytes that don't start with the JPEG magic
When the request is sent
Then the response is HTTP 200, items 2 and 3 are `rejected` with reasons `WRONG_DIMENSIONS` and `INVALID_FORMAT` respectively, item 1 is `accepted` with a `tile_id`, and exactly one new row appears in `tiles` (item 1 only).

**AC-3: Multi-source coexistence with Google Maps**
Given a cell at `(lat=L, lon=Ln, tile_zoom=18, tile_size_meters=200)` already has a `source='google_maps'` row with `captured_at=T1`
When a UAV upload arrives for the same cell with `captured_at=T2 > T1`
Then both rows exist in `tiles` after upload (no overwrite), and a subsequent `GetByTileCoordinatesAsync` returns the `source='uav'` row (per the AZ-484 selection rule).

**AC-4: Same-source UPSERT replaces UAV row**
Given a cell has a `source='uav'` row with `captured_at=T1` and `file_path=./tiles/uav/{z}/{x}/{y}.jpg`
When a second UAV upload arrives for the same cell with `captured_at=T2 > T1`
Then exactly one `source='uav'` row remains for that cell, `captured_at=T2`, `file_path` is updated (or unchanged if the path is identical, which it is in this case), the JPEG on disk is overwritten with the new bytes, and any pre-existing `source='google_maps'` row is untouched.

**AC-5: Unauthenticated request returns 401**
Given the API is running with AZ-487 in place
When `POST /api/satellite/upload` is called without an `Authorization` header
Then the response is HTTP 401 and no row or file is created.

**AC-6: Authenticated but missing permission returns 403**
Given an authenticated request with `permissions: ["FL"]` (no `GPS`)
When `POST /api/satellite/upload` is called with an otherwise-valid batch
Then the response is HTTP 403 and no row or file is created.

**AC-7: Quality-gate rule enforcement (per rule)**
Given an authenticated request with `GPS` permission
When the batch contains an item violating any single quality rule
Then that item is `rejected` with the corresponding reason code:
  - 7a: non-JPEG content-type or wrong magic bytes → `INVALID_FORMAT`
  - 7b: bytes < `MinBytes` or > `MaxBytes` → `SIZE_OUT_OF_BAND`
  - 7c: width or height ≠ `MapConfig.TileSizePixels` → `WRONG_DIMENSIONS`
  - 7d: `captured_at` > now + 30s → `CAPTURED_AT_FUTURE`; `captured_at` < now − `MaxAgeDays` → `CAPTURED_AT_TOO_OLD`
  - 7e: pixel luminance variance below `MinLuminanceVariance` → `IMAGE_TOO_UNIFORM`

**AC-8: Oversized batch returns 400**
Given an authenticated request with `GPS` permission
And a batch with `MaxBatchSize + 1` items
When the request is sent
Then the response is HTTP 400 with an envelope error indicating batch-size violation; no row or file is created.

**AC-9: Contract documentation matches implementation**
Given the implementation is complete
When `_docs/02_document/contracts/api/uav-tile-upload.md` is inspected
Then it has Status `frozen`, Version `1.0.0`, the documented request/response shape matches the actual DTOs, the reject-reason enum matches the implemented codes exactly, and it cross-references `_docs/02_document/contracts/data-access/tile-storage.md`.

**AC-10: Existing tests continue to pass**
Given AZ-487 + AZ-488 are merged
When `scripts/run-tests.sh --full` runs
Then all unit tests + smoke tests + new AZ-487 JWT tests + new AZ-488 UAV tests pass; no AZ-484 integration test regresses (validates that the multi-source storage continues to behave per its frozen contract under live UAV write load).

## Non-Functional Requirements

**Performance**
- Per-item quality-gate cost: target < 50 ms for the typical 256×256 / 50 KB JPEG. Rule 5 (luminance variance) dominates due to the decode; downsample to 32×32 BEFORE variance computation to keep the cost bounded.
- Endpoint p95 end-to-end: target < 2 s for a 10-item batch on the dev hardware. Defer formal perf measurement to the cycle's Step 15 (Performance Test); add a PT-08 NFR to `_docs/02_document/tests/performance-tests.md` with the matching runner-script entry IN THE SAME COMMIT (per cycle 1 retro lesson — see `_docs/06_metrics/retro_2026-05-11.md` Action 2).

**Compatibility**
- Replaces a 501 stub — no real consumer was using the old shape, so the DTO change is not a breaking change in practice.
- Adds the contract `uav-tile-upload.md` v1.0.0 — frozen on merge; future shape changes follow the contract's Versioning Rules.
- Coexists with the AZ-484 frozen `tile-storage` v1.0.0 contract; this PBI is a CONSUMER of that contract on the write side.

**Reliability**
- Per-item failures (e.g., disk write failure mid-batch) MUST be reported in the per-item result with a clear reason — never silently dropped, never propagated to fail the entire batch unless the failure is envelope-level (deserialization, batch-size violation, auth).
- The DB row and the on-disk file must be written in the order: file first, then row (so a partial failure leaves an orphan file rather than a db-row pointing at nothing). Document the orphan-file cleanup as an out-of-scope ops concern.
- Multi-source coexistence behavior MUST match the frozen `tile-storage.md` v1.0.0 contract Inv-3 (per-source UPSERT) exactly — validated by `UavUploadTests.MultiSourceCoexistence_AZ484_Cycle2` and `SameSourceUpsert_AZ484_Cycle2`.

**Security**
- Permission claim check is mandatory; do NOT add a "skip permission check in dev" flag.
- Reject-reason `RejectDetails` strings MUST NOT leak server-side paths, exception types, or internal identifiers (per `_docs/05_security/owasp_review.md` A05/A06 guidance).
- File-path construction takes integer coordinates only — no string concatenation of caller-supplied paths.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-7a | `UavTileQualityGate.Validate` with content-type `image/png` | Reject with `INVALID_FORMAT` |
| AC-7a | `UavTileQualityGate.Validate` with content-type `image/jpeg` but wrong magic bytes | Reject with `INVALID_FORMAT` |
| AC-7a | `UavTileQualityGate.Validate` with valid JPEG | Rule 1 passes; falls through to rule 2 |
| AC-7b | `UavTileQualityGate.Validate` with bytes < `MinBytes` | Reject with `SIZE_OUT_OF_BAND` |
| AC-7b | `UavTileQualityGate.Validate` with bytes > `MaxBytes` | Reject with `SIZE_OUT_OF_BAND` |
| AC-7c | `UavTileQualityGate.Validate` with 512×512 image | Reject with `WRONG_DIMENSIONS` |
| AC-7c | `UavTileQualityGate.Validate` with 256×256 image | Rule 3 passes |
| AC-7d | `UavTileQualityGate.Validate` with `captured_at = now + 1h` | Reject with `CAPTURED_AT_FUTURE` |
| AC-7d | `UavTileQualityGate.Validate` with `captured_at = now - 8 days` (default `MaxAgeDays=7`) | Reject with `CAPTURED_AT_TOO_OLD` |
| AC-7e | `UavTileQualityGate.Validate` with a uniform-grey JPEG | Reject with `IMAGE_TOO_UNIFORM` |
| AC-7e | `UavTileQualityGate.Validate` with a high-variance natural-image JPEG | Rule 5 passes |
| AC-2 | Quality-gate rule ordering: image that fails BOTH rule 1 (wrong format) AND rule 3 (wrong dimensions) | First-failing rule (1) is the reported reason |
| AC-1 | `UavTileUploadHandler` end-to-end with mocked repository: 1-item happy-path batch | `InsertAsync` called once with `Source = "uav"`, `CapturedAt` from request, file path `./tiles/uav/{z}/{x}/{y}.jpg` |
| AC-2 | `UavTileUploadHandler` with 3-item mixed batch (mocked repo) | `InsertAsync` called exactly once (only for the accepted item); response has 3 result items with correct statuses |

## Blackbox Tests

| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|--------------------------|--------------|-------------------|-----------------|
| AC-1 | Empty `tiles` table; valid JWT with GPS perm; 1-item batch with valid 256×256 JPEG, captured_at=now | POST `/api/satellite/upload` | HTTP 200; 1 row in `tiles` with `source='uav'`; file at `./tiles/uav/{z}/{x}/{y}.jpg` exists | Reliability |
| AC-2 | Empty `tiles` table; valid JWT; 3-item mixed batch | POST `/api/satellite/upload` | HTTP 200; per-item results `[accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT]`; exactly 1 new row in `tiles` | Compatibility |
| AC-3 | Pre-seed `tiles` with a `source='google_maps'` row at `(L, Ln, 18, 200)` with `captured_at = now - 1h` | Upload a UAV tile for the same cell with `captured_at = now` | HTTP 200; both rows exist; subsequent `GET /api/satellite/tiles/latlon` for that cell returns the UAV row's metadata | Reliability |
| AC-4 | Pre-seed `tiles` with a `source='uav'` row at the cell with `captured_at = now - 1h` and known file content | Upload a UAV tile for the same cell with new bytes and `captured_at = now` | HTTP 200; exactly one `source='uav'` row remains with new `captured_at` and `file_path`; on-disk JPEG bytes match the new upload | Reliability |
| AC-5 | API running | POST `/api/satellite/upload` with no `Authorization` header | HTTP 401; no row, no file | Security |
| AC-6 | Valid JWT with `permissions: ["FL"]` (no GPS) | POST `/api/satellite/upload` | HTTP 403; no row, no file | Security |
| AC-8 | Valid JWT with GPS perm; batch with `MaxBatchSize + 1 = 101` items | POST `/api/satellite/upload` | HTTP 400 envelope error; no row, no file | — |

## Constraints

- Per-source file-path strategy is fixed: UAV → `./tiles/uav/{z}/{x}/{y}.jpg`; Google Maps → `./tiles/{z}/{x}/{y}.jpg` (grandfathered). Do NOT migrate Google Maps files to a sibling sub-tree in this PBI; that's a separate task if ever needed.
- Per-source UPSERT semantics MUST come from `ITileRepository.InsertAsync` as-implemented in AZ-484 — do NOT introduce a new write path or bypass the repository.
- `TileSource` enum + `TileSourceConverter` from `SatelliteProvider.Common.Enums` are the only sanctioned way to set the `source` wire value (per L-001 in `_docs/LESSONS.md` — never rely on Dapper TypeHandler<T> for enum reads).
- `MaxBatchSize` is a hard cap — no chunked / streaming upload variant in this PBI. If batches > 100 are needed, that's a follow-up redesign (likely async + status-poll).
- Reject-reason codes are a closed enumeration in v1.0.0 of the contract. Adding a new reason requires a contract minor-version bump (per `tile-storage.md` Versioning Rules pattern).
- The `permissions` claim check is hard-coded to require `GPS`. When the admin team coordinates a new `SAT` permission, that's a follow-up code + contract minor-bump.

## Risks & Mitigation

**Risk 1: Quality-gate threshold tuning is wrong**
- *Risk*: The variance threshold (`MinLuminanceVariance = 10`) is a guess. Real UAV imagery from `gps-denied-onboard` may legitimately have low variance (e.g., over uniform terrain), causing false rejects. Or the threshold may be too lax and let actually-blank tiles through.
- *Mitigation*:
  - Threshold is configurable via `UavQualityConfig.MinLuminanceVariance` — operators can tune per-deployment without code change.
  - Reject-reason `IMAGE_TOO_UNIFORM` makes false rejects diagnosable client-side.
  - Add an explicit follow-up task tagged "TUNE-THRESHOLDS" in the dependencies table so the threshold is re-evaluated after the first week of real UAV traffic.

**Risk 2: File-path collision between concurrent UAV uploads**
- *Risk*: Two UAV uploads for the exact same `(z, x, y)` arriving concurrently both compute the same file path; one overwrites the other's bytes after the first write completes but before the second's DB UPSERT.
- *Mitigation*:
  - The DB UPSERT (single transaction at the row level) is the authoritative serialization point. The file-on-disk represents whichever upload last wrote — which is acceptable because both rows share the same per-source `file_path` after UPSERT, and the `captured_at` UPSERT semantics already say "later wins".
  - In the unlikely race where bytes-A are written, then bytes-B are written, then DB UPSERT for A happens AFTER UPSERT for B — the `file_path` is identical so the final state is consistent (file = B, row = B). No data corruption; just last-write-wins on bytes, same as the AZ-484 contract specifies for the row.
  - Document this in the contract under "Concurrency" so consumers don't assume causal ordering.

**Risk 3: Disk-fill from oversized JPEGs**
- *Risk*: A misconfigured UAV could push tens of MB JPEGs and fill the disk despite the 5 MB cap (because the cap is per-item, not per-batch).
- *Mitigation*:
  - `MaxBatchSize = 100` × `MaxBytes = 5 MiB` = 500 MiB worst-case per request. ASP.NET Core's default request-body size limit (30 MB or `KestrelServerOptions.Limits.MaxRequestBodySize`) will reject before that — but this PBI must explicitly set `MaxRequestBodySize` to a safe value (`builder.Services.Configure<KestrelServerOptions>(opts => opts.Limits.MaxRequestBodySize = MaxBatchSize * MaxBytes)` or explicitly cap to e.g. 50 MB and reject larger batches at the framework layer).
  - Operations alerting on disk usage — out of scope; flagged as a `_docs/_process_leftovers/` ops follow-up if not already monitored.

**Risk 4: PT-08 (perf NFR for UAV upload) gets recorded but the runner script is never updated**
- *Risk*: The cycle 1 retro flagged this exact pattern (see `_docs/06_metrics/retro_2026-05-11.md` Action 2). PT-08 must NOT be added to `performance-tests.md` without a matching `scripts/run-performance-tests.sh` scenario in the same commit.
- *Mitigation*:
  - Hard rule for the implementer: PT-08 NFR + runner-script scenario land in the same commit. If the runner work cannot fit in the PBI, PT-08 is recorded as `Deferred — harness work tracked in <follow-up ticket>`, NOT as an active scenario.
  - The cycle's Step 15 perf gate enforces this when retro Action 2 lands as a process change.

**Risk 5: Test fixture for blank/uniform image is brittle**
- *Risk*: Generating a "uniform JPEG" for the rejection test depends on JPEG quantization quirks; a "uniform grey" 256×256 may have non-zero variance after JPEG compression noise.
- *Mitigation*:
  - Generate the fixture with `SixLabors.ImageSharp.Image<L8>` (single-channel grayscale), fill with a single value, save with quality=95. Validate at fixture-generation time that the resulting file's variance is below the threshold.
  - Pin the fixture as a checked-in test asset; do NOT regenerate at test runtime.

## Contract

This task produces the contract at `_docs/02_document/contracts/api/uav-tile-upload.md`.

Consumers (`gps-denied-onboard`, future UAV-equipped clients) MUST read that file — not this task spec — to discover the request/response shape, status codes, reject reason enum, and auth requirements. The `_docs/02_document/contracts/data-access/tile-storage.md` v1.0.0 contract is consumed (this PBI is a producer of `'uav'`-source rows under that contract).
