# Acceptance Criteria — Azaion.Missions > **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14). > **Source**: every criterion below is grounded in observable code behaviour, configuration, suite spec, or HTTP contract — none are aspirational. Where the spec and code currently disagree (rename / GPS-Denied / wire shape), the criterion captures **today's behaviour** with a forward-looking note pointing at the responsible Jira child (B6 / B7 / etc.) under AZ-EPIC AZ-539. > No automated tests exist yet, so today the AC must be verified by inspection. The autodev `existing-code` flow's Phase A Steps 3 → 7 is the planned path to convert these into runnable test cases. --- ## AC-1 — Vehicle CRUD (F1) | # | Criterion | Verification | |---|-----------|--------------| | AC-1.1 | `POST /vehicles` creates a row in `vehicles` and returns the created `Vehicle` (PascalCase JSON today) | Inspect `VehicleService.CreateVehicle`; HTTP `POST /vehicles { Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault }` | | AC-1.2 | If `IsDefault == true` on create or update or `SetDefault`, the service runs `UPDATE vehicles SET is_default = FALSE WHERE is_default = TRUE` BEFORE inserting/updating with `IsDefault = true` | `VehicleService.{CreateVehicle, UpdateVehicle, SetDefault}` — clear-then-set pattern | | AC-1.3 | "Exactly one default" is **stricter than spec** (B12 decision pending — `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`) | code reflects current behaviour; B12 ticket AZ-551 records the resolution decision | | AC-1.4 | The clear-then-set is **NOT** transaction-wrapped → race window can leave 2+ defaults or zero defaults | `VehicleService` — no `db.BeginTransactionAsync`; tracked in `_docs/02_document/components/01_vehicle_catalog/description.md` Caveats #1 | | AC-1.5 | `GET /vehicles` returns a plain `List` (NO pagination, NO total count) ordered by `Name` ASC | `VehicleService.GetVehicles` `OrderBy(a => a.Name)` | | AC-1.6 | `GET /vehicles?name=&isDefault=` filters **case-INSENSITIVELY** on `Name` (LinqToDB renders `LOWER(name) LIKE %lower(input)%`) and exactly on `IsDefault` | `VehicleService.GetVehicles` `a.Name.ToLower().Contains(query.Name.ToLower())` | | AC-1.7 | `GET /vehicles/{id}` returns 404 (`KeyNotFoundException` → `ErrorHandlingMiddleware`) when id absent | `VehicleService.GetVehicle` | | AC-1.8 | `DELETE /vehicles/{id}` returns 409 (`InvalidOperationException` → `ErrorHandlingMiddleware`) when any mission references the vehicle | `VehicleService.DeleteVehicle` `IsAny` check | | AC-1.9 | Every `/vehicles/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `VehiclesController` | ## AC-2 — Mission create / read / update (F2) | # | Criterion | Verification | |---|-----------|--------------| | AC-2.1 | `POST /missions { Name, VehicleId, CreatedDate? }` creates a row and returns the created `Mission` | `MissionService.CreateMission`; default `CreatedDate = UtcNow` if null | | AC-2.2 | `POST /missions` with non-existent `VehicleId` returns `400 Bad Request` (today, via `ArgumentException`) — **spec wants `404`** | `MissionService.CreateMission` existence check; carry-forward divergence | | AC-2.3 | `GET /missions?name=&fromDate=&toDate=&page=&pageSize=` returns `PaginatedResponse` (the only paginated endpoint in this service), ordered by `CreatedDate` DESC (newest first); `name` filter is **case-INSENSITIVE** (`LOWER(name) LIKE %lower(input)%`) | `MissionService.GetMissions` `OrderByDescending(f => f.CreatedDate)`; `f.Name.ToLower().Contains(query.Name.ToLower())`; default `page=1`, `pageSize=20` | | AC-2.4 | `GET /missions/{id}` returns 404 when id absent | `MissionService.GetMission` | | AC-2.5 | `PUT /missions/{id}` applies partial update — non-null fields in `UpdateMissionRequest` overwrite, null fields are preserved | `MissionService.UpdateMission` | | AC-2.6 | LinqToDB does NOT eager-load `[Association]` — `Mission.Vehicle` and `Mission.Waypoints` serialize as `null` / `[]` on the wire | `Database/Entities/Mission.cs`; verified observation | | AC-2.7 | Every `/missions/*` route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` | | AC-2.8 | TOCTOU on `VehicleId` deletion between existence check and insert is **partly mitigated by DB-level FK** — `missions.vehicle_id REFERENCES vehicles(id)` causes PostgreSQL to reject the insert with error code `23503` if the parent was deleted between check and insert. Surface today: `Npgsql PostgresException` (code `23503`) → `ErrorHandlingMiddleware` fallthrough → 500 (UX gap — spec wants 400). Mitigation in app code (wrap check + insert in a transaction OR map `23503` to 400) is carry-forward — tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats | `MissionService.CreateMission`; `Database/DatabaseMigrator.cs` (FK declaration) | ## AC-3 — Mission delete with cross-service cascade (F3) — **most critical** | # | Criterion | Verification | |---|-----------|--------------| | AC-3.1 | `DELETE /missions/{id}` walks the cascade in this exact order: `map_objects` → resolve `waypointIds` → resolve `mediaIds` (via `media.waypoint_id`) → resolve `annotationIds` (via `annotations.media_id`) → `detection` (by `annotation_id`) → `annotations` (by id) → `media` (by id) → `waypoints` (by `mission_id`) → `missions` (by id) | `MissionService.DeleteMission` (post-B6/B7) | | AC-3.2 | Mission missing → 404 (`KeyNotFoundException`) **before** any cascade DELETE runs | `MissionService.DeleteMission` initial existence check | | AC-3.3 | Cascade is **NOT** transaction-wrapped today (ADR-006); partial failure leaves orphan rows in any sub-table | `MissionService.DeleteMission`; no `db.BeginTransactionAsync` | | AC-3.4 | `relation does not exist` for any of `media` / `annotations` / `detection` → 500 with `LogError`; this is an abnormal deployment (some sibling service hasn't migrated) | `Middleware/ErrorHandlingMiddleware.cs` fallthrough | | AC-3.5 | After B7 the cascade does NOT touch `orthophotos` or `gps_corrections` — `gps-denied` owns those tables and lifecycle | post-B7 spec; `_docs/02_document/architecture.md` ADR-007 | | AC-3.6 | End-to-end latency target: <50ms typical against local PostgreSQL on the same device (4–7 sequential round-trips) | `_docs/02_document/architecture.md` § 6 | | AC-3.7 | `autopilot` racing the delete by inserting a `map_object` AFTER step 1 reads zero rows leaves one orphan; small race window in single-operator workflow | `_docs/02_document/system-flows.md` F3 error-scenario table | ## AC-4 — Waypoint create / read / update / delete (F4) | # | Criterion | Verification | |---|-----------|--------------| | AC-4.1 | All routes are nested: `GET/POST/PUT/DELETE /missions/{missionId}/waypoints[/{wpId}]` | `MissionsController` route attributes | | AC-4.2 | **Create**: parent mission missing → 404 (`KeyNotFoundException("Mission not found")`) via an explicit `db.Missions.AnyAsync(m => m.Id == missionId)` check before insert. **Update / Delete**: the check is collapsed into a single composite `WHERE w.MissionId == missionId AND w.Id == waypointId` predicate; if no row matches (parent missing OR child missing OR mismatched parent/child pair) → 404 with the same `Waypoint not found` message. The two error cases (parent vs child) are NOT distinguishable from the response | `WaypointService.{CreateWaypoint, UpdateWaypoint, DeleteWaypoint}` | | AC-4.3 | `GET /missions/{id}/waypoints` is **unpaginated**, ordered by `OrderNum` ASC (matches spec endpoint 6) | `WaypointService.GetWaypoints` `OrderBy(w => w.OrderNum)` | | AC-4.4 | `PUT /missions/{id}/waypoints/{wpId}` is a **full overwrite** of every field even though the request DTO looks "partial-shaped" — non-nullable enums/numerics in `UpdateWaypointRequest` mean every field gets replaced (inconsistent with vehicle's nullable partial-update pattern) | `Services/WaypointService.cs` `UpdateWaypoint` + `DTOs/UpdateWaypointRequest.cs` | | AC-4.5 | `DELETE /missions/{id}/waypoints/{wpId}` walks the same cascade as F3, scoped to one waypoint (`detection` → `annotations` → `media` → `waypoints`) | `WaypointService.DeleteWaypoint` | | AC-4.6 | Same NO-transaction caveat as AC-3.3 applies to waypoint delete | `WaypointService.DeleteWaypoint` | | AC-4.7 | Every waypoint route requires JWT with `permissions=FL` claim | `[Authorize(Policy="FL")]` on `MissionsController` | ## AC-5 — JWT bearer validation (F5) | # | Criterion | Verification | |---|-----------|--------------| | AC-5.1 | Algorithm: **ECDSA-SHA256** asymmetric signature validation against public keys retrieved from `admin`'s JWKS. `ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256]` is pinned — defends against HS256-confusion (an attacker who learns the JWKS public key cannot forge tokens with `alg: HS256` using that key as the HMAC secret) | `Auth/JwtExtensions.cs` `TokenValidationParameters.ValidAlgorithms` | | AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromSeconds(30)` (tighter than .NET's 5-minute default and tighter than the legacy 1-minute setting) | `Auth/JwtExtensions.cs` `ClockSkew = TimeSpan.FromSeconds(30)` | | AC-5.3 | `ValidateIssuer = true` with `ValidIssuer = `; `ValidateAudience = true` with `ValidAudience = `. The CMMC L2 row 3 finding is structurally fixed in this service's code; the suite-level docs may still describe the legacy "iss/aud disabled" model and have a separate sync task pending | `Auth/JwtExtensions.cs` | | AC-5.4 | Missing `Authorization` header on a `[Authorize]` route → 401 | `JwtBearerHandler` | | AC-5.5 | Invalid signature → 401 (ECDSA verify fails against every cached public key whose `kid` matches the token header) | `Auth/JwtExtensions.cs` `IssuerSigningKeyResolver` + `JwtBearerHandler` | | AC-5.6 | Expired token (with 30s skew applied) → 401 | `ValidateLifetime = true` | | AC-5.7 | Token's `kid` not in cached JWKS → 401. JWKS rotation publishes a new `kid`; the cached manager refreshes on the default schedule (matches admin's `Cache-Control: public, max-age=3600`). **No coordinated redeploy** is needed for rotation | `ConfigurationManager` refresh | | AC-5.8 | Valid signature + lifetime + iss + aud, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`05_identity/description.md`) | | AC-5.9 | Request-path validation does NOT call `admin`; only the first protected request after a cold start triggers a synchronous JWKS HTTPS GET against `JWT_JWKS_URL` (which must be HTTPS — `HttpDocumentRetriever { RequireHttps = true }`). Once cached, the manager refreshes on its default schedule. `admin` outage AFTER the JWKS has been cached does NOT take this service down until cache + tokens expire; `admin` outage AT the time of the first JWKS fetch causes the first protected request to fail 500 | `Auth/JwtExtensions.cs` `ConfigurationManager` | | AC-5.10 | Token header with `alg ∉ [EcdsaSha256]` (e.g. forged `alg: HS256`, or genuine but unsupported `alg: RS256`) → 401 — algorithm pin defense | `Auth/JwtExtensions.cs` `ValidAlgorithms` | | AC-5.11 | `iss` claim ≠ resolved `JWT_ISSUER` → 401 | `Auth/JwtExtensions.cs` `ValidateIssuer` + `ValidIssuer` | | AC-5.12 | `aud` claim ≠ resolved `JWT_AUDIENCE` → 401 | `Auth/JwtExtensions.cs` `ValidateAudience` + `ValidAudience` | ## AC-6 — Service startup + schema migration (F6) | # | Criterion | Verification | |---|-----------|--------------| | AC-6.1 | `Program.cs` resolves **four** required configuration values via `Infrastructure/ConfigurationResolver.cs` → `ResolveRequiredOrThrow`: `DATABASE_URL`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_JWKS_URL`. Resolution order per key is env-var-first, then `IConfiguration` config key (`Database:Url` / `Jwt:Issuer` / `Jwt:Audience` / `Jwt:JwksUrl`), else THROW `InvalidOperationException` at startup. **No hardcoded development fallbacks** — ADR-005's "dev fallback secret" branch is obsolete; only the Swagger-unconditional branch remains | `Program.cs`, `Infrastructure/ConfigurationResolver.cs` | | AC-6.2 | `Program.cs` calls `AddJwtAuth(issuer, audience, jwksUrl)` (NOT `AddJwtAuth(secret)`). The legacy `JWT_SECRET` env var / config key is no longer consulted anywhere in the codebase. JWKS is fetched lazily on the first protected request via `Microsoft.IdentityModel.Protocols.ConfigurationManager` with `HttpDocumentRetriever { RequireHttps = true }` | `Program.cs`, `Auth/JwtExtensions.cs` | | AC-6.3 | `DatabaseMigrator.Migrate` runs ONCE at startup, INSIDE a single startup scope (not per-request) | `Program.cs` `using var scope = app.Services.CreateScope(); ... DatabaseMigrator.Migrate(db)` | | AC-6.4 | Migrator runs `CREATE TABLE IF NOT EXISTS` for the 4 owned tables (`vehicles`, `missions`, `waypoints`, `map_objects`) using PostgreSQL `TIMESTAMP` (no timezone) for date columns, with explicit `REFERENCES` for FKs (`missions.vehicle_id → vehicles(id)`, `waypoints.mission_id → missions(id)`, `map_objects.waypoint_id → waypoints(id)`), and `CREATE INDEX IF NOT EXISTS` for 3 indexes | `Database/DatabaseMigrator.cs` | | AC-6.5 | Migrator runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` unconditionally (B9 one-shot kept idempotent indefinitely — re-running on a fresh DB is a no-op) | `Database/DatabaseMigrator.cs` | | AC-6.6 | Migrator is idempotent — every startup runs the same statements; `IF NOT EXISTS` makes them safe to re-run | `Database/DatabaseMigrator.cs` | | AC-6.7 | `postgres-local` unreachable at startup → process exits non-zero; Watchtower restarts the container; `flight-gate` prevents restart mid-mission | `Program.cs` (no DB error swallow); suite arch doc | | AC-6.8 | `azaion` database does not exist → process exits with Npgsql `3D000`; database creation is a provisioning concern, NOT this service | suite-level concern | | AC-6.9 | After migrator, `ErrorHandlingMiddleware` is registered FIRST in the pipeline — wraps every subsequent middleware exception | `Program.cs` middleware order | | AC-6.10 | Service serves on port 8080 inside the container (`EXPOSE 8080`); edge compose maps host `5002:8080` | `Dockerfile`; suite `_infra/_compose/` | | AC-6.11 | CORS is gated by `Infrastructure/CorsConfigurationValidator.cs` at startup: in `Production` (case-insensitive on `ASPNETCORE_ENVIRONMENT`) the host THROWS when `CorsConfig:AllowedOrigins` is empty AND `CorsConfig:AllowAnyOrigin != true`; in non-Production environments the same empty allow-list falls back to permissive (`AllowAnyOrigin/Method/Header`) AND emits a `PermissiveDefaultWarning` startup log. The pre-B11 "all environments permissive" assumption no longer holds | `Program.cs`, `Infrastructure/CorsConfigurationValidator.cs` | | AC-6.12 | The JWKS HTTPS-only constraint (`HttpDocumentRetriever { RequireHttps = true }`) means a misconfigured `JWT_JWKS_URL = http://...` will pass startup config resolution (any non-empty string is accepted by `ResolveRequiredOrThrow`) but cause the first protected request to fail at JWKS-fetch time → 500. Detected only at runtime, not at startup | `Auth/JwtExtensions.cs` `HttpDocumentRetriever` | ## AC-7 — Health probe (F7) | # | Criterion | Verification | |---|-----------|--------------| | AC-7.1 | `GET /health` is anonymous (no `[Authorize]`) | `Program.cs` `MapGet("/health")` | | AC-7.2 | Returns `200 OK` with body `{ "status": "healthy" }` | `Results.Ok(new { status = "healthy" })` | | AC-7.3 | Latency target: <10ms typical (no DB ping today — process-liveness only) | `Program.cs` | | AC-7.4 | If pipeline is down, the probe fails at TCP-connect time and Watchtower restarts the container | suite arch doc | ## AC-8 — Wire shape (HTTP contract) | # | Criterion | Verification | |---|-----------|--------------| | AC-8.1 | Entity / DTO bodies serialize as **PascalCase** today (no `JsonNamingPolicy.CamelCase` configured) — divergent from suite spec (ADR-002 carry-forward) | `Program.cs` (no `JsonSerializerOptions.PropertyNamingPolicy`); `_docs/02_document/architecture.md` ADR-002 | | AC-8.2 | Error envelope is camelCase **by accidental match** — middleware writes `new { statusCode, message }` (lowercase property names preserved by System.Text.Json) | `Middleware/ErrorHandlingMiddleware.cs` | | AC-8.3 | Error envelope **misses** the spec's `errors: object?` field today | `Middleware/ErrorHandlingMiddleware.cs` | | AC-8.4 | The static `ErrorResponse` DTO is **dead on the wire** — middleware writes the anonymous object instead. If `ErrorResponse` were ever used, it would emit PascalCase + the wrong `Errors` shape (`List?` instead of spec's `object?`) | `DTOs/ErrorResponse.cs` | | AC-8.5 | `ErrorHandlingMiddleware` mapping: `KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`, fallthrough → 500 (with stack trace logged via `LogError`) | `Middleware/ErrorHandlingMiddleware.cs` | | AC-8.6 | 500 response body shows `Internal server error` (generic), NOT the stack trace; the stack trace is logged only | `Middleware/ErrorHandlingMiddleware.cs` | | AC-8.7 | `PaginatedResponse` has fields `Items / TotalCount / Page / PageSize` — PascalCase today, divergent from suite spec | `DTOs/PaginatedResponse.cs` | ## AC-9 — Authorization (cross-cutting) | # | Criterion | Verification | |---|-----------|--------------| | AC-9.1 | One named policy `"FL"` is registered in `Auth/JwtExtensions.cs`; satisfied by a `permissions` claim **containing** `"FL"` (`AuthorizationPolicyBuilder.RequireClaim("permissions", "FL")` matches when ANY `permissions` claim value equals `"FL"`, so a multi-permission token `permissions: ["FL","SOMETHING_ELSE"]` is accepted) | `Auth/JwtExtensions.cs` `AddPolicy("FL")` | | AC-9.2 | The string `"FL"` is hardcoded in feature controllers — a typo silently turns into a permanent 403 (no compile-time check) | `Controllers/{Vehicles,Missions}Controller.cs`; `_docs/02_document/module-layout.md` § Verification Needed #4 | | AC-9.3 | The policy NAME `"FL"` retains the legacy "Flight" wording even after the service rename to `missions` — fleet-wide auth change deferred (NOT in this Epic) | `Auth/JwtExtensions.cs`; `../../suite/_docs/00_roles_permissions.md` TODO | | AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` — every protected endpoint has the same gate | `Controllers/{Vehicles,Missions}Controller.cs` | ## AC-10 — Operational invariants | # | Criterion | Verification | |---|-----------|--------------| | AC-10.1 | One container instance per device (vertical scale only) | `Dockerfile`; suite arch doc | | AC-10.2 | RTO ≈ container restart time (~10s); RPO = device-local backup cadence (suite-level) | suite arch doc | | AC-10.3 | Unhandled 500 exceptions are logged with stack trace via `LogError(ex, "Unhandled exception")` | `Middleware/ErrorHandlingMiddleware.cs` | | AC-10.4 | No correlation id, no per-user audit log — supporting a production incident requires grep-by-timestamp | `_docs/02_document/architecture.md` § 7 | | AC-10.5 | The migrator's `DROP TABLE IF EXISTS orthophotos / gps_corrections` block (B9) MUST NOT run before `gps-denied` has migrated its own copy of those tables on the device — out-of-band ordering: deploy `gps-denied` first | `Database/DatabaseMigrator.cs` post-B9; `_docs/02_document/system-flows.md` F6 | | AC-10.6 | The cross-service cascade (`media`, `annotations`, `detection`) requires `annotations` and detection pipeline to have migrated their tables on the same device — abnormal deployment otherwise | `_docs/02_document/components/02_mission_planning/description.md` Caveats #6 |