# 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) — matches spec endpoint 13 | `VehicleService.GetVehicles` | | AC-1.6 | `GET /vehicles?name=&isDefault=` filters case-sensitively on `Name` and exactly on `IsDefault` | `VehicleService.GetVehicles` query expression | | 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) | `MissionService.GetMissions`; 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 produces `Npgsql PostgresException` → 500 (UX gap — spec wants 400) | `MissionService.CreateMission`; tracked in `_docs/02_document/components/02_mission_planning/description.md` Caveats | ## 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 | Parent mission missing → 404 (`KeyNotFoundException`) | `WaypointService.*` initial existence check | | 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: HMAC-SHA256 (HS256) with `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | `Auth/JwtExtensions.cs` | | AC-5.2 | `ValidateLifetime = true`; `ClockSkew = TimeSpan.FromMinutes(1)` (tighter than .NET's 5-minute default) | `Auth/JwtExtensions.cs` | | AC-5.3 | `ValidateIssuer = false` and `ValidateAudience = false` — known CMMC L2 finding (suite-tracked under AZ-487 / AZ-494) | `Auth/JwtExtensions.cs`; `_docs/02_document/architecture.md` § 7 | | AC-5.4 | Missing `Authorization` header → 401 | `JwtBearerHandler` | | AC-5.5 | Invalid signature → 401 | HMAC verify fails | | AC-5.6 | Expired token (with 1-min skew applied) → 401 | `ValidateLifetime` | | AC-5.7 | Token signed with old `JWT_SECRET` (rotation) → 401 across the entire device until coordinated re-deploy | shared-secret model | | AC-5.8 | Valid signature + lifetime, but missing `permissions=FL` claim → 403 | Policy `"FL"` evaluator (`5_identity` description.md) | | AC-5.9 | The local validator never calls back to `admin`; `admin` outage does NOT take this service down (until issued tokens expire) | `Auth/JwtExtensions.cs` — pure local validation | ## AC-6 — Service startup + schema migration (F6) | # | Criterion | Verification | |---|-----------|--------------| | AC-6.1 | `Program.cs` reads `DATABASE_URL` (env or fallback) → `ConvertPostgresUrl` → Npgsql connection string | `Program.cs` `ConvertPostgresUrl` | | AC-6.2 | `Program.cs` reads `JWT_SECRET` (env or fallback) → `AddJwtAuth(jwt)` | `Program.cs` `AddJwtAuth` | | 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`) 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;` (B9 one-shot, post-B9 only) | `Database/DatabaseMigrator.cs` post-B9 | | 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-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 equal to `"FL"` | `Auth/JwtExtensions.cs` | | 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 |