Compare commits

...

5 Commits

Author SHA1 Message Date
Oleksandr Bezdieniezhnykh 32bc5c1e48 [AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 perf run
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
8/8 scenarios PASS within threshold. Cycle-8 strict-validation
overhead is below percentile resolution on every measured
endpoint.

PT-06 (route creation) required one in-cycle perf-script fix:
add requestMaps=false + createTilesZip=false to the body to
satisfy AZ-809's no-defaulting rule. The script had already
been updated for AZ-812's wire rename during cycle 8 but missed
AZ-809's newly required fields. Production code is correct; only
the perf probe was stale.

Report: _docs/06_metrics/perf_2026-05-23_cycle8.md. Trend vs
cycle 7 is flat within noise band on every scenario.

Known harness quirks (pre-existing, not cycle-8 regressions)
surfaced and documented for cleanup:
- PT-07 cross-run cache pollution (hard-coded base coords)
- PT-01 "cold" misnomer (tile cached on disk since cycle 5)
- PT-03 cached-by-PT-02 side effect (cycle-7 note carried forward)

Auto-chains to Step 16 (Deploy).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 16:10:12 +03:00
Oleksandr Bezdieniezhnykh 8fca6e0209 [AZ-809] F-AZ809-1: cap geofences.polygons at 50 (security audit)
Closes the cycle-8 Medium DoS finding. Without the cap, an
authenticated caller could submit millions of bbox polygons in a
single 500 MiB request (Kestrel global limit) and saturate the
FluentValidation allocator on the validator hot path; each polygon
is ~90 bytes of JSON, so the body limit is not a useful gate.

Realistic use is 1-10 polygons per route — 50 leaves 5x headroom
while bounding the worst-case allocation.

Layers:
- CreateRouteRequestValidator: MaxPolygons = 50 + Must(...) chained
  before RuleForEach so the count error fires at "geofences.polygons"
  (not the leaf path).
- Unit: Validate_GeofencePolygonsTooMany_FailsCountRule.
- Integration: GeofencePolygonsTooMany_Returns400 (51 valid bbox
  polygons -> HTTP 400 + errors["geofences.polygons"]).
- Contract: route-creation.md -> v1.0.1 patch (tightening an
  existing range). New Inv-10, new geofence-polygons-too-many
  test case, changelog row.
- Test spec: BT-29 sub-case 9b + AZ-809 AC-1b row in the
  traceability matrix.
- Security report: F-AZ809-1 marked RESOLVED in cycle 8; verdict
  remains PASS_WITH_WARNINGS (Lows + carry-overs unchanged).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:29:10 +03:00
Oleksandr Bezdieniezhnykh ac40a8b352 [AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 security audit
PASS_WITH_WARNINGS. Zero Critical / High.

New cycle-8 findings:
- F-AZ809-1 (Medium / A04 Insecure Design): unbounded
  geofences.polygons enables an authenticated DoS on
  POST /api/satellite/route. Cap candidate: 50 or 500.
- F-AZ810-1 (Low / A09): JsonException.Message echoed in
  UavUploadValidationFilter (new instance of cycle-7 F-AZ795-1
  pattern in a second code path).
- F-AZ810-2 (Low / Informational): UavTileMetadata.CapturedAt
  typed DateTime not DateTimeOffset; freshness window drifts in
  non-UTC dev environments. Zero impact in UTC-deployed prod.

Carry-overs (cycle 7): F-AZ795-1, F-AZ795-2, D-AZ795-1 still
open. Cycle 4 D2-cy4 still open (test-runtime Medium).

Cycle-8 architectural wins recorded: per-endpoint validation
reached 100% coverage; three approved validation paths
formalised; OSM wire-format normalisation under strict mode
(AZ-812); UAV-handler defence-in-depth retained.

Highest-priority cycle-9 follow-up: F-AZ809-1 polygon cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:17:31 +03:00
Oleksandr Bezdieniezhnykh 6207ab7c27 [AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 docs sync
Phase 13 of autodev existing-code flow — document skill in task
mode. Targeted updates to system-level docs that the per-batch
implementation commits did not already cover. Per-module docs
(api_program.md, common_dtos.md, system-flows.md F1/F2/F4) and
the 4 new contract docs (region-request.md, route-creation.md,
tile-latlon.md, uav-tile-upload.md v1.2.0) were already updated
during Step 10 batch commits and were verified-clean here.

architecture.md
- Bump contracts inventory line to mention uav-tile-upload.md v1.2.0
  (was v1.1.0) and add the four cycle-8 contracts (region-request,
  route-creation, tile-latlon, error-shape) so the contract index
  in architecture.md is no longer stale relative to the implemented
  endpoints.
- Add new architectural principle "Strict wire-format validation
  at the API edge (AZ-795 epic, completed across cycles 7-8)" to
  the Architectural Principles list. Describes the two-layer
  enforcement (deserializer + FluentValidation), the three approved
  per-endpoint paths (WithValidation<T> for JSON bodies,
  UavUploadValidationFilter for multipart, RejectUnknownQueryParams
  EndpointFilter + WithValidation<TQuery> for query strings), and
  the no-handler-without-validation rule.

ripple_log_cycle8.md
- New cycle-8 ripple log following the cycle-7 template. Documents
  every directly-changed source file, the importer scan results,
  doc refresh decisions, and the no-ripple component list.
- Records the AZ-795 epic posture: cycle 8 closes the per-endpoint
  rollout. Every public-facing JSON, multipart, and query-param
  endpoint now goes through one of the three approved paths. The
  exempt endpoints (GET region/{id}, GET route/{id}, GET tiles/mgrs
  stub, GET tiles/{z}/{x}/{y}) are listed with justification.

State
- Advance autodev to Step 14 (Security Audit), sub_step phase 0
  awaiting-choice.

No production code change; no test code change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:32:57 +03:00
Oleksandr Bezdieniezhnykh ec0eb909a1 [AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 test-spec sync
Phase 12 of autodev existing-code flow — cycle-update mode of the
test-spec skill. Append cycle-8 coverage to the documentation suite
without rewriting any pre-cycle-8 content.

blackbox-tests.md
- Add 4 new BT entries (BT-28..BT-31) — one per cycle-8 endpoint:
  - BT-28: Region request endpoint strict validation + OSM rename
    (AZ-808 + AZ-812; 11 sub-cases through the new `RegionRequest
    Validator` + the AZ-795 deserializer infra; sub-case `pos` proves
    the new `lat`/`lon` names accepted, sub-case `9` proves the old
    `latitude`/`longitude` rejected by `UnmappedMemberHandling.Disallow`).
  - BT-29: Create route endpoint nested + cross-field validation
    (AZ-809; 15 sub-cases covering nested per-point validators,
    geofence cross-field invariants, and the `createTilesZip` /
    `requestMaps` cross-field rule; advisory ACs 9 + 10 explicitly
    NOT tested per spec).
  - BT-30: UAV upload metadata multipart validation (AZ-810; 14
    sub-cases across the three-layer composition: deserializer,
    FluentValidation, envelope cross-field; documents the unique
    `errors["metadata"]` vs `errors["metadata.items[i].field"]` key
    convention for multipart endpoints).
  - BT-31: GET tiles/latlon query-param validation + unknown-param
    rejection (AZ-811; 8 sub-cases; sub-cases 4b + 4c prove the
    novel `UnknownQueryParameterEndpointFilter` rejects both
    legacy and hostile unknown query keys).

traceability-matrix.md
- Append 41 AC rows (AZ-808 AC-1..AC-8, AZ-809 AC-1..AC-10,
  AZ-810 AC-1..AC-9, AZ-811 AC-1..AC-9, AZ-812 AC-1..AC-6).
- Update Coverage Summary: cycle-8 row added; Total moves from
  126 tests / 75 ACs to 167 tests / 116 ACs.
- Add "Coverage shape notes (Cycle 8 ...)" section explaining the
  multipart enforcement shape (AZ-810), the new query-param filter
  (AZ-811), the AZ-808 + AZ-812 same-cycle coordination, and the
  AZ-810 AC-9 process annotation (false-PASS by source tracing →
  bound to green full-suite re-run after the test-data coord-clamp
  fix in commit b763da3).
- AZ-809 AC-9 + AC-10 marked as `◐ advisory (not tested)` —
  naming-consistency concerns surfaced for parent-suite team
  decision.

State
- Advance autodev to Step 13 (Update Docs), sub_step phase 0
  awaiting-invocation.

No production code change; no contract change; no test code change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 14:28:17 +03:00
16 changed files with 1050 additions and 13 deletions
@@ -27,6 +27,15 @@ public sealed class CreateRouteRequestValidator : AbstractValidator<CreateRouteR
private const int MaxPoints = 500;
private const int MaxNameLength = 200;
private const int MaxDescriptionLength = 1000;
// Geofences are axis-aligned bbox rectangles used for AOI restriction
// during route planning (see route-creation.md). Realistic use is 1-10
// polygons per route; cap at 50 to give 5x headroom while bounding the
// validator's worst-case allocation. The global Kestrel body limit
// (500 MiB, sized for the UAV upload endpoint) is not a useful gate
// here because polygon JSON is small (~90 bytes per minimum-shape
// polygon); without this cap a single authenticated request could
// submit millions of polygons and saturate the LOH.
private const int MaxPolygons = 50;
public CreateRouteRequestValidator()
{
@@ -74,6 +83,8 @@ public sealed class CreateRouteRequestValidator : AbstractValidator<CreateRouteR
RuleFor(req => req.Geofences!.Polygons)
.NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
.NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
.Must(polygons => polygons is null || polygons.Count <= MaxPolygons)
.WithMessage($"`geofences.polygons` must contain at most {MaxPolygons} polygons.")
.OverridePropertyName("geofences.polygons");
RuleForEach(req => req.Geofences!.Polygons)
@@ -49,6 +49,9 @@ public static class CreateRouteValidationTests
// Rule 9: geofence corners + NW-of-SE invariant
await GeofenceNwLatNotGreaterThanSeLat_Returns400(httpClient);
// Rule 9b: geofence polygon-count cap (F-AZ809-1 security-audit fix)
await GeofencePolygonsTooMany_Returns400(httpClient);
// Rule 10/11: requestMaps + createTilesZip required
await MissingRequestMaps_Returns400(httpClient);
@@ -360,6 +363,48 @@ public static class CreateRouteValidationTests
Console.WriteLine(" ✓ NW.lat <= SE.lat rejected by cross-field invariant");
}
private static async Task GeofencePolygonsTooMany_Returns400(HttpClient httpClient)
{
Console.WriteLine();
Console.WriteLine("AZ-809 rule 9b (security-audit F-AZ809-1): geofence polygon-count > 50 → HTTP 400");
// Arrange — 51 polygons, each valid bbox. Only the count rule should fire.
var routeId = Guid.NewGuid();
var polygonsJson = string.Join(
",\n ",
Enumerable
.Range(0, 51)
.Select(_ => "{ \"northWest\": { \"lat\": 50.15, \"lon\": 36.05 }, \"southEast\": { \"lat\": 50.05, \"lon\": 36.15 } }"));
var body = $$"""
{
"id": "{{routeId}}",
"name": "too-many-polygons",
"regionSizeMeters": 1000,
"zoomLevel": 18,
"points": [
{ "lat": 50.10, "lon": 36.10 },
{ "lat": 50.11, "lon": 36.11 }
],
"geofences": {
"polygons": [
{{polygonsJson}}
]
},
"requestMaps": false,
"createTilesZip": false
}
""";
// Act
var response = await PostJsonAsync(httpClient, body);
var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-809 geofence polygons too many");
// Assert
ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-809 geofence polygons too many", expectedErrorPath: "geofences.polygons");
Console.WriteLine(" ✓ 51 polygons rejected with errors[\"geofences.polygons\"] (cap is 50)");
}
private static async Task MissingRequestMaps_Returns400(HttpClient httpClient)
{
Console.WriteLine();
@@ -249,6 +249,33 @@ public class CreateRouteRequestValidatorTests
result.ShouldHaveValidationErrorFor("geofences.polygons");
}
[Fact]
public void Validate_GeofencePolygonsTooMany_FailsCountRule()
{
// Arrange — 51 polygons; cap is 50 (security-audit F-AZ809-1 fix).
// Each polygon is a valid bbox so only the count rule should fire.
var request = ValidRequest();
request.Geofences = new Geofences
{
Polygons = Enumerable
.Range(0, 51)
.Select(_ => new GeofencePolygon
{
NorthWest = new GeoPoint(50.15, 36.05),
SouthEast = new GeoPoint(50.05, 36.15),
})
.ToList(),
};
// Act
var result = _validator.TestValidate(request);
// Assert — OverridePropertyName makes the count rule fire at the
// wire-format path `geofences.polygons` (not the leaf-only `polygons`).
result.ShouldHaveValidationErrorFor("geofences.polygons")
.WithErrorMessage("`geofences.polygons` must contain at most 50 polygons.");
}
[Fact]
public void Validate_CreateTilesZipWithoutRequestMaps_FailsCrossFieldRule()
{
+2 -1
View File
@@ -24,6 +24,7 @@ The three Layer-3 service components are compile-time siblings: each only refere
- Cross-repo deterministic tile identity (AZ-503) — the `TileNamespace` UUID `5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c` and the canonical name format are shared with the sibling workspace `gps-denied-onboard` (`components/c6_tile_cache/_uuid.py:TILE_NAMESPACE`). Both sides MUST produce byte-identical UUIDv5 output so an onboard-cached tile and a server-cached tile for the same `(z, x, y, source, flight_id)` are recognized as the same artifact without a round-trip. Changing the namespace constant on either side is a coordinated cross-repo break. (`inferred-from: Uuidv5.cs, AZ-503 task spec § Constraints`)
- Fire-and-forget async processing with status polling (`inferred-from: queue + background service + status endpoint`)
- JWT-validated callers only — every HTTP endpoint requires a valid HS256-signed Bearer token, validated locally against a shared `JWT_SECRET` per the suite-level auth contract (`suite/_docs/10_auth.md`). Issuer/audience are intentionally not validated yet; signature + lifetime + ≥32-byte key are. Per-endpoint permission claims (e.g. `permissions: ["GPS"]` on the UAV upload) layer on top of this baseline.
- Strict wire-format validation at the API edge (AZ-795 epic, completed across cycles 7-8) — every public-facing endpoint runs every incoming payload through two collaborating layers BEFORE the handler sees it: (a) `JsonSerializerOptions.UnmappedMemberHandling.Disallow` + `[JsonRequired]` on every non-optional DTO axis at the System.Text.Json deserializer; (b) per-endpoint FluentValidation `IValidator<T>` wired via `WithValidation<T>()` (JSON bodies) or `UavUploadValidationFilter` (multipart) or `RejectUnknownQueryParamsEndpointFilter` + `GetTileByLatLonQueryValidator` (query params). Both layers produce identically-shaped RFC 7807 `ValidationProblemDetails` per `error-shape.md` v1.0.0, so callers see one error contract regardless of which layer fired. The principle is: **no payload reaches a handler unless every field is present, every type matches, every range is honored, and no unknown field was silently dropped.** This closes the silent-coercion footgun class (e.g. missing `id` → zero-Guid → untracked region/route; typo `?latitude=``lat=0`; misnamed `{"Latitude":...}``lat=0`) that pre-cycle-7 produced misleading 200-OK responses. Adding a new public endpoint requires either a `WithValidation<T>()` chain (JSON), a `UavUploadValidationFilter`-style multipart filter, or an `RejectUnknownQueryParamsEndpointFilter` + query validator (query string) — there is no other approved path.
**Authentication & Authorization** (AZ-487):
- Validation library: `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.7 (matches `Microsoft.AspNetCore.OpenApi` 10.0.7; AZ-496 bumped both packages from 8.0.21 → 8.0.25 in cycle 3 to close the cycle-1 D1 + cycle-2 D3 supply-chain findings, then AZ-500 bumped both 8.0.25 → 10.0.7 in cycle 4 as part of the .NET 8 → .NET 10 migration). The `TokenValidationParameters` shape is unchanged across the JwtBearer 8 → 10 jump — AZ-487/AZ-494 integration tests are the gate and all pass on .NET 10.
@@ -39,7 +40,7 @@ The three Layer-3 service components are compile-time siblings: each only refere
- *Google Maps* — `TileService.DownloadAndStoreTilesAsync` / `DownloadAndStoreSingleTileAsync` stamp `source='google_maps'`, `flight_id=NULL`, and a deterministic UUIDv5 `id` on every persisted row; tile JPEGs live under `{StorageConfig.TilesDirectory}/{zoom}/...` per the legacy grandfathered layout. `content_sha256` is computed from the on-disk JPEG body.
- *UAV* — `POST /api/satellite/upload` (AZ-488; per-flight key extended by AZ-503) accepts a multipart batch of UAV-captured tiles, runs each item through a 5-rule quality gate (`UavTileQualityGate`), and persists accepted items via `ITileRepository.InsertAsync` with `source='uav'`, `flight_id = metadata.flightId` (or NULL for anonymous uploads), and a deterministic UUIDv5 `id`. UAV JPEGs live under `{StorageConfig.TilesDirectory}/uav/{flight_id or 'none'}/{zoom}/{x}/{y}.jpg`, so `rm -rf ./tiles/uav/{flight_id}/` removes one flight's evidence without touching other flights at overlapping cells. Requires the `GPS` permission claim on top of the JWT baseline.
The N-source storage contract is authoritative in `_docs/02_document/contracts/data-access/tile-storage.md` (v2.0.0 — bumped jointly by AZ-503-foundation and AZ-505 in cycle 6 to capture the identity columns, `tiles_leaflet_path` covering index, and `location_hash`-keyed leaflet read rule). The UAV upload contract is authoritative in `_docs/02_document/contracts/api/uav-tile-upload.md` (v1.1.0; AZ-503 added an optional `flightId` field to per-item metadata — backward-compatible). The bulk tile-inventory contract is authoritative in `_docs/02_document/contracts/api/tile-inventory.md` (v1.0.0; AZ-505). Anything that reads or writes `tiles` MUST follow those contracts rather than re-deriving the rules from prose here.
The N-source storage contract is authoritative in `_docs/02_document/contracts/data-access/tile-storage.md` (v2.0.0 — bumped jointly by AZ-503-foundation and AZ-505 in cycle 6 to capture the identity columns, `tiles_leaflet_path` covering index, and `location_hash`-keyed leaflet read rule). The UAV upload contract is authoritative in `_docs/02_document/contracts/api/uav-tile-upload.md` (v1.2.0; AZ-503 added an optional `flightId` field to per-item metadata in v1.1.0, AZ-810 cycle 8 added the strict metadata-validation section in v1.2.0). The bulk tile-inventory contract is authoritative in `_docs/02_document/contracts/api/tile-inventory.md` (v2.0.0; AZ-505 v1.0.0, AZ-794+AZ-796 cycle 7 bumped to v2.0.0 with the OSM `z/x/y` rename + strict validation rules). The four wire-format contracts added in cycle 8 are authoritative for their respective endpoints: `_docs/02_document/contracts/api/region-request.md` v1.0.0 (`POST /api/satellite/request`, AZ-808+AZ-812), `_docs/02_document/contracts/api/route-creation.md` v1.0.0 (`POST /api/satellite/route`, AZ-809), `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 (`GET /api/satellite/tiles/latlon`, AZ-811), and `_docs/02_document/contracts/api/error-shape.md` v1.0.0 (the cross-endpoint RFC 7807 `ValidationProblemDetails` envelope shared by every validating endpoint, AZ-795 cycle 7). Anything that reads or writes `tiles` MUST follow those contracts rather than re-deriving the rules from prose here.
**Drift signals**:
- `geofence_polygons` mentioned in AGENTS.md as a routes table column but does not exist in schema or entity — documentation drift
@@ -3,9 +3,9 @@
**Component**: WebApi (`SatelliteProvider.Api`) producing rows via RouteManagement (`SatelliteProvider.Services.RouteManagement`) and feeding the background Route Map Processing flow (Flow F5)
**Producer task**: AZ-809 — `_docs/02_tasks/done/AZ-809_route_endpoint_validation.md` (validator + this contract)
**Consumer tasks**: `gps-denied-onboard` AZ-777 Phase 2 (preferred imagery-seeding path — route-based rather than bbox-based)
**Version**: 1.0.0
**Version**: 1.0.1
**Status**: frozen
**Last Updated**: 2026-05-22
**Last Updated**: 2026-05-23
## Purpose
@@ -62,7 +62,7 @@ Per-field constraints:
| `points[i].lat` | number | yes (`[JsonRequired]`) | WGS84 latitude. | `[-90.0, 90.0]`. |
| `points[i].lon` | number | yes (`[JsonRequired]`) | WGS84 longitude. | `[-180.0, 180.0]`. |
| `geofences` | object | no | When present, intermediate points outside ALL polygons get filtered before region enqueue. | See nested shape below. |
| `geofences.polygons` | array | yes (`[JsonRequired]` when `geofences` present) | One or more bbox polygons (NW corner + SE corner). | Non-empty when `geofences` present. |
| `geofences.polygons` | array | yes (`[JsonRequired]` when `geofences` present) | One or more bbox polygons (NW corner + SE corner). | Count `[1, 50]` when `geofences` present. |
| `geofences.polygons[i].northWest` | object | yes (`[JsonRequired]`) | Polygon's northwest corner. | See `GeoPoint` shape. |
| `geofences.polygons[i].southEast` | object | yes (`[JsonRequired]`) | Polygon's southeast corner. | See `GeoPoint` shape. |
| `requestMaps` | bool | yes (`[JsonRequired]`) | When `true`, enqueue background region-requests for every route point inside the geofences (or all points if no geofences). | No default — caller must declare intent. |
@@ -166,6 +166,7 @@ Example body for a polygon corner invariant failure:
- **Inv-7** (per-polygon shape): `northWest` AND `southEast` corners both present.
- **Inv-8** (per-polygon invariant): `northWest.lat > southEast.lat` AND `northWest.lon < southEast.lon`.
- **Inv-9**: Unknown root or nested fields → 400 (deserializer's `UnmappedMemberHandling.Disallow`).
- **Inv-10**: When `geofences` is present, `geofences.polygons.Count` is in `[1, 50]`. Cap to bound validator allocation worst case — see `_docs/05_security/security_report_cycle8.md` § F-AZ809-1. Realistic use is 1-10 polygons per route; the 50 cap leaves 5x headroom.
## Non-Goals
@@ -199,6 +200,7 @@ Example body for a polygon corner invariant failure:
| point-lon-out-of-range | `"points":[..., {"lat":..,"lon":181}]` | HTTP 400 + `errors["points[1].lon"]` | Rule 8 |
| geofence-nw-not-north | NW.lat == SE.lat | HTTP 400 + `errors["geofences.polygons[0].northWest"]` | Rule 9 / Inv-8 |
| geofence-nw-not-west | NW.lon == SE.lon | HTTP 400 + `errors["geofences.polygons[0].northWest"]` | Rule 9 / Inv-8 |
| geofence-polygons-too-many | 51-polygon array | HTTP 400 + `errors["geofences.polygons"]` ("must contain at most 50 polygons.") | Rule 9b / Inv-10 (F-AZ809-1 follow-up) |
| missing-requestMaps | body without `requestMaps` | HTTP 400 + `errors[requestMaps]` | Rule 10 |
| createTilesZip-without-requestMaps | `"requestMaps":false,"createTilesZip":true` | HTTP 400 + `errors[createTilesZip]` | Rule 12 (cross-field) |
| unknown-root-field | extra `"debug":"..."` key | HTTP 400 + `errors[debug]` | Rule 13 (`UnmappedMemberHandling.Disallow`) |
@@ -211,3 +213,4 @@ Example body for a polygon corner invariant failure:
| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-22 | Initial contract for `POST /api/satellite/route`. Publishes the FluentValidation surface (CreateRouteRequestValidator + RoutePointValidator + GeofencePolygonValidator) + the 14 rules in AZ-809, including the probe-confirmed missing-id gap and the cross-field `createTilesZip ⇒ requestMaps` invariant. References `error-shape.md` v1.0.0, `region-request.md` v1.0.0 (F5 enqueue path), and Flows F4/F5 (cross-link). | autodev (Step 10, cycle 8) |
| 1.0.1 | 2026-05-23 | Tighten `geofences.polygons` constraint from "non-empty" to "Count `[1, 50]`". New Inv-10 + test case `geofence-polygons-too-many`. Patch release per Versioning Rules (tightening an existing range). The 50-polygon cap closes a defence-gap surfaced by the cycle-8 security audit: without the cap, an authenticated caller could submit millions of polygons in a single 500 MiB request and saturate the validator's allocation heap. Realistic use is 1-10 polygons per route. | autodev (Step 14 follow-up, cycle 8) |
+105
View File
@@ -0,0 +1,105 @@
# Cycle 8 — Documentation Ripple Log
**Cycle**: 8 (AZ-808 region-request validation + AZ-809 route-creation validation + AZ-810 UAV upload metadata validation + AZ-811 GET tiles/latlon validation + AZ-812 Region OSM rename)
**Generated by**: `/document` skill (task mode) during autodev Step 13
**Resolution method**: `Grep --type cs` against every new or changed symbol introduced by the five tasks. C# `using`-based import analysis on `RequestRegionRequest` (renamed `Lat`/`Lon`), `UavTileMetadata` + `UavTileBatchMetadataPayload` (`[JsonRequired]`), `CreateRouteRequest` + `RoutePoint` + `GeofencePolygon` + `Geofences` + `GeoPoint` (`[JsonRequired]`), the four new validator classes, the two new envelope filters (`UavUploadValidationFilter`, `RejectUnknownQueryParamsEndpointFilter`), and the new query DTO `GetTileByLatLonQuery`. No static-analyzer used — the new surface lives almost entirely behind `Program.cs` + the four per-endpoint test files, so the literal usage scan is exhaustive.
## Directly-changed source files (cycle 8)
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` (AZ-812, modified) — C# properties renamed `Latitude/Longitude``Lat/Lon` with `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]`. AZ-808 added `[JsonRequired]` to `Id` + the two coordinate axes + `SizeMeters` + `ZoomLevel` + `StitchTiles` so partial bodies are rejected at the deserializer layer.
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs` (AZ-810, modified) — `[JsonRequired]` added to `Latitude`, `Longitude`, `TileZoom`, `TileSizeMeters`, `CapturedAt`. `FlightId` intentionally NOT marked required (AZ-503 anonymous-flight semantics require `null` to be valid). `UavTileBatchMetadataPayload.Items` also marked `[JsonRequired]`.
- `SatelliteProvider.Common/DTO/RoutePoint.cs` (AZ-809, modified) — `[JsonRequired]` on `Latitude` + `Longitude`; `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]` confirm the wire shape pre-rename (`RoutePoint` was already using `lat`/`lon`, only `RequestRegionRequest` was the OSM-rename target).
- `SatelliteProvider.Common/DTO/CreateRouteRequest.cs` (AZ-809, modified) — `[JsonRequired]` on every non-optional field (`Id`, `Name`, `RegionSizeMeters`, `ZoomLevel`, `Points`, `RequestMaps`, `CreateTilesZip`); `Description` and `Geofences` left optional.
- `SatelliteProvider.Common/DTO/GeofencePolygon.cs` (AZ-809, modified) — `[JsonRequired]` on `NorthWest` + `SouthEast` so partial polygons fail at the deserializer.
- `SatelliteProvider.Common/DTO/Geofences.cs` (AZ-809, modified) — `[JsonRequired]` on `Polygons` so `geofences: {}` is rejected.
- `SatelliteProvider.Common/DTO/GeoPoint.cs` (AZ-809, modified) — `[JsonRequired]` on both axes for the geofence-corner case.
- `SatelliteProvider.Api/Program.cs` (AZ-808/809/810/811/812, modified) —
- `.WithValidation<RequestRegionRequest>()` on `MapPost("/api/satellite/request", …)` (AZ-808).
- `.WithValidation<CreateRouteRequest>()` on `MapPost("/api/satellite/route", …)` (AZ-809).
- `.AddEndpointFilter<UavUploadValidationFilter>()` on `MapPost("/api/satellite/upload", …)` (AZ-810) — bespoke multipart filter, not the generic `WithValidation<T>()`.
- `.AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter("lat", "lon", "zoom"))` + `.WithValidation<GetTileByLatLonQuery>()` on `MapGet("/api/satellite/tiles/latlon", …)` (AZ-811) — two-filter chain so unknown-key rejection precedes range checks.
- `AddTransient<UavUploadValidationFilter>()` registration (AZ-810 — the filter has injected dependencies).
- The `ParameterDescriptionFilter` Swagger op filter trimmed the obsolete `Latitude`/`Longitude`/`ZoomLevel` query-param annotations (AZ-811 cleanup; lat/lon/zoom remain).
- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` (AZ-808, **new**) — `AbstractValidator<RequestRegionRequest>` with rules for non-zero `id`, `lat`/`lon` ranges, `sizeMeters` ∈ \[100, 10000\], `zoomLevel` ∈ \[0, 22\].
- `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` (AZ-809, **new**) — root validator chaining `RuleForEach(req => req.Points).SetValidator(new RoutePointValidator())` for per-point checks, `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()).OverridePropertyName("geofences.polygons")` for per-polygon checks, plus `Must(req => !(req.CreateTilesZip && !req.RequestMaps)).WithName("createTilesZip")` for the cross-field rule. Note the `OverridePropertyName` is required because FluentValidation's default name policy drops the parent on deep `req.Geofences!.Polygons` expressions.
- `SatelliteProvider.Api/Validators/RoutePointValidator.cs` (AZ-809, **new**) — per-point lat/lon range rules with `OverridePropertyName("lat"/"lon")` so error keys match the wire format.
- `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` (AZ-809, **new**) — cross-field invariants `NW.Lat > SE.Lat` AND `NW.Lon < SE.Lon` for axis-aligned bounding boxes.
- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` (AZ-810, **new**) — `items` count rules (non-null, non-empty, ≤ `UavQualityConfig.MaxBatchSize`) + `RuleForEach(p => p.Items).SetValidator(new UavTileMetadataValidator(...))`.
- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` (AZ-810, **new**) — per-item rules: `latitude` ∈ \[-90, 90\], `longitude` ∈ \[-180, 180\], `tileZoom` ∈ \[0, 22\], `tileSizeMeters` > 0, `capturedAt` within `\[now - MaxAgeDays, now + CapturedAtFutureSkewSeconds\]`. Uses an injectable `TimeProvider` (defaults to `TimeProvider.System`).
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` (AZ-810, **new**) — `IEndpointFilter` for `POST /api/satellite/upload`. Reads the `metadata` form field, deserializes via the strict global `JsonSerializerOptions`, runs `IValidator<UavTileBatchMetadataPayload>` from DI, and enforces the envelope cross-field rule `items.Count == files.Count`. Error-map keys prefixed with `metadata.` so paths surface to the caller as `errors["metadata.items[0].latitude"]`.
- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` (AZ-811, **new**) — `AbstractValidator<GetTileByLatLonQuery>` with `lat`/`lon`/`zoom` rules using `Cascade(CascadeMode.Stop) → NotNull → InclusiveBetween` so missing-param surfaces only as `"\`<paramName>\` is required."`.
- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` (AZ-811, **new**) — generic envelope filter parameterized by an allowed-keys set; rejects unknown query-string parameters with RFC 7807 `ValidationProblemDetails`. **New shared infrastructure** designed for reuse by any future query-param endpoint (AZ-811 AC-9).
- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` (AZ-811, **new**) — `record GetTileByLatLonQuery(double? Lat, double? Lon, int? Zoom)` with `[FromQuery(Name="lat"|"lon"|"zoom")]` on each property. Bound via `[AsParameters]` on the handler. Nullable on purpose — see api_program.md commentary.
- `_docs/02_document/contracts/api/region-request.md` (AZ-808 + AZ-812, **new**) — v1.0.0 wire-format contract for `POST /api/satellite/request`. Published directly with `lat`/`lon` per AZ-812 AC-6 coordination (no v2.0.0 bump needed since AZ-808 + AZ-812 shipped same-cycle).
- `_docs/02_document/contracts/api/route-creation.md` (AZ-809, **new**) — v1.0.0 wire-format contract for `POST /api/satellite/route` covering all 14 validation rules, the nested per-point / per-polygon structure, and the `createTilesZip ⇒ requestMaps` cross-field invariant. Carries advisory notes for AZ-809 AC-9 (`sizeMeters` vs `regionSizeMeters` naming inconsistency) and AC-10 (input/output point-shape asymmetry).
- `_docs/02_document/contracts/api/tile-latlon.md` (AZ-811, **new**) — v1.0.0 wire-format contract for `GET /api/satellite/tiles/latlon` covering the 5 validation rules + the novel unknown-query-param rejection. Documents the rename `?Latitude=&Longitude=&ZoomLevel=``?lat=&lon=&zoom=`.
- `_docs/02_document/contracts/api/uav-tile-upload.md` (AZ-810, modified) — bumped to v1.2.0; new "Validation Rules" section covering the three-layer enforcement (deserializer, FluentValidation, envelope cross-field) and the `errors["metadata.…"]` key convention. Change Log entry names AZ-810.
- `_docs/02_document/modules/api_program.md` (AZ-808/809/810/811/812, modified) — endpoint table entries for the 4 endpoints bumped to credit cycle 8 and reference the new contract docs. New `Api/Validators` section row for each cycle-8 validator. New `Api/DTOs` section for `GetTileByLatLonQuery`. DI Registration item 14 lists every cycle-8 validator. New item documenting the `UavUploadValidationFilter` AddTransient + AddEndpointFilter wiring.
- `_docs/02_document/modules/common_dtos.md` (AZ-808/809/810/812, modified) — `RequestRegionRequest` renamed + JsonPropertyName documented; `RoutePoint`, `CreateRouteRequest`, `GeofencePolygon`, `Geofences`, `GeoPoint`, `UavTileMetadata`, `UavTileBatchMetadataPayload` all carry `[JsonRequired]` annotations explained; input/output `lat`/`latitude` asymmetry on the route endpoint surfaced.
- `_docs/02_document/system-flows.md` (AZ-808/809/811, modified) — F1 (single-tile download) updated to reference `tile-latlon.md` + the unknown-query-param filter. F2 (region request) updated to reference `region-request.md` + the validator. F4 (route creation) updated to reference `route-creation.md` + the cross-field rules.
- `_docs/02_document/architecture.md` (cycle 8, modified by Step 13) — (a) the contracts inventory line bumped to mention `uav-tile-upload.md` v1.2.0 + the four new cycle-8 contracts (`region-request.md`, `route-creation.md`, `tile-latlon.md`, `error-shape.md`); (b) new architectural principle "Strict wire-format validation at the API edge (AZ-795 epic, completed across cycles 7-8)" describing the two-layer enforcement and the no-handler-without-validation rule.
- `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` (AZ-808, **new**) — unit tests against each `RuleFor` chain.
- `SatelliteProvider.Tests/Validators/CreateRouteRequestValidatorTests.cs` + `RoutePointValidatorTests.cs` + `GeofencePolygonValidatorTests.cs` (AZ-809, **new**) — ≥ 13 unit-test methods across the three validators.
- `SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs` + `UavTileBatchMetadataPayloadValidatorTests.cs` (AZ-810, **new**) — ≥ 11 unit-test methods covering each rule (incl. `TimeProvider` injection for freshness).
- `SatelliteProvider.Tests/Validators/GetTileByLatLonQueryValidatorTests.cs` (AZ-811, **new**) — ≥ 3 unit-test methods.
- `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` + `CreateRouteValidationTests.cs` + `UavUploadValidationTests.cs` + `GetTileByLatLonValidationTests.cs` (AZ-808/809/810/811, **new**) — ≥ 45 failure methods + 4 happy paths total; every test uses `ProblemDetailsAssertions` from AZ-795.
- `SatelliteProvider.IntegrationTests/UavUploadTests.cs` (AZ-810 fallout, modified) — `NextTestCoordinate()` clamped to lat ∈ \[50, 70), lon ∈ \[10, 40) via modulo arithmetic. Pre-AZ-810 the seed `(Ticks/TicksPerSecond) % 1_000_000` produced lat > 90° which was silently accepted by the lenient pre-cycle-8 deserializer; the new AZ-810 validator (correctly) rejects it. This is the test-data bug that exposed the AC-9 false-PASS (see `_docs/LESSONS.md` 2026-05-23 entry).
- `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` (AZ-810, modified — same fix) — `NextTestCoordinate()` clamped to lat ∈ \[-70, -50), lon ∈ \[-40, -10) (non-overlapping with `UavUploadTests` to avoid per-source UNIQUE-index collisions when both suites run against the same DB).
- `scripts/probe_region_validation.sh` + `probe_route_validation.sh` + `probe_upload_validation.sh` + `probe_latlon_validation.sh` (AZ-808/809/810/811, **new**) — manual probe scripts modelled on `probe_inventory_validation.sh`.
## Importer scan results
| Symbol | Importer count | Importer files | Component touched |
|--------|----------------|----------------|-------------------|
| `RequestRegionRequest.Lat` / `.Lon` (renamed properties; wire names unchanged at `lat`/`lon`) | 4 | `Program.cs` (request mapping), `RegionService.cs` (handler — uses `.Lat`/`.Lon` to build the queue message), `RegionRequestTests.cs`, `RegionRequestValidationTests.cs` | WebApi, RegionProcessing service, Tests |
| `[JsonRequired]` on every cycle-8 DTO axis | n/a | enforced at runtime by `System.Text.Json` + caught by `GlobalExceptionHandler` (no compile-time consumer beyond the deserializer) | WebApi (deserializer + handler) |
| `RegionRequestValidator` | 3 | `Program.cs` (assembly-scan registration), `RegionRequestValidatorTests.cs`, `RegionRequestValidationTests.cs` (indirect via running API) | WebApi (production), Tests (unit + integration) |
| `CreateRouteRequestValidator` + `RoutePointValidator` + `GeofencePolygonValidator` | 4 | `Program.cs`, 3 unit-test files, `CreateRouteValidationTests.cs` (indirect) | WebApi (production), Tests (unit + integration) |
| `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator` | 3 | `Program.cs`, 2 unit-test files, `UavUploadValidationTests.cs` (indirect) | WebApi (production), Tests (unit + integration) |
| `GetTileByLatLonQueryValidator` | 3 | `Program.cs`, `GetTileByLatLonQueryValidatorTests.cs`, `GetTileByLatLonValidationTests.cs` (indirect) | WebApi (production), Tests (unit + integration) |
| `UavUploadValidationFilter` | 2 | `Program.cs` (DI + endpoint filter wiring), `UavUploadValidationTests.cs` (indirect) | WebApi |
| `RejectUnknownQueryParamsEndpointFilter` | 1 (current) + N-future | `Program.cs` (`MapGet("/api/satellite/tiles/latlon", …).AddEndpointFilter(new RejectUnknownQueryParamsEndpointFilter(...))`); designed to be reused by every future query-param endpoint per AZ-811 AC-9 | WebApi |
| `GetTileByLatLonQuery` | 2 | `Program.cs` (handler signature `[AsParameters] GetTileByLatLonQuery`), `GetTileByLatLonQueryValidatorTests.cs` | WebApi (production), Tests (unit) |
| `RoutePoint.Latitude/Longitude` + `[JsonRequired]` | 4 | `RouteService.cs` (handler), `RouteCreationTests.cs`, `CreateRouteValidationTests.cs`, `RoutePointValidatorTests.cs` | WebApi, RouteManagement service, Tests |
| `CreateRouteRequest.*` + `[JsonRequired]` | 4 | `Program.cs`, `RouteService.cs`, `RouteCreationTests.cs`, `CreateRouteValidationTests.cs` | WebApi, RouteManagement service, Tests |
| `GeofencePolygon`/`Geofences`/`GeoPoint` + `[JsonRequired]` | 5 | `CreateRouteRequest.cs`, `RouteService.cs` (point-in-polygon geofence filtering), `GeofencePolygonValidatorTests.cs`, `CreateRouteValidationTests.cs`, `Json` deserializer | WebApi, RouteManagement service, Tests |
## Doc refresh decisions
All importers land inside components that either received targeted updates during Step 10 (Implement) or were verified-clean during this Step 13:
- **WebApi (`Program.cs`)** — `_docs/02_document/modules/api_program.md` updated during the implementation phase with: (a) endpoint table entries for the 4 cycle-8 endpoints crediting their respective tasks + contract docs, (b) new `Api/Validators` section rows for every cycle-8 validator + envelope filter, (c) new `Api/DTOs` section for `GetTileByLatLonQuery`, (d) DI Registration item 14 listing every cycle-8 validator, (e) DI Registration entry for the `UavUploadValidationFilter` AddTransient + AddEndpointFilter wiring. Verified during Step 13 — no further changes needed.
- **Common (DTOs)** — `_docs/02_document/modules/common_dtos.md` updated with every modified DTO carrying its `[JsonRequired]` annotations explained, the AZ-812 `Lat/Lon` rename + JsonPropertyName attributes, and the route-endpoint input/output naming asymmetry caveat. Verified during Step 13 — no further changes needed.
- **RegionProcessing (`RegionService.cs`)** — no module doc update needed; the handler's behavior is unchanged (it still reads `.Lat`/`.Lon` from the request DTO to build the queue message — only the property names changed, not the values or the queue contract). The internal `RegionRequest` queue message remains on `Latitude`/`Longitude` per design (intentionally kept, see `common_dtos.md` line 43 commentary).
- **RouteManagement (`RouteService.cs`)** — no module doc update needed; the handler's behavior is unchanged. The `[JsonRequired]` annotations only affect the deserializer layer — once a payload passes, the handler sees the same shape it always did.
- **WebApi (`GlobalExceptionHandler.cs`)** — unchanged from cycle 7. The handler is now exercised by 4 more endpoints' deserializer-layer failures (missing `[JsonRequired]` axes, unknown fields, type mismatches) but the implementation is identical.
- **TileDownloader / DataAccess / DataAccess migrations** — not touched by cycle 8.
- **Architecture** — `architecture.md` updated during Step 13 with: (a) contracts inventory line bumped to mention `uav-tile-upload.md` v1.2.0 + the four new cycle-8 contracts, (b) new "Strict wire-format validation at the API edge" architectural principle describing the two-layer enforcement and the no-handler-without-validation rule.
- **System flows** — `system-flows.md` F1/F2/F4 updated during the implementation phase to credit cycle 8 and reference the new contract docs + error-shape contract. F6 (status query) + F8 (tile inventory bulk lookup) untouched — cycle 8 didn't change those endpoints.
- **Tests (unit + integration)** — `_docs/02_document/modules/tests_unit.md` and `tests_integration.md` are not strictly required to enumerate every new test file (cycle 7 didn't extend them past a "AZ-795 + AZ-796 — strict inventory validation" subsection). Cycle 8 keeps the same convention — the new test files are documented in the traceability matrix + test-spec sync (BT-28..BT-31) rather than re-listed per file in the module docs.
- **Tests (blackbox + traceability)** — `tests/blackbox-tests.md` and `tests/traceability-matrix.md` updated during Step 12 (Test-Spec Sync): BT-28..BT-31 added + 41 AC rows added (AZ-808 AC-1..AC-8 + AZ-809 AC-1..AC-10 + AZ-810 AC-1..AC-9 + AZ-811 AC-1..AC-9 + AZ-812 AC-1..AC-6) + Coverage Summary refresh.
## No-ripple components
These components were NOT touched by cycle-8 changes and require no doc update:
- **DataAccess** — no schema, repository signature, or migration changes in cycle 8. The validation work is entirely at the API edge.
- **TileDownloader** — not touched. The four cycle-8 endpoints either don't trigger tile downloads at all (`POST /api/satellite/request`, `POST /api/satellite/route`) or trigger them only after the validator has passed (`GET /api/satellite/tiles/latlon`, `POST /api/satellite/upload`).
- **RegionProcessing background service** — not touched. The validator runs at the API edge before the request is enqueued.
- **RouteManagement processing** — not touched for the same reason.
## Parse-failure / heuristic notes
None — every symbol resolved via direct `Grep --type cs`. No fallback heuristic was needed. The cycle 8 surface is wider than cycle 7 (5 tasks vs 3) but still narrow architecturally — every change is a WebApi-layer concern + DTO annotations + per-endpoint test files. The shared infrastructure landed in cycle 7 (AZ-795); cycle 8 is the per-endpoint rollout.
## AZ-795 epic posture
Cycle 8 completes the per-endpoint rollout of the AZ-795 strict-validation epic. After this cycle, **every public-facing JSON, multipart, and query-param endpoint** in the satellite-provider workspace goes through one of the three approved validation paths:
1. **JSON-body endpoints**`WithValidation<T>()` + `ValidationEndpointFilter<T>` + `JsonSerializerOptions.UnmappedMemberHandling.Disallow`. Used by `POST /api/satellite/tiles/inventory` (AZ-796 cycle 7), `POST /api/satellite/request` (AZ-808 cycle 8), `POST /api/satellite/route` (AZ-809 cycle 8).
2. **Multipart endpoints** — bespoke `UavUploadValidationFilter` composing deserializer + FluentValidation + envelope cross-field. Used by `POST /api/satellite/upload` (AZ-810 cycle 8).
3. **Query-param endpoints**`RejectUnknownQueryParamsEndpointFilter` + `WithValidation<TQuery>()`. Used by `GET /api/satellite/tiles/latlon` (AZ-811 cycle 8).
The `architecture.md` § "Strict wire-format validation at the API edge" principle (added this Step 13) codifies this as a rule for future endpoints — there is no fourth approved path. The previously-open AZ-795 epic now has zero outstanding child tickets in this workspace; future endpoints will pick one of the three paths above and reuse the existing infrastructure without new shared-infra work.
The endpoints NOT validated by the AZ-795 stack are: `GET /api/satellite/region/{id}` (path-only, framework-handled Guid binding — covered by the strict path binder), `GET /api/satellite/route/{id}` (same), `GET /api/satellite/tiles/mgrs` (stub returning empty — no input to validate), `GET /tiles/{z}/{x}/{y}` (path-only, framework-handled int binding — the strict path binder rejects malformed values; whether to range-check `z`/`x`/`y` against slippy-map bounds is a separate decision deferred to parent-suite team per AZ-811 Out of Scope). These exemptions are documented in `api_program.md` so future contributors know they're intentional, not omissions.
+109
View File
@@ -284,3 +284,112 @@ The cycle introduces no new HTTP routes. Functional positive coverage is unchang
**AC trace**: AZ-796 AC-1 (all 9 rules + ProblemDetails shape), AC-2 (happy path); AZ-794 AC-1 (positive z/x/y acceptance — sub-case `pos`), AZ-794 AC-2 (sub-case `9c` proves the old names produce a structured 400, eliminating the silent-coerce-to-0 footgun); AZ-795 (epic-level — every sub-case exercises the shared `ValidationEndpointFilter` + `GlobalExceptionHandler` + `UnmappedMemberHandling.Disallow` infra).
**Notes**: The 9 rules split across two enforcement layers — rules 5/6/9 are enforced by the deserializer (`JsonRequired` + `UnmappedMemberHandling.Disallow` + native JSON type validation, see AZ-795 shared infra) and surface as `BadHttpRequestException``GlobalExceptionHandler.JsonException` branch; rules 2/3/4/7/8 are enforced by `InventoryRequestValidator` (FluentValidation) via `ValidationEndpointFilter<TileInventoryRequest>`. Both paths produce identically-shaped `ValidationProblemDetails` bodies (`error-shape.md` v1.0.0 invariant).
---
## Cycle 8 — AZ-808 / AZ-809 / AZ-810 / AZ-811 / AZ-812 Strict Validation Sweep + Region Rename
Cycle 8 extends the AZ-795 shared validation infrastructure (FluentValidation + `ValidationEndpointFilter<T>` + `GlobalExceptionHandler` + `JsonSerializerOptions.UnmappedMemberHandling.Disallow`) to the four remaining public-facing endpoints, and lands the OSM-convention `Lat`/`Lon` rename on the Region API in the same commit set. Every cycle-8 endpoint emits the same `ValidationProblemDetails` shape (`error-shape.md` v1.0.0) used by AZ-796. No new HTTP routes; existing positive coverage (BT-01..BT-18 region/route/tile flows, BT-13..BT-17 UAV upload, BT-N01..BT-N05 negatives) is preserved. The new tests below cover the strict-rejection behaviour that pre-cycle-8 either silently coerced (missing `id` → zero-Guid; unknown `?latitude=``lat=0`) or accepted out-of-range (lat > 90, zoom > 22) values that downstream code couldn't render.
## BT-28: Region Request Endpoint — Strict Validation + OSM Rename
**Trigger**: A family of `POST /api/satellite/request` calls. AZ-812's `Lat`/`Lon` rename ships in the same commit as AZ-808; every sub-case uses the post-rename wire shape `{"id":"<guid>","lat":..,"lon":..,"sizeMeters":..,"zoomLevel":..,"stitchTiles":..}`.
**Precondition**: API up; valid JWT attached. `error-shape.md` v1.0.0 + `region-request.md` v1.0.0 frozen (v1.0.0 published directly with the post-AZ-812 names per AZ-812 AC-6 coordination).
**Expected**: HTTP 400 with `Content-Type: application/problem+json` for every failure sub-case; HTTP 200 + `RegionStatusResponse` for the happy path. `errors[]` names the offending field path in request-body camelCase.
| # | Rule | Trigger excerpt | Expected `errors` key | Test method |
|---|------|-----------------|-----------------------|-------------|
| pos | Happy path with `lat`/`lon` | full valid body | HTTP 200 — body shape unchanged from pre-AZ-808 (`{regionId,status}`) | `HappyPath_Returns200` |
| 1 | Empty body | zero bytes, `Content-Type: application/json` | (framework-level ProblemDetails) | `EmptyBody_Returns400` |
| 2a | Required `id` (silent-coercion fix) | omit `id` entirely | `id` — message states `id` is required (NOT zero-Guid coercion) | `Post_WithMissingId_ReturnsBadRequest` |
| 2b | Zero-Guid `id` | `"id":"00000000-0000-0000-0000-000000000000"` | `id` | `ZeroGuidId_Returns400` |
| 3 | `lat` in range `[-90, 90]` | `"lat":91` | `lat` | `LatOutOfRange_Returns400` |
| 4 | `lon` in range `[-180, 180]` | `"lon":181` | `lon` | `LonOutOfRange_Returns400` |
| 5 | `sizeMeters` in range `[100, 10_000]` | `"sizeMeters":1000000` | `sizeMeters` | `SizeMetersOutOfRange_Returns400` |
| 6 | `zoomLevel` in range `[0, 22]` | `"zoomLevel":30` | `zoomLevel` | `ZoomOutOfRange_Returns400` |
| 7 | Required `stitchTiles` | omit `stitchTiles` | `stitchTiles` | `MissingStitchTiles_Returns400` |
| 8 | Unknown root field (`UnmappedMemberHandling.Disallow`) | `"debug":42` | path mentioning `debug` | `UnknownRootField_Returns400` |
| 9 | Legacy v1 names (`latitude`/`longitude`) | exact AZ-777 Phase 2 reproduction body | path mentioning `latitude` (treated as unknown post-AZ-812) | `OldLatLongNames_Returns400` |
| 10 | Type mismatch on `lat` | `"lat":"fifty"` | path mentioning `lat` | `LatTypeMismatch_Returns400` |
**Pass criterion**: Every failure sub-case returns HTTP 400 with the expected `errors` key OR an equivalent RFC 7807 body for the empty-body case; the happy path returns HTTP 200 + a non-empty `regionId`. AZ-812 AC-4 is verified by sub-cases `pos` (new names accepted) and `9` (old names rejected by `UnmappedMemberHandling.Disallow`) on the same endpoint. No regression in `RegionRequestTests.cs` (cycle 8 Step 11 — green).
**AC trace**: AZ-808 AC-1 (8 rules + ProblemDetails shape), AC-2 (happy path); AZ-812 AC-2 (wire format end-to-end), AC-3 (existing happy-path tests pass — Step 11 green), AC-4 (post-rename accepted, pre-rename rejected — sub-cases `pos` + `9`).
**Notes**: The 8 validations split between the deserializer (rule 7 missing-required, rule 8 unknown-root, rule 10 type-mismatch — surface via `GlobalExceptionHandler`) and `RegionRequestValidator` (rules 2b/3/4/5/6 — surface via `ValidationEndpointFilter<RequestRegionRequest>`). The silent-coercion case (rule 2a) is the AZ-777 Phase 2 reproducer: pre-cycle-8 a missing `id` deserialized to `Guid.Empty` and the handler silently created a region with id `00000000-...`; post-cycle-8 it fails-fast at the deserializer.
## BT-29: Create Route Endpoint — Nested + Cross-Field Strict Validation
**Trigger**: A family of `POST /api/satellite/route` calls covering AZ-809's 14 rules. Bodies use the post-AZ-809 wire shape `{id, name, description?, regionSizeMeters, zoomLevel, points: [{lat, lon}, …], requestMaps, createTilesZip, geofences?: {polygons: [{northWest, southEast}]}}`.
**Precondition**: API up; valid JWT attached. `error-shape.md` v1.0.0 + `route-creation.md` v1.0.1 frozen.
**Expected**: HTTP 400 with `Content-Type: application/problem+json` for every failure sub-case; HTTP 200 + `RouteResponse` for the happy path. `errors[]` keys use the nested path (e.g. `points[1].lat`, `geofences.polygons[0].northWest`).
| # | Rule | Trigger excerpt | Expected `errors` key | Test method |
|---|------|-----------------|-----------------------|-------------|
| pos | Happy path (`requestMaps=false`, no background processing) | minimal 2-point valid body | HTTP 200 — body shape unchanged | `HappyPath_Returns200` |
| 1 | Empty body | zero bytes | (framework-level ProblemDetails) | `EmptyBody_Returns400` |
| 2a | Required `id` (silent-coercion fix) | omit `id` | `id` is required (no zero-Guid coercion) | `MissingId_Returns400` |
| 2b | Zero-Guid `id` | `"id":"00000000-..."` | `id` | `ZeroGuidId_Returns400` |
| 3 | Required `name` non-empty | `"name":""` | `name` | `EmptyName_Returns400` |
| 5 | `regionSizeMeters` in `[100, 10_000]` | `1000000` | `regionSizeMeters` | `RegionSizeMetersOutOfRange_Returns400` |
| 6 | `zoomLevel` in `[0, 22]` | `30` | `zoomLevel` | `ZoomOutOfRange_Returns400` |
| 7 | `points` count in `[2, 500]` | 1-point array | `points` | `PointsCountBelowMin_Returns400` |
| 8a | Per-point `lat` in `[-90, 90]` | `points[1].lat=91` | `points[1].lat` | `PointLatOutOfRange_Returns400` |
| 8b | Per-point `lon` in `[-180, 180]` | `points[1].lon=181` | `points[1].lon` | `PointLonOutOfRange_Returns400` |
| 9 | Geofence cross-field invariant (`NW.lat > SE.lat` AND `NW.lon < SE.lon`) | NW.lat ≤ SE.lat | `geofences.polygons[0].northWest` | `GeofenceNwSeInvariantViolated_Returns400` |
| 9b | `geofences.polygons.Count <= 50` (security-audit F-AZ809-1 fix, `route-creation.md` v1.0.1 Inv-10) | 51-polygon array | `geofences.polygons` ("must contain at most 50 polygons.") | `GeofencePolygonsTooMany_Returns400` |
| 10 | Required `requestMaps` (no defaulting) | omit `requestMaps` | `requestMaps` | `MissingRequestMaps_Returns400` |
| 12 | Cross-field: `createTilesZip=true` requires `requestMaps=true` | `{createTilesZip:true, requestMaps:false}` | top-level message (no per-field key) | `CreateTilesZipRequiresRequestMaps_Returns400` |
| 13 | Unknown root field | `"debug":42` | path mentioning `debug` | `UnknownRootField_Returns400` |
| 14 | Nested type mismatch | `"points[0].lat":"fifty"` | path mentioning `points[0].lat` | `NestedTypeMismatch_Returns400` |
**Pass criterion**: Every failure sub-case returns HTTP 400 with the expected `errors` key OR an equivalent RFC 7807 body for the empty/cross-field cases; the happy path returns HTTP 200 + a non-empty `routeId`. No regression in `RouteCreationTests.cs` (cycle 8 Step 11 — green). The AZ-779-class footgun (silent coercion of missing `id` to zero-Guid creating untracked routes) is closed by sub-case 2a.
**AC trace**: AZ-809 AC-1 (14 rules + ProblemDetails shape), AC-2 (happy path). Advisory ACs AC-9 (`sizeMeters` vs `regionSizeMeters` naming) and AC-10 (input `lat`/`lon` vs output `latitude`/`longitude` asymmetry) are explicitly **not** tested — surfaced to parent-suite team for follow-up only.
**Notes**: The 14 rules split across three layers — deserializer (rules 3 type-mismatch, 13 unknown-root, 14 nested type mismatch, 10 missing-required), `CreateRouteRequestValidator` (rules 2b/5/6/7, plus orchestration of nested validators), `RoutePointValidator` (rules 8a/8b — applied per-element via `RuleForEach(x => x.Points).SetValidator`), `GeofencePolygonValidator` (rule 9 cross-field), and a top-level `Must()` (rule 12). Rule 4 (`description` length cap if present) is enforced by `CreateRouteRequestValidator.RuleFor(x => x.Description).MaximumLength(...)` and is not exercised by an explicit sub-case because the existing `pos` body omits `description` and there is no probe-confirmed footgun there. The cross-field rule 12 surfaces under a top-level `errors` map entry (no field key) per FluentValidation convention for `RuleFor(x => x).Must(...)`.
## BT-30: UAV Upload Metadata Endpoint — Multipart Strict Validation
**Trigger**: A family of `POST /api/satellite/upload` multipart calls covering AZ-810's 14 rules. The endpoint is `multipart/form-data`, so the validator wires through a **custom** `UavUploadValidationFilter` (NOT the generic `ValidationEndpointFilter<T>` used by JSON-body endpoints) which deserializes the `metadata` form field via the strict global `JsonSerializerOptions` and routes errors through three composed layers (deserializer → FluentValidation → envelope cross-field check).
**Precondition**: API up; valid JWT with `permissions:["GPS"]` attached. `error-shape.md` v1.0.0 + `uav-tile-upload.md` v1.2.0 frozen.
**Expected**: HTTP 400 with `Content-Type: application/problem+json` for every failure sub-case; HTTP 200 + per-item result list for the happy path (per-item file rejections still return HTTP 200 — file-byte semantics unchanged from AZ-488). `errors[]` keys are prefixed with `metadata.` (e.g. `metadata.items[0].latitude`) for FluentValidation-layer rules; deserializer-layer failures surface under `errors["metadata"]` (per `UavUploadValidationFilter` design — the deserializer doesn't know the per-field path inside the form value).
| # | Rule | Layer | Trigger excerpt | Expected `errors` key | Test method |
|---|------|-------|-----------------|-----------------------|-------------|
| pos | Happy path | — | well-formed multipart envelope with valid 256×256 JPEG | HTTP 200, per-item `status=accepted` | `HappyPath_Returns200` |
| 1 | `metadata` form field present | deserializer | omit `metadata` field | `metadata` | `MissingMetadataField_Returns400` |
| 2 | `metadata` deserializes to `UavTileBatchMetadataPayload` | deserializer | `"items":"notanarray"` | `metadata` | `MetadataNotAnObject_Returns400` |
| 3 | `metadata.items` count in `[1, 100]` | FluentValidation | 101-item array | `metadata.items` | `OversizedItemsArray_Returns400` |
| 4 | `metadata.items` count == file count (envelope cross-field) | envelope filter | 2 items, 1 file | both `metadata.items` and `files` | `ItemsFileCountMismatch_Returns400` |
| 5 | per-item `latitude` in `[-90, 90]` | FluentValidation | `items[1].latitude=91` | `metadata.items[1].latitude` | `LatitudeOutOfRange_Returns400` |
| 6 | per-item `longitude` in `[-180, 180]` | FluentValidation | `items[0].longitude=181` | `metadata.items[0].longitude` | `LongitudeOutOfRange_Returns400` |
| 7 | per-item `tileZoom` in `[0, 22]` | FluentValidation | `items[0].tileZoom=30` | `metadata.items[0].tileZoom` | `TileZoomOutOfRange_Returns400` |
| 8 | per-item `tileSizeMeters` > 0 | FluentValidation | `items[0].tileSizeMeters=0` | `metadata.items[0].tileSizeMeters` | `TileSizeMetersZero_Returns400` |
| 9a | per-item `capturedAt` not in future (skew = 30s) | FluentValidation | `capturedAt=UtcNow+1h` | `metadata.items[0].capturedAt` | `CapturedAtFuture_Returns400` |
| 9b | per-item `capturedAt` not older than `MaxAgeDays` (default 7d) | FluentValidation | `capturedAt=UtcNow-60d` | `metadata.items[0].capturedAt` | `CapturedAtTooOld_Returns400` |
| 10 | per-item required fields (`latitude`/`longitude`/`tileZoom`/`tileSizeMeters`/`capturedAt`) present (`[JsonRequired]`) | deserializer | omit `latitude` from one item | `metadata` (deserializer-level; per-field path not available inside form value) | `MissingRequiredField_Returns400` |
| 11 | Unknown root field on `UavTileBatchMetadataPayload` | deserializer | `"debug":42` at metadata root | `metadata` | `UnknownRootField_Returns400` |
| 12 | Unknown nested field on `UavTileMetadata` | deserializer | `"altitude":100` on an item | `metadata` | `UnknownItemField_Returns400` |
| 13 | Type mismatch on per-item field | deserializer | `"latitude":"fifty"` | `metadata` | `LatitudeTypeMismatch_Returns400` |
**Pass criterion**: Every failure sub-case returns HTTP 400 with the expected `errors` key. The happy path returns HTTP 200 + a per-item result list with `status=accepted`. Per-item file rejections (existing `IUavTileQualityGate` semantics: `INVALID_FORMAT`, `WRONG_DIMENSIONS`, `SIZE_OUT_OF_BAND`, `CAPTURED_AT_FUTURE`, `CAPTURED_AT_TOO_OLD`, `IMAGE_TOO_UNIFORM`) still return HTTP 200 with per-item `status=rejected` (AZ-488 contract preserved). **AZ-810 AC-9 verified** by full integration suite green at cycle 8 Step 11 (after the test-data clamp fix — see `_docs/03_implementation/batch_04_cycle8_report.md` AC-9 row + commit `b763da3`).
**AC trace**: AZ-810 AC-1 (14 rules + ProblemDetails shape), AC-2 (happy path unchanged), AC-9 (no AZ-488 regression).
**Notes**: This is the first endpoint where the validation infra had to step **outside** the generic `ValidationEndpointFilter<T>` — multipart form-data is not a JSON body, so the metadata field has to be extracted from `IFormCollection` and deserialized inside a custom filter (`UavUploadValidationFilter`). Three layers compose: (1) deserializer with `JsonSerializerOptions.UnmappedMemberHandling.Disallow` + `[JsonRequired]` — surfaces under `errors["metadata"]`; (2) `UavTileBatchMetadataPayloadValidator` + `UavTileMetadataValidator` (FluentValidation) — error paths prefixed with `metadata.`; (3) envelope cross-field check (`items.Count == files.Count`) inside the filter — surfaces under BOTH `errors["metadata.items"]` AND `errors["files"]`. The `metadata.` prefix and the bare-`"metadata"` key for deserializer-level errors are documented in `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 §Validation Rules.
## BT-31: GET tiles/latlon — Query-Param Strict Validation + Unknown-Param Rejection
**Trigger**: A family of `GET /api/satellite/tiles/latlon?lat=&lon=&zoom=` calls covering AZ-811's 5 rules. The endpoint takes **query parameters** (not a JSON body), so the validator wires through a **novel** `UnknownQueryParameterEndpointFilter` (envelope filter, item 4 of AZ-811's implementation pattern) plus `GetTileByLatLonQueryValidator` (FluentValidation for the typed query params).
**Precondition**: API up; valid JWT attached. `error-shape.md` v1.0.0 + `tile-latlon.md` v1.0.0 frozen.
**Expected**: HTTP 400 with `Content-Type: application/problem+json` for every failure sub-case; HTTP 200 + `DownloadTileResponse` for the happy path. `errors[]` keys name the offending query parameter (e.g. `lat`).
| # | Rule | Trigger excerpt | Expected `errors` key | Test method |
|---|------|-----------------|-----------------------|-------------|
| pos | Happy path | `?lat=47.46&lon=37.64&zoom=18` | HTTP 200 — body shape unchanged | `HappyPath_Returns200` |
| 1 | `lat` in range `[-90, 90]` | `?lat=91` | `lat` | `LatOutOfRange_Returns400` |
| 2 | `lon` in range `[-180, 180]` | `?lon=181` | `lon` | `LonOutOfRange_Returns400` |
| 3 | `zoom` in range `[0, 22]` | `?zoom=30` | `zoom` | `ZoomOutOfRange_Returns400` |
| 4a | `lat` required | omit `?lat=` | `lat` ("`lat` is required") | `MissingLat_Returns400` |
| 4b | Legacy v1 names (`?Latitude=&Longitude=&ZoomLevel=`) | exact pre-AZ-811 wire format | `errors` map names BOTH unknown keys | `LegacyLatitudeLongitudeNames_Returns400` |
| 4c | Hostile / typo query keys | `?debug=1&admin=true` | `errors` map names BOTH unknown keys | `UnknownQueryKeys_Returns400` |
| 5 | `lat` type mismatch | `?lat=fifty` | `lat` | `LatTypeMismatch_Returns400` |
**Pass criterion**: Every failure sub-case returns HTTP 400 with the expected `errors` key. The happy path returns HTTP 200 + a non-empty `tileId` and a path of shape `tiles/{zoom}/{x}/{y}.jpg`. No regression in `TileByLatLonTests.cs` (cycle 8 Step 11 — green). BT-N01 (`lat=91&lon=181&zoom=18`) and BT-N02 (`zoom=25`) — pre-cycle-8 negative scenarios that only asserted "HTTP 4xx or error in body" — are now strictly bound: BT-N01 produces HTTP 400 with `errors["lat"]` AND `errors["lon"]`; BT-N02 produces HTTP 400 with `errors["zoom"]`.
**AC trace**: AZ-811 AC-1 (5 rules + ProblemDetails shape), AC-2 (happy path), AC-9 (the novel unknown-query-param envelope filter is documented in `_docs/02_document/modules/api_program.md` for reuse).
**Notes**: This is the first endpoint to need a generic **unknown-query-param rejection** layer — ASP.NET's default model binder silently ignores unknown query parameters (parallel to `UnmappedMemberHandling.Disallow` for JSON bodies, but no built-in equivalent exists for query strings). The new `UnknownQueryParameterEndpointFilter` introspects the route's declared parameters and rejects any extra keys. Sub-cases `4b` and `4c` exercise this filter: `4b` proves the pre-AZ-811 wire format (`?Latitude=&Longitude=&ZoomLevel=`) that silently fell back to `lat=0, lon=0, zoom=0` now fails fast with HTTP 400 naming all three unknown keys; `4c` proves the same path catches arbitrary hostile / typo keys. The filter is designed for reuse by any future query-param endpoint (AZ-811 AC-9).
+55 -1
View File
@@ -121,6 +121,49 @@
| AZ-796 AC-5 | `/swagger/v1/swagger.json` marks required fields, declares integer ranges per validation rules, declares 400 response with ProblemDetails schema | Doc-state AC — verified at Step 13 (Update Docs) review against the published OpenAPI document; integration smoke is the existing `JwtIntegrationTests.SwaggerDocument_AdvertisesBearerSecurityScheme` pattern (a future analogous test against the validation schema is out-of-scope this cycle) | ◐ doc-verified at Step 13 |
| AZ-796 AC-6 | `_docs/02_document/contracts/api/tile-inventory.md` updated to document the 9 validation rules + error contract reference | Doc-state AC — `_docs/02_document/contracts/api/tile-inventory.md` v2.0.0 Change Log entry naming AZ-796 (verified at Step 13 Update Docs review) | ✓ |
| AZ-796 AC-7 | `scripts/probe_inventory_validation.sh` committed; exercises each failure mode via `curl` + JWT for documentation / regression | Structural: `scripts/probe_inventory_validation.sh` exists in repo and is manually runnable | ✓ |
| AZ-808 AC-1 | Each of the 8 region-request validations rejects with HTTP 400 + ValidationProblemDetails (single-rule precision) | BT-28 sub-cases 1, 2a, 2b, 3, 4, 5, 6, 7, 8, 9, 10 (blackbox); `RegionRequestValidationTests` (integration, 11+ failure methods) + `RegionRequestValidatorTests` (unit, ≥ 8 methods covering each `RuleFor`); shared `ProblemDetailsAssertions` enforces error-shape v1.0.0 conformance | ✓ |
| AZ-808 AC-2 | Happy path unchanged — valid body returns HTTP 200 + `RegionStatusResponse`; background processing still runs; probe's 9-tile Derkachi case completes < 10 s | BT-28 sub-case `pos` (`RegionRequestValidationTests.HappyPath_Returns200`); no regression in existing `RegionRequestTests.cs` (cycle 8 Step 11 — green) | ✓ |
| AZ-808 AC-3 | `RegionRequestValidator` in its own file under `SatelliteProvider.Api/Validators/`; unit-tested (≥ 1 per `RuleFor`) | Structural: `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` exists; `SatelliteProvider.Tests/Validators/RegionRequestValidatorTests.cs` covers each `RuleFor` chain | ✓ |
| AZ-808 AC-4 | `RegionRequestValidationTests.cs` covers happy + 8+ failure modes; MUST include `Post_WithMissingId_ReturnsBadRequest` reproducing 2026-05-22 silent-coercion case | `SatelliteProvider.IntegrationTests/RegionRequestValidationTests.cs` includes `MissingId_Returns400` (the renamed AZ-777 Phase 2 reproducer) and ≥ 11 total failure methods; uses `ProblemDetailsAssertions` from AZ-795 | ✓ |
| AZ-808 AC-5 | `_docs/02_document/contracts/api/region-request.md` v1.0.0 created and published | Doc-state AC — `region-request.md` v1.0.0 created in cycle-8 batch (coordinated with AZ-812 — published directly with `lat`/`lon` names per AZ-812 AC-6); verified at Step 13 (Update Docs) review | ✓ |
| AZ-808 AC-6 | `_docs/02_document/system-flows.md` F2 updated to reference the new contract doc + error shape | Doc-state AC — verified at Step 13 (Update Docs) review | ◐ doc-verified at Step 13 |
| AZ-808 AC-7 | OpenAPI marks `RequestRegionRequest` fields `required`, declares ranges, documents 400 response | Doc-state AC — verified at Step 13 (Update Docs) review against published `/swagger/v1/swagger.json`; Swashbuckle annotations match AZ-796 pattern | ◐ doc-verified at Step 13 |
| AZ-808 AC-8 | Manual probe script exercises each failure mode via `curl` + JWT | Structural: `scripts/probe_region_validation.sh` exists and is manually runnable | ✓ |
| AZ-809 AC-1 | Each of the 14 route-creation validations rejects with HTTP 400 + ValidationProblemDetails (single-rule precision) | BT-29 sub-cases 1..14 (blackbox); `CreateRouteValidationTests` (integration, 14+ failure methods covering deserializer + per-DTO + per-element + cross-field layers) + `CreateRouteRequestValidatorTests` + `RoutePointValidatorTests` + `GeofencePolygonValidatorTests` (unit, ≥ 13 methods total) | ✓ |
| AZ-809 AC-1b (security-audit F-AZ809-1 follow-up; `route-creation.md` v1.0.1 Inv-10) | `geofences.polygons.Count <= 50` rejects with HTTP 400 + `errors["geofences.polygons"]` | BT-29 sub-case 9b (blackbox); `CreateRouteValidationTests.GeofencePolygonsTooMany_Returns400` (integration); `CreateRouteRequestValidatorTests.Validate_GeofencePolygonsTooMany_FailsCountRule` (unit) | ✓ |
| AZ-809 AC-2 | Happy path unchanged — valid body returns HTTP 200 + `RouteResponse`; F5 background still runs when `requestMaps=true`; probe's 2-point 132 m route completes < 20 s | BT-29 sub-case `pos` (`CreateRouteValidationTests.HappyPath_Returns200`); no regression in existing `RouteCreationTests.cs` (cycle 8 Step 11 — green) | ✓ |
| AZ-809 AC-3 | `CreateRouteRequestValidator`, `RoutePointValidator`, `GeofencePolygonValidator` each in their own files under `SatelliteProvider.Api/Validators/`; ≥ 13 unit-test methods total | Structural: three validator files exist as separate `.cs` files; unit-test files together contain ≥ 13 methods covering every `RuleFor` / `RuleForEach` chain | ✓ |
| AZ-809 AC-4 | `CreateRouteValidationTests.cs` covers happy + 13+ failure modes; MUST include `Post_WithMissingId_ReturnsBadRequest` | `SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs` includes `MissingId_Returns400` (the AZ-777 Phase 2 reproducer for route variant) and ≥ 14 total failure methods | ✓ |
| AZ-809 AC-5 | `_docs/02_document/contracts/api/route-creation.md` v1.0.0 created and published | Doc-state AC — `route-creation.md` v1.0.0 created in cycle-8 batch; verified at Step 13 review | ✓ |
| AZ-809 AC-6 | `_docs/02_document/system-flows.md` F4 + F5 updated to reference new contract doc + error shape | Doc-state AC — verified at Step 13 (Update Docs) review | ◐ doc-verified at Step 13 |
| AZ-809 AC-7 | OpenAPI marks all required fields at every nesting level, declares ranges, documents 400 response | Doc-state AC — verified at Step 13 against published `/swagger/v1/swagger.json` | ◐ doc-verified at Step 13 |
| AZ-809 AC-8 | Manual probe script exercises each failure mode via `curl` + JWT | Structural: `scripts/probe_route_validation.sh` exists and is manually runnable | ✓ |
| AZ-809 AC-9 | (Advisory) `RequestRegionRequest.sizeMeters` vs `CreateRouteRequest.regionSizeMeters` naming inconsistency surfaced for parent-suite decision | Not tested — surfaced in `_docs/03_implementation/batch_*_cycle8_report.md` for parent-suite team follow-up | ◐ advisory (not tested) |
| AZ-809 AC-10 | (Advisory) Input `points: [{lat, lon}]` vs output `points: [{latitude, longitude}]` round-trip asymmetry surfaced for parent-suite decision | Not tested — surfaced in `_docs/03_implementation/batch_*_cycle8_report.md` for parent-suite team follow-up | ◐ advisory (not tested) |
| AZ-810 AC-1 | Each of the 14 upload-metadata validations rejects with HTTP 400 + ValidationProblemDetails (single-rule precision) | BT-30 sub-cases 1..13 (blackbox); `UavUploadValidationTests` (integration, ≥ 13 failure methods covering deserializer + FluentValidation + envelope cross-field layers) + `UavTileMetadataValidatorTests` + `UavTileBatchMetadataPayloadValidatorTests` (unit, ≥ 11 methods total) | ✓ |
| AZ-810 AC-2 | Happy path unchanged — valid envelope returns HTTP 200 + per-item result list; per-item file rejections (`IUavTileQualityGate`) still return HTTP 200 with per-item status | BT-30 sub-case `pos` (`UavUploadValidationTests.HappyPath_Returns200`); existing AZ-488 BT-13..BT-17 + `UavUploadTests` continue green (cycle 8 Step 11 after the AZ-810 test-data coord-clamp fix in commit `b763da3`) | ✓ |
| AZ-810 AC-3 | `UavTileMetadataValidator` + `UavTileBatchMetadataPayloadValidator` each in their own files under `SatelliteProvider.Api/Validators/`; ≥ 11 unit-test methods total | Structural: two validator files plus `UavUploadValidationFilter.cs` (the multipart envelope filter) exist under `SatelliteProvider.Api/Validators/`; unit-test files contain ≥ 11 methods covering each `RuleFor` | ✓ |
| AZ-810 AC-4 | `UavUploadValidationTests.cs` covers happy + 12+ failure modes with full ValidationProblemDetails assertion | `SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs` contains ≥ 13 integration test methods spanning all three enforcement layers; uses `ProblemDetailsAssertions` | ✓ |
| AZ-810 AC-5 | `_docs/02_document/contracts/api/uav-tile-upload.md` bumped to v1.2.0 with the new validation section | Doc-state AC — `uav-tile-upload.md` v1.2.0 Change Log entry naming AZ-810; verified at Step 13 review | ✓ |
| AZ-810 AC-6 | `_docs/02_document/modules/api_program.md` documents the new multipart validation endpoint filter | Doc-state AC — `api_program.md` updated in cycle-8 batch; verified at Step 13 review | ✓ |
| AZ-810 AC-7 | OpenAPI marks `UavTileBatchMetadataPayload` + `UavTileMetadata` fields `required`, declares ranges, documents 400 response | Doc-state AC — verified at Step 13 against published `/swagger/v1/swagger.json`; `[JsonRequired]` annotations propagate to Swashbuckle as `required: [latitude, longitude, tileZoom, tileSizeMeters, capturedAt]` and `required: [items]` | ◐ doc-verified at Step 13 |
| AZ-810 AC-8 | Manual probe script exercises each failure mode via multipart `curl` + JWT | Structural: `scripts/probe_upload_validation.sh` exists, reuses the AZ-808/AZ-809/AZ-811 probe-script pattern, and is manually runnable | ✓ |
| AZ-810 AC-9 | No regression in existing AZ-488 integration tests (`UavTileBatchUploadTests.cs`, `UavTileQualityGateTests.cs`) | Cycle 8 Step 11 full integration run green AFTER fixing a pre-existing latent bug in `UavUploadTests.NextTestCoordinate` that AZ-810 exposed (seed `(Ticks/TicksPerSecond) % 1_000_000` produced lat > 90°; clamped to lat ∈ [50, 70), lon ∈ [10, 40) in commit `b763da3`). The original AC-9 verification (cycle 8 batch_04 report — "verified by tracing source") was a false-PASS; the green re-run is the binding evidence. Lesson recorded in `_docs/LESSONS.md` (2026-05-23) | ✓ (verified by full-suite re-run) |
| AZ-811 AC-1 | Each of the 5 GET-lat/lon validations rejects with HTTP 400 + ValidationProblemDetails | BT-31 sub-cases 1..5 + 4a/4b/4c (blackbox); `GetTileByLatLonValidationTests` (integration, ≥ 7 failure methods) + `GetTileByLatLonQueryValidatorTests` (unit, ≥ 3 methods) | ✓ |
| AZ-811 AC-2 | Happy path unchanged — `?lat=&lon=&zoom=` returns HTTP 200 + `DownloadTileResponse`; tile still downloaded/persisted | BT-31 sub-case `pos` (`GetTileByLatLonValidationTests.HappyPath_Returns200`); no regression in existing `TileByLatLonTests.cs` (cycle 8 Step 11 — green) | ✓ |
| AZ-811 AC-3 | `GetTileByLatLonQueryValidator` in its own file under `SatelliteProvider.Api/Validators/`; unit-tested (≥ 3 methods) | Structural: `GetTileByLatLonQueryValidator.cs` exists; unit-test file covers the 5 rules in ≥ 3 methods | ✓ |
| AZ-811 AC-4 | `GetTileByLatLonValidationTests.cs` covers happy + 4+ failure modes | `SatelliteProvider.IntegrationTests/GetTileByLatLonValidationTests.cs` contains ≥ 7 failure methods + 1 happy path; uses `ProblemDetailsAssertions` | ✓ |
| AZ-811 AC-5 | `_docs/02_document/contracts/api/tile-latlon.md` v1.0.0 created and published | Doc-state AC — `tile-latlon.md` v1.0.0 created in cycle-8 batch; verified at Step 13 review | ✓ |
| AZ-811 AC-6 | `_docs/02_document/modules/api_program.md::GetTileByLatLon Handler` updated to reference the validator + new contract doc | Doc-state AC — `api_program.md` updated in cycle-8 batch; verified at Step 13 review | ✓ |
| AZ-811 AC-7 | OpenAPI marks query params required + ranges + 400 response | Doc-state AC — verified at Step 13 against published `/swagger/v1/swagger.json` | ◐ doc-verified at Step 13 |
| AZ-811 AC-8 | Manual probe script exercises each failure mode via `curl` + JWT | Structural: `scripts/probe_latlon_validation.sh` exists and is manually runnable | ✓ |
| AZ-811 AC-9 | The novel `UnknownQueryParameterEndpointFilter` (rule 4 — unknown-query-param rejection) is documented in `_docs/02_document/modules/api_program.md` so the next query-param endpoint can reuse it | Doc-state AC — the filter's behavior + reuse contract documented in `api_program.md`; verified at Step 13 review. BT-31 sub-cases `4b` (legacy `?Latitude=&Longitude=&ZoomLevel=` rejected) and `4c` (hostile `?debug=1&admin=true` rejected) prove the filter works as documented | ✓ |
| AZ-812 AC-1 | `RequestRegionRequest` DTO uses `Lat` / `Lon` (C#) + `[JsonPropertyName("lat")]` / `[JsonPropertyName("lon")]` | Structural: `SatelliteProvider.Common/DTO/RequestRegionRequest.cs` diff shows the rename + JsonPropertyName attributes; sibling DTOs `RoutePoint` and `GeoPoint` already used `lat`/`lon` (no change there) | ✓ |
| AZ-812 AC-2 | Wire format is `{"lat":..,"lon":..}` end-to-end (request body, OpenAPI schema, docs, all integration tests) | BT-28 sub-case `pos` exercises the post-rename wire shape; integration test `RegionRequestValidationTests` + `RegionRequestTests` use `lat`/`lon` in every body; `region-request.md` v1.0.0 ships with `lat`/`lon` from day one (AZ-812 AC-6 coordination with AZ-808); OpenAPI verified at Step 13 | ✓ |
| AZ-812 AC-3 | `RegionTests.cs` happy-path tests pass against new wire format | Cycle 8 Step 11 full run — green; all `RegionRequestTests` updated to send `lat`/`lon` in the same commit as the DTO rename | ✓ |
| AZ-812 AC-4 | `curl` probe with `{"id":"<guid>","lat":49.94,"lon":36.31,"sizeMeters":200,"zoomLevel":18,"stitchTiles":false}` returns HTTP 200 + valid `regionId`; old `{"latitude":..,"longitude":..}` returns HTTP 400 with `UnmappedMemberHandling.Disallow` rejecting the unknown fields | BT-28 sub-case `pos` (new names accepted) + sub-case `9` (`OldLatLongNames_Returns400` — old `latitude`/`longitude` rejected as unknown). The strict-deserializer behavior is what AZ-795's `UnmappedMemberHandling.Disallow` makes possible; pre-cycle-8 the rename would have silently coerced old names to `Lat=0, Lon=0` | ✓ |
| AZ-812 AC-5 | Docs updated: `common_dtos.md`, `api_program.md`, `system-flows.md` (F2) | Doc-state AC — all three files updated in cycle-8 batch; verified at Step 13 review | ◐ doc-verified at Step 13 |
| AZ-812 AC-6 | Contract doc coordination: `region-request.md` v1.0.0 published directly with `lat`/`lon` (because AZ-808 + AZ-812 shipped in same cycle) — no `v1.0.0 → v2.0.0` bump needed | Doc-state AC — `region-request.md` v1.0.0 Change Log section names both AZ-808 (validation rules) and AZ-812 (`lat`/`lon` field names); verified at Step 13 review | ✓ |
## Restrictions → Test Mapping
@@ -171,7 +214,8 @@
| Cycle 5 — AZ-504 perf-script fix (shell harness + Step-15 gate) | 1 standalone shell harness (4 cases) | 2/4 verified now (AC-1, AC-2); 2/4 gated at Step 15 (AC-3, AC-4) | — |
| Cycle 6 — AZ-505 inventory + HTTP/2 + leaflet covering index (integration + blackbox + perf) | 3 integration files + 4 blackbox (BT-23..BT-26) + 1 perf (PT-09) | 7/7 (AC-1..AC-7; AC-7 is doc-only). Also resolves the 5 AZ-503 deferrals (AC-5, 6, 9, 10, 12). | — |
| Cycle 7 — AZ-794 + AZ-795 + AZ-796 strict inventory validation + z/x/y rename (integration + unit + blackbox + contract) | 1 integration file (`TileInventoryValidationTests`, 16 tests) + 1 unit file (`InventoryRequestValidatorTests`, 16 tests) + 1 blackbox (BT-27 with 16 sub-cases) + 1 new contract (`error-shape.md` v1.0.0) + 1 bumped contract (`tile-inventory.md` v2.0.0) | 12/12 in-scope (AZ-794 AC-1..AC-4, AZ-795 epic-level, AZ-796 AC-1..AC-7); 2 ACs (AZ-794 AC-3 + AZ-796 AC-5) are `◐ doc-verified at Step 13`. | — |
| **Total** | **126** | **75/75 in-scope (100%); 2 AZ-504 ACs gated at Step 15; 2 cycle-7 ACs doc-verified at Step 13** | **8/8 (100%)** |
| Cycle 8 — AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812 strict validation sweep + region OSM rename (integration + unit + blackbox + contracts) | 4 integration files (`RegionRequestValidationTests`, `CreateRouteValidationTests`, `UavUploadValidationTests`, `GetTileByLatLonValidationTests` — ≥ 45 failure methods + 4 happy paths) + 5 unit files (`RegionRequestValidatorTests`, `CreateRouteRequestValidatorTests`, `RoutePointValidatorTests`, `GeofencePolygonValidatorTests`, `UavTileMetadataValidatorTests`, `UavTileBatchMetadataPayloadValidatorTests`, `GetTileByLatLonQueryValidatorTests` — ≥ 35 methods across the 4 endpoints) + 4 blackbox (BT-28..BT-31 with ≥ 41 sub-cases) + 4 new contracts (`region-request.md` v1.0.0, `route-creation.md` v1.0.0, `tile-latlon.md` v1.0.0, `uav-tile-upload.md` v1.2.0 bump) + 4 probe scripts | 41/41 in-scope (AZ-808 AC-1..AC-8, AZ-809 AC-1..AC-8, AZ-810 AC-1..AC-9, AZ-811 AC-1..AC-9, AZ-812 AC-1..AC-6); 8 ACs are `◐ doc-verified at Step 13` (per-endpoint OpenAPI / system-flows updates) + 2 advisory non-tested (AZ-809 AC-9, AC-10 — naming consistency surfaced for parent-suite). AZ-810 AC-9 (no AZ-488 regression) verified after the AZ-810 test-data coord-clamp fix (commit `b763da3`) — the original "traced by source" verification was a false-PASS; the green full-suite re-run is the binding evidence. | — |
| **Total** | **167** | **116/116 in-scope (100%); 2 AZ-504 ACs gated at Step 15; 10 ACs doc-verified at Step 13 (2 cycle-7 + 8 cycle-8); 2 advisory non-tested (cycle-8 AZ-809 AC-9/AC-10)** | **8/8 (100%)** |
**Coverage shape notes (Cycle 5 — AZ-503 foundation):**
- AZ-503 was split mid-cycle (Option C, autodev Step 10 batch 2): 7 of 12 original ACs land here; 5 (AC-5, AC-6, AC-9, AC-10, AC-12) are deferred to AZ-505 with a `Blocks` link in Jira and an entry in `_docs/02_tasks/_dependencies_table.md`. The deferred rows above are marked `◐ deferred → AZ-505` so the matrix surfaces the scope boundary explicitly.
@@ -209,3 +253,13 @@
- AZ-505 AC-6's existing row (cycle 6 — "Request validation — 400 on both populated, 400 on neither, 400 on > 5000 entries, 401 on anonymous") remains accurate. Its 4 cases overlap with AZ-796 AC-1 sub-cases 2a, 2b, 4, and the anonymous case (also SEC-05). Both rows are kept per cycle-update rule 4 ("Preserve existing traceability IDs"); the duplication is by design — AZ-505 AC-6 was the cycle-6 contract (status-code-only), AZ-796 AC-1 is the cycle-7 contract (status code + ProblemDetails shape + field-path errors). The cycle-7 row is the binding one going forward; the cycle-6 row stays as historical record.
- Cycle-update rule check: no NFR conflicts. The 5000-entry cap is reaffirmed (matches AZ-505); the supported zoom range 0..22 is reaffirmed (matches `tile-inventory.md` Inv-7); the error shape contract is **new** (`error-shape.md` v1.0.0) — but no prior cycle declared a different error shape, so this is greenfield content, not a conflict.
- Step 10 artifact gap (cycle 7): no `implementation_report_*_cycle7.md` was produced in `_docs/03_implementation/`. The actual implementation evidence lives in commits `dceaddc` (cycle 7 task adoption) + `865dfdb` (cycle 7 Step 10 implementation), in the state file's `detail` field (which recorded the test-run outcome), and in the new test artifacts themselves (`InventoryRequestValidator.cs`, `InventoryRequestValidatorTests.cs`, `TileInventoryValidationTests.cs`, `ProblemDetailsAssertions.cs`, `error-shape.md` v1.0.0). This artifact gap is recorded here for cycle 7 retrospective follow-up — the matrix itself is unaffected because cycle-update mode's source-of-truth is the task specs in `_docs/02_tasks/done/`, not the implementation report.
**Coverage shape notes (Cycle 8 — AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812 strict validation sweep + region OSM rename):**
- Cycle 8 completes the AZ-795 epic's per-endpoint rollout — every public-facing endpoint now goes through the shared validation infra. AZ-795's `AZ-795 (epic)` row from cycle 7 remains `✓`; cycle 8 adds 4 endpoint-scoped per-AC rows (AZ-808, AZ-809, AZ-810, AZ-811) plus the AZ-812 region-rename rows that ride the AZ-795 `UnmappedMemberHandling.Disallow` infra to make the old field names fail-fast (mirroring cycle 7's AZ-794 / AZ-796 coupling).
- AZ-810 introduced a **new** validation enforcement shape — the `multipart/form-data` envelope — because `POST /api/satellite/upload` is the only endpoint that can't use the generic `ValidationEndpointFilter<T>`. The bespoke `UavUploadValidationFilter` composes three layers (deserializer, FluentValidation, envelope cross-field) with a different error-key convention (`errors["metadata"]` for deserializer-level failures vs `errors["metadata.items[i].field"]` for FluentValidation-layer failures). This is documented in BT-30 §Notes and `_docs/02_document/contracts/api/uav-tile-upload.md` v1.2.0 §Validation Rules so future multipart endpoints can reuse the pattern.
- AZ-811 introduced a **new** generic infra piece — `UnknownQueryParameterEndpointFilter` (rule 4 — the parallel of `UnmappedMemberHandling.Disallow` for query strings). Documented in `_docs/02_document/modules/api_program.md` per AZ-811 AC-9. The next query-param endpoint can reuse it without reinventing the unknown-key rejection logic.
- AZ-808 + AZ-812 shipped in the same cycle. The AZ-812 OSM rename (`Latitude/Longitude``Lat/Lon`) was coordinated with AZ-808's validator authoring so the validator was never written against the old names (per AZ-812 AC-6 coordination). `region-request.md` is published as v1.0.0 (not v1.0.0→v2.0.0 bump) with both AZ-808 (validation rules) and AZ-812 (`lat`/`lon` field names) in the Change Log.
- BT-N01 and BT-N02 (legacy negative scenarios for `GET /api/satellite/tiles/latlon` that loosely asserted "HTTP 4xx") are NOT rewritten — they remain as historical record. BT-31 sub-cases 1, 2, 3 supersede them with strict assertions (HTTP 400 + named `errors` key). Both rows are kept per cycle-update rule 4 ("Preserve existing traceability IDs"); the cycle-8 row is the binding one going forward.
- AZ-809 ACs 9 + 10 are **advisory** (surfaced for parent-suite team decision, not implemented or tested this cycle). Matrix marks them `◐ advisory (not tested)`. They're recorded so the next cycle / parent-suite review sees them without having to re-discover them from the task spec. AC-9: `RequestRegionRequest.sizeMeters` vs `CreateRouteRequest.regionSizeMeters` naming inconsistency. AC-10: input `points: [{lat, lon}]` vs output `points: [{latitude, longitude}]` round-trip asymmetry on the route endpoint. Either keep + document, or harmonize in a follow-up MAJOR contract bump for both — parent-suite team's call.
- AZ-810 AC-9 (no AZ-488 regression) has a **process annotation**: cycle 8's batch_04 report originally claimed AC-9 "verified by tracing each AZ-488 test payload's metadata shape against the new rules" without running the integration suite. That verification was a false-PASS — the suite was actually red on the AZ-488 happy path because `UavUploadTests.NextTestCoordinate()` produced lat > 90° (a pre-existing latent bug masked by the absence of any validator before AZ-810). The bug was fixed by clamping the test-data generator to OSM-valid ranges in commit `b763da3` and AC-9 is now bound to the green full-suite re-run, not to source tracing. Process lesson recorded in `_docs/LESSONS.md` (2026-05-23).
- Cycle-update rule check: no NFR conflicts. Range bounds (`lat ∈ [-90, 90]`, `lon ∈ [-180, 180]`, `zoom ∈ [0, 22]`, `tileSizeMeters > 0`) are reaffirmed across all 4 endpoints — they were never previously contested. The error-shape contract (`error-shape.md` v1.0.0 from cycle 7) is reused unchanged.
@@ -0,0 +1,60 @@
# Dependency Scan (Cycle 8)
**Date**: 2026-05-23
**Mode**: Delta scan
**Scope**: Cycle-8 delta over the cycle-7 dependency scan (`_docs/05_security/dependency_scan_cycle7.md`). Cycle-8 surface = AZ-808 + AZ-809 + AZ-810 + AZ-811 (strict input validation rolled out across the remaining 4 endpoints) + AZ-812 (region-API wire rename `Latitude`/`Longitude``Lat`/`Lon`).
**Method**: Manifest diff via `git diff --name-only 865dfdb..b763da3 -- '*.csproj'` (cycle-7 tip → cycle-8 tip; verified empty). `dotnet list package --vulnerable` is intentionally not run (per `AGENTS.md`: that command hangs the agent shell in this workspace). The manifest-diff substitute is deterministic because the result is null.
## Cycle-8 Package Manifest Diff
| csproj | Cycle 7 baseline | Cycle 8 change | Net effect on supply chain |
|--------|------------------|----------------|----------------------------|
| `SatelliteProvider.Api/SatelliteProvider.Api.csproj` | references `Microsoft.AspNetCore.OpenApi 10.0.7`, `Microsoft.AspNetCore.Authentication.JwtBearer 10.0.7`, `Newtonsoft.Json 13.0.4`, `Serilog.AspNetCore 8.0.3`, `Serilog.Sinks.File 6.0.0`, `SixLabors.ImageSharp 3.1.11`, `Swashbuckle.AspNetCore 10.1.7`, `FluentValidation 12.0.0`, `FluentValidation.DependencyInjectionExtensions 12.0.0` | **+0 PackageReferences**. Every cycle-8 validator + filter reuses the AZ-795 infrastructure already shipped in cycle 7. | None. |
| `SatelliteProvider.Common/SatelliteProvider.Common.csproj` | unchanged from cycle 5 | **+0 PackageReferences** — the cycle-8 DTO changes (`[JsonRequired]` on `RequestRegionRequest`, `CreateRouteRequest`, `RoutePoint`, `GeoPoint`, `GeofencePolygon.NorthWest/SouthEast`, `Geofences.Polygons`, `UavTileMetadata.*`) and the AZ-812 rename are BCL + `System.Text.Json.Serialization` only. | None. |
| `SatelliteProvider.DataAccess/SatelliteProvider.DataAccess.csproj` | unchanged from cycle 5 | **+0 PackageReferences**. | None. |
| `SatelliteProvider.Services.TileDownloader/SatelliteProvider.Services.TileDownloader.csproj` | unchanged from cycle 5 | **+0 PackageReferences**. The cycle-8 validator path lives in the API project; the existing `UavTileUploadHandler` defence-in-depth path is untouched. | None. |
| `SatelliteProvider.Services.RegionProcessing/SatelliteProvider.Services.RegionProcessing.csproj` | unchanged from cycle 5 | **+0 PackageReferences**. | None. |
| `SatelliteProvider.Services.RouteManagement/SatelliteProvider.Services.RouteManagement.csproj` | unchanged from cycle 5 | **+0 PackageReferences**. | None. |
| `SatelliteProvider.Tests/SatelliteProvider.Tests.csproj` | unchanged from cycle 5 | **+0 PackageReferences** — the 8 new validator unit-test files reuse the cycle-7 `FluentValidation.TestHelper` namespace (transitive via `FluentValidation` main package, picked up via `ProjectReference` to the API). | None. |
| `SatelliteProvider.IntegrationTests/SatelliteProvider.IntegrationTests.csproj` | unchanged from cycle 5 | **+0 PackageReferences** — the 4 new integration test files (`CreateRouteValidationTests`, `GetTileByLatLonValidationTests`, `RegionFieldRenameTests`, `RegionRequestValidationTests`, `UavUploadValidationTests`) reuse the cycle-7 `ProblemDetailsAssertions` helper + the pre-existing `Xunit` + `Microsoft.AspNetCore` ProjectReference. | None. |
| `SatelliteProvider.TestSupport/SatelliteProvider.TestSupport.csproj` | unchanged from cycle 5 | **+0 PackageReferences**. | None. |
**Net cycle-8 dependency change**: **zero new `PackageReference` lines, zero removed lines, zero version bumps**. Every `*.csproj` file in the repo is byte-identical between `865dfdb` (cycle-7 tip) and `b763da3` (cycle-8 tip).
## Cycle-7 Carry-overs
Because cycle 8 added no new packages and bumped none, every cycle-7 dependency finding remains in force unchanged:
### D-AZ795-1 (Low / Hardening) — FluentValidation 12.0.0 → 12.1.1
- Filed in `dependency_scan_cycle7.md` § "FluentValidation 12.0.0" and `security_report_cycle7.md` § "D-AZ795-1".
- Status at cycle-8 tip: **still open**. Cycle 8 did not bump either `FluentValidation` or `FluentValidation.DependencyInjectionExtensions` from 12.0.0 to 12.1.1. The same hardening-release recommendation carries forward.
- Cycle-8-specific re-check at https://github.com/FluentValidation/FluentValidation/security/advisories (audit date 2026-05-23): no NEW advisories published against 12.x since the cycle-7 audit. The bump remains pure forward-compatibility hardening.
### D2-cy4 (Medium / test-runtime only) — `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks`
- Filed in `dependency_scan_cycle4.md` and re-confirmed in cycles 5 and 7.
- Status at cycle-8 tip: **still open**. Cycle 8 did not bump `Microsoft.NET.Test.Sdk`. Test-runtime exposure only; not reachable from the API process.
## Cycle-8 New Source Code Runtime Surface
Because no new packages were added, the new runtime surface introduced by cycle 8 sits **entirely within already-vetted packages**:
| New surface | Hosted by | Risk delta |
|-------------|-----------|------------|
| 4 new `AbstractValidator<T>` subclasses (`RegionRequestValidator`, `CreateRouteRequestValidator`, `GetTileByLatLonQueryValidator`, `UavTileBatchMetadataPayloadValidator`) + 4 helper validators (`RoutePointValidator`, `GeofencePolygonValidator`, `UavTileMetadataValidator`, the inner `GeoCornerValidator`) | `FluentValidation 12.0.0` — same package surface assessed in cycle 7. | None — reflection scan path (`AddValidatorsFromAssemblyContaining<Program>()`) is unchanged from cycle 7; cycle 8 adds more registered types but uses the same registration call. |
| `RejectUnknownQueryParamsEndpointFilter` + `UavUploadValidationFilter` — two new `IEndpointFilter` types | ASP.NET Core 10 — `Microsoft.AspNetCore.Http` already in the BCL footprint. | None — `IEndpointFilter` API surface is unchanged; `UavUploadValidationFilter` is `AddTransient<>`-registered, so per-request instance isolation matches the existing `WithValidation<T>()` pattern from cycle 7. |
| `[JsonRequired]` annotations on 6 modified DTOs (`RequestRegionRequest`, `CreateRouteRequest`, `RoutePoint`, `GeoPoint`, `GeofencePolygon`, `UavTileMetadata`) | `System.Text.Json.Serialization` — BCL. | None — cycle-7 baseline already used `[JsonRequired]` on `TileCoord`; cycle 8 just expands coverage. |
| `[JsonPropertyName]` annotations added by AZ-812 to `RequestRegionRequest.Lat`/`Lon` | `System.Text.Json.Serialization` — BCL. | None. |
## Cycle-8 Findings
**No new dependency findings.**
The cycle-7 D-AZ795-1 Low/Hardening recommendation (`FluentValidation 12.0.0 → 12.1.1`) is **carried forward unchanged** and re-iterated in this cycle's report. Cycle 8 did not regress any prior cycle's posture.
## Verdict
**PASS** (cycle-8 delta) — zero new CVEs, zero new supply-chain blockers, zero new packages.
Cumulative verdict (carrying forward earlier cycles): **PASS_WITH_WARNINGS** — D2-cy4 (cycle-4 Medium, test-runtime only) + D-AZ795-1 (cycle-7 Low/Hardening) both still in effect. Cycle 8 adds nothing to the cumulative dependency-finding ledger.
@@ -0,0 +1,70 @@
# Infrastructure & Configuration Review (Cycle 8)
**Date**: 2026-05-23
**Mode**: Delta scan
**Scope**: Cycle-8 changes to deployment configs, CI/CD files, environment templates, and shell scripts only.
## Cycle-8 Infrastructure-Layer Diff
Computed via `git diff --name-only 865dfdb..b763da3` (cycle-7 tip → cycle-8 tip), filtered to infrastructure-relevant paths:
| File | Diff summary | Security relevance |
|------|--------------|--------------------|
| `Dockerfile`, `Dockerfile.tests`, `Dockerfile.api` (and any image-build file) | **NOT modified** | None — cycle 8 did not touch any image build. |
| `docker-compose.yml`, `docker-compose.tests.yml`, `docker-compose.prod.yml` (and any orchestration file) | **NOT modified** | None — cycle 8 did not touch any compose file. |
| `.woodpecker.yml`, `.github/workflows/**` (and any CI/CD pipeline definition) | **NOT modified** | None — cycle 8 added no automated pipeline changes. |
| `.env`, `.env.example`, `.env.tests` (and any environment template) | **NOT modified** | None — cycle 8 read no new env vars (every cycle-8 validator and filter is pure code; FluentValidation 12.0.0 has no config knobs). |
| `appsettings.json`, `appsettings.Development.json`, `appsettings.tests.json` | **NOT modified** | None — cycle 8 added no new configuration sections. The pre-existing `UavQualityConfig` section is unchanged. |
| `scripts/probe_latlon_validation.sh` | NEW manual probe script for AZ-811 | Reviewed in `static_analysis_cycle8.md` § Test Code Review § `scripts/probe_*_validation.sh`. ✓ |
| `scripts/probe_region_validation.sh` | NEW manual probe script for AZ-808 | Reviewed in `static_analysis_cycle8.md` § Test Code Review § `scripts/probe_*_validation.sh`. ✓ |
| `scripts/probe_route_validation.sh` | NEW manual probe script for AZ-809 | Reviewed in `static_analysis_cycle8.md` § Test Code Review § `scripts/probe_*_validation.sh`. ✓ |
| `scripts/probe_upload_validation.sh` | NEW manual probe script for AZ-810 | Reviewed in `static_analysis_cycle8.md` § Test Code Review § `scripts/probe_*_validation.sh`. ✓ |
| `scripts/run-performance-tests.sh` | Modified — diff is exclusively the AZ-812 wire-format rename (`?Latitude=…&Longitude=…&ZoomLevel=…``?lat=…&lon=…&zoom=…` across query strings, and `{"latitude":…,"longitude":…}``{"lat":…,"lon":…}` across JSON bodies in PT-01 through PT-08 invocations) | No new credentials, no new shell-injection surface. The change is a forced wire-update made necessary by AZ-812's contract rename. ✓ |
| `.vscode/launch.json`, `.vscode/tasks.json`, `README.md` | Modified — developer-tooling artifacts | Out of scope for security audit (not deployed; do not affect runtime). |
## Probe Shell Scripts — Common Safety Posture
All four new `probe_*_validation.sh` scripts follow the same defensive pattern (verified per `static_analysis_cycle8.md`):
- `#!/usr/bin/env bash` with `set -euo pipefail` at the top — fail-fast on undefined vars, broken pipes, command failures.
- `API_URL="${API_URL:-https://localhost:8080}"` default; `JWT="${JWT:-}"` with an explicit `if [[ -z "${JWT}" ]]; then echo "ERROR: set JWT env var to a bearer token. …"; exit 2; fi` guard.
- `curl -k` used (justified — the dev cert is self-signed; the scripts target localhost in dev/test only — documented in each script's header).
- Hand-built JSON payloads via shell variable interpolation — values are integer / quoted strings only, no caller-supplied shell strings.
- No embedded secrets, no hardcoded `$JWT`, no `.env` reads.
Identical posture to the cycle-7 `probe_inventory_validation.sh`. ✓
## Container & Image Security — Carried Forward Unchanged
| Check | Status (carried from cycle 5/6/7) | Cycle-8 impact |
|-------|-----------------------------------|----------------|
| Non-root container user (Dockerfile `USER` directive) | Already in effect | None — no Dockerfile change. |
| Minimal base image (alpine/distroless/etc.) | The API image uses the .NET 10 SDK base — same as cycle 7; image hardening is still owned by a separate, unscheduled follow-up task. | None. |
| No secrets in build args | Verified cycle 5; no `Dockerfile` change in cycle 7 or 8 | None. |
| Health checks | Compose `healthcheck` block on Postgres unchanged | None. |
## CI/CD Security — Carried Forward Unchanged
| Check | Status | Cycle-8 impact |
|-------|--------|----------------|
| Secrets management (env vars / vault, not pipeline literals) | Existing pattern preserved | None. |
| No credentials in pipeline definitions | `.woodpecker.yml` untouched in cycle 8 | None. |
| Artifact signing | Existing posture (none — owned by a separate operational improvement track) | None. |
| Dependency-audit step in pipeline | Existing posture (manual audit per `dependency_scan_cycle*.md`; no automated `dotnet list package --vulnerable` in CI due to the build-hang issue noted in `AGENTS.md`) | None. |
## Environment & Secrets
- `.env.example` — not modified in cycle 8. The cycle-8 code reads no new env vars.
- `appsettings.Development.json` — not modified in cycle 8.
- `appsettings.json` — production template; unchanged in cycle 8.
- The cycle-7 host-port change (`5432:5432``5433:5432`) is preserved through cycle 8; not relevant to production exposure (production containers run on a private docker network without host-port mapping per the existing deployment model).
## Cycle-7 + Earlier Carry-overs
No infrastructure-layer carry-overs from earlier cycles. The cycle-2 `UavQualityConfig`-derived `KestrelServerOptions.Limits.MaxRequestBodySize = 500 MiB` (set in `Program.cs:41-43`) is noted in `owasp_review_cycle8.md` § A05 as a configuration choice that contributed to the F-AZ809-1 exploit-math worst case, but it is not itself an infrastructure-layer finding — the value is correct for the UAV upload endpoint, and the proper fix is per-endpoint size narrowing (a code change, not an infra change).
## Verdict (Phase 4)
**PASS** — zero new infrastructure-layer findings.
The four new probe scripts are dev/test only, env-driven, fail-fast under `set -euo pipefail`, and contain no embedded credentials. The single perf-script update is a wire-rename diff with no security impact. Cycle 8 made no Docker, compose, CI, env-template, or appsettings changes.
+111
View File
@@ -0,0 +1,111 @@
# OWASP Top 10 Review (Cycle 8)
**Date**: 2026-05-23
**Mode**: Delta scan against OWASP Top 10:2021 (current at audit time per https://owasp.org/www-project-top-ten/ — verified 2026-05-23; the 2025 candidate revision is still in public-comment phase and not adopted).
**Scope**: Cycle-8 delta only — AZ-808 (region POST validator), AZ-809 (route POST validator + per-point + per-polygon), AZ-810 (UAV upload metadata validator + custom filter), AZ-811 (lat/lon GET validator + unknown-query-param filter), AZ-812 (region-API `Latitude`/`Longitude``Lat`/`Lon` rename). Earlier cycles' OWASP reviews remain authoritative for their respective surfaces; this file does NOT re-walk the cycle-5 / cycle-7 baselines.
## A01 — Broken Access Control
**Status**: PASS
- `.RequireAuthorization()` is preserved on every cycle-8 endpoint:
- `POST /api/satellite/request` at `Program.cs:251`
- `POST /api/satellite/route` at `Program.cs:267`
- `GET /api/satellite/tiles/latlon` at `Program.cs:213`
- `POST /api/satellite/upload` at `Program.cs:238` (additionally requires the `GPS` permission claim via `SatellitePermissions.UavUploadPolicy`).
- Endpoint-filter execution order is governed by ASP.NET Core's middleware → routing → endpoint-filter pipeline. `app.UseAuthorization()` (line 206) reads the endpoint metadata produced by `.RequireAuthorization()` and short-circuits anonymous callers with 401 BEFORE the endpoint dispatch reaches any endpoint filter. Cycle-8 verification: none of the four new validation paths runs unless the caller is authenticated.
- The new `RejectUnknownQueryParamsEndpointFilter` (lat/lon GET) does not establish its own auth gate — it relies on the endpoint chain's existing `.RequireAuthorization()`. Anonymous query-param probing is impossible.
- No new CORS policy in cycle 8. `TilesCors` (cycle-6 baseline) is unchanged.
- No new IDOR paths — the four endpoints operate on caller-supplied identifiers but do not couple them to any tenant or owner field; tiles remain globally-scoped in the post-AZ-484 model. Geospatial identifiers (`Lat`, `Lon`, `Z/X/Y`) are deterministic projections of physical reality, not capability tokens.
## A02 — Cryptographic Failures
**Status**: N/A (cycle 8)
- Cycle 8 has no cryptographic operations. JWT validation is unchanged from cycle 4 (`AddSatelliteJwt` — HS256 with ≥ 32-byte secret, `ValidateLifetime + ValidateIssuer + ValidateAudience = true`, ClockSkew = 30s).
- The cycle-5 UUIDv5 SHA-1 surface (`Uuidv5.Create`) is unaffected.
- TLS posture (Kestrel `Http1AndHttp2` with self-signed dev cert / ingress termination in prod) — unchanged from cycle 6.
- F-AZ810-2 (`DateTime` vs `DateTimeOffset` parsing) is NOT a crypto failure — it's a time-handling correctness concern documented under A09.
## A03 — Injection
**Status**: PASS
- No SQL / Dapper / Npgsql usage in any cycle-8 new file. (`grep -r 'Dapper\|Npgsql' SatelliteProvider.Api/Validators SatelliteProvider.Api/DTOs SatelliteProvider.Common/DTO` → zero matches across the cycle-8 surface.)
- No `Process.Start` / shell-out / `eval` in any cycle-8 new file.
- All inputs reaching the cycle-8 validators are strongly typed by the time the rules execute (`double`, `int`, `Guid`, `DateTime`, `string`, `IReadOnlyList<T>`). `System.Text.Json` has already parsed and rejected anything malformed before the validator runs, and the cycle-7 deserializer hardening (`UnmappedMemberHandling.Disallow`) is in force on every cycle-8 path including the multipart `metadata` field (`UavUploadValidationFilter.cs:73` uses the same global `JsonSerializerOptions` via `IOptions<JsonOptions>`).
- The cycle-8 wire-format rename (AZ-812) means the deserializer now strictly enforces `lat` / `lon` against the post-rename JSON schema — legacy `Latitude` / `Longitude` fields are rejected as unknown members, surfacing as `JsonException` → 400 via `GlobalExceptionHandler` (not silently bound to a fallback property). This is a small *anti-injection* improvement at the schema boundary.
## A04 — Insecure Design
**Status**: **PASS (post-follow-up)** — was PASS_WITH_WARNINGS at audit time; F-AZ809-1 was the only Medium and was resolved in the Step-14 follow-up commit (cycle 8). Post-follow-up posture is PASS.
- AZ-808 / AZ-809 / AZ-810 / AZ-811 are themselves a *design fix* for the remaining unprotected endpoints — completing the AZ-795 epic's per-endpoint rollout. Pre-cycle-8, four endpoints (region POST, route POST, lat/lon GET, UAV upload) used ad-hoc inline `try/catch` blocks or no input validation at all. Cycle 8 centralises every public endpoint behind one of three approved validation paths:
1. `WithValidation<T>()` for JSON-body endpoints (RegionRequest, CreateRouteRequest).
2. `WithValidation<T>()` + `RejectUnknownQueryParamsEndpointFilter` for query-string endpoints (GetTileByLatLonQuery).
3. Custom `IEndpointFilter` for non-standard wire formats (`UavUploadValidationFilter` for multipart).
- The cycle-7 architecture-doc § 9 coverage table now reaches **100% of public-facing input endpoints** with validators (region POST, route POST, lat/lon GET, inventory POST, UAV upload). Future drift visibility is high.
- **F-AZ809-1** (from `static_analysis_cycle8.md`) identified a design-level gap: `CreateRouteRequestValidator` lacked a max-count cap on `Geofences.Polygons`, in contrast to every other list-bearing field across the API (route `Points` ≤ 500, UAV `Items` ≤ 100, inventory `Tiles`/`LocationHashes` ≤ 5000). **Resolved in the Step-14 follow-up (cycle 8)**: `MaxPolygons = 50` cap added + matching unit + integration tests + `route-creation.md` v1.0.1 Inv-10. The pattern "cap every collection field" is now fully consistent across the API.
## A05 — Security Misconfiguration
**Status**: PASS
- `UnmappedMemberHandling.Disallow` (cycle-7 global default) is now backed by per-endpoint FluentValidation rules at every cycle-8 endpoint, completing the defence-in-depth hardening for mass-assignment prevention.
- Swagger exposure is still gated by `app.Environment.IsDevelopment()` (unchanged).
- `appsettings.Development.json` clearly tags DEV-ONLY JWT iss/aud values; `appsettings.json` ships empty so production fail-fast triggers if env vars are missing (unchanged from cycle 4).
- The new `AddTransient<UavUploadValidationFilter>` registration in `Program.cs:128` is correct — transient (not singleton) ensures each request gets a fresh filter instance, preventing accidental cross-request state retention through filter-instance fields.
- The cycle-7 `AddValidatorsFromAssemblyContaining<Program>()` scope rule is unchanged; the cycle-8 validators all live in `SatelliteProvider.Api.dll`, so the reflection scan correctly picks them up.
- **Note (informational, not a finding)**: the global Kestrel body-size limit (`MaxRequestBodySize = 500 MiB`) was originally set for the UAV upload endpoint in cycle 2 (AZ-488). It applies to every endpoint by default because Kestrel exposes a per-server, not per-endpoint, default. Cycle 8's F-AZ809-1 highlights the consequence: tight per-endpoint count caps in validators are now the primary defence on JSON endpoints, not the framework body limit. A future hardening cycle could narrow the body limit per-endpoint via `IRequestSizeLimitMetadata` on the `RouteHandlerBuilder` for non-upload endpoints — but this is hardening, not a finding.
## A06 — Vulnerable & Outdated Components
**Status**: PASS_WITH_WARNINGS (carry-over Low)
- See `dependency_scan_cycle8.md` for the full table. Summary:
- Cycle 8 added zero new packages and bumped zero existing packages.
- **D-AZ795-1** (cycle-7 carry-over): `FluentValidation` + `FluentValidation.DependencyInjectionExtensions` 12.0.0 → 12.1.1 hardening recommendation — still open. Cycle 8 did not bump.
- **D2-cy4** (cycle-4 carry-over): `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks` Medium — test-runtime exposure only, still open.
## A07 — Identification and Authentication Failures
**Status**: PASS
- JWT validation parameters unchanged from cycle 4 (`AddSatelliteJwt`).
- No new auth-bypass paths introduced by cycle 8. The four new validators / filters cannot run for anonymous callers (see A01).
- The five new integration test files (`CreateRouteValidationTests`, `GetTileByLatLonValidationTests`, `RegionFieldRenameTests`, `RegionRequestValidationTests`, `UavUploadValidationTests`) all mint valid tokens via the shared `JwtTestHelpers.MintAuthenticated(...)` — proves the happy path is properly auth-gated and not relying on any test-only bypass.
- `RejectUnknownQueryParamsEndpointFilter` rejects malicious query keys (e.g. `?debug=1`, `?Authorization=…`) at the endpoint-filter layer — narrows the auth fingerprinting surface vs. the pre-cycle-8 behaviour where unknown keys silently bound to defaults.
## A08 — Software and Data Integrity Failures
**Status**: N/A (cycle 8)
- No CI/CD changes, no artifact-signing changes, no auto-update paths touched in cycle 8. The only CI/script-adjacent file modified is `scripts/run-performance-tests.sh` (wire-rename diff only — see `static_analysis_cycle8.md` § Test Code Review).
## A09 — Security Logging and Monitoring Failures
**Status**: PASS_WITH_WARNINGS (3 Lows — F-AZ810-1 new + F-AZ795-1 + F-AZ795-2 carry-over)
- `GlobalExceptionHandler` 5xx branch logs `Method`, `Path`, `correlationId`, and the exception object (via Serilog default). The 4xx branch does NOT log the exception (intentional, avoids noisy log signal from malformed-payload spam). Unchanged from cycle 7.
- The cycle-8 validators and the new `UavUploadValidationFilter` do NOT add any logging — they return `Results.ValidationProblem(...)` directly without any audit trail. This is consistent with cycle 7's `ValidationEndpointFilter<T>` posture (the response itself carries enough detail for the client to self-debug; the server logs would otherwise drown in 400s from typo-prone callers). Acceptable for the threat model.
- **F-AZ810-1** (NEW in cycle 8): `UavUploadValidationFilter.cs:82` echoes `JsonException.Message` to the client — same information-disclosure pattern as cycle-7 F-AZ795-1 in a NEW code path. Both surfaces leak `System.*` type names + parse positions. **Filed as Low**.
- **F-AZ795-1** (cycle-7 carry-over): `GlobalExceptionHandler.cs:108-117` — still open.
- **F-AZ795-2** (cycle-7 carry-over): `GlobalExceptionHandler.cs:88-93` — still open. Cycle 8 reduces practical reachability on the 4 newly-validated endpoints but doesn't eliminate it.
- **F-AZ810-2** (Informational, listed under A09 as a time-handling correctness concern with downstream logging/monitoring implications): `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset` — stale tiles could pass the freshness check in dev environments with non-UTC host TZ if the caller omits the `Z` suffix. Zero observable impact in UTC-deployed production. **Filed as Low / Informational**.
## A10 — Server-Side Request Forgery (SSRF)
**Status**: N/A (cycle 8)
- No URL-input fields, no outbound HTTP calls triggered by the cycle-8 surface. The pre-existing `GoogleMapsDownloaderV2` (outbound calls to Google Maps for tile fetches in the region/route processing paths) is not modified by cycle 8.
- The new `RejectUnknownQueryParamsEndpointFilter` works on the request's already-parsed query collection — no URL parsing, no outbound resolution.
## Cross-Reference with `security_approach.md`
The repo does not contain `_docs/00_problem/security_approach.md` (same as cycle 7). The OWASP review proceeds against the cycle-5 + cycle-6 + cycle-7 architectural decisions documented in `_docs/02_document/architecture.md` § 7 (Security Architecture) and § 9 (Strict wire-format validation at the API edge — added in cycle 7, extended in cycle 8). Cycle 8's input-validation completion cleanly extends those decisions; the F-AZ809-1 gap is the only deviation and is documented as such.
## Verdict (Phase 3)
**PASS_WITH_WARNINGS** (post-follow-up) — A04 (Insecure Design) is now **PASS** (F-AZ809-1 resolved in Step-14 follow-up); A06 + A09 remain `PASS_WITH_WARNINGS` from the carry-over Lows + the new F-AZ810-1 Low. Every other OWASP category is PASS or N/A.
Per the skill's verdict-logic, Medium severity yields PASS_WITH_WARNINGS — but cycle 8 resolved its only Medium in-cycle via the Step-14 follow-up commit before any production exposure. Post-follow-up posture is cleaner than the audit-time snapshot.
+125
View File
@@ -0,0 +1,125 @@
# Security Audit Report (Cycle 8)
**Date**: 2026-05-23
**Scope**: Cycle-8 delta over the cycle-7 audit (`_docs/05_security/security_report_cycle7.md`). Cycle-8 surface = AZ-808 (region POST validator) + AZ-809 (route POST validator + per-point + per-polygon) + AZ-810 (UAV upload metadata validator + custom `UavUploadValidationFilter`) + AZ-811 (lat/lon GET validator + `RejectUnknownQueryParamsEndpointFilter`) + AZ-812 (region-API `Latitude`/`Longitude``Lat`/`Lon` wire rename — OSM convention).
**Trigger**: `/autodev` Step 14 (Security Audit) — feature cycle 8, post-implementation, post-test-spec-sync, post-docs-update.
**Verdict (cycle-8 delta)**: **PASS_WITH_WARNINGS** — 0 Medium *open* (F-AZ809-1 **RESOLVED in cycle 8** via the Step-14 follow-up — `MaxPolygons = 50` cap added to `CreateRouteRequestValidator` + matching unit + integration tests) + 2 new Lows open (F-AZ810-1, F-AZ810-2) + 2 cycle-7 Low carry-overs (F-AZ795-1, F-AZ795-2) + 1 cycle-7 dependency Low carry-over (D-AZ795-1) + 1 cycle-4 dependency Medium carry-over (D2-cy4, test-runtime only). Zero Critical / High.
**Verdict (cumulative)**: **PASS_WITH_WARNINGS** — 1 cycle-4 Medium open (D2-cy4, test-runtime only) + 0 cycle-8 Medium open + multiple Lows.
## Summary
| Severity | Cycle 7 delta | Cycle 8 delta (audit-time) | Cycle 8 delta (post-follow-up) | Cumulative open |
|----------|---------------|----------------------------|--------------------------------|-----------------|
| Critical | 0 | 0 | 0 | 0 |
| High | 0 | 0 | 0 | 0 |
| Medium | 0 | 1 (F-AZ809-1) | **0 — F-AZ809-1 RESOLVED in cycle 8** | 1 (D2-cy4 cycle-4 carry — `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks`; test-runtime exposure only) |
| Low | 3 (F-AZ795-1, F-AZ795-2, D-AZ795-1) | 2 new (F-AZ810-1, F-AZ810-2) | 2 new (unchanged — both filed for cycle 9) | 5+ (3 cycle-7 carry + 2 cycle-8 new) |
## OWASP Top 10:2021 Assessment
| Category | Status (cycle-8 delta) | Findings |
|----------|------------------------|----------|
| A01 — Broken Access Control | PASS | — |
| A02 — Cryptographic Failures | N/A | No crypto in cycle 8 |
| A03 — Injection | PASS | — (cycle 8 strengthens — strict deserialization now backed by per-endpoint range checks at all four newly-validated endpoints) |
| A04 — Insecure Design | **PASS (post-follow-up)** — was PASS_WITH_WARNINGS at audit time | F-AZ809-1 **RESOLVED in cycle 8** via Step-14 follow-up: `MaxPolygons = 50` cap added to `CreateRouteRequestValidator` + new Inv-10 in `route-creation.md` v1.0.1 + matching unit + integration tests |
| A05 — Security Misconfiguration | PASS | (Informational note re: global Kestrel 500 MiB body limit; not a finding) |
| A06 — Vulnerable Components | PASS_WITH_WARNINGS | D-AZ795-1 (Low — cycle-7 carry; FluentValidation 12.0.0 → 12.1.1 hardening still available) |
| A07 — Auth Failures | PASS | — (JWT unchanged; every cycle-8 endpoint retains `RequireAuthorization()`) |
| A08 — Data Integrity Failures | N/A | No CI/CD or artifact-signing surface in cycle 8 |
| A09 — Logging Failures | PASS_WITH_WARNINGS | F-AZ810-1 (Low — new instance of F-AZ795-1 pattern in `UavUploadValidationFilter`) + F-AZ795-1 + F-AZ795-2 (Low cycle-7 carry-overs) + F-AZ810-2 (Low / Informational time-handling) |
| A10 — SSRF | N/A | No URL-input fields in cycle 8 |
## Findings
| # | Severity | Category | Location | Title |
|---|----------|----------|----------|-------|
| F-AZ809-1 | Medium (**RESOLVED in cycle 8**) | Insecure Design (A04) | `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82` (pre-fix) | Unbounded `geofences.polygons` collection enables an authenticated DoS on `POST /api/satellite/route` — fixed via `MaxPolygons = 50` cap |
| F-AZ810-1 | Low | Information Disclosure (A09) | `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84` | `JsonException.Message` propagated to client in new code path (parallel to cycle-7 F-AZ795-1) |
| F-AZ810-2 | Low | Time-handling correctness (A09 adjacent) | `SatelliteProvider.Common/DTO/UavTileMetadata.cs:30` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60` | `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset` — freshness window drifts by host TZ in non-UTC dev environments |
| F-AZ795-1 | Low | Information Disclosure (A09) | `SatelliteProvider.Api/GlobalExceptionHandler.cs:108-117` | (cycle-7 carry) `JsonException.Message` propagated to client in 400 response — still open |
| F-AZ795-2 | Low | Information Disclosure (A09) | `SatelliteProvider.Api/GlobalExceptionHandler.cs:88-93` | (cycle-7 carry) Generic `BadHttpRequestException.Message` propagated as `Detail` for non-JSON 400 paths — still open |
| D-AZ795-1 | Low | Vulnerable & Outdated Components (A06) | NuGet | (cycle-7 carry) `FluentValidation` + `FluentValidation.DependencyInjectionExtensions` 12.0.0 → 12.1.1 (hardening release; no published CVE) — still open |
| D2-cy4 | Medium | Vulnerable & Outdated Components (A06) | NuGet (test runtime) | (cycle-4 carry) `Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks` — test-runtime exposure only — still open |
### Finding Details
**F-AZ809-1: Unbounded `geofences.polygons` collection enables an authenticated DoS** (Medium / A04 — Insecure Design) — **RESOLVED in cycle 8 (Step-14 follow-up commit, see git log)**
- Location: `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-92` (post-fix; was lines 72-82 pre-fix).
- Description: The cycle-8 `CreateRouteRequestValidator` chained `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator())` but originally enforced only `NotEmpty` on the collection — no upper bound on `Geofences.Polygons.Count`. The sibling `Points` collection IS capped at 500; the global `KestrelServerOptions.Limits.MaxRequestBodySize` is set to 500 MiB to accommodate the UAV upload endpoint and applies to every route. With ~90 bytes per minimum-shape polygon JSON, an authenticated caller could have submitted ~5.8 million polygons in a single request; each invalid polygon yields ~3 `ValidationFailure` allocations, for ~17 million `ValidationFailure` objects in worst case — sufficient to saturate the LOH and trigger a full GC pass.
- Impact: Medium. Auth-gated (`RequireAuthorization()` at `Program.cs:267`) — only tenant operators with a valid JWT could reach the endpoint. Within the cycle-8 threat model this was contained; promotion risk eliminated by the cap.
- **Resolution** (Step-14 follow-up): `MaxPolygons = 50` constant added to `CreateRouteRequestValidator.cs`; chained as `.Must(polygons => polygons is null || polygons.Count <= MaxPolygons).WithMessage("…must contain at most 50 polygons.")` on the `geofences.polygons` rule. Cap chosen at 50 because geofences are AOI-restriction rectangles (per `route-creation.md` v1.0.1 Inv-10) — realistic use is 1-10 polygons per route, 50 gives 5x headroom while bounding the validator's worst-case allocation to ~150 `ValidationFailure` objects (well within normal request-handling overhead). Tests added: `CreateRouteRequestValidatorTests.Validate_GeofencePolygonsTooMany_FailsCountRule` (unit) + `CreateRouteValidationTests.GeofencePolygonsTooMany_Returns400` (integration, asserts 51-polygon array → HTTP 400 with `errors["geofences.polygons"]`). Contract bumped to `route-creation.md` v1.0.1 (patch — tightens an existing range; new Inv-10 + test case).
- Status: **resolved**. No cycle-9 follow-up required.
**F-AZ810-1: `JsonException.Message` propagated to client in `UavUploadValidationFilter`** (Low / A09 — Information Disclosure)
- Location: `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84` (the `catch (JsonException ex)` block of the metadata parse)
- Description: The cycle-8 `UavUploadValidationFilter` echoes the raw `JsonException.Message` directly to the client as the value of `errors["metadata"]` — the SAME information-disclosure pattern as cycle-7 F-AZ795-1 (in `GlobalExceptionHandler.cs`), introduced in a second code path that bypasses the global exception handler (the filter intercepts and returns `Results.ValidationProblem(...)` directly).
- Impact: Low. Auth-gated (`.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` at `Program.cs:238`) — only callers holding a valid JWT *with* the `GPS` permission claim can reach the filter. Leaks `System.*` type names + JSON parse positions — content already inferable from the OpenAPI spec.
- Remediation: Sanitise the response message to a generic string (e.g. `"`metadata` could not be parsed as JSON. See the server log for details."`) while continuing to log the raw `ex.Message` server-side under the request's `correlationId`. Lock-step with F-AZ795-1's remediation. Add an integration-test assertion in `UavUploadValidationTests` that no `System.*` substring appears in the response body's `errors[]` value.
- Status: filed for cycle 9 as a child of the same F-AZ795-1 ticket — both call sites get the sanitiser at once.
**F-AZ810-2: `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset`** (Low / Informational — Time-handling correctness)
- Location: `SatelliteProvider.Common/DTO/UavTileMetadata.cs:30` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60`
- Description: When `System.Text.Json` deserializes an ISO-8601 string without a `Z` / `+HH:MM` suffix into a `DateTime`, `Kind = Unspecified`. `DateTime.ToUniversalTime()` treats `Unspecified` values as **local time** — the freshness comparison drifts by the host's timezone offset. In a UTC-deployed prod container the local offset is zero; in a developer's local environment with `TZ=Europe/Kyiv` (UTC+02:00 or +03:00) a `capturedAt` value of `"2026-05-22T12:00:00"` would be treated as `2026-05-22T10:00:00Z` in summer, shifting the freshness window by the offset.
- Impact: Low / Informational. Zero observable impact in the supported deployment configuration (Docker containers run in UTC; the cycle-2 `docker-compose.yml` does not override `TZ`). No security exploit: an attacker cannot force the server's TZ. Reported as a defence-in-depth correctness item.
- Remediation (two options): (1) Change the DTO type to `DateTimeOffset` + custom JSON converter rejecting offset-less ISO-8601. (2) Add a FluentValidation rule rejecting `DateTime.Kind == DateTimeKind.Unspecified`. Option 2 is the minimum behaviour-preserving fix; Option 1 is correct for v2.0 of `uav-tile-upload.md`.
- Status: filed for cycle 9 as Low; not release-blocking — every documented client and every integration-test fixture sends the `Z` suffix.
**F-AZ795-1 / F-AZ795-2 / D-AZ795-1**: see `_docs/05_security/security_report_cycle7.md` § "Finding Details" — all three carry-overs are unchanged at cycle-8 tip. The cycle-8 F-AZ810-1 finding is a NEW instance of the F-AZ795-1 pattern in a different code path; both surfaces must be sanitised in lock-step.
**D2-cy4**: see `_docs/05_security/dependency_scan_cycle4.md` — unchanged at cycle-8 tip. Test-runtime exposure only.
## Dependency Vulnerabilities
| Package | CVE | Severity | Fix Version | Status |
|---------|-----|----------|-------------|--------|
| FluentValidation 12.0.0 | — (hardening only) | Low | 12.1.1 | D-AZ795-1 cycle-7 carry — still open |
| FluentValidation.DependencyInjectionExtensions 12.0.0 | — (hardening only) | Low | 12.1.1 | D-AZ795-1 cycle-7 carry — still open (same item) |
| Microsoft.NET.Test.Sdk 17.8.0 (transitive `NuGet.Frameworks`) | — (cycle-4 carry) | Medium | TBD (next Test SDK refresh cycle) | D2-cy4 cycle-4 carry — still open |
No new dependency vulnerabilities in cycle 8. Cycle 8 added zero new packages.
## Recommendations
### Immediate (Critical/High)
None.
### Short-term (Medium)
1. ~~**Add `MaxPolygons` cap to `CreateRouteRequestValidator`** (F-AZ809-1)~~**DONE in Step-14 follow-up (cycle 8)**. Cap set at 50; see Finding Details § F-AZ809-1 § Resolution.
### Long-term (Low / Hardening)
2. **Sanitise client-visible 400 messages** (F-AZ795-1 + F-AZ795-2 + F-AZ810-1) — single sanitiser applied to BOTH `GlobalExceptionHandler.WriteClientErrorAsync` AND `UavUploadValidationFilter.InvokeAsync`. Pre-existing-class instance in `UavTileUploadHandler.cs:80` must also be sanitised at the same time so the defence-in-depth path doesn't continue leaking. Add matching test assertions in `TileInventoryValidationTests` + `UavUploadValidationTests`. Estimated 2 hours. File as a small follow-up child of AZ-795 (epic).
3. **Bump FluentValidation 12.0.0 → 12.1.1** (D-AZ795-1) — single `.csproj` edit + regression test pass. No API surface change in 12.0.0 → 12.1.1 per the upstream changelog.
4. **Add `DateTimeKind.Unspecified` rejection rule to `UavTileMetadataValidator`** (F-AZ810-2 Option 2) — single `.Must(capturedAt => capturedAt.Kind != DateTimeKind.Unspecified)` rule. Doesn't break any documented client (all examples send `Z` suffix). One-line code change + matching unit test.
5. **Per-endpoint Kestrel body-size narrowing** (informational hardening) — apply `.WithMetadata(new RequestSizeLimitMetadata { MaxRequestBodySize = … })` to each non-upload endpoint so the 500 MiB global is constrained for JSON endpoints (e.g. 1 MiB for region/route, 10 MiB for inventory). Reduces F-AZ809-1's exploit surface even after the polygon cap is applied. Tracker child of AZ-795 or a standalone hardening task.
### Pre-existing-class follow-up (Low / Informational)
6. **Drop the `try/catch (ArgumentException)` block in the `CreateRoute` handler** (`Program.cs:387-399`) — pre-cycle-8 inconsistency that cycle 8 reduced the reachability of but did not eliminate. The validator now intercepts most `ArgumentException` cases; the catch leaks `ex.Message` in a non-conformant `{error: string}` shape. Fold into the same cycle-9 follow-up as #2 above.
### Cumulative reminders (carry-overs)
- **D2-cy4**`Microsoft.NET.Test.Sdk 17.8.0` transitive `NuGet.Frameworks` Medium, test-runtime only. Owned by the next Test SDK refresh.
## Cycle-8 Architectural Wins
The audit specifically wants to record four improvements introduced this cycle:
1. **Per-endpoint input-validation completion** — the AZ-795 strict-validation epic introduced in cycle 7 reached **100% coverage** in cycle 8. Every public-facing input endpoint (region POST, route POST, lat/lon GET, inventory POST, UAV upload) now runs a validator behind the same `error-shape.md` v1.0.0 contract. Future drift visibility is high; the architecture doc's coverage table is now complete.
2. **Three approved validation paths formalised** — the architecture doc (`_docs/02_document/architecture.md` § 9) now codifies three approved validation paths: `WithValidation<T>()` for JSON-body endpoints; `WithValidation<T>()` + `RejectUnknownQueryParamsEndpointFilter` for query-string endpoints; custom `IEndpointFilter` for non-standard wire formats. Future endpoints have a clear pattern to follow; an audit can verify any new endpoint via the chosen-path criterion.
3. **Wire-format normalisation under strict mode** (AZ-812) — `Latitude` / `Longitude``Lat` / `Lon` on the region endpoint aligns with OSM convention used everywhere else in the API. Under `UnmappedMemberHandling.Disallow` (cycle 7 global), legacy `Latitude` / `Longitude` payloads are now rejected at the deserializer with a structured 400 — no silent coercion, no compatibility-shim layer. This is a hard breaking change for any consumer still on the old shape, but it is intentional and documented as the post-cycle-8 contract.
4. **Defence-in-depth retained for the UAV upload handler**`UavTileUploadHandler.HandleAsync` retains every envelope check it had pre-cycle-8 (`metadata` presence, JSON parse, `Items.Count == 0`, `Items.Count != files.Count`, `Items.Count > MaxBatchSize`). The new `UavUploadValidationFilter` is *additive* — it intercepts the primary endpoint path but does not weaken the service-layer guard for direct callers (e.g. unit tests). The two layers' shape choices are mutually compatible.
## Verdict
**PASS_WITH_WARNINGS** (post-follow-up) — 0 Medium open (F-AZ809-1 resolved in Step-14 follow-up) + 2 cycle-8 Lows + 3 cycle-7 Low carry-overs + 1 cycle-4 Medium carry-over (test-runtime only). Zero Critical / High.
Per the skill's verdict-logic, Medium severity yields PASS_WITH_WARNINGS — but cycle 8 fixed its only Medium in-cycle, leaving only the cycle-4 test-runtime carry-over Medium open. Cycle 8 is **safe to release** within its documented threat model (authenticated callers only on every endpoint). The post-follow-up posture is *cleaner* than the cycle-7 baseline because cycle 8 added zero new open Mediums and resolved its in-cycle finding before commit.
Cumulative posture: PASS_WITH_WARNINGS (1 cycle-4 Medium open carry-over + 0 cycle-8 Medium open + multiple Lows). Cycle 8 completed an architecturally important hardening epic (100% input-validation coverage) without leaving Medium or higher debt.
+241
View File
@@ -0,0 +1,241 @@
# Static Analysis (Cycle 8)
**Date**: 2026-05-23
**Mode**: Delta scan
**Scope**: Source code introduced or changed by AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812. Cycle-7 baseline (`static_analysis_cycle7.md`) remains authoritative for the AZ-794 / AZ-795 / AZ-796 surface; this scan only audits the cycle-8 delta.
**Files in scope** (40 changed source files; non-test detail):
- **API — 11 files**
- `SatelliteProvider.Api/Program.cs` (DI + endpoint wiring deltas — `WithValidation<RequestRegionRequest>`, `WithValidation<CreateRouteRequest>`, `WithValidation<GetTileByLatLonQuery>`, `AddEndpointFilter<UavUploadValidationFilter>`, `AddTransient<UavUploadValidationFilter>`, `RejectUnknownQueryParamsEndpointFilter` registration)
- `SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs` (new — record with nullable bindings)
- `SatelliteProvider.Api/Validators/RegionRequestValidator.cs` (new — AZ-808)
- `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs` (new — AZ-809)
- `SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs` (new — AZ-809)
- `SatelliteProvider.Api/Validators/RoutePointValidator.cs` (new — AZ-809)
- `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs` (new — AZ-810)
- `SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs` (new — AZ-810)
- `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs` (new — AZ-810)
- `SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs` (new — AZ-811)
- `SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs` (new — AZ-811)
- `SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs` (AZ-811 — added `lat`/`lon`/`zoom` description entries)
- **Common — 6 DTO files** (AZ-808/809/810/812 — `[JsonRequired]` annotations + AZ-812 rename)
- `SatelliteProvider.Common/DTO/RequestRegionRequest.cs`
- `SatelliteProvider.Common/DTO/CreateRouteRequest.cs`
- `SatelliteProvider.Common/DTO/GeofencePolygon.cs`
- `SatelliteProvider.Common/DTO/GeoPoint.cs`
- `SatelliteProvider.Common/DTO/RoutePoint.cs`
- `SatelliteProvider.Common/DTO/UavTileMetadata.cs`
- **Test code** (reviewed for fixture-only secrets + auth-bypass patterns) — 8 new validator unit tests + 4 new integration test files + several modified integration test helpers.
- **Shell scripts** (reviewed for embedded secrets + unsafe sequences) — 4 new probe scripts (`probe_latlon_validation.sh`, `probe_region_validation.sh`, `probe_route_validation.sh`, `probe_upload_validation.sh`) + 1 modified perf script (`run-performance-tests.sh`, wire-rename diff only).
**Method**: Read each new file end-to-end; targeted `Grep` for injection / hardcoded-credential / unsafe-API patterns (`password|secret|api.?key|bearer|token` over `SatelliteProvider.Api/Validators` returned **0 matches**); diff-review of every DTO change vs. its cycle-7 baseline; trace each `[JsonRequired]` chain through to its FluentValidation rule.
## Findings
### F-AZ809-1 — Unbounded `geofences.polygons` collection enables an authenticated DoS via `CreateRouteRequest` (Medium / A04 — Insecure Design) — **RESOLVED in cycle 8 (Step-14 follow-up)**
- **Location**: `SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82` (`When(req => req.Geofences is not null, () => …)`).
- **Description**: The `CreateRouteRequestValidator` chains a `RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator())` block but only enforces `NotEmpty` on the collection. There is **no upper bound on `Geofences.Polygons.Count`**. The parent collection `Points` IS capped (`MaxPoints = 500` at line 27) — the polygons collection is the only nested list-bearing field on this endpoint without a cap.
- **Code**:
```csharp
When(req => req.Geofences is not null, () =>
{
RuleFor(req => req.Geofences!.Polygons)
.NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
.NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
.OverridePropertyName("geofences.polygons");
RuleForEach(req => req.Geofences!.Polygons)
.SetValidator(new GeofencePolygonValidator())
.OverridePropertyName("geofences.polygons");
});
```
- **Exploit math** (worst-case envelope under current configuration):
- `KestrelServerOptions.Limits.MaxRequestBodySize = uavBatchBodyLimit = MaxBatchSize × MaxBytes = 100 × 5 MiB = 500 MiB` (set globally in `Program.cs:41-43` for the UAV endpoint; the same limit applies to every endpoint by default because Kestrel exposes a per-server, not per-endpoint, default).
- Minimum JSON polygon footprint: `{"northWest":{"lat":1.0,"lon":2.0},"southEast":{"lat":3.0,"lon":4.0}}` ≈ 90 bytes including the comma separator.
- Theoretical maximum polygon count in a single 500 MiB request: `500 MiB / 90 bytes ≈ 5.8 million polygons`.
- With invalid polygons (e.g. `lat` out of range), `GeofencePolygonValidator` adds 2 corner-range `ValidationFailure` objects + 1 cross-field NW-of-SE failure = ~3 failures per polygon. Worst-case allocation: ~17 million `ValidationFailure` instances + the matching error-map keys before the filter formats the `ValidationProblemDetails` body.
- **Impact**: Medium. A single authenticated authorized request can saturate the LOH (large object heap) and trigger a full GC pass on the API process. The endpoint is `RequireAuthorization()`-gated (line 251 in `Program.cs`) so anonymous callers cannot reach the validator — the attacker must hold a valid JWT. But once authenticated, a single malformed request degrades the service for every other tenant operator until GC reclaims the heap. Repeatable. No data leak, no privilege escalation; pure availability impact.
- **OWASP mapping**: A04 — Insecure Design (missing rate/size limit on a collection-bearing input field). Adjacent to A05 (Security Misconfiguration — the global Kestrel limit was set for the UAV endpoint in cycle 5 but applies to every endpoint).
- **Remediation**: Add `Must(p => p is null || p.Count <= MaxPolygons)` with a defensible upper bound. Reasonable cap candidates:
- `MaxPolygons = 50` (consistent with the historical use case — a route is unlikely to need more than a handful of geofence rectangles for AOI restriction).
- `MaxPolygons = MaxPoints = 500` (consistent with the sibling `Points` cap on the same DTO).
- The matching `cumulative_review_batches_01-04_cycle8_report.md` already enumerates `points.Count <= 500` (route), `items.Count <= 100` (UAV upload), `coords.Count <= 1000` (tile inventory, cycle 7) as bounded — the missing entry for `geofences.polygons` is the one inconsistency.
- **Status**: **resolved in cycle 8 (Step-14 follow-up)**. Cap added: `MaxPolygons = 50` constant + `.Must(polygons => polygons is null || polygons.Count <= MaxPolygons).WithMessage("…must contain at most 50 polygons.")` on the `geofences.polygons` rule. Cap chosen at 50 because geofences are AOI-restriction rectangles — realistic use is 1-10 polygons per route, 50 gives 5x headroom while bounding the validator's worst-case allocation to ~150 `ValidationFailure` objects. Tests added: `CreateRouteRequestValidatorTests.Validate_GeofencePolygonsTooMany_FailsCountRule` (unit) + `CreateRouteValidationTests.GeofencePolygonsTooMany_Returns400` (integration). Contract bumped to `route-creation.md` v1.0.1 (patch — tightens an existing range; new Inv-10 + test case). See `security_report_cycle8.md` § F-AZ809-1 § Resolution for the full disposition.
### F-AZ810-1 — `JsonException.Message` propagated to client in `UavUploadValidationFilter` (Low / A09 — Information Disclosure)
- **Location**: `SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84` (the `catch (JsonException ex)` block of the metadata parse).
- **Code**:
```csharp
catch (JsonException ex)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" },
});
}
```
- **Description**: The cycle-8 `UavUploadValidationFilter` echoes the raw `JsonException.Message` directly to the client as the value of `errors["metadata"]`. This is the **same information-disclosure pattern as cycle-7 F-AZ795-1** (in `GlobalExceptionHandler.cs:108-117`), introduced in a *second* code path that bypasses the global exception handler (the filter intercepts and returns `Results.ValidationProblem(...)` directly). Cycle-7 F-AZ795-1 remains open; cycle 8 adds a second instance of the same pattern that would also need to be sanitised by the same remediation.
- **Impact**: Low. Same severity classification as F-AZ795-1. Auth-gated (`.RequireAuthorization(SatellitePermissions.UavUploadPolicy)` at `Program.cs:238`) — only callers holding a valid JWT *with* the `GPS` permission claim can reach the filter and trigger this path. The leaked content (type names, parse positions, `System.Text.Json` fingerprint) is already inferable from the OpenAPI spec; the new path narrows the attack surface for an authenticated GPS-permissioned operator but does not expose secrets, PII, or pivot vectors.
- **Remediation**: Sanitise the response message to a generic string (e.g. `"`metadata` could not be parsed as JSON. See the server log for details."`) while continuing to log the raw `ex.Message` server-side under the request's `correlationId`. Best done in tandem with F-AZ795-1's remediation since both paths surface the same exception class through the same response shape. Add an integration-test assertion in `UavUploadValidationTests` that no `System.*` substring appears in the response body's `errors[]` value, mirroring the cycle-7 gap noted in `static_analysis_cycle7.md` § F-AZ795-1 *Test coverage gap*.
- **Status**: open — file as a cycle-9 follow-up child of the same F-AZ795-1 ticket so both call sites get the sanitiser at once.
### F-AZ810-2 — `UavTileMetadata.CapturedAt` typed `DateTime` not `DateTimeOffset` (Low / Informational — Time-handling correctness)
- **Location**: `SatelliteProvider.Common/DTO/UavTileMetadata.cs:30` + `SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60`.
- **Code**:
```csharp
// UavTileMetadata.cs:30
[JsonRequired]
public DateTime CapturedAt { get; init; }
// UavTileMetadataValidator.cs:52-60
RuleFor(m => m.CapturedAt)
.Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
.WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
.Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
.WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
```
- **Description**: When `System.Text.Json` deserializes an ISO-8601 string into a `DateTime`, the resulting `DateTime.Kind` depends on the string's offset suffix:
- `"2026-05-22T12:00:00Z"``Kind = Utc`.
- `"2026-05-22T12:00:00+03:00"``Kind = Local` after normalization to local time.
- `"2026-05-22T12:00:00"` (no suffix) → `Kind = Unspecified`.
For `Kind = Unspecified`, `DateTime.ToUniversalTime()` treats the value as **local time**, which means the freshness comparison drifts by the host's timezone offset. In a UTC-deployed prod container (Linux `TZ=UTC`), the local offset is zero and there is no observable impact. In a developer's local environment (e.g. `TZ=Europe/Kyiv` = UTC+02:00 or +03:00), a `capturedAt` value of `"2026-05-22T12:00:00"` would be treated as `2026-05-22T10:00:00Z` (in summer), shifting the freshness window by the offset.
- **Impact**: Low / Informational. Zero observable impact in the supported deployment configuration (Docker containers run in UTC by default; the cycle-2 `docker-compose.yml` does not override `TZ`). The freshness rule could be loosely-bounded by the same offset (in either direction) in a dev environment with a non-UTC host TZ. No security exploit: an attacker cannot force the server's TZ; they can only submit `capturedAt` values, and the server's UTC-deployed configuration treats those deterministically.
- **OWASP mapping**: A09 — Security Logging and Monitoring Failures (adjacent — a stale freshness window could mask an out-of-band attack on the upload endpoint).
- **Remediation** (two options):
1. **Strict**: Change the DTO type to `DateTimeOffset` so the parsed value always carries an explicit offset, and add a JSON converter that rejects offset-less ISO-8601 strings at deserialization (`JsonConverterAttribute` pointing at a custom converter that checks for the `Z` / `+HH:MM` suffix and throws `JsonException` if missing — surfaces as a 400 via `GlobalExceptionHandler`).
2. **Lenient**: Add a FluentValidation rule that rejects `DateTime.Kind == DateTimeKind.Unspecified` so the caller must supply a tz-aware ISO-8601 string. Keeps the DTO shape; doesn't break clients that already send `"Z"` suffix.
Option 2 is the minimum behaviour-preserving fix. Option 1 is correct for a v2.0 of `uav-tile-upload.md`.
- **Status**: open — file as a Low cycle-9 follow-up. Not release-blocking for cycle 8 because every documented client in `uav-tile-upload.md` example payloads and every integration-test fixture sends the `Z` suffix.
## Pattern Sweep — Cycle-8 Delta
### Injection (SQL / Command / XSS / Template)
| Pattern | Result |
|---------|--------|
| `string.Format`, interpolation `$"..."`, or concatenation feeding into a Dapper / Npgsql command in the new files | None. The 9 new files in `SatelliteProvider.Api/Validators/` and `SatelliteProvider.Api/DTOs/` do not touch the data layer. |
| `Process.Start`, `subprocess`, `eval`, `Invoke-Expression`, raw `system()` | None. |
| User-input echoed into HTML (XSS) | None. The API returns JSON only. The cycle-8 `RejectUnknownQueryParamsEndpointFilter` echoes the offending parameter name back as a JSON string value — `System.Text.Json` performs canonical JSON escaping of control characters; no HTML injection vector. |
| Template injection (Razor / Liquid / etc.) | None. No templating in the new files. |
### Authentication & Authorization
| Pattern | Result |
|---------|--------|
| Hardcoded credentials, secrets, API keys | `Grep -i 'password|secret|api.?key|bearer|token'` against `SatelliteProvider.Api/Validators/` returned **0 matches**. The new integration test files (`CreateRouteValidationTests`, `GetTileByLatLonValidationTests`, `RegionRequestValidationTests`, `UavUploadValidationTests`, `RegionFieldRenameTests`) reuse the runner-side `JwtTestHelpers.MintAuthenticated(...)` helper for token attachment; no hardcoded secret material. |
| Missing `.RequireAuthorization()` on a public endpoint | Every cycle-8 endpoint binding in `Program.cs` retains `.RequireAuthorization()`: `RequestRegion` (line 251), `CreateRoute` (267), `GetTileByLatLon` (213), `UploadUavTileBatch` (238 — additionally requires the `GPS` permission claim via `SatellitePermissions.UavUploadPolicy`). |
| Validator running before auth check | No. ASP.NET Core endpoint filters run AFTER the routing layer's authorization middleware (`app.UseAuthorization()` at `Program.cs:206`). All four cycle-8 filters (`ValidationEndpointFilter<RequestRegionRequest>`, `ValidationEndpointFilter<CreateRouteRequest>`, `RejectUnknownQueryParamsEndpointFilter` + `ValidationEndpointFilter<GetTileByLatLonQuery>`, `UavUploadValidationFilter`) cannot run for anonymous callers — the 401 short-circuit fires first. |
| Permission/policy regression | `RequiresGpsPermission` on `/api/satellite/upload` retained. `RejectUnknownQueryParamsEndpointFilter` does NOT call `RequireAuthorization()` itself — it relies on the endpoint chain's existing `.RequireAuthorization()` (line 213). Correct. |
### Cryptographic Failures
| Pattern | Result |
|---------|--------|
| Weak hash (MD5 / SHA1) used for passwords or signatures | None in cycle 8. Pre-existing UUIDv5 SHA-1 surface (`Uuidv5.Create`) is untouched. |
| New crypto material introduced | None. Cycle 8 has no cryptography. |
| Plaintext transmission | API listens on `https://+:8080` with ALPN (cycle-6 baseline, unchanged). |
### Data Exposure
| Pattern | Result |
|---------|--------|
| Sensitive data in logs | The new validators and filters do NOT log the request body. `GlobalExceptionHandler` 5xx branch logs `Method`, `Path`, `correlationId`, and the exception object (pre-existing). Cycle 8 doesn't widen this. |
| Sensitive fields in API responses | F-AZ810-1 (`JsonException.Message` echo) above is the only new echo-back path. No password hashes, no PII (the four endpoints carry only metadata: geospatial coords, integer IDs, timestamps). The `RejectUnknownQueryParamsEndpointFilter` echoes the offending parameter NAME (not value) back — and the list of allowed param names IS already in the OpenAPI spec, so no new fingerprinting surface. |
| Debug endpoints in production | Swagger is gated by `app.Environment.IsDevelopment()` (unchanged). |
| Secrets in version control | `.env*` files are gitignored. The 4 new probe scripts (`probe_*_validation.sh`) all use `${JWT:?…}`-style env-var reads with explicit "set JWT env var" error guards; no embedded credentials. |
### Insecure Deserialization
| Pattern | Result |
|---------|--------|
| `Pickle` / `BinaryFormatter` / unsafe XML / `JsonConvert.DeserializeObject<T>` with `TypeNameHandling.All` | None in cycle 8. `UavUploadValidationFilter.cs:73` uses `JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(...)` with the global `JsonSerializerOptions` from `IOptions<JsonOptions>` — which has `UnmappedMemberHandling.Disallow` set by cycle-7 `ConfigureHttpJsonOptions`. ✓ |
| Unbounded collection sizes | **F-AZ809-1 above**`geofences.polygons` lacks a cap. Other cycle-8 list fields ARE capped: `Points.Count <= 500` (`CreateRouteRequestValidator:60-61`), `Items.Count <= MaxBatchSize=100` (`UavTileBatchMetadataPayloadValidator:27-28`). Cycle-7 `Tiles.Count <= 5000` / `LocationHashes.Count <= 5000` unchanged. The framework-level `MaxRequestBodySize` bound is in force for all paths — but it's set at 500 MiB to accommodate the UAV upload endpoint, which makes per-endpoint count caps the primary defence. |
### Integer Overflow / Bounded Math
The cycle-8 validators do not perform any integer arithmetic beyond `Math.Max(options.ValueLengthLimit, uavQuality.MaxBatchSize * 512)` (`Program.cs:61``100 * 512 = 51_200`, no overflow possible). No left-shifts, no power-of-two computations on caller-controlled values. ✓
### ReDoS / Algorithmic Complexity
Cycle-8 validation rules are all O(1) per entry × N entries with bounded N (with the F-AZ809-1 exception above):
| Validator | Per-entry cost | Total cost (worst case) |
|-----------|----------------|-------------------------|
| `RegionRequestValidator` | O(1) — 5 range checks on scalar fields | O(1) |
| `CreateRouteRequestValidator` (root + cross-field invariant) | O(1) base + O(N points) + O(P polygons unbounded) | **O(N × P)** with unbounded P — see F-AZ809-1 |
| `RoutePointValidator` (via `RuleForEach`) | O(1) — 2 range checks | O(500) bounded by parent `MaxPoints` |
| `GeofencePolygonValidator` (via `RuleForEach`) | O(1) — 2 corner null + 4 range + 2 cross-field | O(P) where P is unbounded — see F-AZ809-1 |
| `UavTileMetadataValidator` (via `RuleForEach`) | O(1) — 4 range checks + 2 freshness | O(100) bounded by `MaxBatchSize` |
| `UavTileBatchMetadataPayloadValidator` (root) | O(1) | O(1) |
| `GetTileByLatLonQueryValidator` | O(1) — 3 cascaded NotNull + range | O(1) |
| `RejectUnknownQueryParamsEndpointFilter` | O(K log K) where K = caller's query-param count | O(K) bounded by Kestrel's per-request header/query limits (default 32 KB of query string) |
No regex, no recursion, no nested loops with caller-controlled bounds (except F-AZ809-1).
## `UavUploadValidationFilter` Defence-in-Depth Verification
The filter intercepts the primary multipart-upload code path. The pre-existing `UavTileUploadHandler.HandleAsync` retains the SAME envelope checks (`SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs:64-141`) — confirming the filter is **defence-in-depth on top of** the handler's checks, not a replacement:
| Check | `UavUploadValidationFilter` (filter layer) | `UavTileUploadHandler` (handler layer — defence-in-depth) |
|-------|--------------------------------------------|-----------------------------------------------------------|
| `metadata` form field present + non-empty | Line 62-68 | Line 68-71 (`string.IsNullOrWhiteSpace`) |
| `metadata` parses as JSON | Line 71-84 (catches `JsonException`) | Line 74-81 (catches `JsonException`) — **same pattern, same `ex.Message` echo (pre-existing parallel to F-AZ810-1)** |
| `metadata.items` non-null + non-empty | Line 86-92 (`payload is null`) + `UavTileBatchMetadataPayloadValidator:25-26` (`NotNull`/`NotEmpty`) | Line 83-86 (`Items.Count == 0`) |
| `metadata.items.Count == files.Count` | Line 105-118 | Line 88-91 |
| `metadata.items.Count <= MaxBatchSize` | `UavTileBatchMetadataPayloadValidator:27-28` | Line 93-96 |
The handler's envelope checks are still reachable by any caller invoking `IUavTileUploadHandler.HandleAsync(...)` directly (e.g. unit tests, or a future programmatic flow). Cycle 8 left them intact — correct ✓. The duplicated `JsonException.Message` echo in the handler (line 80) is a pre-existing-class instance of the same Low finding as F-AZ810-1; the remediation MUST sanitise both call sites in lock-step or the defence-in-depth path will continue to leak.
## Pre-existing-Surface Inconsistency Noted (NOT a cycle-8 regression)
`Program.cs:387-399` — the `CreateRoute` handler still wraps its body in `try { ... } catch (ArgumentException ex) { return Results.BadRequest(new { error = ex.Message }); }`. This is a pre-cycle-8 inconsistency: the response shape is the unstructured `{error: string}` object, NOT the `ValidationProblemDetails` shape mandated by `error-shape.md` v1.0.0. Cycle 8 added `WithValidation<CreateRouteRequest>` at line 268 which intercepts most `ArgumentException` cases — so the `catch` block is now largely dead code, but it WILL fire if `IRouteService.CreateRouteAsync` itself throws `ArgumentException` (e.g. a future internal validation rule). When it fires, the response leaks `ex.Message` in a non-conformant shape.
- **Severity**: Low / Informational. Pre-existing inconsistency; cycle 8 reduced its reachable surface but did not eliminate it.
- **Remediation**: Drop the `try/catch` block now that the validator covers the same cases at the filter layer; let unexpected `ArgumentException` propagate to `GlobalExceptionHandler` for uniform 400 + sanitised `ValidationProblemDetails` (or 500 with a `correlationId` if the cause is internal). One-line edit; recommend folding into the same cycle-9 follow-up as F-AZ795-1 / F-AZ810-1 since all three converge on the same error-shape consistency improvement.
## Test Code Review
### `SatelliteProvider.Tests/Validators/*ValidatorTests.cs` (8 new files)
- Pure CPU; no I/O, no network, no file system, no DB.
- All inputs constructed inline. No fixture-file reads, no hardcoded JWTs.
- All test files share the cycle-7 `ValidatorTestModuleInitializer.cs` (`[ModuleInitializer]``GlobalValidatorConfig.ApplyOnce()` at test-assembly load) — single source of truth for the camelCase property resolver. No test drift risk.
- ✓ No findings.
### `SatelliteProvider.IntegrationTests/{CreateRouteValidationTests,GetTileByLatLonValidationTests,RegionFieldRenameTests,RegionRequestValidationTests,UavUploadValidationTests}.cs` + modified helpers
- All five new files use the runner-side `JwtTestHelpers.MintAuthenticated(...)` to attach a Bearer token, mirroring cycle 7's pattern. No hardcoded secret material; the token's signing secret comes from the `JWT_SECRET` env var (32+ bytes, dev-only in `docker-compose.tests.yml`).
- Test inputs use raw `HttpRequestMessage` with hand-built JSON strings — exercises the exact wire shape the validator + deserializer see in production. Each file covers the cycle-8 negative cases for its endpoint.
- `ProblemDetailsAssertions.cs` was extended (additive) — no removed assertions. The cycle-7 `error-shape.md` Inv-2 / Inv-4 contract assertions all still apply.
- `UavUploadTests.cs` + `UavUploadValidationTests.cs` both clamp their fixture-generated coordinates into non-overlapping OSM-valid sub-ranges (cycle-8 hotfix commit `b763da3` per `_docs/03_implementation/batch_04_cycle8_report.md` § AC-9) — a test-data correctness fix, not a security finding.
- ✓ No findings.
### `scripts/probe_{latlon,region,route,upload}_validation.sh` (4 new files)
- All four scripts begin with `set -euo pipefail` — fail-fast on undefined vars, broken pipes, command failures.
- All four read `${API_URL:-https://localhost:8080}` (default to localhost) and `${JWT:-}` with an explicit "ERROR: set JWT env var" guard that `exit 2`s if `$JWT` is empty. No embedded credentials.
- `curl -k` is used (justified — the dev cert is self-signed; the scripts target localhost in dev/test only — documented in each script's header).
- ✓ No findings — same posture as cycle-7's `probe_inventory_validation.sh`.
### `scripts/run-performance-tests.sh` (modified, wire-rename only)
- Diff is exclusively the AZ-812 wire-format rename: `?Latitude=…&Longitude=…&ZoomLevel=…``?lat=…&lon=…&zoom=…` and `{"latitude":…,"longitude":…}``{"lat":…,"lon":…}` across PT-01 through PT-08 invocations. No new code, no new credentials, no shell-injection surface introduced.
- ✓ No findings.
## Cycle-7 Carry-overs (still open at cycle-8 tip)
| Finding | Source cycle | Carry-over status | Cycle-8 interaction |
|---------|--------------|-------------------|---------------------|
| **F-AZ795-1**`JsonException.Message` propagated in `GlobalExceptionHandler.cs:108-117` | cycle 7 | open | Unchanged. Cycle 8 introduced **F-AZ810-1** which is the SAME pattern in a NEW code path; both need lock-step sanitiser remediation. |
| **F-AZ795-2** — Generic `BadHttpRequestException.Message` propagated in `GlobalExceptionHandler.cs:88-93` | cycle 7 | open | Unchanged. Cycle 8 routes more endpoint failures through `WithValidation<T>()` which builds `ValidationProblemDetails` directly — reducing the practical reachability of this path on the 4 newly-validated endpoints, but not eliminating it (model-binding failures pre-validator can still hit the path). |
## Verdict (Phase 2)
**PASS_WITH_WARNINGS** (post-follow-up) — 0 Medium open (F-AZ809-1 resolved in cycle 8) + 2 new Lows (F-AZ810-1, F-AZ810-2) + 2 cycle-7 Low carry-overs (F-AZ795-1, F-AZ795-2). No Critical or High findings.
Per the skill's verdict-logic, Medium severity yields PASS_WITH_WARNINGS — but cycle 8 resolved its only Medium in-cycle via the Step-14 follow-up commit before any production exposure. The 2 new Lows + 2 carry-over Lows are not release-blockers in isolation; they are filed for the next cycle's follow-up batch.
@@ -0,0 +1,75 @@
# Perf Run — Cycle 8 (AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812)
**Date**: 2026-05-23T12:50Z (first run) + 2026-05-23T12:53Z (re-run after PT-06 script fix)
**Run label**: cycle8 — full default-parameter run after the cycle-8 strict-validation sweep (AZ-808 region POST + AZ-809 route POST + AZ-810 UAV upload metadata + AZ-811 GET tile lat/lon + AZ-812 region OSM rename) and the in-cycle F-AZ809-1 polygon-cap follow-up (commit `8fca6e0`).
**Trigger**: autodev existing-code Step 15 (Performance Test gate). Cycle 8 goal: confirm that adding FluentValidation + `JsonUnmappedMemberHandling.Disallow` to four endpoints — and the new 50-polygon cap on `POST /api/satellite/route` — introduced no measurable regression on existing perf scenarios.
**Runner**: `scripts/run-performance-tests.sh` (default params: `PERF_REPEAT_COUNT=20`, `PERF_UAV_BATCH_SIZE=10`). Two runs were needed; see "PT-06 script fix" below.
**System under test**: `docker compose up -d --build` against `mcr.microsoft.com/dotnet/aspnet:10.0`; api healthy on `https://localhost:18980` (TLS+ALPN, dev cert `./certs/api.crt` trusted via `--cacert`). Postgres on `localhost:5433`. Single docker-compose stack lifecycle across both runs (no restart between them).
**Build**: `SatelliteProvider.IntegrationTests` Release built inside the SDK; 0 errors / 15 warnings (carried-over NU1902 IdentityModel — already tracked as D-AZ795-1 / Low / Hardening — plus CA2227 setter-on-collection — already noted, design choice for DTO mutability).
**JWT**: minted by `SatelliteProvider.IntegrationTests --mint-only` (canonical `JwtTokenFactory` surface per AZ-491); 4 h lifetime, 341 bytes.
## Results
| # | Scenario | Verdict | Observed | Threshold | Source of measurement | Source of threshold |
|---|----------|---------|----------|-----------|-----------------------|---------------------|
| PT-01 | Tile download (cold) | **PASS** | 885 ms | ≤ 30000 ms | run 1 | `_docs/02_document/tests/performance-tests.md` |
| PT-02 | Cached tile retrieval | **PASS** | 244 ms | ≤ 500 ms | run 1 | `_docs/02_document/tests/performance-tests.md` |
| PT-03 | Region 200 m / z18 | **PASS** | 99 ms | ≤ 60000 ms | run 1 | `_docs/02_document/tests/performance-tests.md` |
| PT-04 | Region 500 m / z18 + stitch | **PASS** | 2128 ms | ≤ 120000 ms | run 1 | `_docs/02_document/tests/performance-tests.md` |
| PT-05 | 5 concurrent regions | **PASS** | 2663 ms | ≤ 300000 ms | run 1 | `_docs/02_document/tests/performance-tests.md` |
| PT-06 | Route creation (2 points) | **PASS** (after fix) | 83 ms | ≤ 5000 ms | run 2 (post-script-fix; see below) | `_docs/02_document/tests/performance-tests.md` |
| PT-07 | Region request distribution (N=20, cold + warm) | **PASS** | cold p50=2113 ms, p95=2274 ms · warm p50=52 ms, p95=108 ms | warm p95 < cold p95 (delta 2166 ms) | run 1 — only run with a true cold cache for the PT-07 coord band; see "Known harness quirks" below | AZ-484 / AZ-492 |
| PT-08 | UAV batch upload (batch=10, N=20) | **PASS** | batch p50=106 ms, p95=379 ms; per-item proxy p95=37 ms; accepted=200, rejected=0, failed=0 | batch p95 ≤ 2000 ms (AZ-488) | run 1 | `_docs/02_document/tests/performance-tests.md` |
**Raw verdict: 8 Pass · 0 Warn · 0 Fail · 0 Unverified** (after the PT-06 script-fix re-run).
## PT-06 script fix (AZ-809 contract-sync follow-up)
Run 1 reported `PT-06: HTTP 400 (expected 200)`. Root cause: the perf script's `POST /api/satellite/route` body omitted `requestMaps` and `createTilesZip`. AZ-809 (cycle 8) made both fields required — no defaulting — under FluentValidation's `RuleFor(...).NotNull()` chain (see `BT-29` rule 10 in `_docs/02_document/tests/blackbox-tests.md` and `route-creation.md` v1.0.1 Inv-10 / Rule 10). The perf script had been updated for AZ-812's lat/lon wire rename during cycle 8 but missed AZ-809's newly required fields — a contract-sync miss, not a production regression.
Fix: one line in `scripts/run-performance-tests.sh` PT-06 body — added `"requestMaps":false,"createTilesZip":false`. Re-run confirmed `PT-06: 83 ms` (well under the 5000 ms threshold, comparable to cycle-7's 161 ms). Both fields are `false` so the rule-12 cross-field (`createTilesZip=true requires requestMaps=true`) is not exercised — the scenario's intent is route-creation latency, not the cross-field gate.
## AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812 NFR verification
Cycle 8 added per-endpoint strict validation (FluentValidation + `JsonUnmappedMemberHandling.Disallow`) to four endpoints. The directly relevant scenarios are PT-03 / PT-04 / PT-05 (region POST — exercises AZ-808 validator on every accept), PT-06 (route POST — exercises AZ-809 validator), PT-07 (region POST distribution — exercises AZ-808 validator 40 times), and PT-08 (UAV upload — exercises AZ-810 validator 200 times via the 20×10 batch). PT-01 / PT-02 exercise AZ-811 (GET tile lat/lon) on every call.
**Validator cost**: invisible at the percentile resolutions reported. Each cycle-8 validator iterates O(N) bounds checks on a small input (typical N ≤ 20 for routes / regions / UAV batches), each check is constant-time, and the FluentValidation rule cache amortises the rule-tree across requests. The measured p95 deltas vs cycle 7 are all within noise band (see "Trend vs cycle 7" below).
**F-AZ809-1 polygon-cap fix (commit `8fca6e0`)**: the new `MaxPolygons = 50` check is a single `Count <= 50` comparison on the chained `RuleFor(...).Must(...)`; it runs in O(1) and is invisible at the PT-06 resolution (83 ms). No new perf scenario was added for the cap because the cap's intent is allocation-bounding under adversarial load, not steady-state latency — that load profile would belong in a separate adversarial/abuse scenario, deferred (see "Follow-ups").
**Auth-before-validation ordering**: confirmed in `Program.cs` (`UseAuthentication()` / `UseAuthorization()` run before any `WithValidation()` endpoint filter and before `UavUploadValidationFilter`). PT-06 / PT-07 / PT-08 calls carry the Bearer token; an unauthenticated probe would 401 before any validator runs, so the validator cost is bounded by authenticated traffic only.
## Trend comparison vs cycle 7
| Scenario | Cycle 7 | Cycle 8 | Δ | Cause |
|----------|---------|---------|---|-------|
| PT-01 cold | 998 ms | 885 ms | -113 ms | noise band (Google Maps DNS / cold-network variance) |
| PT-02 cached | 269 ms | 244 ms | -25 ms | noise |
| PT-03 region 200 m | 139 ms | 99 ms | -40 ms | noise (both runs warm-cache hits on the PT-02 / PT-03 shared coord) |
| PT-04 region 500 m + stitch | 2110 ms | 2128 ms | +18 ms | noise |
| PT-05 5 concurrent | 3145 ms | 2663 ms | -482 ms | noise band (queue scheduler variance under 5-way concurrency) |
| PT-06 route create | 161 ms | 83 ms | -78 ms | noise band (TLS connection state); post-fix value |
| PT-07 cold p95 / warm p95 | 2608 ms / 76 ms | 2274 ms / 108 ms | cold -334 ms / warm +32 ms | warm uptick is noise (≈40 % of the cycle-7 warm-p95 absolute number — both within sub-200 ms noise band on dev hardware); cold improved as the network state stabilised. Delta cold→warm remains > 2000 ms — same order of magnitude as cycle 7 (2532 ms). |
| PT-08 batch p95 | 284 ms | 379 ms | +95 ms | noise band (single-client p95 over 20 batches on shared dev hardware; the cycle 7 number was on the low end of the historical distribution — cycle 5 measured 350 ms, cycle 6 measured 544 ms, cycle 8 sits in the middle of that band). |
**Zero scenarios show a meaningful regression attributable to cycle 8.** All deltas are within the historical noise band for dev hardware (PT-08 has the widest historical band: 284544 ms across cycles 5/6/7/8).
## Known harness quirks (pre-existing — not cycle-8 regressions)
These surfaced during this run but are NOT caused by cycle 8. Each is documented here for trend-tracking visibility; remediation is a perf-harness-cleanup track, not a cycle-8 deliverable.
- **PT-07 cross-run cache pollution**: `PT07_BASE_LAT` / `PT07_BASE_LON` are hard-coded constants (47.471747, 37.657063) with deterministic per-iteration offsets. Back-to-back perf runs against the same docker-compose stack lifecycle reuse the same coord band, so the second run's "cold pass" is actually a warm-cache hit (p95 dropped from 2274 ms in run 1 to 70 ms in run 2; warm p95 also 70 ms; the script's strict `<` check then fails because they're equal). Cycle-7's report (§ "Trend comparison") implicitly noted the same family of issue for PT-03. Fix candidate: parameterise the base coord by a per-run nonce or `PERF_RUN_LABEL` env so each cycle's PT-07 starts cold.
- **PT-01 "cold" misnomer**: `PT01_LAT` / `PT01_LON` (47.461347, 37.646663) are hard-coded; the tile has been cached on the host filesystem since cycle 5 (`./tiles/18/...`). The reported number is "first-request latency on a stack-lifecycle-fresh API process," not a true Google-Maps round-trip. The 885 ms is comfortably under the 30 s threshold because the threshold was set for the genuine cold case; the measurement nonetheless under-reports cold-path latency. Fix candidate same as PT-07: parameterise by nonce.
- **PT-03 cached-by-PT-02 side effect** (cycle 7's note): PT-03 reuses the (47.461747, 37.647063) coord PT-02 already populated; this is by design (PT-03's threshold is 60 s end-to-end, so even a fully cold region would pass, and the test's intent is region-orchestration overhead, not tile-fetch latency).
## Follow-ups (not blocking deploy)
1. **Perf-harness cleanup** — parameterise `PT07_BASE_*` and `PT01_*` coords by a run-nonce env (e.g., `PERF_RUN_LABEL`) so back-to-back runs and trend comparisons are not corrupted by cross-run cache state. Tracker entry candidate (sized ~2 SP).
2. **F-AZ809-1 cap adversarial scenario** — add an explicit PT-NN that posts 50 polygons (the cap) and 51 polygons (one over), measures validator latency and 400-response time. This converts the cap's intent (DoS-bound) into a measurable regression-gate. Deferred to cycle 9 (sized ~3 SP — adds one perf scenario + threshold + report row).
3. **PT-09 promotion**`tile-inventory.md` already notes the option to promote `TileInventoryTests.PerformanceBudget_AC4` from full-suite to `scripts/run-performance-tests.sh § PT-09` if the budget tightens. Not needed at the cycle-8 budget level; track for cycle 10+.
## Verdict (Step 15)
**PASS** — 8/8 scenarios within threshold (after the trivial AZ-809 contract-sync fix to PT-06's body). Zero meaningful regressions attributable to cycle 8. Cycle-8 strict-validation overhead is below percentile resolution on every measured endpoint.
Cleared to auto-chain to Step 16 (Deploy).
+7 -7
View File
@@ -2,14 +2,14 @@
## Current Step
flow: existing-code
step: 11
name: Run Tests
status: in_progress
step: 15
name: Performance Test
status: completed
sub_step:
phase: 2
name: run-tests
detail: "re-run after coord-clamp fix"
retry_count: 1
phase: 5
name: report-rendered
detail: "verdict: PASS (8/8 scenarios). Required one in-cycle AZ-809 contract-sync fix to PT-06 body. Report: _docs/06_metrics/perf_2026-05-23_cycle8.md."
retry_count: 0
cycle: 8
tracker: jira
auto_push: true
+1 -1
View File
@@ -273,7 +273,7 @@ fi
echo ""
echo "PT-06: Route Point Interpolation Speed (threshold: 5000ms)"
ROUTE_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
BODY="{\"id\":\"$ROUTE_ID\",\"name\":\"Perf Test\",\"regionSizeMeters\":300,\"zoomLevel\":18,\"points\":[{\"lat\":48.276067,\"lon\":37.384458},{\"lat\":48.270740,\"lon\":37.374029}]}"
BODY="{\"id\":\"$ROUTE_ID\",\"name\":\"Perf Test\",\"regionSizeMeters\":300,\"zoomLevel\":18,\"points\":[{\"lat\":48.276067,\"lon\":37.384458},{\"lat\":48.270740,\"lon\":37.374029}],\"requestMaps\":false,\"createTilesZip\":false}"
START=$(date +%s%N)
HTTP_CODE=$(curl "${CURL_OPTS[@]}" -s -o /dev/null -w "%{http_code}" -X POST -H "Content-Type: application/json" -H "$AUTH_HEADER" -d "$BODY" "$API_URL/api/satellite/route")