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>
27 KiB
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-storagev1.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/uploadaccepts a multipart batch of UAV tiles in a single request, runs each item through a quality gate, persists the accepted ones viaITileRepository.InsertAsyncwithsource='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, refreshedcaptured_atandfile_path. - An unauthenticated request returns 401 (from AZ-487). A request with a valid JWT but missing the
GPSpermission 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.mdv1.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 JSONmetadatafield carrying an array of per-tile metadata records (Latitude,Longitude,TileZoom,TileSizeMeters,CapturedAt) plus anIFormFileCollectionof 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 likeHeight,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) inSatelliteProvider.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 aTileEntitywithSource = TileSourceConverter.ToWireValue(TileSource.Uav),CapturedAtfrom the request,FilePathset to the new path; callsITileRepository.InsertAsync(which UPSERTs per the 5-column unique key from AZ-484). - Returns
UavTileBatchUploadResponse.
- Quality gate
UavTileQualityGateinSatelliteProvider.Services.TileDownloader, exposed via interface for testability:- Rule 1 (Format): image content-type is
image/jpegAND magic bytes (FF D8 FF) confirm JPEG. Reject reason:INVALID_FORMAT. - Rule 2 (Size band):
5 KiB ≤ image bytes ≤ 5 MiB. Bounds configurable viaUavQualityConfig.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_atis not in the future (allow ≤ 30s clock skew) AND not older than 7 days fromDateTime.UtcNow(configurable viaUavQualityConfig.MaxAgeDaysdefault 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.MinLuminanceVariancedefault ~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).
- Rule 1 (Format): image content-type is
- Permission enforcement on the endpoint:
.RequireAuthorization(policy => policy.RequireClaim("permissions", "GPS"))(or equivalent — match the suite-wide claim shape; ifpermissionsis a string-array claim, use aClaimsAuthorizationRequirementthat checks array membership). - New configuration class
SatelliteProvider.Common.Configs.UavQualityConfig:MinBytes(int, default5 * 1024)MaxBytes(int, default5 * 1024 * 1024)MaxAgeDays(int, default7)MinLuminanceVariance(double, default10.0)MaxBatchSize(int, default100— see Constraints)- Wired in
Program.csviabuilder.Services.Configure<UavQualityConfig>(builder.Configuration.GetSection("UavQuality"));.
Program.csupdates:- Replace the 501
UploadImagehandler 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).
- Replace the 501
- 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; thetiles.sourcecolumn 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}.jpgpath.
- UAV:
- Documentation:
_docs/02_document/contracts/api/uav-tile-upload.mdv1.0.0 — new contract file. Statusfrozenupon 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 fromtile-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: addUAV 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 newUavTileQualityGateand the per-source file-path layout._docs/02_document/data_model.md: note thatfile_pathsemantics now depend onsource(uavrows live under./tiles/uav/,google_mapsrows 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).
UavTileBatchUploadRequestDTO model-binding round-trip (multipart parse + JSON metadata parse).- File-path construction test (UAV path matches
./tiles/uav/{z}/{x}/{y}.jpgformat; safe against path-traversal in tile coordinates — coordinates are typedintso 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 intileswithsource='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 agoogle_mapsrow; upload auavtile for the same cell with latercaptured_at; assert both rows exist intiles, theuavrow wins on subsequentGetByTileCoordinatesAsync(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 latercaptured_at; assert exactly onesource='uav'row remains,captured_atandfile_pathupdated, file on disk overwritten.UavUploadTests.NoToken_Returns401: unauthenticated upload returns 401 (validates AZ-487 coverage extends to this endpoint).UavUploadTests.ValidTokenWithoutGpsPermission_Returns403: JWT withpermissions: ["FL"](no GPS) returns 403.UavUploadTests.ValidTokenWithGpsPermission_Returns200: JWT withpermissions: ["GPS"]proceeds normally.UavUploadTests.OversizedBatch_Returns400: batch with >MaxBatchSizeitems 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_mapstiles — 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
permissionsclaim values — uses existingGPS. IfSAT(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 <
MinBytesor >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.mdwith the matching runner-script entry IN THE SAME COMMIT (per cycle 1 retro lesson — see_docs/06_metrics/retro_2026-05-11.mdAction 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.mdv1.0.0 — frozen on merge; future shape changes follow the contract's Versioning Rules. - Coexists with the AZ-484 frozen
tile-storagev1.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.mdv1.0.0 contract Inv-3 (per-source UPSERT) exactly — validated byUavUploadTests.MultiSourceCoexistence_AZ484_Cycle2andSameSourceUpsert_AZ484_Cycle2.
Security
- Permission claim check is mandatory; do NOT add a "skip permission check in dev" flag.
- Reject-reason
RejectDetailsstrings MUST NOT leak server-side paths, exception types, or internal identifiers (per_docs/05_security/owasp_review.mdA05/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.InsertAsyncas-implemented in AZ-484 — do NOT introduce a new write path or bypass the repository. TileSourceenum +TileSourceConverterfromSatelliteProvider.Common.Enumsare the only sanctioned way to set thesourcewire value (per L-001 in_docs/LESSONS.md— never rely on Dapper TypeHandler for enum reads).MaxBatchSizeis 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.mdVersioning Rules pattern). - The
permissionsclaim check is hard-coded to requireGPS. When the admin team coordinates a newSATpermission, 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 fromgps-denied-onboardmay 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_UNIFORMmakes 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.
- Threshold is configurable via
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_pathafter UPSERT, and thecaptured_atUPSERT 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_pathis 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.
- 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
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 orKestrelServerOptions.Limits.MaxRequestBodySize) will reject before that — but this PBI must explicitly setMaxRequestBodySizeto 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.mdAction 2). PT-08 must NOT be added toperformance-tests.mdwithout a matchingscripts/run-performance-tests.shscenario 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.
- 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
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.
- Generate the fixture with
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).