Files
Oleksandr Bezdieniezhnykh 1802d32107 [AZ-488] UAV tile batch upload + 5-rule quality gate
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>
2026-05-11 23:50:49 +03:00

27 KiB
Raw Permalink Blame History

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 > MaxBytesSIZE_OUT_OF_BAND
  • 7c: width or height ≠ MapConfig.TileSizePixelsWRONG_DIMENSIONS
  • 7d: captured_at > now + 30s → CAPTURED_AT_FUTURE; captured_at < now MaxAgeDaysCAPTURED_AT_TOO_OLD
  • 7e: pixel luminance variance below MinLuminanceVarianceIMAGE_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<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).