From 42a3cc74678c4de4f38630e0cb39741474fb6630 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 11 May 2026 21:04:49 +0300 Subject: [PATCH] [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 --- _docs/02_tasks/_dependencies_table.md | 18 +- .../todo/AZ-487_jwt_validation_baseline.md | 201 ++++++++++++++ _docs/02_tasks/todo/AZ-488_uav_tile_upload.md | 260 ++++++++++++++++++ _docs/_autodev_state.md | 4 +- 4 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 _docs/02_tasks/todo/AZ-487_jwt_validation_baseline.md create mode 100644 _docs/02_tasks/todo/AZ-488_uav_tile_upload.md diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index 9d2ed9b..3344985 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -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 diff --git a/_docs/02_tasks/todo/AZ-487_jwt_validation_baseline.md b/_docs/02_tasks/todo/AZ-487_jwt_validation_baseline.md new file mode 100644 index 0000000..30682ee --- /dev/null +++ b/_docs/02_tasks/todo/AZ-487_jwt_validation_baseline.md @@ -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 `, 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 = ` 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. diff --git a/_docs/02_tasks/todo/AZ-488_uav_tile_upload.md b/_docs/02_tasks/todo/AZ-488_uav_tile_upload.md new file mode 100644 index 0000000..a9cd05e --- /dev/null +++ b/_docs/02_tasks/todo/AZ-488_uav_tile_upload.md @@ -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(builder.Configuration.GetSection("UavQuality"));`. +- `Program.cs` updates: + - Replace the 501 `UploadImage` handler with the new handler invocation. + - Update endpoint mapping: `app.MapPost("/api/satellite/upload", UploadUavTileBatch).Accepts("multipart/form-data").RequireAuthorization(policy => policy.RequireClaim("permissions", "GPS")).WithOpenApi(...)`. + - Keep `.DisableAntiforgery()` (matches existing endpoint stub; multipart batch with JWT auth doesn't need antiforgery). +- Per-source file-path strategy: + - UAV: `./tiles/uav/{TileZoom}/{TileX}/{TileY}.jpg` — new sub-tree, created on demand. + - Google Maps: stays at `./tiles/{TileZoom}/{TileX}/{TileY}.jpg` — grandfathered, no file migration. The path itself implicitly identifies the source for legacy rows; the `tiles.source` column is the authoritative source marker for new code. + - `TileService.BuildTileEntity` (Google Maps producer side, AZ-484) requires no change — it already writes to the bare `./tiles/{z}/{x}/{y}.jpg` path. +- Documentation: + - `_docs/02_document/contracts/api/uav-tile-upload.md` v1.0.0 — new contract file. Status `frozen` upon implementation completion. Includes: request shape, response shape, reject reason codes (closed enumeration), HTTP status codes, auth + permission requirements, file-path layout, per-source UPSERT semantics referenced from `tile-storage.md`. + - `_docs/02_document/architecture.md` § Architecture Vision: brief mention that UAV ingestion is now live as the second producer. + - `_docs/02_document/glossary.md`: add `UAV Tile Upload`, `Quality Gate`, and the 5 reject-reason codes as defined terms. + - `_docs/02_document/components/01_web_api/description.md`: document the new endpoint + permission requirement. + - `_docs/02_document/components/03_tile_downloader/description.md`: document the new `UavTileQualityGate` and the per-source file-path layout. + - `_docs/02_document/data_model.md`: note that `file_path` semantics now depend on `source` (`uav` rows live under `./tiles/uav/`, `google_maps` rows live at the bare `./tiles/` root). +- Tests — Unit: + - Each quality-gate rule in isolation (1 happy path + ≥1 reject path per rule = 10+ test methods). + - Quality gate rule ordering test (when multiple rules would reject, the first applicable reason is reported). + - `UavTileBatchUploadRequest` DTO model-binding round-trip (multipart parse + JSON metadata parse). + - File-path construction test (UAV path matches `./tiles/uav/{z}/{x}/{y}.jpg` format; safe against path-traversal in tile coordinates — coordinates are typed `int` so this is a smoke test, not a deep injection test). +- Tests — Integration: + - `UavUploadTests.HappyPath_BatchOfTwoTiles_Returns200_PersistsRows`: upload a 2-item batch with all-good tiles; assert HTTP 200, both items accepted, both rows present in `tiles` with `source='uav'`, file paths exist on disk. + - `UavUploadTests.MixedBatch_PartialReject_Returns200_WithPerItemResults`: upload a 3-item batch where item 1 is good, item 2 fails dimensions, item 3 fails JPEG magic; assert HTTP 200, results array has correct per-item statuses + reason codes. + - `UavUploadTests.MultiSourceCoexistence_AZ484_Cycle2`: pre-seed the cell with a `google_maps` row; upload a `uav` tile for the same cell with later `captured_at`; assert both rows exist in `tiles`, the `uav` row wins on subsequent `GetByTileCoordinatesAsync` (validates AZ-484 selection rule under live multi-source load). + - `UavUploadTests.SameSourceUpsert_AZ484_Cycle2`: upload a UAV tile for a cell, then upload a second UAV tile for the same cell with later `captured_at`; assert exactly one `source='uav'` row remains, `captured_at` and `file_path` updated, file on disk overwritten. + - `UavUploadTests.NoToken_Returns401`: unauthenticated upload returns 401 (validates AZ-487 coverage extends to this endpoint). + - `UavUploadTests.ValidTokenWithoutGpsPermission_Returns403`: JWT with `permissions: ["FL"]` (no GPS) returns 403. + - `UavUploadTests.ValidTokenWithGpsPermission_Returns200`: JWT with `permissions: ["GPS"]` proceeds normally. + - `UavUploadTests.OversizedBatch_Returns400`: batch with > `MaxBatchSize` items returns 400 with envelope error (not a per-item reject — the envelope itself is malformed). + +### Excluded + +- Geofence-whitelist quality rule — explicitly removed during planning ("remove this gate for now"). May re-emerge as a follow-up PBI if operations finds UAV uploads landing in unexpected regions. +- Async / queued processing — the batch is sync. If batch-size limits become painful, async + status-poll is a follow-up redesign, not part of this PBI. +- File-path migration for existing `google_maps` tiles — explicit user choice (grandfather); no migration ships in this PBI. +- Multi-image tile fusion (averaging UAV + Google Maps for the same cell) — out of scope; the AZ-484 selection rule (most-recent winner) remains the only resolution. +- New `permissions` claim values — uses existing `GPS`. If `SAT` (or similar) is needed later, it's a coordination task with the admin team, not a code change here. +- UAV-specific photogrammetry metadata (Height, FocalLength, SensorWidth, SensorHeight from the old `UploadImageRequest`) — explicitly removed from scope by user during planning ("not necessary for now"). The legacy DTO is deleted with the stub. +- Image storage outside the local filesystem (S3, GCS, etc.) — out of scope; matches existing google_maps behavior. +- Compression / re-encoding of accepted UAV JPEGs — stored as-received. + +## Acceptance Criteria + +**AC-1: Happy-path single-item batch persists with source='uav'** +Given an authenticated request with a valid `GPS` permission claim +And a 1-item batch with a 256×256 JPEG, captured_at = now, size 50 KB +When the request is sent to `POST /api/satellite/upload` +Then the response is HTTP 200, the item is `accepted`, `tile_id` is returned, the `tiles` table has a new row with `source='uav'`, `captured_at` matches the request, and the file exists at `./tiles/uav/{z}/{x}/{y}.jpg`. + +**AC-2: Multi-item batch with partial reject returns per-item results** +Given an authenticated request with a valid `GPS` permission claim +And a 3-item batch where: item 1 is valid; item 2 has dimensions 512×512 (wrong); item 3 has bytes that don't start with the JPEG magic +When the request is sent +Then the response is HTTP 200, items 2 and 3 are `rejected` with reasons `WRONG_DIMENSIONS` and `INVALID_FORMAT` respectively, item 1 is `accepted` with a `tile_id`, and exactly one new row appears in `tiles` (item 1 only). + +**AC-3: Multi-source coexistence with Google Maps** +Given a cell at `(lat=L, lon=Ln, tile_zoom=18, tile_size_meters=200)` already has a `source='google_maps'` row with `captured_at=T1` +When a UAV upload arrives for the same cell with `captured_at=T2 > T1` +Then both rows exist in `tiles` after upload (no overwrite), and a subsequent `GetByTileCoordinatesAsync` returns the `source='uav'` row (per the AZ-484 selection rule). + +**AC-4: Same-source UPSERT replaces UAV row** +Given a cell has a `source='uav'` row with `captured_at=T1` and `file_path=./tiles/uav/{z}/{x}/{y}.jpg` +When a second UAV upload arrives for the same cell with `captured_at=T2 > T1` +Then exactly one `source='uav'` row remains for that cell, `captured_at=T2`, `file_path` is updated (or unchanged if the path is identical, which it is in this case), the JPEG on disk is overwritten with the new bytes, and any pre-existing `source='google_maps'` row is untouched. + +**AC-5: Unauthenticated request returns 401** +Given the API is running with AZ-487 in place +When `POST /api/satellite/upload` is called without an `Authorization` header +Then the response is HTTP 401 and no row or file is created. + +**AC-6: Authenticated but missing permission returns 403** +Given an authenticated request with `permissions: ["FL"]` (no `GPS`) +When `POST /api/satellite/upload` is called with an otherwise-valid batch +Then the response is HTTP 403 and no row or file is created. + +**AC-7: Quality-gate rule enforcement (per rule)** +Given an authenticated request with `GPS` permission +When the batch contains an item violating any single quality rule +Then that item is `rejected` with the corresponding reason code: + - 7a: non-JPEG content-type or wrong magic bytes → `INVALID_FORMAT` + - 7b: bytes < `MinBytes` or > `MaxBytes` → `SIZE_OUT_OF_BAND` + - 7c: width or height ≠ `MapConfig.TileSizePixels` → `WRONG_DIMENSIONS` + - 7d: `captured_at` > now + 30s → `CAPTURED_AT_FUTURE`; `captured_at` < now − `MaxAgeDays` → `CAPTURED_AT_TOO_OLD` + - 7e: pixel luminance variance below `MinLuminanceVariance` → `IMAGE_TOO_UNIFORM` + +**AC-8: Oversized batch returns 400** +Given an authenticated request with `GPS` permission +And a batch with `MaxBatchSize + 1` items +When the request is sent +Then the response is HTTP 400 with an envelope error indicating batch-size violation; no row or file is created. + +**AC-9: Contract documentation matches implementation** +Given the implementation is complete +When `_docs/02_document/contracts/api/uav-tile-upload.md` is inspected +Then it has Status `frozen`, Version `1.0.0`, the documented request/response shape matches the actual DTOs, the reject-reason enum matches the implemented codes exactly, and it cross-references `_docs/02_document/contracts/data-access/tile-storage.md`. + +**AC-10: Existing tests continue to pass** +Given AZ-487 + AZ-488 are merged +When `scripts/run-tests.sh --full` runs +Then all unit tests + smoke tests + new AZ-487 JWT tests + new AZ-488 UAV tests pass; no AZ-484 integration test regresses (validates that the multi-source storage continues to behave per its frozen contract under live UAV write load). + +## Non-Functional Requirements + +**Performance** +- Per-item quality-gate cost: target < 50 ms for the typical 256×256 / 50 KB JPEG. Rule 5 (luminance variance) dominates due to the decode; downsample to 32×32 BEFORE variance computation to keep the cost bounded. +- Endpoint p95 end-to-end: target < 2 s for a 10-item batch on the dev hardware. Defer formal perf measurement to the cycle's Step 15 (Performance Test); add a PT-08 NFR to `_docs/02_document/tests/performance-tests.md` with the matching runner-script entry IN THE SAME COMMIT (per cycle 1 retro lesson — see `_docs/06_metrics/retro_2026-05-11.md` Action 2). + +**Compatibility** +- Replaces a 501 stub — no real consumer was using the old shape, so the DTO change is not a breaking change in practice. +- Adds the contract `uav-tile-upload.md` v1.0.0 — frozen on merge; future shape changes follow the contract's Versioning Rules. +- Coexists with the AZ-484 frozen `tile-storage` v1.0.0 contract; this PBI is a CONSUMER of that contract on the write side. + +**Reliability** +- Per-item failures (e.g., disk write failure mid-batch) MUST be reported in the per-item result with a clear reason — never silently dropped, never propagated to fail the entire batch unless the failure is envelope-level (deserialization, batch-size violation, auth). +- The DB row and the on-disk file must be written in the order: file first, then row (so a partial failure leaves an orphan file rather than a db-row pointing at nothing). Document the orphan-file cleanup as an out-of-scope ops concern. +- Multi-source coexistence behavior MUST match the frozen `tile-storage.md` v1.0.0 contract Inv-3 (per-source UPSERT) exactly — validated by `UavUploadTests.MultiSourceCoexistence_AZ484_Cycle2` and `SameSourceUpsert_AZ484_Cycle2`. + +**Security** +- Permission claim check is mandatory; do NOT add a "skip permission check in dev" flag. +- Reject-reason `RejectDetails` strings MUST NOT leak server-side paths, exception types, or internal identifiers (per `_docs/05_security/owasp_review.md` A05/A06 guidance). +- File-path construction takes integer coordinates only — no string concatenation of caller-supplied paths. + +## Unit Tests + +| AC Ref | What to Test | Required Outcome | +|--------|--------------|------------------| +| AC-7a | `UavTileQualityGate.Validate` with content-type `image/png` | Reject with `INVALID_FORMAT` | +| AC-7a | `UavTileQualityGate.Validate` with content-type `image/jpeg` but wrong magic bytes | Reject with `INVALID_FORMAT` | +| AC-7a | `UavTileQualityGate.Validate` with valid JPEG | Rule 1 passes; falls through to rule 2 | +| AC-7b | `UavTileQualityGate.Validate` with bytes < `MinBytes` | Reject with `SIZE_OUT_OF_BAND` | +| AC-7b | `UavTileQualityGate.Validate` with bytes > `MaxBytes` | Reject with `SIZE_OUT_OF_BAND` | +| AC-7c | `UavTileQualityGate.Validate` with 512×512 image | Reject with `WRONG_DIMENSIONS` | +| AC-7c | `UavTileQualityGate.Validate` with 256×256 image | Rule 3 passes | +| AC-7d | `UavTileQualityGate.Validate` with `captured_at = now + 1h` | Reject with `CAPTURED_AT_FUTURE` | +| AC-7d | `UavTileQualityGate.Validate` with `captured_at = now - 8 days` (default `MaxAgeDays=7`) | Reject with `CAPTURED_AT_TOO_OLD` | +| AC-7e | `UavTileQualityGate.Validate` with a uniform-grey JPEG | Reject with `IMAGE_TOO_UNIFORM` | +| AC-7e | `UavTileQualityGate.Validate` with a high-variance natural-image JPEG | Rule 5 passes | +| AC-2 | Quality-gate rule ordering: image that fails BOTH rule 1 (wrong format) AND rule 3 (wrong dimensions) | First-failing rule (1) is the reported reason | +| AC-1 | `UavTileUploadHandler` end-to-end with mocked repository: 1-item happy-path batch | `InsertAsync` called once with `Source = "uav"`, `CapturedAt` from request, file path `./tiles/uav/{z}/{x}/{y}.jpg` | +| AC-2 | `UavTileUploadHandler` with 3-item mixed batch (mocked repo) | `InsertAsync` called exactly once (only for the accepted item); response has 3 result items with correct statuses | + +## Blackbox Tests + +| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References | +|--------|--------------------------|--------------|-------------------|-----------------| +| AC-1 | Empty `tiles` table; valid JWT with GPS perm; 1-item batch with valid 256×256 JPEG, captured_at=now | POST `/api/satellite/upload` | HTTP 200; 1 row in `tiles` with `source='uav'`; file at `./tiles/uav/{z}/{x}/{y}.jpg` exists | Reliability | +| AC-2 | Empty `tiles` table; valid JWT; 3-item mixed batch | POST `/api/satellite/upload` | HTTP 200; per-item results `[accepted, rejected:WRONG_DIMENSIONS, rejected:INVALID_FORMAT]`; exactly 1 new row in `tiles` | Compatibility | +| AC-3 | Pre-seed `tiles` with a `source='google_maps'` row at `(L, Ln, 18, 200)` with `captured_at = now - 1h` | Upload a UAV tile for the same cell with `captured_at = now` | HTTP 200; both rows exist; subsequent `GET /api/satellite/tiles/latlon` for that cell returns the UAV row's metadata | Reliability | +| AC-4 | Pre-seed `tiles` with a `source='uav'` row at the cell with `captured_at = now - 1h` and known file content | Upload a UAV tile for the same cell with new bytes and `captured_at = now` | HTTP 200; exactly one `source='uav'` row remains with new `captured_at` and `file_path`; on-disk JPEG bytes match the new upload | Reliability | +| AC-5 | API running | POST `/api/satellite/upload` with no `Authorization` header | HTTP 401; no row, no file | Security | +| AC-6 | Valid JWT with `permissions: ["FL"]` (no GPS) | POST `/api/satellite/upload` | HTTP 403; no row, no file | Security | +| AC-8 | Valid JWT with GPS perm; batch with `MaxBatchSize + 1 = 101` items | POST `/api/satellite/upload` | HTTP 400 envelope error; no row, no file | — | + +## Constraints + +- Per-source file-path strategy is fixed: UAV → `./tiles/uav/{z}/{x}/{y}.jpg`; Google Maps → `./tiles/{z}/{x}/{y}.jpg` (grandfathered). Do NOT migrate Google Maps files to a sibling sub-tree in this PBI; that's a separate task if ever needed. +- Per-source UPSERT semantics MUST come from `ITileRepository.InsertAsync` as-implemented in AZ-484 — do NOT introduce a new write path or bypass the repository. +- `TileSource` enum + `TileSourceConverter` from `SatelliteProvider.Common.Enums` are the only sanctioned way to set the `source` wire value (per L-001 in `_docs/LESSONS.md` — never rely on Dapper TypeHandler for enum reads). +- `MaxBatchSize` is a hard cap — no chunked / streaming upload variant in this PBI. If batches > 100 are needed, that's a follow-up redesign (likely async + status-poll). +- Reject-reason codes are a closed enumeration in v1.0.0 of the contract. Adding a new reason requires a contract minor-version bump (per `tile-storage.md` Versioning Rules pattern). +- The `permissions` claim check is hard-coded to require `GPS`. When the admin team coordinates a new `SAT` permission, that's a follow-up code + contract minor-bump. + +## Risks & Mitigation + +**Risk 1: Quality-gate threshold tuning is wrong** +- *Risk*: The variance threshold (`MinLuminanceVariance = 10`) is a guess. Real UAV imagery from `gps-denied-onboard` may legitimately have low variance (e.g., over uniform terrain), causing false rejects. Or the threshold may be too lax and let actually-blank tiles through. +- *Mitigation*: + - Threshold is configurable via `UavQualityConfig.MinLuminanceVariance` — operators can tune per-deployment without code change. + - Reject-reason `IMAGE_TOO_UNIFORM` makes false rejects diagnosable client-side. + - Add an explicit follow-up task tagged "TUNE-THRESHOLDS" in the dependencies table so the threshold is re-evaluated after the first week of real UAV traffic. + +**Risk 2: File-path collision between concurrent UAV uploads** +- *Risk*: Two UAV uploads for the exact same `(z, x, y)` arriving concurrently both compute the same file path; one overwrites the other's bytes after the first write completes but before the second's DB UPSERT. +- *Mitigation*: + - The DB UPSERT (single transaction at the row level) is the authoritative serialization point. The file-on-disk represents whichever upload last wrote — which is acceptable because both rows share the same per-source `file_path` after UPSERT, and the `captured_at` UPSERT semantics already say "later wins". + - In the unlikely race where bytes-A are written, then bytes-B are written, then DB UPSERT for A happens AFTER UPSERT for B — the `file_path` is identical so the final state is consistent (file = B, row = B). No data corruption; just last-write-wins on bytes, same as the AZ-484 contract specifies for the row. + - Document this in the contract under "Concurrency" so consumers don't assume causal ordering. + +**Risk 3: Disk-fill from oversized JPEGs** +- *Risk*: A misconfigured UAV could push tens of MB JPEGs and fill the disk despite the 5 MB cap (because the cap is per-item, not per-batch). +- *Mitigation*: + - `MaxBatchSize = 100` × `MaxBytes = 5 MiB` = 500 MiB worst-case per request. ASP.NET Core's default request-body size limit (30 MB or `KestrelServerOptions.Limits.MaxRequestBodySize`) will reject before that — but this PBI must explicitly set `MaxRequestBodySize` to a safe value (`builder.Services.Configure(opts => opts.Limits.MaxRequestBodySize = MaxBatchSize * MaxBytes)` or explicitly cap to e.g. 50 MB and reject larger batches at the framework layer). + - Operations alerting on disk usage — out of scope; flagged as a `_docs/_process_leftovers/` ops follow-up if not already monitored. + +**Risk 4: PT-08 (perf NFR for UAV upload) gets recorded but the runner script is never updated** +- *Risk*: The cycle 1 retro flagged this exact pattern (see `_docs/06_metrics/retro_2026-05-11.md` Action 2). PT-08 must NOT be added to `performance-tests.md` without a matching `scripts/run-performance-tests.sh` scenario in the same commit. +- *Mitigation*: + - Hard rule for the implementer: PT-08 NFR + runner-script scenario land in the same commit. If the runner work cannot fit in the PBI, PT-08 is recorded as `Deferred — harness work tracked in `, NOT as an active scenario. + - The cycle's Step 15 perf gate enforces this when retro Action 2 lands as a process change. + +**Risk 5: Test fixture for blank/uniform image is brittle** +- *Risk*: Generating a "uniform JPEG" for the rejection test depends on JPEG quantization quirks; a "uniform grey" 256×256 may have non-zero variance after JPEG compression noise. +- *Mitigation*: + - Generate the fixture with `SixLabors.ImageSharp.Image` (single-channel grayscale), fill with a single value, save with quality=95. Validate at fixture-generation time that the resulting file's variance is below the threshold. + - Pin the fixture as a checked-in test asset; do NOT regenerate at test runtime. + +## Contract + +This task produces the contract at `_docs/02_document/contracts/api/uav-tile-upload.md`. + +Consumers (`gps-denied-onboard`, future UAV-equipped clients) MUST read that file — not this task spec — to discover the request/response shape, status codes, reject reason enum, and auth requirements. The `_docs/02_document/contracts/data-access/tile-storage.md` v1.0.0 contract is consumed (this PBI is a producer of `'uav'`-source rows under that contract). diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 23dbad1..90a319c 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -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