mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 05:41:14 +00:00
[AZ-487] [AZ-488] Cycle 2 Step 9: JWT baseline + UAV upload task specs
Created two PBIs for cycle 2 under epic AZ-483 (multi-source tile
storage + UAV upload). Splits the originally-planned single AZ-485
into 2 cohesive tasks because the combined scope was ~10 SP and
JWT auth is independently shippable:
- AZ-487 (2 SP) JWT validation baseline. Adds HS256 JwtBearer
middleware against JWT_SECRET env var per the suite-level auth
contract (suite/_docs/10_auth.md). Applies .RequireAuthorization()
on all existing endpoints. Skips iss/aud validation (suite doc
does not specify). No /users/me endpoints. Hard prerequisite for
AZ-488.
- AZ-488 (8 SP, over-cap user-accepted) UAV tile upload endpoint
with batch + 5-rule quality gate. Replaces the 501 stub. Multipart
batch DTO, 5 quality rules (format, size band, dimensions,
captured_at age 7d, blank/uniform variance heuristic). UAV files
land at ./tiles/uav/{z}/{x}/{y}.jpg; google_maps grandfathered
at bare ./tiles/{z}/{x}/{y}.jpg. Per-source UPSERT via the
AZ-484 ITileRepository.InsertAsync. Sync 200 with per-item
results. Requires GPS permission claim. Produces frozen contract
uav-tile-upload.md v1.0.0.
Both Jira tickets created and linked. Dependencies table updated.
Autodev state advanced to cycle 2 Step 10 (Implement).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,8 +62,14 @@ Roadmap: `_docs/04_refactoring/03-code-quality-refactoring/analysis/refactoring_
|
||||
|
||||
| Task | Title | Depends On | Points | Status |
|
||||
|------|-------|-----------|--------|--------|
|
||||
| AZ-484 | Multi-source tile storage schema (source + captured_at) | — | 5 | To Do |
|
||||
| AZ-485 (planned) | UAV upload endpoint + quality gate | AZ-484, contract `tile-storage.md` v1.0.0 | ~5 | Not yet created (deferred to a future Step 9 loop) |
|
||||
| AZ-484 | Multi-source tile storage schema (source + captured_at) | — | 5 | Done (deployed cycle 1) |
|
||||
|
||||
### Step 9 cycle 2 — New Task: JWT validation baseline + UAV upload completion
|
||||
|
||||
| Task | Title | Depends On | Points | Status |
|
||||
|------|-------|-----------|--------|--------|
|
||||
| AZ-487 | JWT validation baseline (HS256, JWT_SECRET, all endpoints) | — (consumes suite-level contract `suite/_docs/10_auth.md`) | 2 | To Do |
|
||||
| AZ-488 | UAV tile upload endpoint with batch + 5-rule quality gate | AZ-487 (hard prereq), AZ-484 contract `tile-storage.md` v1.0.0 | 8 (over-cap, user-accepted) | To Do |
|
||||
|
||||
## Execution Order
|
||||
|
||||
@@ -86,14 +92,18 @@ Phase 4 (Typing/config/tooling/polish): AZ-371 → AZ-370 → AZ-373 → AZ-374
|
||||
|
||||
### Step 9 cycle 1 (Multi-source tile storage epic AZ-483)
|
||||
1. AZ-484 — Multi-source tile storage schema (foundational)
|
||||
2. AZ-485 (planned) — UAV upload endpoint + quality gate (consumer of AZ-484's contract)
|
||||
|
||||
### Step 9 cycle 2
|
||||
1. AZ-487 — JWT validation baseline (must merge first; AZ-488 hard-depends on it)
|
||||
2. AZ-488 — UAV tile upload endpoint + 5-rule quality gate (consumer of both AZ-484 contract and AZ-487 auth)
|
||||
|
||||
## Total Effort
|
||||
|
||||
Step 6: 6 tasks, 17 story points
|
||||
Step 8 (02-coupling-refactoring): 6 tasks, 17 story points
|
||||
Step 8 (03-code-quality-refactoring): 27 tasks, ~66 story points
|
||||
Step 9 cycle 1: 1 task created (AZ-484, 5 pts); 1 deferred (AZ-485)
|
||||
Step 9 cycle 1: 1 task created (AZ-484, 5 pts)
|
||||
Step 9 cycle 2: 2 tasks created (AZ-487 = 2 pts, AZ-488 = 8 pts over-cap user-accepted) — total 10 pts
|
||||
|
||||
## Coverage Verification
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# JWT validation baseline (HS256, JWT_SECRET, all endpoints)
|
||||
|
||||
**Task**: AZ-487_jwt_validation_baseline
|
||||
**Name**: JWT validation baseline
|
||||
**Description**: Add HS256 JWT validation to satellite-provider so every existing endpoint requires a valid token issued by the centralized Admin API; align with the suite-wide auth contract documented in `suite/_docs/10_auth.md`.
|
||||
**Complexity**: 2 points
|
||||
**Dependencies**: None (consumes the suite-level JWT contract `suite/_docs/10_auth.md`; no in-repo task dependency)
|
||||
**Component**: WebApi (`SatelliteProvider.Api`)
|
||||
**Tracker**: AZ-487
|
||||
**Epic**: none (cross-cutting hardening; AZ-488 hard-depends on this)
|
||||
|
||||
## Problem
|
||||
|
||||
Satellite-provider currently has zero authentication — every endpoint is open. The suite-level auth design (`suite/_docs/10_auth.md`) requires every .NET API instance in the suite to validate JWTs locally using a shared HMAC key supplied via the `JWT_SECRET` env var. The Security Audit (Step 14, cycle 1, `_docs/05_security/owasp_review.md`) flagged the absence of authentication as the largest hardening gap blocking public-network exposure. The next planned PBI (AZ-488 — UAV upload endpoint) cannot ship without the auth baseline because UAV uploads must carry an authenticated user identity.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Every existing HTTP endpoint (except possibly Swagger UI in non-Production) returns `401 Unauthorized` when the request has no `Authorization: Bearer …` header, an expired token, or a token whose HMAC signature does not verify against the `JWT_SECRET` env var.
|
||||
- Authenticated requests reach the existing handlers unchanged — no behavioral change for valid-token callers.
|
||||
- A request's user identity is exposed to handlers via the standard `HttpContext.User` claims principal (`sub`, `email`, `role`, `permissions[]` per the suite contract).
|
||||
- Swagger UI accepts a Bearer token via the standard "Authorize" button so manual testing keeps working.
|
||||
- The `JWT_SECRET` env var is documented in `appsettings.Development.json` (with a clearly-fake dev value), `docker-compose.yml`, and (eventually) the deployment scripts.
|
||||
- Existing 213 unit + 5 smoke tests continue to pass after test setup is updated to attach a valid dev token.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- New ServiceCollection extension `SatelliteProvider.Api.Authentication.AuthenticationServiceCollectionExtensions.AddSatelliteJwt(this IServiceCollection, IConfiguration)`:
|
||||
- Adds `Microsoft.AspNetCore.Authentication.JwtBearer` (use the same minor version as the existing `Microsoft.AspNetCore.OpenApi` package — currently 8.0.x; if a security upgrade is happening alongside, pin both consistently).
|
||||
- Configures `AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options => …)` with `TokenValidationParameters`:
|
||||
- `ValidateIssuerSigningKey = true`, `IssuerSigningKey = SymmetricSecurityKey(Encoding.UTF8.GetBytes(JWT_SECRET))`
|
||||
- `ValidateLifetime = true`, `ClockSkew = TimeSpan.FromSeconds(30)` (match suite default; document the choice in code)
|
||||
- `ValidateIssuer = false`, `ValidateAudience = false` (per suite doc — issuer/audience semantics are not specified yet)
|
||||
- `RequireSignedTokens = true`, `RequireExpirationTime = true`
|
||||
- Throws on startup if `JWT_SECRET` is missing or shorter than 32 bytes (HMAC-SHA256 minimum-secure key size).
|
||||
- New ServiceCollection extension call wired into `Program.cs`: `builder.Services.AddSatelliteJwt(builder.Configuration);` followed by `builder.Services.AddAuthorization();` and the matching `app.UseAuthentication(); app.UseAuthorization();` middleware ordering (after `UseCors`, before endpoint routing).
|
||||
- `.RequireAuthorization()` applied to every existing `MapGet`/`MapPost` in `Program.cs`:
|
||||
- `GET /tiles/{z}/{x}/{y}` (`ServeTile`)
|
||||
- `GET /api/satellite/tiles/latlon` (`GetTileByLatLon`)
|
||||
- `GET /api/satellite/tiles/mgrs` (`GetSatelliteTilesByMgrs`)
|
||||
- `POST /api/satellite/upload` (`UploadImage` — currently 501 stub)
|
||||
- `POST /api/satellite/request` (`RequestRegion`)
|
||||
- `GET /api/satellite/region/{id}` (`GetRegionStatus`)
|
||||
- `POST /api/satellite/route` (`CreateRoute`)
|
||||
- `GET /api/satellite/route/{id}` (`GetRoute`)
|
||||
- `Program.cs` Swagger configuration extended with a `SecurityDefinition("Bearer", ...)` and a global `SecurityRequirement` so the "Authorize" button appears in Swagger UI.
|
||||
- `appsettings.json` and `appsettings.Development.json`: add `Jwt:Secret` configuration key reading `JWT_SECRET` via the standard ASP.NET Core env-var binding. The dev file ships a 32+ byte placeholder secret clearly marked `DEV-ONLY-DO-NOT-USE-IN-PROD`.
|
||||
- `docker-compose.yml`: add `JWT_SECRET` to the api service's `environment` block, sourcing from the host env (or a `.env` entry the operator supplies).
|
||||
- `.env.example` (create if missing): include `JWT_SECRET=` line with comment.
|
||||
- Test infrastructure update:
|
||||
- Add a small test helper `SatelliteProvider.Tests.TestUtilities.JwtTokenFactory` (or reuse if a similar one exists) that signs a valid dev token using the same secret used by the integration test environment.
|
||||
- Update `scripts/run-tests.sh` (or the relevant test setup) to ensure the integration test container starts with `JWT_SECRET` set and the test runner attaches a Bearer token to every request.
|
||||
- Update existing smoke tests' HTTP request setup to attach the test token by default.
|
||||
- New unit tests:
|
||||
- `AuthenticationServiceCollectionExtensionsTests`: `AddSatelliteJwt_RegistersJwtBearerScheme`, `AddSatelliteJwt_ThrowsOnMissingSecret`, `AddSatelliteJwt_ThrowsOnShortSecret`.
|
||||
- Token factory: `JwtTokenFactory_ProducesTokenAcceptedByValidationParameters`.
|
||||
- New integration tests:
|
||||
- `JwtIntegrationTests.AnonymousRequest_To_AnyEndpoint_Returns401`
|
||||
- `JwtIntegrationTests.ExpiredToken_Returns401`
|
||||
- `JwtIntegrationTests.InvalidSignature_Returns401`
|
||||
- `JwtIntegrationTests.ValidToken_Returns200_OnHealthyEndpoint`
|
||||
- Documentation:
|
||||
- Update `_docs/02_document/architecture.md` § Architecture Vision to add an "Authentication & authorization" sub-section noting that satellite-provider validates JWTs issued by the centralized Admin API per the suite-level auth contract.
|
||||
- Update `_docs/02_document/components/01_web_api/description.md` to describe the JWT middleware ordering and where `.RequireAuthorization()` is applied.
|
||||
- Update `_docs/02_document/modules/api_program.md` to reflect the new middleware chain.
|
||||
- Reference (do not duplicate) `suite/_docs/10_auth.md` from this task spec and from the WebApi component description.
|
||||
|
||||
### Excluded
|
||||
|
||||
- `GET /users/me` and `PUT /users/me` endpoints — explicitly not shipped (decision recorded in task discussion: nothing depends on them, the suite-level "hosted on every .NET API instance" policy is over-specified for satellite-provider).
|
||||
- Permission-claim enforcement on the existing endpoints — they only require *any* valid token in this PBI. Per-endpoint permission checks land later (AZ-488 enforces `permissions` claim contains `GPS` for the upload endpoint).
|
||||
- Refresh/logout endpoints — admin API only, per the suite contract.
|
||||
- Issuer/audience claim validation — see Constraints below.
|
||||
- Asymmetric (RS256 / ES256) signature support — out of scope; the suite contract specifies HMAC.
|
||||
- JWKS endpoint fetching — not needed for HMAC.
|
||||
- Rate limiting on `401` responses — separate hardening item from `_docs/05_security/security_report.md`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Anonymous request returns 401**
|
||||
Given the API is running with `JWT_SECRET` configured
|
||||
When any request to any endpoint (except Swagger metadata) is sent without an `Authorization` header
|
||||
Then the response is HTTP 401 Unauthorized.
|
||||
|
||||
**AC-2: Expired token returns 401**
|
||||
Given a JWT signed with the configured secret but with `exp` in the past
|
||||
When the API receives a request carrying that token
|
||||
Then the response is HTTP 401 Unauthorized and the body identifies the failure reason at the `WWW-Authenticate` header level (no leakage of internal details in the response body).
|
||||
|
||||
**AC-3: Invalid signature returns 401**
|
||||
Given a JWT whose payload has been tampered with so the HMAC signature no longer verifies
|
||||
When the API receives a request carrying that token
|
||||
Then the response is HTTP 401 Unauthorized.
|
||||
|
||||
**AC-4: Valid token reaches the handler unchanged**
|
||||
Given a JWT signed with the configured secret with `exp` in the future
|
||||
When the API receives a `GET /api/satellite/tiles/latlon` request carrying that token with valid query parameters
|
||||
Then the response is identical (status, headers other than `Authorization`-related, body) to the pre-AZ-487 behavior for the same request.
|
||||
|
||||
**AC-5: Startup fails on missing or short secret**
|
||||
Given `JWT_SECRET` is unset, empty, or shorter than 32 bytes
|
||||
When the API starts
|
||||
Then startup fails with a clear error message identifying the missing/invalid secret; the API does not bind to its port.
|
||||
|
||||
**AC-6: HttpContext.User exposes claims**
|
||||
Given a JWT containing claims `sub`, `email`, `role`, `permissions`
|
||||
When a handler reads `HttpContext.User`
|
||||
Then `HttpContext.User.Identity.IsAuthenticated` is `true`, the `sub` claim is present, and the `permissions` claim values are accessible via the standard claims API.
|
||||
|
||||
**AC-7: Swagger UI works with the Authorize button**
|
||||
Given the API is running in Development
|
||||
When the operator opens Swagger UI, clicks "Authorize", pastes a valid token, and triggers any endpoint
|
||||
Then the request is sent with the Bearer token and the response is whatever the handler would return for an authenticated request.
|
||||
|
||||
**AC-8: All existing tests pass with the test token attached**
|
||||
Given the test infrastructure attaches a valid dev-token to every test HTTP request
|
||||
When `scripts/run-tests.sh --full` runs
|
||||
Then all 213 unit tests + the existing 5 smoke scenarios + the new JWT integration tests pass.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
- JWT validation per request must add < 1 ms overhead (HMAC-SHA256 + claims parsing); no cryptographic operations beyond what `JwtBearer` does by default. No caching required.
|
||||
|
||||
**Compatibility**
|
||||
- The HTTP contract change (every endpoint can now return 401) is documented in the architecture doc. No request/response schema field changes for valid-token callers.
|
||||
- Existing callers (`gps-denied-onboard`, mission planner UI) MUST coordinate to attach the Bearer token they already hold from the admin API. This is a coordination cost flagged as a deployment risk in Risks below.
|
||||
|
||||
**Reliability**
|
||||
- Missing/invalid `JWT_SECRET` is fail-fast at startup, never at first request — operators learn about the misconfiguration immediately, not on first user traffic.
|
||||
|
||||
**Security**
|
||||
- Secret length validation enforces ≥ 32 bytes — RFC 2104 §3 minimum for HMAC-SHA256.
|
||||
- `RequireExpirationTime = true` rejects tokens that omit `exp` (defense against forged "never-expires" tokens).
|
||||
- `ClockSkew = TimeSpan.FromSeconds(30)` is the maximum drift; do not relax further without explicit security review.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|--------------|------------------|
|
||||
| AC-5 | `AddSatelliteJwt` with missing `Jwt:Secret` configuration | Startup throws a clear `InvalidOperationException` (or equivalent) naming the missing key |
|
||||
| AC-5 | `AddSatelliteJwt` with `Jwt:Secret` shorter than 32 bytes | Startup throws with a message identifying the minimum length requirement |
|
||||
| AC-1, AC-3, AC-4 | `JwtTokenFactory` test helper round-trip via `TokenValidationParameters` | Tokens minted by the helper validate; tampered tokens fail validation |
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|--------------------------|--------------|-------------------|-----------------|
|
||||
| AC-1 | API running with valid `JWT_SECRET` | Send `GET /api/satellite/tiles/latlon?...` with NO `Authorization` header | HTTP 401 | Compatibility |
|
||||
| AC-2 | API running with valid `JWT_SECRET`; expired token minted | Send `GET /api/satellite/region/{id}` with the expired token | HTTP 401 | — |
|
||||
| AC-3 | API running with valid `JWT_SECRET`; tampered token | Send any request with the tampered token | HTTP 401 | Security |
|
||||
| AC-4 | API running with valid `JWT_SECRET`; valid token | Send `GET /api/satellite/tiles/latlon?Latitude=...&Longitude=...&ZoomLevel=18` | HTTP 200 with the tile metadata response (identical to pre-AZ-487 behavior) | Performance |
|
||||
| AC-7 | API running in Development with Swagger UI accessible | Open Swagger, Authorize with a token, invoke an endpoint | Request includes `Authorization: Bearer <token>`, endpoint returns its normal response | — |
|
||||
| AC-8 | Full test environment | Run `scripts/run-tests.sh --full` | All tests pass | — |
|
||||
|
||||
## Constraints
|
||||
|
||||
- The suite contract `suite/_docs/10_auth.md` is authoritative for token shape and signing key distribution. This task implements the validator side; if the contract changes (e.g., admin migrates to RS256), this is a follow-up task.
|
||||
- HMAC secret MUST come from the `JWT_SECRET` env var — never from a checked-in config file. The dev placeholder in `appsettings.Development.json` is acceptable because the file is committed and the placeholder is clearly fake; the `JWT_SECRET` env var (when set) overrides it.
|
||||
- No new cross-component `ProjectReference` — JWT plumbing lives entirely in `SatelliteProvider.Api`.
|
||||
- Issuer/audience claims are NOT validated in this PBI because the suite contract does not specify expected values. If the admin team confirms specific values later, add `ValidateIssuer = true` + `ValidIssuer = <value>` and same for audience as a small follow-up — not as part of this task.
|
||||
- ClockSkew tightened from the JwtBearer default (5 minutes) to 30 seconds; document the reason in code (defense in depth — operators in mixed-clock environments may need to override, but the default should be tight).
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Existing callers break the moment AZ-487 deploys**
|
||||
- *Risk*: `gps-denied-onboard` and mission planner UI currently call satellite-provider with no `Authorization` header. The instant `.RequireAuthorization()` lands, every existing call returns 401 — production-incident-grade breaking change.
|
||||
- *Mitigation*:
|
||||
- Coordinate the deploy with `gps-denied-onboard` and UI teams BEFORE merge: confirm both already hold a valid JWT from the admin API and can attach it to outbound calls.
|
||||
- Stage the deploy: ship to `dev` only first, validate that suite e2e tests pass, then promote to `stage`/`prod`.
|
||||
- Feature flag option (rejected during planning): a compile-time/config flag to bypass auth was considered. Decision: NO bypass flag — auth bypasses tend to become permanent. Coordinate the rollout instead.
|
||||
|
||||
**Risk 2: Test infrastructure leaks the dev secret**
|
||||
- *Risk*: Hard-coding the test secret in test source could let a careless copy-paste land it in production config.
|
||||
- *Mitigation*:
|
||||
- Test secret is ≥ 32 bytes BUT clearly tagged `TEST-ONLY-` prefix.
|
||||
- Test secret is read from an env var (`SATELLITE_TEST_JWT_SECRET`) in the test runner, not hard-coded — same separation pattern as production.
|
||||
- `.gitignore` already excludes `.env`; ensure no `.env.test` file is added without scrubbing.
|
||||
|
||||
**Risk 3: `JwtBearer` package version drift vs Microsoft.AspNetCore.OpenApi**
|
||||
- *Risk*: Picking a major version that mismatches the existing ASP.NET Core 8 packages introduces transitive-dependency conflicts at build time. The Security Audit also flagged a recommended bump of `Microsoft.AspNetCore.OpenApi 8.0.21 → 8.0.25`.
|
||||
- *Mitigation*:
|
||||
- Pin `Microsoft.AspNetCore.Authentication.JwtBearer` to the same minor version family as the rest of the ASP.NET Core 8 packages already in the project.
|
||||
- If the OpenApi bump from `_docs/05_security/security_report.md` happens in this PBI, align JwtBearer to the same version. Otherwise pin to the existing `8.0.21` series.
|
||||
|
||||
**Risk 4: HS256 secret rotation invalidates all live tokens**
|
||||
- *Risk*: The HMAC pattern means rotating `JWT_SECRET` invalidates every issued token across every API instance simultaneously — there's no overlap window unless the validator accepts both old and new secrets briefly.
|
||||
- *Mitigation*:
|
||||
- Out of scope for this PBI — operationally the suite's current rotation policy is "all tokens become invalid; all clients re-login". Document this in the WebApi component description so operators know.
|
||||
- If multi-secret support is needed later (admin team adds RS256 + JWKS), it's a follow-up task.
|
||||
|
||||
## Cross-component coordination
|
||||
|
||||
This PBI changes the HTTP contract observed by:
|
||||
|
||||
- `gps-denied-onboard` — must attach Bearer token to outbound calls; coordinate before merge.
|
||||
- `mission planner UI` — same.
|
||||
- Any other satellite-provider consumer not yet inventoried.
|
||||
|
||||
A smoke test in the suite-level e2e harness (`suite/e2e/`) MUST be updated alongside this PBI's deploy, OR temporarily skipped with an issue-tracked unskip task. Surface this to the suite repo owner during code review.
|
||||
@@ -0,0 +1,260 @@
|
||||
# UAV tile upload endpoint with batch + 5-rule quality gate
|
||||
|
||||
**Task**: AZ-488_uav_tile_upload
|
||||
**Name**: UAV tile upload endpoint
|
||||
**Description**: Replace the 501 stub at `POST /api/satellite/upload` with a JWT-authenticated multipart batch endpoint that ingests UAV-captured satellite tiles, runs each item through a 5-rule quality gate, and persists accepted tiles via the AZ-484 multi-source storage path with `source='uav'`.
|
||||
**Complexity**: 8 points (OVER 5 SP CAP — explicit user override accepted during planning)
|
||||
**Dependencies**:
|
||||
- AZ-487 (JWT validation baseline) — must merge first; this task adds permission-claim enforcement on top
|
||||
- AZ-484 (Multi-source tile storage schema) — already merged; consumes the frozen `tile-storage` v1.0.0 contract
|
||||
**Component**: WebApi (`SatelliteProvider.Api`) + TileDownloader (`SatelliteProvider.Services.TileDownloader` — for the quality gate + storage helper) + DataAccess (consumer only)
|
||||
**Tracker**: AZ-488
|
||||
**Epic**: AZ-483 (Multi-source tile storage + UAV upload)
|
||||
|
||||
## Problem
|
||||
|
||||
The Architecture Vision in `_docs/02_document/architecture.md` commits to a multi-producer tile model where Google Maps satellite imagery and UAV-captured imagery coexist per cell, with the most-recent across sources winning on read. AZ-484 delivered the storage half of that vision (schema + repository + Google Maps producer side). What's missing is the second producer: an HTTP endpoint that lets `gps-denied-onboard` and other UAV-equipped clients push freshly-captured tiles into the same `tiles` table. Without this endpoint, the multi-source design is theoretical — only Google Maps writes today, so the AZ-484 work has no second producer to validate against in production.
|
||||
|
||||
The endpoint must also prevent the obvious failure mode: a UAV upload with a blank, wrong-size, stale, or corrupt image silently displaces the better Google Maps imagery for that cell on the next read. That's what the quality gate is for.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `POST /api/satellite/upload` accepts a multipart batch of UAV tiles in a single request, runs each item through a quality gate, persists the accepted ones via `ITileRepository.InsertAsync` with `source='uav'`, and returns per-item accept/reject results in a single HTTP 200 response.
|
||||
- A UAV upload for a cell that already has a Google Maps row coexists with that row (per the AZ-484 contract Inv-3); subsequent reads return whichever has the higher `captured_at`.
|
||||
- A second UAV upload for the same cell (same source) UPSERTs — exactly one `source='uav'` row per cell, refreshed `captured_at` and `file_path`.
|
||||
- An unauthenticated request returns 401 (from AZ-487). A request with a valid JWT but missing the `GPS` permission claim returns 403.
|
||||
- All five quality-gate rules are enforced; rejected items appear in the response with a machine-readable reason code so the client can log/retry.
|
||||
- UAV files land at `./tiles/uav/{tile_zoom}/{tile_x}/{tile_y}.jpg`; existing Google Maps files stay at `./tiles/{tile_zoom}/{tile_x}/{tile_y}.jpg` (grandfathered — no migration).
|
||||
- The frozen `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0 documents the request/response shape, status codes, and reject reason codes for downstream consumers.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- New batch request DTO replacing `SatelliteProvider.Api.DTOs.UploadImageRequest`:
|
||||
- `UavTileBatchUploadRequest`: a multipart form binding with a JSON `metadata` field carrying an array of per-tile metadata records (`Latitude`, `Longitude`, `TileZoom`, `TileSizeMeters`, `CapturedAt`) plus an `IFormFileCollection` of the corresponding image files. Per-item correlation by ordinal index (file `[i]` corresponds to metadata `[i]`).
|
||||
- The pre-existing `UploadImageRequest` (single-image with photogrammetry fields like `Height`, `FocalLength`, `SensorWidth`, `SensorHeight`) is removed; nothing currently consumes it (the endpoint was a 501 stub).
|
||||
- New response DTO `UavTileBatchUploadResponse`:
|
||||
- `Items: UavTileUploadResultItem[]` — per-item result.
|
||||
- `UavTileUploadResultItem`: `Index: int`, `Status: "accepted" | "rejected"`, `TileId: Guid?` (set when accepted), `RejectReason: string?` (set when rejected, machine-readable code), `RejectDetails: string?` (human-readable extra info; never leaks server internals).
|
||||
- New handler `UavTileUploadHandler` (or equivalent service) in `SatelliteProvider.Services.TileDownloader`:
|
||||
- Iterates the batch.
|
||||
- Runs each item through the quality gate (see below) — short-circuits on the first violation, records the reason.
|
||||
- For accepted items: writes the JPEG to `./tiles/uav/{TileZoom}/{TileX}/{TileY}.jpg` (the directory is created on demand), constructs a `TileEntity` with `Source = TileSourceConverter.ToWireValue(TileSource.Uav)`, `CapturedAt` from the request, `FilePath` set to the new path; calls `ITileRepository.InsertAsync` (which UPSERTs per the 5-column unique key from AZ-484).
|
||||
- Returns `UavTileBatchUploadResponse`.
|
||||
- Quality gate `UavTileQualityGate` in `SatelliteProvider.Services.TileDownloader`, exposed via interface for testability:
|
||||
- **Rule 1 (Format)**: image content-type is `image/jpeg` AND magic bytes (`FF D8 FF`) confirm JPEG. Reject reason: `INVALID_FORMAT`.
|
||||
- **Rule 2 (Size band)**: `5 KiB ≤ image bytes ≤ 5 MiB`. Bounds configurable via `UavQualityConfig.MinBytes` / `MaxBytes`. Reject reason: `SIZE_OUT_OF_BAND`.
|
||||
- **Rule 3 (Dimensions)**: image width AND height equal `MapConfig.TileSizePixels` (default 256). Strict equality; no tolerance. Reject reason: `WRONG_DIMENSIONS`.
|
||||
- **Rule 4 (Captured-at age)**: `captured_at` is not in the future (allow ≤ 30s clock skew) AND not older than 7 days from `DateTime.UtcNow` (configurable via `UavQualityConfig.MaxAgeDays` default 7). Reject reasons: `CAPTURED_AT_FUTURE`, `CAPTURED_AT_TOO_OLD`.
|
||||
- **Rule 5 (Blank/uniform heuristic)**: compute pixel luminance variance via ImageSharp on a downsampled (e.g., 32×32) version of the image. Reject if variance < threshold (`UavQualityConfig.MinLuminanceVariance` default ~10 on 0-255 scale). Reject reason: `IMAGE_TOO_UNIFORM`.
|
||||
- Rule order: 1, 2, 3, 4, 5. Rule 1 (format) runs first because it's cheapest and gates the others; Rule 5 runs last because it's the most expensive (decode + downsample).
|
||||
- Permission enforcement on the endpoint: `.RequireAuthorization(policy => policy.RequireClaim("permissions", "GPS"))` (or equivalent — match the suite-wide claim shape; if `permissions` is a string-array claim, use a `ClaimsAuthorizationRequirement` that checks array membership).
|
||||
- New configuration class `SatelliteProvider.Common.Configs.UavQualityConfig`:
|
||||
- `MinBytes` (int, default `5 * 1024`)
|
||||
- `MaxBytes` (int, default `5 * 1024 * 1024`)
|
||||
- `MaxAgeDays` (int, default `7`)
|
||||
- `MinLuminanceVariance` (double, default `10.0`)
|
||||
- `MaxBatchSize` (int, default `100` — see Constraints)
|
||||
- Wired in `Program.cs` via `builder.Services.Configure<UavQualityConfig>(builder.Configuration.GetSection("UavQuality"));`.
|
||||
- `Program.cs` updates:
|
||||
- Replace the 501 `UploadImage` handler with the new handler invocation.
|
||||
- Update endpoint mapping: `app.MapPost("/api/satellite/upload", UploadUavTileBatch).Accepts<UavTileBatchUploadRequest>("multipart/form-data").RequireAuthorization(policy => policy.RequireClaim("permissions", "GPS")).WithOpenApi(...)`.
|
||||
- Keep `.DisableAntiforgery()` (matches existing endpoint stub; multipart batch with JWT auth doesn't need antiforgery).
|
||||
- Per-source file-path strategy:
|
||||
- UAV: `./tiles/uav/{TileZoom}/{TileX}/{TileY}.jpg` — new sub-tree, created on demand.
|
||||
- Google Maps: stays at `./tiles/{TileZoom}/{TileX}/{TileY}.jpg` — grandfathered, no file migration. The path itself implicitly identifies the source for legacy rows; the `tiles.source` column is the authoritative source marker for new code.
|
||||
- `TileService.BuildTileEntity` (Google Maps producer side, AZ-484) requires no change — it already writes to the bare `./tiles/{z}/{x}/{y}.jpg` path.
|
||||
- Documentation:
|
||||
- `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0 — new contract file. Status `frozen` upon implementation completion. Includes: request shape, response shape, reject reason codes (closed enumeration), HTTP status codes, auth + permission requirements, file-path layout, per-source UPSERT semantics referenced from `tile-storage.md`.
|
||||
- `_docs/02_document/architecture.md` § Architecture Vision: brief mention that UAV ingestion is now live as the second producer.
|
||||
- `_docs/02_document/glossary.md`: add `UAV Tile Upload`, `Quality Gate`, and the 5 reject-reason codes as defined terms.
|
||||
- `_docs/02_document/components/01_web_api/description.md`: document the new endpoint + permission requirement.
|
||||
- `_docs/02_document/components/03_tile_downloader/description.md`: document the new `UavTileQualityGate` and the per-source file-path layout.
|
||||
- `_docs/02_document/data_model.md`: note that `file_path` semantics now depend on `source` (`uav` rows live under `./tiles/uav/`, `google_maps` rows live at the bare `./tiles/` root).
|
||||
- Tests — Unit:
|
||||
- Each quality-gate rule in isolation (1 happy path + ≥1 reject path per rule = 10+ test methods).
|
||||
- Quality gate rule ordering test (when multiple rules would reject, the first applicable reason is reported).
|
||||
- `UavTileBatchUploadRequest` DTO model-binding round-trip (multipart parse + JSON metadata parse).
|
||||
- File-path construction test (UAV path matches `./tiles/uav/{z}/{x}/{y}.jpg` format; safe against path-traversal in tile coordinates — coordinates are typed `int` so this is a smoke test, not a deep injection test).
|
||||
- Tests — Integration:
|
||||
- `UavUploadTests.HappyPath_BatchOfTwoTiles_Returns200_PersistsRows`: upload a 2-item batch with all-good tiles; assert HTTP 200, both items accepted, both rows present in `tiles` with `source='uav'`, file paths exist on disk.
|
||||
- `UavUploadTests.MixedBatch_PartialReject_Returns200_WithPerItemResults`: upload a 3-item batch where item 1 is good, item 2 fails dimensions, item 3 fails JPEG magic; assert HTTP 200, results array has correct per-item statuses + reason codes.
|
||||
- `UavUploadTests.MultiSourceCoexistence_AZ484_Cycle2`: pre-seed the cell with a `google_maps` row; upload a `uav` tile for the same cell with later `captured_at`; assert both rows exist in `tiles`, the `uav` row wins on subsequent `GetByTileCoordinatesAsync` (validates AZ-484 selection rule under live multi-source load).
|
||||
- `UavUploadTests.SameSourceUpsert_AZ484_Cycle2`: upload a UAV tile for a cell, then upload a second UAV tile for the same cell with later `captured_at`; assert exactly one `source='uav'` row remains, `captured_at` and `file_path` updated, file on disk overwritten.
|
||||
- `UavUploadTests.NoToken_Returns401`: unauthenticated upload returns 401 (validates AZ-487 coverage extends to this endpoint).
|
||||
- `UavUploadTests.ValidTokenWithoutGpsPermission_Returns403`: JWT with `permissions: ["FL"]` (no GPS) returns 403.
|
||||
- `UavUploadTests.ValidTokenWithGpsPermission_Returns200`: JWT with `permissions: ["GPS"]` proceeds normally.
|
||||
- `UavUploadTests.OversizedBatch_Returns400`: batch with > `MaxBatchSize` items returns 400 with envelope error (not a per-item reject — the envelope itself is malformed).
|
||||
|
||||
### Excluded
|
||||
|
||||
- Geofence-whitelist quality rule — explicitly removed during planning ("remove this gate for now"). May re-emerge as a follow-up PBI if operations finds UAV uploads landing in unexpected regions.
|
||||
- Async / queued processing — the batch is sync. If batch-size limits become painful, async + status-poll is a follow-up redesign, not part of this PBI.
|
||||
- File-path migration for existing `google_maps` tiles — explicit user choice (grandfather); no migration ships in this PBI.
|
||||
- Multi-image tile fusion (averaging UAV + Google Maps for the same cell) — out of scope; the AZ-484 selection rule (most-recent winner) remains the only resolution.
|
||||
- New `permissions` claim values — uses existing `GPS`. If `SAT` (or similar) is needed later, it's a coordination task with the admin team, not a code change here.
|
||||
- UAV-specific photogrammetry metadata (Height, FocalLength, SensorWidth, SensorHeight from the old `UploadImageRequest`) — explicitly removed from scope by user during planning ("not necessary for now"). The legacy DTO is deleted with the stub.
|
||||
- Image storage outside the local filesystem (S3, GCS, etc.) — out of scope; matches existing google_maps behavior.
|
||||
- Compression / re-encoding of accepted UAV JPEGs — stored as-received.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Happy-path single-item batch persists with source='uav'**
|
||||
Given an authenticated request with a valid `GPS` permission claim
|
||||
And a 1-item batch with a 256×256 JPEG, captured_at = now, size 50 KB
|
||||
When the request is sent to `POST /api/satellite/upload`
|
||||
Then the response is HTTP 200, the item is `accepted`, `tile_id` is returned, the `tiles` table has a new row with `source='uav'`, `captured_at` matches the request, and the file exists at `./tiles/uav/{z}/{x}/{y}.jpg`.
|
||||
|
||||
**AC-2: Multi-item batch with partial reject returns per-item results**
|
||||
Given an authenticated request with a valid `GPS` permission claim
|
||||
And a 3-item batch where: item 1 is valid; item 2 has dimensions 512×512 (wrong); item 3 has bytes that don't start with the JPEG magic
|
||||
When the request is sent
|
||||
Then the response is HTTP 200, items 2 and 3 are `rejected` with reasons `WRONG_DIMENSIONS` and `INVALID_FORMAT` respectively, item 1 is `accepted` with a `tile_id`, and exactly one new row appears in `tiles` (item 1 only).
|
||||
|
||||
**AC-3: Multi-source coexistence with Google Maps**
|
||||
Given a cell at `(lat=L, lon=Ln, tile_zoom=18, tile_size_meters=200)` already has a `source='google_maps'` row with `captured_at=T1`
|
||||
When a UAV upload arrives for the same cell with `captured_at=T2 > T1`
|
||||
Then both rows exist in `tiles` after upload (no overwrite), and a subsequent `GetByTileCoordinatesAsync` returns the `source='uav'` row (per the AZ-484 selection rule).
|
||||
|
||||
**AC-4: Same-source UPSERT replaces UAV row**
|
||||
Given a cell has a `source='uav'` row with `captured_at=T1` and `file_path=./tiles/uav/{z}/{x}/{y}.jpg`
|
||||
When a second UAV upload arrives for the same cell with `captured_at=T2 > T1`
|
||||
Then exactly one `source='uav'` row remains for that cell, `captured_at=T2`, `file_path` is updated (or unchanged if the path is identical, which it is in this case), the JPEG on disk is overwritten with the new bytes, and any pre-existing `source='google_maps'` row is untouched.
|
||||
|
||||
**AC-5: Unauthenticated request returns 401**
|
||||
Given the API is running with AZ-487 in place
|
||||
When `POST /api/satellite/upload` is called without an `Authorization` header
|
||||
Then the response is HTTP 401 and no row or file is created.
|
||||
|
||||
**AC-6: Authenticated but missing permission returns 403**
|
||||
Given an authenticated request with `permissions: ["FL"]` (no `GPS`)
|
||||
When `POST /api/satellite/upload` is called with an otherwise-valid batch
|
||||
Then the response is HTTP 403 and no row or file is created.
|
||||
|
||||
**AC-7: Quality-gate rule enforcement (per rule)**
|
||||
Given an authenticated request with `GPS` permission
|
||||
When the batch contains an item violating any single quality rule
|
||||
Then that item is `rejected` with the corresponding reason code:
|
||||
- 7a: non-JPEG content-type or wrong magic bytes → `INVALID_FORMAT`
|
||||
- 7b: bytes < `MinBytes` or > `MaxBytes` → `SIZE_OUT_OF_BAND`
|
||||
- 7c: width or height ≠ `MapConfig.TileSizePixels` → `WRONG_DIMENSIONS`
|
||||
- 7d: `captured_at` > now + 30s → `CAPTURED_AT_FUTURE`; `captured_at` < now − `MaxAgeDays` → `CAPTURED_AT_TOO_OLD`
|
||||
- 7e: pixel luminance variance below `MinLuminanceVariance` → `IMAGE_TOO_UNIFORM`
|
||||
|
||||
**AC-8: Oversized batch returns 400**
|
||||
Given an authenticated request with `GPS` permission
|
||||
And a batch with `MaxBatchSize + 1` items
|
||||
When the request is sent
|
||||
Then the response is HTTP 400 with an envelope error indicating batch-size violation; no row or file is created.
|
||||
|
||||
**AC-9: Contract documentation matches implementation**
|
||||
Given the implementation is complete
|
||||
When `_docs/02_document/contracts/api/uav-tile-upload.md` is inspected
|
||||
Then it has Status `frozen`, Version `1.0.0`, the documented request/response shape matches the actual DTOs, the reject-reason enum matches the implemented codes exactly, and it cross-references `_docs/02_document/contracts/data-access/tile-storage.md`.
|
||||
|
||||
**AC-10: Existing tests continue to pass**
|
||||
Given AZ-487 + AZ-488 are merged
|
||||
When `scripts/run-tests.sh --full` runs
|
||||
Then all unit tests + smoke tests + new AZ-487 JWT tests + new AZ-488 UAV tests pass; no AZ-484 integration test regresses (validates that the multi-source storage continues to behave per its frozen contract under live UAV write load).
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
- Per-item quality-gate cost: target < 50 ms for the typical 256×256 / 50 KB JPEG. Rule 5 (luminance variance) dominates due to the decode; downsample to 32×32 BEFORE variance computation to keep the cost bounded.
|
||||
- Endpoint p95 end-to-end: target < 2 s for a 10-item batch on the dev hardware. Defer formal perf measurement to the cycle's Step 15 (Performance Test); add a PT-08 NFR to `_docs/02_document/tests/performance-tests.md` with the matching runner-script entry IN THE SAME COMMIT (per cycle 1 retro lesson — see `_docs/06_metrics/retro_2026-05-11.md` Action 2).
|
||||
|
||||
**Compatibility**
|
||||
- Replaces a 501 stub — no real consumer was using the old shape, so the DTO change is not a breaking change in practice.
|
||||
- Adds the contract `uav-tile-upload.md` v1.0.0 — frozen on merge; future shape changes follow the contract's Versioning Rules.
|
||||
- Coexists with the AZ-484 frozen `tile-storage` v1.0.0 contract; this PBI is a CONSUMER of that contract on the write side.
|
||||
|
||||
**Reliability**
|
||||
- Per-item failures (e.g., disk write failure mid-batch) MUST be reported in the per-item result with a clear reason — never silently dropped, never propagated to fail the entire batch unless the failure is envelope-level (deserialization, batch-size violation, auth).
|
||||
- The DB row and the on-disk file must be written in the order: file first, then row (so a partial failure leaves an orphan file rather than a db-row pointing at nothing). Document the orphan-file cleanup as an out-of-scope ops concern.
|
||||
- Multi-source coexistence behavior MUST match the frozen `tile-storage.md` v1.0.0 contract Inv-3 (per-source UPSERT) exactly — validated by `UavUploadTests.MultiSourceCoexistence_AZ484_Cycle2` and `SameSourceUpsert_AZ484_Cycle2`.
|
||||
|
||||
**Security**
|
||||
- Permission claim check is mandatory; do NOT add a "skip permission check in dev" flag.
|
||||
- Reject-reason `RejectDetails` strings MUST NOT leak server-side paths, exception types, or internal identifiers (per `_docs/05_security/owasp_review.md` A05/A06 guidance).
|
||||
- File-path construction takes integer coordinates only — no string concatenation of caller-supplied paths.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|--------------|------------------|
|
||||
| AC-7a | `UavTileQualityGate.Validate` with content-type `image/png` | Reject with `INVALID_FORMAT` |
|
||||
| AC-7a | `UavTileQualityGate.Validate` with content-type `image/jpeg` but wrong magic bytes | Reject with `INVALID_FORMAT` |
|
||||
| AC-7a | `UavTileQualityGate.Validate` with valid JPEG | Rule 1 passes; falls through to rule 2 |
|
||||
| AC-7b | `UavTileQualityGate.Validate` with bytes < `MinBytes` | Reject with `SIZE_OUT_OF_BAND` |
|
||||
| AC-7b | `UavTileQualityGate.Validate` with bytes > `MaxBytes` | Reject with `SIZE_OUT_OF_BAND` |
|
||||
| AC-7c | `UavTileQualityGate.Validate` with 512×512 image | Reject with `WRONG_DIMENSIONS` |
|
||||
| AC-7c | `UavTileQualityGate.Validate` with 256×256 image | Rule 3 passes |
|
||||
| AC-7d | `UavTileQualityGate.Validate` with `captured_at = now + 1h` | Reject with `CAPTURED_AT_FUTURE` |
|
||||
| AC-7d | `UavTileQualityGate.Validate` with `captured_at = now - 8 days` (default `MaxAgeDays=7`) | Reject with `CAPTURED_AT_TOO_OLD` |
|
||||
| AC-7e | `UavTileQualityGate.Validate` with a uniform-grey JPEG | Reject with `IMAGE_TOO_UNIFORM` |
|
||||
| AC-7e | `UavTileQualityGate.Validate` with a high-variance natural-image JPEG | Rule 5 passes |
|
||||
| AC-2 | Quality-gate rule ordering: image that fails BOTH rule 1 (wrong format) AND rule 3 (wrong dimensions) | First-failing rule (1) is the reported reason |
|
||||
| AC-1 | `UavTileUploadHandler` end-to-end with mocked repository: 1-item happy-path batch | `InsertAsync` called once with `Source = "uav"`, `CapturedAt` from request, file path `./tiles/uav/{z}/{x}/{y}.jpg` |
|
||||
| AC-2 | `UavTileUploadHandler` with 3-item mixed batch (mocked repo) | `InsertAsync` called exactly once (only for the accepted item); response has 3 result items with correct statuses |
|
||||
|
||||
## Blackbox Tests
|
||||
|
||||
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|
||||
|--------|--------------------------|--------------|-------------------|-----------------|
|
||||
| AC-1 | Empty `tiles` table; valid JWT with GPS perm; 1-item batch with valid 256×256 JPEG, captured_at=now | POST `/api/satellite/upload` | HTTP 200; 1 row in `tiles` with `source='uav'`; file at `./tiles/uav/{z}/{x}/{y}.jpg` exists | Reliability |
|
||||
| AC-2 | Empty `tiles` table; valid JWT; 3-item mixed batch | POST `/api/satellite/upload` | HTTP 200; per-item results `[accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT]`; exactly 1 new row in `tiles` | Compatibility |
|
||||
| AC-3 | Pre-seed `tiles` with a `source='google_maps'` row at `(L, Ln, 18, 200)` with `captured_at = now - 1h` | Upload a UAV tile for the same cell with `captured_at = now` | HTTP 200; both rows exist; subsequent `GET /api/satellite/tiles/latlon` for that cell returns the UAV row's metadata | Reliability |
|
||||
| AC-4 | Pre-seed `tiles` with a `source='uav'` row at the cell with `captured_at = now - 1h` and known file content | Upload a UAV tile for the same cell with new bytes and `captured_at = now` | HTTP 200; exactly one `source='uav'` row remains with new `captured_at` and `file_path`; on-disk JPEG bytes match the new upload | Reliability |
|
||||
| AC-5 | API running | POST `/api/satellite/upload` with no `Authorization` header | HTTP 401; no row, no file | Security |
|
||||
| AC-6 | Valid JWT with `permissions: ["FL"]` (no GPS) | POST `/api/satellite/upload` | HTTP 403; no row, no file | Security |
|
||||
| AC-8 | Valid JWT with GPS perm; batch with `MaxBatchSize + 1 = 101` items | POST `/api/satellite/upload` | HTTP 400 envelope error; no row, no file | — |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Per-source file-path strategy is fixed: UAV → `./tiles/uav/{z}/{x}/{y}.jpg`; Google Maps → `./tiles/{z}/{x}/{y}.jpg` (grandfathered). Do NOT migrate Google Maps files to a sibling sub-tree in this PBI; that's a separate task if ever needed.
|
||||
- Per-source UPSERT semantics MUST come from `ITileRepository.InsertAsync` as-implemented in AZ-484 — do NOT introduce a new write path or bypass the repository.
|
||||
- `TileSource` enum + `TileSourceConverter` from `SatelliteProvider.Common.Enums` are the only sanctioned way to set the `source` wire value (per L-001 in `_docs/LESSONS.md` — never rely on Dapper TypeHandler<T> for enum reads).
|
||||
- `MaxBatchSize` is a hard cap — no chunked / streaming upload variant in this PBI. If batches > 100 are needed, that's a follow-up redesign (likely async + status-poll).
|
||||
- Reject-reason codes are a closed enumeration in v1.0.0 of the contract. Adding a new reason requires a contract minor-version bump (per `tile-storage.md` Versioning Rules pattern).
|
||||
- The `permissions` claim check is hard-coded to require `GPS`. When the admin team coordinates a new `SAT` permission, that's a follow-up code + contract minor-bump.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: Quality-gate threshold tuning is wrong**
|
||||
- *Risk*: The variance threshold (`MinLuminanceVariance = 10`) is a guess. Real UAV imagery from `gps-denied-onboard` may legitimately have low variance (e.g., over uniform terrain), causing false rejects. Or the threshold may be too lax and let actually-blank tiles through.
|
||||
- *Mitigation*:
|
||||
- Threshold is configurable via `UavQualityConfig.MinLuminanceVariance` — operators can tune per-deployment without code change.
|
||||
- Reject-reason `IMAGE_TOO_UNIFORM` makes false rejects diagnosable client-side.
|
||||
- Add an explicit follow-up task tagged "TUNE-THRESHOLDS" in the dependencies table so the threshold is re-evaluated after the first week of real UAV traffic.
|
||||
|
||||
**Risk 2: File-path collision between concurrent UAV uploads**
|
||||
- *Risk*: Two UAV uploads for the exact same `(z, x, y)` arriving concurrently both compute the same file path; one overwrites the other's bytes after the first write completes but before the second's DB UPSERT.
|
||||
- *Mitigation*:
|
||||
- The DB UPSERT (single transaction at the row level) is the authoritative serialization point. The file-on-disk represents whichever upload last wrote — which is acceptable because both rows share the same per-source `file_path` after UPSERT, and the `captured_at` UPSERT semantics already say "later wins".
|
||||
- In the unlikely race where bytes-A are written, then bytes-B are written, then DB UPSERT for A happens AFTER UPSERT for B — the `file_path` is identical so the final state is consistent (file = B, row = B). No data corruption; just last-write-wins on bytes, same as the AZ-484 contract specifies for the row.
|
||||
- Document this in the contract under "Concurrency" so consumers don't assume causal ordering.
|
||||
|
||||
**Risk 3: Disk-fill from oversized JPEGs**
|
||||
- *Risk*: A misconfigured UAV could push tens of MB JPEGs and fill the disk despite the 5 MB cap (because the cap is per-item, not per-batch).
|
||||
- *Mitigation*:
|
||||
- `MaxBatchSize = 100` × `MaxBytes = 5 MiB` = 500 MiB worst-case per request. ASP.NET Core's default request-body size limit (30 MB or `KestrelServerOptions.Limits.MaxRequestBodySize`) will reject before that — but this PBI must explicitly set `MaxRequestBodySize` to a safe value (`builder.Services.Configure<KestrelServerOptions>(opts => opts.Limits.MaxRequestBodySize = MaxBatchSize * MaxBytes)` or explicitly cap to e.g. 50 MB and reject larger batches at the framework layer).
|
||||
- Operations alerting on disk usage — out of scope; flagged as a `_docs/_process_leftovers/` ops follow-up if not already monitored.
|
||||
|
||||
**Risk 4: PT-08 (perf NFR for UAV upload) gets recorded but the runner script is never updated**
|
||||
- *Risk*: The cycle 1 retro flagged this exact pattern (see `_docs/06_metrics/retro_2026-05-11.md` Action 2). PT-08 must NOT be added to `performance-tests.md` without a matching `scripts/run-performance-tests.sh` scenario in the same commit.
|
||||
- *Mitigation*:
|
||||
- Hard rule for the implementer: PT-08 NFR + runner-script scenario land in the same commit. If the runner work cannot fit in the PBI, PT-08 is recorded as `Deferred — harness work tracked in <follow-up ticket>`, NOT as an active scenario.
|
||||
- The cycle's Step 15 perf gate enforces this when retro Action 2 lands as a process change.
|
||||
|
||||
**Risk 5: Test fixture for blank/uniform image is brittle**
|
||||
- *Risk*: Generating a "uniform JPEG" for the rejection test depends on JPEG quantization quirks; a "uniform grey" 256×256 may have non-zero variance after JPEG compression noise.
|
||||
- *Mitigation*:
|
||||
- Generate the fixture with `SixLabors.ImageSharp.Image<L8>` (single-channel grayscale), fill with a single value, save with quality=95. Validate at fixture-generation time that the resulting file's variance is below the threshold.
|
||||
- Pin the fixture as a checked-in test asset; do NOT regenerate at test runtime.
|
||||
|
||||
## Contract
|
||||
|
||||
This task produces the contract at `_docs/02_document/contracts/api/uav-tile-upload.md`.
|
||||
|
||||
Consumers (`gps-denied-onboard`, future UAV-equipped clients) MUST read that file — not this task spec — to discover the request/response shape, status codes, reject reason enum, and auth requirements. The `_docs/02_document/contracts/data-access/tile-storage.md` v1.0.0 contract is consumed (this PBI is a producer of `'uav'`-source rows under that contract).
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Current Step
|
||||
flow: existing-code
|
||||
step: 9
|
||||
name: New Task
|
||||
step: 10
|
||||
name: Implement
|
||||
status: not_started
|
||||
sub_step:
|
||||
phase: 0
|
||||
|
||||
Reference in New Issue
Block a user