Step 9 (New Task) closure for cycle 8. Queues 5 task specs under the AZ-795 strict-validation umbrella + OSM-naming harmonization: - AZ-808 region-request validator (POST /api/satellite/request) 3 pts - AZ-809 route-creation validator (POST /api/satellite/route) 5 pts - AZ-810 UAV upload metadata validator (POST /api/satellite/upload) 5 pts - AZ-811 lat/lon GET validator (GET /api/satellite/tiles/latlon) 2 pts - AZ-812 Region DTO rename latitude/longitude -> lat/lon 3 pts Total 18 SP. Origin: cross-repo request from gps-denied-onboard agent (2026-05-22) after AZ-777 Phase 2 black-box probe of the Region API surfaced silent-coercion behavior + the lone OSM-deviating coord naming convention left in the producer's public surface. Ordering recorded (per /autodev Step 10 dirty-tree decision): AZ-812 ships first so AZ-808 validator + contract doc + integration tests are written against the final lat/lon names. AZ-809/AZ-810/AZ-811 are independent of AZ-812 (their DTOs already use OSM short form). Deps table updated: cycle-8b (AZ-812) folded into cycle-8 ordering as step 1; AZ-808 dependency upgraded SOFT -> HARD on AZ-812. Co-authored-by: Cursor <cursoragent@cursor.com>
14 KiB
Strict validation for route-creation endpoint (POST /api/satellite/route)
Task: AZ-809_route_endpoint_validation
Name: Strict validation for route-creation endpoint
Description: Add FluentValidation-backed strict input validation to POST /api/satellite/route (route creation — client submits ordered waypoints + optional geofence polygons; producer interpolates intermediate points every ≈ 200 m and — if requestMaps=true — enqueues a region request per route point for async tile backfill). Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400). Third concrete child of AZ-795; reuses the shared infra wired in cycle 7.
Complexity: 5 points (14 rules — was 13 before the 2026-05-22 probe added the Id rule; 3 validator classes; cross-field constraint; new contract doc)
Dependencies: AZ-795 (HARD — shared infra); AZ-796 (single-DTO reference); AZ-808 (no-prior-contract pattern, same batch)
Component: SatelliteProvider.Api/Validators + SatelliteProvider.Common (CreateRouteRequest, RoutePoint, GeofencePolygon, GeoPoint DTOs)
Tracker: AZ-809 (https://denyspopov.atlassian.net/browse/AZ-809)
Epic: AZ-795 — Strict input validation across all public endpoints
Originating ticket: gps-denied-onboard AZ-777 Phase 2 (cross-repo, 2026-05-22) — route-based seeding is the consumer's preferred imagery seeding path; black-box probe surfaced silent-coercion + input/output naming asymmetry
Scope
Add FluentValidation-backed strict input validation to POST /api/satellite/route. Reject malformed payloads with RFC 7807 ValidationProblemDetails (HTTP 400) per the Epic's error-shape.md v1.0.0 contract.
Originating discovery: AZ-777 Phase 2 (gps-denied-onboard) — the consumer's preferred imagery seeding path is route-based (flight-track waypoints) rather than bbox-based, so this endpoint is the primary integration target for the Derkachi reference tile catalog. A black-box probe (2026-05-22) confirmed real silent-coercion behavior and an input/output naming asymmetry (see Probe-confirmed gaps below).
Jira AZ-809 is the authoritative spec; this file mirrors the in-workspace-only sections that the satellite-provider implementer will need.
Probe-confirmed gaps (2026-05-22)
A black-box probe of the running producer captured these concrete behaviors:
Idsilently coerces to zero-Guid when omitted. Same gap as the Region endpoint (AZ-808).CreateRouteRequest.Idhas no[Required]and no[JsonRequired], so the deserializer yields zero-Guid. Validator must reject missing/zero Id.- Happy path works end-to-end for both
requestMaps:false(route storage only, instant) andrequestMaps:true(route + background tile backfill, ~15s for a 2-point 132m route at z=18). Validator must NOT regress. - Input/output naming asymmetry on points (new finding). Input
points: [{"lat":..,"lon":..}](OSM short form, per[JsonPropertyName("lat")]onRoutePoint). But the response echoes points as{"latitude":..,"longitude":..,"pointType":..,"sequenceNumber":..,"segmentIndex":..,"distanceFromPrevious":..}. This is a DTO round-trip inconsistency on the same object type. NOT in scope for this validation task, but surfaced as AC-10 (advisory) so the parent-suite team can decide whether to file a follow-up. UnmappedMemberHandling.Disallowis active globally (verified via AZ-808 probe), so unknown-field rejection (rule 13) will work out-of-the-box onceWithValidation<T>()is wired.
Endpoint surface
POST /api/satellite/route
Current wire format (per CreateRouteRequest, probe-confirmed 2026-05-22):
{
"id": "a1b2c3d4-...",
"name": "derkachi-flight-1",
"description": "AZ-777 Phase 2 seed route",
"regionSizeMeters": 1000,
"zoomLevel": 18,
"points": [
{ "lat": 50.10, "lon": 36.10 },
{ "lat": 50.11, "lon": 36.11 }
],
"geofences": {
"polygons": [
{ "northWest": { "lat": 50.15, "lon": 36.05 },
"southEast": { "lat": 50.05, "lon": 36.15 } }
]
},
"requestMaps": true,
"createTilesZip": false
}
Response (current, probe-confirmed): HTTP 200 with RouteResponse (id, name, description, regionSizeMeters, zoomLevel, totalDistanceMeters, totalPoints, points[], requestMaps, mapsReady, csvFilePath, summaryFilePath, stitchedImagePath, tilesZipPath, createdAt, updatedAt). Note response uses latitude/longitude for echoed points — see AC-10.
Background processing per Flow F5 if requestMaps=true; client polls GET /api/satellite/route/{id} until mapsReady:true.
Required validations
- Body present — null/empty body → 400 (
errors.$). idrequired, non-zero Guid — NEW (probe-confirmed gap, same as AZ-808). Missing or00000000-...→ 400 witherrors.id.namerequired — non-empty string, length[1, 200]. Missing/empty → 400 witherrors.name.descriptionoptional — if present, length[0, 1000]. Over cap → 400 witherrors.description.regionSizeMetersrequired — double, in[100.0, 10000.0](align with region endpoint). Out-of-range or missing → 400 witherrors.regionSizeMeters.zoomLevelrequired — int, in[0, 22](align withTileCoordValidator). Out-of-range or missing → 400 witherrors.zoomLevel.pointsrequired, non-empty — at least 2 entries (currentFlow F4precondition), at most 500 entries (cap to prevent runaway region-enqueue — confirm cap with parent-suite team). Below 2 or above 500 → 400 witherrors.points.- Per-point:
latrequired, double, in[-90.0, 90.0];lonrequired, double, in[-180.0, 180.0]. Missing/out-of-range → 400 witherrors.points[i].lator.lon. geofencesoptional — if present:polygonsrequired, non-empty.- Per-polygon:
northWest+southEastboth required, each with validlat/lon. - Cross-field invariant:
northWest.lat > southEast.latANDnorthWest.lon < southEast.lon(i.e. NW is genuinely north-of and west-of SE). - Violations → 400 with
errors.geofences.polygons[i].<field>.
requestMapsrequired — bool. Missing → 400 witherrors.requestMaps.createTilesZiprequired — bool. Missing → 400 witherrors.createTilesZip.- Cross-field constraint:
createTilesZip == trueimpliesrequestMaps == true(can't zip what wasn't downloaded). Violation → 400 witherrors.$orerrors.createTilesZip. - Unknown root or nested fields rejected — covered by AZ-795's
UnmappedMemberHandling.Disallow(probe-confirmed active globally via AZ-808). Any unknown field at any nesting level → 400 witherrors.<path>("could not be mapped to any .NET member"). - Type mismatch — e.g.
"lat": "fifty"at any nesting level → 400 witherrors.<path>. Covered by AZ-795'sGlobalExceptionHandler.
Implementation pattern (mirror AZ-796, extended for nesting)
- New files (all under
SatelliteProvider.Api/Validators/):CreateRouteRequestValidator.cs— root validator with rules 2–7, 10–12.RoutePointValidator.cs— per-point validator (rule 8); invoked viaRuleForEach(x => x.Points).SetValidator(new RoutePointValidator()).GeofencePolygonValidator.cs— per-polygon validator (rule 9); invoked viaRuleForEach(x => x.Geofences.Polygons).SetValidator(new GeofencePolygonValidator())(guarded byWhen(x => x.Geofences != null)).
- Mark required props on
CreateRouteRequest,RoutePoint,Geofences,GeofencePolygon,GeoPointwith[JsonRequired]per the cycle-7TileCoordpattern. Pay special attention toId(probe confirmed it's not enforced today). - Add
.WithValidation<CreateRouteRequest>()to theMapPost("/api/satellite/route", ...)chain. - Unit tests:
SatelliteProvider.Tests/Validators/CreateRouteRequestValidatorTests.cs+RoutePointValidatorTests.cs+GeofencePolygonValidatorTests.cs(≥ 13 test methods total — one perRuleFor/RuleForEachchain; new id-rule method must reproduce the probe's missing-id case). - Integration tests:
SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs(new file) — ≥ 14 methods (1 happy + 1 per failure-mode AC). - Manual probe:
scripts/probe_route_validation.sh. MUST include missing-id, NW-southeast-inverted polygon, points-too-few, createTilesZip-without-requestMaps.
New contract doc
Create _docs/02_document/contracts/api/route-creation.md v1.0.0. Like the region endpoint, this has no formal contract today. Cover:
- Endpoint, auth, request body (with nested DTO recursion), response body (
RouteResponseshape — acknowledge the input/output point-naming asymmetry; reference AC-10 advisory), error shape (referenceerror-shape.mdv1.0.0). - Invariants (client-provided non-zero Id; one routeId per request; min 2 points; max 500 points; polygon NW>SE; cross-field createTilesZip implies requestMaps).
- Test cases table (same format as
tile-inventory.mdv2.0.0). MUST include missing-id, geofence NW/SE inversion, createTilesZip cross-field, points-too-few cases. - Cross-link to Flow F4 (Route Creation) + Flow F5 (Route Map Processing background) +
region-request.md(referenced by F5 enqueue path).
Coordination with sibling tickets
- Parent (AZ-795): depends on shared infra already landed in cycle 7.
- AZ-796 (inventory): reference for single-DTO validator pattern.
- AZ-808 (region): reference for endpoint without prior contract doc (same precondition: must create new
region-request.md); coordinate field-name conventions across the two contracts. The naming inconsistencyRequestRegionRequest.sizeMetersvsCreateRouteRequest.regionSizeMeters(same concept, different names) is flagged in AC-9. - AZ-812 (region field rename): tangentially related — AZ-812 is bringing Region into the lat/lon convention that Route already uses. No direct dependency on this task.
- AZ-777 (gps-denied-onboard): consumer-side dependency — Phase 2 cannot proceed safely until this validator lands AND
route-creation.mdexists.
Acceptance criteria
AC-1: Each of the 14 validations above rejects with HTTP 400 + ValidationProblemDetails (single-rule precision).
AC-2: Happy path unchanged — a valid body still returns HTTP 200 + RouteResponse; background F5 processing still runs when requestMaps=true; probe's 2-point 132m route still completes (mapsReady:true) in under 20 seconds.
AC-3: All three validators (CreateRouteRequestValidator, RoutePointValidator, GeofencePolygonValidator) live in their own files under SatelliteProvider.Api/Validators/ and are unit-tested (≥ 1 test per RuleFor/RuleForEach chain, ≥ 13 methods total).
AC-4: SatelliteProvider.IntegrationTests/CreateRouteValidationTests.cs covers happy + 13+ failure modes with full ValidationProblemDetails assertion. MUST include Post_WithMissingId_ReturnsBadRequest (reproducing the 2026-05-22 probe's silent-coercion case).
AC-5: _docs/02_document/contracts/api/route-creation.md v1.0.0 created and published.
AC-6: _docs/02_document/system-flows.md F4 + F5 updated to reference the new contract doc + error shape.
AC-7: OpenAPI spec marks all required fields at every nesting level, declares ranges, and documents the 400 response.
AC-8: Manual probe script exercises each failure mode end-to-end via curl + JWT.
AC-9 (advisory — surface in PR, parent-suite to decide): the inconsistency RequestRegionRequest.sizeMeters vs CreateRouteRequest.regionSizeMeters is named differently for the same concept. Either keep the discrepancy and document why, or harmonize to a single name in a follow-up MAJOR contract bump for both.
AC-10 (advisory — surface in PR, parent-suite to decide): the input/output point-naming asymmetry on this endpoint (input points: [{"lat":..,"lon":..}], response points: [{"latitude":..,"longitude":..}] for the same RoutePoint round-trip) is a DTO inconsistency. Probe-confirmed 2026-05-22. Either keep + document, or file a follow-up to harmonize.
Out of scope
- Route processing semantics (Flow F5 background, ZIP creation, point-in-polygon geofence filtering) — validation lives at the API layer only.
GET /api/satellite/route/{id}status endpoint (separate task if needed; Guid binding is framework-handled).- Performance — nested validation overhead is negligible vs interpolation + background region enqueue.
- Route interpolation algorithm — unchanged.
- Input/output point-naming asymmetry fix — surfaced as AC-10 advisory only.
Constraints
- Breaking behavior change — callers today omitting
id(silently getting zero-Guid) or sending malformed nested bodies will start getting 400. Known consumer set: gps-denied-onboard (uses correct body shape with id and lat/lon points, per black-box probe 2026-05-22). Other consumers TBD by parent-suite team. - No regression in any existing
RouteCreationTests.cshappy-path coverage. - Cross-field constraint (rule 12) requires custom
When/Otherwiseor a top-levelMust()rule — FluentValidation 12.0.0 supports both; pick the more readable one.
References
- Jira AZ-809: https://denyspopov.atlassian.net/browse/AZ-809
- Parent Epic: AZ-795
- Reference implementations: AZ-796 (single-DTO pattern), AZ-808 (no-prior-contract pattern, same batch)
- Tangentially related: AZ-812 (region field rename to OSM)
- Cycle-7 retro:
_docs/06_metrics/retro_2026-05-22_cycle7.md(flagged this endpoint as a per-endpoint child of AZ-795) - Originating consumer discovery: gps-denied-onboard AZ-777 Phase 2 (route-based seeding is the consumer's preferred path; 2026-05-22 black-box probe surfaced silent-coercion + naming asymmetry)
- Related contract docs:
error-shape.mdv1.0.0,tile-inventory.mdv2.0.0 (both produced by AZ-795+AZ-796 cycle 7)