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>
14 KiB
Contract: route-creation
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.1
Status: frozen
Last Updated: 2026-05-23
Purpose
Defines the HTTP contract for POST /api/satellite/route — the route-onboarding endpoint that stores an ordered set of waypoints, interpolates intermediate points every ~200 m, and (optionally, when requestMaps=true) enqueues a region request per route point so background processing pre-fetches map tiles for the entire route corridor. Geofence polygons (optional) restrict which intermediate points get region-requests. Callers poll GET /api/satellite/route/{id} until mapsReady=true (when requestMaps=true) or read the response directly (when requestMaps=false).
This is v1.0.0 — published alongside AZ-809's validator landing. There is no prior contract document; the producer-doc surface before AZ-809 was modules/api_program.md::CreateRoute Handler + Flow F4 + Flow F5 only.
Endpoint
POST /api/satellite/route
Content-Type: application/json
Authorization: Bearer <JWT>
The request MUST carry a valid JWT (AZ-487). No permissions claim is required. Anonymous requests are rejected with HTTP 401.
Shape
Request body
{
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
"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
}
Per-field constraints:
| Field | Type | Required | Description | Constraints |
|---|---|---|---|---|
id |
UUID | yes ([JsonRequired]) |
Caller-supplied idempotency key. POSTing twice with the same id returns the existing route resource. |
Non-zero GUID (validator rejects 00000000-...). |
name |
string | yes ([JsonRequired]) |
Human-readable route name (used in produced filenames). | Length [1, 200]. Empty/whitespace rejected. |
description |
string | no | Free-text description. | Length [0, 1000] when present. |
regionSizeMeters |
number | yes ([JsonRequired]) |
Side length of the square region requested per route point. | [100.0, 10000.0] (aligned with region-request.md::sizeMeters). |
zoomLevel |
integer | yes ([JsonRequired]) |
Slippy-map zoom level for region tiles. | [0, 22]. |
points |
array | yes ([JsonRequired]) |
Ordered waypoints. Server interpolates additional intermediate points every ~200 m between consecutive originals. | Count [2, 500]. |
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). | 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. |
createTilesZip |
bool | yes ([JsonRequired]) |
When true, AFTER all region tiles are ready, package them into a ZIP at tilesZipPath. Requires requestMaps=true (can't zip what wasn't downloaded). |
No default. Cross-field invariant with requestMaps. |
GeoPoint shape (used by northWest / southEast):
| Field | Type | Required | Constraints |
|---|---|---|---|
lat |
number | yes ([JsonRequired]) |
[-90.0, 90.0]. |
lon |
number | yes ([JsonRequired]) |
[-180.0, 180.0]. |
Polygon corner cross-field invariant (GeofencePolygonValidator):
northWest.lat > southEast.lat(NW is genuinely north-of SE).northWest.lon < southEast.lon(NW is genuinely west-of SE).
Response body (post-AC-2 unchanged from pre-AZ-809)
{
"id": "8f5e6d3e-1a2b-4c3d-9e8f-0123456789ab",
"name": "derkachi-flight-1",
"description": "AZ-777 Phase 2 seed route",
"regionSizeMeters": 1000,
"zoomLevel": 18,
"totalDistanceMeters": 132.4,
"totalPoints": 3,
"points": [
{ "latitude": 50.10, "longitude": 36.10, "pointType": "original", "sequenceNumber": 0, "segmentIndex": 0, "distanceFromPrevious": null },
{ "latitude": 50.105, "longitude": 36.105, "pointType": "intermediate", "sequenceNumber": 1, "segmentIndex": 0, "distanceFromPrevious": 66.2 },
{ "latitude": 50.11, "longitude": 36.11, "pointType": "original", "sequenceNumber": 2, "segmentIndex": 0, "distanceFromPrevious": 66.2 }
],
"requestMaps": true,
"mapsReady": false,
"csvFilePath": null,
"summaryFilePath": null,
"stitchedImagePath": null,
"tilesZipPath": null,
"createdAt": "2026-05-22T14:00:00Z",
"updatedAt": "2026-05-22T14:00:00Z"
}
Advisory AC-10: The response echoes points as {"latitude":..,"longitude":..} (legacy long form) but the request accepts {"lat":..,"lon":..} (OSM short form). This input/output asymmetry on the same RoutePoint round-trip is documented and intentional for v1.0.0 — fixing it would be a major contract break. A follow-up task can harmonize the response side.
Endpoint summary
| Method | Path | Request | Response | Status codes |
|---|---|---|---|---|
POST |
/api/satellite/route |
CreateRouteRequest body |
RouteResponse (route resource snapshot) |
200, 400, 401 |
Error shape
All 400 responses conform to _docs/02_document/contracts/api/error-shape.md v1.0.0. Three sources produce identically-shaped ValidationProblemDetails bodies:
- Deserializer envelope (
UnmappedMemberHandling.Disallow+[JsonRequired]) — rejects missing-required fields and unknown root/nested keys witherrors[<path>]produced viaGlobalExceptionHandler'sJsonExceptionpath. CreateRouteRequestValidator— rejects non-zero-Id, name/description length, range checks on size / zoom / points-count, and the cross-fieldcreateTilesZip ⇒ requestMapsrule.RoutePointValidator+GeofencePolygonValidator— invoked viaRuleForEach/SetValidator; rejects per-point lat/lon out-of-range, per-polygon corner out-of-range, and the NW-north-of-SE / NW-west-of-SE invariants.
Example body for a missing-id failure (probe-confirmed pre-AZ-809 silent zero-Guid coercion):
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"id": ["The JSON property 'id' is required, but a value was not supplied."]
}
}
Example body for a nested per-point failure:
{
"errors": {
"points[1].lat": ["`lat` must be between -90 and 90."]
}
}
Example body for a polygon corner invariant failure:
{
"errors": {
"geofences.polygons[0].northWest": ["`northWest.lat` must be greater than `southEast.lat` (NW is north-of SE)."]
}
}
Invariants
- Inv-1:
idis a non-zero GUID, supplied by the caller. Re-POST with the same id returns the existing route (idempotent contract perIdempotentPostTests). - Inv-2:
pointshas at least 2 entries (Flow F4 precondition) and at most 500 entries (cap to prevent runaway region-enqueue). - Inv-3: Every
points[i].lat ∈ [-90, 90]andpoints[i].lon ∈ [-180, 180]. - Inv-4:
regionSizeMeters ∈ [100, 10000](aligned withregion-request.md::sizeMeters). - Inv-5:
zoomLevel ∈ [0, 22](slippy-map range, aligned withregion-request.mdInv-5 andtile-inventory.mdInv-8). - Inv-6 (cross-field):
createTilesZip=true ⇒ requestMaps=true(can't zip what wasn't downloaded). - Inv-7 (per-polygon shape):
northWestANDsouthEastcorners both present. - Inv-8 (per-polygon invariant):
northWest.lat > southEast.latANDnorthWest.lon < southEast.lon. - Inv-9: Unknown root or nested fields → 400 (deserializer's
UnmappedMemberHandling.Disallow). - Inv-10: When
geofencesis present,geofences.polygons.Countis 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
- Not covered: route mutation. No PUT / PATCH endpoint exists; routes are immutable post-creation.
- Not covered: background processing (Flow F5) — Flow F5 docs cover the region enqueue, tile download, ZIP packaging, and
mapsReadytransition. - Not covered: response field renaming. The input/output naming asymmetry (
points[i].latrequest vspoints[i].latituderesponse) is acknowledged in AC-10 advisory and tracked for a future major contract bump. - Not covered: the legacy
RouteValidatorinSatelliteProvider.Services.RouteManagement— it remains as defense-in-depth for direct service-layer callers; its checks are now redundant with this contract but a separate cleanup task should consolidate.
Versioning Rules
- Patch (1.0.x): Documentation clarifications, additional invariants that do not change wire behavior.
- Minor (1.x.0): Adding an optional field consumers may safely ignore; relaxing a range; supporting a new geofence shape type alongside the existing bbox.
- Major (2.0.0): Renaming any request or response field; tightening any existing range; harmonizing the response point names to
lat/lon(resolves AC-10); changing thecreateTilesZip ⇔ requestMapscross-field rule semantics.
Test Cases
| Case | Input | Expected | Notes |
|---|---|---|---|
| happy-path-no-maps | full body with requestMaps=false |
HTTP 200 + RouteResponse (mapsReady=false, no background processing) | AC-2 |
| happy-path-with-maps | full body with requestMaps=true |
HTTP 200; background F5 enqueues regions; GET /api/satellite/route/{id} shows mapsReady=true within ~20s for a 2-point 132m route at z=18 |
AC-2 + existing RouteCreationTests |
| empty-body | "" |
HTTP 400 | Rule 1 |
| missing-id | body without id |
HTTP 400 + errors[id] ("required") |
Rule 2 (probe-confirmed gap) |
| zero-guid-id | "id":"00000000-..." |
HTTP 400 + errors[id] ("non-zero GUID") |
Rule 2 |
| empty-name | "name":"" |
HTTP 400 + errors[name] |
Rule 3 |
| description-too-long | "description":<1001 chars> |
HTTP 400 + errors[description] |
Rule 4 |
| regionSize-out-of-range | "regionSizeMeters":1000000 |
HTTP 400 + errors[regionSizeMeters] |
Rule 5 |
| zoom-out-of-range | "zoomLevel":30 |
HTTP 400 + errors[zoomLevel] |
Rule 6 |
| points-too-few | 1-point array | HTTP 400 + errors[points] |
Rule 7 (Flow F4 precondition) |
| points-too-many | 501-point array | HTTP 400 + errors[points] |
Rule 7 (cap) |
| point-lat-out-of-range | "points":[..., {"lat":91,..}] |
HTTP 400 + errors["points[1].lat"] |
Rule 8 |
| 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) |
| point-lat-type-mismatch | "points":[{"lat":"fifty",..}, ..] |
HTTP 400 (nested JSON error) | Rule 14 (GlobalExceptionHandler) |
| auth-anonymous | no Bearer token | HTTP 401 | Standard .RequireAuthorization() |
| idempotent-replay | re-POST same id |
HTTP 200 (echoes existing resource) | IdempotentPostTests AC-2 |
Change Log
| 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) |