# Solution Draft 01 — UAV Batch Upload (AZ-485 family)

**Date**: 2026-05-11
**Mode**: Research Mode A (Initial)
**Output Class**: Technical-component selection
**Inputs**: `_docs/02_task_plans/uav-batch-upload/problem.md`; `_docs/02_task_plans/uav-batch-upload/00_research/00_ac_assessment.md`
**Status**: Draft 01 (no prior drafts to assess)

## Core conclusion

The four-task family (AZ-485/486/487/488) is **build-grade-tractable on top of the existing stack — no new dependencies required for AZ-485, AZ-486, or AZ-488; AZ-487 brings in one new built-in NuGet package** (`Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x`, ships with the framework). The only architectural decision worth the user's attention is the **geofence shape (Q4)** — keep the existing bbox or move to polygon/NetTopologySuite — and that decision has a real SP impact on AZ-488 (2 → 4 SP).

## Key findings (5 points)

1. **JWT/AZ-487**: `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x` (ships with the framework, version-pinned to ASP.NET Core 8) handles HS256 + the user's "validate signature + `exp` only" requirement out of the box via `TokenValidationParameters` with `ValidateIssuer = false`, `ValidateAudience = false`, `ValidateLifetime = true`, and a `SymmetricSecurityKey`. No third-party library justified.
2. **Multipart/AZ-485**: ASP.NET Core 8 minimal API supports `IFormFileCollection` + scalar `[FromForm]` parameters natively. Per-endpoint `MaxRequestBodySize` and `MultipartBodyLengthLimit` overrides apply via endpoint metadata (`IRequestSizeLimitMetadata`, `IFormOptionsMetadata`). `app.MapPost(...).DisableAntiforgery()` is required because antiforgery is enabled by default for forms but irrelevant to a JWT-protected non-browser endpoint.
3. **ImageSharp/AZ-486**: ImageSharp 3.1.11 already-pinned API surface is sufficient — `Image.Identify` for dimensions + format without decoding, `Image.DetectFormat` for magic-bytes check, and `Image<L8>.ProcessPixelRows` for streaming-grade luminance stddev computation. No additional NuGet packages needed. Welford's online algorithm fits the per-row callback shape perfectly.
4. **Geofence/AZ-488**: **Recommend Option A — reuse the existing `GeofencePolygon` bbox shape** (NorthWest + SouthEast corners). Rationale: the codebase already understands bbox; the user said "geofence" without specifying polygon; ray-casting is overkill for an internal whitelist that will likely have ≤ 5 axis-aligned regions. NetTopologySuite is **rejected** for this scope (heavy, +9 MB, includes a full topology engine when we need a 4-line containment check). Option B (true polygons) is **deferred** as a future task if real-world geofences turn out to need shapes beyond rectangles.
5. **File+DB consistency/AZ-485**: **Recommend write-row-then-write-file with idempotent overwrite**. The per-source UPSERT (already in place from AZ-484) makes the row write idempotent; the file write is `File.WriteAllBytesAsync(tempPath); File.Move(tempPath, finalPath, overwrite: true)` for atomic replace. On file write failure: log + return `rejected: storage_failure` per tile in the response; the row stays as authoritative metadata pointing at the (now-missing) file path. Pre-existing GMaps download path has the same coupling (`TileService.DownloadAndStoreTilesAsync`) — match its behavior, not invent a new one.

---

## Project Constraint Matrix

| Constraint Source | Constraint | Impact on Component Selection |
|--|--|--|
| problem.md / user Step 1 | Sync 200 with mixed `[{tileId, status, reason?}]` | Rules out async/202 patterns; rules out HTTP 207 |
| problem.md / user Step 1 | Minimum metadata only | No new request DTO complexity; ~5 fields per tile |
| problem.md / user Step 1 | All 6 quality checks | AZ-486 is structurally a pipeline of 6 small validators |
| problem.md / user Step 1 | JWT HS256, claims `sub`+`exp` only | Rules out IdP/OIDC integration; symmetric key in env |
| problem.md / user Step 1 | Same `./tiles/{z}/{x}/{y}.jpg` path as GMaps | UAV file overwrites GMaps file when uploaded for the same cell — file-level "most recent wins" matches DB-level rule |
| existing code | `GeofencePolygon` is a bbox in this codebase | Polygon would be a new concept; bbox is consistent |
| existing code | `TileSourceConverter.ToWireValue(TileSource.Uav)` already exists from AZ-484 | UAV row writes are 1-line conversion at the boundary |
| existing code | Per-source UPSERT in `TileRepository.InsertAsync` (AZ-484) | UAV InsertAsync = same call site as GMaps; no new repository method |
| existing code | `TileService.DownloadAndStoreTilesAsync` writes file then row | Establish file-write/row-write order parity |
| existing code | No auth middleware | AZ-487 must wire `app.UseAuthentication()` + `.UseAuthorization()` from scratch; tests must construct valid tokens |
| existing code | No rate limiter | Out of scope for this family; recorded as Risk |
| LESSONS L-001 | Dapper TypeHandler<T> bypassed for enum reads | No new persisted enums in this family; verdict stays response-only |
| LESSONS ring-buffer/process | NFR + runner script must land same step | Any AZ-485 perf NFR (e.g., batch throughput) must come with the runner scenario |
| LESSONS ring-buffer/estimation | Test-site counts need grep evidence | new-task Step 4 codebase analysis must grep, not estimate |
| .NET 8.0 LTS only | Pin all package versions to 8.0.x major | `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x`, no 9.x |

## Component Fit Matrix

### Q1 — JWT auth (AZ-487)

| Candidate | Fit verdict | Notes |
|--|--|--|
| `Microsoft.AspNetCore.Authentication.JwtBearer 8.0.x` | **Selected** | Ships with ASP.NET Core 8; supports HS256 via `SymmetricSecurityKey`; `ValidateIssuer=false`, `ValidateAudience=false`, `ValidateLifetime=true` matches user's "claims = sub + exp only"; per-endpoint `RequireAuthorization()` works on minimal API |
| `OpenIddict 5.x` | Rejected | Full OAuth2/OIDC server framework; orders of magnitude bigger than needed for HS256-validation-only |
| Custom token middleware | Rejected | Reinvents token parsing/validation; security-critical code that should not be hand-rolled |
| `IdentityServer / Duende` | Rejected | Issuer/server, not validator; commercial license |

**Mode pinned**: `JwtBearerOptions.TokenValidationParameters` with explicit symmetric-key shape. Mandatory MVE below.

### Q2 — Multipart batch upload (AZ-485)

| Candidate | Fit verdict | Notes |
|--|--|--|
| Minimal API `IFormFileCollection` + `[FromForm]` scalar fields | **Selected for batches up to ~50 MB** | Native to ASP.NET Core 8; no wire shape issue; FormOptions overridable per-endpoint |
| `MultipartReader` streaming | Deferred | Required only if batches exceed in-memory budget. Not in initial scope; revisit if production telemetry shows batches >50 MB. |
| Separate per-tile POST (no batch) | Rejected — user explicitly chose batch | Would simplify code but contradicts requirement |

**Mode pinned**: `app.MapPost("/api/satellite/uav/upload", (IFormFileCollection files, [FromForm] string metadata, ...) => ...).DisableAntiforgery().RequireAuthorization()`. Per-endpoint size cap via `.WithMetadata(new RequestSizeLimitMetadata { MaxRequestBodySize = 50_000_000 })` and `.WithMetadata(new FormOptionsMetadata { MultipartBodyLengthLimit = 50_000_000 })`.

### Q3 — Quality gate (AZ-486)

| Check | API | Performance |
|--|--|--|
| Format = JPEG | `Image.DetectFormat(stream)` then check `format.Name == "JPEG"` | Header-only read; ~1 ms per tile |
| Dimensions = `MapConfig.TileSizePixels` (256) | `Image.Identify(stream)` returns `ImageInfo { Width, Height }` | Same header-only path; same ~1 ms |
| File size 4 KB ≤ size ≤ 2 MB | `IFormFile.Length` direct check | O(1), no decode |
| `captured_at` within last 365 days | `DateTime.UtcNow - capturedAt ≤ TimeSpan.FromDays(365)` | O(1) |
| Blank/uniform: luminance stddev < 5 | `Image.Load<L8>(stream).ProcessPixelRows(accessor => ...)` Welford online stddev | ~5–8 ms per 256×256 tile (single-channel decode + one pass) |
| Geofence containment | `GeofenceWhitelist.Contains(lat, lon)` (AZ-488) | O(M) per tile, M ≤ 10 polygons → negligible |

**Mode pinned**: `Image.Identify` first (cheap, gives format + dimensions in one call); only proceed to `Image.Load<L8>` for the blank-detection check after the cheap checks pass. Total per-tile budget under 15 ms.

| Candidate alternatives | Fit verdict |
|--|--|
| Magick.NET / ImageMagick | Rejected — second image library on the same stack; ImageSharp already pinned and sufficient |
| `System.Drawing.Common` | Rejected — Microsoft has marked `System.Drawing.Common` non-cross-platform on .NET 6+; would break Linux container |
| Pre-trained ML "is-this-blank" model | Rejected — overkill; Welford stddev is the canonical, fast baseline |

### Q4 — Geofence shape (AZ-488)

| Candidate | Fit verdict | Sizing impact |
|--|--|--|
| **Reuse `GeofencePolygon` bbox** (NW + SE corners, point-in-bbox check is a 4-comparison if-statement) | **Selected** | AZ-488 = 2 SP as originally scoped |
| True polygon (lat/lon vertex list, ray-casting from scratch) | Deferred | Would push AZ-488 to ~4 SP (new DTO + ray-cast utility + tests for edge cases like point-on-edge, antimeridian crossing) |
| `NetTopologySuite 2.5.x` | Rejected | +9 MB binary, full topology engine; not justified for ≤ 10 axis-aligned whitelisted regions |

Storage: `appsettings.json` array under `UavQualityGate.GeofenceWhitelist[]`, list of `{ northWest: {lat,lon}, southEast: {lat,lon} }`. Empty array = "allow all" (sentinel). Loaded via `IOptions<UavQualityGateConfig>` like every other config in this codebase.

### Q5 — File+DB consistency (AZ-485)

| Candidate | Fit verdict |
|--|--|
| **Write file with `.tmp`, then `InsertAsync` (UPSERT), then `File.Move(temp → final, overwrite: true)`** | **Selected** | Two-phase: file write is recoverable (delete .tmp on failure), DB write is idempotent (UPSERT), final atomic rename. |
| Write row first, then file | Rejected | Race window: row points at non-existent file if process crashes between |
| Distributed transaction across FS + DB (e.g., `TransactionScope`) | Rejected | `System.IO.File` is not a transactional resource manager on Linux — would silently no-op the FS rollback |
| Outbox pattern with retry queue | Deferred | Worth considering if production traffic warrants exactly-once-delivery guarantees; out of scope for this cycle |

## Tech Stack Summary

| Layer | Choice | Version | Justification |
|--|--|--|--|
| HTTP framework | ASP.NET Core minimal API | 8.0 (existing) | Match existing endpoints |
| Auth middleware | `Microsoft.AspNetCore.Authentication.JwtBearer` | 8.0.x | Built-in; HS256 supported |
| Image inspection | `SixLabors.ImageSharp` | 3.1.11 (existing) | Already pinned; supports `Identify`, `Load<L8>`, `ProcessPixelRows` |
| DB access | `Dapper` + `Npgsql` | 2.1.35 / 9.0.2 (existing) | UPSERT via `TileRepository.InsertAsync` (AZ-484) |
| Geofence | Reuse `SatelliteProvider.Common.DTO.GeofencePolygon` | existing | Bbox shape, simple containment check |
| Config | `Microsoft.Extensions.Options` `IOptions<T>` | 8.0.x (existing) | New `UavQualityGateConfig` registered same way as `MapConfig`, etc. |

**No new top-level dependencies on top of the existing stack** other than the JwtBearer package.

## Security analysis (concise)

| Threat | Control | Where |
|--|--|--|
| Forged tokens | HMAC SHA-256 signature validation against shared secret | AZ-487 — `JwtBearerOptions.TokenValidationParameters` |
| Token replay | `exp` claim validation | AZ-487 — `ValidateLifetime = true` |
| Signing key leak | env var, not in source; documented in `.env.example` (matches existing GMaps API key handling) | AZ-487 + AGENTS.md update |
| Path traversal in upload | Tile path is computed from validated lat/lon/zoom server-side, not from any user-supplied filename | AZ-485 — `tile_x/tile_y` derived via existing `GeoUtils.WorldToTilePos` |
| Image-decode attacks (zip bomb, malformed JPEG) | `Image.Identify` uses bounded reads; `Image.Load` wrapped in try/catch around `InvalidImageContentException` and `UnknownImageFormatException`; per-file size cap before decode | AZ-486 |
| Disk fill DoS | Per-batch size cap (`MaxRequestBodySize = 50 MB`); per-file size cap (2 MB); rate limiter recommended but tracked separately from this family | AZ-485 — endpoint metadata; rate limiter is Security Audit I3 deferred |
| JWT logged in logs | `Serilog` request logging configured today does not log Authorization header; verify Pii filter is on | AZ-487 verification step |

## Risks & Mitigations

| Risk | Severity | Mitigation |
|--|--|--|
| File overwrite for same cell across sources causes data loss at the file system level (DB row carries source, file does not) | Medium | Re-confirm with user in new-task Step 5 — option to use `./tiles/uav/{z}/{x}/{y}.jpg` to keep both bytes addressable. Default is "yes, same path" per Step 1 answer. |
| Quality-gate thresholds (blank stddev, age limit, byte size bounds) need real-world tuning | Low | Make every threshold configurable via `UavQualityGateConfig`; ship defaults; tune in production telemetry |
| JWT signing-key rotation procedure | Medium | Out of scope for AZ-487; document the procedure ("change env var + restart") in `_docs/02_document/deployment/`. Shared secret rotation requires a coordinated upload-client + server change, accepted limitation |
| Test infrastructure for JWT (integration tests must mint tokens) | Low | Use `JsonWebTokenHandler` from `System.IdentityModel.Tokens.Jwt` in test code with the same shared secret as the test config; pattern is well-trodden |
| Adversarial UAV uploader sends `captured_at` in the future | Low | Add a 7th implicit check: `captured_at <= DateTime.UtcNow`. Cheap, catches one obvious abuse |

## Minimum Viable Examples (saved verbatim per per-mode API gate)

### MVE-1 — JWT validation (Q1 selected mode)

```csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        var key = builder.Configuration["UavAuth:SigningKey"]
            ?? throw new InvalidOperationException("UavAuth:SigningKey is required");

        options.RequireHttpsMetadata = false;
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            ValidateAudience = false,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
            ClockSkew = TimeSpan.FromSeconds(30),
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/api/satellite/uav/upload", (IFormFileCollection files, [FromForm] string metadata) =>
    {
        return Results.Ok(new { received = files.Count });
    })
    .DisableAntiforgery()
    .RequireAuthorization();
```

### MVE-2 — Per-endpoint size limits (Q2 selected mode)

```csharp
using Microsoft.AspNetCore.Http.Metadata;

const long MaxBatchBytes = 50L * 1024 * 1024;

app.MapPost("/api/satellite/uav/upload", handler)
    .DisableAntiforgery()
    .RequireAuthorization()
    .WithMetadata(
        new RequestSizeLimitMetadata { MaxRequestBodySize = MaxBatchBytes },
        new FormOptionsMetadata
        {
            MultipartBodyLengthLimit = MaxBatchBytes,
            ValueLengthLimit = 16 * 1024,
            MultipartBoundaryLengthLimit = 256,
        });
```

### MVE-3 — Quality gate (Q3 selected mode)

```csharp
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;

public sealed record QualityResult(bool Accepted, string? Reason);

public sealed class TileQualityGate
{
    private readonly UavQualityGateConfig _cfg;

    public QualityResult Check(IFormFile file, double lat, double lon, DateTime capturedAt, GeofenceWhitelist geofence)
    {
        if (file.Length < _cfg.MinFileBytes) return new(false, "file_too_small");
        if (file.Length > _cfg.MaxFileBytes) return new(false, "file_too_large");

        var capturedAge = DateTime.UtcNow - capturedAt;
        if (capturedAge > TimeSpan.FromDays(_cfg.MaxAgeDays)) return new(false, "captured_at_too_old");
        if (capturedAge < TimeSpan.Zero) return new(false, "captured_at_in_future");

        if (!geofence.Contains(lat, lon)) return new(false, "outside_geofence");

        using var stream = file.OpenReadStream();

        IImageFormat? detectedFormat = Image.DetectFormat(stream);
        if (detectedFormat is null || detectedFormat.Name != "JPEG") return new(false, "format_not_jpeg");

        stream.Position = 0;
        ImageInfo info = Image.Identify(stream);
        if (info.Width != _cfg.ExpectedTilePixels || info.Height != _cfg.ExpectedTilePixels)
            return new(false, "wrong_dimensions");

        stream.Position = 0;
        try
        {
            using var img = Image.Load<L8>(stream);
            double mean = 0, m2 = 0; long n = 0;
            img.ProcessPixelRows(accessor =>
            {
                for (int y = 0; y < accessor.Height; y++)
                {
                    var row = accessor.GetRowSpan(y);
                    for (int x = 0; x < row.Length; x++)
                    {
                        double v = row[x].PackedValue;
                        n++;
                        double delta = v - mean;
                        mean += delta / n;
                        m2 += delta * (v - mean);
                    }
                }
            });
            double stddev = n > 1 ? Math.Sqrt(m2 / (n - 1)) : 0;
            if (stddev < _cfg.MinLuminanceStdDev) return new(false, "image_blank_or_uniform");
        }
        catch (InvalidImageContentException) { return new(false, "image_corrupt"); }
        catch (UnknownImageFormatException)  { return new(false, "format_unknown"); }

        return new(true, null);
    }
}
```

### MVE-4 — Geofence whitelist (Q4 selected mode)

```csharp
public sealed class GeofenceWhitelist
{
    private readonly IReadOnlyList<GeofencePolygon> _polygons;

    public GeofenceWhitelist(IReadOnlyList<GeofencePolygon> polygons) => _polygons = polygons;

    public bool Contains(double lat, double lon)
    {
        if (_polygons.Count == 0) return true; // sentinel: empty list = allow all

        foreach (var p in _polygons)
        {
            if (p.NorthWest is null || p.SouthEast is null) continue;
            if (lat <= p.NorthWest.Lat && lat >= p.SouthEast.Lat
                && lon >= p.NorthWest.Lon && lon <= p.SouthEast.Lon)
                return true;
        }
        return false;
    }
}
```

### MVE-5 — File+DB consistency (Q5 selected mode)

```csharp
public sealed class UavTilePersister
{
    private readonly ITileRepository _repo;
    private readonly StorageConfig _cfg;
    private readonly ILogger<UavTilePersister> _log;

    public async Task<Guid?> PersistAsync(IFormFile file, double lat, double lon, int zoom,
        double tileSizeMeters, DateTime capturedAt, CancellationToken ct)
    {
        var (tileX, tileY) = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), zoom);
        var finalPath = Path.Combine(_cfg.TilesRoot, zoom.ToString(), tileX.ToString(), tileY + ".jpg");
        var tempPath  = finalPath + ".uav.tmp";

        Directory.CreateDirectory(Path.GetDirectoryName(finalPath)!);

        await using (var dst = File.Create(tempPath))
            await file.CopyToAsync(dst, ct);

        try
        {
            var entity = new TileEntity
            {
                Id = Guid.NewGuid(),
                TileZoom = zoom, TileX = tileX, TileY = tileY,
                Latitude = lat, Longitude = lon,
                TileSizeMeters = tileSizeMeters,
                TileSizePixels = _cfg.ExpectedTilePixels,
                ImageType = "jpg",
                Source = TileSourceConverter.ToWireValue(TileSource.Uav),
                CapturedAt = capturedAt,
                FilePath = Path.GetRelativePath(AppContext.BaseDirectory, finalPath),
            };

            var id = await _repo.InsertAsync(entity);

            File.Move(tempPath, finalPath, overwrite: true);
            return id;
        }
        catch
        {
            File.Delete(tempPath);
            throw;
        }
    }
}
```

## Sources

| Source | Tier | Citation |
|--|--|--|
| ASP.NET Core 8.0.21 source — JwtBearer public API | L1 | `github.com/dotnet/aspnetcore/blob/v8.0.21/src/Security/Authentication/JwtBearer/src/PublicAPI.Shipped.txt` (via context7 `/dotnet/aspnetcore/v8.0.21`) |
| ASP.NET Core 8.0.21 source — FormOptions, IRequestSizeLimitMetadata | L1 | `github.com/dotnet/aspnetcore/blob/v8.0.21/src/Http/Http/src/PublicAPI.Shipped.txt` |
| ASP.NET Core docs — JWT Bearer config sample | L1 | `learn.microsoft.com/aspnet/core/security/authentication/configure-jwt-bearer-authentication` (via context7 `/dotnet/aspnetcore`) |
| SixLabors.ImageSharp docs — Image.Identify, ProcessPixelRows | L1 | `docs.sixlabors.com/articles/imagesharp/imageinfo` (via context7 `/sixlabors/imagesharp`) |
| Codebase grep — `GeofencePolygon` shape, `TileRepository.InsertAsync` UPSERT | L1 (project source) | `SatelliteProvider.Common/DTO/GeofencePolygon.cs`, `SatelliteProvider.DataAccess/Repositories/TileRepository.cs` |
| AZ-484 task family closure (this repo, cycle 1) | L1 (project history) | `_docs/03_implementation/implementation_report_multi_source_tile_storage_cycle1.md`; `_docs/02_document/contracts/data-access/tile-storage.md` |

## Quality checklist

- [x] Each component selection has an exact-fit verdict against the Project Constraint Matrix
- [x] Per-mode API capability verification done for each library (JwtBearer, ASP.NET Core multipart, ImageSharp)
- [x] MVE saved for every selected mode (5 of them)
- [x] "Do not use" / Rejected list present per question
- [x] L1 sources for every key claim
- [x] Multi-perspective considered: implementer (MVE), security architect (threats table), domain (existing code constraints), contrarian (rejected NTS / OpenIddict / Magick.NET / async-202)
- [x] No conclusions from "gut feel" — every selection has matrix evidence
