# 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(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("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 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(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 `, 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` (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).