refactor: enhance JWT authentication and CORS configuration

Updated JWT authentication to use configuration values instead of hardcoded secrets, improving security and flexibility. Enhanced CORS policy to conditionally allow origins based on configuration settings, with logging for permissive defaults. Updated README to reflect project renaming and clarify service context.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 19:48:25 +03:00
parent 2fe394d732
commit 7025f4d075
74 changed files with 8494 additions and 19 deletions
+64 -7
View File
@@ -1,24 +1,66 @@
using System.Text; using Azaion.Flights.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
namespace Azaion.Flights.Auth; namespace Azaion.Flights.Auth;
public static class JwtExtensions public static class JwtExtensions
{ {
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret) public const string JwtIssuerEnvVar = "JWT_ISSUER";
public const string JwtIssuerConfigKey = "Jwt:Issuer";
public const string JwtAudienceEnvVar = "JWT_AUDIENCE";
public const string JwtAudienceConfigKey = "Jwt:Audience";
public const string JwtJwksUrlEnvVar = "JWT_JWKS_URL";
public const string JwtJwksUrlConfigKey = "Jwt:JwksUrl";
public static IServiceCollection AddJwtAuth(this IServiceCollection services, IConfiguration configuration)
{ {
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
var issuer = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtIssuerEnvVar, JwtIssuerConfigKey, "JWT issuer");
var audience = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtAudienceEnvVar, JwtAudienceConfigKey, "JWT audience");
var jwksUrl = ConfigurationResolver.ResolveRequiredOrThrow(configuration, JwtJwksUrlEnvVar, JwtJwksUrlConfigKey, "JWKS URL");
// JwtBearer's stock ConfigurationManager targets the full OIDC discovery
// document; admin only exposes JWKS, so we wire a JWKS-only retriever.
// The manager caches the document and refreshes on the default schedule
// (matches admin's Cache-Control: public, max-age=3600 on /.well-known/jwks.json).
var jwksConfigManager = new ConfigurationManager<JsonWebKeySet>(
jwksUrl,
new JwksRetriever(),
new HttpDocumentRetriever { RequireHttps = true });
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuerSigningKey = true, ValidateIssuer = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)), ValidIssuer = issuer,
ValidateIssuer = false, ValidateAudience = true,
ValidateAudience = false, ValidAudience = audience,
ValidateLifetime = true, ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1) ValidateIssuerSigningKey = true,
// Pin algorithms so a token forged with alg=HS256 using the
// public key as the HMAC secret cannot pass validation.
ValidAlgorithms = [SecurityAlgorithms.EcdsaSha256],
RequireSignedTokens = true,
RequireExpirationTime = true,
ClockSkew = TimeSpan.FromSeconds(30),
IssuerSigningKeyResolver = (_, _, kid, _) =>
{
var jwks = jwksConfigManager
.GetConfigurationAsync(CancellationToken.None)
.GetAwaiter()
.GetResult();
if (string.IsNullOrEmpty(kid))
return jwks.GetSigningKeys();
return jwks.GetSigningKeys().Where(k => k.KeyId == kid);
}
}; };
}); });
@@ -28,4 +70,19 @@ public static class JwtExtensions
return services; return services;
} }
// ConfigurationManager<JsonWebKeySet> needs an IConfigurationRetriever<JsonWebKeySet>.
// Microsoft ships OpenIdConnectConfigurationRetriever (full discovery doc) but
// no JWKS-only equivalent, so we implement the minimal version here.
private sealed class JwksRetriever : IConfigurationRetriever<JsonWebKeySet>
{
public async Task<JsonWebKeySet> GetConfigurationAsync(string address, IDocumentRetriever retriever, CancellationToken cancel)
{
ArgumentNullException.ThrowIfNull(address);
ArgumentNullException.ThrowIfNull(retriever);
var document = await retriever.GetDocumentAsync(address, cancel).ConfigureAwait(false);
return new JsonWebKeySet(document);
}
}
} }
+27
View File
@@ -0,0 +1,27 @@
namespace Azaion.Flights.Infrastructure;
public static class ConfigurationResolver
{
// Fail-fast contract: missing or whitespace-only values throw at startup so a
// production deploy without the operator-confirmed values cannot silently
// accept an insecure default (e.g. a development JWT secret, a localhost DB).
public static string ResolveRequiredOrThrow(
IConfiguration configuration,
string envVar,
string configKey,
string humanLabel)
{
ArgumentNullException.ThrowIfNull(configuration);
var value = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrWhiteSpace(value))
value = configuration[configKey];
if (string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException(
$"{humanLabel} is not configured. Set the {envVar} environment variable " +
$"or the {configKey} configuration key.");
return value;
}
}
@@ -0,0 +1,41 @@
namespace Azaion.Flights.Infrastructure;
public static class CorsConfigurationValidator
{
public const string MissingOriginsMessage =
"CORS is misconfigured: CorsConfig:AllowedOrigins is empty and CorsConfig:AllowAnyOrigin is not true. " +
"Refusing to start in Production with a permissive CORS policy. " +
"Set CorsConfig:AllowedOrigins to a non-empty array, or set CorsConfig:AllowAnyOrigin=true to opt in.";
public const string PermissiveDefaultWarning =
"CorsConfig:AllowedOrigins is empty and CorsConfig:AllowAnyOrigin is not true. " +
"Permissive CORS is being applied for environment {Environment}; do not run with this configuration in Production.";
public static void EnsureSafeForEnvironment(
string[] allowedOrigins,
bool allowAnyOrigin,
string environmentName)
{
ArgumentNullException.ThrowIfNull(allowedOrigins);
ArgumentNullException.ThrowIfNull(environmentName);
if (allowedOrigins.Length == 0
&& !allowAnyOrigin
&& string.Equals(environmentName, "Production", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(MissingOriginsMessage);
}
}
public static bool ShouldUsePermissivePolicy(string[] allowedOrigins, bool allowAnyOrigin)
{
ArgumentNullException.ThrowIfNull(allowedOrigins);
return allowAnyOrigin || allowedOrigins.Length == 0;
}
public static bool ShouldWarnAboutPermissiveDefault(string[] allowedOrigins, bool allowAnyOrigin)
{
ArgumentNullException.ThrowIfNull(allowedOrigins);
return allowedOrigins.Length == 0 && !allowAnyOrigin;
}
}
+30 -9
View File
@@ -2,23 +2,25 @@ using LinqToDB;
using LinqToDB.Data; using LinqToDB.Data;
using Azaion.Flights.Auth; using Azaion.Flights.Auth;
using Azaion.Flights.Database; using Azaion.Flights.Database;
using Azaion.Flights.Infrastructure;
using Azaion.Flights.Middleware; using Azaion.Flights.Middleware;
using Azaion.Flights.Services; using Azaion.Flights.Services;
const string DatabaseUrlEnvVar = "DATABASE_URL";
const string DatabaseUrlConfigKey = "Database:Url";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var databaseUrl = builder.Configuration["DATABASE_URL"] var databaseUrl = ConfigurationResolver.ResolveRequiredOrThrow(
?? Environment.GetEnvironmentVariable("DATABASE_URL") builder.Configuration,
?? "Host=localhost;Database=azaion;Username=postgres;Password=changeme"; DatabaseUrlEnvVar,
DatabaseUrlConfigKey,
"Database connection string");
var connectionString = databaseUrl.StartsWith("postgresql://") var connectionString = databaseUrl.StartsWith("postgresql://")
? ConvertPostgresUrl(databaseUrl) ? ConvertPostgresUrl(databaseUrl)
: databaseUrl; : databaseUrl;
var jwtSecret = builder.Configuration["JWT_SECRET"]
?? Environment.GetEnvironmentVariable("JWT_SECRET")
?? "development-secret-key-min-32-chars!!";
builder.Services.AddScoped(_ => builder.Services.AddScoped(_ =>
{ {
var options = new DataOptions().UsePostgreSQL(connectionString); var options = new DataOptions().UsePostgreSQL(connectionString);
@@ -29,10 +31,22 @@ builder.Services.AddScoped<FlightService>();
builder.Services.AddScoped<WaypointService>(); builder.Services.AddScoped<WaypointService>();
builder.Services.AddScoped<AircraftService>(); builder.Services.AddScoped<AircraftService>();
builder.Services.AddJwtAuth(jwtSecret); builder.Services.AddJwtAuth(builder.Configuration);
var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
var allowAnyOrigin = builder.Configuration.GetValue<bool>("CorsConfig:AllowAnyOrigin");
CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, builder.Environment.EnvironmentName);
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader())); {
if (CorsConfigurationValidator.ShouldUsePermissivePolicy(allowedOrigins, allowAnyOrigin))
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
else
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod();
});
});
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
@@ -40,6 +54,13 @@ builder.Services.AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();
if (CorsConfigurationValidator.ShouldWarnAboutPermissiveDefault(allowedOrigins, allowAnyOrigin))
{
app.Services
.GetRequiredService<ILogger<Program>>()
.LogWarning(CorsConfigurationValidator.PermissiveDefaultWarning, app.Environment.EnvironmentName);
}
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<AppDataConnection>(); var db = scope.ServiceProvider.GetRequiredService<AppDataConnection>();
+17 -2
View File
@@ -1,3 +1,18 @@
# Azaion.Flights # Azaion.Missions
.NET 8 REST API for flights, waypoints, and aircraft management. > **NOTE (forward-looking)**: this repo is being renamed `flights` -> `missions` (Jira AZ-EPIC, child B4). Until B4 + B5 land, the .NET project file is still `Azaion.Flights.csproj` and the namespace is `Azaion.Flights.*`. The forward-looking name is used here intentionally.
.NET 10 REST API for **mission planning** (missions + waypoints) and the **vehicle catalog** (Plane / Copter / UGV / GuidedMissile) on Azaion edge devices.
GPS-Denied (orthophoto upload, live-GPS SSE, GPS corrections) is **not** part of this service -- it lives in the separate `gps-denied` service. See `../suite/_docs/11_gps_denied.md`.
## Suite context
- **Tier**: edge (runs on Jetson / OrangePI / operator-PC).
- **Spec**: `../suite/_docs/02_missions.md` (post-rename).
- **DB**: shared local PostgreSQL on the edge device; this service migrates only its own 4 tables (`vehicles`, `missions`, `waypoints`, `map_objects`).
- **Auth**: JWT validated locally with the suite-wide HMAC secret. Tokens are minted by the remote `admin` service.
## Local docs
- `_docs/02_document/` -- bottom-up discovery + module + component documentation produced by autodev.
+128
View File
@@ -0,0 +1,128 @@
# 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<Vehicle>` (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<Mission>` 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<Mission>` (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 (47 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<string>?` 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<T>` 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 |
@@ -0,0 +1,241 @@
# Input Data Parameters — Azaion.Missions
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
> Schemas below match the actual `Database/Entities/*.cs` LinqToDB mappings and `DTOs/*.cs` request shapes (post-B6 names). Today's source still uses pre-rename names; the doc-vs-code mapping is in `_docs/02_document/04_verification_log.md` § 0.
---
## 1. Configuration input (env vars)
| Variable | Type | Required | Default (dev fallback) | Source order | Format / constraints | Used by |
|----------|------|----------|------------------------|--------------|----------------------|---------|
| `DATABASE_URL` | string | yes (production) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` | `IConfiguration``Environment.GetEnvironmentVariable` → fallback | Either `postgresql://user:pass@host:port/db` (converted via local helper `ConvertPostgresUrl`) OR a raw Npgsql connection string | `Program.cs` (DI registration of `AppDataConnection`) |
| `JWT_SECRET` | string | yes (production) | `development-secret-key-min-32-chars!!` | same as above | UTF-8 string, ≥32 chars (`SymmetricSecurityKey` accepts shorter but `JwtBearer` HS256 requires ≥32 bytes) | `Program.cs` `AddJwtAuth` |
| `AZAION_REVISION` | string | no | (build-time) | Dockerfile `ARG` baked from `CI_COMMIT_SHA` | git SHA | Dockerfile only; surfaced via `docker inspect` |
| `ASPNETCORE_URLS` | string | no | `http://+:8080` | ASP.NET Core convention | URL list | ASP.NET Core host |
**Important**: ADR-005 carry-forward — neither Swagger UI mounting nor the dev fallbacks for `JWT_SECRET` / `DATABASE_URL` are gated on `IsDevelopment()`. A production deploy without the env vars set will silently boot with the well-known dev secret; tracked at suite level (CMMC L2 row 3, AZ-487 / AZ-494).
## 2. HTTP request DTOs (post-B6 shapes)
### 2.1 Vehicle (`/vehicles`)
```csharp
public class CreateVehicleRequest {
public VehicleType Type { get; set; } // enum int: Plane=0, Copter=1, UGV=2, GuidedMissile=3
public string Model { get; set; } = "";
public string Name { get; set; } = "";
public FuelType FuelType { get; set; } // enum int: Electric=0, Gasoline=1, Diesel=2
public decimal BatteryCapacity { get; set; }
public decimal EngineConsumption { get; set; }
public decimal EngineConsumptionIdle { get; set; }
public bool IsDefault { get; set; }
}
public class UpdateVehicleRequest { // all properties nullable -- partial update
public VehicleType? Type;
public string? Model;
public string? Name;
public FuelType? FuelType;
public decimal? BatteryCapacity;
public decimal? EngineConsumption;
public decimal? EngineConsumptionIdle;
public bool? IsDefault;
}
public class GetVehiclesQuery {
public string? Name { get; set; } // case-sensitive contains
public bool? IsDefault { get; set; } // exact match
}
public class SetDefaultRequest {
public bool IsDefault { get; set; }
}
```
**Validation**: NONE today. No `[Required]`, no `[Range]`, no min-length. Empty `Name`, negative `BatteryCapacity`, out-of-range enum int values are accepted. Carry-forward improvement.
### 2.2 Mission (`/missions`)
```csharp
public class CreateMissionRequest {
public Guid VehicleId { get; set; }
public string Name { get; set; } = "";
public DateTime? CreatedDate { get; set; } // defaults to UtcNow if null
}
public class UpdateMissionRequest { // partial update
public string? Name { get; set; }
public Guid? VehicleId { get; set; }
}
public class GetMissionsQuery {
public string? Name { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
```
**Validation**: existence check on `VehicleId` (returns 400 today via `ArgumentException`; spec wants 404 — carry-forward divergence). No bounds on `Page` / `PageSize` (negative or huge values accepted by binding).
### 2.3 Waypoint (`/missions/{id}/waypoints`)
```csharp
public class GeoPoint { // shared value object; all fields nullable
public decimal? Lat { get; set; }
public decimal? Lon { get; set; }
public string? Mgrs { get; set; } // Military Grid Reference System
}
public class CreateWaypointRequest {
public GeoPoint? GeoPoint { get; set; } // nullable: all-null is accepted today (no invariant)
public WaypointSource WaypointSource { get; set; } // enum int
public WaypointObjective WaypointObjective { get; set; } // enum int
public int OrderNum { get; set; }
public decimal Height { get; set; }
}
public class UpdateWaypointRequest { // identical SHAPE to Create -- non-nullable enums/numerics
public GeoPoint? GeoPoint { get; set; }
public WaypointSource WaypointSource { get; set; }
public WaypointObjective WaypointObjective { get; set; }
public int OrderNum { get; set; }
public decimal Height { get; set; }
}
```
**Validation**: NONE. No min-length, no enum range check, no `Lat`/`Lon` bounds, no MGRS format validation. `GeoPoint` may be all-null. **`UpdateWaypoint` is structurally NOT partial** — every field gets overwritten on PUT (inconsistent with vehicle's partial-update pattern).
**Spec divergence (Geopoint)**: spec stores `Waypoints.GPS` as a single `string GPS` field with `Lat <-> MGRS` auto-conversion (`../../suite/_docs/02_missions.md`, `../../suite/_docs/00_database_schema.md`). Code stores 3 separate columns with NO conversion. Carry-forward.
## 3. Persisted data — owned tables (post-B7+B9)
### 3.1 `vehicles` (owned)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | UUID | NO | primary key |
| `type` | INTEGER | NO | `VehicleType` enum int (Plane / Copter / UGV / GuidedMissile) |
| `model` | TEXT | NO | |
| `name` | TEXT | NO | |
| `fuel_type` | INTEGER | NO | `FuelType` enum int |
| `battery_capacity` | NUMERIC | NO | |
| `engine_consumption` | NUMERIC | NO | |
| `engine_consumption_idle` | NUMERIC | NO | |
| `is_default` | BOOLEAN | NO | "exactly one default" enforced by `VehicleService` (stricter than spec — B12 decision) |
### 3.2 `missions` (owned)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | UUID | NO | primary key |
| `created_date` | TIMESTAMPTZ | NO | server-assigned `UtcNow` if not supplied |
| `name` | TEXT | NO | |
| `vehicle_id` | UUID | NO | logical FK to `vehicles.id`; existence-checked in service, no DB-level FK constraint declared in migrator |
Index: `ix_missions_vehicle_id` on `vehicle_id`.
### 3.3 `waypoints` (owned)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | UUID | NO | primary key |
| `mission_id` | UUID | NO | logical FK to `missions.id` |
| `lat` | NUMERIC | YES | spec divergence — see § 2.3 |
| `lon` | NUMERIC | YES | spec divergence |
| `mgrs` | TEXT | YES | spec divergence |
| `waypoint_source` | INTEGER | NO | `WaypointSource` enum int |
| `waypoint_objective` | INTEGER | NO | `WaypointObjective` enum int |
| `order_num` | INTEGER | NO | listing order |
| `height` | NUMERIC | NO | metres |
Index: `ix_waypoints_mission_id` on `mission_id`.
### 3.4 `map_objects` (owned schema; written by `autopilot`)
| Column | Type | Nullable | Notes |
|--------|------|----------|-------|
| `id` | UUID | NO | primary key |
| `mission_id` | UUID | NO | logical FK to `missions.id` |
| `h3_index` | TEXT | NO | Uber H3 hex grid cell |
| `mgrs` | TEXT | NO | |
| `lat` | NUMERIC | YES | |
| `lon` | NUMERIC | YES | |
| `class_num` | INTEGER | NO | detection class id |
| `label` | TEXT | NO | |
| `size_width_m` | NUMERIC | NO | |
| `size_length_m` | NUMERIC | NO | |
| `confidence` | NUMERIC | NO | 0..1 |
| `object_status` | INTEGER | NO | `ObjectStatus` enum int |
| `first_seen_at` | TIMESTAMPTZ | NO | |
| `last_seen_at` | TIMESTAMPTZ | NO | |
Index: `ix_map_objects_mission_id` on `mission_id`.
`autopilot` is the writer (per `../../suite/_docs/06_autopilot_design.md`); this service owns the schema and cascade-deletes only.
## 4. Persisted data — borrowed read-only stubs
| Table | Schema owner | This service uses for |
|-------|--------------|------------------------|
| `media` | `annotations` (per `../../suite/_docs/01_annotations.md`) | id resolution + cascade-delete walk on mission/waypoint delete |
| `annotations` | `annotations` | id resolution + cascade-delete walk |
| `detection` (singular by upstream owner) | Detection pipeline | cascade-delete walk |
Stub schemas (just enough to query / delete by id):
```csharp
[Table("media")] public class Media { [PrimaryKey, Column("id")] public string Id = ""; [Column("waypoint_id")] public Guid? WaypointId; }
[Table("annotations")] public class Annotation { [PrimaryKey, Column("id")] public string Id = ""; [Column("media_id")] public string MediaId = ""; }
[Table("detection")] public class Detection { [PrimaryKey, Column("id")] public Guid Id; [Column("annotation_id")] public string AnnotationId = ""; }
```
Migrations for these tables are owned by the respective sibling services. If they have not migrated on a given device, this service's cascade-delete walk fails on `relation does not exist` (abnormal deployment).
## 5. Removed in B7 (post-B7+B9 schema)
These tables and entities are **out of this repo**; cleanup happens once on legacy devices via the B9 `DROP TABLE IF EXISTS` block in `DatabaseMigrator`:
| Table | Pre-B7 owner | Post-B7 owner |
|-------|--------------|---------------|
| `orthophotos` | this repo (`Orthophoto` entity, 03_gps_denied component) | `gps-denied` service (separate repo) |
| `gps_corrections` | this repo (`GpsCorrection` entity, 03_gps_denied component) | `gps-denied` service |
`gps-denied` references `mission_id` / `waypoint_id` as plain GUIDs in its OWN tables — no runtime coupling, no FK declaration, no cascade by this service.
## 6. Enum values
| Enum | Values | Persisted as | Defined in |
|------|--------|--------------|------------|
| `VehicleType` | `Plane=0`, `Copter=1`, `UGV=2`, `GuidedMissile=3` | INTEGER | `Enums/VehicleType.cs` (post-B6) |
| `FuelType` | `Electric=0`, `Gasoline=1`, `Diesel=2` | INTEGER | `Enums/FuelType.cs` |
| `WaypointSource` | `Operator=0`, `Mission=1`, ... | INTEGER | `Enums/WaypointSource.cs` |
| `WaypointObjective` | `Surveillance=0`, `Strike=1`, ... | INTEGER | `Enums/WaypointObjective.cs` |
| `ObjectStatus` | `Active=0`, `Lost=1`, ... | INTEGER | `Enums/ObjectStatus.cs` (used only by `MapObject`) |
Per `_docs/02_document/modules/enums.md`, integer values are NOT range-validated on input — model binding accepts any int.
## 7. Inbound data shapes (HTTP)
| Endpoint | Method | Body / Query | Returns |
|----------|--------|--------------|---------|
| `/vehicles` | GET | `?name=&isDefault=` | `List<Vehicle>` (PascalCase JSON; not paginated) |
| `/vehicles/{id}` | GET | — | `Vehicle` |
| `/vehicles` | POST | `CreateVehicleRequest` | `Vehicle` (created) |
| `/vehicles/{id}` | PUT | `UpdateVehicleRequest` (partial) | `Vehicle` (updated) |
| `/vehicles/{id}/setDefault` | POST | `SetDefaultRequest` | `Vehicle` |
| `/vehicles/{id}` | DELETE | — | 204 / 409 if referenced |
| `/missions` | GET | `?name=&fromDate=&toDate=&page=&pageSize=` | `PaginatedResponse<Mission>` |
| `/missions/{id}` | GET | — | `Mission` |
| `/missions` | POST | `CreateMissionRequest` | `Mission` (created) |
| `/missions/{id}` | PUT | `UpdateMissionRequest` (partial) | `Mission` (updated) |
| `/missions/{id}` | DELETE | — | 204 / 404; runs F3 cascade |
| `/missions/{id}/waypoints` | GET | — | `List<Waypoint>` (unpaginated, ordered by `OrderNum`) |
| `/missions/{id}/waypoints` | POST | `CreateWaypointRequest` | `Waypoint` (created) |
| `/missions/{id}/waypoints/{wpId}` | PUT | `UpdateWaypointRequest` (full overwrite) | `Waypoint` |
| `/missions/{id}/waypoints/{wpId}` | DELETE | — | 204; runs F4 scoped cascade |
| `/health` | GET | — anonymous | `200 { "status": "healthy" }` |
All routes except `/health` require JWT bearer with `permissions=FL` claim.
@@ -0,0 +1,62 @@
{
"$comment": "Expected per-table delete counts and cascade order for FT-P-12 (mission cascade delete F3). Used as the file_reference comparison for the cascade walk.",
"input_fixture": "fixture_cascade_F3.sql",
"trigger": "DELETE /missions/22222222-0000-0000-0000-000000000001",
"expected_response": {
"status_code": 204,
"body_length": 0
},
"expected_cascade_order": [
"SELECT FROM map_objects WHERE mission_id = M1",
"DELETE FROM map_objects WHERE mission_id = M1",
"SELECT FROM waypoints WHERE mission_id = M1",
"SELECT FROM media WHERE waypoint_id IN (WP1, WP2)",
"SELECT FROM annotations WHERE media_id IN (ME1, ME2)",
"DELETE FROM detection WHERE annotation_id IN (AN1, AN2)",
"DELETE FROM annotations WHERE id IN (AN1, AN2)",
"DELETE FROM media WHERE id IN (ME1, ME2)",
"DELETE FROM waypoints WHERE mission_id = M1",
"DELETE FROM missions WHERE id = M1"
],
"expected_per_table_post_state": {
"missions": {
"filter": "id = '22222222-0000-0000-0000-000000000001'",
"expected_count": 0,
"comparison": "exact"
},
"waypoints": {
"filter": "mission_id = '22222222-0000-0000-0000-000000000001'",
"expected_count": 0,
"comparison": "exact"
},
"map_objects": {
"filter": "mission_id = '22222222-0000-0000-0000-000000000001'",
"expected_count": 0,
"comparison": "exact"
},
"media": {
"filter": "id IN ('media-fixture-001', 'media-fixture-002')",
"expected_count": 0,
"comparison": "exact"
},
"annotations": {
"filter": "id IN ('anno-fixture-001', 'anno-fixture-002')",
"expected_count": 0,
"comparison": "exact"
},
"detection": {
"filter": "annotation_id IN ('anno-fixture-001', 'anno-fixture-002')",
"expected_count": 0,
"comparison": "exact"
}
},
"expected_per_table_pre_state_for_safety_check": {
"missions": 1,
"waypoints": 2,
"map_objects": 3,
"media": 2,
"annotations": 2,
"detection": 2
},
"expected_total_round_trips": "between 6 and 9 (4 SELECT + 5 DELETE per the documented walk; allow ±1 for collapsed/skipped phases when chains are empty)"
}
@@ -0,0 +1,69 @@
{
"$comment": "Expected per-table delete counts and cascade order for FT-P-18 (waypoint cascade delete F4). Asserts that the SIBLING waypoint chain remains untouched.",
"input_fixture": "fixture_cascade_F4.sql",
"trigger": "DELETE /missions/22222222-0000-0000-0000-000000000004/waypoints/33333333-0000-0000-0000-00000000F4A1",
"expected_response": {
"status_code": 204,
"body_length": 0
},
"expected_cascade_order": [
"SELECT FROM media WHERE waypoint_id = WP1",
"SELECT FROM annotations WHERE media_id = ME1",
"DELETE FROM detection WHERE annotation_id = AN1",
"DELETE FROM annotations WHERE id = AN1",
"DELETE FROM media WHERE id = ME1",
"DELETE FROM waypoints WHERE id = WP1"
],
"expected_per_table_post_state_target_chain": {
"waypoints": {
"filter": "id = '33333333-0000-0000-0000-00000000F4A1'",
"expected_count": 0,
"comparison": "exact"
},
"media": {
"filter": "id = 'media-F4-target-001'",
"expected_count": 0,
"comparison": "exact"
},
"annotations": {
"filter": "id = 'anno-F4-target-001'",
"expected_count": 0,
"comparison": "exact"
},
"detection": {
"filter": "annotation_id = 'anno-F4-target-001'",
"expected_count": 0,
"comparison": "exact"
}
},
"expected_per_table_post_state_sibling_chain_must_remain": {
"waypoints": {
"filter": "id = '33333333-0000-0000-0000-00000000F4B2'",
"expected_count": 1,
"comparison": "exact"
},
"media": {
"filter": "id = 'media-F4-sibling-002'",
"expected_count": 1,
"comparison": "exact"
},
"annotations": {
"filter": "id = 'anno-F4-sibling-002'",
"expected_count": 1,
"comparison": "exact"
},
"detection": {
"filter": "annotation_id = 'anno-F4-sibling-002'",
"expected_count": 1,
"comparison": "exact"
}
},
"expected_per_table_pre_state_for_safety_check": {
"missions": 1,
"waypoints": 2,
"media": 2,
"annotations": 2,
"detection": 2
},
"expected_total_round_trips": "5 to 6 (2 SELECT + 4 DELETE for the target chain; mission row is NOT touched)"
}
@@ -0,0 +1,54 @@
-- Fixture: full F3 cascade chain rooted at one mission.
-- Used by: blackbox-tests.md FT-P-12, FT-N-04 (variant), resilience-tests.md NFT-RES-01, security-tests.md NFT-SEC-08 (variant)
-- Naming: post-rename target. Pre-rename code path runs the same DDL via Azaion.Flights.Database.DatabaseMigrator;
-- this file ASSUMES the schema is already in place (the missions container's startup runs the migrator).
--
-- Deterministic UUIDs so tests can assert against known IDs.
--
-- Chain shape:
-- 1 vehicle (V1)
-- 1 mission (M1) → references V1
-- 2 waypoints (WP1, WP2) → both reference M1
-- 2 media rows (ME1 ↔ WP1, ME2 ↔ WP2)
-- 2 annotations (AN1 ↔ ME1, AN2 ↔ ME2)
-- 2 detection rows (DT1 ↔ AN1, DT2 ↔ AN2)
-- 3 map_objects (MO1, MO2, MO3) → all reference M1
BEGIN;
-- Vehicle (1 row)
INSERT INTO vehicles (id, type, model, name, fuel_type, battery_capacity, engine_consumption, engine_consumption_idle, is_default)
VALUES ('11111111-0000-0000-0000-000000000001', 0, 'Bayraktar', 'BR-test', 1, 0, 5, 1, true);
-- Mission (1 row) — id M1
INSERT INTO missions (id, created_date, name, vehicle_id)
VALUES ('22222222-0000-0000-0000-000000000001', '2026-05-14T00:00:00Z', 'cascade-F3-fixture', '11111111-0000-0000-0000-000000000001');
-- Waypoints (2 rows) — ids WP1, WP2
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, waypoint_source, waypoint_objective, order_num, height) VALUES
('33333333-0000-0000-0000-000000000001', '22222222-0000-0000-0000-000000000001', 50.45, 30.52, NULL, 0, 0, 1, 100),
('33333333-0000-0000-0000-000000000002', '22222222-0000-0000-0000-000000000001', 50.46, 30.53, NULL, 0, 0, 2, 110);
-- Media (2 rows) — borrowed-table stubs; the test side-channel CREATEs these tables before the test class runs
-- IMPORTANT: media/annotations/detection are owned by sibling services in production; in tests, side-channel CREATEs them.
INSERT INTO media (id, waypoint_id) VALUES
('media-fixture-001', '33333333-0000-0000-0000-000000000001'),
('media-fixture-002', '33333333-0000-0000-0000-000000000002');
-- Annotations (2 rows)
INSERT INTO annotations (id, media_id) VALUES
('anno-fixture-001', 'media-fixture-001'),
('anno-fixture-002', 'media-fixture-002');
-- Detection (2 rows; uuid PK)
INSERT INTO detection (id, annotation_id) VALUES
('44444444-0000-0000-0000-000000000001', 'anno-fixture-001'),
('44444444-0000-0000-0000-000000000002', 'anno-fixture-002');
-- Map objects (3 rows; written by autopilot in production)
INSERT INTO map_objects (id, mission_id, h3_index, mgrs, lat, lon, class_num, label, size_width_m, size_length_m, confidence, object_status, first_seen_at, last_seen_at) VALUES
('55555555-0000-0000-0000-000000000001', '22222222-0000-0000-0000-000000000001', '8a2a107255dffff', '38UPV1234567890', 50.45, 30.52, 1, 'truck', 3.0, 6.0, 0.91, 0, '2026-05-14T00:00:01Z', '2026-05-14T00:00:02Z'),
('55555555-0000-0000-0000-000000000002', '22222222-0000-0000-0000-000000000001', '8a2a107255bffff', '38UPV1234567891', 50.46, 30.53, 2, 'armor', 4.0, 8.0, 0.88, 0, '2026-05-14T00:00:03Z', '2026-05-14T00:00:04Z'),
('55555555-0000-0000-0000-000000000003', '22222222-0000-0000-0000-000000000001', '8a2a107255affff', '38UPV1234567892', 50.47, 30.54, 1, 'truck', 3.0, 6.0, 0.93, 1, '2026-05-14T00:00:05Z', '2026-05-14T00:00:06Z');
COMMIT;
@@ -0,0 +1,39 @@
-- Fixture: scoped F4 cascade chain rooted at one waypoint, with a sibling waypoint that has its own chain
-- (so the test asserts the sibling chain is INTACT after deleting the target waypoint).
-- Used by: blackbox-tests.md FT-P-18, resilience-tests.md NFT-RES-02
--
-- Chain shape:
-- 1 vehicle (V1)
-- 1 mission (M1) → references V1
-- 2 waypoints:
-- WP1 (target) → 1 media (ME1) → 1 annotation (AN1) → 1 detection (DT1)
-- WP2 (sibling) → 1 media (ME2) → 1 annotation (AN2) → 1 detection (DT2)
-- No map_objects (F4 cascade does not touch map_objects per the documented walk).
BEGIN;
INSERT INTO vehicles (id, type, model, name, fuel_type, battery_capacity, engine_consumption, engine_consumption_idle, is_default)
VALUES ('11111111-0000-0000-0000-000000000004', 0, 'Bayraktar', 'BR-F4-test', 1, 0, 5, 1, false);
INSERT INTO missions (id, created_date, name, vehicle_id)
VALUES ('22222222-0000-0000-0000-000000000004', '2026-05-14T00:00:00Z', 'cascade-F4-fixture', '11111111-0000-0000-0000-000000000004');
-- Waypoints — WP1 is the delete target, WP2 is the sibling that must remain after delete
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, waypoint_source, waypoint_objective, order_num, height) VALUES
('33333333-0000-0000-0000-00000000F4A1', '22222222-0000-0000-0000-000000000004', 50.45, 30.52, NULL, 0, 0, 1, 100), -- WP1 target
('33333333-0000-0000-0000-00000000F4B2', '22222222-0000-0000-0000-000000000004', 50.46, 30.53, NULL, 0, 0, 2, 110); -- WP2 sibling
-- Media chain for both waypoints
INSERT INTO media (id, waypoint_id) VALUES
('media-F4-target-001', '33333333-0000-0000-0000-00000000F4A1'),
('media-F4-sibling-002', '33333333-0000-0000-0000-00000000F4B2');
INSERT INTO annotations (id, media_id) VALUES
('anno-F4-target-001', 'media-F4-target-001'),
('anno-F4-sibling-002', 'media-F4-sibling-002');
INSERT INTO detection (id, annotation_id) VALUES
('44444444-0000-0000-0000-00000000F4D1', 'anno-F4-target-001'),
('44444444-0000-0000-0000-00000000F4D2', 'anno-F4-sibling-002');
COMMIT;
@@ -0,0 +1,207 @@
# Expected Results — Azaion.Missions
> **Status**: derived-from-spec (autodev `/test-spec` Step 3 prerequisite, 2026-05-14).
> **Source**: every row below is grounded in `_docs/00_problem/acceptance_criteria.md` (AC-1…AC-10), `_docs/00_problem/input_data/data_parameters.md` (HTTP shapes), and `_docs/00_problem/restrictions.md`.
> **Naming convention**: rows describe the **post-rename target** (`/vehicles`, `/missions`, `Vehicle`, `Mission`, `VehicleType { Plane, Copter, UGV, GuidedMissile }`). Where today's pre-rename code diverges, the row carries a `today:` note. The B-tickets `AZ-544 / AZ-545 / AZ-546 / AZ-547 / AZ-548 / AZ-549 / AZ-550 / AZ-551` are the planned converger; the leftover index is `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`.
> **Input shape**: this is an HTTP API service, not a data-processing pipeline. "Input" rows describe HTTP requests (method + path + JWT claim + body / query); reference files only appear when a scenario needs a fixture row (e.g., a pre-existing mission row in the DB).
---
## Result Format Legend
| Result Type | When to Use | Example |
|-------------|-------------|---------|
| Exact value | Output must match precisely | `status_code: 200`, `body.IsDefault: true` |
| Tolerance range | Numeric output with acceptable variance | `latency: ≤ 50ms` |
| Threshold | Output must exceed or stay below a limit | `latency < 500ms` |
| Pattern match | Output must match a string/regex pattern | `body.message contains "Internal server error"` |
| File reference | Complex output compared against a reference file | `match expected_results/cascade_F3_walk.json` |
| Schema match | Output structure must conform to a schema | `body matches PaginatedResponse<Mission>` |
| Set/count | Output must contain specific items or counts | `body.length == 0`, `body.Items.length ≤ 20` |
| DB state | Side effect on persisted rows must hold post-call | `db.vehicles WHERE is_default=true count == 1` |
| Log assertion | Side effect on logger must hold post-call | `logger emits "Unhandled exception" with stack trace` |
## Comparison Methods
| Method | Description | Tolerance Syntax |
|--------|-------------|-----------------|
| `exact` | Actual == Expected | N/A |
| `numeric_tolerance` | abs(actual - expected) ≤ tolerance | `± <value>` |
| `threshold_min` | actual ≥ threshold | `≥ <value>` |
| `threshold_max` | actual ≤ threshold | `≤ <value>` |
| `regex` | actual matches regex pattern | regex string |
| `substring` | actual contains substring | substring |
| `json_diff` | structural comparison against reference JSON | diff tolerance per field |
| `set_contains` | actual output set contains expected items | subset notation |
| `set_equals` | actual output set equals expected exactly | set equality |
| `db_query` | result of a `SELECT` against a controlled test DB equals expected | exact / count |
| `file_reference` | compare against reference file in `expected_results/` | file path |
---
## Input → Expected Result Mapping
### AC-1 — Vehicle CRUD (`/vehicles`)
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 1.1 | `POST /vehicles` body `{ Type:0, Model:"Bayraktar", Name:"BR-01", FuelType:1, BatteryCapacity:0, EngineConsumption:5, EngineConsumptionIdle:1, IsDefault:false }`, JWT `permissions=FL` | Create non-default Plane | `status_code: 201`; body `Vehicle` with `Id` (UUID), `Type:0`, `Name:"BR-01"`, `IsDefault:false` (PascalCase per AC-8.1); `db.vehicles.count == prev+1` | exact (status, body fields), db_query (count) | N/A | N/A |
| 1.2 | Same as 1.1 but `IsDefault:true` against a DB containing one prior `vehicles` row with `is_default=true` | Create default — must demote prior default first (AC-1.2) | `status_code: 201`; new row has `IsDefault:true`; the prior default row now has `is_default=false`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true == 1` | exact (status), db_query (count, prior row state) | N/A | N/A |
| 1.3 | Same as 1.2 but inject a concurrent `INSERT vehicles (..., is_default=true)` between the service's `UPDATE … SET is_default=FALSE` and its `INSERT` | TOCTOU race window (AC-1.4) | `status_code: 201`; `SELECT COUNT(*) FROM vehicles WHERE is_default=true >= 2` is observable in at least one race interleaving | db_query (count) | N/A | N/A |
| 1.4 | `POST /vehicles/{id}/setDefault` body `{ IsDefault:true }` against `id` of a non-default row | Promote existing vehicle to default (AC-1.2) | `status_code: 200`; body `Vehicle` with `IsDefault:true`; previous default has `is_default=false`; default count == 1 | exact (status, body), db_query (count) | N/A | N/A |
| 1.5 | `GET /vehicles` no query, JWT `permissions=FL`, DB has 3 rows | List all vehicles (AC-1.5) | `status_code: 200`; body is JSON `array` (NOT `PaginatedResponse`); `body.length == 3`; PascalCase property names | exact (status, length), schema (array, not paginated), exact (case) | N/A | N/A |
| 1.6 | `GET /vehicles?name=BR&isDefault=true` against DB with `["BR-01" default, "BR-02" non-default, "MQ-9" default]` | Filter by name substring + is_default (AC-1.6) | `status_code: 200`; `body.length == 1`; `body[0].Name == "BR-01"` | exact (status, length, value) | N/A | N/A |
| 1.7 | `GET /vehicles?name=br` against DB with `"BR-01"` only | Case-sensitive name filter (AC-1.6) | `status_code: 200`; `body.length == 0` | exact (status, length) | N/A | N/A |
| 1.8 | `GET /vehicles/{id}` with `id` not in DB | Vehicle not found (AC-1.7) | `status_code: 404`; body matches `{ statusCode:404, message: <non-empty string> }` (camelCase by accidental match per AC-8.2) | exact (status), schema (envelope shape), exact (case) | N/A | N/A |
| 1.9 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | Vehicle in use → 409 (AC-1.8) | `status_code: 409`; body envelope `{ statusCode:409, message:<non-empty> }`; `db.vehicles WHERE id={id}` still exists (count==1) | exact (status, envelope shape), db_query | N/A | N/A |
| 1.10 | `DELETE /vehicles/{id}` against vehicle referenced by 0 missions | Vehicle deletable | `status_code: 204`; `db.vehicles WHERE id={id}` count == 0 | exact (status), db_query | N/A | N/A |
| 1.11 | `GET /vehicles` without `Authorization` header | Unauthenticated (AC-1.9, AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
| 1.12 | `GET /vehicles` with JWT having `permissions="OTHER"` | Wrong permission (AC-1.9, AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
### AC-2 — Mission create / read / update (`/missions`)
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 2.1 | `POST /missions` body `{ Name:"Recon-01", VehicleId:<existing>, CreatedDate:null }`, JWT `FL` | Create mission with default created date (AC-2.1) | `status_code: 201`; body `Mission` with server-assigned `Id`, `CreatedDate` set to a UTC timestamp within `now ± 5s`; `Name == "Recon-01"`; `VehicleId` echoes input | exact (status, fields), numeric_tolerance (CreatedDate ± 5s) | ±5s | N/A |
| 2.2 | `POST /missions` body `{ Name:"Recon-02", VehicleId:<random uuid>, CreatedDate:null }` | Vehicle not found (AC-2.2) | `status_code: 400` (today via `ArgumentException`; spec wants 404 — divergence carry-forward) | exact (status) | N/A | N/A |
| 2.3 | `GET /missions` no query, DB has 25 missions | Default pagination (AC-2.3) | `status_code: 200`; body matches `PaginatedResponse<Mission>` schema; `body.Page == 1`; `body.PageSize == 20`; `body.TotalCount == 25`; `body.Items.length == 20` | schema, exact (counts) | N/A | N/A |
| 2.4 | `GET /missions?page=2&pageSize=20` against same 25-row DB | Second page | `body.Page == 2`; `body.PageSize == 20`; `body.Items.length == 5` | exact (counts) | N/A | N/A |
| 2.5 | `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z` against DB with 3 January missions and 2 February missions | Date range filter | `body.TotalCount == 3`; `body.Items.length == 3` | exact (counts) | N/A | N/A |
| 2.6 | `GET /missions/{id}` with `id` not in DB | Not found (AC-2.4) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A |
| 2.7 | `PUT /missions/{id}` body `{ Name:"Recon-01-renamed", VehicleId:null }` against existing mission | Partial update — only Name (AC-2.5) | `status_code: 200`; `body.Name == "Recon-01-renamed"`; `body.VehicleId == <previous>` (preserved) | exact (status, fields) | N/A | N/A |
| 2.8 | `GET /missions/{id}` against mission with 2 waypoints | LinqToDB does NOT eager-load (AC-2.6) | `body.Vehicle == null`; `body.Waypoints` is `null` or `[]` (depending on JSON null serialization) | exact (null/empty) | N/A | N/A |
| 2.9 | `POST /missions` simulating TOCTOU: vehicle exists at check time, deleted before insert | TOCTOU FK race (AC-2.8) | `status_code: 500`; logger emits `LogError(ex, "Unhandled exception")` with `Npgsql.PostgresException` in the stack | exact (status), log_assertion (substring) | N/A | N/A |
| 2.10 | `GET /missions` without `Authorization` | Unauthenticated (AC-2.7, AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
### AC-3 — Mission cascade delete (`DELETE /missions/{id}`) — most critical
Test data fixtures live in `expected_results/fixture_cascade_F3.sql` (seed script that creates one mission with the full dependency chain across `map_objects`, `waypoints`, `media`, `annotations`, `detection`).
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 3.1 | Apply `fixture_cascade_F3.sql`, then `DELETE /missions/{seeded_id}`, JWT `FL` | Full cascade walk (AC-3.1) | `status_code: 204`; rows in `map_objects`, `waypoints`, `media`, `annotations`, `detection`, `missions` matching the seeded chain are all deleted; cascade order is `map_objects → detection → annotations → media → waypoints → missions` (validated via per-statement instrumentation in tests) | exact (status), db_query (each table count == 0 for seeded ids), file_reference (cascade order log) | N/A | `expected_results/cascade_F3_walk.json` |
| 3.2 | `DELETE /missions/{id}` with `id` not in DB | Mission not found before any cascade runs (AC-3.2) | `status_code: 404`; NO `DELETE` statement issued against `map_objects`, `waypoints`, etc. (validated via SQL query log instrumentation) | exact (status), log_assertion (no DELETE on dependency tables) | N/A | N/A |
| 3.3 | Apply `fixture_cascade_F3.sql`, drop `media` table from test DB, then `DELETE /missions/{id}` | Cascade fails mid-walk on missing dep table (AC-3.4) | `status_code: 500`; logger emits `Unhandled exception` with `relation "media" does not exist`; `db.missions WHERE id={id}` still exists (cascade NOT transaction-wrapped per AC-3.3, partial deletes remain) | exact (status), log_assertion (regex), db_query (target row remains) | N/A | N/A |
| 3.4 | Apply `fixture_cascade_F3.sql`, then `DELETE /missions/{id}` while a parallel `INSERT INTO map_objects … mission_id={id}` runs immediately after the service's `SELECT FROM map_objects` step | Orphan-row race (AC-3.7) | `status_code: 204`; `SELECT COUNT(*) FROM map_objects WHERE mission_id={id} >= 1` is observable in at least one race interleaving | db_query | N/A | N/A |
| 3.5 | `DELETE /missions/{id}` with `id` of a 1-waypoint mission against local PostgreSQL on the same device, no map_objects/media/annotations/detection rows | Latency target (AC-3.6) | end-to-end latency `≤ 50ms` (P50 across 100 invocations) | threshold_max | ≤ 50ms (P50) | N/A |
| 3.6 | After `B7+B9` migration ran, `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` | Tables removed (AC-3.5) | both queries return `NULL` | exact | N/A | N/A |
### AC-4 — Waypoint CRUD (`/missions/{id}/waypoints`)
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 4.1 | `GET /missions/{nonexistent}/waypoints`, JWT `FL` | Parent mission missing (AC-4.2) | `status_code: 404`; envelope `{ statusCode:404, message:<non-empty> }` | exact (status, envelope) | N/A | N/A |
| 4.2 | `GET /missions/{id}/waypoints` against mission with 5 waypoints having `OrderNum [3, 1, 2, 5, 4]` | Unpaginated, ordered (AC-4.3) | `status_code: 200`; body is JSON array; `body.length == 5`; `[w.OrderNum for w in body] == [1, 2, 3, 4, 5]` | exact (status, length, ordering) | N/A | N/A |
| 4.3 | `POST /missions/{id}/waypoints` body `{ GeoPoint:{Lat:50.45, Lon:30.52, Mgrs:null}, WaypointSource:0, WaypointObjective:0, OrderNum:1, Height:120 }` | Create waypoint with lat/lon | `status_code: 201`; body `Waypoint` with server-assigned `Id`; `body.GeoPoint.Lat == 50.45`; `body.Mgrs == null` (no auto-conversion today, divergent from spec — see data_parameters.md § 2.3) | exact (status, fields) | N/A | N/A |
| 4.4 | `PUT /missions/{id}/waypoints/{wpId}` body `{ GeoPoint:null, WaypointSource:1, WaypointObjective:1, OrderNum:2, Height:0 }` against waypoint that previously had `Height=120` | Full overwrite (AC-4.4) — every field replaced including `Height: 120 → 0` | `status_code: 200`; `body.Height == 0` (overwritten); `body.OrderNum == 2`; `body.GeoPoint == null` | exact (status, every field) | N/A | N/A |
| 4.5 | Apply `fixture_cascade_F4.sql` (waypoint with media→annotations→detection chain), then `DELETE /missions/{mid}/waypoints/{wpId}` | Scoped cascade (AC-4.5) | `status_code: 204`; rows for that waypoint's `detection`, `annotations`, `media`, `waypoints` are all deleted; rows belonging to OTHER waypoints in the same mission are untouched | exact (status), db_query (per table) | N/A | `expected_results/cascade_F4_walk.json` |
| 4.6 | `DELETE` as in 4.5 with `media` table dropped | Same NO-transaction caveat as AC-3.3 (AC-4.6) | `status_code: 500`; partial deletes remain | exact (status), db_query | N/A | N/A |
| 4.7 | `GET /missions/{id}/waypoints` without `Authorization` | Unauthenticated (AC-4.7) | `status_code: 401` | exact (status) | N/A | N/A |
### AC-5 — JWT bearer validation
JWT fixtures use `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256, claims include `permissions=FL` unless noted.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 5.1 | `GET /vehicles` without `Authorization` header | Missing token (AC-5.4) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.2 | `GET /vehicles` with `Authorization: Bearer <token signed by different secret>` | Invalid signature (AC-5.5) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.3 | `GET /vehicles` with token where `exp` is `now - 120s` (outside 1-min skew) | Expired (AC-5.6) | `status_code: 401` | exact (status) | N/A | N/A |
| 5.4 | `GET /vehicles` with token where `exp` is `now - 30s` (inside 1-min skew per AC-5.2) | Within skew | `status_code: 200` | exact (status) | N/A | N/A |
| 5.5 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions` claim absent | Missing claim (AC-5.8) | `status_code: 403` | exact (status) | N/A | N/A |
| 5.6 | `GET /vehicles` with valid HS256 signature + lifetime, `permissions == "ADMIN"` | Wrong claim value | `status_code: 403` | exact (status) | N/A | N/A |
| 5.7 | `GET /vehicles` with no `iss` and no `aud` claim, otherwise valid | `ValidateIssuer/ValidateAudience = false` (AC-5.3) | `status_code: 200` | exact (status) | N/A | N/A |
| 5.8 | Restart service with rotated `JWT_SECRET`, replay a previously valid token | Cross-rotation invalidation (AC-5.7) | `status_code: 401` | exact (status) | N/A | N/A |
### AC-6 — Service startup + schema migration
Bootstrap fixtures use a Postgres container started fresh per scenario.
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 6.1 | Start service with `DATABASE_URL=postgresql://u:p@h:5432/d` (URL form) | URL conversion (AC-6.1) | service binds `:8080`; `GET /health` returns `200`; logger does NOT emit a connection error | log_assertion (no error), exact (health 200) | N/A | N/A |
| 6.2 | Start service with `DATABASE_URL=Host=h;Database=d;Username=u;Password=p` (raw form) | Raw connection string accepted (AC-6.1) | same as 6.1 | log_assertion, exact | N/A | N/A |
| 6.3 | Start service against an empty `azaion` database, inspect schema after startup | Migrator creates 4 owned tables + 3 indexes (AC-6.4) | `SELECT to_regclass(t)` returns non-NULL for each of `vehicles, missions, waypoints, map_objects`; index list contains `ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id` | set_equals (table set), set_contains (index set) | N/A | N/A |
| 6.4 | Start service twice in a row against the same DB | Idempotency (AC-6.6) | second startup completes with same exit code as first; no `relation already exists` error in logs | exact (exit code), log_assertion (no error) | N/A | N/A |
| 6.5 | Pre-create `orthophotos` and `gps_corrections` tables, then start a post-B9 service | One-shot legacy drop (AC-6.5, AC-10.5) | both tables are absent after startup; `SELECT to_regclass('orthophotos')` and `SELECT to_regclass('gps_corrections')` both return NULL | exact | N/A | N/A |
| 6.6 | Start service with `DATABASE_URL` pointing at unreachable host | DB unreachable (AC-6.7) | process exits with non-zero exit code within `≤ 30s` | exact (non-zero), threshold_max (≤ 30s) | ≤ 30s | N/A |
| 6.7 | Start service against a postgres instance where the `azaion` database does NOT exist | DB missing (AC-6.8) | process exits with non-zero exit code; logger emits message containing Npgsql `3D000` | exact (non-zero), log_assertion (substring `3D000`) | N/A | N/A |
| 6.8 | Make any handler throw `InvalidOperationException`, observe response | `ErrorHandlingMiddleware` registered FIRST (AC-6.9) | response: `status_code: 409`; envelope is the camelCase `{ statusCode, message }`; logger captured stack | exact (status, envelope), log_assertion | N/A | N/A |
| 6.9 | Start service, run `curl localhost:8080` from inside container | Listens on port 8080 (AC-6.10) | TCP connect succeeds; `/health` returns `200` | exact | N/A | N/A |
### AC-7 — Health probe
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 7.1 | `GET /health` without `Authorization` (AC-7.1) | Anonymous health probe | `status_code: 200`; body equals `{ "status": "healthy" }` exactly (case-sensitive property names) | exact (status, body) | N/A | N/A |
| 7.2 | `GET /health` with PostgreSQL stopped | Probe is process-liveness only (AC-7.3) | `status_code: 200`; body equals `{ "status": "healthy" }` (no DB ping today) | exact | N/A | N/A |
| 7.3 | `GET /health` measured locally, 100 sequential calls | Latency target (AC-7.3) | P50 latency `≤ 10ms` | threshold_max | ≤ 10ms (P50) | N/A |
### AC-8 — Wire shape (HTTP contract)
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 8.1 | `GET /vehicles/{id}` with valid id, JWT `FL` | Entity body case (AC-8.1) | response body has top-level keys `Id, Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault` (PascalCase, NO `id`/`type`/etc.) | set_equals (key set, case-sensitive) | N/A | N/A |
| 8.2 | `GET /missions/{nonexistent}`, JWT `FL` | Error envelope case (AC-8.2) | response body has exactly the keys `statusCode, message` (lowercase `s`/`m`) | set_equals (key set, case-sensitive) | N/A | N/A |
| 8.3 | Same as 8.2 | Error envelope MUST NOT include spec's `errors` field today (AC-8.3) | response body MUST NOT contain key `errors` | exact (key absence) | N/A | N/A |
| 8.4 | `GET /missions/{nonexistent}` | KeyNotFoundException → 404 (AC-8.5) | `status_code: 404` | exact (status) | N/A | N/A |
| 8.5 | `POST /missions` with `VehicleId = <random uuid>` (existence check fails) | ArgumentException → 400 (AC-8.5) | `status_code: 400` | exact (status) | N/A | N/A |
| 8.6 | `DELETE /vehicles/{id}` against vehicle in use | InvalidOperationException → 409 (AC-8.5) | `status_code: 409` | exact (status) | N/A | N/A |
| 8.7 | Force a generic `Exception` (e.g., divide-by-zero in a handler) | Fallthrough → 500 + body redaction (AC-8.6) | `status_code: 500`; body equals `{ "statusCode":500, "message":"Internal server error" }` exactly; logger captures the stack via `LogError` | exact (status, body), log_assertion | N/A | N/A |
| 8.8 | `GET /missions?page=1&pageSize=10` against 5-mission DB | `PaginatedResponse<Mission>` PascalCase (AC-8.7) | response body has top-level keys `Items, TotalCount, Page, PageSize` (PascalCase) | set_equals (key set) | N/A | N/A |
### AC-9 — Authorization (cross-cutting)
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 9.1 | Each protected endpoint (`/vehicles`, `/missions`, `/missions/{id}/waypoints`) called with token having `permissions == "FL"` | Policy "FL" satisfies (AC-9.1, AC-9.4) | each call: `status_code``{200, 201, 204}` (no 401/403) | exact (status set) | N/A | N/A |
| 9.2 | Any protected endpoint called with `permissions == "fl"` (lowercase) or `"FLight"` | Hardcoded string mismatch (AC-9.2) | `status_code: 403` | exact (status) | N/A | N/A |
| 9.3 | `GET /health` with NO `Authorization` header | Health is exempt (AC-9.4 contrast) | `status_code: 200` | exact (status) | N/A | N/A |
### AC-10 — Operational invariants (verifiable observables)
| # | Input | Input Description | Expected Result | Comparison | Tolerance | Reference File |
|---|-------|-------------------|-----------------|------------|-----------|---------------|
| 10.1 | Force any handler to throw `Exception` | Stack trace logged, NOT returned (AC-10.3, AC-8.6) | logger output contains the type name of the thrown exception AND the file path of the throw site; HTTP body equals `{ "statusCode":500, "message":"Internal server error" }` (no stack in body) | log_assertion (substring), exact (body) | N/A | N/A |
| 10.2 | Apply `fixture_cascade_F3.sql`, then `DELETE /missions/{id}` with `media` table dropped | Cascade NOT transaction-wrapped (AC-3.3, AC-10.[8 in restrictions]) | `map_objects` rows for `mission_id` are deleted (work done before failure remains); only post-failure work is missing — `media`/`annotations`/`detection`/`waypoints`/`missions` rows still present | db_query (per table) | N/A | N/A |
| 10.3 | After `B9` migrator runs once on a device with legacy `orthophotos` and `gps_corrections` rows | One-shot destructive step (AC-10.5, AC-6.5) | both tables are absent post-startup; second startup leaves DB unchanged (idempotent because `IF EXISTS`) | exact, db_query | N/A | N/A |
---
## Expected Result Reference Files
The reference files below are required for cascade-walk and fixture-seeding scenarios. They live alongside this report under `_docs/00_problem/input_data/expected_results/`.
| File | Purpose | Used by |
|------|---------|---------|
| `cascade_F3_walk.json` | Cascade order + per-table delete-count expectations for AC-3.1 | 3.1 |
| `cascade_F4_walk.json` | Same for the waypoint-scoped F4 cascade | 4.5 |
| `fixture_cascade_F3.sql` | Seed script for AC-3 cascade scenarios (creates 1 mission with full chain across `map_objects`, `waypoints`, `media`, `annotations`, `detection`) | 3.1, 3.3, 3.4, 10.2 |
| `fixture_cascade_F4.sql` | Seed script for AC-4 cascade scenarios (single waypoint with media chain) | 4.5, 4.6 |
These reference files are **not yet produced** in this turn — they are listed here so the test-spec skill (Phase 1) can confirm coverage. Step 5 (Decompose Tests) and Step 6 (Implement Tests) will materialise them as concrete fixtures.
---
## Coverage Summary
| AC Group | # Test Inputs | Quantifiable Pass/Fail | Notes |
|----------|---------------|------------------------|-------|
| AC-1 Vehicle CRUD | 12 | 100% | 1 row covers TOCTOU race (1.3) |
| AC-2 Mission CRUD | 10 | 100% | 1 row covers TOCTOU race (2.9) |
| AC-3 Cascade delete F3 | 6 | 100% | Fixture-seed scenarios + latency P50 |
| AC-4 Waypoint CRUD F4 | 7 | 100% | Includes full-overwrite vs partial divergence (4.4) |
| AC-5 JWT validation | 8 | 100% | Skew, rotation, missing/invalid claim |
| AC-6 Startup + migration | 9 | 100% | Idempotency, B9 legacy drop, bootstrap failure modes |
| AC-7 Health probe | 3 | 100% | Anonymous, no DB ping, latency P50 |
| AC-8 Wire shape | 8 | 100% | PascalCase + camelCase divergence locked in (today) |
| AC-9 Authorization | 3 | 100% | Hardcoded `"FL"` typo coverage |
| AC-10 Operational invariants | 3 | 100% | Subset that is API-observable; non-observable rows (RTO/RPO, hardware) tracked under restrictions |
**Total**: 69 input rows; every row has at least one quantifiable comparison method. Cascade walk + bootstrap rows depend on test fixtures listed above; those will be created by the test implementation step.
## Open questions (carry-forward)
1. AC-3.6 latency target `<50ms` is documented for "local PostgreSQL on the same device" — the test environment must mirror this (PostgreSQL container on the same host as the service container, no inter-host network) for the threshold to be meaningful. Decision deferred to test-spec Phase 2 environment design.
2. AC-1.3 / AC-2.9 / AC-3.4 race-window scenarios require a controllable concurrency primitive (parallel client, instrumented transaction barrier). Deferred to test-spec Phase 2 environment design.
3. AC-5.7 secret-rotation scenario requires service restart between requests; this is "container-restart" semantics in production. Deferred to test-spec Phase 2.
+94
View File
@@ -0,0 +1,94 @@
# Problem — Azaion.Missions
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
> **Mode**: retrospective synthesis from the verified `_docs/02_document/` set + the canonical suite spec at `../../suite/_docs/02_missions.md`.
> **Forward-looking caveat**: same as `solution.md` § header — references to "the system" mean the post-rename, post-GPS-Denied-removal target. Today's source still uses pre-rename names; deltas tracked under Jira AZ-EPIC (AZ-539) children B4B12.
---
## What is this system?
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment: it is the local source of truth for the operator's vehicle inventory, mission plans, and ordered waypoints, and it is the orchestrator of the cross-service cascade-delete that keeps the rest of the device's edge stack consistent when missions or waypoints are removed.
It is one of ~6 backend services running side-by-side **on each customer device** (Jetson Orin / OrangePI / operator-PC). All edge services share **one local PostgreSQL** on the device; each migrates only the tables it owns. JWTs are minted by the central `admin` service and validated locally with a shared HMAC secret — `missions` never calls `admin` back.
## What problem does it solve?
When a human operator plans, runs, and tears down missions on the edge:
1. **Inventory** — the operator must register the vehicles they own (Plane / Copter / UGV / GuidedMissile) and pick a default for one-click mission setup.
2. **Mission planning** — the operator must define a named mission against a chosen vehicle and lay out an ordered set of waypoints (lat/lon or MGRS, with per-waypoint source / objective / height).
3. **Mission lifecycle** — when a mission or waypoint is deleted, every downstream artefact (media uploaded by `annotations`, AI annotations, AI detections, autopilot-emitted `map_objects`) MUST be cleaned up in FK-safe order so the local DB doesn't accumulate orphans.
4. **Cross-service cohesion** — sibling services (`autopilot`, `annotations`, detection pipeline, `gps-denied`, `ui`) must be able to read mission/waypoint data without round-trips to the central admin and without their own copies. The shared local DB is that contract.
5. **Local trust** — the operator's device may be intermittently offline from the central network. Authn/authz cannot depend on a live `admin` callback; tokens validate locally with a shared secret.
## Who are the users?
| Persona | Role | How they interact |
|---------|------|-------------------|
| **Operator** | Human running missions in the field | Through the React `ui` (REST + JWT). Sole human user of this service today |
| **`autopilot`** | Sibling edge service (consumes missions/waypoints, writes `map_objects`) | Reads `missions` / `waypoints` from the shared DB; writes `map_objects` to a table this service owns the schema for and cascade-deletes |
| **`annotations`** | Sibling edge service (owns `media` / `annotations` table schemas) | Indirect — `missions` cascade-deletes from `media` / `annotations` when missions/waypoints are removed |
| **Detection pipeline** | Sibling edge service (owns the `detection` table schema) | Indirect — same pattern as `annotations` |
| **`gps-denied`** *(post-B7)* | Sibling edge service (owns `orthophotos` + `gps_corrections`) | None at runtime — references `mission_id` / `waypoint_id` as plain GUIDs in its own tables; manages its own cleanup |
| **`admin`** | Central .NET service (token issuer) | One-way: issues JWTs that this service validates locally. `admin` outages do NOT take this service down (until tokens expire) |
There are **no application-level admin / superuser roles inside this service** — every protected endpoint is gated by the single `"FL"` permission. The role → permission matrix is suite-level (`../../suite/_docs/00_roles_permissions.md`).
## How does it work at a high level?
ASP.NET Core thin controller → service class → linq2db active-record over a per-HTTP-request scoped `AppDataConnection`. No repository abstraction; no in-process message queue or event bus; no background workers.
```mermaid
flowchart LR
ui[[Operator UI]] -- "REST + JWT" --> mc[MissionsController / VehiclesController]
mc --> svc[MissionService / VehicleService / WaypointService]
svc --> ado[AppDataConnection<br/>linq2db ITable&lt;T&gt;]
ado --> pg[(postgres-local)]
autopilot[[autopilot]] -- "DB read missions/waypoints<br/>DB write map_objects" --> pg
svc -. "cascade delete on mission/waypoint delete" .-> pg
```
Seven flows make up the runtime surface (`_docs/02_document/system-flows.md`):
- **F1** Vehicle CRUD
- **F2** Mission create / read / update
- **F3** Mission delete + **cross-service cascade** (the most critical flow; not transaction-wrapped today — ADR-006)
- **F4** Waypoint create / read / update / delete (delete is a scoped F3)
- **F5** JWT bearer validation (cross-cutting; local HS256 only)
- **F6** Service startup + idempotent schema migration
- **F7** Anonymous `GET /health` probe
## Cross-cutting contracts owned here
1. **JWT validation contract** — trust admin-issued tokens via shared HMAC secret (`JWT_SECRET`); reject everything else with `401`/`403`.
2. **Mission ownership graph & cascade-delete** — only place in the system that knows `mission → {map_objects, waypoints → media → annotations → detection}`.
3. **Suite-standard wire shapes** *(currently divergent — see `_docs/02_document/architecture.md` ADR-002)* — error envelope and `PaginatedResponse<T>` are shared with `annotations`, `admin`, `satellite-provider`.
## Out of scope for this service
- **GPS-Denied** (orthophoto upload, live-GPS, GPS correction) — separate `gps-denied` service after B7.
- **Token issuance** — `admin` mints tokens; this service only validates.
- **Detection / AI** — owned by the detection pipeline; this service only cascade-deletes orphaned rows on mission/waypoint delete.
- **Media storage** — `annotations` owns `media` (text PK + waypoint FK); this service only cascade-deletes.
- **Multi-instance HA** — exactly one container per device; horizontal scale-out explicitly not supported.
## Cross-reference index
| Concern | Where |
|---------|-------|
| Spec (canonical, post-rename) | `../../suite/_docs/02_missions.md` |
| Top-level architecture (envelope, pagination, topology) | `../../suite/_docs/00_top_level_architecture.md` |
| Authoritative ER diagram | `../../suite/_docs/00_database_schema.md` |
| Roles & `FL` permission origin | `../../suite/_docs/00_roles_permissions.md` |
| GPS-Denied (separate service) | `../../suite/_docs/11_gps_denied.md` |
| Verified architecture (this repo) | `_docs/02_document/architecture.md` |
| Verified system flows (this repo) | `_docs/02_document/system-flows.md` |
| Glossary (confirmed-by-user) | `_docs/02_document/glossary.md` |
| Verification log (drift mapping) | `_docs/02_document/04_verification_log.md` |
| Solution (retrospective) | `_docs/01_solution/solution.md` |
| Restrictions (retrospective) | `_docs/00_problem/restrictions.md` |
| Acceptance criteria (retrospective) | `_docs/00_problem/acceptance_criteria.md` |
| Input data | `_docs/00_problem/input_data/data_parameters.md` |
| Security approach | `_docs/00_problem/security_approach.md` |
| Rename leftover (Jira index) | `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` |
+83
View File
@@ -0,0 +1,83 @@
# Restrictions — Azaion.Missions
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
> Each restriction below is grounded in code, configuration, or Dockerfile evidence — none are aspirational. References point to the artefact that establishes the constraint.
---
## Hardware restrictions
| # | Restriction | Evidence |
|---|-------------|----------|
| H1 | Service runs on operator-owned edge devices (Jetson Orin / OrangePI / operator-PC), one container per device | `Dockerfile` multi-arch; `../../suite/_docs/00_top_level_architecture.md` § Edge Tier |
| H2 | Multi-arch container — ARM64 dominant (Jetson / OrangePI), AMD64 supported (operator-PC) | `Dockerfile` `--platform=$BUILDPLATFORM`, `dotnet publish --os linux --arch $arch`; `.woodpecker/build-arm.yml` tag suffix `-arm` |
| H3 | Vertical scale only — exactly one instance per device, no horizontal scale-out | `_docs/02_document/architecture.md` § 3 Deployment Model; suite arch doc § Edge Tier |
| H4 | No managed cloud — every deployment is on customer-owned hardware | suite arch doc § Edge Tier |
| H5 | Watchtower handles container restarts; `flight-gate` prevents container restart mid-mission | suite arch doc § Edge Tier; `_docs/02_document/architecture.md` § 6 Availability |
| H6 | Resource limits not enforced inside the container; device-level cgroups / docker compose limits set at suite level | `Dockerfile` (no `--memory` / cpu); suite `_infra/_compose/` |
## Software restrictions
| # | Restriction | Evidence |
|---|-------------|----------|
| S1 | Language: C#; runtime: .NET 10 (`net10.0`) | `Azaion.Flights.csproj` (post-B5: `Azaion.Missions.csproj`) |
| S2 | Web framework: ASP.NET Core (`Microsoft.NET.Sdk.Web`) | csproj |
| S3 | Data access library: linq2db `6.2.0` | csproj `<PackageReference Include="linq2db" Version="6.2.0" />` |
| S4 | Database driver: Npgsql `10.0.2` | csproj |
| S5 | Auth library: `Microsoft.AspNetCore.Authentication.JwtBearer` `10.0.5` | csproj |
| S6 | Swagger / OpenAPI: Swashbuckle `10.1.5`, mounted unconditionally (NOT gated on `IsDevelopment()`) — ADR-005 carry-forward | csproj + `Program.cs` |
| S7 | Database engine: PostgreSQL (no other DB engines supported) | `Program.cs` `UsePostgreSQL`; suite arch doc § Database Topology |
| S8 | One csproj, one root namespace (`Azaion.Missions.*` post-B5) — components are logical groupings, not compilation units | csproj; `_docs/02_document/architecture.md` ADR-008 |
| S9 | No `src/` directory — project sits at the repo root | repo layout; `_docs/02_document/00_discovery.md` § Repository Layout |
| S10 | Layer-organized layout (`Controllers/`, `Services/`, `DTOs/`, `Enums/`, `Auth/`, `Middleware/`, `Database/` at repo root) | repo layout; `_docs/02_document/module-layout.md` |
| S11 | No automated tests today (no `tests/` directory; no test sibling project) | repo layout; `_docs/02_document/00_discovery.md` § Test Layout |
| S12 | No migration tool — schema bootstrap via raw `CREATE TABLE IF NOT EXISTS` + one-shot B9 `DROP TABLE IF EXISTS` block | `Database/DatabaseMigrator.cs`; ADR-004 |
| S13 | No in-process message queue, no event bus, no RPC — components communicate via direct C# calls registered in DI | `_docs/02_document/architecture.md` § 5; `Program.cs` |
| S14 | Tables OWNED by this service (post-B7+B9): `vehicles`, `missions`, `waypoints`, `map_objects` (4 owned). 3 borrowed read-only stubs (`media`, `annotations`, `detection`) | `Database/DatabaseMigrator.cs`; `Database/AppDataConnection.cs`; `_docs/02_document/data_model.md` |
| S15 | `gps-denied` is decoupled by design — no runtime call in either direction; `gps-denied` references `mission_id` / `waypoint_id` as plain GUIDs in its own tables | ADR-007 |
## Environment / configuration restrictions
| # | Restriction | Evidence |
|---|-------------|----------|
| E1 | Two required env vars at runtime: `DATABASE_URL`, `JWT_SECRET` | `Program.cs` `Environment.GetEnvironmentVariable` |
| E2 | `DATABASE_URL` accepts either a `postgresql://user:pass@host:port/db` URL OR a raw Npgsql connection string (local helper `ConvertPostgresUrl`) | `Program.cs` `ConvertPostgresUrl` |
| E3 | Hardcoded development fallbacks: `JWT_SECRET=development-secret-key-min-32-chars!!`, `DATABASE_URL=Host=localhost;Database=azaion;Username=postgres;Password=changeme` (NOT gated on `IsDevelopment()`) — ADR-005 carry-forward; production deploys MUST override | `Program.cs` |
| E4 | `JWT_SECRET` is shared across `admin` + every backend service on the same edge device — rotation requires a coordinated re-deploy across all of them | `_docs/02_document/components/05_identity/description.md`; suite arch doc |
| E5 | Container `EXPOSE 8080`; edge compose maps host port `5002:8080` | `Dockerfile`; suite `_infra/_compose/` |
| E6 | Image tag: `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` post-B10 (was `azaion/flights:*-arm` pre-B10) | `.woodpecker/build-arm.yml` (post-B10) |
| E7 | Entrypoint: `dotnet Azaion.Missions.dll` post-B5 (was `Azaion.Flights.dll` pre-B5) | `Dockerfile` (post-B5) |
| E8 | No environment-specific overrides in `appsettings.*.json` — single config flow via env vars | `Program.cs`; no `appsettings.Production.json` in repo |
| E9 | CORS: `AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in **all** environments (assumed safe behind suite reverse proxy) | `Program.cs` |
| E10 | TLS termination is the suite reverse proxy's responsibility — container exposes plain HTTP on `:8080` | `Dockerfile`; suite arch doc |
## Operational restrictions
| # | Restriction | Evidence |
|---|-------------|----------|
| O1 | Migrator runs at every process start; idempotent (`IF NOT EXISTS`); B9 adds a one-shot `DROP TABLE IF EXISTS orthophotos / gps_corrections` block for fielded legacy devices | `Database/DatabaseMigrator.cs` |
| O2 | `flight-gate` (suite-level) is the ONLY orchestration that prevents restart mid-mission; no Kubernetes | suite arch doc § Edge Tier |
| O3 | No version table; the migrator runs every startup | `Database/DatabaseMigrator.cs` |
| O4 | Single Woodpecker CI job per repo: docker build + push on `[dev, stage, main]` branches; no test, no security scan, no migration check | `.woodpecker/build-arm.yml` |
| O5 | No structured logging (Serilog / Seq) — `LogError(ex, "Unhandled exception")` is the only application-level log | `Middleware/ErrorHandlingMiddleware.cs`; `_docs/02_document/architecture.md` § 7 |
| O6 | No correlation ID, no per-request audit trail, no per-user attribution (JWT user-id claim parsed but not consumed) | `Auth/JwtExtensions.cs`; `_docs/02_document/components/05_identity/description.md` |
| O7 | Health endpoint: `GET /health` returns `{ status: "healthy" }` with no DB ping (process-liveness only) | `Program.cs` `MapGet("/health")` |
| O8 | Cascade-delete is **NOT** transaction-wrapped today (ADR-006) — partial failure leaves orphan rows in `media` / `annotations` / `detection` / `map_objects` | `Services/FlightService.cs` (post-B6: `MissionService.cs`); `Services/WaypointService.cs` |
| O9 | Each backend service is responsible for its own table migrations; if `annotations` is absent at deploy time, the cascade-delete walk fails on `relation does not exist` (abnormal edge deployment) | `_docs/02_document/components/02_mission_planning/description.md` Caveats #6 |
| O10 | One-instance-per-device constraint means session state, in-memory caches, and rate limits are NOT cluster-aware (none of these are implemented today either) | `Program.cs`; suite arch doc |
## Out-of-scope (NOT this service's responsibility)
| Concern | Where it lives | Why it's not here |
|---------|----------------|-------------------|
| Token issuance (sign / mint) | `admin` (central .NET service) | Local validation only; offline-tolerant edge design |
| User CRUD, role assignment | `admin` + `../../suite/_docs/00_roles_permissions.md` | Suite-level concern |
| Media storage / upload | `annotations` (sibling edge service) | `annotations` owns the table schema |
| AI annotation rules | `annotations` | Schema and behaviour both owned by `annotations` |
| Object detection / class definitions | Detection pipeline (sibling edge service) | Pipeline owns the `detection` table |
| `map_objects` write path | `autopilot` (sibling edge service) | This service owns the schema + cascade-delete only |
| Orthophoto / live-GPS / GPS correction | `gps-denied` (separate service after B7) | ADR-007 |
| TLS / HTTPS termination | Suite reverse proxy | `_docs/02_document/architecture.md` § 7 |
| Schema rename / column drop / type change | Future migration tool (ADR-004 carry-forward) | Today's `IF NOT EXISTS` migrator can't reshape existing schema; B9's `DROP TABLE IF EXISTS` is the single explicit destructive step |
| `iss` / `aud` JWT validation | Suite-level remediation (CMMC L2 row 3, AZ-487 / AZ-494) | Out of this Epic; consistent with shared-secret model today |
| camelCase wire-shape migration | Suite-wide cutover (ADR-002 carry-forward) | All-or-nothing; UI + autopilot consume PascalCase today |
+132
View File
@@ -0,0 +1,132 @@
# Security Approach — Azaion.Missions
> **Status**: derived-from-code (autodev `/document` Step 6, 2026-05-14).
> All claims below trace to actual code, configuration, or a tracked suite-level finding. Items called out as "currently divergent" are intentional carry-forward — see `_docs/02_document/architecture.md` § 8 ADRs and `00_discovery.md` § Spec ↔ Code Divergences.
---
## 1. Authentication
**Mechanism**: JWT bearer (HS256) with **local validation only** — this service never calls back to the issuing `admin` service.
**Trust model**: a single shared HMAC secret (`JWT_SECRET`) is provisioned to `admin` (issuer) and to every backend service on each edge device (validators). Rotation requires a coordinated re-deploy across all of them.
**Validation parameters** (`Auth/JwtExtensions.cs`):
| Parameter | Value | Notes |
|-----------|-------|-------|
| Algorithm | HS256 (`SymmetricSecurityKey(UTF-8(JWT_SECRET))`) | Symmetric → asymmetric switch is suite-wide concern, not in this Epic |
| `ValidateLifetime` | `true` | Tokens with `exp` in the past are rejected |
| `ClockSkew` | `TimeSpan.FromMinutes(1)` | Tighter than .NET's 5-min default |
| `ValidateIssuer` | **`false`** | Known CMMC L2 finding (suite-tracked AZ-487/AZ-494); consistent with shared-secret trust |
| `ValidateAudience` | **`false`** | Same finding as above |
| `ValidateIssuerSigningKey` | `true` | Always required when `ValidateLifetime`/`ValidateIssuer` are set explicitly |
**Failure outcomes**:
| Condition | HTTP code |
|-----------|-----------|
| Missing `Authorization` header | 401 |
| Invalid signature | 401 |
| Expired token (with 1-min skew) | 401 |
| Token signed with old `JWT_SECRET` after rotation | 401 (until coordinated re-deploy + re-login) |
| Valid signature + lifetime, but missing `permissions=FL` claim | 403 |
**`admin` outage**: tokens issued before the outage continue to validate locally. This service does **not** require `admin` to be reachable for any flow. Once issued tokens expire, new logins fail at `admin`'s end (UI concern), but this service stays up.
## 2. Authorization
**Single named policy**: `"FL"`. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy is satisfied by a `permissions` claim with value `"FL"`.
**Role → permission matrix** is suite-level (`../../suite/_docs/00_roles_permissions.md`); this service does NOT enforce roles, only the `FL` permission.
**No per-method authz**: every protected endpoint has the same gate. There is no notion of "read-only operator" vs "full-access operator" inside this service.
**Hardcoded policy name carries legacy wording**: the string `"FL"` (originally "Flight") survives the rename to `missions`. Renaming the permission code is a fleet-wide auth change (would invalidate every issued token until new ones are minted) and is **NOT** in this Epic. Tracked as a TODO in `../../suite/_docs/00_roles_permissions.md`.
**Typo risk**: the `"FL"` string is repeated in feature controllers as a raw string. A typo silently turns into a permanent 403 with no compile-time detection. Mitigation: code review + the `module-layout.md` § Verification Needed #4 entry.
**No per-user attribution / audit**: the JWT's `sub` / user-id claim is parsed by `JwtBearerHandler` into `ClaimsPrincipal`, but nothing in this service consumes it. Logs are timestamp-only — incident reconstruction requires correlation by request time, not by user.
## 3. Data protection
**At rest**: PostgreSQL on-disk encryption is the device-level concern (suite-level, NOT this service). This service does NOT encrypt data at the column level.
**In transit**:
- The container `EXPOSE 8080` is **plain HTTP**. TLS termination is handled by the suite's edge reverse proxy (per `../../suite/_docs/00_top_level_architecture.md`).
- No `app.UseHttpsRedirection()` in this service. If the reverse proxy is misconfigured or absent, traffic between the operator UI and this service may be cleartext on the local edge network.
**Secrets management**:
- `JWT_SECRET` and `DATABASE_URL` are env vars (with hardcoded dev fallbacks). See § 6 below.
- No secret manager (Vault, AWS SM, K8s Secrets) — secrets are baked into the device's docker compose env at provisioning time.
- No runtime gate prevents startup with the dev fallback in production (ADR-005 carry-forward).
## 4. Input validation
**None at the application layer**. No `[Required]`, no `[Range]`, no min-length attributes; no custom validators. The following are all accepted by ASP.NET Core model binding without rejection:
| Bad input | Accepted today |
|-----------|---------------|
| `CreateVehicleRequest.Name = ""` | yes |
| `CreateVehicleRequest.BatteryCapacity = -1` | yes |
| `CreateVehicleRequest.Type = (VehicleType)999` | yes — int casts to enum without range check |
| `CreateWaypointRequest.OrderNum = -1` | yes |
| `CreateWaypointRequest.GeoPoint = null` (or all three of `Lat`/`Lon`/`Mgrs` null) | yes |
| `GetMissionsQuery.Page = -1` / `PageSize = 1_000_000` | yes — no bounds |
This is a **carry-forward concern** — input-shape testing is not a security gate today; the threat surface is mitigated by the closed edge network and authenticated single-operator workflow. Tightening is on the autodev backlog (Phase B feature cycle).
## 5. CORS
**Open in every environment**: `AllowAnyOrigin / AllowAnyMethod / AllowAnyHeader` in `Program.cs`. Spec does not mandate a CORS policy.
The closed edge network behind the suite reverse proxy is the deployment-shape mitigation. Worth confirming on the **first production rollout** that the upstream proxy whitelists origins; if not, this is a finding to surface.
## 6. Production-deploy footguns
These are explicit security-relevant risks the code carries today, all tracked at the suite level or as carry-forward:
| Footgun | Where | Mitigation |
|---------|-------|------------|
| **Dev fallback for `JWT_SECRET`** silently accepted in production if env var unset | `Program.cs` (no `IsDevelopment` gate, ADR-005) | Suite-level remediation pending; recommend "fail-fast at startup if `JWT_SECRET` is unset OR equals the well-known dev fallback" |
| **Dev fallback for `DATABASE_URL`** silently accepted in production if env var unset | `Program.cs` | Same pattern as `JWT_SECRET`; misconfigured deploy hits localhost Postgres on the device, which usually doesn't exist → process exits, but the failure mode is loud (crash) not silent |
| **Swagger UI mounted unconditionally** | `Program.cs` (no `IsDevelopment` gate, ADR-005) | Reverse-proxy-level allowlist on `/swagger` is the suite-level mitigation; verify on first production rollout |
| **CORS `AllowAnyOrigin/Method/Header`** in production | `Program.cs` | Reverse-proxy origin whitelist is the suite-level mitigation |
| **No HTTPS redirection** | `Program.cs` (no `app.UseHttpsRedirection()`) | Reverse proxy enforces TLS upstream |
| **Stack trace logged for unhandled 500s** | `Middleware/ErrorHandlingMiddleware.cs` `LogError(ex, ...)` | Stack is logged only — NOT returned in the HTTP response body (the 500 body is the generic `"Internal server error"` message from middleware) |
| **JWT `iss`/`aud` validation disabled** | `Auth/JwtExtensions.cs` | CMMC L2 row 3 finding; tracked at suite level under AZ-487 / AZ-494 |
| **Cascade-delete is NOT transaction-wrapped** (data-integrity, not auth) | `Services/MissionService.cs`, `Services/WaypointService.cs` (ADR-006) | One-line fix queued; recommended to land with B6 |
| **Hardcoded permission string `"FL"`** in feature controllers | `Controllers/{Vehicles,Missions}Controller.cs` | Risk: typo silently turns into permanent 403; mitigation by code review + `module-layout.md` |
| **Permission code `"FL"` retains legacy "Flight" wording** post-rename | `Auth/JwtExtensions.cs` | Fleet-wide auth change deferred (not in this Epic); TODO in `../../suite/_docs/00_roles_permissions.md` |
## 7. Audit logging
**None at the application level today.** The only structured log emitted by application code is `_logger.LogError(ex, "Unhandled exception")` in `ErrorHandlingMiddleware` for 500s. There is:
- **No correlation ID** per request
- **No per-user attribution** (the JWT user-id claim is not consumed)
- **No security-event log** (auth failures are logged by `JwtBearerHandler` at default ASP.NET Core levels — typically Information, not surfaced as a dedicated audit channel)
- **No data-access audit** (writes/deletes go directly through linq2db with no wrapper that emits an audit row)
Production incident response on this service today requires grep-by-timestamp correlation against the operator UI's logs and `admin`'s issuance logs.
## 8. Threat model summary (one-paragraph)
The deployment shape — closed edge network, single operator per device, suite reverse proxy enforcing TLS and origin allowlisting upstream, Watchtower restart on crash — is the **primary defence-in-depth layer** for everything not handled by HS256 JWT validation and the `FL` permission gate. The known weak points (dev fallbacks not gated on `IsDevelopment()`, no input validation, no application-level audit log, `iss`/`aud` not validated) are documented and tracked, with the most critical ones (CMMC L2 row 3, default-vehicle race) under suite-level or B-ticket Jira IDs. This Epic (rename + GPS-Denied removal) does **not** change the security posture; it preserves every current invariant.
## 9. References
| Concern | File |
|---------|------|
| Auth registration | `Auth/JwtExtensions.cs` |
| Authorization attribute usage | `Controllers/AircraftsController.cs` (post-B6: `VehiclesController.cs`), `Controllers/FlightsController.cs` (post-B6: `MissionsController.cs`) |
| Error envelope (no stack-leak) | `Middleware/ErrorHandlingMiddleware.cs` |
| Env var resolution + dev fallbacks | `Program.cs` |
| CMMC L2 scorecard | `../../suite/_docs/05_security/cmmc_l2_scorecard.md` |
| Roles & `FL` permission origin | `../../suite/_docs/00_roles_permissions.md` |
| ADR-005 (Swagger + dev fallbacks) | `_docs/02_document/architecture.md` § 8 |
| ADR-002 (PascalCase wire shape) | `_docs/02_document/architecture.md` § 8 |
| Component identity description | `_docs/02_document/components/05_identity/description.md` |
| Component http-conventions description | `_docs/02_document/components/06_http_conventions/description.md` |
+217
View File
@@ -0,0 +1,217 @@
# Solution — Azaion.Missions
> **Status**: derived-from-code (autodev `/document` Step 5, 2026-05-14).
> **Mode**: retrospective synthesis from the verified `_docs/02_document/` set.
> **Forward-looking caveat**: this document describes the **post-rename, post-GPS-Denied-removal** target the documentation already reflects. Today's source still uses `Azaion.Flights.*`, `Aircraft*`/`Flight*`/`Orthophoto*`/`GpsCorrection*` filenames, and `[Route("aircrafts"|"flights")]`. The implementation gap is tracked under Jira AZ-EPIC (AZ-539) children B4B12; the doc-vs-code reconciliation table lives in `_docs/02_document/04_verification_log.md` § 0. References to "the implemented solution" in this document mean the code as it exists today **plus** the deltas closed by B4B12.
---
## 1. Product Solution Description
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of an Azaion deployment — vehicle inventory (Plane / Copter / UGV / GuidedMissile), mission plans, ordered waypoints, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed.
**Runtime topology**: exactly one container per device (Jetson Orin / OrangePI / operator-PC), co-located with `annotations`, the detection pipeline, `autopilot`, `gps-denied`, and the React `ui`. All edge services share **one local PostgreSQL** on the device; each migrates and writes only the tables it owns. JWTs are minted by the central `admin` service and validated locally with a shared HMAC secret — `missions` never calls back.
### Component interaction (high-level)
```mermaid
flowchart LR
ui[[Operator UI]]
admin[[admin service<br/>JWT issuer]]
autopilot[[autopilot]]
annotations[[annotations]]
detection[[detection pipeline]]
gps[[gps-denied service]]
subgraph missions["missions (this service, one .NET process)"]
direction TB
h07[07_host<br/>Program.cs / DI / startup]
c01[01_vehicle_catalog]
c02[02_mission_planning]
p04[04_persistence<br/>AppDataConnection + Migrator]
i05[05_identity<br/>JWT bearer + FL policy]
h06[06_http_conventions<br/>error envelope + pagination]
end
pg[(postgres-local<br/>shared per device)]
ui -- "REST + JWT" --> c01
ui -- "REST + JWT" --> c02
admin -. "shared HMAC secret (token only)" .-> i05
c01 --> p04
c02 --> p04
c02 -. "cross-service cascade delete" .-> annotations
c02 -. "cross-service cascade delete" .-> detection
autopilot <-- "DB read missions/waypoints<br/>DB write map_objects" --> pg
p04 --> pg
annotations <--> pg
detection <--> pg
gps -. "no runtime coupling<br/>(GUID refs only)" .-> pg
h07 --> c01
h07 --> c02
h07 --> p04
h07 --> i05
h07 --> h06
```
The HTTP surface is summarised in `_docs/02_document/system-flows.md` (7 flows, F1F7); the per-component HTTP routes are listed in `_docs/02_document/components/0[1,2]_*/description.md`.
---
## 2. Architecture (as implemented)
The dominant pattern is **thin ASP.NET Core controller → service class → linq2db active-record over a per-request scoped `DataConnection`**, with **no repository abstraction** and **no in-process message queue / event bus**. ADR rationale (ADR-001 .. ADR-008) is in `_docs/02_document/architecture.md` § 8.
### 2.1 Per-component solution table
| # | Component | Solution (what it does) | Tools / libs | Advantages | Limitations | Requirements satisfied | Security | Cost | Fit |
|---|-----------|-------------------------|--------------|------------|-------------|------------------------|----------|------|-----|
| 01 | `01_vehicle_catalog` | Vehicle CRUD + `is_default` exclusivity. Controller `[Authorize(Policy="FL")]``VehicleService``ITable<Vehicle>` | ASP.NET Core, linq2db `ITable<Vehicle>`, `[Authorize]` | Single owner of the inventory abstraction; same exact pattern as `02_mission_planning` so engineers context-switch cheaply | "Exactly one default" is enforced by clear-then-set without a transaction → race window (B12 decision pending); no input validation on `Name`/`BatteryCapacity` (carry-forward) | Spec § 6.1 (Vehicle Catalog), suite roles `FL` | `[Authorize(Policy="FL")]` on every action; no per-method authz | One service file + one controller (~190 LoC together) | **Good** — matches operator-paced load, vertical scale only |
| 02 | `02_mission_planning` | Mission + Waypoint CRUD + the **cross-service cascade-delete walk**. Existence-checks `vehicle_id` on create/update; paginates `GET /missions` (the only paginated endpoint). | ASP.NET Core, linq2db, `PaginatedResponse<T>` (`06_http_conventions`) | One canonical place that knows the full mission ownership graph; cascade walks `map_objects → media → annotations → detection → waypoints → missions` in FK order | **Cascade is NOT transaction-wrapped** (ADR-006) → partial failure leaves orphans; `UpdateWaypoint` is a full overwrite even though DTO looks partial; `vehicle_id` missing returns `400` (spec wants `404`); LinqToDB does not eager-load `[Association]` so `Vehicle` and `Waypoints` serialize null/empty | Spec § 6.2 (Mission Planning + Waypoints), spec § cascade contract | `[Authorize(Policy="FL")]` on every action; **no audit log**, no correlation id | Two service files + one controller (~370 LoC together); sequential I/O (47 round-trips per cascade) — single-digit ms typical against local Postgres | **Acceptable today; will need transaction wrap (one-line) before SLO commitments** |
| 04 | `04_persistence` | `AppDataConnection : DataConnection` exposes `ITable<T>` for every persisted entity (4 owned post-B7+B9 + 3 borrowed read-only stubs). `DatabaseMigrator` runs `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` at startup; B9 adds a one-shot `DROP TABLE IF EXISTS orthophotos / gps_corrections` for fielded devices | linq2db 6.2.0, Npgsql 10.0.2, raw `Execute` for DDL | Lightweight; no migration tool dependency; idempotent every restart; `ITable<T>` lets cross-component reads/cascades stay typed | No schema versioning; column drops / type changes need manual SQL or a future migration tool; no connection-pool tuning beyond Npgsql defaults | Spec § database schema, suite ER diagram (post-B7) | DB credentials are env-driven (`DATABASE_URL`); no column-level encryption; relies on PG-level access control | One file for the connection (~70 LoC) + one for the migrator (~120 LoC post-B9) | **Good for current schema scale (4 owned tables)**; will become limiting when schema starts evolving frequently |
| 05 | `05_identity` | `JwtExtensions.AddJwtAuth` registers `JwtBearer` with HMAC-SHA256 + the named policy `"FL"` (1-min clock skew). Validation is local; this service never calls `admin` | `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.5, `SymmetricSecurityKey` | `admin` outage does NOT take this service down (until tokens expire); zero-trip auth = lowest possible auth latency | `iss` / `aud` validation **disabled** (CMMC L2 row 3, AZ-487 / AZ-494 — suite-tracked, NOT in this Epic); the policy code `"FL"` retains the legacy "Flight" wording even after the service rename (fleet-wide auth change deferred); user-id claim is parsed but **not consumed** anywhere (no per-user audit) | Spec § auth, `../../suite/_docs/00_roles_permissions.md` | Shared HMAC secret (`JWT_SECRET`); rotation requires coordinated re-deploy across every backend that shares the secret | One file (~60 LoC) | **Good for the deployment shape (closed edge network behind a reverse proxy)**; `iss`/`aud` gap is a documented and tracked finding |
| 06 | `06_http_conventions` | `ErrorHandlingMiddleware` (global exception → JSON envelope) + `PaginatedResponse<T>` + the **dead** `ErrorResponse` DTO | ASP.NET Core middleware, `System.Text.Json` (defaults) | Single chokepoint for HTTP wire shape — error mapping is uniform across components | **Two divergences from the suite spec carry forward** (ADR-002): entity/DTO bodies are PascalCase (no `JsonNamingPolicy.CamelCase`); error envelope misses spec's `errors` field. The error envelope IS already camelCase by accidental match (anonymous-object literal). The `ErrorResponse` DTO is dead on the wire and has the wrong shape (`List<string>?` instead of spec's `object?` keyed by field name) | Spec § Error Response Format, § Pagination | `LogError(ex, ...)` only — no PII redaction (none in payload today); fallback `500` body shows the generic message, NOT the stack trace (logged only) | One middleware file + two DTO files (~80 LoC together) | **Acceptable until the suite-wide camelCase migration**; cutover is all-or-nothing because UI + autopilot consume PascalCase today |
| 07 | `07_host` | `Program.cs` composition root: env → connection string adapter, JWT registration, scoped DI for `AppDataConnection` + service classes, run migrator at startup, mount middleware in correct order, `MapGet("/health")`, mount Swagger | ASP.NET Core minimal host APIs | One file you can read top-to-bottom in one sitting; environment-fallback adapter (`ConvertPostgresUrl`) makes `dotnet run` zero-config in dev | **Swagger UI + dev fallbacks are NOT gated on `IsDevelopment()`** (ADR-005) — a misconfigured production deploy silently boots with `JWT_SECRET=development-secret-key-min-32-chars!!`; CORS is `AllowAnyOrigin/Method/Header` in every environment (assumed safe behind suite reverse proxy) | Spec § service composition; container `EXPOSE 8080`; Watchtower restart contract | Hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL` (security finding tracked at suite level) | One file (~150 LoC) | **Acceptable in the closed edge environment**; the unconditional Swagger + dev fallbacks are debt that should be paid when the service moves to a less trusted network |
### 2.2 Cross-cutting design choices
| Choice | Rationale | Status |
|--------|-----------|--------|
| One PostgreSQL per device, shared by all edge services (ADR-001) | 6× operational overhead saved per device; cross-service cascade is physically possible in one DB connection | **Implemented** |
| Manual cascade-delete in code, NOT `ON DELETE CASCADE` (ADR-003) | Schema-level cascade would couple `annotations` / detection schemas to this service's lifecycle | **Implemented** (transaction-wrap missing — ADR-006 carry-forward) |
| `CREATE TABLE IF NOT EXISTS` schema bootstrap (ADR-004), no migration tool | 4-table schema; no column drops or type changes; restart-driven deploy via Watchtower | **Implemented** (B9 adds the one explicit `DROP TABLE IF EXISTS` block for fielded devices) |
| Local JWT validation, no callback to `admin` (ADR-005, F5) | Zero auth-related coupling at runtime; `admin` outage doesn't take this service down | **Implemented** (`iss`/`aud` validation disabled — suite-tracked) |
| One csproj, one root namespace (ADR-008); layering by convention not by compiler | Service is small enough that 6 csprojs add more navigation cost than safety value | **Implemented** (post-B5); enforcement via `module-layout.md` § Allowed Dependencies + `/code-review` Phase 7 |
| GPS-Denied moved to a sibling service (ADR-007, B7+B9) | Different scaling + deployment cadence; GPS-Denied owns its tables and lifecycle | **Doc-only today**; B7 (code) + B9 (DB migration) close the gap |
### 2.3 Implementation order (relative to other components)
The 6 components have no circular dependencies. Implementation/refactor order (lowest layer first), per `_docs/02_document/module-layout.md`:
1. `04_persistence` (Layer 1) — depends only on linq2db + Npgsql.
2. `05_identity`, `06_http_conventions` (Layer 2) — depend on ASP.NET Core only.
3. `01_vehicle_catalog` (Layer 3) — depends on `04_persistence`, `05_identity`.
4. `02_mission_planning` (Layer 4) — depends on `01_vehicle_catalog` (vehicle existence check), `04_persistence`, `05_identity`, `06_http_conventions` (paginated envelope).
5. `07_host` (Layer 5) — depends on every other component (composition root).
Cross-component reads happen via the shared `AppDataConnection` (e.g. `02_mission_planning` reads `vehicles` to existence-check `vehicle_id`); the codebase does **not** wrap that lookup behind an interface in `01_vehicle_catalog`. This is intentional (one csproj, one DB) — see ADR-008.
---
## 3. Testing Strategy (as observed)
> **Status (today)**: **NO automated tests are present in this codebase.** The Tech Stack table in `_docs/02_document/architecture.md` § 2 says "Tests: None present"; `_docs/02_document/00_discovery.md` § Repository Layout confirms there is no `tests/` directory and the csproj has no test project sibling. The autodev `existing-code` flow's Phase A Steps 3 → 7 (Test Spec → Decompose Tests → Implement Tests → Run Tests) is the planned path to close this gap.
### 3.1 What exists today
| Layer | Coverage | Where it is |
|-------|----------|-------------|
| Unit | None | — |
| Integration / functional | None | — |
| Non-functional (perf, load, security) | None | — |
| Health probe | One endpoint (`GET /health` returns `{ status: "healthy" }`) | `Program.cs` `MapGet("/health")` |
| Schema sanity | Indirect — `DatabaseMigrator` runs at every startup; if a column/table is missing the process crashes | `Database/DatabaseMigrator.cs` |
| Wire-shape verification | Manual diff against `../../suite/_docs/00_top_level_architecture.md` § Error Response Format + § Pagination | Code review |
### 3.2 What the autodev `existing-code` flow will produce
- **Step 3 (Test Spec)** → `_docs/02_document/tests/traceability-matrix.md` + per-flow scenario files for F1F7. The 8 ADRs and 7 carry-forward concerns from `architecture.md` are the seed set for test scenarios.
- **Step 4 (Code Testability Revision)** → minimal, surgical fixes if the codebase blocks tests from running (env-driven `DATABASE_URL` already lands here; hardcoded dev fallbacks in `Program.cs` are the prime candidate). Scope: smallest set of changes; deeper refactors deferred to Step 8.
- **Step 5 (Decompose Tests)** → per-test task files in `_docs/02_tasks/todo/`, plus `_test_infrastructure.md`.
- **Step 6 (Implement Tests)** → `tests/Azaion.Missions.Tests/` sibling project (xUnit is the suite-standard choice; per `coderule.mdc` "follow the established directory structure", no `src/` layer).
- **Step 7 (Run Tests)** → green test suite forms the safety net for Step 8 (Refactor) and every Phase B feature cycle thereafter.
### 3.3 Scenarios likely to land first (anticipated, not yet specified)
These are obvious test seams given the F1F7 flows and the 7 carry-forward concerns; the actual scenario set is produced by Step 3.
| Priority | Scenario family | Why it lands first |
|----------|-----------------|--------------------|
| 1 | `MissionService.DeleteMission` — full cascade in dependency order | Critical-flow F3, NOT transaction-wrapped today; tests would catch any future regressions in the cascade chain immediately |
| 1 | `WaypointService.DeleteWaypoint` — scoped cascade variant | Same reason as F3; same NO-transaction caveat |
| 2 | `MissionService.CreateMission / UpdateMission``vehicle_id` existence check + spec-vs-code `400` vs `404` divergence | Locks in the current behaviour so the spec-conformance fix is intentional, not accidental |
| 2 | `VehicleService.SetDefault` / Create / Update — "exactly one default" race | B12 decision (spec-vs-code stricter behaviour) — tests pin whichever resolution the user picks |
| 2 | `ErrorHandlingMiddleware` mapping (`KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`, fallthrough → 500) | Wire-shape contract used by every flow |
| 3 | JWT validation — accept valid HS256 / reject invalid signature / reject expired (with 1-min skew) / reject missing-`FL` claim | F5 cross-cutting; pins the local-validation contract |
| 3 | `DatabaseMigrator.Migrate` — idempotent on a fresh DB, idempotent on already-migrated DB, B9 `DROP` on a fielded-legacy DB | F6; tests guard the only explicit destructive step |
---
## 4. Non-functional behaviour (observed)
| Concern | Observed behaviour | Where it is set | Notes |
|---------|--------------------|-----------------|-------|
| Latency | Single-digit ms typical; cascade delete = 47 sequential round-trips against local Postgres | `Database/AppDataConnection.cs` (per-request scope), `MissionService.DeleteMission` | No SLO in spec; observed under operator-paced load |
| Throughput | Operator-paced (~1 op/s peak); not load-tested | — | Edge deployment shape; not a hot path |
| Availability | Best-effort per device; Watchtower restarts on crash; `flight-gate` prevents restart mid-mission | `Dockerfile` + `../../suite/_infra/_compose/`, suite arch doc | No multi-instance HA per device by design |
| Recovery | RTO ≈ container restart time (~10s); RPO = device-local backup cadence (suite concern) | Watchtower + suite-level backup | — |
| Cascade atomicity | **Currently violated** (ADR-006); one-line fix queued | `Services/MissionService.cs`, `Services/WaypointService.cs` | Recommended to land with B6 |
| Wire-shape conformance | **Currently divergent** on entity/DTO case + error envelope's missing `errors` field (ADR-002) | `Program.cs` (no `JsonNamingPolicy.CamelCase`); `Middleware/ErrorHandlingMiddleware.cs` (anonymous object envelope) | Cutover is suite-wide; out of this Epic |
| Health endpoint | `< 10 ms` typical (no DB ping); used by Watchtower + reverse proxy | `Program.cs` `MapGet("/health")` | Future improvement: gate on DB ping |
| Resource limits | None in code; container-level limits set by edge compose | `Dockerfile` (no `--memory` / cpu limits inside) | Suite-level concern |
---
## 5. References
### 5.1 Source artefacts (this repo)
| Concern | File |
|---------|------|
| Web host composition | `Program.cs` |
| Vehicle catalog | `Controllers/AircraftsController.cs` (post-B6/B8: `Controllers/VehiclesController.cs`), `Services/AircraftService.cs` (post-B6: `VehicleService.cs`) |
| Mission planning | `Controllers/FlightsController.cs` (post-B6/B8: `Controllers/MissionsController.cs`), `Services/FlightService.cs` (post-B6: `MissionService.cs`), `Services/WaypointService.cs` |
| Persistence | `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`, `Database/Entities/*.cs` |
| Identity | `Auth/JwtExtensions.cs` |
| HTTP conventions | `Middleware/ErrorHandlingMiddleware.cs`, `DTOs/PaginatedResponse.cs`, `DTOs/ErrorResponse.cs` |
| Container | `Dockerfile` |
| CI | `.woodpecker/build-arm.yml` |
| Project | `Azaion.Flights.csproj` (post-B5: `Azaion.Missions.csproj`) |
### 5.2 Generated documentation (this repo)
| Doc | Path |
|-----|------|
| Discovery | `_docs/02_document/00_discovery.md` |
| Per-module docs (12 modules) | `_docs/02_document/modules/*.md` |
| Per-component descriptions (6 components) | `_docs/02_document/components/*/description.md` |
| Module layout (file ownership + layering) | `_docs/02_document/module-layout.md` |
| Architecture (this solution's source-of-truth) | `_docs/02_document/architecture.md` |
| System flows (F1F7) | `_docs/02_document/system-flows.md` + `_docs/02_document/diagrams/flows/*.md` |
| Data model | `_docs/02_document/data_model.md` |
| Glossary (confirmed by user) | `_docs/02_document/glossary.md` |
| Verification log (drift mapping) | `_docs/02_document/04_verification_log.md` |
| Deployment notes | `_docs/02_document/deployment/{containerization,ci_cd_pipeline,environment_strategy,observability}.md` |
### 5.3 Suite-level cross-references
| Concern | File |
|---------|------|
| Primary spec for this service | `../../suite/_docs/02_missions.md` |
| Top-level architecture (error envelope, pagination, topology) | `../../suite/_docs/00_top_level_architecture.md` |
| Authoritative ER diagram (shared edge Postgres) | `../../suite/_docs/00_database_schema.md` |
| Roles & permissions (`FL` permission origin) | `../../suite/_docs/00_roles_permissions.md` |
| GPS-Denied (separate service after B7) | `../../suite/_docs/11_gps_denied.md` |
| CMMC L2 scorecard (JWT `iss`/`aud` finding) | `../../suite/_docs/05_security/cmmc_l2_scorecard.md` |
| Repo-config (post-rename) | `../../suite/_docs/_repo-config.yaml` |
### 5.4 Tracker (Jira AZ project)
| Plan ID | Jira | Type | SP | Status |
|---------|------|------|----|--------|
| Epic | [AZ-539](https://denyspopov.atlassian.net/browse/AZ-539) | Epic | — | To Do |
| B1 (local docs) | [AZ-540](https://denyspopov.atlassian.net/browse/AZ-540) | Task | 3 | **Done** |
| B2 (suite docs) | [AZ-541](https://denyspopov.atlassian.net/browse/AZ-541) | Task | 3 | **Done** |
| B3 (state bookkeeping) | [AZ-542](https://denyspopov.atlassian.net/browse/AZ-542) | Task | 3 | **Done** |
| B4 (repo rename) | [AZ-543](https://denyspopov.atlassian.net/browse/AZ-543) | Task | 3 | To Do |
| B5 (csproj + namespace) | [AZ-544](https://denyspopov.atlassian.net/browse/AZ-544) | Story | 3 | To Do |
| B6 (domain rename) | [AZ-545](https://denyspopov.atlassian.net/browse/AZ-545) | Story | 5 | To Do |
| B7 (drop GPS-Denied) | [AZ-546](https://denyspopov.atlassian.net/browse/AZ-546) | Story | 3 | To Do |
| B8 (HTTP routes) | [AZ-547](https://denyspopov.atlassian.net/browse/AZ-547) | Story | 3 | To Do |
| B9 (DB migration) | [AZ-548](https://denyspopov.atlassian.net/browse/AZ-548) | Story | 5 | To Do |
| B10 (Dockerfile + image tag) | [AZ-549](https://denyspopov.atlassian.net/browse/AZ-549) | Task | 2 | To Do |
| B11 (consumer cutover) | [AZ-550](https://denyspopov.atlassian.net/browse/AZ-550) | Story | 5 | To Do |
| B12 (default vehicle rule decision) | [AZ-551](https://denyspopov.atlassian.net/browse/AZ-551) | Task | 2 | To Do |
Leftover index: `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`.
+248
View File
@@ -0,0 +1,248 @@
# Codebase Discovery — Azaion.Missions
> **NOTE (forward-looking)**: this discovery doc reflects the **post-rename, post-GPS-Denied-removal target** for this repo. Today the source still uses `Azaion.Flights` namespace, `Aircraft*`/`Flight*`/`Orthophoto*`/`GpsCorrection*` filenames, `[Route("aircrafts"|"flights")]`, and migrates 6 tables. The renames + drops are tracked under Jira AZ-EPIC + child tickets B5B12 (see plan and `_autodev_state.md`). The doc IS the spec for that work.
## Suite Context (read first)
This workspace is **one submodule** of the `azaion-suite` meta-repo (an aggregate of 11 component submodules orchestrated by the parent at `../../`). The canonical, human-confirmed documentation for what this service is supposed to do lives at:
| Suite doc | Relevance to `missions` |
|-----------|-------------------------|
| `../../suite/_docs/02_missions.md` | **Primary spec** — Missions / Waypoints / Vehicles CRUD, all 15 endpoints (post-rename) |
| `../../suite/_docs/00_top_level_architecture.md` | Service topology, deployment tiers, error/pagination wire conventions |
| `../../suite/_docs/00_database_schema.md` | Authoritative ER diagram for the shared edge PostgreSQL |
| `../../suite/_docs/00_roles_permissions.md` | `FL` permission code consumed by this service |
| `../../suite/_docs/04_system_design_clarifications.md` | SSE conventions (relevant for downstream consumers) |
| `../../suite/_docs/glossary.md` | Suite-wide terminology |
| `../../suite/_docs/11_gps_denied.md` | **NOT this service** — GPS-Denied is a separate (out-of-this-repo) service |
| `../../suite/_docs/05_security/cmmc_l2_scorecard.md` | CMMC L2 row 3 finding on JWT iss/aud validation, tracked at suite level (AZ-487/AZ-494) |
`../../suite/_docs/_repo-config.yaml` post-rename: `name: missions, stack: .NET, deployment_tier: edge, primary_doc: _docs/02_missions.md`.
### What `missions` IS, in suite terms
`missions` is the **edge-tier .NET service that owns the "mission" domain** of an Azaion deployment. It runs on each Jetson / OrangePI / operator-PC alongside `annotations`, `detections`, `autopilot`, `gps-denied`, and the React `ui`. **All edge services share one local PostgreSQL** on the device; each migrates only its own tables. JWTs are minted by the remote `admin` service and validated locally with the shared HMAC secret. Per the spec, this service has TWO feature slots, both gated by ONE permission:
| Spec feature slot | Permission | Status in code today |
|-------------------|------------|----------------------|
| Vehicle Catalog (Plane / Copter / UGV / GuidedMissile inventory + default selection) | `FL` | Implemented (today as "aircraft catalog" with Plane/Copter only; `VehicleType` expansion is B6) |
| Mission Planning (Missions + Waypoints CRUD + cross-service cascade-delete) | `FL` | Implemented (today as "flight planning"; rename + cascade shrink is B6 + B7) |
GPS-Denied is **not** a feature of this service. The orthophoto / live-GPS / GPS-correction endpoints listed in `../../suite/_docs/11_gps_denied.md` live in the new (separate) `gps-denied` service.
### Why "missions" not "flights"
The previous service name `flights` was too narrow. The fleet covered by `VehicleType` includes:
- `Plane = 0` — fixed-wing UAV
- `Copter = 1` — multirotor UAV
- `UGV = 2` — Unmanned Ground Vehicle (per `../../hardware/_standalone/target_acquisition/target_acquisition.md`)
- `GuidedMissile = 3` — single-use loitering munition
A "flight" only describes air vehicles. A "mission" is the right abstraction for any of the above.
### Cross-service contracts owned here
1. **JWT validation** — trusts admin-issued tokens via shared HMAC secret (`JWT_SECRET`). This service never talks to `admin`; it only validates.
2. **Mission cascade-delete** — when a mission or waypoint is deleted, this service tears down rows in `media`, `annotations`, `detection` (owned schema-wise by `annotations` + the detection pipeline) AND `map_objects` (written by `autopilot` from H3-indexed detections per `../../suite/_docs/06_autopilot_design.md`). It is the only place in the system that knows the full mission ownership graph.
3. **Suite-standard wire shapes** — error envelope and `PaginatedResponse<T>` are shared with `annotations`, `admin`, `satellite-provider` (defined in `../../suite/_docs/00_top_level_architecture.md`).
---
## Repository Layout
```
flights/ ← post-rename: missions/
├── Auth/ (1 file) — JWT bearer auth setup + permission policy
├── Controllers/ (2 files) — REST API surface
├── DTOs/ (15 files) — Request/response/query/shared payloads
├── Database/
│ ├── AppDataConnection.cs — LinqToDB DataConnection with ITable<T> properties
│ ├── DatabaseMigrator.cs — Bootstraps the 4 tables this service OWNS the schema for (post-B7+B9)
│ └── Entities/ (7 files) — LinqToDB-mapped row types (4 owned + 3 borrowed read-only stubs) (post-B7)
├── Enums/ (5 files) — Domain enumerations stored as INTEGER columns
├── Middleware/ (1 file) — Global exception → JSON error mapper
├── Entities/ (empty dir) — likely scaffolding leftover; delete in B5
├── Infrastructure/ (empty dir) — likely scaffolding leftover; delete in B5
├── Program.cs — Web host composition root + URL→connection-string adapter
├── GlobalUsings.cs — `global using LinqToDB[.Async|.Data]`
├── Azaion.Missions.csproj — `Microsoft.NET.Sdk.Web`, TargetFramework `net10.0` (post-B5)
├── Dockerfile — Multi-arch SDK build → `dotnet/aspnet:10.0` runtime, EXPOSE 8080 (entrypoint Azaion.Missions.dll post-B10)
├── README.md — One-liner; today says ".NET 8" — STALE (csproj targets net10.0)
├── .woodpecker/build-arm.yml — Single CI job: docker build + push on `[dev, stage, main]`
└── .gitignore
```
Total source files **post-B7**: **~33 `.cs` files** across 7 logical directories (down from 37 today; loses `Aircraft.cs` renamed → `Vehicle.cs`, `Flight.cs` renamed → `Mission.cs`, `Orthophoto.cs` deleted, `GpsCorrection.cs` deleted, `OrthophotoRequest.cs` does not exist anyway).
## Tech Stack
| Concern | Technology | Source of evidence |
|---------|-----------|--------------------|
| Language / runtime | C# on .NET 10 (`net10.0`) | csproj |
| Web framework | ASP.NET Core (`Microsoft.NET.Sdk.Web`) | csproj, `Program.cs` |
| Data access | linq2db `6.2.0` | csproj, `AppDataConnection.cs` |
| Database driver | Npgsql `10.0.2` (PostgreSQL — shared with all other edge services) | csproj, `Program.cs` (`UsePostgreSQL`) |
| Migrations | None (raw `CREATE TABLE IF NOT EXISTS` for the 4 owned tables, plus a one-shot `DROP TABLE IF EXISTS` block in B9 for legacy GPS-Denied tables) | `Database/DatabaseMigrator.cs` |
| Auth | JWT bearer, HS256 shared-secret with admin; single claim-based permission `FL` (post-B7) | `Auth/JwtExtensions.cs` |
| API docs | Swashbuckle `10.1.5` (Swagger UI mounted unconditionally) | csproj, `Program.cs` |
| CORS | Open default policy (`AllowAnyOrigin/Method/Header`) | `Program.cs` |
| Error handling | Custom middleware mapping `KeyNotFoundException`/`ArgumentException`/`InvalidOperationException` → 404/400/409, fallthrough → 500 | `Middleware/ErrorHandlingMiddleware.cs` |
| Health endpoint | `GET /health` returns `{ status: "healthy" }` | `Program.cs` |
| Container build | Dockerfile multi-arch (`--platform=$BUILDPLATFORM`, `dotnet publish --os linux --arch $arch`) | `Dockerfile` |
| CI | Woodpecker single ARM-tagged build-and-push job; tag pattern `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` (post-B10) | `.woodpecker/build-arm.yml` |
| Tests | **None present** — tracked in `../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md` | full file scan |
## Entry Points
- `Program.cs` — Web host (top-level statements). Builds DI graph, runs `DatabaseMigrator.Migrate` once at startup, then `app.Run()`.
- HTTP routes (registered via `MapControllers` + one minimal `MapGet`) (post-B6 + B8):
- `[Authorize FL]` `vehicles/*``VehiclesController` (matches spec items 1015 in `../../suite/_docs/02_missions.md`)
- `[Authorize FL]` `missions/*``MissionsController` (matches spec items 19, includes nested `missions/{id}/waypoints/*`)
- Anonymous `GET /health`
- **No GPS-Denied endpoints** — those live in the separate `gps-denied` service.
## Configuration / Secrets
Resolved at startup in `Program.cs`:
| Key | Source order | Default (development fallback) |
|-----|-------------|------------------------------|
| `DATABASE_URL` | `IConfiguration``Environment.GetEnvironmentVariable` → fallback | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` |
| `JWT_SECRET` | `IConfiguration``Environment.GetEnvironmentVariable` → fallback | `development-secret-key-min-32-chars!!` |
`DATABASE_URL` accepts either a `postgresql://user:pass@host:port/db` URL (converted via local helper `ConvertPostgresUrl`) or a raw Npgsql connection string. Edge compose passes `DATABASE_URL: postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` per `../../suite/_docs/00_top_level_architecture.md`.
## Test Layout
No tests detected. Tracked in `../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md`. The autodev BUILD pipeline will fill this gap (Steps 3 → 6 of existing-code flow). Sibling project would be `Azaion.Missions.Tests` (post-B5).
## Existing Documentation
- `README.md` — one line, says ".NET 8" but `csproj` targets `net10.0`. **STALE** — fixed by Phase A1 of the rename plan.
- No inline XML doc comments on any public type observed.
- No `docs/` directory in this submodule. The canonical docs live at `../../suite/_docs/` (suite level).
- This `_docs/02_document/` tree is the local autodev artifact set.
## Dependency Graph (file-level, internal-only) — post-rename
Modules grouped by topological layer (each module imports only from layers above it). Internal-only edges shown; external NuGet edges (LinqToDB, ASP.NET Core, Npgsql) omitted.
```
Layer 0 — Leaves (no internal deps)
Enums.VehicleType, Enums.FuelType, Enums.WaypointSource,
Enums.WaypointObjective, Enums.ObjectStatus
DTOs.GeoPoint, DTOs.SetDefaultRequest, DTOs.UpdateMissionRequest,
DTOs.CreateMissionRequest, DTOs.GetMissionsQuery, DTOs.PaginatedResponse,
DTOs.ErrorResponse, DTOs.GetVehiclesQuery
Database.Entities.Media, Database.Entities.Annotation,
Database.Entities.Detection
Middleware.ErrorHandlingMiddleware
Auth.JwtExtensions
GlobalUsings
Layer 1 — Depend on Enums (or GeoPoint)
DTOs.CreateVehicleRequest → Enums
DTOs.UpdateVehicleRequest → Enums
DTOs.CreateWaypointRequest → Enums, DTOs.GeoPoint
DTOs.UpdateWaypointRequest → Enums, DTOs.GeoPoint
Database.Entities.Vehicle → Enums
Database.Entities.MapObject → Enums
Database.Entities.Waypoint → Enums (Association → Mission)
Database.Entities.Mission → Enums (Associations → Vehicle, Waypoint)
Layer 2 — Database wiring
Database.AppDataConnection → all Database.Entities (7)
Database.DatabaseMigrator → Database.AppDataConnection
Layer 3 — Services
Services.VehicleService → AppDataConnection, Entities, DTOs
Services.WaypointService → AppDataConnection, Entities, DTOs, Enums
Services.MissionService → AppDataConnection, Entities, DTOs
(cascade also touches MapObjects,
Media, Annotations, Detections)
Layer 4 — HTTP surface
Controllers.VehiclesController → Services.VehicleService, DTOs
Controllers.MissionsController → Services.MissionService,
Services.WaypointService, DTOs
Layer 5 — Composition root
Program.cs → Auth.JwtExtensions, Database (AppDataConnection,
DatabaseMigrator), Middleware.ErrorHandlingMiddleware,
Services.{Vehicle,Waypoint,Mission}Service
```
### Topological Processing Order (used for module-level documentation)
1. **Enums** (5)
2. **Simple DTOs** (8)
3. **Simple Entities** (3 cross-service stubs)
4. **Cross-cutting leaves** (3): `JwtExtensions`, `ErrorHandlingMiddleware`, `GlobalUsings`
5. **DTOs depending on Enums** (4)
6. **Entities depending on Enums** (4)
7. **DB wiring** (2)
8. **Services** (3)
9. **Controllers** (2)
10. **Composition root** (1)
No import cycles detected.
---
## Cross-Repo Schema Ownership (the key insight)
The shared edge PostgreSQL hosts tables owned by multiple services. This service's `AppDataConnection` exposes ALL of them, but only migrates the 4 it owns:
| Table | Schema owner | Written by | Read by `missions` | `missions` writes? |
|-------|--------------|-----------|-------------------|--------------------|
| `vehicles` | missions | missions (`VehicleService`) | yes | yes (full CRUD) |
| `missions` | missions | missions (`MissionService`) | yes | yes (full CRUD) |
| `waypoints` | missions | missions (`WaypointService`) | yes | yes (full CRUD) |
| `map_objects` | missions | `autopilot` (per `../../suite/_docs/06_autopilot_design.md`) | no | cascade-delete only |
| `media` | `annotations` (per `../../suite/_docs/01_annotations.md`) | `annotations` | yes (id + waypoint_id resolution) | cascade-delete only |
| `annotations` | `annotations` | `annotations` | yes (id + media_id resolution) | cascade-delete only |
| `detection` | detection pipeline | detections / ai-training | yes (id resolution) | cascade-delete only |
| `orthophotos` | **gps-denied** (separate service, post-B7) | gps-denied | no | no (was: cascade-delete only) |
| `gps_corrections` | **gps-denied** (separate service, post-B7) | gps-denied | no | no (was: cascade-delete only) |
This pattern explains the shape of `AppDataConnection` and `DatabaseMigrator` — what looks like "incompleteness" in the migrator is the deliberate per-service ownership pattern.
---
## Cycles / Anomalies (post-rename)
- **Entity ↔ Entity navigation** (`Mission.Waypoints``Waypoint.Mission`, `Mission.Vehicle`) — LinqToDB `[Association]` pair, not an import cycle.
- **Empty directories**: `Entities/` and `Infrastructure/` at the root are empty. **Delete in B5** as part of the rename pass.
- **`detection` table singularity**: `Detection.cs` maps to `[Table("detection")]` (singular) while every other table is plural. Owned by another service — naming is THEIR call to make consistent.
- **README ".NET 8" claim**: contradicts `net10.0` in `csproj`. Fix in Phase A1.
## Spec ↔ Code Divergences (carried into the verification log)
| # | Concern | Spec source | Code reality | Resolution |
|---|---------|------------|--------------|------------|
| 1 | Service / domain naming | `../../suite/_docs/02_missions.md` (post-rename) | `Azaion.Flights.*`, `[Route("flights")]`, `[Route("aircrafts")]` today | B5 + B6 + B8 |
| 2 | GPS-Denied feature slot | `../../suite/_docs/11_gps_denied.md` (separate service) | Schema (`Orthophoto`, `GpsCorrection`) + cascade branches still in this repo | B7 + B9 (drop entirely) |
| 3 | `VehicleType` membership | `../../suite/_docs/02_missions.md` (Plane / Copter / UGV / GuidedMissile) | `AircraftType { Plane, Copter }` only | B6 |
| 4 | `Geopoint` representation | `../../suite/_docs/02_missions.md` § "GPS (Geopoint)" + `../../suite/_docs/00_database_schema.md` (`Waypoints.GPS: string`) | 3 separate columns (`lat NUMERIC`, `lon NUMERIC`, `mgrs TEXT`); no auto-conversion | Out of this Epic — carry forward |
| 5 | Error wire shape | `../../suite/_docs/00_top_level_architecture.md` § Error Response Format (camelCase, `errors: object?` keyed by field) | PascalCase `{ "StatusCode", "Message" }`; no `errors`; `ErrorResponse` DTO has `List<string>?` (wrong shape) and is unused | Out of this Epic — carry forward |
| 6 | Pagination wire shape | `../../suite/_docs/00_top_level_architecture.md` § Pagination (camelCase) | PascalCase via System.Text.Json defaults | Out of this Epic — carry forward |
| 7 | Vehicle `IsDefault` exclusivity | `../../suite/_docs/02_missions.md` § 11 + 15 (just toggles) | Code clears the flag on every other row (stricter than spec) + race-prone | **B12** (decision-only ticket) |
| 8 | Vehicle listing pagination | Spec endpoint 13 says "unpaginated" | Matches spec ✓ | — |
| 9 | Waypoint listing pagination | Spec endpoint 6 says "unpaginated" | Matches spec ✓ | — |
| 10 | Cascade-delete chain | Spec § 9 covers mission delete cascade; § 5 covers waypoint delete | Code matches the spec's cascade order; missing transaction wrapping | B7 shrinks the chain; transaction wrap is opportunistic improvement carried forward |
| 11 | Swagger gating | Not specified | Unconditional, no `IsDevelopment` guard | Out of this Epic |
| 12 | CORS policy | Not specified | Open in all environments | Out of this Epic |
| 13 | JWT issuer/audience validation | CMMC L2 finding (suite-tracked, AZ-487/AZ-494) | Disabled, consistent with shared-secret model; suite-level remediation pending | Suite-level (not this Epic) |
| 14 | `FL` permission code name | `../../suite/_docs/00_roles_permissions.md` | Code carries `"FL"` (legacy "Flight" name) even after rename to `missions` | TODO in `00_roles_permissions.md`; not this Epic (fleet-wide auth change) |
| 15 | `FuelType` for `GuidedMissile` | Not specified | Existing `{ Electric, Gasoline, Diesel }` may not fit single-use missiles | Phase C decision — may spawn follow-up ticket |
## Cross-cutting Observations
1. No automated tests in the repository.
2. CORS allows any origin/method/header in all environments — production exposure if not overridden upstream.
3. JWT secret default (`development-secret-key-min-32-chars!!`) is hardcoded; production deploys MUST set `JWT_SECRET`.
4. Swagger UI is enabled unconditionally (no `IsDevelopment` guard).
5. Database password default (`changeme`) is hardcoded.
6. CI pipeline (`.woodpecker/build-arm.yml`) has only a build/push step — no test, no security scan, no migration check.
7. Schema bootstrap runs every startup (`DatabaseMigrator.Migrate`); idempotent (`IF NOT EXISTS`) but no schema versioning. The B9 `DROP TABLE IF EXISTS` is the one explicit destructive step in the migrator's history.
+150
View File
@@ -0,0 +1,150 @@
# Step 4 — Verification Log
**Status**: complete
**Date**: 2026-05-14
**Mode**: rename-aware (per autodev `/autodev` choice A)
**Scope**: every artifact under `_docs/02_document/` cross-checked against actual workspace source.
## 0. Verification mode (the one nuance)
The `_docs/02_document/` set is **forward-looking** — it documents the post-rename, post-GPS-Denied-removal target. The workspace code is still pre-rename (`Azaion.Flights.csproj`, `Aircraft*` / `Flight*` / `Orthophoto*` / `GpsCorrection*` files, `[Route("aircrafts"|"flights")]`, 6 owned tables, both `"FL"` and `"GPS"` policies). Each doc carries an explicit forward-looking note pointing at the responsible Jira children (B5B12) under epic AZ-539.
Verification therefore applies a **rename mapping** when comparing docs to code:
| Doc symbol | Code symbol | Reconciled by |
|------------|-------------|---------------|
| `Vehicle*`, `vehicles`, `VehicleType { Plane, Copter, UGV, GuidedMissile }` | `Aircraft*`, `aircrafts`, `AircraftType { Plane, Copter }` | AZ-545 (B6) domain rename + value extension |
| `Mission*`, `missions`, `mission_id`, `vehicle_id` | `Flight*`, `flights`, `flight_id`, `aircraft_id` | AZ-545 (B6) |
| `[Route("vehicles")]`, `[Route("missions")]` | `[Route("aircrafts")]`, `[Route("flights")]` | AZ-547 (B8) |
| `Azaion.Missions.*` namespace, `Azaion.Missions.csproj`, `Azaion.Missions.dll` | `Azaion.Flights.*`, `Azaion.Flights.csproj`, `Azaion.Flights.dll` | AZ-544 (B5) |
| 4 owned tables (no `orthophotos`, no `gps_corrections`), 7 entities | 6 owned tables, 9 entities | AZ-546 (B7) entity drop + AZ-548 (B9) DB migration |
| Single `"FL"` policy in `JwtExtensions` | Both `"FL"` AND `"GPS"` policies | AZ-546 (B7) |
| Cascade omits `orthophotos` / `gps_corrections` branches | Cascade still touches both | AZ-546 (B7) |
| `azaion/missions:*-arm` image tag, `dotnet Azaion.Missions.dll` entrypoint | `azaion/flights:*-arm`, `dotnet Azaion.Flights.dll` | AZ-549 (B10), AZ-544 (B5) |
Any doc claim covered by this mapping is treated as **expected, NOT drift**. Only mismatches NOT covered by the mapping are flagged below.
## 1. Counts
| Domain | Doc claim (post-target) | Code reality (today, pre-rename) | Reconciles via |
|--------|-------------------------|-----------------------------------|----------------|
| Component count | 6 (`01_vehicle_catalog`, `02_mission_planning`, `04_persistence`, `05_identity`, `06_http_conventions`, `07_host`) | 6 folders on disk under `_docs/02_document/components/` matching exactly | (no rename gap; matches today) |
| Module docs | 12 | 12 modules in `_docs/02_document/modules/` mapping 1:1 to source files (under rename) | rename mapping |
| Source `.cs` files | "~33 post-B7" | 37 today | drops 4 in B6+B7 (Aircraft.cs, Flight.cs, Orthophoto.cs, GpsCorrection.cs); rename leaves the other 33 |
| Owned tables | 4 | 6 | B7 + B9 |
| Entities | 7 | 9 | B7 |
| Indexes in migrator | 3 (post-B9) | 6 (today) | B9 drops 3 GPS-Denied indexes |
| Auth policies | 1 (`"FL"`) | 2 (`"FL"`, `"GPS"`) | B7 deletes `"GPS"` per AZ-546 acceptance |
| HTTP route prefixes | `/vehicles/*`, `/missions/*`, `GET /health` | `/aircrafts/*`, `/flights/*`, `GET /health` | B8 |
All count claims reconcile. ✓
## 2. Per-symbol sweep (entities, signatures, routes)
### Entities (post-rename target vs today's code)
| Doc says (`modules/entities.md`, `data_model.md`) | Code today | Reconciles? |
|----|----|----|
| `Vehicle [Table("vehicles")]` with PK `id`, columns `type, model, name, fuel_type, battery_capacity, engine_consumption, engine_consumption_idle, is_default` | `Aircraft [Table("aircrafts")]` with identical column set + PK | ✓ B6 rename only |
| `Mission [Table("missions")]` with PK `id`, columns `created_date, name, vehicle_id`; associations `Vehicle?`, `List<Waypoint>` | `Flight [Table("flights")]` with `created_date, name, aircraft_id`; associations `Aircraft?`, `List<Waypoint>` | ✓ B6 rename only |
| `Waypoint` with `mission_id` FK + association `Mission?` | `Waypoint` with `flight_id` FK + association `Flight?` | ✓ B6 rename only |
| `MapObject` with `mission_id` FK | `MapObject` with `flight_id` FK | ✓ B6 rename only |
| `Media`, `Annotation`, `Detection` borrowed stubs | identical stubs in code | ✓ no change needed |
| Removed in B7: `Orthophoto`, `GpsCorrection` | both still present in `Database/Entities/` | ✓ B7 will delete |
### Service signatures (post-rename vs today)
| Doc claim | Code today | Reconciles? |
|----|----|----|
| `VehicleService.{CreateVehicle, UpdateVehicle, GetVehicle, GetVehicles, DeleteVehicle, SetDefault}` | `AircraftService.{CreateAircraft, UpdateAircraft, GetAircraft, GetAircrafts, DeleteAircraft, SetDefault}` — 6 methods, identical shapes | ✓ B6 rename only |
| `MissionService.{CreateMission, UpdateMission, GetMission, GetMissions, DeleteMission}` | `FlightService.{CreateFlight, UpdateFlight, GetFlight, GetFlights, DeleteFlight}` — 5 methods, identical shapes | ✓ B6 rename only |
| `WaypointService.{CreateWaypoint(missionId, ...), UpdateWaypoint(missionId, wpId, ...), GetWaypoints(missionId), DeleteWaypoint(missionId, wpId)}` | `WaypointService.{CreateWaypoint(flightId, ...), UpdateWaypoint(flightId, waypointId, ...), GetWaypoints(flightId), DeleteWaypoint(flightId, waypointId)}` | ✓ B6 rename only (parameter name `flightId → missionId`) |
| `JwtExtensions.AddJwtAuth(IServiceCollection, string)` registers **only** the `"FL"` policy (post-B7) | Same signature; registers `"FL"` AND `"GPS"` policies | ✓ B7 will drop `"GPS"` |
| `ErrorHandlingMiddleware(RequestDelegate, ILogger<>)` with `Invoke(HttpContext)` mapping `KeyNotFound→404 / Argument→400 / InvalidOperation→409 / *→500` | Identical | ✓ no rename gap |
### HTTP routes (post-B8 vs today)
| Doc post-target | Today's `[Route(...)]` | Reconciles? |
|----|----|----|
| 6 vehicle endpoints under `/vehicles` | 6 endpoints under `/aircrafts` | ✓ B8 |
| 5 mission endpoints under `/missions` + 4 waypoint sub-endpoints under `/missions/{id}/waypoints/...` | identical 5+4 endpoints under `/flights` + `/flights/{id}/waypoints/...` | ✓ B8 |
| `GET /health` (anonymous) | identical | ✓ no rename gap |
### Migrator DDL (post-B7+B9 vs today)
| Doc post-target | Today's `DatabaseMigrator.Sql` | Reconciles? |
|----|----|----|
| 4 `CREATE TABLE IF NOT EXISTS`: `vehicles, missions, waypoints, map_objects` + 3 indexes on FKs | 6 `CREATE TABLE IF NOT EXISTS`: `aircrafts, flights, waypoints, orthophotos, gps_corrections, map_objects` + 6 indexes | ✓ B7+B9 (drop `orthophotos` + `gps_corrections` tables and their 3 indexes; rename `aircrafts`/`flights` columns to `vehicles`/`missions` per B6/B9 mapping) |
| `DROP TABLE IF EXISTS orthophotos / gps_corrections` (one-shot in B9) for fielded devices | not present | ✓ B9 will add |
All symbol-level claims reconcile. ✓
## 3. Flow-correctness sweep
| Flow | Doc says | Code today | Reconciles? |
|----|----|----|----|
| F1 Vehicle CRUD | 6 endpoints; "exactly one default" exclusivity rule via clear-then-set in code; spec is just-toggle | `AircraftService.CreateAircraft / UpdateAircraft / SetDefault` all clear-then-set when `IsDefault==true`; no transaction | ✓ matches code; B12 decision tracked |
| F2 Mission create/read/update | Existence check on `vehicle_id` returns `ArgumentException → 400` (spec wants 404) | `FlightService.CreateFlight / UpdateFlight``aircraftExists` check throws `ArgumentException("Aircraft {id} not found")` → middleware → 400 | ✓ matches; spec divergence carry-forward |
| F3 Mission cascade-delete | Order: `map_objects → waypoints/media/annotations/detection → waypoints → missions`. NOT transaction-wrapped. Post-B7: no orthophoto/gps_correction branches | `FlightService.DeleteFlight` order today: `map_objects → gps_corrections → orthophotos → waypoints/media/annotations/detection → waypoints → flights`. NOT transaction-wrapped | ✓ B7 removes the two extra branches |
| F4 Waypoint create/read/update/delete | Delete walks `media/annotations/detection`, post-B7 no `gps_corrections` branch; `UpdateWaypoint` is full overwrite | `WaypointService.DeleteWaypoint` walks `media/annotations/detection` AND `gps_corrections` today; `UpdateWaypoint` is full overwrite | ✓ B7 removes `gps_corrections` branch |
| F5 JWT validation | HS256, shared secret, `ValidateIssuer/ValidateAudience = false`, `ClockSkew = 1 minute`, single `"FL"` policy post-B7 | `JwtExtensions` matches exactly; today has BOTH `"FL"` and `"GPS"` policies | ✓ B7 drops `"GPS"` |
| F6 Startup + migration | `Program.cs` builds host → resolves `DATABASE_URL` (with `ConvertPostgresUrl`) → `JWT_SECRET` → registers scoped services → migrates → starts. Pipeline: `ErrorHandlingMiddleware FIRST → Cors → Authentication → Authorization → Swagger → MapControllers → MapGet("/health") → Run` | `Program.cs` matches exactly; service registration is `FlightService, WaypointService, AircraftService` today instead of `Mission/Waypoint/VehicleService` | ✓ B5+B6 rename + DI re-registration |
| F7 Health probe | `MapGet("/health", () => Results.Ok(new { status = "healthy" }))`, anonymous | identical | ✓ no rename gap |
All flow claims reconcile. ✓
## 4. Drift NOT covered by the rename mapping
These are real findings. **Items in § 4.1 were corrected inline as part of this verification pass; § 4.2 are flagged for follow-up.**
### 4.1 Corrected inline (this pass)
| # | Where | Problem | Correction |
|---|-------|---------|------------|
| D1 | `components/06_http_conventions/description.md` § 1, § 3, § Caveats #1, header | Doc claimed the global error envelope is PascalCase. Actual middleware code is `JsonSerializer.Serialize(new { statusCode = (int)code, message })` — anonymous-type property names are written lowercase-first, and `System.Text.Json` preserves them as-is when no `JsonNamingPolicy` is configured, so the wire output IS `{"statusCode":..., "message":"..."}` (camelCase). Internally inconsistent (§ 1 said camelCase ✓, § 3 example showed PascalCase ✗) | Rewrote § 3 example, Caveat #1, and the header status line to distinguish entity bodies (PascalCase, true divergence) from the error envelope (camelCase, accidental match). Carry-forward concerns — missing `errors` field and dead `ErrorResponse` DTO — explicitly retained |
| D2 | `architecture.md` ADR-002 | Same broad PascalCase claim covering the error envelope | Reworded ADR title + body to scope PascalCase to entity / DTO bodies; added explicit "exception (accidental match)" for the error envelope |
| D3 | `architecture.md` § 6 NFR table — "API spec conformance" row | Same broad claim | Same scoping correction inline |
| D4 | `system-flows.md` § Cross-cutting concerns #3 | "Wire shape is PascalCase today, NOT camelCase" — wrong for the error envelope | Reworded to distinguish entity bodies from the error envelope |
| D5 | `data_model.md` § 11 Backward compatibility | Same broad PascalCase claim | Same scoping correction inline |
| D6 | `modules/middleware.md` § Internal Logic + Notes/Smells #1 + #2 | Internal Logic section asserted PascalCase wire shape; Notes #1 said middleware emits PascalCase `{ "StatusCode", "Message" }`; Notes #2 generalized that as "system-wide divergence" | Rewrote Internal Logic to show the actual `{"statusCode":..., "message":"..."}` and explain the lowercase-by-construction match; Notes #1 reworded to "partial spec divergence" with the missing `errors` field as the remaining issue; Notes #2 reworded to scope PascalCase to entity / DTO bodies |
| D7 | `modules/dtos.md` last bullet (Spec divergence) | Bundled `PaginatedResponse` (real PascalCase divergence) and `ErrorResponse` (camelCase on case but missing `errors` field) into a single PascalCase claim | Split into two bullets — `PaginatedResponse` is genuine PascalCase divergence; `ErrorResponse` is dead code with the runtime envelope already camelCase but missing the `errors` field |
| D8 | `modules/controller_missions.md` Notes #5 | Cross-referenced "anonymous PascalCase JSON quirk as middleware" — wrong cross-ref since the middleware envelope is camelCase | Reworded to scope PascalCase to entity / DTO bodies and explicitly note that the global error envelope from the middleware is camelCase |
| D9 | `modules/database.md` § Internal Logic | Said "2 `CREATE INDEX IF NOT EXISTS` statements" but listed 3 indexes | Corrected to "3" and listed the index names: `ix_missions_vehicle_id`, `ix_waypoints_mission_id`, `ix_map_objects_mission_id` |
### 4.2 Flagged but NOT corrected (carry-forward — confirm with user)
| # | Where | Concern | Why not auto-fix |
|---|-------|---------|------------------|
| F1 | Cascade-delete error scenario in `diagrams/flows/flow_mission_cascade_delete.md` § Error Scenarios | Text references "step 7" (a successful `DELETE FROM missions`) but the cascade order list above it numbers steps 15 and the data-flow table numbers them 18. Three different numberings in one file | Pre-existing inconsistency; minor; correcting it would also need a numbering decision the user might prefer to make once globally |
| F2 | `module-layout.md` § Per-Component Mapping — `05_identity` Public API | Lists only the `"FL"` policy as the public API surface. Code today also exposes `"GPS"`. Forward-looking is correct (B7 will drop `"GPS"`); the `05_identity/description.md` already mentions the dual-policy state in its forward-looking note. Decision: leave `module-layout.md` forward-looking-only (consistent with the rest of the file), OR add a one-line "today also exposes `\"GPS\"` — see B7" caveat | Editorial choice for the user — both readings are defensible |
| F3 | The pre-existing carry-forward divergences in `00_discovery.md` § Spec ↔ Code Divergences (Geopoint shape, error envelope `errors` field, Swagger / CORS unconditional, etc.) | All real, all already documented with their resolution path (this Epic vs out-of-Epic). No new finding here | These are the *intentional* carry-forward items. They are the agenda for future Epics; not in scope for verification |
## 5. Stale-folder check (resolved)
The git status snapshot at session start showed 11 untracked component folders under `_docs/02_document/components/` (6 new + 5 stale: `01_host`, `02_auth`, `03_web_infrastructure`, `05_aircraft`, `06_flight`). Direct on-disk verification (`ls _docs/02_document/components/`) shows **only 6 folders** — the 5 stale entries do NOT exist. The git status was stale; no cleanup needed.
## 6. Verification metrics
| Metric | Count |
|--------|-------|
| Documents reviewed | 25 (`00_discovery.md`, `architecture.md`, `system-flows.md`, `data_model.md`, `module-layout.md`, 6 component descriptions, 12 module docs, 4 deployment docs, components diagram, 7 flow diagram files) |
| Source files cross-referenced | 37 `.cs` files + `Dockerfile` + `Azaion.Flights.csproj` + `.woodpecker/build-arm.yml` + `README.md` |
| Entities verified | 9 (today) ↔ 7 (post-target) — all reconcile under B6/B7 mapping |
| Service methods verified | 15 across 3 services — all reconcile under B6 mapping |
| HTTP endpoints verified | 16 (today: 6 vehicles + 5 missions + 4 waypoints + 1 health) — all reconcile under B6/B8 mapping |
| Doc-internal inconsistencies fixed inline | 9 (D1D9 above; spans 8 files) |
| Real drift (not covered by rename) flagged for user | 2 (F1, F2 above) |
| Carry-forward divergences (already documented) | 15 (the `00_discovery.md` § Spec ↔ Code Divergences table) |
| Hallucinated entities | 0 |
| Coverage | 12/12 modules documented; 6/6 components written; 4/4 deployment docs present; 7/7 flow files present |
| Completeness score | 100% (full coverage; no module or component left undocumented) |
## 7. Summary
- The forward-looking documentation is **internally consistent** with respect to the rename + GPS-Denied removal it describes (B5B12).
- It is **consistent with the actual pre-rename code** when read through the rename mapping documented in § 0 — every counted symbol, signature, route, and flow reconciles.
- One **systematic doc-internal inconsistency** was found and fixed: the global error envelope's wire-shape case-style was misstated as PascalCase across 8 files when the middleware actually emits camelCase. The unrelated divergences (missing `errors` field, dead `ErrorResponse` DTO) remain as carry-forward concerns and are now stated correctly.
- No hallucinated entities or methods. No missing module or component coverage.
- Two minor editorial concerns (F1, F2) are flagged but not auto-fixed — confirm with user.
**Outcome**: docs are accurate as the spec for B5B12. Ready to proceed to Step 4.5 (Glossary & Architecture Vision).
+195
View File
@@ -0,0 +1,195 @@
# Azaion.Missions — Final Documentation Report
> **Status**: complete (autodev `/document` Step 7, 2026-05-14).
> **Mode**: retrospective documentation of an existing codebase, post-Step 4 verification + Step 4.5 user-confirmed glossary & vision.
> **Forward-looking caveat**: every artefact in this set describes the **post-rename, post-GPS-Denied-removal** target. Today's source still uses pre-rename names; the doc-vs-code reconciliation table lives in `04_verification_log.md` § 0 and the implementation deltas are tracked under Jira AZ-EPIC AZ-539 children B4B12 (see `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`).
---
## Executive Summary
`missions` is the edge-tier .NET 10 REST service that owns the mission domain (vehicles, missions, waypoints) of an Azaion deployment. The autodev `/document` skill produced a complete bottom-up documentation set across 8 steps: discovery → 12 module docs → 6 component specs → module-layout (file ownership + 5-layer dependency table) → architecture / system-flows / data-model / deployment → verification (9 inline corrections, 2 drift items captured) → user-confirmed glossary & architecture vision → retrospective solution / problem / restrictions / acceptance-criteria / security-approach. This terminates Phase A Step 1 of the autodev `existing-code` flow; Step 2 (Architecture Baseline Scan) is the next auto-chained step.
## Problem Statement
The system is the per-device authority for **vehicle inventory** (Plane / Copter / UGV / GuidedMissile), **mission plans**, and **ordered waypoints** — and is the single orchestrator of the cross-service cascade-delete that keeps `media` / `annotations` / `detection` / `map_objects` consistent when missions or waypoints are removed. It runs as one container per device alongside `annotations`, the detection pipeline, `autopilot`, `gps-denied`, and the React `ui`, sharing one local PostgreSQL with per-service table ownership enforced by convention. JWTs are validated locally with a shared HMAC secret — the service never calls back to the central `admin` issuer. Full statement in `_docs/00_problem/problem.md`.
## Architecture Overview
**Pattern**: thin ASP.NET Core controller → service class → linq2db active-record over a per-HTTP-request scoped `AppDataConnection`. No repository abstraction; no in-process message queue / event bus; no background workers.
**Technology stack**: C# / .NET 10 (`net10.0`) on ASP.NET Core, linq2db `6.2.0` over PostgreSQL via Npgsql `10.0.2`, JWT bearer (HS256, shared secret), Swashbuckle `10.1.5` (Swagger UI mounted unconditionally — ADR-005).
**Deployment**: docker compose per edge device (Jetson Orin / OrangePI / operator-PC); multi-arch ARM64 + AMD64 image built by Woodpecker; Watchtower handles container restarts; `flight-gate` prevents container restart mid-mission; vertical scale only (one instance per device).
8 ADRs (see `architecture.md` § 8): one Postgres per device (ADR-001), PascalCase wire shape carry-forward (ADR-002), manual cascade-delete (ADR-003), `IF NOT EXISTS` schema bootstrap (ADR-004), Swagger + dev fallbacks ungated (ADR-005), cascade-not-transaction-wrapped carry-forward (ADR-006), GPS-Denied moved out (ADR-007), one-csproj layering by convention (ADR-008).
## Component Summary
| # | Component | Purpose | Dependencies (logical layer) | Spec / Epic |
|---|-----------|---------|------------------------------|-------------|
| 01 | `01_vehicle_catalog` | Vehicle CRUD + `is_default` exclusivity (stricter than spec — B12 decision pending) | Layer 3 → `04_persistence`, `05_identity` | suite spec § 6.1; B6 / B12 |
| 02 | `02_mission_planning` | Mission + Waypoint CRUD + cross-service cascade-delete walk | Layer 4 → `01_vehicle_catalog` (existence check), `04_persistence`, `05_identity`, `06_http_conventions` | suite spec § 6.2; B6 / B7 / B8 |
| 04 | `04_persistence` | `AppDataConnection` (linq2db `ITable<T>`) + `DatabaseMigrator` (`CREATE TABLE IF NOT EXISTS` + B9 one-shot `DROP`) | Layer 1 → linq2db + Npgsql only | B7 / B9 |
| 05 | `05_identity` | `JwtExtensions.AddJwtAuth` — HS256 local validation + `"FL"` policy | Layer 2 → ASP.NET Core only | suite-level remediation (AZ-487/AZ-494 carry-forward) |
| 06 | `06_http_conventions` | `ErrorHandlingMiddleware` + `PaginatedResponse<T>` + dead `ErrorResponse` DTO | Layer 2 → ASP.NET Core only | ADR-002 carry-forward |
| 07 | `07_host` | `Program.cs` composition root: env adapter, JWT registration, scoped DI, run migrator, register middleware, `MapGet("/health")`, mount Swagger | Layer 5 → every other component | B5 (csproj rename) / B10 (image tag) |
**Implementation order** (based on dependency graph in `module-layout.md`):
1. **Layer 1**: `04_persistence` — depends only on linq2db + Npgsql.
2. **Layer 2**: `05_identity`, `06_http_conventions` — depend on ASP.NET Core only.
3. **Layer 3**: `01_vehicle_catalog`.
4. **Layer 4**: `02_mission_planning` (reads `vehicles` for existence checks; uses `PaginatedResponse<T>`).
5. **Layer 5**: `07_host` (composition root).
No circular dependencies between components.
## System Flows
| Flow | Description | Key components | Criticality |
|------|-------------|----------------|-------------|
| F1 | Vehicle CRUD | `01_vehicle_catalog``04_persistence` | High |
| F2 | Mission create / read / update with `vehicle_id` existence check | `02_mission_planning``04_persistence`, with `01_vehicle_catalog` lookup | High |
| F3 | Mission delete with cross-service cascade (touches `map_objects`, `media`, `annotations`, `detection`, `waypoints`, `missions`) | `02_mission_planning``04_persistence` + cross-service tables | **Critical** (data integrity; not transaction-wrapped today — ADR-006) |
| F4 | Waypoint CRUD (delete is a scoped F3 cascade) | `02_mission_planning` (`WaypointService`) → `04_persistence` | High |
| F5 | JWT bearer validation (cross-cutting; local HS256 only) | `05_identity` (pipeline middleware) | **Critical** (every authenticated route) |
| F6 | Service startup + idempotent schema migration (B9 one-shot `DROP TABLE IF EXISTS` for fielded legacy devices) | `07_host``04_persistence` | High |
| F7 | Anonymous `GET /health` probe (process-liveness only; no DB ping) | `07_host` | Medium |
Full sequences and per-flow Mermaid diagrams in `system-flows.md` and `diagrams/flows/flow_*.md`.
## Risk Summary
The codebase has no automated tests today, so "risks" here are **observed-from-code carry-forward concerns** (architecture.md § Carry-forward + 00_discovery.md § Spec ↔ Code Divergences), classified by impact. Mitigation column points at the responsible Jira child or the suite-level ticket.
| Level | Count | Items |
|-------|-------|-------|
| Critical | 1 | Cascade-delete is **NOT transaction-wrapped** (ADR-006) — partial failure leaves orphan rows. **One-line fix**, recommended to land with B6 |
| High | 3 | (a) `JWT_SECRET` / `DATABASE_URL` dev fallbacks not gated on `IsDevelopment()` (ADR-005, suite-tracked); (b) JWT `iss`/`aud` validation disabled (CMMC L2 row 3, AZ-487/AZ-494 suite-tracked); (c) Wire-shape divergence — entity/DTO bodies PascalCase, error envelope missing `errors` field (ADR-002 carry-forward) |
| Medium | 4 | (a) "Exactly one default vehicle" stricter than spec + race-prone (B12 / AZ-551 — decision-only ticket); (b) `vehicle_id`-not-found returns 400 instead of spec's 404 (carry-forward); (c) Swagger UI mounted unconditionally (ADR-005); (d) CORS open in all environments (carry-forward) |
| Low | 4 | (a) `ErrorResponse` DTO is dead on the wire and has wrong shape; (b) `Geopoint` stored as 3 flat columns instead of spec's auto-converting `string GPS`; (c) `FL` permission code retains legacy "Flight" wording post-rename (suite-level fleet-wide change); (d) `FuelType` enum may not fit single-use `GuidedMissile` |
**Mitigation status**: every Critical/High item is either covered by an open Jira ticket (B6, B12, AZ-487/AZ-494) or explicitly logged as a suite-level carry-forward in `04_verification_log.md`. No Critical/High item is unaccounted for.
## Test Coverage
**No automated tests exist today.** Verification of every documented behaviour is by code inspection only. The autodev `existing-code` flow's Phase A Steps 3 → 7 is the planned path to convert `_docs/00_problem/acceptance_criteria.md` (10 AC groups, ~60 individual criteria) into runnable test cases.
| Component | Integration | Performance | Security | Acceptance | AC coverage today |
|-----------|-------------|-------------|----------|------------|-------------------|
| `01_vehicle_catalog` | 0 | 0 | 0 | 0 | AC-1 (9 criteria) — inspection only |
| `02_mission_planning` | 0 | 0 | 0 | 0 | AC-2 (8) + AC-3 (7) + AC-4 (7) — inspection only |
| `04_persistence` | 0 | 0 | 0 | 0 | AC-6 (10) — inspection only |
| `05_identity` | 0 | 0 | 0 | 0 | AC-5 (9) + AC-9 (4) — inspection only |
| `06_http_conventions` | 0 | 0 | 0 | 0 | AC-8 (7) — inspection only |
| `07_host` | 0 | 0 | 0 | 0 | AC-6 (10) + AC-7 (4) + AC-10 (6) — inspection only |
**Overall AC coverage by automated tests**: 0 / ~60 (0%) — the gap that Phase A Steps 37 will close.
## Rename Epic Roadmap (Jira AZ-EPIC AZ-539)
The full doc-vs-code rename + GPS-Denied removal is tracked as one Jira Epic + 12 child tickets. B1B3 (the documentation half) landed in this turn; B4B12 (the code half) are still **To Do**.
| Order | Plan ID | Jira | Type | SP | Status | Component / artefact |
|-------|---------|------|------|----|--------|-----------------------|
| Epic | — | [AZ-539](https://denyspopov.atlassian.net/browse/AZ-539) | Epic | — | To Do | umbrella |
| 1 | B1 | [AZ-540](https://denyspopov.atlassian.net/browse/AZ-540) | Task | 3 | **Done** (this turn) | local docs (this repo's `_docs/`) |
| 2 | B2 | [AZ-541](https://denyspopov.atlassian.net/browse/AZ-541) | Task | 3 | **Done** (this turn) | suite docs (`../../suite/_docs/`) |
| 3 | B3 | [AZ-542](https://denyspopov.atlassian.net/browse/AZ-542) | Task | 3 | **Done** (this turn) | local + suite state bookkeeping |
| 4 | B4 | [AZ-543](https://denyspopov.atlassian.net/browse/AZ-543) | Task | 3 | To Do | repo rename (Gitea + suite `.gitmodules` + `git mv`) |
| 5 | B5 | [AZ-544](https://denyspopov.atlassian.net/browse/AZ-544) | Story | 3 | To Do | csproj + namespace `Azaion.Flights``Azaion.Missions` |
| 6 | B6 | [AZ-545](https://denyspopov.atlassian.net/browse/AZ-545) | Story | 5 | To Do | domain rename `Aircraft → Vehicle`, `Flight → Mission`, `AircraftType → VehicleType { Plane, Copter, UGV, GuidedMissile }` |
| 7 | B7 | [AZ-546](https://denyspopov.atlassian.net/browse/AZ-546) | Story | 3 | To Do | drop GPS-Denied entities, `"GPS"` policy, cascade branches, migrator entries |
| 8 | B8 | [AZ-547](https://denyspopov.atlassian.net/browse/AZ-547) | Story | 3 | To Do | HTTP routes `/aircrafts → /vehicles`, `/flights → /missions` |
| 9 | B9 | [AZ-548](https://denyspopov.atlassian.net/browse/AZ-548) | Story | 5 | To Do | DB migration: `ALTER TABLE` rename + `DROP TABLE IF EXISTS` for legacy GPS-Denied |
| 10 | B10 | [AZ-549](https://denyspopov.atlassian.net/browse/AZ-549) | Task | 2 | To Do | Dockerfile entrypoint + Woodpecker image tag + suite compose service block |
| 11 | B11 | [AZ-550](https://denyspopov.atlassian.net/browse/AZ-550) | Story | 5 | To Do | consumer cutover (autopilot + ui + suite e2e) |
| 12 | B12 | [AZ-551](https://denyspopov.atlassian.net/browse/AZ-551) | Task | 2 | To Do | decision-only: lift "exactly one default" into spec + transaction-wrap, OR drop from code |
**Total estimated effort (remaining)**: 35 SP across 9 To-Do tickets. Implementation order has hard dependencies (B5 → B6 → B7 → B8 → B9 → B10 → B11; B4 ride-along; B12 independent decision).
## Key Decisions Made (during documentation)
| # | Decision | Rationale | Alternatives rejected |
|---|----------|-----------|------------------------|
| 1 | Document the **post-rename** target rather than today's pre-rename source | Aligns with the user's intended end state and the Jira Epic; avoids documenting code that's about to be deleted (B7) | (a) Document today's code as-is — rejected: would require a near-total rewrite the moment B5B7 land. (b) Document both — rejected: doubles maintenance burden until B5B7 ship |
| 2 | Each forward-looking doc carries an explicit "post-rename" note pointing at the responsible Jira child | Future readers can reconcile what they see in code against the doc without re-running discovery | Implicit forward-looking — rejected: too easy to mistake a doc for current state |
| 3 | Treat the verification step as **rename-aware** | The doc-vs-code rename mapping is captured once in `04_verification_log.md` § 0; mismatches not covered by the mapping are the only flagged drift | Treat every rename as a verification failure — rejected: would flag every entity name and produce noise |
| 4 | Component count went 7 → 6 (dropped `03_gps_denied`) and `01_aircraft_catalog``01_vehicle_catalog` | Matches the post-rename suite spec; logged in `state.json.decomposition_revised` | Keep 7 with `03_gps_denied` as deprecated — rejected: would conflict with B7's removal scope |
| 5 | `architecture.md` § "Architecture Vision" + `glossary.md` were both confirmed by the user (Step 4.5) | Downstream skills (refactor, decompose, new-task) treat these as authoritative | Skip the glossary — rejected: existing-code projects benefit most from explicit terminology reconciliation |
| 6 | Solution / problem / restrictions / acceptance / security all describe **today's behaviour with carry-forward divergences explicitly called out** | Tests against the docs will catch unintended behaviour changes; spec-conformance fixes are intentional | Rewrite the docs to match the suite spec — rejected: would misrepresent the running code |
## Open Questions (for Phase A Step 2 onward)
| # | Question | Impact | Where it surfaces next |
|---|----------|--------|-------------------------|
| 1 | Will B12 / AZ-551 lift "exactly one default vehicle" into spec (with transaction-wrap) or drop the rule from code? | AC-1.2 / AC-1.3 / AC-1.4 will change shape; the F1 test scenarios in Step 3 will pin whichever resolution the user picks | B12 / AZ-551 ticket |
| 2 | Should B6 also land the cascade transaction-wrap (ADR-006 carry-forward, one-line fix)? | Closes the only Critical risk; B6 is a rename pass and the transaction wrap can ride along cheaply | B6 / AZ-545 review |
| 3 | When does the suite-wide camelCase wire-shape migration happen (ADR-002 carry-forward)? | Affects every `Mission` / `Vehicle` / `PaginatedResponse` consumer (UI + autopilot); not in this Epic | Suite-level ticket (not yet filed) |
| 4 | Does `gps-denied` need to be deployed BEFORE B9's `DROP TABLE IF EXISTS` runs on fielded devices? | Out-of-band ordering matters: AC-10.5; F6 error scenario | B9 / B10 acceptance review |
| 5 | Will B11 cover any other consumers besides UI + autopilot? | Determines B11 scope | B11 / AZ-550 review |
These do not block Step 2 (Architecture Baseline Scan); they are inputs to subsequent test-spec / refactor / new-task work.
## Artifact Index
### Documentation (this repo, `_docs/`)
| File | Description |
|------|-------------|
| `_docs/00_problem/problem.md` | High-level problem statement (post-rename target) |
| `_docs/00_problem/restrictions.md` | Hardware / software / environment / operational restrictions (4 categories, 41 items) |
| `_docs/00_problem/acceptance_criteria.md` | 10 AC groups (~60 criteria), every criterion grounded in code |
| `_docs/00_problem/input_data/data_parameters.md` | Env vars, HTTP DTOs, table schemas (4 owned + 3 borrowed), enum values, endpoint matrix |
| `_docs/00_problem/security_approach.md` | Authn / authz / data protection / input validation / CORS / 8 production-deploy footguns / threat-model summary |
| `_docs/01_solution/solution.md` | Retrospective solution: per-component table + cross-cutting choices + implementation order + testing strategy |
| `_docs/02_document/00_discovery.md` | Suite context, repository layout, tech stack, entry points, configuration, dependency graph (5 layers), spec ↔ code divergences (15 items) |
| `_docs/02_document/04_verification_log.md` | Step 4 verification: rename-aware mode, counts, per-symbol sweep, drift mapping table |
| `_docs/02_document/architecture.md` | Architecture (Vision + 8 sections + 8 ADRs); confirmed by user at Step 4.5 |
| `_docs/02_document/system-flows.md` | F1F7 narrative + cross-cutting concerns + per-flow error tables |
| `_docs/02_document/data_model.md` | Entity / table / cross-service ownership map |
| `_docs/02_document/glossary.md` | Glossary; confirmed by user at Step 4.5 |
| `_docs/02_document/module-layout.md` | File ownership + 5-layer dependency table; status `derived-from-code` |
| `_docs/02_document/components/01_vehicle_catalog/description.md` | Component spec (post-rename) |
| `_docs/02_document/components/02_mission_planning/description.md` | Component spec |
| `_docs/02_document/components/04_persistence/description.md` | Component spec |
| `_docs/02_document/components/05_identity/description.md` | Component spec |
| `_docs/02_document/components/06_http_conventions/description.md` | Component spec |
| `_docs/02_document/components/07_host/description.md` | Component spec |
| `_docs/02_document/modules/{auth,controller_missions,controller_vehicles,database,dtos,entities,enums,middleware,program,service_mission,service_vehicle,service_waypoint}.md` | 12 module docs |
| `_docs/02_document/diagrams/components.md` | Mermaid component relationship diagram |
| `_docs/02_document/diagrams/flows/flow_{vehicle_crud,mission_lifecycle,mission_cascade_delete,waypoint_lifecycle,jwt_validation,startup_migration,health_probe}.md` | Per-flow Mermaid sequence diagrams |
| `_docs/02_document/deployment/{containerization,ci_cd_pipeline,environment_strategy,observability}.md` | Deployment notes |
### State / process artefacts
| File | Description |
|------|-------------|
| `_docs/_autodev_state.md` | Autodev orchestrator state (this skill: Phase A Step 1, complete on Step 7 write) |
| `_docs/02_document/state.json` | Document skill internal state |
| `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` | Rename leftover index — kept until B4B12 ship |
| `_docs/tasks/done/AZ-540_missions_rename_b1_local_docs.md` | B1 task spec (Done) |
| `_docs/tasks/done/AZ-542_missions_rename_b3_state_bookkeeping.md` | B3 task spec (Done) |
| `_docs/tasks/todo/AZ-544_missions_rename_b5_csproj_namespace.md` | B5 task spec |
| `_docs/tasks/todo/AZ-545_missions_rename_b6_domain_rename.md` | B6 task spec |
| `_docs/tasks/todo/AZ-546_missions_rename_b7_drop_gps_denied.md` | B7 task spec |
| `_docs/tasks/todo/AZ-547_missions_rename_b8_http_routes.md` | B8 task spec |
| `_docs/tasks/todo/AZ-548_missions_rename_b9_db_migration.md` | B9 task spec |
| `_docs/tasks/todo/AZ-551_missions_rename_b12_default_vehicle_rule.md` | B12 task spec |
### Suite-level cross-references (read-only from this repo)
| File | Description |
|------|-------------|
| `../../suite/_docs/02_missions.md` | Primary spec (post-rename) |
| `../../suite/_docs/00_top_level_architecture.md` | Topology, error envelope, pagination |
| `../../suite/_docs/00_database_schema.md` | Authoritative ER diagram |
| `../../suite/_docs/00_roles_permissions.md` | `FL` permission origin |
| `../../suite/_docs/11_gps_denied.md` | Separate `gps-denied` service (post-B7) |
| `../../suite/_docs/05_security/cmmc_l2_scorecard.md` | CMMC L2 row 3 finding (AZ-487/AZ-494) |
| `../../suite/_docs/_repo-config.yaml` | Repo registry (post-rename `name: missions`) |
## Next Step in the Autodev Existing-Code Flow
Phase A Step 2 — **Architecture Baseline Scan**: invoke `code-review/SKILL.md` in baseline mode (Phase 1 + Phase 7) against the full codebase, save the output to `_docs/02_document/architecture_compliance_baseline.md`. The scan compares the running code against the just-confirmed `architecture.md` + `module-layout.md` and flags pre-existing High/Critical structural issues. After the baseline is clean, the autodev auto-chains to Step 3 (Test Spec) — which produces `_docs/02_document/tests/traceability-matrix.md` and per-flow scenario files for F1F7, the seed set for the test suite implementation in Steps 57.
+364
View File
@@ -0,0 +1,364 @@
# Azaion.Missions — Architecture
> **NOTE (forward-looking)**: this document reflects the **post-rename, post-GPS-Denied-removal** target. Today's source still uses `Azaion.Flights` namespace, `Aircraft*`/`Flight*`/`Orthophoto*`/`GpsCorrection*` filenames, `[Route("aircrafts"|"flights")]`, and migrates 6 tables. The renames + drops are tracked under Jira AZ-EPIC + child tickets B5B12 (see `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`). The doc IS the spec for that work.
## Architecture Vision
> **Status**: confirmed-by-user (autodev `/document` Step 4.5, 2026-05-14). Source-of-truth for "what this service is and why" — downstream skills (`/refactor`, `/decompose`, `/new-task`, `/code-review`) consume this section before reading the lower-level technical sections below.
`missions` is the **edge-tier .NET 10 REST service** that owns the **mission domain** of each Azaion deployment — vehicle inventory, mission plans, waypoint sequences, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. **Exactly one instance runs per device** (Jetson Orin / OrangePI / operator-PC) alongside sibling edge services (`annotations`, detection, `autopilot`, `gps-denied`, `ui`), all sharing **ONE local PostgreSQL with per-service table ownership enforced by convention**. JWTs are minted remotely by the central `admin` service and validated locally with a shared HMAC secret; this service never calls back. The dominant pattern is **thin controller → service → linq2db active-record over a per-request scoped `DataConnection`**, with **no repository abstraction** and **no in-process message queue / event bus**.
### Components & responsibilities (6 logical components, 1 csproj)
| # | Component | Responsibility |
|---|-----------|----------------|
| 01 | `01_vehicle_catalog` | Vehicle CRUD + "is_default" exclusivity (stricter than spec — B12 decision pending) |
| 02 | `02_mission_planning` | Mission + Waypoint CRUD + the cross-service cascade-delete walk (canonical owner of the full mission ownership graph) |
| 04 | `04_persistence` | `AppDataConnection` (LinqToDB) + `DatabaseMigrator` (`CREATE TABLE IF NOT EXISTS` for the 4 owned tables post-B7 + B9) |
| 05 | `05_identity` | `JwtExtensions`; shared-secret HS256 validation; one `"FL"` policy (post-B7) |
| 06 | `06_http_conventions` | `ErrorHandlingMiddleware` + `PaginatedResponse<T>` + the unused `ErrorResponse` DTO |
| 07 | `07_host` | `Program.cs` composition root; runs migrator at startup; serves on port 8080 |
### Major data flows (7 — see `system-flows.md` for full sequences)
- **F1 Vehicle CRUD** — operator UI → vehicle service → DB.
- **F2 Mission create/read/update** — UI → mission service, with vehicle existence check.
- **F3 Mission delete + CASCADE** *(critical)* — walks across `annotations` + detection schemas; **not transaction-wrapped today** (ADR-006).
- **F4 Waypoint CRUD** — delete is a scoped F3 cascade.
- **F5 JWT bearer validation** — every protected request; local HS256, no `iss`/`aud` (CMMC L2 finding, suite-tracked under AZ-487 / AZ-494).
- **F6 Startup + schema migration** — `Program → DatabaseMigrator.Migrate → app.Run`.
- **F7 Health probe** — anonymous `GET /health`; process-liveness only.
### Architectural principles / non-negotiables (inferred from the code)
- **One PostgreSQL per device; per-service table ownership enforced by convention.** *[inferred-from: `../../suite/_docs/00_top_level_architecture.md` § Database Topology, `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`]*
- **Manual cascade-delete in code, NOT `ON DELETE CASCADE` in schema.** *[inferred-from: `Database/DatabaseMigrator.cs`, `FlightService.DeleteFlight` (today's `MissionService.DeleteMission`)]*
- **JWT validated locally with no callback to `admin`** (HS256 shared-secret). *[inferred-from: `Auth/JwtExtensions.cs`]*
- **Forward-only-additive schema bootstrap** (`CREATE TABLE IF NOT EXISTS`); B9's `DROP TABLE IF EXISTS` is the one explicit destructive step. *[inferred-from: `Database/DatabaseMigrator.cs`]*
- **Layer-organized layout** (`Controllers/`, `Services/`, `DTOs/`, `Enums/`), NOT feature-folders; one project / one root namespace; layering rules in `module-layout.md` enforced by convention not by the compiler. *[inferred-from: repository tree + `Azaion.Flights.csproj` (today's `Azaion.Missions.csproj`)]*
- **`gps-denied` is decoupled by design** — no runtime call in either direction; rows reference `mission_id` / `waypoint_id` as plain GUIDs in `gps-denied`'s own tables. *[inferred-from: ADR-007 + AZ-546 acceptance criteria]*
- **Watchtower-restart + `flight-gate` is the ONLY orchestration**; no Kubernetes; vertical scale only (one instance per device). *[inferred-from: `Dockerfile` + `../../suite/_docs/00_top_level_architecture.md`]*
### Carry-forward concerns (acknowledged, NOT in this Epic's scope)
These divergences from spec or known foot-guns are tracked in `00_discovery.md` § Spec ↔ Code Divergences and called out in component / module docs. They are deliberately deferred:
- PascalCase entity-body wire shape vs spec's camelCase (the *error envelope* is already camelCase by accidental match — see ADR-002).
- Cascade-delete is not transaction-wrapped (ADR-006); one-line fix to land opportunistically with B6.
- Swagger UI + dev-fallback secrets (`JWT_SECRET`, `DATABASE_URL`) NOT gated on `IsDevelopment()` (ADR-005).
- `"FL"` policy code retains the legacy "Flight" wording even after the service rename — fleet-wide auth change, not in this Epic.
- `Geopoint` stored as 3 flat columns (`lat`, `lon`, `mgrs`) instead of spec's single auto-converting `string GPS`.
- F2 returns `400` instead of spec's `404` on a missing `VehicleId` (`ArgumentException` mapping).
- `ErrorResponse` DTO is dead on the wire and has the wrong shape (`List<string>?` instead of spec's `object?` keyed by field name).
## 1. System Context
**Problem being solved**: Provide the edge-tier (.NET) service that owns the **mission domain** of an Azaion deployment — vehicle inventory (Plane / Copter / UGV / GuidedMissile), mission plans, waypoint sequences, and the cross-service cascade-delete that keeps the rest of the edge stack consistent when missions or waypoints are removed. The service runs **on the device** (Jetson / OrangePI / operator-PC), one instance per device, and shares the local PostgreSQL with its sibling edge services.
**System boundaries**:
- **Inside the system**: the 6 components (`01_vehicle_catalog`, `02_mission_planning`, `04_persistence`, `05_identity`, `06_http_conventions`, `07_host`), their HTTP surface, and the migrator that owns 4 PostgreSQL tables (`vehicles`, `missions`, `waypoints`, `map_objects`).
- **Outside the system**: the central `admin` service (mints JWTs); the React `ui` (consumer); the `autopilot` service (writes `map_objects` via the same DB); the `annotations` service (owns `media` + `annotations` tables); the detection pipeline (owns `detection`); the new `gps-denied` service (owns `orthophotos` + `gps_corrections` — out of this repo as of B7).
**External systems**:
| System | Integration Type | Direction | Purpose |
|--------|------------------|-----------|---------|
| `admin` (.NET, central) | JWT (HMAC shared secret) | Inbound (validation only) | Issues bearer tokens that this service validates locally; no network call back |
| Operator UI (React, edge) | REST (JSON over HTTP) | Inbound | All vehicle / mission / waypoint CRUD |
| `autopilot` (edge) | Shared DB (PostgreSQL on the same device) | Bidirectional | `autopilot` writes `map_objects` (this service owns the schema and cascade-deletes them); `autopilot` reads `missions` + `waypoints` to drive the vehicle |
| `annotations` (edge) | Shared DB | Outbound delete | `missions` cascade-deletes from `media` + `annotations` on mission/waypoint delete; `annotations` owns the schema |
| Detection pipeline (edge) | Shared DB | Outbound delete | Same pattern — `missions` cascade-deletes `detection` rows; pipeline owns the schema |
| `gps-denied` (separate edge service) | Shared DB (loose ref by GUID) | None at runtime | `gps-denied` rows reference `mission_id` / `waypoint_id` as plain GUIDs; no inbound HTTP call into `missions` and no outbound call from `missions` to `gps-denied` (decoupled by design after B7) |
| `postgres-local` (PostgreSQL 16+) | TCP | Outbound | Sole datastore. Shared with every other edge service on the same device |
## 2. Technology Stack
| Layer | Technology | Version | Rationale |
|-------|------------|---------|-----------|
| Language | C# | net10.0 | Suite-wide convention for backend services (per `../../suite/_docs/_repo-config.yaml`) |
| Web framework | ASP.NET Core (`Microsoft.NET.Sdk.Web`) | net10.0 | Built-in DI, middleware pipeline, attribute routing, JWT bearer auth |
| Data access | linq2db | 6.2.0 | Suite-wide ORM choice; explicit SQL escape hatch + attribute mapping; works well with the manual cascade pattern |
| Database driver | Npgsql | 10.0.2 | PostgreSQL native protocol driver |
| Schema bootstrap | linq2db raw `Execute` (`CREATE TABLE IF NOT EXISTS`) | — | Forward-only-additive; one `DROP TABLE IF EXISTS orthophotos / gps_corrections` block in B9 |
| Auth | `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer with HS256 (shared secret with `admin`); local validation, no callback to issuer |
| API docs | `Swashbuckle.AspNetCore` | 10.1.5 | Swagger UI + JSON spec (mounted unconditionally — see ADR-005) |
| HTTP error envelope | Custom `ErrorHandlingMiddleware` | — | Maps `KeyNotFoundException`/`ArgumentException`/`InvalidOperationException` → 404/400/409 (see ADR-002 and component `06_http_conventions` Caveats for divergences from suite spec) |
| Container | `mcr.microsoft.com/dotnet/aspnet:10.0` (multi-arch SDK build) | 10.0 | Matches edge target architectures (ARM64 dominant; AMD64 used for operator-PC) |
| CI | Woodpecker (`.woodpecker/build-arm.yml`) | — | Single docker-build-and-push job triggered on `[dev, stage, main]`; suite-standard runner |
| Hosting | Docker compose on each edge device | — | Service runs alongside `annotations`, `detection`, `autopilot`, `gps-denied`, `ui`, `postgres-local` per `../../suite/_docs/00_top_level_architecture.md` |
| Tests | **None present** | — | Tracked in `../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md`; will be filled by the autodev BUILD pipeline (Steps 3 → 6) |
**Key constraints from discovery**:
- **No `src/` directory** — the .NET project sits at the repo root (`Azaion.Missions.csproj`, `Program.cs`). `coderule.mdc` says "follow the established directory structure", and the established structure here has no `src/`. This shape persists post-rename.
- **No per-component csproj** — there is one project, effectively one root namespace (`Azaion.Missions.*` post-B5). Components are logical groupings, not compilation units. Cross-component dependencies are checked by convention (per `module-layout.md` § Allowed Dependencies), not by compiler.
- **Layer-organized, not feature-organized layout** — `Controllers/`, `Services/`, `DTOs/`, `Enums/`, `Auth/`, `Middleware/`, `Database/` at the root. Component `Owns` globs are file-by-file lists across multiple top-level directories. See `module-layout.md` § Layout Rules.
- **One PostgreSQL shared with all edge services** — the per-service ownership pattern is the load-bearing convention (`../../suite/_docs/00_top_level_architecture.md` § Database Topology).
- **No automated tests** — every change today is human-reviewed only. Adding a `tests/Azaion.Missions.Tests/` sibling project is on the autodev backlog (Steps 57 of existing-code flow).
## 3. Deployment Model
**Environments**: Development (local `dotnet run` + local PostgreSQL), edge production (Docker compose on each device).
**Infrastructure**:
- **On-prem only** — every Azaion edge deployment is on customer-owned hardware (Jetson Orin / OrangePI / operator-PC). No managed cloud.
- **Container orchestration**: plain `docker compose` per device (see `../../suite/_infra/_compose/`). No Kubernetes.
- **Scaling**: vertical only — exactly one instance of `missions` per edge device, sized to the device. Horizontal scale-out of edge services is explicitly out of scope (each device is its own deployment).
- **Watchtower** restarts the container if it crashes; `flight-gate` (per `../../suite/_docs/00_top_level_architecture.md`) prevents container restart mid-mission.
**Image / port wiring** (post-B10):
- Image tag: `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` (was `azaion/flights:*-arm` pre-B10).
- Container `EXPOSE 8080`; edge compose maps host port `5002:8080`.
- Entrypoint: `dotnet Azaion.Missions.dll` (was `Azaion.Flights.dll` pre-B5).
- Multi-arch build: `--platform=$BUILDPLATFORM`, `dotnet publish --os linux --arch $arch` so a single Dockerfile produces both ARM64 and AMD64 image variants.
**Environment-specific configuration**:
| Config | Development | Edge production |
|--------|-------------|-----------------|
| `DATABASE_URL` | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` (hardcoded fallback) | `postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` (compose env) |
| `JWT_SECRET` | `development-secret-key-min-32-chars!!` (hardcoded fallback) | Provisioned secret shared across `admin` + every backend service on the device |
| Logging | Console / Debug (ASP.NET Core defaults) | Console only (no Serilog / structured logging configured today) |
| Swagger | enabled | enabled (NOT gated on `IsDevelopment()` — see ADR-005) |
| CORS | `AllowAnyOrigin/Method/Header` | `AllowAnyOrigin/Method/Header` (no environment override; assumed safe behind suite reverse proxy) |
| Migrator | runs at process start | runs at process start (idempotent `IF NOT EXISTS` + the one B9 `DROP TABLE IF EXISTS` block for legacy GPS-Denied tables on previously-deployed devices) |
For containerization details, CI pipeline structure, and observability, see `_docs/02_document/deployment/`.
## 4. Data Model Overview
> Detailed entity column shapes live in `_docs/02_document/modules/entities.md`. Detailed cross-service ownership lives in `_docs/02_document/data_model.md`.
**Core entities** (post-B7 shape — 7 entity files, 4 owned tables + 3 borrowed read-only stubs):
| Entity | Description | Owned by component |
|--------|-------------|--------------------|
| `Vehicle` | Operator-managed inventory of mission-capable assets (Plane / Copter / UGV / GuidedMissile). 1 default at most by spec; code currently enforces "exactly one default" (see B12) | `01_vehicle_catalog` (logically); table schema in `04_persistence` |
| `Mission` | Planned mission; FK to a `Vehicle` | `02_mission_planning` (logically); table schema in `04_persistence` |
| `Waypoint` | Ordered geo-point inside a `Mission`; FK to `Mission` | `02_mission_planning` (logically); table schema in `04_persistence` |
| `MapObject` | H3-indexed detection projection written by `autopilot`; FK to `Mission` | `04_persistence` owns the schema; `autopilot` is the writer; this service cascade-deletes |
| `Media` | Borrowed read-only stub. Owned by `annotations`. Cascade-delete only | `04_persistence` declares the entity for ITable access; schema owned by `annotations` |
| `Annotation` | Borrowed read-only stub. Owned by `annotations`. Cascade-delete only | Same as `Media` |
| `Detection` | Borrowed read-only stub. Owned by detection pipeline. Cascade-delete only | Schema owned by detection pipeline; this service has cascade-delete responsibility only |
**Removed in B7+B9**: `Orthophoto` and `GpsCorrection` entities + tables. Now owned by the separate `gps-denied` service.
**Key relationships**:
- `Vehicle (1) ── (0..N) Mission``mission.vehicle_id → vehicle.id`. Existence-checked on `MissionService.CreateMission` / `UpdateMission` (the FK constraint is the safety net).
- `Mission (1) ── (0..N) Waypoint``waypoint.mission_id → mission.id`.
- `Mission (1) ── (0..N) MapObject``map_object.mission_id → mission.id`. Written by `autopilot`; cascade-deleted by `missions`.
- `Waypoint (1) ── (0..N) Media` (cross-service FK to `annotations`-owned table) — cascade-deleted by `missions`.
- `Media (1) ── (0..N) Annotation` (intra-`annotations` FK) — cascade-deleted by `missions` while walking the dependency graph.
- `Annotation (1) ── (0..N) Detection` (intra-detection FK) — cascade-deleted by `missions` while walking the dependency graph.
**No FK to `gps-denied` tables**`orthophotos` / `gps_corrections` reference `mission_id` and `waypoint_id` as plain GUIDs in the `gps-denied` service's own tables. Cleanup of those rows is `gps-denied`'s own concern; this service does NOT cascade into them.
**Data flow summary**:
- `Operator UI → missions (HTTP)` — vehicle + mission + waypoint CRUD; the dominant inbound flow.
- `admin → operator UI → missions (JWT)` — admin mints token; UI carries it to every backend; this service validates locally (HS256, shared secret).
- `autopilot → missions (DB read)``autopilot` reads `missions` + `waypoints` to drive the vehicle.
- `autopilot → missions (DB write)``autopilot` writes `map_objects`; this service owns the table schema and cascade-deletes them.
- `missions → annotations + detection (DB delete)` — cascade-delete walk during mission/waypoint delete; tears down `media`, `annotations`, `detection` rows in dependency order.
## 5. Integration Points
### Internal Communication
This service is a single .NET process. Components communicate via direct C# calls registered in DI (`07_host`). There is no in-process message queue, no RPC, no event bus.
| From | To | Protocol | Pattern | Notes |
|------|----|----------|---------|-------|
| `07_host` | `04_persistence`, `05_identity`, `06_http_conventions` | DI registration | Composition root | Wired once at startup |
| `01_vehicle_catalog` (controller → service) | `04_persistence` (`AppDataConnection`) | Direct C# call | Active-record over `ITable<Vehicle>` | Per-request scoped DB connection |
| `02_mission_planning` (controllers → services) | `04_persistence` (`AppDataConnection`) | Direct C# call | Active-record over `ITable<Mission>`, `ITable<Waypoint>`, plus cascade delete touching `MapObject`, `Media`, `Annotation`, `Detection` | Per-request scoped DB connection. **No transaction wraps cascade delete** — see `02_mission_planning` Caveats #1 |
| `02_mission_planning` (`MissionService`) | `01_vehicle_catalog` (existence) | Direct DB read against `vehicles` | Existence check | Cross-component, but reads via the shared `AppDataConnection`; no service-to-service call |
| `01_vehicle_catalog`, `02_mission_planning` (controllers) | `05_identity` (`"FL"` policy) | ASP.NET Core `[Authorize(Policy = "FL")]` attribute | Pipeline check | String-typed policy reference (see `module-layout.md` § Verification Needed #4) |
| `02_mission_planning` (`MissionService.GetMissions`) | `06_http_conventions` (`PaginatedResponse<T>`) | Direct C# type | DTO | Sole consumer of the paginated envelope |
| Every controller exception | `06_http_conventions` (`ErrorHandlingMiddleware`) | Pipeline interceptor | Exception → status mapping | Middleware is registered FIRST so it wraps everything |
### External Integrations
| External system | Protocol | Auth | Rate limits | Failure mode |
|-----------------|----------|------|-------------|--------------|
| Operator UI | REST (JSON over HTTP) | JWT bearer | None enforced | Standard HTTP error envelope (see ADR-002 for the suite-spec divergence still in code) |
| `admin` (token issuance) | None at runtime — this service validates tokens locally | Shared HMAC secret (`JWT_SECRET`) | N/A | Rejected token → `401`. No network call to `admin`, so `admin` outage does NOT take this service down (until issued tokens expire) |
| `postgres-local` | PostgreSQL wire protocol via Npgsql | Username + password (`DATABASE_URL`) | Connection pool default (Npgsql) | Connection failure → `KeyNotFoundException` cannot fire (different exception type) → middleware fallthrough → 500. Migrator failure at startup crashes the process; Watchtower restarts the container |
| `autopilot` (DB-mediated) | Shared `postgres-local` | Same DB credentials | N/A | If `autopilot` writes a `map_object` referencing a deleted mission, the FK constraint rejects the insert. If a mission delete races with an `autopilot` write, the cascade may leave one row of `map_objects` that the next mission delete would reject — small race window, no data corruption |
| `annotations`, detection pipeline (DB-mediated, schema borrowing) | Shared `postgres-local` | Same DB credentials | N/A | If `annotations` is absent at deploy time, the cascade walks `media` / `annotations` and gets `relation does not exist` → 500. In standard edge deployment all services are present (suite compose stack) — see `02_mission_planning` Caveats #6 |
| `gps-denied` (post-B7) | None — no runtime coupling. `gps-denied` owns its own tables and references `mission_id` / `waypoint_id` as plain GUIDs | N/A | N/A | Decoupled by design |
## 6. Non-Functional Requirements
> Numbers below are observable from code + Dockerfile + Woodpecker; the spec (`../../suite/_docs/02_missions.md`) does not state explicit SLOs. Where targets are inferred, that is called out.
| Requirement | Target | Measurement | Priority |
|-------------|--------|-------------|----------|
| Availability | Best-effort per-device; no multi-instance HA per device | One container per device; restart-on-crash via Watchtower; `flight-gate` prevents restart mid-mission per `../../suite/_docs/00_top_level_architecture.md` | High (per-device) |
| Latency (p95) | **Not specified.** Code uses synchronous LINQ-to-SQL with one DB round-trip per operation; cascade delete has up to 7 sequential SELECTs/DELETEs. On a local PostgreSQL on the same device this is single-digit ms typical | `/health` and CRUD endpoints; no explicit latency budget | Medium (inferred) |
| Throughput | **Not specified.** Edge deployment is one operator + one or two background consumers (`autopilot`, `ui`); load is operator-paced not load-tested | — | Low (inferred) |
| Data retention | No retention policy in this service. Data persists in `postgres-local` until manually deleted via the API or device wipe | — | — |
| Recovery (RPO/RTO) | RPO = device-local backup cadence (suite-level concern, not this service); RTO ≈ container restart time (~10s) | Watchtower restart on crash | Medium |
| Scalability | One instance per edge device; horizontal scale-out NOT supported | — | — (out of scope) |
| Cascade delete atomicity | **Currently violated**`MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` are NOT wrapped in a transaction (see `02_mission_planning` Caveats #1). Partial failure leaves orphan rows in `media` / `annotations` / `detection` / `map_objects`. Fix is one-line (`db.BeginTransactionAsync`) | Carry-forward improvement | High (data integrity) |
| API spec conformance | **Currently divergent** on entity/DTO wire shape (PascalCase vs spec camelCase) and on error envelope's missing `errors` field; the unused `ErrorResponse` DTO has wrong `Errors` shape (see ADR-002). Note: error envelope is already camelCase on case (accidental match) | Manual diff against `../../suite/_docs/00_top_level_architecture.md` § Error Response Format + § Pagination | High (cross-service contract) |
| Health endpoint | `GET /health` returns `{ status: "healthy" }` in <10ms | `Program.cs` `MapGet` | High (used by container orchestration) |
## 7. Security Architecture
**Authentication**: JWT bearer (HS256). Tokens are minted by the central `admin` service and validated locally by `05_identity` using a shared HMAC secret (`JWT_SECRET`). This service NEVER calls back to `admin`; rotation of the secret requires coordinated redeploy across every backend service that shares the secret. Per the CMMC L2 scorecard (`../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3), `iss` / `aud` validation is currently disabled; this is a known finding tracked at the suite level under AZ-487 / AZ-494 (out of this Epic's scope).
**Authorization**: Single named policy `"FL"`, gated by a `permissions` claim value. Every controller route in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The role → permission matrix lives in `../../suite/_docs/00_roles_permissions.md`. Note: the policy code `"FL"` carries the legacy "Flight" name even after the service rename to `missions`; renaming the permission code is a fleet-wide auth change (would invalidate every issued token until new ones are minted) and is **NOT** in this Epic's scope. Tracked as a TODO in `../../suite/_docs/00_roles_permissions.md`.
**Data protection**:
- **At rest**: PostgreSQL on-disk encryption is the device-level concern (suite-level, not this service). This service does not encrypt data at the column level.
- **In transit**: TLS termination is the reverse proxy's responsibility. This service does NOT enforce HTTPS redirection. The container `EXPOSE 8080` is plain HTTP; the upstream reverse proxy adds TLS.
- **Secrets management**: `DATABASE_URL` and `JWT_SECRET` are env vars. The `Program.cs` hardcoded fallbacks (`development-secret-key-min-32-chars!!`, `Password=changeme`) are dev-only and MUST be overridden in production. There is **no runtime gate** that blocks startup with the dev fallback in production — see ADR-005.
**Audit logging**: None at the application level. The only structured log emitted by app code is `06_http_conventions`' middleware `LogError(ex, "Unhandled exception")` for unhandled 500s. There is no per-request audit trail, no correlation ID, and no per-user attribution (the JWT's user-id claim is not consumed — see `05_identity` Caveats #2).
**Input validation**: None. No `[Required]` attributes, no range checks. Empty `Name`, negative `BatteryCapacity`, invalid enum int values are accepted on input. Carry-forward improvement; not in this Epic's scope.
**CORS**: `AllowAnyOrigin/Method/Header` in all environments. Spec does not mandate a CORS policy — likely safe behind the suite's edge reverse proxy (per `../../suite/_docs/00_top_level_architecture.md`) but worth confirming on first production deployment.
## 8. Key Architectural Decisions
> ADR numbering reflects what is implemented today (post-rename, post-B7). Items called out as "currently divergent" are intentional carry-forward — they are implemented choices that diverge from the suite spec; tightening them is suite-level work, not part of this Epic.
### ADR-001: One PostgreSQL per edge device, shared by all edge services
**Context**: Each edge device runs ~6 backend services (this one + `annotations`, detection, `autopilot`, `gps-denied`, plus the React `ui`). Each service needs persistent storage; running ~6 separate Postgres instances per device is operationally heavy.
**Decision**: Run ONE `postgres-local` per device. Every service connects to it; every service migrates only the tables it owns (this service owns `vehicles`, `missions`, `waypoints`, `map_objects` post-B7+B9). Cross-service reads / cascade deletes happen through ITable accessors against the shared schema.
**Alternatives considered**:
1. **One Postgres per service** — rejected: 6× the operational overhead per device for no real isolation gain (services run on the same OS anyway).
2. **SQLite per service** — rejected: cross-service queries (cascade delete walking from `mission` to `media` to `annotation` to `detection`) require a single transactional database; SQLite-per-service would require a coordination layer.
**Consequences**:
- Cross-service cascade-delete is physically possible and atomic *within one DB connection* (the transaction-wrap is a one-line carry-forward — see ADR-006).
- Schema ownership boundary is enforced by **convention**, not by access control. Any service could write to any table; the rule "only owners write" is upheld by code review.
- If `annotations` is absent from a deployment, this service's cascade-delete fails on `relation does not exist`. Standard edge compose includes all services; this is acceptable.
### ADR-002: PascalCase wire shape on entity bodies (currently divergent from suite spec)
**Context**: Spec (`../../suite/_docs/00_top_level_architecture.md` § Error Response Format + § Pagination) mandates camelCase JSON across all .NET services. Code today emits PascalCase for entity / DTO responses (`Vehicle`, `Mission`, `Waypoint`, `PaginatedResponse<Mission>`) via System.Text.Json defaults — entity property names are PascalCase and no `JsonNamingPolicy.CamelCase` is configured. **Exception (accidental match)**: the global error envelope IS already camelCase, because `ErrorHandlingMiddleware` writes an anonymous object literal `new { statusCode = ..., message }` whose property names are lowercase-first by construction; `System.Text.Json` preserves them as-is.
**Decision (current, carry-forward)**: Keep PascalCase entity bodies until a coordinated suite-wide camelCase migration. Adding `JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase` would flip every endpoint's wire shape simultaneously; the UI and `autopilot` consumers would need to be updated in lock-step.
**Alternatives considered**:
1. **Fix unilaterally now** — rejected for this Epic: would break the UI without a coordinated cutover.
2. **Per-route override** — rejected: all-or-nothing is the cleaner cutover.
**Consequences**:
- Entity/DTO HTTP responses do NOT match the suite spec on case style.
- The error envelope DOES match spec on case (camelCase) but still misses the `errors` field; the `ErrorResponse` DTO is dead on the wire (middleware writes the anonymous object instead) and its `Errors` field shape (`List<string>?`) doesn't match spec (`object?` keyed by field name) — both carry forward until the migration.
### ADR-003: Manual cascade-delete in code, not `ON DELETE CASCADE` in schema
**Context**: Mission deletion has to clean up rows across multiple tables, some of which are owned by other services (`media` / `annotations` / `detection`). Schema-level `ON DELETE CASCADE` would force the foreign service's schema to encode this service's lifecycle.
**Decision**: This service owns the cascade walk. `MissionService.DeleteMission` deletes in dependency order: `map_objects` → resolve `waypoint_ids` → resolve `media_ids` and `annotation_ids``detection``annotations``media``waypoints``missions`.
**Alternatives considered**:
1. **`ON DELETE CASCADE` at the schema level** — rejected: would require the `annotations` service to encode this service's domain in its own migration. Schema becomes coupled to consumer.
2. **Soft-delete + tombstone everywhere** — rejected: read paths everywhere would have to filter; the spec does not require it.
**Consequences**:
- The cascade walk lives in one place (`MissionService.DeleteMission` + `WaypointService.DeleteWaypoint`).
- It is **not transaction-wrapped today** (see ADR-006) — a one-line fix carried forward.
- If `gps-denied` ever adds rows that need cleanup on mission delete, that's `gps-denied`'s concern (it owns the tables and the lifecycle) — this service does not extend its cascade.
### ADR-004: Schema bootstrap via `CREATE TABLE IF NOT EXISTS` (no migration tool)
**Context**: Edge deployments are restart-driven (Watchtower picks up new images); each container start runs the migrator. A heavy migration tool (Flyway, EF Core migrations) adds dependencies and complexity.
**Decision**: `DatabaseMigrator.Migrate` runs additive `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` for the 4 owned tables. The B9 ticket adds a one-shot `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` block for fielded devices that previously ran the legacy schema.
**Alternatives considered**:
1. **EF Core / Flyway** — rejected: adds a build dependency and a state table for what is currently a 4-table schema with no column drops or type changes.
2. **External SQL scripts** — rejected: harder to keep aligned with code-side entity changes; deployment becomes two-step.
**Consequences**:
- Column drops / type changes / constraint changes will require manual SQL or a future migration tool. The B9 `DROP` is the one explicit destructive step in the migrator's history.
- No version table; the migrator is idempotent and runs every startup.
- Acceptable today; will become a real problem if the schema starts evolving frequently.
### ADR-005: Swagger + dev fallbacks not gated on `IsDevelopment()`
**Context**: ASP.NET Core's idiomatic pattern gates Swagger UI and dev-only convenience features on `app.Environment.IsDevelopment()`. Today, this service mounts Swagger unconditionally and uses hardcoded dev fallbacks for `JWT_SECRET` / `DATABASE_URL` if env vars are unset.
**Decision (current, carry-forward)**: Leave both unconditional today. Swagger UI is useful on edge devices for one-off operator debugging through the local network. The hardcoded dev fallbacks are a known foot-gun (a misconfigured production deploy will silently use the well-known secret) but they are intentional during the rename phase to keep `dotnet run` working zero-config.
**Alternatives considered**:
1. **Gate both on `IsDevelopment()`** — preferred long-term; out of this Epic.
2. **Fail-fast at startup if `JWT_SECRET` is unset** — preferred long-term; out of this Epic.
**Consequences**:
- Swagger UI is exposed on every deployment. The reverse proxy may or may not whitelist it; verify on first production rollout.
- A production deployment without `JWT_SECRET` set will silently boot with the well-known dev secret. This is a security finding tracked at the suite level (see CMMC L2 row 3).
### ADR-006: Cascade-delete is NOT transaction-wrapped (carry-forward)
**Context**: `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` issue 47 sequential `DELETE` statements across tables. Without a transaction, partial failure leaves orphan rows.
**Decision (current, carry-forward)**: Today the cascade runs autocommit-per-statement. Wrapping in `db.BeginTransactionAsync()` is one extra line and will land as part of the broader testability / refactor pass after the rename Epic.
**Alternatives considered**:
1. **Wrap now in B6** — possible; B6 is a rename, not a behavior change. The transaction wrap is a separate one-line concern that can either ride along (cheap) or land standalone.
2. **Saga / outbox pattern** — overkill for an in-process, one-DB cascade.
**Consequences**:
- Partial cascade failure leaves orphan rows in `media` / `annotations` / `detection` / `map_objects` / `waypoints`. The next mission delete or `autopilot` write may surface the inconsistency as an FK violation.
- **Recommended**: include the transaction wrap when B6 lands; it is a one-line change that materially raises the data-integrity floor.
### ADR-007: GPS-Denied moved out of this repo (B7 + B9)
**Context**: The pre-rename `flights` repo had a `03_gps_denied` component covering orthophoto upload + live-GPS / GPS-correction endpoints. Per `../../suite/_docs/11_gps_denied.md` and the rename plan, GPS-Denied is its own domain (orthorectification of satellite imagery; correction of GPS drift in denied environments) and does not belong inside the mission-planning service.
**Decision**: Delete `Database/Entities/Orthophoto.cs`, `Database/Entities/GpsCorrection.cs`, the corresponding DTOs/controllers/services, the `"GPS"` policy, and the cascade branches that referenced `orthophotos` / `gps_corrections`. Add a one-shot `DROP TABLE IF EXISTS` block to the migrator for fielded devices.
**Alternatives considered**:
1. **Keep GPS-Denied in this repo, behind a feature flag** — rejected: the new `gps-denied` service has different scaling and deployment concerns (heavier disk for orthos, separate update cadence).
2. **Leave the schema, drop only the API** — rejected: leaves dead tables on every device with no ownership; cleanup later would be harder.
**Consequences**:
- 9 entity files → 7 entity files. 6 owned tables → 4 owned tables.
- `MissionService.DeleteMission` cascade chain shrinks (no `orthophotos` / `gps_corrections` branch). One less foot-gun.
- `gps-denied` references `mission_id` / `waypoint_id` as plain GUIDs in its own tables. **No runtime coupling** between the two services — `gps-denied` is responsible for cleaning up its own rows when missions are deleted (its own concern, its own decision).
### ADR-008: One project, one root namespace (no per-component csproj)
**Context**: Some .NET solutions split each component into its own csproj for compile-time enforcement of "no upward dependencies". This service has 6 logical components but one csproj.
**Decision**: Keep one project (`Azaion.Missions.csproj` post-B5), one effective root namespace (`Azaion.Missions.*`). Layering rules in `module-layout.md` § Allowed Dependencies are enforced by **convention** (and by the autodev `code-review` Phase 7), not by the compiler.
**Alternatives considered**:
1. **Per-component csproj** — rejected for this codebase: 6 csprojs in a service this small has more solution-management overhead than it has value. Cross-component types are referenced directly, not through public APIs.
2. **Shared `Common` project + per-component projects** — rejected: same overhead as #1, plus the cross-cutting concerns (`Auth/`, `Middleware/`) are tiny and don't warrant their own DLL.
**Consequences**:
- A typo in an import won't be caught by the compiler — code review + the layering table in `module-layout.md` are the safety net.
- Solution remains easy for one engineer to navigate.
- If the service ever splits in two, the rename to per-project structure would be a separate refactor (not part of this Epic).
@@ -0,0 +1,143 @@
# Architecture Compliance Baseline
**Mode**: code-review baseline (Phase 1 + Phase 7)
**Date**: 2026-05-14
**Scope**: full pre-rename codebase (`Azaion.Flights.csproj`, root namespace `Azaion.Flights.*`)
**Spec**: `_docs/02_document/architecture.md` + `_docs/02_document/module-layout.md`
**Verdict**: **PASS_WITH_WARNINGS** (2 High Architecture findings — both resolved 2026-05-14 via doc retag, see "Resolution applied" below; 0 Critical, 0 cycles)
## Resolution applied (2026-05-14)
F1 and F2 were resolved in-band by a one-edit retag in `_docs/02_document/module-layout.md`:
the four persisted-column enums (`VehicleType` / `AircraftType`, `FuelType`, `WaypointSource`, `WaypointObjective`) are now owned by `04_persistence` (matching how `ObjectStatus` was already tagged), eliminating the Foundation ← Feature import violation without touching code. The `using Azaion.Flights.Enums;` directive in `Database/Entities/Aircraft.cs` and `Database/Entities/Waypoint.cs` is now an intra-component reference.
F3 and F4 remain open as Low-severity items — see "Recommendations for downstream steps" at the bottom.
> **Reading guide**: this is a one-time scan of the *current* code against the *post-rename* architecture documented in `architecture.md` + `module-layout.md`. Pre-rename divergences (`Azaion.Flights.*` namespace, `Aircraft*`/`Flight*`/`Orthophoto*`/`GpsCorrection*` filenames, `[Route("aircrafts"|"flights")]`, 6-table migrator, `"GPS"` policy) are explicitly NOT findings — they are tracked under Jira AZ-EPIC children **B5 (namespace), B6 (domain rename), B7 (drop GPS-Denied), B8 (HTTP routes), B9 (DB migration)**. See `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` for the full rename index. The findings below are *layering* / *structural* observations that survive the rename and that downstream skills (Step 4 testability refactor, Step 8 optional refactor) need to know about.
## Scope of the scan
**Files scanned (37 total)**: every `*.cs` under the repo root, excluding `bin/` and `obj/`. Source layout is layer-organized at the repo root (no `src/`); component ownership is by file-path glob per `module-layout.md` § Per-Component Mapping.
**Phase 7 checks performed**:
| Check | Source of truth | Approach |
|-------|-----------------|----------|
| Layer direction (no upward imports) | `module-layout.md` § Allowed Dependencies (Foundation 1 ← Feature 3 ← Composition 4) | Parsed every `using Azaion.Flights.*` directive; mapped importer/importee files to their components per the Owns globs; flagged any importer-layer < importee-layer pair |
| Public API respect (no internal imports) | `module-layout.md` § Per-Component Mapping (`Public API` rows) | The codebase has no per-component compiled public API surface (one csproj, one root namespace, no per-component `internal`). All same-namespace types are reachable. The check degenerates to "do feature components reach into another feature's files?" — none do |
| New cyclic module dependencies | Import graph | Built the per-component import graph (7 nodes today including `03_gps_denied`-leftover entities, 6 post-B7); checked for cycles |
| Duplicate symbols across components | Class/function name index | Walked every `class`/`record`/`static class`; cross-checked names against other components |
| Cross-cutting re-implementation | Architecture.md § 7 (auth/middleware/persistence ownership) | Searched feature components for inline JWT setup, custom error envelope writers, ad-hoc DB connection construction |
## Findings
| # | Severity | Category | File:Line | Title |
|---|----------|----------------|------------------------------------|------------------------------------------------------|
| 1 | High | Architecture | `Database/Entities/Aircraft.cs:2` | Foundation entity imports feature-component enums |
| 2 | High | Architecture | `Database/Entities/Waypoint.cs:2` | Foundation entity imports feature-component enums |
| 3 | Low | Maintainability| `Database/Entities/Flight.cs:2` | Dead `using Azaion.Flights.Enums;` directive |
| 4 | Low | Maintainability| `Entities/`, `Infrastructure/`, `DTOs/Requests/` | Three empty scaffolding directories at repo root |
**No findings** for: cyclic module dependencies, public-API bypass, duplicate symbols across components, cross-cutting re-implementation. Layer-direction is clean apart from the two enum-ownership cases below.
## Finding Details
### F1: Foundation entity imports feature-component enums (High / Architecture)
- **Location**: `Database/Entities/Aircraft.cs:2``using Azaion.Flights.Enums;`
- **Importer component**: `04_persistence` (Foundation, Layer 1)
- **Importee component**: `01_vehicle_catalog` (Feature, Layer 3) — `Enums/AircraftType.cs` and `Enums/FuelType.cs` are listed in `module-layout.md` under `01_vehicle_catalog` Owns
- **Symbols used**: `AircraftType` (line 14), `FuelType` (line 23)
- **Why it matters**: `module-layout.md` § Allowed Dependencies declares Foundation may NOT import from Feature surfaces. The current entity references make a structural foundation file (loaded via `AppDataConnection`'s `ITable<Aircraft>` from every feature service) depend on a *feature-owned* file. If `01_vehicle_catalog` were ever extracted to its own assembly, the entity would not compile without inverting the dependency.
- **Suggested resolution** (cheapest first):
1. **Reassign ownership** in `module-layout.md`: move `Enums/AircraftType.cs` (post-B6: `Enums/VehicleType.cs`) and `Enums/FuelType.cs` to `04_persistence` Owns. They are *persisted column types*, not feature-private — `ObjectStatus.cs` is already owned by `04` for the same reason.
2. **Or relocate the files** to `Database/Enums/` (or keep them in `Enums/` and just retag ownership) so the layout doc and disk stay aligned.
- **B-ticket interaction**: lands cleanly in **B6** (domain rename) since B6 already touches `AircraftType``VehicleType` and adds `UGV` + `GuidedMissile` variants. Re-tagging ownership at the same time is one extra `module-layout.md` edit.
### F2: Foundation entity imports feature-component enums (High / Architecture)
- **Location**: `Database/Entities/Waypoint.cs:2``using Azaion.Flights.Enums;`
- **Importer component**: `04_persistence` (Foundation, Layer 1)
- **Importee component**: `02_mission_planning` (Feature, Layer 3) — `Enums/WaypointSource.cs` and `Enums/WaypointObjective.cs` are listed in `module-layout.md` under `02_mission_planning` Owns
- **Symbols used**: `WaypointSource` (line 26), `WaypointObjective` (line 29)
- **Why it matters**: same as F1 — Layer 1 file depends on Layer 3 file. Same fix shape.
- **Suggested resolution**: reassign `WaypointSource` and `WaypointObjective` ownership to `04_persistence` in `module-layout.md`. Both are persisted column types stored as `INTEGER` in the `waypoints` table (see `DatabaseMigrator.cs:38-39`); they are domain enums, not feature-internal toggles.
- **B-ticket interaction**: orthogonal to B6 (rename only). Can land standalone as a one-line `module-layout.md` edit at the same time as F1.
### F3: Dead `using Azaion.Flights.Enums;` directive (Low / Maintainability)
- **Location**: `Database/Entities/Flight.cs:2`
- **Description**: `Flight.cs` declares `using Azaion.Flights.Enums;` but does not reference any type from that namespace (the entity has only `Guid`, `DateTime`, `string`, `Aircraft`, `List<Waypoint>` fields).
- **Suggestion**: delete the `using` directive. C# `Microsoft.NET.Sdk` enables the analyzer that flags this in IDE; CI is not configured to fail on it today, hence it has slipped in.
- **B-ticket interaction**: trivial cleanup; goes into the testability `list-of-changes.md` if other touch-ups are aggregated, otherwise leave for a future refactor pass.
### F4: Three empty scaffolding directories (Low / Maintainability)
- **Locations**:
- `Entities/` — empty (shadowed by `Database/Entities/`)
- `Infrastructure/` — empty
- `DTOs/Requests/` — empty
- **Description**: each directory exists on disk with no `*.cs` files. They are scaffolding leftovers, already flagged in `module-layout.md` § Verification Needed #3. With B7 removing GPS-Denied (the historical reason for `Entities/` and `Infrastructure/` to be earmarked for orthophoto path resolvers), the rationale for keeping them is gone.
- **Suggestion**: `git rm -r` all three as part of B5 (csproj/namespace) or a follow-up cleanup. No code-side impact; small clarity win for new contributors who currently have to scan three empty directories before realising they are noise.
- **B-ticket interaction**: cleanest as a B5 add-on (B5 already touches the project file structure). Otherwise standalone.
## Cyclic dependencies
**None detected** at the component level.
Intra-component bidirectional `[Association]` annotations exist in `04_persistence` (`Flight ↔ Aircraft`, `Flight ↔ Waypoint`) but those are within a single component and are the normal linq2db pattern. The cross-feature reads in `AircraftService.DeleteAircraft` (reads `db.Flights`) and `FlightService.CreateFlight` / `UpdateFlight` (reads `db.Aircrafts`) go *through* the foundation `AppDataConnection`, not directly across feature boundaries — graph remains acyclic at component granularity (`01 → 04 ← 02`, not `01 ↔ 02`).
## Public API respect
Not applicable in the strict sense: `module-layout.md` declares "no per-component public-API file; types referenced directly" because the codebase is one csproj / one root namespace (ADR-008). All same-namespace types are reachable from every other file. The intent of this check — "no feature-component reaches into another feature's internals" — is satisfied: the only cross-feature flow is `02_mission_planning` reading `db.Aircrafts`/`db.Flights` via the foundation `AppDataConnection`, which is the explicit per-component-mapping-blessed path.
## Duplicate symbols across components
None. Class names (`Aircraft`, `Flight`, `Waypoint`, `Vehicle`-post-B6, `Mission`-post-B6, `MapObject`, `Media`, `Annotation`, `Detection`, `Orthophoto`-pre-B7, `GpsCorrection`-pre-B7, `AppDataConnection`, `DatabaseMigrator`, `JwtExtensions`, `ErrorHandlingMiddleware`, `PaginatedResponse<T>`, `ErrorResponse`, every `Create*Request` / `Update*Request` / `Get*Query` DTO) all unique within the repo.
## Cross-cutting concerns
No re-implementation found. JWT setup lives only in `Auth/JwtExtensions.cs` (`05_identity`). Error envelope is written only in `Middleware/ErrorHandlingMiddleware.cs` (`06_http_conventions`) — note the unused `DTOs/ErrorResponse.cs` is owned by `06` and is dead-on-the-wire, not a re-implementation. DB connection is constructed only in `Program.cs` and resolved everywhere else from DI (`07_host``04_persistence`).
## Pre-rename divergences (NOT findings — tracked under B-tickets)
For traceability, the following items would be reported as Architecture findings if read against `architecture.md` literally, but are **explicitly excluded** because they are the Jira AZ-EPIC scope:
| Tracked under | Pre-rename state in code | Post-rename target in docs |
|---------------|--------------------------|----------------------------|
| **B5** | `Azaion.Flights.csproj` + `namespace Azaion.Flights.*` | `Azaion.Missions.csproj` + `namespace Azaion.Missions.*` |
| **B6** | `Aircraft*` / `Flight*` filenames; `AircraftType { Plane, Copter }` enum | `Vehicle*` / `Mission*` filenames; `VehicleType { Plane, Copter, UGV, GuidedMissile }` |
| **B7** | `Database/Entities/Orthophoto.cs` + `GpsCorrection.cs` exist; `AppDataConnection.Orthophotos` / `GpsCorrections` ITables exist; `FlightService.DeleteFlight` cascades into `orthophotos` + `gps_corrections`; `WaypointService.DeleteWaypoint` cascades into `gps_corrections`; `JwtExtensions.cs` registers a `"GPS"` policy that no controller uses | All of the above removed; one `"FL"` policy only |
| **B8** | `[Route("aircrafts")]`, `[Route("flights")]` on controllers | `[Route("vehicles")]`, `[Route("missions")]` |
| **B9** | `DatabaseMigrator` creates 6 tables (`aircrafts`, `flights`, `waypoints`, `orthophotos`, `gps_corrections`, `map_objects`) | 4 tables (`vehicles`, `missions`, `waypoints`, `map_objects`) + one-shot `DROP TABLE IF EXISTS orthophotos / gps_corrections` for fielded devices |
Code that does NOT diverge from the architecture doc and is therefore production-correct today (apart from the rename labels): the layering structure (Controllers → Services → AppDataConnection), the per-request scoped `DataConnection`, the `ErrorHandlingMiddleware` exception → status mapping, the JWT validation pattern, the migrator idempotency, and the cascade-delete *order* (only the `orthophotos` / `gps_corrections` *branches* of the cascade go away in B7).
## Carry-forward concerns already in `architecture.md` (NOT new findings)
Re-stated here only so the reader can confirm they were considered during the scan and intentionally not re-raised:
- ADR-006 — cascade delete is not transaction-wrapped. Already a recommended one-line fix to land with B6.
- ADR-005 — Swagger + dev fallbacks not gated on `IsDevelopment()`.
- ADR-002 — PascalCase entity wire shape vs spec's camelCase. Coordinated suite-wide cutover, not in this Epic.
- `module-layout.md` Verification Needed #5`"FL"` policy referenced as a string literal across feature controllers.
- F2 in `00_problem/restrictions.md` (E3) — hardcoded dev-fallback secrets in `Program.cs`.
## Recommendations for downstream steps
| Step | Recommended action |
|------|--------------------|
| **Step 4 — Code Testability Revision** | Append F3 (dead `using` in `Flight.cs`) to the testability `list-of-changes.md` since the file will likely be touched by B6 anyway. Do NOT include F1/F2 — they are layout-doc edits, not code edits, and conflating them with the testability surgical scope risks scope creep. |
| **Step 5 — Decompose Tests** | No baseline-driven action. Test specs are not affected by the layering doc. |
| **Step 6 — Implement Tests** | No baseline-driven action. |
| **Step 8 — Refactor (optional)** | Resolve F1 and F2 by reassigning enum ownership in `module-layout.md` (one Markdown edit). Optionally physically relocate the four enum files into `Database/Enums/` if a second-pass refactor wants disk-and-doc alignment. F4 (delete empty scaffolding dirs) and the carry-forward ADRs above are also reasonable Step 8 candidates. |
## Phase 1 inputs read (for traceability)
- `_docs/02_document/architecture.md` — Architecture Vision, layering rules, ADRs, NFRs
- `_docs/02_document/module-layout.md` — Per-Component Mapping, Allowed Dependencies, Verification Needed
- `_docs/00_problem/restrictions.md` — restrictions S1S15 (pinned tech stack, layout convention)
- `_docs/02_document/state.json` — confirms `current_step: complete` for documentation
- `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` — confirms which divergences are tracked under B-tickets and therefore NOT findings here
No project-side `restrictions.md` over-ride applies. `_docs/01_solution/solution.md` exists and was *not* read because the baseline scan is structural and Phase 1 explicitly limits to architecture + layout + restrictions.
@@ -0,0 +1,109 @@
# 01 — Vehicle Catalog
**Spec source**: `../../../suite/_docs/02_missions.md` § "Vehicles" (items 10-15) and `../../../suite/_docs/00_database_schema.md` § `Vehicles` table.
**Required permission**: `FL` (Operator, Operator+, Validator, CompanionPC, Admin, ApiAdmin per `../../../suite/_docs/00_roles_permissions.md`).
**Implementation status**: ✅ implemented (with one stricter-than-spec rule -- see Caveats #1).
> **NOTE (forward-looking)**: file paths and identifiers below reflect the post-rename state. Today's source still uses `Aircraft*` filenames + `[Route("aircrafts")]`. The renames are tracked under Jira AZ-EPIC children B6 (domain rename) and B8 (HTTP routes). The doc IS the spec for that work.
**Files** (post-rename):
- HTTP: `Controllers/VehiclesController.cs`
- Service: `Services/VehicleService.cs`
- DTOs: `DTOs/CreateVehicleRequest.cs`, `DTOs/UpdateVehicleRequest.cs`, `DTOs/GetVehiclesQuery.cs`, `DTOs/SetDefaultRequest.cs`
- Resource enums: `Enums/VehicleType.cs`, `Enums/FuelType.cs`
(The `Vehicle` entity itself lives in `04_persistence` because the table is part of the shared edge-PostgreSQL schema this service migrates.)
## 1. High-Level Overview
**Purpose**: Maintain the inventory of physical vehicles available to operators on this edge device. Vehicles are not just UAVs -- the catalog covers four classes today:
| `VehicleType` | Description |
|---------------|-------------|
| `Plane = 0` | Fixed-wing UAV |
| `Copter = 1` | Multirotor UAV |
| `UGV = 2` | Unmanned Ground Vehicle (per `../../../hardware/_standalone/target_acquisition/target_acquisition.md`) |
| `GuidedMissile = 3` | Single-use loitering munition |
Fields capture vehicle type, model / display name, fuel/battery characteristics, and an `is_default` flag used by the UI when starting a new mission.
**Architectural pattern**: Controller -> Service -> linq2db `ITable<Vehicle>` (active-record style; no repository abstraction).
**Upstream dependencies**: `05_identity` (`[Authorize FL]`), `04_persistence` (`AppDataConnection`, `Vehicle` entity), `06_http_conventions` (error mapping).
**Downstream consumers**: `02_mission_planning` reads vehicles (existence check on `mission.vehicle_id` in `MissionService.CreateMission` / `UpdateMission`).
## 2. Internal Interface
```csharp
public class VehicleService(AppDataConnection db) {
Task<Vehicle> CreateVehicle(CreateVehicleRequest);
Task<Vehicle> UpdateVehicle(Guid id, UpdateVehicleRequest);
Task<Vehicle> GetVehicle(Guid id);
Task<List<Vehicle>> GetVehicles(GetVehiclesQuery); // unpaginated by spec
Task DeleteVehicle(Guid id); // 409 if referenced by any mission
Task SetDefault(Guid id, SetDefaultRequest);
}
```
Throws `KeyNotFoundException` (-> 404), `InvalidOperationException` (-> 409, on delete-with-references).
## 3. External API
| Spec # | Endpoint | Method | Auth | Description |
|--------|----------|--------|------|-------------|
| 10 | `/vehicles` | POST | `FL` | Create. If `IsDefault=true`, code clears the flag on every other vehicle first (see Caveats #1). |
| 11 | `/vehicles/{id:guid}` | PUT | `FL` | Partial update -- every nullable field is applied only if non-null. |
| 12 | `/vehicles/{id:guid}` | DELETE | `FL` | 204 on success; 409 if any mission references the vehicle. |
| 13 | `/vehicles` | GET | `FL` | List, optionally filtered by `Name` (case-insensitive contains) and `IsDefault`. **Unpaginated** (matches spec). |
| 14 | `/vehicles/{id:guid}` | GET | `FL` | Single by id. 404 if missing. |
| 15 | `/vehicles/{id:guid}/default` | PATCH | `FL` | Set/clear default flag (see Caveats #1 for the exclusivity divergence). |
Wire shape: `Vehicle` entity serialized PascalCase via System.Text.Json defaults -- see `06_http_conventions` Caveats for the suite-wide divergence (spec is camelCase).
## 4. Data Access Patterns
| Query | Frequency | Hot Path | Index |
|-------|-----------|----------|-------|
| `vehicles WHERE id = ?` | Every read/update/delete | Yes | PK ✓ |
| `vehicles ORDER BY name` | List endpoint | Medium | None -- table is small in practice |
| `vehicles WHERE LOWER(name) LIKE %?%` | List with name filter | Low | None -- full scan |
| `vehicles WHERE is_default = TRUE -> UPDATE FALSE` | On every default-setting create/update/SetDefault | Medium | A partial index `WHERE is_default` would help if catalog grows |
### Storage Estimates
Not specified in spec. Vehicle tables in field deployments are typically tens to low hundreds of rows.
## 5. Implementation Details
**State Management**: Stateless service.
**Code-only business rule -- "exactly one default" exclusivity**: enforced by clearing the `is_default` flag on every other row BEFORE setting it on the target. **This is stricter than the spec** (`../../../suite/_docs/02_missions.md` §11 + §15 just `Set(IsDefault, request.IsDefault).Update()`). The exclusivity is also race-prone without a transaction (concurrent default-set ops can both clear and set, producing two defaults). Resolution tracked under Jira AZ-EPIC child B12.
**Error Handling**: Service throws domain exceptions; `06_http_conventions`' middleware maps them.
## 6. Extensions and Helpers
None.
## 7. Caveats & Edge Cases
1. **`IsDefault` exclusivity divergence from spec** -- code is stricter than spec; concurrent ops are race-prone (no transaction). Tracked as B12.
2. **`SetDefault(false)` does not preserve "at least one default exists"** -- caller can leave the system with zero defaults.
3. **No validation on request DTOs** (no `[Required]`, no range checks): empty `Name`, negative `BatteryCapacity`, invalid enum int values, etc., are accepted.
4. **Entity returned on the wire** with no DTO mapping -- couples DB column shape to HTTP response shape. Today benign because `Vehicle` has no associations.
5. **Case-insensitive search via `LOWER(...)`** -- full-table scan; fine while the catalog is small.
6. **`FuelType` may not fit `GuidedMissile`** -- the existing `{ Electric, Gasoline, Diesel }` set assumes a powered, reusable vehicle. Carry forward as Phase C decision (see plan); may spawn a follow-up ticket to allow a `None` value or make `FuelType` nullable for missiles.
## 8. Dependency Graph
**Must be implemented after**: `05_identity`, `06_http_conventions`, `04_persistence`.
**Can be implemented in parallel with**: `02_mission_planning` (modulo the existence-check coupling).
**Blocks**: `02_mission_planning` (existence check), `07_host`.
## 9. Logging Strategy
No app-level logs in this component. Errors surface via `06_http_conventions`' middleware only.
@@ -0,0 +1,143 @@
# 02 — Mission Planning (Missions + Waypoints + Cross-Service Cascade)
**Spec source**: `../../../suite/_docs/02_missions.md` § "Missions" (items 1-9), `../../../suite/_docs/00_database_schema.md` § `Missions` + `Waypoints`.
**Required permission**: `FL`.
**Implementation status**: ✅ implemented (with two divergences -- see Caveats).
> **NOTE (forward-looking)**: file paths, route prefixes, and identifiers below reflect the post-rename state. Today's source still uses `Flight*` filenames + `[Route("flights")]` and the cascade still touches `orthophotos` + `gps_corrections`. Renames + cascade shrink tracked under Jira AZ-EPIC children B6 (rename), B7 (GPS-Denied removal), B8 (HTTP routes).
**Files** (post-rename):
- HTTP: `Controllers/MissionsController.cs` (parent + nested waypoint routes)
- Services: `Services/MissionService.cs`, `Services/WaypointService.cs`
- DTOs: `DTOs/CreateMissionRequest.cs`, `DTOs/UpdateMissionRequest.cs`, `DTOs/GetMissionsQuery.cs`, `DTOs/CreateWaypointRequest.cs`, `DTOs/UpdateWaypointRequest.cs`, `DTOs/GeoPoint.cs`
- Resource enums: `Enums/WaypointSource.cs`, `Enums/WaypointObjective.cs`
(Entity row maps live in `04_persistence`.)
## 1. High-Level Overview
**Purpose**: Own the **mission lifecycle** for an edge deployment. A "mission" is a planned record with a name, creation timestamp, and assigned vehicle (Plane / Copter / UGV / GuidedMissile); "waypoints" are the ordered geo-points (with altitude, source, and objective) that define the mission's route. This component is consumed by `autopilot` (reads the mission and waypoints to drive the vehicle) and by the `ui` (map view + planning UI).
**Architectural pattern**: Aggregate root (`Mission`) with a sub-aggregate (`Waypoint`). Manual cascade-delete -- schema declares plain `REFERENCES` (no `ON DELETE CASCADE`); this service walks the dependency graph by hand.
**Cross-service contract -- the cascade**: when a mission or waypoint is deleted, this service **also** tears down rows in tables it does NOT own the schema for: `media` + `annotations` (owned by the `annotations` service) and `detection` (owned by the detection pipeline), plus its own `map_objects`. Per `../../../suite/_docs/02_missions.md` §5 + §9 this is the canonical, spec-defined behavior -- this service is the only place that knows the full mission ownership graph and is contractually responsible for this cleanup. The shared local PostgreSQL on the edge device makes the multi-table cascade physically possible in one connection.
**Removed from cascade in B7**: `orthophotos` and `gps_corrections`. Those tables now live in the separate `gps-denied` service per `../../../suite/_docs/11_gps_denied.md`. `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` no longer reference them.
**Upstream dependencies**: `05_identity` (`[Authorize FL]`), `04_persistence`, `06_http_conventions`, `01_vehicle_catalog` (existence check on `vehicle_id`).
**Downstream consumers** (live runtime): `autopilot` (reads missions + waypoints), `ui` (planning + map). The new `gps-denied` service references `mission_id` and `waypoint_id` from its own tables but does NOT depend on this service at runtime; cleanup of its rows is its own concern.
## 2. Internal Interface
```csharp
public class MissionService(AppDataConnection db) {
Task<Mission> CreateMission(CreateMissionRequest);
Task<Mission> UpdateMission(Guid id, UpdateMissionRequest);
Task<Mission> GetMission(Guid id);
Task<PaginatedResponse<Mission>> GetMissions(GetMissionsQuery);
Task DeleteMission(Guid id); // cross-service cascade
}
public class WaypointService(AppDataConnection db) {
Task<Waypoint> CreateWaypoint(Guid missionId, CreateWaypointRequest);
Task<Waypoint> UpdateWaypoint(Guid missionId, Guid waypointId, UpdateWaypointRequest);
Task<List<Waypoint>> GetWaypoints(Guid missionId); // unpaginated by spec
Task DeleteWaypoint(Guid missionId, Guid waypointId); // cross-service cascade
}
```
Throws `KeyNotFoundException` (-> 404), `ArgumentException` (-> 400, when referenced `vehicle_id` doesn't exist).
## 3. External API
### Missions
| Spec # | Endpoint | Method | Auth | Description |
|--------|----------|--------|------|-------------|
| 1 | `/missions` | POST | `FL` | Create. Body `CreateMissionRequest`. Code throws `ArgumentException -> 400` if `VehicleId` doesn't exist; spec says 404 -- minor divergence. |
| 2 | `/missions/{id:guid}` | PUT | `FL` | Partial update (Name and/or VehicleId). |
| 7 | `/missions/{id:guid}` | GET | `FL` | Single by id. |
| 8 | `/missions` | GET | `FL` | Paginated list. Query: `Name?`, `FromDate?`, `ToDate?`, `Page=1`, `PageSize=20`. Returns `PaginatedResponse<Mission>` (envelope from `06_http_conventions`). |
| 9 | `/missions/{id:guid}` | DELETE | `FL` | Cascade-deletes waypoints, media, annotations, detection, map_objects (in dependency order). |
### Waypoints (nested under mission)
| Spec # | Endpoint | Method | Auth | Description |
|--------|----------|--------|------|-------------|
| 3 | `/missions/{id:guid}/waypoints` | POST | `FL` | Create. Body `CreateWaypointRequest`. 404 if mission missing. |
| 4 | `/missions/{id:guid}/waypoints/{wpId:guid}` | PUT | `FL` | **Full overwrite** of all waypoint fields (Caveats #2 -- diverges from partial-update intent). |
| 5 | `/missions/{id:guid}/waypoints/{wpId:guid}` | DELETE | `FL` | Cascade-deletes related media, annotations, detection. |
| 6 | `/missions/{id:guid}/waypoints` | GET | `FL` | Ordered by `OrderNum`. Unpaginated (matches spec). |
Wire shape: PascalCase (suite-wide divergence -- see `06_http_conventions`).
## 4. Data Access Patterns
### Read queries
| Query | Frequency | Hot Path | Index |
|-------|-----------|----------|-------|
| `missions WHERE id = ?` | Every read/update/delete | Yes | PK ✓ |
| `missions WHERE ... ORDER BY created_date DESC LIMIT N OFFSET M` (+ count) | Listing | Yes | None on `created_date` -- could be added |
| `vehicles WHERE id = ?` (existence) | Every mission create / update with vehicle change | Yes | PK ✓ (cross-component) |
| `waypoints WHERE mission_id = ? AND id = ?` | Per-waypoint read/update/delete | Yes | PK + `ix_waypoints_mission_id` ✓ |
| `waypoints WHERE mission_id = ? ORDER BY order_num` | Nested list | Medium | `ix_waypoints_mission_id` (sort still in-memory) |
### Cascade-delete writes (`MissionService.DeleteMission`)
In strict dependency order:
1. `DELETE FROM map_objects WHERE mission_id = ?` (autopilot-written, owned-here schema)
2. Resolve `waypointIds = SELECT id FROM waypoints WHERE mission_id = ?`
3. If any: resolve `mediaIds`, `annotationIds`, then `DELETE FROM detection`, `DELETE FROM annotations`, `DELETE FROM media` *(cross-service tables -- schema owned by annotations + detection pipeline)*
4. `DELETE FROM waypoints WHERE mission_id = ?`
5. `DELETE FROM missions WHERE id = ?`
`WaypointService.DeleteWaypoint` does the equivalent steps 2-4 scoped to one waypoint.
**No transaction wraps either cascade** -- partial failure leaves orphan rows. Tracked as Caveat #1; would be a one-line fix (`db.BeginTransactionAsync()`).
### Caching
None.
### Storage Estimates
Not specified.
## 5. Implementation Details
**State Management**: Stateless services.
**Key business rules**:
- `mission.vehicle_id` must reference an existing vehicle (validated on create + on update if changed).
- `waypoint.mission_id` must reference an existing mission (validated on create).
- Cascade tables must be deleted in child-before-parent order due to FK constraints.
**Error Handling**: Services throw; `06_http_conventions` middleware maps.
## 6. Extensions and Helpers
`PaginatedResponse<T>` (defined in `06_http_conventions`) is consumed only by this component.
## 7. Caveats & Edge Cases
1. **No transaction around cascade delete** -- partial failure orphans rows in `media`, `annotations`, `detection`, `map_objects`, or `waypoints`. Wrapping in `db.BeginTransactionAsync()` is one extra line and would make the cascade atomic.
2. **`UpdateWaypoint` overwrites all fields** even though the request looks "partial-shaped" -- sending `{}` zeroes out coordinates and resets enums. Spec §4 also overwrites all fields, but spec uses the auto-converting `Geopoint` type so a missing `Geopoint` would be `null` not zero. With code's 3-flat-fields shape, this is more error-prone.
3. **Geopoint shape divergence from spec**: spec defines a single `string GPS` with auto-conversion (`Lat <-> MGRS`). Code uses 3 separate columns with no conversion. Carries through `Waypoint`, `MapObject`, and the request DTOs.
4. **Vehicle existence check + mission insert is non-transactional** -- TOCTOU window for vehicle delete is mitigated by the FK (which would reject the insert), but the error UX would surface as a 500 instead of a 400 in that race.
5. **No reorder endpoint** -- N waypoints reordered = N PUTs, racy.
6. **Cascade depends on cross-service tables existing in the same DB.** In standard edge deployment this is guaranteed (annotations/detection migrate them in the same compose stack, same `postgres-local`). In any deployment where those services are absent, the cascade will throw `relation does not exist`.
7. **Entity returned on the wire** with `[Association]` properties (`Mission.Vehicle`, `Mission.Waypoints`, `Waypoint.Mission`); LinqToDB does NOT eager-load by default on `FirstOrDefaultAsync(predicate)`, so they serialize as `null` / `[]`. Verify in Step 4 against actual responses.
8. **Spec §1 says 404 on missing VehicleId**; code throws `ArgumentException` which maps to **400**. Minor divergence.
## 8. Dependency Graph
**Must be implemented after**: `05_identity`, `06_http_conventions`, `04_persistence`, `01_vehicle_catalog`.
**Blocks**: `07_host`.
## 9. Logging Strategy
No app-level logs.
@@ -0,0 +1,122 @@
# 04 — Persistence (Edge PostgreSQL)
**Spec source**: `../../../suite/_docs/00_database_schema.md` (authoritative ER diagram), `../../../suite/_docs/00_top_level_architecture.md` § Database Topology (per-edge-device PostgreSQL pattern).
**Implementation status**: ✅ implemented -- the 4 owned tables migrate cleanly; the 3 borrowed tables read/delete cleanly under standard edge deployment.
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's source still has 6 owned tables (incl. `orthophotos`, `gps_corrections`) and 9 entity files (incl. `Aircraft.cs`, `Flight.cs`, `Orthophoto.cs`, `GpsCorrection.cs`). Renames + table drops tracked under Jira AZ-EPIC children B5 (namespace), B6 (rename), B7 (GPS-Denied removal), B9 (DB migration).
**Files** (post-rename):
- Connection / migrator: `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`
- Owned-table entities (4): `Database/Entities/{Vehicle, Mission, Waypoint, MapObject}.cs`
- Borrowed-table entities (3): `Database/Entities/{Media, Annotation, Detection}.cs`
- Cross-cutting enum: `Enums/ObjectStatus.cs`
## 1. High-Level Overview
**Purpose**: All PostgreSQL access for this service against the **shared local edge PostgreSQL**. Owns the LinqToDB `DataConnection` (one per HTTP request), the entity row maps, and the in-process schema bootstrap. The shared-DB pattern is documented in `../../../suite/_docs/00_top_level_architecture.md` § Database Topology -- every edge service connects to the same `postgres-local` and migrates only its own tables.
**Architectural pattern**: linq2db `DataConnection` + attribute-mapped entities. No repository abstraction.
**Upstream dependencies**: None.
**Downstream consumers**: `01_vehicle_catalog` (`VehicleService`), `02_mission_planning` (`MissionService`, `WaypointService`), `07_host` (registers the connection + runs the migrator).
## 2. Internal Interface
```csharp
public class AppDataConnection(DataOptions options) : DataConnection(options) {
// Owned tables -- schema migrated by this service
ITable<Vehicle> Vehicles; // owned + written
ITable<Mission> Missions; // owned + written
ITable<Waypoint> Waypoints; // owned + written
ITable<MapObject> MapObjects; // owned schema; written by autopilot
// Borrowed tables -- schema migrated by other suite services
ITable<Media> Media; // owned by `annotations` service
ITable<Annotation> Annotations; // owned by `annotations` service
ITable<Detection> Detections; // owned by detection pipeline
}
public static class DatabaseMigrator { static void Migrate(AppDataConnection db); }
```
Entity surface -- see `modules/entities.md` for column-level shape.
## 3. External API
Not applicable.
## 4. Data Access Patterns
### Tables this service migrates (owned)
| Table | Schema source | Writers | Indexes |
|-------|---------------|---------|---------|
| `vehicles` | this migrator | `01_vehicle_catalog` | PK only |
| `missions` | this migrator | `02_mission_planning` | PK + `ix_missions_vehicle_id` |
| `waypoints` | this migrator | `02_mission_planning` | PK + `ix_waypoints_mission_id` |
| `map_objects` | this migrator | `autopilot` (per `../../../suite/_docs/06_autopilot_design.md`) | PK + `ix_map_objects_mission_id` |
**Removed in B7 + B9**: `orthophotos` and `gps_corrections`. Those tables now live in the separate `gps-denied` service. `DatabaseMigrator` includes a one-shot `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` for fielded edge devices that previously ran the legacy schema (B9).
### Tables this service borrows (NOT migrated here, intentional cross-service ownership)
| Table | Schema source | Writers | This service's interaction |
|-------|---------------|---------|----------------------------|
| `media` | `annotations` migrator | `annotations` (Media CRUD) | Read `id`, `waypoint_id`; cascade-delete only (during mission/waypoint delete) |
| `annotations` | `annotations` migrator | `annotations` (Annotations CRUD) | Read `id`, `media_id`; cascade-delete only |
| `detection` (singular) | detection pipeline migrator | `detections` / `ai-training` | Read `id`, `annotation_id`; cascade-delete only |
This split matches the suite-wide pattern in `../../../suite/_docs/00_top_level_architecture.md` § Database Topology and `../../../suite/_docs/01_annotations.md` § Database. Each edge service migrates its own tables; all services see the full shared schema through their own `DataConnection`.
### Caching Strategy
None.
### Storage Estimates
Not specified in spec.
### Data Management
- **Seed data**: none. The migrator only creates schema.
- **Rollback**: not built-in. Forward-only-additive (`IF NOT EXISTS`); the B9 DROPs are the one explicit destructive step.
## 5. Implementation Details
**State Management**: `DataConnection` is per-HTTP-request scoped (registered via `AddScoped` in `07_host`). Each request gets its own physical Npgsql connection from the pool. All services within one request share that connection.
**Algorithmic Complexity**: Trivial -- direct column selects, FK joins via `[Association]`, single-table inserts/updates/deletes.
**Key Dependencies**:
| Library | Version | Purpose |
|---------|---------|---------|
| `linq2db` | 6.2.0 | LINQ -> SQL provider, attribute mapping, async extensions |
| `Npgsql` | 10.0.2 | PostgreSQL driver |
**Error Handling**: linq2db / Npgsql exceptions propagate up; if they happen to be `KeyNotFoundException` / `Argument...` (rare), the global middleware in `06_http_conventions` maps them. Otherwise -> 500.
## 6. Extensions and Helpers
None.
## 7. Caveats & Edge Cases
1. **No schema versioning** -- additive `IF NOT EXISTS` only. Column drops, type changes, constraint changes require manual SQL or a migration tool. Acceptable today; will become a problem when the schema evolves under a deployed fleet. The B9 `DROP` is the one explicit exception.
2. **No transaction wrapping in `Migrate`** -- multi-statement `Execute` runs as autocommit-per-statement. All statements are individually idempotent so partial failure is recoverable on next startup.
3. **Mixed PK types**: `Guid` for in-house tables, `string` for `media`, `annotations` (XxHash64-based per `../../../suite/_docs/00_database_schema.md`). The TEXT-PK entities are the ones whose IDs are computed from file content, allowing dedup across services.
4. **Geopoint columns split into 3 fields** (`lat`, `lon`, `mgrs`) -- diverges from the spec's single `string GPS` representation. Carry the divergence to verification log.
5. **`detection` table singularity** -- owned by another service; not this service's call to rename.
6. **`LOWER(...)` indexes absent** -- case-insensitive name search is full-table scan. Fine while tables are small.
7. **No SQL logging configured** -- debug LinqToDB issues by enabling `DataConnection.WriteTraceLine` or wrapping the provider; not done today.
## 8. Dependency Graph
**Must be implemented after**: nothing internal.
**Can be implemented in parallel with**: `05_identity`, `06_http_conventions`.
**Blocks**: `01_vehicle_catalog`, `02_mission_planning`, `07_host`.
## 9. Logging Strategy
LinqToDB defaults -- no SQL logging configured.
@@ -0,0 +1,102 @@
# 05 — Identity & Authorization
**Spec source**: `../../../suite/_docs/10_auth.md` (suite-wide JWT model), `../../../suite/_docs/00_roles_permissions.md` (the `FL` permission code).
**Implementation status**: ✅ implemented. Single policy `FL` is declared and consumed by every controller.
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's `JwtExtensions.cs` also declares a `"GPS"` policy reserved for the (now-removed-from-this-repo) GPS-Denied endpoints. After Jira AZ-EPIC child B7 lands, only `"FL"` remains.
**Files**: `Auth/JwtExtensions.cs`
## 1. High-Level Overview
**Purpose**: Validate JWT bearer tokens issued by the remote `admin` service and expose the named authorization policy (`FL`) used by controllers in the feature components. This service does not issue tokens -- it consumes them.
**Architectural pattern**: ASP.NET Core extension method (`AddJwtAuth`) configuring `IServiceCollection` at DI time.
**Upstream dependencies**: None internally.
**Downstream consumers**: `07_host` (calls `AddJwtAuth(jwtSecret)` once); `01_vehicle_catalog`, `02_mission_planning` (controllers carry `[Authorize(Policy = "FL")]`).
## 2. Internal Interface
```csharp
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret);
```
Side effects: registers `JwtBearerDefaults.AuthenticationScheme` and one named authorization policy in DI:
| Policy | Requirement |
|--------|-------------|
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` |
## 3. Suite-wide JWT pattern
This is the canonical "every backend service" identity model in the Azaion suite. Per `../../../suite/_docs/00_top_level_architecture.md` and `../../../suite/_docs/10_auth.md`:
```
┌─────────────────────┐ ┌──────────────────────┐
│ Operator UI │ POST /login │ admin (.NET, remote) │
│ (React, edge) │ ──────────────► │ central user DB │
│ │ ◄────────────── │ mints HS256 JWT │
│ │ Bearer JWT │ (claim: permissions)│
└──────────┬──────────┘ └──────────────────────┘
│ Bearer JWT (the SAME token reused for every service)
├──────────────────► annotations (.NET, edge) -- ANN claim
├──────────────────► missions (.NET, edge) -- FL claim ◄── this service
├──────────────────► satellite-provider (.NET, remote) -- ADM claim
└──────────────────► (any future .NET service)
```
Every service (admin, annotations, missions, satellite-provider, ...) shares one HMAC secret (`JWT_SECRET`) and validates tokens locally with no network round-trip. The user logs in once at the UI; the resulting bearer token is reusable across every service. **This service neither issues tokens nor talks to the central user DB** -- it only validates.
The `permissions` claim drives per-service `[Authorize(Policy = "...")]` checks. The role -> permission matrix lives in `../../../suite/_docs/00_roles_permissions.md`. All routes here require `FL`.
## 4. External API
None directly. Auth contract is observable only via `401 Unauthorized` / `403 Forbidden` on protected routes.
## 5. Data Access Patterns
None.
## 6. Implementation Details
**Algorithm**: HMAC-SHA256 signature validation via `SymmetricSecurityKey(UTF-8(jwtSecret))`. Matches the suite-wide shared-secret model.
**Token validation flags**:
- `ValidateIssuerSigningKey = true`
- `ValidateLifetime = true` (with `ClockSkew = 1 minute` -- tighter than .NET's 5-minute default)
- `ValidateIssuer = false`, `ValidateAudience = false` -- `iss` / `aud` NOT enforced (consistent with shared-secret intra-suite model). Per the CMMC L2 scorecard (`../../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3), this is a known finding tracked at the suite level under AZ-487/AZ-494; the remediation will copy the `satellite-provider` pattern across `annotations` and `missions`.
**Key Dependencies**:
| Library | Version | Purpose |
|---------|---------|---------|
| `Microsoft.AspNetCore.Authentication.JwtBearer` | 10.0.5 | JWT bearer middleware + handler |
| `Microsoft.IdentityModel.Tokens` | (transitive) | `SymmetricSecurityKey`, `TokenValidationParameters` |
## 7. Extensions and Helpers
None.
## 8. Caveats & Edge Cases
1. **Shared-secret trust model** -- any service that knows `JWT_SECRET` can mint tokens this API will accept. Not safe for multi-tenant or third-party token issuance. Consistent with the rest of the suite; tightening this is suite-wide work, not a per-service decision.
2. **No claim type for "user id" is consumed** -- only the `permissions` claim is checked. Services don't know who is calling them; per-user audit trails / business rules cannot be enforced at the service layer today. When a future feature needs an "applied by" attribution this gap will need to close.
3. **No offline-grace-window logic in this service** -- `../../../suite/_docs/10_auth.md` describes an offline JWT cache; that lives in the UI / `admin` consumption pattern, not here.
4. **Hardcoded fallback secret** in `Program.cs` (`"development-secret-key-min-32-chars!!"`) is dev-only. Production deployments MUST set `JWT_SECRET`.
5. **`FL` permission code carries the legacy "Flight" name even after the service rename to `missions`.** The plan documents this explicitly: changing the permission code is a fleet-wide auth change (would break every issued token until new ones are minted) and is **NOT** in this Epic's scope. Tracked as a TODO in `../../../suite/_docs/00_roles_permissions.md`.
## 9. Dependency Graph
**Must be implemented after**: nothing.
**Can be implemented in parallel with**: `04_persistence`, `06_http_conventions`.
**Blocks**: `07_host`, `01_vehicle_catalog`, `02_mission_planning`.
## 10. Logging Strategy
ASP.NET Core's JwtBearer handler logs token validation outcomes at default levels (Information / Debug). Not customized.
@@ -0,0 +1,117 @@
# 06 — HTTP Conventions (Suite-Standard Wire Layer)
**Spec source**: `../../../suite/_docs/00_top_level_architecture.md` § "Error Response Format" + § "Pagination". These are **suite-wide** — all .NET services (`missions`, `annotations`, `admin`, `satellite-provider`) are expected to emit the same shapes.
**Implementation status**: ⚠ **DIVERGES from suite spec on entity bodies (PascalCase) and on the error envelope's missing `errors` field**. The error envelope IS already camelCase on case (an accidental match — the anonymous object literal uses lowercase property names). See Caveats.
**Files**: `Middleware/ErrorHandlingMiddleware.cs`, `DTOs/ErrorResponse.cs`, `DTOs/PaginatedResponse.cs`
## 1. High-Level Overview
**Purpose**: Implement the suite's two cross-cutting wire conventions:
1. **Error envelope**`{ statusCode, message, errors? }` (camelCase, `errors` is `object?` keyed by field name) — emitted by the global exception → JSON middleware.
2. **Paginated response envelope**`{ items, totalCount, page, pageSize }` (camelCase) — wrapped around list endpoints.
**Architectural pattern**: ASP.NET Core middleware (pipeline interceptor) + plain DTO types.
**Upstream dependencies**: None internally.
**Downstream consumers**: `07_host` (registers middleware first in pipeline); `02_mission_planning` (consumes `PaginatedResponse<Mission>` from `MissionService.GetMissions`); every component benefits indirectly from the global error handler.
## 2. Internal Interface
```csharp
public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger) {
Task Invoke(HttpContext context);
}
public class ErrorResponse { // currently unused on the wire (Caveats)
int StatusCode;
string Message;
List<string>?Errors; // wrong shape per spec — see Caveats
}
public class PaginatedResponse<T> {
List<T> Items;
int TotalCount;
int Page;
int PageSize;
}
```
## 3. External API
Not an endpoint owner — defines the **error response wire shape** (which deviates from spec today).
### Spec-mandated shape (`../../../suite/_docs/00_top_level_architecture.md` § Error Response Format)
```json
{
"statusCode": 400,
"message": "Missing required fields",
"errors": {
"Name": ["Name is required"]
}
}
```
### Code's actual shape (anonymous object via `JsonSerializer.Serialize(new { statusCode, message })` with no naming policy override)
```json
{
"statusCode": 404,
"message": "Vehicle <guid> not found"
}
```
Property names are camelCase (the anonymous-type property names `statusCode` / `message` are written lowercase-first in code, and `System.Text.Json` preserves them as-is when no `JsonNamingPolicy` is configured). Two divergences from spec remain: no `errors` field, and the `ErrorResponse` DTO is unused (middleware writes the anonymous object instead, and the DTO's `Errors: List<string>?` is the wrong shape per spec — should be `object?` keyed by field name).
### Status code mapping (consistent with spec where there's overlap)
| Exception type | HTTP status |
|----------------|-------------|
| `KeyNotFoundException` | 404 Not Found |
| `ArgumentException` (base — covers `ArgumentNullException`, etc.) | 400 Bad Request |
| `InvalidOperationException` | 409 Conflict |
| anything else | 500 Internal Server Error (message generic, exception logged) |
## 4. Data Access Patterns
None.
## 5. Implementation Details
**State Management**: Stateless.
**Key Dependencies**: `System.Text.Json` (response serialization), `Microsoft.Extensions.Logging.ILogger<T>`.
**Error Handling Strategy**: This component IS the error handler. Recoverable domain failures (`KeyNotFound`, `Argument`, `InvalidOperation`) are mapped to specific status codes with the exception's message; everything else is a 500 with a sanitized message and a logged stack trace.
## 6. Extensions and Helpers
`ErrorResponse` and `PaginatedResponse<T>` could move to a shared helpers folder if a future component spawns more wire-shape concerns; today only `PaginatedResponse<T>` is consumed (by `MissionService.GetMissions`).
## 7. Caveats & Edge Cases
1. **PascalCase wire shape for entity bodies** vs. suite-spec camelCase. Controller responses that return entities (`Ok(vehicle)`, `Ok(mission)`, `PaginatedResponse<Mission>`) serialize PascalCase property names because the entity / DTO types are declared PascalCase and no `JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase` is configured. **Exception**: the global error envelope IS already camelCase (the anonymous object literal uses lowercase property names directly).
2. **`ErrorResponse` DTO is dead on the wire** — middleware writes an anonymous object instead. AND the DTO's `Errors` is `List<string>?` while spec says `object?` (per-field validation arrays keyed by field name). Two divergences in one DTO. Note: even if the DTO were used, its property names would serialize as PascalCase (`StatusCode`, `Message`, `Errors`) and would diverge from spec on case — additional reason the anonymous-object workaround happens to align with spec on case.
3. **No `errors` field emitted** — even when it would be relevant (validation failures). Today the codebase has no validation attributes anyway, so 400s come from `ArgumentException` with a single `Message`. When validation is added, the spec's per-field shape will need to be implemented.
4. **`InvalidOperationException → 409`** is non-standard; any third-party library throwing `InvalidOperationException` for an unrelated reason becomes a 409, masking the real cause. In this codebase the only intentional use is `VehicleService.DeleteVehicle` ("vehicle is referenced by missions" — a true 409).
5. **No correlation ID / request ID** in the error body — production support has to grep logs by timestamp.
6. **`PaginatedResponse<T>` is used by exactly one endpoint** (missions list). `vehicles` and `waypoints` listings are unpaginated by spec, so this is correct.
## 8. Dependency Graph
**Must be implemented after**: nothing.
**Can be implemented in parallel with**: `04_persistence`, `05_identity`.
**Blocks**: `07_host` (pipeline order matters — must be in DI by the time `app.UseMiddleware<ErrorHandlingMiddleware>` runs); `02_mission_planning` (uses `PaginatedResponse<T>`).
## 9. Logging Strategy
| Log Level | When | Example |
|-----------|------|---------|
| ERROR | Unhandled exception caught by the catch-all branch | `Unhandled exception` (stack trace attached via `LogError(ex, ...)`) |
(No INFO/DEBUG/WARN emitted by this component.)
@@ -0,0 +1,78 @@
# 07 — Host (Composition Root)
**Spec source**: `../../../suite/_docs/00_top_level_architecture.md` § Edge compose excerpt -- confirms the env vars (`DATABASE_URL`, `JWT_SECRET`), port (`5002:8080`), and DB target (`postgres-local`).
**Implementation status**: ✅ implemented.
> **NOTE (forward-looking)**: post-rename. Today's source has `Azaion.Flights` namespace + `dotnet Azaion.Flights.dll` entrypoint + container image `azaion/flights:*-arm`. Renames + DLL/image/compose changes tracked under Jira AZ-EPIC children B5 (namespace), B10 (Dockerfile + Woodpecker + suite compose).
**Files**: `Program.cs`, `GlobalUsings.cs`
## 1. High-Level Overview
**Purpose**: Build the ASP.NET Core web host: read environment, register all DI services, configure the request pipeline, run the schema migrator at startup, and serve the API on port 8080 (mapped to host 5002 in edge compose).
**Architectural pattern**: Composition root + ASP.NET Core minimal-host bootstrap (top-level statements).
**Upstream dependencies**: Every other component in this service.
**Downstream consumers**: The container runtime (`ENTRYPOINT ["dotnet", "Azaion.Missions.dll"]` in `Dockerfile` after B10) and any local `dotnet run`.
## 2. Internal Interface
None. The host has no exported types -- its surface is the running HTTP server.
## 3. External API
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/health` | GET | Public | Returns `{ "status": "healthy" }` |
| `/swagger/*` | GET | Public | Swagger UI + JSON spec, served unconditionally in all environments |
| (mapped controllers from feature components) | various | Per-controller `[Authorize]` | See components 01 (vehicles) and 02 (missions). |
## 4. Data Access Patterns
- Opens a single scope at startup to call `DatabaseMigrator.Migrate(db)` -- populates the 4 owned tables in the shared local PostgreSQL.
- Registers `AppDataConnection` as **scoped** so each HTTP request gets a fresh `DataConnection` (one Npgsql connection per request from the pool).
## 5. Implementation Details
**State Management**: Stateless (request pipeline). The only run-once side effect is the migrator call.
**Key Dependencies**:
| Library | Version | Purpose |
|---------|---------|---------|
| `Microsoft.AspNetCore` (in `Microsoft.NET.Sdk.Web`) | net10.0 | Web host + middleware pipeline |
| `linq2db` | 6.2.0 | DB access via `AppDataConnection` registration |
| `Npgsql` | 10.0.2 | PostgreSQL driver (used through linq2db) |
| `Swashbuckle.AspNetCore` | 10.1.5 | Swagger UI + JSON spec generation |
**Error Handling**: Delegated to `06_http_conventions`' middleware, placed FIRST in the pipeline so it wraps everything else.
**Configuration**: Reads `DATABASE_URL` and `JWT_SECRET` from `IConfiguration` -> `Environment.GetEnvironmentVariable` -> hardcoded dev fallback. Both fallbacks are dev-only and MUST be overridden in production.
**`ConvertPostgresUrl` helper**: ad-hoc parser converting `postgresql://user[:pass]@host[:port]/db` to Npgsql key=value form. Does not URL-decode user/password -- caveat for credentials with `@`, `:`, `/`, `%`.
## 6. Extensions and Helpers
- `GlobalUsings.cs` -- three project-wide `global using` directives for LinqToDB.
## 7. Caveats & Edge Cases
- **No environment guards**: Swagger and the dev fallbacks for secrets are NOT gated on `IsDevelopment()`. If `JWT_SECRET` is unset in production, the service silently runs with the well-known development secret.
- **CORS open by default**: `AllowAnyOrigin/Method/Header` applied unconditionally. Spec doesn't mandate a CORS policy -- likely safe behind suite's reverse proxy on edge, but worth confirming.
- **Migrator failure crashes the process** at startup. Container orchestrator (Watchtower-restarted Docker) is expected to bring it back; `flight-gate` (per `../../../suite/_docs/00_top_level_architecture.md`) ensures this doesn't happen mid-mission.
- **No HTTPS redirection** middleware; assumes a TLS-terminating reverse proxy upstream (Caddy fronting Gitea is documented but in-deployment TLS termination is environment-specific).
- **Port 8080** matches the Dockerfile `EXPOSE 8080` and edge compose `5002:8080` mapping per `../../../suite/_docs/00_top_level_architecture.md` excerpt.
- **No GPS-Denied service registration** here. Earlier drafts of this doc reserved a slot for a GPS-Denied feature component; per Jira AZ-EPIC child B7, GPS-Denied lives in a separate (out-of-this-repo) service, so this host registers only `VehicleService`, `MissionService`, `WaypointService`.
## 8. Dependency Graph
**Must be implemented after**: every other component (01-06).
**Blocks**: nothing internal (it is the runtime root).
## 9. Logging Strategy
ASP.NET Core defaults (Console / Debug providers, no Serilog/structured logging configured). The only structured log emitted by app code is `06_http_conventions`' middleware `LogError(ex, "Unhandled exception")`. No correlation ID, no request tracing.
+232
View File
@@ -0,0 +1,232 @@
# Azaion.Missions — Data Model
> **NOTE (forward-looking)**: this document reflects the **post-rename, post-GPS-Denied-removal** state. Today the source still has 9 entity files (`Aircraft.cs`, `Flight.cs`, `Orthophoto.cs`, `GpsCorrection.cs` are still present), 6 owned tables (incl. `aircrafts`, `flights`, `orthophotos`, `gps_corrections`), and the cascade still references the legacy GPS-Denied tables. Renames + table drops are tracked under Jira AZ-EPIC children B5 (namespace), B6 (rename), B7 (GPS-Denied removal), B9 (DB migration). The doc IS the spec for that work.
This document is the system-level data model. Per-component data access patterns live in the component descriptions; column-level shape lives in `modules/entities.md`. The authoritative ER diagram lives at `../../suite/_docs/00_database_schema.md` (this is its scoped restatement).
## 1. Database Topology (the load-bearing convention)
This service participates in the suite-standard **shared local PostgreSQL on each edge device** pattern, documented in `../../suite/_docs/00_top_level_architecture.md` § Database Topology.
```
┌────────────────────────────────────────────────────────────┐
│ Edge device (Jetson / OPi) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ postgres-local (PostgreSQL) │ │
│ │ one DB instance, shared by every backend service │ │
│ │ │ │
│ │ tables owned by: │ │
│ │ missions → vehicles, missions, waypoints, │ │
│ │ map_objects (this service) │ │
│ │ annotations → media, annotations │ │
│ │ detection-px → detection │ │
│ │ gps-denied → orthophotos, gps_corrections │ │
│ │ (post-B7 — moved out of this │ │
│ │ repo, schema owned externally) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▲ ▲ ▲ │
│ ┌───────┴──────┐ ┌──────┴───────┐ ┌─────┴───────┐ │
│ │ missions svc │ │annotations svc│ │ ... others │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
└────────────────────────────────────────────────────────────┘
```
**Each service's schema-ownership rule**:
- The owner is the **only writer** for the table's lifecycle (CRUD).
- The owner runs the migrations (`CREATE TABLE`, `CREATE INDEX`).
- Other services may **read** any table through their own `DataConnection` (LinqToDB sees the full schema by reflection on the live DB).
- Other services may **delete** rows from non-owned tables only as part of a documented cross-service cascade (this service's mission-delete walk is the canonical example — see `architecture.md` ADR-003).
The pattern is enforced by **convention**, not by per-service DB users. Every service connects with the same `DATABASE_URL` credentials and could in principle write to any table. Reviews keep this honest.
## 2. Tables this service owns (post-B7 + B9)
The migrator (`DatabaseMigrator.Migrate`) owns the schema for exactly 4 tables and runs `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` for each on every startup.
| Table | Purpose | Owner component | Writer | Schema-creating service |
|-------|---------|-----------------|--------|--------------------------|
| `vehicles` | Operator-managed inventory of mission-capable assets (Plane / Copter / UGV / GuidedMissile) | `01_vehicle_catalog` (logically); `04_persistence` (table) | `01_vehicle_catalog` (`VehicleService`) | this service |
| `missions` | Planned mission record; FK to vehicle | `02_mission_planning` (logically); `04_persistence` (table) | `02_mission_planning` (`MissionService`) | this service |
| `waypoints` | Ordered geo-points within a mission; FK to mission | `02_mission_planning` (logically); `04_persistence` (table) | `02_mission_planning` (`WaypointService`) | this service |
| `map_objects` | H3-indexed detection projection (class + confidence + spatial position); FK to mission | `04_persistence` (table) | **`autopilot`** (per `../../suite/_docs/06_autopilot_design.md`) — this service is responsible for **schema migration + cascade delete only** | this service |
## 3. Tables this service borrows (read-only; cascade-delete only)
These tables exist in the same `postgres-local`. This service exposes `ITable<T>` accessors through `AppDataConnection` so it can **read** ids and **delete** rows during its mission/waypoint cascade. It never inserts or updates them.
| Table | Schema source | Writer | This service's interaction |
|-------|---------------|--------|----------------------------|
| `media` | `annotations` migrator | `annotations` (Media CRUD) | Read `id`, `waypoint_id`; cascade-delete only |
| `annotations` | `annotations` migrator | `annotations` (Annotations CRUD) | Read `id`, `media_id`; cascade-delete only |
| `detection` *(singular — not this service's call to rename)* | detection pipeline migrator | `detections` / `ai-training` | Read `id`, `annotation_id`; cascade-delete only |
## 4. Tables removed in B7 + B9
These tables were owned by this repo before the rename refactor; per the plan they now belong to the new `gps-denied` service (`../../suite/_docs/11_gps_denied.md`).
| Table | Pre-B7 owner | Post-B7 owner | Migration step |
|-------|--------------|---------------|-----------------|
| `orthophotos` | this repo | **`gps-denied`** | B7 removes the entity + service code; B9 adds `DROP TABLE IF EXISTS orthophotos` to this service's migrator (one-shot for fielded devices that previously ran the legacy schema) |
| `gps_corrections` | this repo | **`gps-denied`** | Same — B7 + B9 |
The new `gps-denied` service owns these tables' lifecycle. It references `mission_id` and `waypoint_id` from its own tables as plain GUIDs. **There is no runtime call** between this service and `gps-denied` — see `architecture.md` ADR-007 and `02_mission_planning` § cascade.
## 5. Entity-Relationship Diagram (post-B7)
```mermaid
erDiagram
VEHICLE ||--o{ MISSION : "vehicle_id (FK)"
MISSION ||--o{ WAYPOINT : "mission_id (FK)"
MISSION ||--o{ MAP_OBJECT : "mission_id (FK)"
WAYPOINT ||--o{ MEDIA : "waypoint_id (FK, nullable)"
MEDIA ||--o{ ANNOTATION : "media_id (FK)"
ANNOTATION ||--o{ DETECTION : "annotation_id (FK)"
VEHICLE {
uuid id PK
int type "VehicleType: Plane Copter UGV GuidedMissile"
text model
text name
int fuel_type "FuelType: Electric Gasoline Diesel"
decimal battery_capacity
decimal engine_consumption
decimal engine_consumption_idle
bool is_default
}
MISSION {
uuid id PK
timestamp created_date
text name
uuid vehicle_id FK
}
WAYPOINT {
uuid id PK
uuid mission_id FK
decimal lat "nullable"
decimal lon "nullable"
text mgrs "nullable"
int waypoint_source "WaypointSource enum"
int waypoint_objective "WaypointObjective enum"
int order_num
decimal height
}
MAP_OBJECT {
uuid id PK
uuid mission_id FK
text h3_index "Uber H3 hex grid"
text mgrs
decimal lat "nullable"
decimal lon "nullable"
int class_num
text label
decimal size_width_m
decimal size_length_m
decimal confidence
int object_status "ObjectStatus enum"
timestamp first_seen_at
timestamp last_seen_at
}
MEDIA {
text id PK "XxHash64-based; computed by annotations service"
uuid waypoint_id FK "nullable — Media may attach to a non-waypoint context"
}
ANNOTATION {
text id PK "XxHash64-based"
text media_id FK
}
DETECTION {
uuid id PK
text annotation_id FK
}
```
The diagram above is a scoped restatement of `../../suite/_docs/00_database_schema.md` (authoritative). Borrowed tables (`media`, `annotations`, `detection`) show only the columns this service touches; their full column shapes are owned by their respective services.
## 6. Key Relationships and Invariants
### Owned-table invariants
- **`mission.vehicle_id` MUST reference an existing `vehicle.id`** — enforced by FK + by `MissionService` existence check at create / update. The two together close the TOCTOU gap (FK rejects insert if the vehicle was deleted between check and insert; UX surfaces as a `500` instead of a `400` in that race window — see `02_mission_planning` Caveats #4).
- **`waypoint.mission_id` MUST reference an existing `mission.id`** — enforced by FK + by `WaypointService` existence check at create.
- **`map_object.mission_id` MUST reference an existing `mission.id`** — enforced by FK only. `autopilot` is the writer; `missions` is the cascade-deleter.
- **At most one `vehicle.is_default = TRUE`** is the spec invariant. Code enforces "exactly one default" by clearing the flag on every other row before setting it on the target — **stricter than spec, race-prone without a transaction.** Tracked under Jira AZ-551 (B12) for resolution.
### Cross-service-table invariants (cascade only)
- **`media.waypoint_id` is nullable** — `Media` can attach to a non-waypoint context (mission-level media); enforcement is on `annotations`'s side.
- **Cascade order is FK-driven** — the mission-delete walk in `MissionService.DeleteMission` deletes child rows before parent rows: `map_objects``detection``annotations``media``waypoints``missions`. See `diagrams/flows/flow_mission_cascade_delete.md` for the authoritative order.
### Cross-data-model conventions (suite-wide)
- **Mixed PK types**: `vehicles`, `missions`, `waypoints`, `map_objects`, `detection` use `uuid` (LinqToDB `Guid`); `media`, `annotations` use `text` (XxHash64-based content hash, computed by `annotations`). The text-PK shape lets `annotations` deduplicate the same physical media across services per `../../suite/_docs/00_database_schema.md`.
- **`detection` is a singular table name** while every other table is plural. The detection pipeline owns the naming choice — this service does not "fix" it.
## 7. Indexes
Defined by `DatabaseMigrator.Migrate` (post-B7+B9):
| Index | Table | Purpose |
|-------|-------|---------|
| PK on `id` | `vehicles`, `missions`, `waypoints`, `map_objects` | Lookup-by-id; created implicitly by `PRIMARY KEY` |
| `ix_missions_vehicle_id` | `missions` | Existence check on vehicle delete; FK lookup |
| `ix_waypoints_mission_id` | `waypoints` | List nested waypoints; cascade-delete walk |
| `ix_map_objects_mission_id` | `map_objects` | Cascade-delete walk on mission delete |
**Indexes that DO NOT exist** (could matter on growth — carry-forward as opportunistic improvements):
- No index on `vehicles.is_default` — partial index `WHERE is_default` would help if catalog grows past low hundreds of rows. Today the catalog is small.
- No index on `missions.created_date` — used as the `ORDER BY` in the paginated list. Full scan + sort today; fine while mission count is in the hundreds, becomes relevant past ~10k.
- No `LOWER(...)` indexes for case-insensitive name search — full scan today; fine while owned tables are small.
- No order-by index on `waypoints.order_num` — sort is in-memory after `WHERE mission_id = ?` returns. Fine for the typical-case dozens of waypoints per mission.
## 8. Domain Enums (stored as INTEGER in the DB)
Defined under `Enums/`; rendered to / from PostgreSQL `INT` columns by LinqToDB.
| Enum | Backing column(s) | Values | Notes |
|------|-------------------|--------|-------|
| `VehicleType` | `vehicles.type` | `Plane=0, Copter=1, UGV=2, GuidedMissile=3` | Extended from {Plane, Copter} in B6 |
| `FuelType` | `vehicles.fuel_type` | `Electric, Gasoline, Diesel` | **May not fit `GuidedMissile`** — carry-forward Phase C decision (`01_vehicle_catalog` Caveats #6) |
| `WaypointSource` | `waypoints.waypoint_source` | (Operator-defined; values per `Enums/WaypointSource.cs`) | Source attribution for the waypoint |
| `WaypointObjective` | `waypoints.waypoint_objective` | (Operator-defined; values per `Enums/WaypointObjective.cs`) | Mission-time objective tag |
| `ObjectStatus` | `map_objects.object_status` | (Detection-pipeline-defined) | Cross-cutting status enum; lives in `04_persistence` because it's used by `MapObject` (the only consumer today) |
There are **no `CHECK` constraints** on the integer columns — sending an invalid integer (e.g., `VehicleType = 99`) is accepted at the DB level and surfaces only when LinqToDB tries to deserialize. `01_vehicle_catalog` Caveats #3 notes the missing input validation.
## 9. Migration strategy
This service uses **forward-only-additive** schema bootstrap:
- Every startup: `DatabaseMigrator.Migrate` runs all `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` statements. Idempotent on a steady-state device.
- Column drops, type changes, constraint changes are **not supported** by this migrator; they would need manual SQL or a future migration tool (Flyway / EF Core migrations).
- The B9 ticket adds the **one explicit destructive step** in the migrator's history: `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;`. Idempotent on devices that already cleaned up; one-shot on fielded edge devices that previously ran the legacy schema. **Out-of-band ordering**: deploy `gps-denied` first so it owns its own copy of the schema before `missions` drops the legacy tables (see `diagrams/flows/flow_startup_migration.md` error scenarios).
See `architecture.md` ADR-004 for the rationale of the `IF NOT EXISTS` approach.
## 10. Seed data
None. The migrator only creates schema. Vehicles, missions, and waypoints are operator-created via the API on first use.
## 11. Backward compatibility
- **No schema versioning** in this service today. Compatibility is enforced by the additive-only convention plus the B9 one-shot exception.
- **Wire shape (HTTP) is currently divergent from spec** for entity / DTO bodies (PascalCase via `System.Text.Json` defaults) and for the error envelope's missing `errors` field. Note: the error envelope is already camelCase on case (accidental match — middleware writes an anonymous object literal whose property names are lowercase-first by construction). Cross-version compatibility for clients (UI, `autopilot`) is implicit — both consumers were built against the live PascalCase entity shape. The future camelCase migration on entity bodies (out of this Epic) would be a coordinated cutover (see `architecture.md` ADR-002).
- **No rollback mechanism** — the additive-only migrator does not record a downgrade path. The B9 `DROP` is unidirectional; once `gps-denied` owns the tables there is no recipe to "give them back" to `missions`.
## 12. Observed data sizes (typical edge deployment)
Not specified in spec. Estimated from operational context (single operator, single edge device, single deployment cycle):
| Table | Typical row count | Growth driver |
|-------|------------------|---------------|
| `vehicles` | tens to low hundreds | Manual CRUD; rarely grows past the operator's usable fleet |
| `missions` | hundreds to low thousands per device per year | Operator activity |
| `waypoints` | typically 10100 per mission (dominant), occasionally 1000+ | Mission complexity |
| `map_objects` | hundreds to tens of thousands per mission | Detection cadence + mission duration; **dominant table by row count** |
| `media` (borrowed) | one row per captured media artifact | Owned by `annotations`; this service deletes via cascade |
| `annotations` (borrowed) | one row per labeled annotation | Owned by `annotations` |
| `detection` (borrowed) | one row per high-confidence detection | Owned by detection pipeline |
These are rough operational estimates, not load-test results. They influence indexing decisions (see § 7) and inform why no transaction wrap on cascade delete is "tolerable today" — typical mission deletes touch single-digit thousands of rows at most, which is well within a single PG round-trip's span.
@@ -0,0 +1,96 @@
# CI / CD Pipeline
> **NOTE (forward-looking)**: image registry path reflects the **post-rename** state. Today's pipeline pushes `azaion/flights:${BRANCH}-arm`. Rename tracked under Jira AZ-EPIC child B10 (Dockerfile + Woodpecker + suite compose).
## Source
`./.woodpecker/build-arm.yml` (single CI file). One job: build + push the container image.
```yaml
when:
event: [push, manual]
branch: [dev, stage, main]
labels:
platform: arm64
steps:
- name: build-push
image: docker
environment:
REGISTRY_HOST: { from_secret: registry_host }
REGISTRY_USER: { from_secret: registry_user }
REGISTRY_TOKEN: { from_secret: registry_token }
commands:
- echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
- export TAG=${CI_COMMIT_BRANCH}-arm
- export BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
- |
docker build -f Dockerfile \
--build-arg CI_COMMIT_SHA=$CI_COMMIT_SHA \
--label org.opencontainers.image.revision=$CI_COMMIT_SHA \
--label org.opencontainers.image.created=$BUILD_DATE \
--label org.opencontainers.image.source=$CI_REPO_URL \
-t $REGISTRY_HOST/azaion/missions:$TAG . # post-B10
- docker push $REGISTRY_HOST/azaion/missions:$TAG # post-B10
volumes:
- /var/run/docker.sock:/var/run/docker.sock
```
## Triggers
| Trigger | Branch filter | Outcome |
|---------|---------------|---------|
| `push` | `dev`, `stage`, `main` | Build + push the corresponding `${BRANCH}-arm` image tag |
| `manual` | any of `dev`, `stage`, `main` | Same as `push` — used for rebuilding without a code change (e.g., after a base-image security patch) |
| `pull_request`, other branches | — | **Not built today.** Feature branches do not produce images |
## Runner / platform
- **`labels: platform: arm64`** — the pipeline runs on an ARM64-labeled Woodpecker runner. The build is therefore **native** for the ARM64 image variant (no QEMU). The Dockerfile's `--platform=$BUILDPLATFORM` for the build stage ensures the SDK image matches the runner's architecture.
- For the (currently absent) AMD64 variant a second pipeline file (`build-amd.yml`) on an AMD64-labeled runner would be the natural pattern.
## Secrets
| Secret | Source | Purpose |
|--------|--------|---------|
| `registry_host` | Woodpecker secret store | Hostname of the suite's container registry (Caddy-fronted Gitea Container Registry per `../../../suite/_docs/00_top_level_architecture.md`) |
| `registry_user` | Woodpecker secret store | Service account that has push rights to `azaion/missions` |
| `registry_token` | Woodpecker secret store | Personal access token for the service account |
`docker login` is piped via stdin (`--password-stdin`) — token never lands on the command line or in process listings.
## OCI labels emitted
Three standard OCI labels are baked into every published image:
| Label | Value | Source |
|-------|-------|--------|
| `org.opencontainers.image.revision` | `$CI_COMMIT_SHA` | Git commit driving this build |
| `org.opencontainers.image.created` | `$BUILD_DATE` (ISO 8601 UTC) | `date -u +%Y-%m-%dT%H:%M:%SZ` at build time |
| `org.opencontainers.image.source` | `$CI_REPO_URL` | Suite Gitea repo URL |
These let `docker inspect` answer "which commit and when?" without consulting the registry's metadata.
## What the pipeline does **NOT** do (carry-forward improvements)
- **No `dotnet test` step** — there is no test project in the repo today. Tracked in `../../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md`. When a `tests/Azaion.Missions.Tests/` sibling lands (autodev existing-code Steps 57), the natural insert is a `name: test` step that runs `dotnet test --collect "XPlat Code Coverage"` against the build before the docker step.
- **No security scan** — neither container scanning (Trivy / Grype on the published image) nor source scanning (CodeQL / Semgrep) is wired. Suite-level concern; out of this Epic.
- **No migration check** — the migrator runs at app startup (Flow F6). A CI-time "does the migrator's DDL parse cleanly against an empty PG" smoke test is the next-cheapest safety net once tests exist.
- **No SBOM**`docker buildx --sbom` would surface base-image CVEs at registry-push time. Carry-forward.
- **No image-signing** — Notary v2 / cosign is not wired. Carry-forward; suite-wide concern.
- **No multi-arch matrix** — only `arm64` builds today (see `containerization.md` § Multi-arch limitation).
## Pipeline run-time characteristics
- Single step, single image. Typical wall-clock time: 25 minutes on the suite's ARM64 runner (most of it `dotnet publish` + the runtime image layer pull).
- No caching layer between builds today. `dotnet restore` re-downloads NuGet packages on every run. A persistent `~/.nuget` volume on the runner would shave ~30 seconds per build.
## Failure modes
| Failure | Symptom in Woodpecker | Recovery |
|---------|------------------------|----------|
| `dotnet publish` fails | Build step fails with compilation errors | Standard — fix source, push again |
| Registry login fails (rotated token) | `docker login` exits non-zero | Rotate `registry_token` in Woodpecker secrets |
| Registry push rate-limited | `docker push` 429 | Retry the manual run; rare in practice with Gitea-hosted registry |
| ARM64 runner offline | Pipeline waits in queue | Bring the runner back online; pipeline auto-resumes |
@@ -0,0 +1,61 @@
# Containerization
> **NOTE (forward-looking)**: image tag, csproj name, and entrypoint reflect the **post-rename** state. Today's `Dockerfile` ENTRYPOINT is `dotnet Azaion.Flights.dll` and the image tag base is `azaion/flights`. Renames tracked under Jira AZ-EPIC children B5 (csproj/namespace) and B10 (Dockerfile entrypoint + Woodpecker image tag).
## Source
`./Dockerfile` (single Dockerfile at the repo root). Multi-arch via build args.
## Build strategy: multi-arch from a single Dockerfile
```dockerfile
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
COPY . .
RUN arch=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
dotnet publish -c Release -o /app --os linux --arch $arch
FROM mcr.microsoft.com/dotnet/aspnet:10.0
ARG CI_COMMIT_SHA=unknown
ENV AZAION_REVISION=$CI_COMMIT_SHA
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENTRYPOINT ["dotnet", "Azaion.Missions.dll"] # post-B5 + B10
```
Key choices:
- **`--platform=$BUILDPLATFORM`** on the build stage — the SDK runs on the **builder's** native architecture (typically AMD64 in CI), but `dotnet publish --os linux --arch $arch` cross-publishes for the **target** architecture (`arm64` for Jetson / OPi; `amd64` for operator-PC). This avoids needing QEMU emulation for the (slow) build phase.
- **Two-stage**: SDK image (~800 MB) for the build, runtime image (`mcr.microsoft.com/dotnet/aspnet:10.0`, ~210 MB) for the published artifacts. Final image carries no SDK, no source code, no `.csproj`.
- **`AZAION_REVISION` env var** baked from `CI_COMMIT_SHA` at build time — surfaces the source commit at runtime for support / triage. Not consumed by the application code today; visible only via `docker inspect` or `env` in the running container.
- **`EXPOSE 8080`** matches the ASP.NET Core default (no `ASPNETCORE_URLS` override) and the edge compose port mapping `5002:8080`.
## What the Dockerfile does **NOT** do (carry-forward improvements)
- **No `.dockerignore`** — every file under the repo root is copied into the build context, including `_docs/`, `.cursor/`, and the previously-committed `obj/` / `bin/` (cleaned by `dotnet publish` but still copied across the wire). A `.dockerignore` would shrink the build context meaningfully on Jetson-class builders. Tracked as opportunistic improvement.
- **No `HEALTHCHECK` directive**`/health` exists in the application (Flow F7) but the container itself does not declare a healthcheck. Compose-level healthcheck is the suite's expected mechanism.
- **No non-root `USER`** — the runtime image runs as root. The `aspnet:10.0` base image supports a non-root user (`USER app`) since .NET 8; switching is a one-line change on the next refresh and would tighten container isolation.
- **No build-time test pass** — no `dotnet test` step. The repo has no test project today (tracked in `../../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md`); when a `tests/Azaion.Missions.Tests/` project lands, a pre-publish `dotnet test` step is the natural next addition.
## Image lifecycle on edge devices
1. Woodpecker builds + pushes `${REGISTRY_HOST}/azaion/missions:${BRANCH}-arm` (post-B10) on every push to `dev` / `stage` / `main` (see `ci_cd_pipeline.md`).
2. Watchtower running on each edge device polls the registry; on a new digest for the device's pinned tag, it pulls and re-creates the container.
3. `flight-gate` (per `../../../suite/_docs/00_top_level_architecture.md`) prevents container restart mid-mission. Once the active mission completes, the new image becomes live.
4. Container starts → `Program.cs` → migrator → `app.Run()` (Flow F6).
## Image variants and tag strategy
| Branch | Tag (post-B10) | Audience |
|--------|----------------|----------|
| `dev` | `azaion/missions:dev-arm` | Engineers; rolling latest-dev |
| `stage` | `azaion/missions:stage-arm` | Pre-prod customer demo devices |
| `main` | `azaion/missions:main-arm` | Production edge fleets |
**No semantic version tags today** (no `v1.2.3`-style tags). Watchtower polls the named tags directly. Carry-forward improvement: add `${REGISTRY_HOST}/azaion/missions:${CI_COMMIT_SHA:0:8}-arm` alongside the branch tag so rollback to a specific build is possible without rebuilding.
## Multi-arch — current limitation
Today the Woodpecker pipeline (`ci_cd_pipeline.md`) builds **only the `arm64` variant** (the runner is ARM64-labeled and the tag suffix is `-arm`). For AMD64 (operator-PC) deployments, a second pipeline / second runner / `linux/amd64` build args would be needed. Out of this Epic — the current operator-PC deployment story uses local builds.
@@ -0,0 +1,101 @@
# Environment Strategy
> **NOTE (forward-looking)**: image tag, container name, and namespace reflect the **post-rename** state. Today's edge compose still references `azaion/flights:${BRANCH}-arm` and the container name is typically `flights`. Rename tracked under B10 (suite compose update).
## Environments
| Environment | Where it runs | Audience | Image tag (post-B10) |
|-------------|---------------|----------|----------------------|
| **Development** | Local workstation (`dotnet run` from the repo root, or `docker run` against a local PG) | Engineers | none — built ad-hoc |
| **Edge production** | Each customer-owned edge device (Jetson Orin / OrangePI / operator-PC), one container per device | Operators, autopilot, UI | `azaion/missions:dev-arm` / `stage-arm` / `main-arm` per device tier |
There is **no centralized staging environment** in the suite. Each edge device is its own deployment; the `stage` image tag is for pre-prod customer demo devices, not for a separate cloud staging cluster.
## Configuration sources (precedence)
`Program.cs` resolves each setting in this order:
1. `IConfiguration` (defaults: appsettings.json + ASPNETCORE_-prefixed env vars)
2. `Environment.GetEnvironmentVariable(...)` (legacy fallback for unprefixed env)
3. **Hardcoded dev fallback** (last resort)
### `DATABASE_URL`
| Environment | Value source | Resolved value |
|-------------|--------------|----------------|
| Development (no env set) | hardcoded fallback | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` |
| Development (env set) | env var | Whatever the engineer sets (URL form OR raw Npgsql key=value form both work) |
| Edge production | env var passed via docker compose | `postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion` (per `../../../suite/_docs/00_top_level_architecture.md`) |
**`ConvertPostgresUrl` helper** parses the URL form into Npgsql key=value form. **Does NOT URL-decode user/password** — credentials with `@`, `:`, `/`, `%` will be mis-parsed. `07_host` Caveats #4 calls this out. Mitigation: avoid those characters in the local PG password (the standard suite provisioning does), or pass a raw Npgsql key=value string instead of a URL.
### `JWT_SECRET`
| Environment | Value source | Resolved value |
|-------------|--------------|----------------|
| Development (no env set) | hardcoded fallback | `development-secret-key-min-32-chars!!` (**well-known; never use in production**) |
| Development (env set) | env var | Whatever the engineer sets |
| Edge production | env var (compose `env_file` or per-device-provisioned secret) | The shared HMAC secret used by `admin` + every backend service on the device. **Rotation is suite-coordinated** — see `architecture.md` § Security |
**Critical foot-gun (ADR-005 carry-forward)**: there is **no runtime gate** that blocks startup with the dev fallback in production. A misconfigured production deploy will silently boot with the well-known dev secret. The CMMC L2 scorecard tracks the broader fix at suite level (AZ-487 / AZ-494).
## Other configuration
These settings are **NOT** environment-overridable today (carry-forward improvements):
| Setting | Current behavior | Should it differ between dev and prod? |
|---------|------------------|----------------------------------------|
| Swagger UI mount | Always on | Yes — production should gate on `IsDevelopment()` (ADR-005) |
| CORS policy | `AllowAnyOrigin/Method/Header` always | Yes — production should restrict to the suite's reverse-proxy origin |
| HTTPS redirection | None — assumes upstream TLS termination | No — the suite's reverse proxy handles TLS; this is correct |
| Logging verbosity | ASP.NET Core defaults (Information+) | Probably not at this scale; `LogLevel:Default = Warning` could be useful in prod |
| Migrator `DROP TABLE IF EXISTS` (B9 one-shot) | Runs every startup; idempotent on already-cleaned devices | No — the idempotent design means this is safe everywhere |
## Edge compose excerpt (suite-wide pattern, post-B10)
Per `../../../suite/_docs/00_top_level_architecture.md` § Edge compose:
```yaml
services:
missions: # was: flights (pre-B10)
image: ${REGISTRY_HOST}/azaion/missions:${BRANCH:-main}-arm
container_name: missions
restart: unless-stopped
depends_on:
postgres-local:
condition: service_healthy
environment:
DATABASE_URL: postgresql://postgres:${PG_LOCAL_PASSWORD}@postgres-local/azaion
JWT_SECRET: ${JWT_SECRET}
ports:
- "5002:8080"
networks:
- azaion-edge
```
The actual file lives in the suite repo (`../../../suite/_infra/_compose/`) — the snippet here is illustrative.
## Secrets management
- **Local dev**: hardcoded fallbacks in `Program.cs` (the dev-secret values listed above).
- **Edge production**: env vars sourced from a per-device `.env` file (created at provisioning) OR a local secrets manager (per-customer choice — the suite supports both patterns). The `JWT_SECRET` is suite-wide (one value across all backend services on the device).
- **Rotation**: changing `JWT_SECRET` invalidates every issued token until new ones are minted. Coordinated procedure across the device's backend services + UI re-login. There is **no online rotation** — every backend must be restarted with the new secret simultaneously.
## Network / port layout
- Container `EXPOSE 8080`; bound HTTP only (no TLS in this service).
- Edge compose maps `5002:8080` per the suite convention.
- Reverse proxy (Caddy fronting the suite per `../../../suite/_docs/00_top_level_architecture.md`) terminates TLS upstream.
- No outbound network calls except to `postgres-local` (DB).
## Restart / lifecycle
- Container restart policy: `unless-stopped` in production compose (manually-stopped containers stay stopped; crash → auto-restart).
- Watchtower polls the registry and triggers re-create on new image digest.
- `flight-gate` (suite component) gates container restart so it does not happen mid-mission. Once the active mission completes, Watchtower's queued restart goes through.
- `missions` itself does not implement graceful shutdown beyond ASP.NET Core's defaults — in-flight HTTP requests are allowed to complete; idle connections are closed.
## Backup / disaster recovery
- **Out of scope for this service.** PostgreSQL backup is a per-device, suite-level concern (not per-service). Each edge device runs `postgres-local`; backup cadence and offsite replication are decided at provisioning time and documented at suite level (currently informally).
- **No application-level export** — the only way to read mission / waypoint data out of the device is through the API or `pg_dump` against `postgres-local`.
@@ -0,0 +1,75 @@
# Observability
> **Honest assessment**: observability in this service is **minimal** today. This document records what exists, what does not, and what the natural next steps are. It is intentionally short — there is not much to describe.
## What exists
### Logging
- **Provider**: ASP.NET Core defaults (Console + Debug providers via `Microsoft.Extensions.Logging`). No Serilog, no NLog, no structured logging.
- **Format**: plain text to stdout (Console provider). Docker collects via the standard container log stream; `docker logs missions` reveals everything.
- **Application-emitted logs** (only):
| Source | Level | When | Message |
|--------|-------|------|---------|
| `06_http_conventions/ErrorHandlingMiddleware` | `ERROR` | Unhandled exception caught by the catch-all branch | `"Unhandled exception"` (with stack trace via `LogError(ex, ...)`) |
No `INFO`, `DEBUG`, `WARN` logs are emitted by application code.
- **Framework logs**: `JwtBearerHandler` and ASP.NET Core's request pipeline log token-validation outcomes and request lifecycle at `Information` / `Debug` levels (`05_identity` § Logging). LinqToDB does NOT log SQL by default — `04_persistence` Caveats #7.
### Metrics
- **None.** No `Microsoft.Extensions.Diagnostics.Metrics` consumption, no Prometheus / OpenTelemetry exporters, no application-level counters.
### Tracing
- **None.** No `Activity` / `OpenTelemetry` instrumentation. No correlation IDs, no W3C `traceparent` propagation.
### Health endpoint
- `GET /health``{ "status": "healthy" }` (Flow F7). Confirms process liveness + HTTP pipeline serving. **Does NOT verify DB connectivity** today.
### Build-time metadata
- `AZAION_REVISION` env var baked from `CI_COMMIT_SHA` at build time (`Dockerfile`). Visible via `docker inspect` or `env` inside the running container, but **not surfaced via any HTTP endpoint** today.
## What does not exist (carry-forward)
### Correlation / request tracing
- No request ID generation (would be a 5-line middleware: emit `X-Request-Id` if absent, propagate to logs).
- No client-supplied correlation ID propagation.
- No way to grep logs by anything other than timestamp.
### Structured logging
- Console-only plaintext means log aggregation (Loki / ELK / Splunk) has to parse free-text. A switch to `Microsoft.Extensions.Logging.Console.JsonFormatter` (or Serilog with JSON sink) would emit `{ "Timestamp": ..., "Level": ..., "Message": ..., "Properties": {...} }` and make downstream querying viable.
### Audit logging
- No per-request audit trail (which user / token did what). The JWT's `sub` / `user_id` claim is **not consumed** today — `05_identity` Caveats #2. Adding it would unlock per-user attribution for vehicle changes and mission deletions.
### Application metrics
- No counters for: requests served, error rate, mission-delete cascade duration, `vehicles.is_default` race occurrences, JWT validation failure rate.
- No DB metrics: connection pool utilization, query latency p50 / p95 / p99, slow-query log.
- No SLO tracking — `architecture.md` § NFRs notes that no explicit latency budget is set.
### Distributed tracing
- The cross-service cascade (`missions``media` / `annotations` / `detection` rows in shared PG) would benefit from a single trace ID per cascade walk. Today it is one trace span (the HTTP request) that opaquely runs many SQL statements.
### DB liveness on `/health`
- Flow F7 § Future improvement notes the natural extension: `await db.ExecuteAsync("SELECT 1")` inside the `/health` handler. Today the migrator-at-startup is the only DB-availability gate.
## Natural next steps (in rough priority order)
1. **Request ID middleware + emit it from `ErrorHandlingMiddleware` log + Response header.** ~10 lines, immediately useful for production support.
2. **DB ping in `/health`** — flips `/health` from "process liveness" to "service readiness". Costs <1ms per probe.
3. **Switch console logger to JSON formatter** — flat-rate change; downstream log aggregation becomes feasible.
4. **Surface `AZAION_REVISION` via a `/version` endpoint** (one line on top of the existing `MapGet`) so support knows which build is on the device without `docker inspect`.
5. **OpenTelemetry instrumentation** — once #14 are in, a minimal OTel exporter (HTTP server + LinqToDB SqlClient activity) would give tracing for free.
These are deferred to **post-rename** work (out of this Epic). The rename refactor (B5B10) does not change observability, and the autodev BUILD pipeline (Steps 37 of existing-code flow) will exercise the existing code as-is. Adding observability is a separate pass that should land alongside the testing work in autodev cycle 2 or later.
+72
View File
@@ -0,0 +1,72 @@
# Components — High-Level Wiring (`missions` service)
The shape below mirrors the spec features in `../../../suite/_docs/02_missions.md` rather than the source-tree directories. GPS-Denied is **not** a component of this service -- per Jira AZ-EPIC child B7 it lives in a separate `gps-denied` service and is documented in `../../../suite/_docs/11_gps_denied.md`.
```mermaid
flowchart TB
subgraph Suite[Azaion suite]
Admin["admin (issues JWTs)"]
Annotations["annotations\n(owns media + annotations)"]
Detection["detection pipeline\n(owns detection)"]
Autopilot["autopilot\n(reads missions, writes map_objects)"]
UI["ui (browser)"]
GpsDenied["gps-denied (separate service)\n(owns orthophotos + gps_corrections)"]
end
subgraph Missions[missions service — this codebase]
Host[07_host\n• Program.cs\n• /health, Swagger]
Auth[05_identity\n• JwtExtensions]
Conv[06_http_conventions\n• ErrorMiddleware\n• PaginatedResponse]
Persist[04_persistence\n• AppDataConnection\n• DatabaseMigrator\n• 7 entities]
Catalog[01_vehicle_catalog\n• VehiclesController\n• VehicleService\n• Plane / Copter / UGV / GuidedMissile]
Planning[02_mission_planning\n• MissionsController\n• Mission + Waypoint services\n• cross-service cascade]
end
UI -->|HTTP + JWT| Host
Admin -->|mints JWT (HMAC shared secret)| Auth
Host --> Conv
Host --> Auth
Host --> Persist
Auth --> Catalog
Auth --> Planning
Conv --> Catalog
Conv --> Planning
Persist --> Catalog
Persist --> Planning
Catalog -->|vehicle existence check| Planning
Planning -.delete cascade.-> Annotations
Planning -.delete cascade.-> Detection
Planning -.delete cascade.-> Autopilot
Autopilot -->|reads missions + waypoints| Planning
GpsDenied -.references mission_id / waypoint_id\n(no runtime call into this service).-> Planning
```
## Component map (file <-> component)
> **NOTE (forward-looking)**: file paths reflect the post-rename state. Today's source still uses `Aircraft*`/`Flight*`/`AircraftType` filenames + 9 entities. Renames tracked under Jira AZ-EPIC children B5 / B6 / B7 / B8.
| Component | Files (post-rename) |
|-----------|---------------------|
| 01_vehicle_catalog | `Controllers/VehiclesController.cs`, `Services/VehicleService.cs`, 4 vehicle DTOs (`CreateVehicleRequest`, `UpdateVehicleRequest`, `GetVehiclesQuery`, `SetDefaultRequest`), `Enums/VehicleType.cs`, `Enums/FuelType.cs` |
| 02_mission_planning | `Controllers/MissionsController.cs`, `Services/MissionService.cs`, `Services/WaypointService.cs`, 6 mission/waypoint DTOs, `Enums/WaypointSource.cs`, `Enums/WaypointObjective.cs`, `DTOs/GeoPoint.cs` |
| 04_persistence | `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`, all 7 entities in `Database/Entities/` (`Vehicle`, `Mission`, `Waypoint`, `MapObject`, `Media`, `Annotation`, `Detection`), `Enums/ObjectStatus.cs` |
| 05_identity | `Auth/JwtExtensions.cs` |
| 06_http_conventions | `Middleware/ErrorHandlingMiddleware.cs`, `DTOs/ErrorResponse.cs`, `DTOs/PaginatedResponse.cs` |
| 07_host | `Program.cs`, `GlobalUsings.cs` |
## Layering summary
- **Foundation (parallelisable)**: 04_persistence, 05_identity, 06_http_conventions
- **Feature components**: 01_vehicle_catalog (depends on 04+05+06), 02_mission_planning (depends on 04+05+06+01)
- **Composition root**: 07_host (last; wires everything)
## Cross-service relationships (live runtime)
| Boundary | Direction | Mechanism |
|----------|-----------|-----------|
| `admin` -> `missions` | inbound | JWT minted with shared HMAC secret; validated by 05_identity |
| `missions` -> `annotations`, `detection` (DB) | outbound write | Cross-table cascade-delete on the shared local PostgreSQL during mission/waypoint delete |
| `missions` -> `autopilot` (DB) | outbound delete | `map_objects` cleanup on mission delete (autopilot is the writer) |
| `autopilot` -> `missions` (DB) | inbound read of mission state | autopilot reads `missions` + `waypoints` to drive the vehicle |
| `ui` -> `missions` (HTTP) | inbound | All controllers (vehicle CRUD, mission planning) |
| `gps-denied` <- `missions` (no runtime coupling) | -- | The new `gps-denied` service owns its own DB tables (`orthophotos`, `gps_corrections`); they reference `mission_id` / `waypoint_id` as plain GUIDs. There is no inbound HTTP call from `gps-denied` to `missions` and no outbound call from `missions` to `gps-denied` -- decoupled by design. |
@@ -0,0 +1,64 @@
# Flow F7 — Health probe
> Trivial flow. Documented for completeness because it is the contract every external orchestrator (Watchtower, docker compose healthcheck, reverse proxy) relies on.
## Description
`GET /health` returns `{ "status": "healthy" }` with no auth. Confirms the process is up and the HTTP pipeline is serving — does NOT confirm DB connectivity, JWT validation works, or any feature endpoint is reachable.
## Preconditions
- HTTP pipeline is serving (i.e., F6 reached `app.Run()`).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Probe as Watchtower / compose / reverse proxy
participant Host as 07_host
Probe->>Host: GET /health (no Authorization header)
Host-->>Probe: 200 OK + { "status": "healthy" }
```
## Flowchart
```mermaid
flowchart LR
Start([GET /health]) --> Resp([200 OK + healthy])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Probe | `MapGet("/health")` | (no body, no auth) | HTTP GET |
| 2 | `MapGet("/health")` | Probe | `{ "status": "healthy" }` | JSON (PascalCase irrelevant — single key) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Process down | TCP layer | Probe gets `ECONNREFUSED` | Orchestrator restarts container |
| Pipeline not yet at `app.Run()` (mid-startup) | TCP layer | TCP connect succeeds but no response | Probe times out; orchestrator typically retries with backoff |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Latency | <5ms | Pure pipeline execution; no I/O |
| Throughput | bounded only by ASP.NET Core's request handling | Not load-tested |
## Future improvement (carry-forward, NOT this Epic)
Add a DB ping:
```csharp
app.MapGet("/health", async (AppDataConnection db) =>
{
await db.ExecuteAsync("SELECT 1");
return Results.Ok(new { status = "healthy" });
});
```
This would let `flight-gate` and reverse-proxy checks reflect actual readiness rather than process liveness. Today the migrator runs at startup and crashes the process on DB failure (F6), which is a coarse but workable substitute for one-shot bring-up. In a steady-state running device, a transient DB outage AFTER startup would not be caught by `/health` today.
@@ -0,0 +1,93 @@
# Flow F5 — JWT bearer validation
> Cross-cutting flow that runs on every `[Authorize]` request. Local validation only — this service never calls back to the issuing `admin` service.
## Description
ASP.NET Core's `JwtBearerHandler` validates incoming `Authorization: Bearer <jwt>` headers against the shared HMAC secret (`JWT_SECRET`). On success, the request continues to the controller with a `ClaimsPrincipal` attached. On signature / lifetime failure → `401`. On valid token but missing `"FL"` permission claim → `403`. The `iss` / `aud` claims are intentionally NOT validated today (CMMC L2 finding tracked at suite level under AZ-487 / AZ-494 — see `05_identity` § Implementation Details).
## Preconditions
- `JWT_SECRET` is resolved at startup (env or hardcoded dev fallback per `architecture.md` ADR-005).
- `AddJwtAuth(jwtSecret)` was called during `Program.cs` startup (F6).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Client as UI / Operator API client
participant Pipeline as ASP.NET Pipeline
participant Handler as JwtBearerHandler
participant Policy as Auth policy "FL"
participant Ctrl as Feature Controller
participant Errs as 06_http_conventions
Client->>Pipeline: HTTP request + Authorization: Bearer <jwt>
Pipeline->>Errs: enter ErrorHandlingMiddleware
Errs->>Handler: hand off (anonymous endpoints skip this)
Handler->>Handler: parse token; verify HMAC-SHA256 signature using SymmetricSecurityKey(UTF-8(JWT_SECRET))
alt Signature invalid OR token expired (ClockSkew = 1 minute)
Handler-->>Client: 401 Unauthorized
else Valid token
Handler->>Handler: build ClaimsPrincipal; skip iss/aud validation
Handler->>Policy: evaluate policy "FL" (requires permissions claim == "FL")
alt Claim missing or != "FL"
Policy-->>Client: 403 Forbidden
else permissions=FL
Policy-->>Ctrl: forward to controller action
Ctrl-->>Client: business response
end
end
```
## Flowchart
```mermaid
flowchart TD
Start([Incoming request]) --> AnonEP{Endpoint requires auth?}
AnonEP -->|no| Forward([Forward to controller])
AnonEP -->|yes| Header{Authorization: Bearer present?}
Header -->|no| Unauth1([401 Unauthorized])
Header -->|yes| Sig{HMAC-SHA256 signature valid?}
Sig -->|no| Unauth2([401 Unauthorized])
Sig -->|yes| Life{Lifetime valid? ClockSkew=1min}
Life -->|no| Unauth3([401 Unauthorized — expired])
Life -->|yes| BuildPrincipal[Build ClaimsPrincipal — skip iss/aud]
BuildPrincipal --> Policy{permissions claim == FL?}
Policy -->|no| Forbid([403 Forbidden])
Policy -->|yes| Forward
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Client | Pipeline | `Authorization: Bearer <jwt>` header | HTTP header |
| 2 | `JwtBearerHandler` | (in-process) | parsed JWT (header, payload, signature) | JSON Web Token |
| 3 | `JwtBearerHandler` | (in-process) | `ClaimsPrincipal` | .NET principal object |
| 4 | Authorization policy evaluator | Controller | "policy satisfied" / `403` | flag |
| 5 | `JwtBearerHandler` | Client (only on failure) | `401` / `403` | HTTP status (no body) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `Authorization` header on `[Authorize]` route | `JwtBearerHandler` | Header absent | `401`. Client must obtain a token from `admin` |
| Malformed JWT | `JwtBearerHandler` | Token parse failure | `401` |
| Signature mismatch (wrong / rotated `JWT_SECRET`) | `JwtBearerHandler` | HMAC verify fails | `401`. Suite-wide secret rotation is coordinated re-deploy of every backend that shares the secret + UI re-login |
| Expired token | `JwtBearerHandler` | `ValidateLifetime = true` (`ClockSkew = 1 min`) | `401`. Tighter than .NET's 5-min default — caller may experience earlier expiration than expected |
| `permissions` claim missing or wrong value | Policy `"FL"` evaluator | claim lookup | `403` |
| Token signed with the well-known dev fallback secret | (silent acceptance) | None | **Security risk in production**. ADR-005 carry-forward; suite-tracked under CMMC L2 row 3 |
| Token from a third-party that knows `JWT_SECRET` | (silent acceptance) | None | **Trust model is shared-secret intra-suite**. Any third-party with the secret can mint accepted tokens. Out of this Epic's scope; suite-wide concern |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Validation latency | sub-millisecond typical | Pure HMAC + claim lookup; no I/O, no network call |
| Throughput | bounded by request throughput | No back-pressure; no token cache (no DB / network round-trip to cache) |
## Notes on `iss` / `aud` validation (suite-tracked)
`ValidateIssuer = false`, `ValidateAudience = false` — consistent with the shared-secret intra-suite model. The CMMC L2 scorecard (`../../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3) flags this as a finding. The remediation will copy the `satellite-provider` pattern across `annotations` and `missions` (suite work, AZ-487 / AZ-494). It is **NOT** in this Epic's scope and will not change as part of the rename refactor.
@@ -0,0 +1,140 @@
# Flow F3 — Mission delete with cross-service cascade
> **Most critical flow in this service.** Touches tables this service does NOT own the schema for; not transaction-wrapped today (`architecture.md` ADR-006). Post-rename / post-B7: cascade no longer touches `orthophotos` + `gps_corrections` (those moved to the separate `gps-denied` service).
## Description
`DELETE /missions/{id}` walks the full ownership graph for one mission and tears down rows in dependency order: `map_objects` (autopilot-written, owned-here schema) → for every waypoint, the `media` / `annotations` / `detection` rows transitively related to it (cross-service tables; schemas owned by `annotations` and the detection pipeline) → `waypoints``missions`. The walk runs against a single `AppDataConnection` (one Npgsql connection from the per-request scope), but is **not wrapped in a transaction** — partial failure leaves orphan rows. See `02_mission_planning` Caveats #1.
## Preconditions
- Mission exists (`KeyNotFoundException``404` otherwise).
- Schema for the borrowed tables (`media`, `annotations`, `detection`) is present in `postgres-local`. In standard suite edge deployment all sibling services have run their own migrations on the same DB. If `annotations` or detection pipeline is absent from the deployment, the cascade fails on `relation does not exist` (`02_mission_planning` Caveats #6).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Identity as 05_identity
participant Errs as 06_http_conventions
participant Ctrl as MissionsController
participant MS as MissionService
participant DB as 04_persistence (postgres-local)
participant Annot as [[annotations service schema]]
participant Det as [[detection pipeline schema]]
participant AP as [[autopilot service schema]]
UI->>Identity: DELETE /missions/{id} + JWT
Identity-->>Ctrl: authorized (policy "FL")
Ctrl->>MS: DeleteMission(id)
MS->>DB: SELECT 1 FROM missions WHERE id = ?
alt Not found
DB-->>MS: 0 rows
MS-->>Errs: KeyNotFoundException
Errs-->>UI: 404 Not Found
else Found
MS->>AP: DELETE FROM map_objects WHERE mission_id = ?
MS->>DB: SELECT id FROM waypoints WHERE mission_id = ? → waypointIds
opt waypointIds.Any()
MS->>Annot: SELECT id FROM media WHERE waypoint_id IN (waypointIds) → mediaIds
MS->>Annot: SELECT id FROM annotations WHERE media_id IN (mediaIds) → annotationIds
MS->>Det: DELETE FROM detection WHERE annotation_id IN (annotationIds)
MS->>Annot: DELETE FROM annotations WHERE id IN (annotationIds)
MS->>Annot: DELETE FROM media WHERE id IN (mediaIds)
end
MS->>DB: DELETE FROM waypoints WHERE mission_id = ?
MS->>DB: DELETE FROM missions WHERE id = ?
MS-->>Ctrl: void
Ctrl-->>UI: 204 No Content
end
```
## Cascade order (authoritative)
```
1. DELETE FROM map_objects WHERE mission_id = ?
(autopilot writes; this service owns schema and cleanup)
2. SELECT id FROM waypoints WHERE mission_id = ? → waypointIds
3. If waypointIds.Any():
SELECT id FROM media WHERE waypoint_id IN waypointIds → mediaIds
SELECT id FROM annotations WHERE media_id IN mediaIds → annotationIds
DELETE FROM detection WHERE annotation_id IN annotationIds (cross-service)
DELETE FROM annotations WHERE id IN annotationIds (cross-service)
DELETE FROM media WHERE id IN mediaIds (cross-service)
4. DELETE FROM waypoints WHERE mission_id = ?
5. DELETE FROM missions WHERE id = ?
```
The order is FK-driven (child rows before parent) and it is the spec-defined behavior in `../../../suite/_docs/02_missions.md` § 9.
## Flowchart (with B7 — no GPS-Denied branch)
```mermaid
flowchart TD
Start([DELETE /missions/id]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Lookup[SELECT 1 FROM missions WHERE id=?]
Lookup --> Exists{Found?}
Exists -->|no| NotFound([404])
Exists -->|yes| MapObj[DELETE FROM map_objects WHERE mission_id=?]
MapObj --> WPs[SELECT id FROM waypoints WHERE mission_id=?]
WPs --> AnyWP{Any waypoints?}
AnyWP -->|no| DelWP[DELETE FROM waypoints]
AnyWP -->|yes| Media[SELECT id FROM media WHERE waypoint_id IN ?]
Media --> Anns[SELECT id FROM annotations WHERE media_id IN ?]
Anns --> Det[DELETE FROM detection WHERE annotation_id IN ?]
Det --> DelAnn[DELETE FROM annotations]
DelAnn --> DelMed[DELETE FROM media]
DelMed --> DelWP
DelWP --> DelMis[DELETE FROM missions]
DelMis --> Done([204 No Content])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `MissionsController` | mission id (URL) | path param |
| 2 | `MissionService` | `map_objects` | `DELETE` | SQL |
| 3 | `MissionService` | `waypoints` (read) | `SELECT id` | SQL → list of GUIDs in memory |
| 4 | `MissionService` | `media` (read) | `SELECT id WHERE waypoint_id IN (...)` | SQL → list of strings (TEXT PK) |
| 5 | `MissionService` | `annotations` (read) | `SELECT id WHERE media_id IN (...)` | SQL → list of strings |
| 6 | `MissionService` | `detection` / `annotations` / `media` (delete) | `DELETE WHERE ... IN (...)` | SQL |
| 7 | `MissionService` | `waypoints` / `missions` (delete) | `DELETE` | SQL |
| 8 | `MissionService` | UI | (no body) | `204 No Content` |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Mission not found | Step 1 (existence check) | `null` lookup | `KeyNotFoundException``404` |
| `relation does not exist` for `media` / `annotations` / `detection` | Steps 46 | Npgsql `PostgresException` (`42P01`) | `500`. **Indicates `annotations` or detection pipeline never migrated on this device.** Abnormal edge deployment — fix is to run those services' migrations once. See `02_mission_planning` Caveats #6 |
| Partial failure mid-cascade (network blip, lock timeout, FK violation) | Any DELETE step | Npgsql exception | `500`. **Orphan rows left behind.** Re-running the same `DELETE /missions/{id}` is partially safe — already-deleted children are no-ops, remaining children proceed; but the original mission row may already be deleted by a successful step 7 in the previous attempt, leaving step 5/6 children orphaned forever. **Mitigated by ADR-006 carry-forward** (transaction wrap) |
| `autopilot` writes a `map_object` racing this delete | Step 2 vs. concurrent insert | None | Insert may succeed AFTER `DELETE FROM map_objects` reads zero rows, leaving an orphan that survives until the next mission delete or manual cleanup. Small race window in single-operator workflow |
| Cascade depth grows because a waypoint accumulates many media rows | Step 4 / 5 result sets | None enforced | LinqToDB sends parameter list inline; PostgreSQL can take 65k params in `IN (...)`. Above ~30k waypoints / media this would need batching — not a near-term concern |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency | <50ms typical for missions with ≤100 waypoints / ≤1000 media | 47 sequential round-trips against local PostgreSQL on the same device |
| Latency on a "fat" mission (10k waypoints / 100k media) | seconds | Each `IN (...)` resolution scales linearly with the result set; PG plan is FK-driven so no full scan |
| Orphan rate | 0 once transaction-wrap lands (ADR-006) | Today: non-zero on any failure mid-cascade |
| Throughput | 1 op / mission delete; not load-tested | Operator-paced; not a hot path |
## Notes on B7 (post-GPS-Denied-removal)
Pre-B7 the cascade also included:
```
DELETE FROM gps_corrections WHERE waypoint_id IN waypointIds
DELETE FROM orthophotos WHERE mission_id = ?
```
Both branches are removed in B7. The `gps-denied` service now owns those tables and is responsible for cleaning up its own rows when missions are deleted (its own concern, its own decision — see `architecture.md` ADR-007). There is **no runtime call** from `missions` to `gps-denied` to "tell it to clean up"; the decoupling is intentional. If the `gps-denied` service is interested in the deletion event, it can poll, watch, or rely on its own per-row TTL — that is a `gps-denied`-side decision documented in `../../../suite/_docs/11_gps_denied.md`.
@@ -0,0 +1,82 @@
# Flow F2 — Mission create / read / update
> Post-rename. Today: `[Route("flights")]`, `Flight*` files.
## Description
Mission CRUD excluding delete (delete is the cross-service cascade in F3). Create / update validate that the referenced `vehicle_id` exists; list (`GET /missions`) is the only paginated endpoint in this service.
## Preconditions
- Service is running, schema in place (F6).
- Caller holds JWT with `permissions=FL` (F5).
- For create / update with `VehicleId`: the referenced vehicle exists (F1).
## Sequence Diagram (POST `/missions`)
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Identity as 05_identity
participant Errs as 06_http_conventions
participant Ctrl as MissionsController
participant MS as MissionService
participant DB as 04_persistence (postgres-local)
UI->>Identity: POST /missions + JWT + { Name, VehicleId }
Identity-->>Ctrl: authorized (policy "FL")
Ctrl->>MS: CreateMission(req)
MS->>DB: SELECT 1 FROM vehicles WHERE id = @VehicleId
alt Vehicle missing
DB-->>MS: 0 rows
MS-->>Errs: throw ArgumentException("VehicleId not found")
note right of MS: Spec says 404; code returns 400. Carry-forward.
Errs-->>UI: 400 Bad Request (PascalCase error envelope)
else Vehicle exists
DB-->>MS: 1 row
MS->>DB: INSERT INTO missions (id, name, vehicle_id, created_date) VALUES (...)
DB-->>MS: row inserted
MS-->>Ctrl: Mission entity
Ctrl-->>UI: 201 Created + Mission (Vehicle / Waypoints serialize as null / [] — no eager load)
end
```
## Flowchart (GET `/missions` paginated)
```mermaid
flowchart TD
Start([GET /missions?Name=&FromDate=&ToDate=&Page=&PageSize=]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Build[Build LINQ predicate from optional filters]
Build --> Count[COUNT * over filtered set]
Count --> Page[SELECT ... ORDER BY created_date DESC LIMIT pageSize OFFSET]
Page --> Wrap[Wrap in PaginatedResponse Items, TotalCount, Page, PageSize]
Wrap --> Done([200 OK + envelope, PascalCase])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `MissionsController` | `CreateMissionRequest` / `UpdateMissionRequest` / `GetMissionsQuery` | JSON / query string (PascalCase) |
| 2 | `MissionService` | `vehicles` table | existence check `SELECT 1` | SQL |
| 3 | `MissionService` | `missions` table | INSERT / UPDATE / SELECT | SQL |
| 4 | `MissionService` | UI | `Mission` entity / `PaginatedResponse<Mission>` | JSON (PascalCase) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `VehicleId` missing on create / update | `MissionService.CreateMission` / `UpdateMission` | existence check returns false | `ArgumentException``400` (spec wants `404` — minor divergence, B-set carry-forward) |
| TOCTOU: vehicle deleted between existence check and insert | `MissionService.CreateMission` | FK constraint violation | Npgsql `PostgresException` → middleware → `500`. UX gap (should be `400`); rare in practice |
| Mission not found | `MissionService.GetMission` / `UpdateMission` | entity lookup `null` | `KeyNotFoundException``404` |
| Page / PageSize out of range | None enforced | n/a | LinqToDB `Skip(negative)` / `Take(0)` returns empty set; no error returned to client |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency (single mission) | <15ms typical | Two round-trips on create (existence check + insert); one on read |
| Paginated list latency | <30ms typical for ≤1000 rows | No index on `created_date` — full scan + sort. Add `ix_missions_created_date` if list latency becomes an issue |
| Throughput | Operator-paced | Not load-tested |
@@ -0,0 +1,92 @@
# Flow F6 — Service startup + schema migration
> One-shot per process start. Idempotent migrator (`CREATE ... IF NOT EXISTS`). Post-B9 the migrator additionally `DROP TABLE IF EXISTS orthophotos / gps_corrections` for fielded edge devices that previously ran the legacy schema.
## Description
`Program.cs` builds the DI graph from environment, runs `DatabaseMigrator.Migrate(db)` once inside a startup scope, then starts serving HTTP. The migrator only owns the 4 tables this service is responsible for (`vehicles`, `missions`, `waypoints`, `map_objects`); the borrowed tables (`media`, `annotations`, `detection`) are migrated by their owner services in their own startups.
## Preconditions
- `DATABASE_URL` resolves (env or hardcoded dev fallback).
- `postgres-local` is reachable.
- The `azaion` database itself exists in PostgreSQL (created at provisioning time, NOT by this migrator).
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
participant Docker as Docker / Watchtower
participant Host as 07_host (Program.cs)
participant Cfg as IConfiguration
participant Identity as 05_identity
participant DI as DI container
participant Migrator as DatabaseMigrator
participant DB as postgres-local
Docker->>Host: ENTRYPOINT dotnet Azaion.Missions.dll
Host->>Cfg: read DATABASE_URL → ConvertPostgresUrl → Npgsql connection string
Host->>Cfg: read JWT_SECRET (env or hardcoded fallback)
Host->>Identity: AddJwtAuth(jwtSecret) — DI registration only, no network
Host->>DI: register controllers + middleware + scoped AppDataConnection + scoped services
Host->>DI: build Host
Host->>Migrator: scope.Resolve<AppDataConnection>(); Migrate(db)
Migrator->>DB: CREATE TABLE IF NOT EXISTS vehicles (...)
Migrator->>DB: CREATE TABLE IF NOT EXISTS missions (...)
Migrator->>DB: CREATE TABLE IF NOT EXISTS waypoints (...)
Migrator->>DB: CREATE TABLE IF NOT EXISTS map_objects (...)
Migrator->>DB: CREATE INDEX IF NOT EXISTS ix_missions_vehicle_id, ix_waypoints_mission_id, ix_map_objects_mission_id
Migrator->>DB: DROP TABLE IF EXISTS orthophotos
Migrator->>DB: DROP TABLE IF EXISTS gps_corrections
note right of Migrator: B9 one-shot. Idempotent on devices that already cleaned up.
Migrator-->>Host: void
Host->>Host: register ErrorHandlingMiddleware FIRST in pipeline
Host->>Host: UseAuthentication / UseAuthorization
Host->>Host: MapControllers + MapGet("/health") + UseSwagger
Host->>Docker: app.Run() — listening on 0.0.0.0:8080
```
## Flowchart
```mermaid
flowchart TD
Start([Container start]) --> ReadCfg[Read DATABASE_URL + JWT_SECRET]
ReadCfg --> RegDI[DI registrations: controllers, middleware, scoped DB + services]
RegDI --> Build[Build Host]
Build --> OpenScope[Open startup scope]
OpenScope --> Migrate[Run DatabaseMigrator.Migrate]
Migrate --> Create["CREATE TABLE IF NOT EXISTS x4 + indexes"]
Create --> Drop["DROP TABLE IF EXISTS orthophotos, gps_corrections (B9)"]
Drop --> Pipeline[Wire pipeline: error MW first, auth, controllers, /health, Swagger]
Pipeline --> Run([app.Run on :8080])
Migrate -. on failure .-> Crash([Process exits non-zero — Watchtower restarts])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | Environment / appsettings | `Program.cs` | `DATABASE_URL`, `JWT_SECRET` | string |
| 2 | `Program.cs` | DI container | service registrations | C# code |
| 3 | `DatabaseMigrator` | `postgres-local` | DDL statements (CREATE / INDEX / DROP) | SQL |
| 4 | `Program.cs` | OS / Docker | bind to `0.0.0.0:8080` | TCP listener |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `postgres-local` unreachable | Migrate step | Npgsql `IOException` / `SocketException` | Process exits non-zero. Watchtower restarts; `flight-gate` prevents restart mid-mission. **Fix**: ensure `postgres-local` healthcheck passes before `missions` starts (compose `depends_on` with `condition: service_healthy`) |
| `azaion` database missing | Migrate step | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database — provisioning concern, not this service. Documented in `../../suite/_docs/00_top_level_architecture.md` |
| `DROP TABLE IF EXISTS orthophotos` fails because table is locked by `gps-denied` | B9 one-shot | Lock timeout or `55006` | Process exits, Watchtower restarts in a few seconds. **Out-of-band ordering**: deploy `gps-denied` FIRST so it has its own copy of the schema before `missions` drops the legacy tables. Documented in B9 ticket |
| One `CREATE TABLE` succeeds, the next fails | Mid-Migrate | Npgsql exception on later statement | Process exits. Each statement is individually idempotent (`IF NOT EXISTS`) so the next startup retries safely from the start. No partial-migration cleanup needed |
| Wrong PostgreSQL version (e.g., < 13) | Migrate step | Specific syntax errors in newer features | Process exits. Suite-supported version is PG 16+; older devices need a Postgres upgrade |
| `DATABASE_URL` malformed (e.g. user password contains `@`) | `ConvertPostgresUrl` | parse failure / silent mis-parse | `ConvertPostgresUrl` does NOT URL-decode user/password — caveat for credentials with `@`, `:`, `/`, `%`. `07_host` Caveats. Mitigation: avoid those chars in passwords, OR pass a raw Npgsql key=value string instead of a URL |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Cold start total time | <2 seconds typical on Jetson Orin | Migrator runs ~10 DDL statements; all are no-ops on a steady-state device |
| Cold start with legacy GPS-Denied tables present | +1 second | First-time-on-device B9 `DROP` adds two DDL statements |
| Crash recovery (Watchtower restart) | ~10 seconds | Container restart latency dominates |
@@ -0,0 +1,87 @@
# Flow F1 — Vehicle CRUD
> Post-rename / post-B7. Today: `[Route("aircrafts")]`, `Aircraft*` files. See `02_mission_planning` is unaffected by route prefix.
## Description
Operator manages the inventory of `Vehicle` rows. Six endpoints: POST, PUT, DELETE, GET-list (unpaginated by spec), GET-by-id, PATCH `/default`. Every endpoint is gated by `[Authorize(Policy = "FL")]`. The "exactly one default vehicle" exclusivity rule is **stricter than spec** — the code clears `IsDefault` on every other row before setting it on the target. See `01_vehicle_catalog` Caveats #1, tracked under Jira AZ-551 (B12).
## Preconditions
- Service is running and schema is in place (Flow F6 has completed).
- Caller holds a JWT with `permissions=FL` (Flow F5 succeeds).
## Sequence Diagram (POST `/vehicles` with `IsDefault: true`)
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Pipeline as ASP.NET Pipeline
participant Identity as 05_identity
participant Errs as 06_http_conventions
participant Ctrl as VehiclesController
participant Svc as VehicleService
participant DB as 04_persistence (postgres-local)
UI->>Pipeline: POST /vehicles + Bearer JWT + body
Pipeline->>Errs: enter middleware (catches exceptions)
Errs->>Identity: validate JWT + policy "FL"
alt JWT or policy fails
Identity-->>UI: 401 / 403
else authorized
Identity-->>Ctrl: ClaimsPrincipal attached
Ctrl->>Svc: CreateVehicle(req)
opt req.IsDefault == true
Svc->>DB: UPDATE vehicles SET is_default=FALSE WHERE is_default=TRUE
note right of Svc: Stricter than spec. Race-prone (no transaction). B12.
end
Svc->>DB: INSERT INTO vehicles VALUES (...)
DB-->>Svc: row id
Svc-->>Ctrl: Vehicle entity
Ctrl-->>Errs: 201 Created + Vehicle (PascalCase JSON)
Errs-->>UI: 201 Created
end
```
## Flowchart (DELETE `/vehicles/{id}`)
```mermaid
flowchart TD
Start([DELETE /vehicles/id]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Lookup[SELECT 1 FROM vehicles WHERE id=?]
Lookup --> Exists{Found?}
Exists -->|no| NotFound([KeyNotFoundException → 404])
Exists -->|yes| Refs[SELECT 1 FROM missions WHERE vehicle_id=?]
Refs --> InUse{Any mission references it?}
InUse -->|yes| Conflict([InvalidOperationException → 409])
InUse -->|no| Del[DELETE FROM vehicles WHERE id=?]
Del --> Done([204 No Content])
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `VehiclesController` | `CreateVehicleRequest` / `UpdateVehicleRequest` / `SetDefaultRequest` / query string | JSON (PascalCase) |
| 2 | `VehicleService` | `vehicles` table | INSERT / UPDATE / DELETE | SQL |
| 3 | `vehicles` table | `VehicleService` | row(s) | LinqToDB entity mapping |
| 4 | `VehicleService` | UI | `Vehicle` entity | JSON (PascalCase) — entity returned directly, no DTO mapping |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing / invalid JWT | Pipeline | `JwtBearerHandler` | `401`; client refreshes token |
| Missing `"FL"` claim | Policy evaluator | Claim lookup | `403` |
| Vehicle not found | Service entity lookup | `null` result | `KeyNotFoundException``404` |
| Delete-with-references | `VehicleService.DeleteVehicle` | `IsAny<Mission>` true | `InvalidOperationException``409` |
| Concurrent default-set | `VehicleService.{Create,Update}Vehicle` / `SetDefault` | none (no transaction) | Race window: 2+ defaults OR zero defaults. B12 |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency (CRUD) | <10ms typical | Single round-trip against local PostgreSQL |
| Throughput | Operator-paced | Not load-tested; catalog is small in practice (tens to low hundreds of rows) |
@@ -0,0 +1,86 @@
# Flow F4 — Waypoint create / read / update / delete
> Post-rename / post-B7. Waypoint delete is a scoped variant of F3's cross-service cascade — same NO-transaction caveat applies (`architecture.md` ADR-006).
## Description
Waypoint CRUD nested under a mission (`/missions/{id}/waypoints/*`). Read-list is **unpaginated by spec**, ordered by `OrderNum`. **`UpdateWaypoint` is a full overwrite** of every field even though the request DTO looks "partial-shaped" (see `02_mission_planning` Caveats #2). Delete walks the cross-service cascade for **one** waypoint (compare F3 which walks for ALL waypoints of a mission).
## Preconditions
- Parent mission exists (`KeyNotFoundException``404` otherwise on every endpoint).
- Caller holds JWT with `permissions=FL` (F5).
- Schema in place for borrowed tables (`media`, `annotations`, `detection`) for delete.
## Sequence Diagram (DELETE one waypoint)
```mermaid
sequenceDiagram
autonumber
participant UI as Operator UI
participant Identity as 05_identity
participant Ctrl as MissionsController
participant WS as WaypointService
participant DB as 04_persistence (postgres-local)
participant Annot as [[annotations service schema]]
participant Det as [[detection pipeline schema]]
UI->>Identity: DELETE /missions/{id}/waypoints/{wpId} + JWT
Identity-->>Ctrl: authorized (policy "FL")
Ctrl->>WS: DeleteWaypoint(missionId, wpId)
WS->>DB: SELECT 1 FROM waypoints WHERE mission_id=? AND id=?
alt Not found
DB-->>WS: 0 rows
WS-->>UI: 404 Not Found
else Found
WS->>Annot: SELECT id FROM media WHERE waypoint_id = ? → mediaIds
WS->>Annot: SELECT id FROM annotations WHERE media_id IN ? → annotationIds
WS->>Det: DELETE FROM detection WHERE annotation_id IN annotationIds
WS->>Annot: DELETE FROM annotations WHERE id IN annotationIds
WS->>Annot: DELETE FROM media WHERE id IN mediaIds
WS->>DB: DELETE FROM waypoints WHERE id = ?
WS-->>UI: 204 No Content
end
```
## Flowchart (PUT — full overwrite)
```mermaid
flowchart TD
Start([PUT /missions/id/waypoints/wpId + body]) --> Auth{JWT + FL valid?}
Auth -->|no| Reject([401 / 403])
Auth -->|yes| Lookup[SELECT * FROM waypoints WHERE mission_id=? AND id=?]
Lookup --> Exists{Found?}
Exists -->|no| NotFound([404])
Exists -->|yes| Overwrite["UPDATE waypoints SET lat=?, lon=?, mgrs=?, alt=?, source=?, objective=?, order_num=?, name=? WHERE id=?"]
Overwrite --> Done([200 OK + Waypoint])
note1[NOTE: Sending partial body zeroes out missing numeric fields and resets enums to default 0. Spec uses Geopoint type with auto-conversion; code uses 3 flat fields. Carry-forward.]
```
## Data Flow
| Step | From | To | Data | Format |
|------|------|----|------|--------|
| 1 | UI | `MissionsController` (nested) | mission id + waypoint id (URL) + `CreateWaypointRequest` / `UpdateWaypointRequest` body | path params + JSON |
| 2 | `WaypointService` | `waypoints` table | INSERT / UPDATE / SELECT / DELETE | SQL |
| 3 | `WaypointService` | `media` / `annotations` / `detection` (delete only) | `SELECT id` then `DELETE WHERE id IN (...)` | SQL |
| 4 | `WaypointService` | UI | `Waypoint` entity / `List<Waypoint>` / `204` | JSON (PascalCase) |
## Error Scenarios
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Parent mission not found | Service entity lookup | `null` | `KeyNotFoundException``404` |
| Waypoint not found in mission | Service entity lookup with both ids | `null` | `KeyNotFoundException``404` |
| PUT zeroes out coordinates | `WaypointService.UpdateWaypoint` | None | Body intent is "partial" but code overwrites every column → silent data loss for missing fields. Carry-forward (`02_mission_planning` Caveats #2) |
| Race on N waypoints reordered as N PUTs | Caller-side | None | No reorder endpoint exists — caller must coordinate; partially-applied reorders leave inconsistent `order_num`s (`02_mission_planning` Caveats #5) |
| Delete cascade `relation does not exist` for `media` / `annotations` / `detection` | DELETE steps | Npgsql `PostgresException` (`42P01`) | `500`. Same diagnosis as F3: abnormal edge deployment |
| Partial failure mid-delete-cascade | Same as F3 | Npgsql exception | `500` + orphan rows. ADR-006 carry-forward |
## Performance Expectations
| Metric | Target | Notes |
|--------|--------|-------|
| Create / read / update | <10ms typical | Single round-trip |
| List (unpaginated) | <30ms typical for ≤1000 waypoints | `ix_waypoints_mission_id` index used; sort by `order_num` is in-memory (no order index) |
| Delete (with cross-service cascade) | <30ms typical for ≤100 media rows per waypoint | 56 round-trips |
+128
View File
@@ -0,0 +1,128 @@
# Glossary — `missions` (Azaion edge-tier .NET service)
**Status**: confirmed-by-user
**Date**: 2026-05-14
**Scope**: terms used inside this submodule's `_docs/02_document/` set, plus suite-level terms recurring in those docs. Generic CS / industry terms intentionally omitted.
> **NOTE**: this glossary reflects the **post-rename, post-GPS-Denied-removal** target. The pre-rename names (`Aircraft`, `Flight`, `Orthophoto`, `GpsCorrection`, the `"GPS"` policy) are kept as deprecated entries to make code-vs-doc reconciliation possible during the B5B12 ticket window. The B-tickets are tracked under Jira AZ-EPIC (AZ-539); the leftover at `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` is the source of truth for the rename plan.
## A
- **admin** — remote .NET service that mints HS256 JWTs against the central user PostgreSQL; this service only validates. *source: `components/05_identity/description.md`*
- **Aircraft** *(deprecated → Vehicle, B6)* — pre-rename name for the operator-managed inventory entry. *source: `00_discovery.md`, `modules/entities.md`*
- **Annotation** — borrowed read-only entity (text PK, FK to `media`); schema owned by `annotations`; cascade-deleted by `missions`. *source: `modules/entities.md`*
- **annotations** *(suite service)* — edge-tier .NET sibling that owns the `media` + `annotations` table schemas. *source: `data_model.md`*
- **AppDataConnection**`linq2db` `DataConnection` exposing `ITable<T>` for every persisted entity (4 owned + 3 borrowed post-B7); per-HTTP-request scoped. *source: `modules/database.md`*
- **autopilot** *(suite service)* — edge service that reads `missions` + `waypoints` to drive the vehicle and writes `map_objects`. *source: `data_model.md`, `components/04_persistence/description.md`*
- **AZ-539 (AZ-EPIC)** — umbrella Jira epic covering this rename + multi-vehicle support + GPS-Denied removal. *source: `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`*
- **AZAION_REVISION** — env var baked from `CI_COMMIT_SHA` at build time; surfaces the source commit at runtime via `docker inspect`. *source: `deployment/containerization.md`*
## B
- **B-tickets (B1B12)** — child stories under AZ-EPIC. B1 docs, B2 suite-doc cleanup, B3 state bookkeeping, B5 namespace/csproj, B6 domain rename, B7 GPS-Denied removal, B8 HTTP routes, B9 DB migration, B10 Dockerfile/image, B12 default-vehicle decision. *source: `_docs/tasks/`*
## C
- **Cascade-delete** *(this service's contract)* — manual walk in `MissionService.DeleteMission` / `WaypointService.DeleteWaypoint` that deletes rows in FK order across other services' tables (`media`, `annotations`, `detection`) plus this service's own `map_objects`, `waypoints`, `missions`. NOT transaction-wrapped today (ADR-006). *source: `architecture.md` ADR-003 + ADR-006*
- **CMMC L2 row 3** — scorecard finding: JWT `iss`/`aud` validation is disabled across the .NET suite services. Tracked at suite level under AZ-487 / AZ-494; out of this Epic. *source: `components/05_identity/description.md`*
- **Copter**`VehicleType = 1`; multirotor UAV. *source: `00_discovery.md`*
## D
- **DatabaseMigrator** — startup-time idempotent schema bootstrap; runs `CREATE TABLE IF NOT EXISTS` for 4 owned tables + 3 indexes (post-B9). B9 also adds a one-shot `DROP TABLE IF EXISTS` for legacy GPS-Denied tables. *source: `modules/database.md`*
- **Default vehicle** (`is_default`) — boolean on `Vehicle`. Code enforces "exactly one default" by clear-then-set; spec only toggles. Race-prone (no transaction). Resolution tracked under B12. *source: `components/01_vehicle_catalog/description.md`*
- **Detection** *(entity)* — borrowed read-only entity (singular table name owned by detection pipeline); FK to `annotation`. Cascade-deleted by `missions`. *source: `modules/entities.md`*
- **detection pipeline** — edge AI service that owns the `detection` table schema. *source: `data_model.md`*
## E
- **Edge tier** — per-device deployment on Jetson Orin / OrangePI / operator-PC; one container per service per device. *source: `00_discovery.md`, `architecture.md`*
- **ErrorHandlingMiddleware** — global exception → JSON mapper. Maps `KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`; everything else → 500 (with stack trace logged). Emits a camelCase anonymous-object envelope `{ statusCode, message }` — accidental match with the spec's case style; missing the spec's `errors` field. *source: `modules/middleware.md`, `components/06_http_conventions/description.md`*
- **ErrorResponse DTO** — defined in `DTOs/ErrorResponse.cs` but unused on the wire; declares PascalCase properties + wrong shape (`List<string>? Errors` instead of spec's `object?`). Dead code candidate. *source: `modules/dtos.md`*
## F
- **FL policy / "FL" permission** — the only authorization policy this service consumes; satisfied by a JWT `permissions` claim with value `"FL"`. The permission *code* retains the legacy "Flight" wording even after the service rename to `missions` (renaming the code is a fleet-wide auth change — not in this Epic). *source: `components/05_identity/description.md`*
- **Flight** *(deprecated → Mission, B6)* — pre-rename name for the planned operation entity. *source: `00_discovery.md`, `modules/entities.md`*
- **flight-gate** — suite-level supervisor that prevents container restart mid-mission. *source: `../../suite/_docs/00_top_level_architecture.md`*
- **FuelType** — enum `{ Electric=0, Gasoline=1, Diesel=2 }`. May not fit `GuidedMissile` (Phase C decision; carry-forward). *source: `modules/enums.md`*
## G
- **GeoPoint** — shared DTO `{ Lat?, Lon?, Mgrs? }`. Spec wants a single auto-converting `string GPS` (carry-forward divergence — out of this Epic). *source: `modules/dtos.md`, `modules/entities.md`*
- **GPS policy / "GPS" permission** *(deprecated, removed in B7)* — pre-B7 second policy in code that authorized orthophoto / GPS-correction endpoints. Removed in AZ-546 (B7). *source: today's `Auth/JwtExtensions.cs`, `components/05_identity/description.md`*
- **GpsCorrection** *(deprecated → `gps-denied` service, B7+B9)* — pre-B7 entity for GPS-correction CRUD. *source: `modules/entities.md` (forward-looking)*
- **gps-denied** *(suite service, post-B7)* — separate edge service that owns `orthophotos` + `gps_corrections` tables and references `mission_id` / `waypoint_id` as plain GUIDs. **No runtime coupling** to `missions` either direction. *source: `architecture.md` ADR-007*
- **GuidedMissile**`VehicleType = 3`; single-use loitering munition (added in B6). *source: `modules/enums.md`*
## H
- **H3 / H3 hex grid** — Uber's hexagonal hierarchical spatial index used on `map_objects.h3_index` for fast spatial bucketing of detections. *source: `modules/entities.md`, `data_model.md`*
- **`/health`** — anonymous `GET /health` returning `{ status: "healthy" }`. Process-liveness only; does NOT ping the DB. *source: `system-flows.md` F7*
## J
- **JWT bearer (HS256)** — minted by central `admin` service, validated locally with the shared `JWT_SECRET`; no callback to issuer per request. `ClockSkew = 1 minute` (tighter than .NET's 5-minute default). *source: `system-flows.md` F5, `modules/auth.md`*
- **JWT_SECRET** — shared HMAC secret used by every .NET service in the suite. Rotation requires coordinated redeploy. Hardcoded dev fallback in `Program.cs` MUST be overridden in production. *source: `components/05_identity/description.md`, `components/07_host/description.md`*
## L
- **linq2db** *(6.2.0)* — LINQ-to-SQL provider with attribute mapping; this service's only ORM. `[Association]` navigation does NOT eager-load by default on `FirstOrDefaultAsync(predicate)`. *source: `architecture.md` § Tech Stack*
## M
- **MapObject** — H3-indexed detection projection with class + confidence + spatial position; FK to `Mission`. **Schema owned by this service, written by `autopilot`, cascade-deleted by `missions`.** *source: `components/04_persistence/description.md`*
- **Media** — borrowed read-only entity (text PK, nullable `waypoint_id`); schema owned by `annotations`. Cascade-deleted by `missions`. *source: `modules/entities.md`*
- **MGRS** — Military Grid Reference System; alternate location encoding stored alongside `lat`/`lon` on `waypoints`, `map_objects`. *source: `modules/entities.md`*
- **Mission** — planned operation entity; FK to `Vehicle`. Pre-rename name "Flight". *source: `components/02_mission_planning/description.md`*
- **Mission Planning** *(component `02_mission_planning`)* — owns `Mission` + `Waypoint` CRUD plus the cross-service cascade-delete walk. *source: `components/02_mission_planning/description.md`*
- **`missions`** *(this service)* — edge-tier .NET 10 REST service that owns the mission domain of each Azaion deployment. Pre-rename: `flights`. *source: `architecture.md`*
## O
- **Operator personas** — Operator, Operator+, Validator, CompanionPC, Admin, ApiAdmin — roles in the suite-level RBAC matrix that resolve to the `FL` permission. *source: `../../suite/_docs/00_roles_permissions.md`*
- **Orthophoto** *(deprecated → `gps-denied` service, B7+B9)* — pre-B7 entity for satellite-image orthophoto upload + listing. *source: `modules/entities.md` (forward-looking)*
## P
- **PaginatedResponse&lt;T&gt;** — shared envelope `{ Items, TotalCount, Page, PageSize }` (PascalCase wire shape — divergent from spec's camelCase). Used only by `GET /missions`. *source: `components/06_http_conventions/description.md`, `modules/dtos.md`*
- **Plane**`VehicleType = 0`; fixed-wing UAV. *source: `modules/enums.md`*
- **postgres-local** — ONE PostgreSQL instance per edge device, shared by every backend service on the device. Per-service table ownership enforced by convention (not by per-service DB users). *source: `data_model.md` § 1, `../../suite/_docs/00_top_level_architecture.md`*
## S
- **Suite** — the parent meta-repo `azaion-suite` aggregating 11 component submodules orchestrated by the parent at `../../`. Authoritative human-confirmed docs live at `../../suite/_docs/`. *source: `00_discovery.md`*
- **Swagger**`Swashbuckle.AspNetCore` (10.1.5); UI mounted unconditionally (no `IsDevelopment()` gate) — ADR-005 carry-forward. *source: `components/07_host/description.md`*
## U
- **UGV**`VehicleType = 2`; Unmanned Ground Vehicle (added in B6). References `../../hardware/_standalone/target_acquisition/target_acquisition.md`. *source: `modules/enums.md`*
- **`ui`** *(suite service)* — React frontend on each edge device; the dominant inbound HTTP consumer. *source: `architecture.md`*
## V
- **Vehicle** — operator-managed inventory entry; one of `{ Plane, Copter, UGV, GuidedMissile }`. Pre-rename name "Aircraft". *source: `components/01_vehicle_catalog/description.md`*
- **Vehicle Catalog** *(component `01_vehicle_catalog`)* — owns `Vehicle` CRUD + the "is_default" exclusivity rule. *source: `components/01_vehicle_catalog/description.md`*
- **VehicleType** — enum `{ Plane=0, Copter=1, UGV=2, GuidedMissile=3 }`. Extended from `{ Plane, Copter }` in B6. *source: `modules/enums.md`*
## W
- **Watchtower** — container restart-on-crash + image-update poller running on each edge device; works in conjunction with `flight-gate` to avoid restart mid-mission. *source: `architecture.md` § Deployment Model*
- **Waypoint** — ordered geo-point inside a `Mission`; FK to `Mission`. *source: `modules/entities.md`, `components/02_mission_planning/description.md`*
- **WaypointObjective** — enum `{ Surveillance=0, Strike=1, Recon=2 }`. *source: `modules/enums.md`*
- **WaypointSource** — enum `{ Auto=0, Manual=1 }`. *source: `modules/enums.md`*
- **Woodpecker** — CI runner; one ARM-tagged build job per push to `dev` / `stage` / `main`. Single Dockerfile-based build + push step; no test, no security scan today. *source: `deployment/ci_cd_pipeline.md`*
## Synonym pairs (today's code ↔ post-rename target)
| Today (`Azaion.Flights.*`) | Post-rename (`Azaion.Missions.*`) | Touched by |
|----------------------------|-----------------------------------|------------|
| `Aircraft` (entity, controller, service, DTOs, enum) | `Vehicle` | B6 |
| `Flight` (entity, controller, service, DTOs, table) | `Mission` | B6 |
| `aircraft_id` (FK on missions) | `vehicle_id` | B6 + B9 |
| `flight_id` (FK on waypoints, map_objects, orthophotos, gps_corrections) | `mission_id` | B6 + B9 |
| `[Route("aircrafts")]`, `[Route("flights")]` | `[Route("vehicles")]`, `[Route("missions")]` | B8 |
| `Azaion.Flights.csproj`, `dotnet Azaion.Flights.dll`, `azaion/flights:*-arm` | `Azaion.Missions.csproj`, `dotnet Azaion.Missions.dll`, `azaion/missions:*-arm` | B5 + B10 |
| `"GPS"` policy + `Orthophoto` + `GpsCorrection` entities + cascade branches | *(removed)* | B7 + B9 |
| 6 owned tables, 9 entities | 4 owned tables, 7 entities | B7 + B9 |
| `AircraftType { Plane, Copter }` | `VehicleType { Plane, Copter, UGV, GuidedMissile }` | B6 |
+173
View File
@@ -0,0 +1,173 @@
# Module Layout
**Status**: derived-from-code (post-rename, forward-looking — see Verification Needed)
**Language**: csharp
**Layout Convention**: **custom** (layer-organized, NOT per-component-directory — see `## Verification Needed` below)
**Root**: `./` (no `src/` directory; .NET `Microsoft.NET.Sdk.Web` project at the repo root)
**Last Updated**: 2026-05-14
> **NOTE (forward-looking)**: file paths reflect the post-rename + post-GPS-Denied-removal state. Today's source still uses `Aircraft*` / `Flight*` / `Orthophoto*` / `GpsCorrection*` filenames, csproj is `Azaion.Flights.csproj`, and namespace is `Azaion.Flights.*`. Renames + drops are tracked under Jira AZ-EPIC children B5 (namespace), B6 (rename), B7 (GPS-Denied removal), B8 (HTTP routes), B9 (DB migration), B10 (Dockerfile / image / compose).
## Layout Rules
This codebase **does not** follow the "one directory per component" convention from the template. It is organized **horizontally** by architectural layer:
```
./
├── Auth/ ← cross-cutting (component 05_identity)
├── Controllers/ ← API surface (one file per feature component, 01 + 02)
├── Database/ ← persistence (component 04)
│ └── Entities/
├── DTOs/ ← payload types (split across components 01, 02, 06)
├── Enums/ ← domain enums (split across components 01, 02, 04)
├── Middleware/ ← cross-cutting (component 06_http_conventions)
├── Services/ ← business logic (one file per feature component, 01 + 02)
├── Entities/ ← EMPTY (scaffolding leftover)
├── Infrastructure/ ← EMPTY (scaffolding leftover)
├── Program.cs ← composition root (component 07_host)
└── GlobalUsings.cs ← composition root (component 07_host)
```
Consequence: each component's `Owns` glob is a SET OF FILE PATHS spanning multiple directories, NOT a single directory glob. There is no `shared/` directory; cross-cutting concerns (`05_identity`, `06_http_conventions`) own their root-level dirs (`Auth/`, `Middleware/`) directly.
The C# project has no separate per-component csproj — there is one project (post-rename: `Azaion.Missions.csproj`; today: `Azaion.Flights.csproj`) and effectively one root namespace (post-rename: `Azaion.Missions.*`). Other components reference each other through types directly; there is no compiled "Public API" boundary.
## Per-Component Mapping
### Component: 01_vehicle_catalog
- **Epic**: Jira AZ-EPIC (rename + multi-vehicle support)
- **Directory(ies)**: spread across `Controllers/`, `Services/`, `DTOs/`, `Enums/`
- **Public API** (types other components reference): `Services/VehicleService.cs` (consumed by `07_host` for DI registration; `02_mission_planning` consumes its existence semantics through the DB)
- **Owns (exclusive write)** (post-rename):
- `Controllers/VehiclesController.cs`
- `Services/VehicleService.cs`
- `DTOs/CreateVehicleRequest.cs`
- `DTOs/UpdateVehicleRequest.cs`
- `DTOs/GetVehiclesQuery.cs`
- `DTOs/SetDefaultRequest.cs`
- **Internal**: none — every file is publicly importable in C# without explicit visibility annotations
- **Imports from**: `04_persistence` (`AppDataConnection`, `Vehicle` entity, `VehicleType` enum, `FuelType` enum), `05_identity` (`[Authorize(Policy = "FL")]`), `06_http_conventions` (exception → middleware mapping is implicit)
- **Consumed by**: `02_mission_planning` (FK existence-check on `vehicle_id`), `07_host` (DI registration)
### Component: 02_mission_planning
- **Epic**: Jira AZ-EPIC
- **Directory(ies)**: spread across `Controllers/`, `Services/`, `DTOs/`, `Enums/`
- **Public API** (post-rename): `Services/MissionService.cs`, `Services/WaypointService.cs` (DI-registered in `07_host`)
- **Owns (exclusive write)** (post-rename):
- `Controllers/MissionsController.cs`
- `Services/MissionService.cs`
- `Services/WaypointService.cs`
- `DTOs/CreateMissionRequest.cs`
- `DTOs/UpdateMissionRequest.cs`
- `DTOs/GetMissionsQuery.cs`
- `DTOs/CreateWaypointRequest.cs`
- `DTOs/UpdateWaypointRequest.cs`
- `DTOs/GeoPoint.cs`
- **Internal**: none
- **Imports from**: `04_persistence` (incl. `WaypointSource` + `WaypointObjective` enums), `05_identity`, `06_http_conventions` (`PaginatedResponse<T>`), `01_vehicle_catalog` (existence semantics through DB FK)
- **Consumed by**: `07_host` (DI), and external `autopilot` / `ui` (cross-service over HTTP)
### Component: 04_persistence
- **Epic**: Jira AZ-EPIC
- **Directory(ies)**: `Database/`, plus `Enums/ObjectStatus.cs` (cross-cutting status enum)
- **Public API**: `Database/AppDataConnection.cs` (the `DataConnection` type other components depend on), `Database/DatabaseMigrator.cs` (called by `07_host` at startup), all 7 entities under `Database/Entities/` (referenced by services + DTOs as row maps), the four persisted-column enums under `Enums/` (`VehicleType`, `FuelType`, `WaypointSource`, `WaypointObjective`, `ObjectStatus`)
- **Owns (exclusive write)** (post-rename):
- `Database/AppDataConnection.cs`
- `Database/DatabaseMigrator.cs`
- `Database/Entities/Vehicle.cs`
- `Database/Entities/Mission.cs`
- `Database/Entities/Waypoint.cs`
- `Database/Entities/MapObject.cs`
- `Database/Entities/Media.cs` (borrowed schema)
- `Database/Entities/Annotation.cs` (borrowed schema)
- `Database/Entities/Detection.cs` (borrowed schema)
- `Enums/VehicleType.cs` (persisted on `vehicles.type`; consumed by `01_vehicle_catalog` DTOs)
- `Enums/FuelType.cs` (persisted on `vehicles.fuel_type`; consumed by `01_vehicle_catalog` DTOs)
- `Enums/WaypointSource.cs` (persisted on `waypoints.waypoint_source`; consumed by `02_mission_planning` DTOs)
- `Enums/WaypointObjective.cs` (persisted on `waypoints.waypoint_objective`; consumed by `02_mission_planning` DTOs)
- `Enums/ObjectStatus.cs` (persisted on `map_objects.object_status`)
- **Internal**: none
- **Imports from**: nothing internal
- **Consumed by**: `01_vehicle_catalog`, `02_mission_planning`, `07_host`
### Component: 05_identity
- **Epic**: Jira AZ-EPIC
- **Directory(ies)**: `Auth/`
- **Public API**: `Auth/JwtExtensions.AddJwtAuth(...)` (called by `07_host`); the `"FL"` policy NAME (referenced as a string by feature controllers — string-typed dependency, NOT compile-checked)
- **Owns (exclusive write)**:
- `Auth/JwtExtensions.cs`
- **Internal**: none
- **Imports from**: nothing internal
- **Consumed by**: `01_vehicle_catalog`, `02_mission_planning`, `07_host`
### Component: 06_http_conventions
- **Epic**: Jira AZ-EPIC
- **Directory(ies)**: `Middleware/`, plus `DTOs/ErrorResponse.cs` and `DTOs/PaginatedResponse.cs`
- **Public API**: `Middleware/ErrorHandlingMiddleware` (registered by `07_host`); `DTOs/PaginatedResponse<T>` (returned by `02_mission_planning`); `DTOs/ErrorResponse` is unused on the wire today (see component description Caveats #2)
- **Owns (exclusive write)**:
- `Middleware/ErrorHandlingMiddleware.cs`
- `DTOs/ErrorResponse.cs`
- `DTOs/PaginatedResponse.cs`
- **Internal**: none
- **Imports from**: nothing internal
- **Consumed by**: `02_mission_planning` (`PaginatedResponse<T>`), `07_host` (middleware registration), all components implicitly (exception → status code mapping)
### Component: 07_host
- **Epic**: Jira AZ-EPIC
- **Directory(ies)**: repo root
- **Public API**: none (it is the runtime entry point)
- **Owns (exclusive write)**:
- `Program.cs`
- `GlobalUsings.cs`
- **Internal**: none
- **Imports from**: `04_persistence`, `05_identity`, `06_http_conventions`, `01_vehicle_catalog`, `02_mission_planning`
- **Consumed by**: nothing internal — invoked by the .NET runtime via `dotnet Azaion.Missions.dll` (post-rename) / `dotnet Azaion.Flights.dll` (today)
## Shared / Cross-Cutting
There is no `shared/` directory in this codebase. The role canonically taken by `shared/*` is filled by:
- **`05_identity`** (`Auth/`) — auth setup + named policies
- **`06_http_conventions`** (`Middleware/` + 2 DTOs) — error envelope + paginated response envelope
- **`04_persistence`** — provides shared `AppDataConnection` to all feature components
All five enums under `Enums/` (`VehicleType`, `FuelType`, `WaypointSource`, `WaypointObjective`, `ObjectStatus`) are owned by `04_persistence` because they are *persisted column types* — every one of them maps to an `INTEGER` column in the schema (`Database/DatabaseMigrator.cs`) and is referenced from a `04_persistence`-owned entity (`Vehicle`, `Waypoint`, `MapObject`). Feature components (`01`, `02`) consume them as foundation types, never own them. This was retagged on 2026-05-14 to resolve baseline findings F1 + F2 (see `_docs/02_document/architecture_compliance_baseline.md`); previously `VehicleType` / `FuelType` were tagged under `01` and `WaypointSource` / `WaypointObjective` under `02`, which created a Foundation ← Feature layering violation.
## Allowed Dependencies (layering)
Read top-to-bottom; an upper layer may import from a lower layer but NEVER the reverse.
| Layer | Components | May import from |
|-------|------------|-----------------|
| 4. Composition root | 07_host | 1, 2, 3 |
| 3. Feature surfaces | 01_vehicle_catalog, 02_mission_planning | 1, 2 |
| 2. Domain (none today) | — | 1 |
| 1. Foundation | 04_persistence, 05_identity, 06_http_conventions | (none) |
There is no "Domain" layer in this codebase — feature components are thin (controller + service + DTOs) and bind directly to persistence entities. This matches typical CRUD-style ASP.NET Core services and is the deliberate shape per `../../suite/_docs/02_missions.md`.
Violations of this table are **Architecture** findings in code-review Phase 7 (High severity).
## Layout Conventions (reference)
| Language | Root | Per-component path | Public API file | Test path |
|----------|------|-------------------|-----------------|-----------|
| C# (.NET) | `src/` (canonical) | `src/<Component>/` | `src/<Component>/<Component>.cs` (namespace root) | `tests/<Component>.Tests/` |
| **C# (this repo)** | `./` (NOT canonical) | spread by horizontal layer | (no per-component public-API file; types referenced directly) | **no tests project** |
## Verification Needed
- [ ] **Forward-looking file paths**: every "Owns" path above reflects the post-rename target (B5/B6/B7/B8). Today the files still have `Aircraft*` / `Flight*` / `Orthophoto*` / `GpsCorrection*` names. Implementers of B5B10 should treat this layout as the spec for the rename, not the current ground truth. After B6 ships the layout matches the disk.
- [ ] **Layer-organized vs component-organized layout**: this codebase organizes files by horizontal layer (`Controllers/`, `Services/`, `DTOs/`, `Enums/`) not by feature component. The Owns globs are composed from multiple directories, which is unusual and means a single directory rename would touch multiple components' Owns. **Question for user**: keep as-is (matches the rest of the suite's .NET services? — needs verification against `annotations` and `admin` layout) or should a future refactor move toward feature-folders?
- [ ] **`Entities/` and `Infrastructure/` at the root are EMPTY** — `Entities/` is shadowed by `Database/Entities/`. With GPS-Denied moving out of this repo, the historical "earmarked for orthophoto path resolver" reason is gone. **Question for user**: delete both empty dirs as part of B5?
- [ ] **No `src/` directory** — the .NET project sits at the repo root. `coderule.mdc` says "For existing projects, follow the established directory structure." → established structure is "no `src/`"; this layout DOC respects that. Confirm we should NOT move it.
- [ ] **Policy name is string-typed** — feature controllers reference `"FL"` as a raw string. A typo would silently turn into a permanent 403. **Question for user**: should `05_identity` expose a typed `PolicyNames.FL` constant? Cheap improvement; not a blocker for documentation.
- [ ] **Cross-component DTO clusters**: `DTOs/` directory mixes payloads from `01`, `02`, and `06`. Owns globs are file-by-file. Acceptable for now; a future refactor could split into per-component subfolders (e.g. `DTOs/Vehicle/`, `DTOs/Mission/`, `DTOs/Common/`).
- [ ] **No `tests/` project exists** (per `../../suite/_docs/_process_leftovers/2026-04-22_ci-unit-test-lane-missing-projects.md`). Test-spec / test-implement steps in autodev will need to create a sibling `Azaion.Missions.Tests` csproj — at `tests/Azaion.Missions.Tests/` (suite-canonical) or somewhere else? Confirm.
- [ ] **Cycles spanning components**: none detected. The `Vehicle → Mission → Waypoint` association graph is intra-component (entirely inside `04_persistence`).
+71
View File
@@ -0,0 +1,71 @@
# Module: `Azaion.Missions.Auth`
**Files (1)**: `Auth/JwtExtensions.cs`
> **NOTE (forward-looking)**: this module's source paths and namespace will become `Azaion.Missions.*` after the `flights -> missions` rename ticket lands (Jira AZ-EPIC, child B5 / B7). Today the file still says `Azaion.Flights`. The behavior described below already matches the post-rename intent: only the `FL` policy remains, the `GPS` policy is removed (per B7).
## Purpose
Single static extension (`AddJwtAuth`) that registers JWT bearer authentication and the named authorization policy `FL` used by controllers.
## Public Interface
```csharp
public static class JwtExtensions {
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret);
}
```
## Internal Logic
1. `AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...)` configures token validation:
- `IssuerSigningKey = SymmetricSecurityKey(UTF-8(jwtSecret))` -> **HS256 / shared-secret** validation.
- `ValidateIssuer = false`, `ValidateAudience = false` -- `iss` / `aud` claims are NOT checked. Tokens with any issuer/audience are accepted as long as the signature and lifetime are valid. (CMMC L2 finding -- see `../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3 and the suite-level remediation tracked under AZ-487/AZ-494.)
- `ValidateIssuerSigningKey = true`, `ValidateLifetime = true`.
- `ClockSkew = 1 minute` (tighter than the .NET default of 5 minutes).
2. `AddAuthorizationBuilder()` registers one policy:
- `"FL"` -> requires the JWT to contain a `permissions` claim with value `"FL"`.
`RequireClaim("permissions", "FL")` matches on a claim named `"permissions"` whose value equals `"FL"`. With multi-permission tokens, the token typically has multiple `permissions` claims, one per permission.
## Suite-wide JWT pattern
This service consumes JWTs minted by the remote `admin` service against the central user PostgreSQL (per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/10_auth.md`). Every `.NET` service in the suite -- `admin`, `annotations`, `missions` (this one), `satellite-provider` -- shares one HMAC secret (`JWT_SECRET`) and validates tokens locally with no network round-trip. The user logs in once at the UI; the resulting bearer token is reusable across every service.
## Dependencies
- `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`)
- `Microsoft.IdentityModel.Tokens` (transitive -- `SymmetricSecurityKey`, `TokenValidationParameters`)
- `System.Text` (for `Encoding.UTF8`)
No internal dependencies.
## Consumers
- `Program.cs` -- `builder.Services.AddJwtAuth(jwtSecret)` is called once at startup.
- Controllers reference the policy indirectly via `[Authorize(Policy = "FL")]` (used on both `VehiclesController` and `MissionsController`).
## Configuration
Reads no configuration directly -- `jwtSecret` is passed by the caller. `Program.cs` resolves it from `IConfiguration["JWT_SECRET"]` -> `Environment.GetEnvironmentVariable("JWT_SECRET")` -> fallback `"development-secret-key-min-32-chars!!"`.
## External Integrations
None at the network level -- token validation is purely cryptographic against the shared secret.
## Security
- **Algorithm**: HMAC-SHA256 via `SymmetricSecurityKey`. The token issuer (`admin`) must use the SAME secret to sign -- there is no public-key flow.
- **No issuer/audience validation** -- any service that knows the shared secret can mint tokens that this API will accept. This trust model assumes the secret is private to the suite; it is not safe for multi-tenant or third-party token issuance.
- **Clock skew tolerance**: 1 minute (tight, intentional).
- The fallback secret in `Program.cs` is hardcoded. It MUST be overridden in production.
## Tests
None present.
## Notes / Smells
1. **Single permission (`FL`) gates the whole API.** All routes carry `[Authorize(Policy = "FL")]`. There is no operator-vs-admin distinction at this layer; granular permissions are governed by the role->permission matrix in `../../suite/_docs/00_roles_permissions.md`.
2. **No authentication scheme name override** -- uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent.
3. **No claim type for "user id"** -- only the `permissions` claim is consumed; whatever subject identity the issuer puts in the token is ignored at the policy layer. Audit logs / business rules that need a user identifier currently have no per-call user binding (services don't take `HttpContext.User`). When `02_mission_planning` adds attribution to actions like waypoint-set / mission-rename, this becomes a blocker.
@@ -0,0 +1,71 @@
# Module: `Azaion.Missions.Controllers.MissionsController`
**File**: `Controllers/MissionsController.cs`
> **NOTE (forward-looking)**: post-rename + post-route-change. Today's source is `Controllers/FlightsController.cs` mounted at `[Route("flights")]` with nested waypoint routes under `/flights/{id}/waypoints/...`. Renames + route changes tracked under Jira AZ-EPIC children B6 + B8.
## Purpose
REST surface for the `missions` resource AND its nested `waypoints` sub-resource. Wraps `MissionService` for the parent and `WaypointService` for the nested routes.
## Public Interface
### Missions
| HTTP | Route | Action | Body / Query | Returns |
|------|-------|--------|--------------|---------|
| `POST` | `/missions` | `Create` | `CreateMissionRequest` | `201` + `Location: /missions/{id}`, body: `Mission` |
| `PUT` | `/missions/{id:guid}` | `Update` | `UpdateMissionRequest` | `200`, body: `Mission` |
| `GET` | `/missions/{id:guid}` | `Get` | -- | `200`, body: `Mission` |
| `GET` | `/missions` | `GetAll` | `GetMissionsQuery` (`Name?`, `FromDate?`, `ToDate?`, `Page=1`, `PageSize=20`) | `200`, body: `PaginatedResponse<Mission>` |
| `DELETE` | `/missions/{id:guid}` | `Delete` | -- | `204` |
### Waypoints (nested under a mission)
| HTTP | Route | Action | Body | Returns |
|------|-------|--------|------|---------|
| `POST` | `/missions/{id:guid}/waypoints` | `CreateWaypoint` | `CreateWaypointRequest` | `201` + `Location: /missions/{id}/waypoints/{wpId}`, body: `Waypoint` |
| `PUT` | `/missions/{id:guid}/waypoints/{waypointId:guid}` | `UpdateWaypoint` | `UpdateWaypointRequest` | `200`, body: `Waypoint` |
| `DELETE` | `/missions/{id:guid}/waypoints/{waypointId:guid}` | `DeleteWaypoint` | -- | `204` |
| `GET` | `/missions/{id:guid}/waypoints` | `GetWaypoints` | -- | `200`, body: `List<Waypoint>` (no pagination) |
Class-level decorators: `[ApiController]`, `[Route("missions")]`, `[Authorize(Policy = "FL")]`.
## Internal Logic
Same pattern as `VehiclesController`: each action awaits the appropriate service method and wraps in `Created` / `Ok` / `NoContent`.
## Dependencies
- `MissionService`, `WaypointService` (constructor-injected; primary constructor)
- `Azaion.Missions.DTOs`
## Consumers
- HTTP clients.
- Cross-service callers in the suite: `autopilot` reads `GET /missions/{id}` + `GET /missions/{id}/waypoints` to drive UAV behavior; `ui` paginates `/missions`. Both will need to be updated to the new prefix as part of B11 (consumer updates).
## Data Models
Returns `Mission` (which has `Vehicle?` + `List<Waypoint>` association properties) and `Waypoint` (with `Mission?` association property) directly. Whether associations are populated on the wire depends on LinqToDB query behavior -- by default, `FirstOrDefaultAsync(predicate)` does NOT eager-load associations, so `mission.Vehicle` and `mission.Waypoints` will serialize as `null`/empty in JSON. Verify in Step 4 against actual API responses if available.
## Configuration / External Integrations
None directly.
## Security
- All routes behind `[Authorize(Policy = "FL")]`.
- Composite-key handling in waypoint operations means a stolen waypoint id alone is not enough -- the attacker must also know the parent mission id.
## Tests
None present.
## Notes / Smells
1. **Inconsistent listing pagination** -- `GET /missions` paginates, `GET /missions/{id}/waypoints` and `GET /vehicles` do not. Verification flag.
2. **Nested resource modeling** -- waypoints are exposed only as a sub-resource of a mission, never as `/waypoints/{id}`. Consistent with the data model (`mission_id` is `NOT NULL`).
3. **`Update` of a mission allows changing `vehicle_id`** but the controller doesn't reflect any business constraint (e.g., immutable after start). All such constraints would need to live in the service.
4. **No bulk endpoints** -- no batch create / batch delete for waypoints despite the natural use case ("upload a route plan").
5. **Entity body PascalCase wire shape** -- the whole API has no `JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase`, so `Mission`, `Waypoint`, and `PaginatedResponse<Mission>` responses serialize PascalCase property names. Spec says camelCase (per `../../suite/_docs/00_top_level_architecture.md`). Note: the global error envelope produced by `ErrorHandlingMiddleware` is already camelCase (anonymous object literal) -- this divergence applies only to entity / DTO bodies (see `middleware.md` Notes #1#2 for the distinction). Carry to verification log.
@@ -0,0 +1,71 @@
# Module: `Azaion.Missions.Controllers.VehiclesController`
**File**: `Controllers/VehiclesController.cs`
> **NOTE (forward-looking)**: post-rename. Today's source is `Controllers/AircraftsController.cs` mounted at `[Route("aircrafts")]`. Renames + route changes tracked under Jira AZ-EPIC children B6 (domain rename) and B8 (HTTP route prefix rename).
## Purpose
REST surface for the `vehicles` resource. Thin HTTP wrapper over `VehicleService` -- every action delegates 1:1 with no extra logic.
## Public Interface
| HTTP | Route | Action | Body / Query | Returns |
|------|-------|--------|--------------|---------|
| `POST` | `/vehicles` | `Create` | body: `CreateVehicleRequest` | `201 Created` + `Location: /vehicles/{id}`, body: `Vehicle` |
| `PUT` | `/vehicles/{id:guid}` | `Update` | body: `UpdateVehicleRequest` | `200 OK`, body: `Vehicle` |
| `DELETE` | `/vehicles/{id:guid}` | `Delete` | -- | `204 No Content` |
| `GET` | `/vehicles` | `GetAll` | query: `GetVehiclesQuery` (`Name?`, `IsDefault?`) | `200 OK`, body: `List<Vehicle>` (no pagination) |
| `GET` | `/vehicles/{id:guid}` | `Get` | -- | `200 OK`, body: `Vehicle` |
| `PATCH` | `/vehicles/{id:guid}/default` | `SetDefault` | body: `SetDefaultRequest` | `204 No Content` |
Class-level decorators:
- `[ApiController]` -- automatic 400 for model-binding/validation errors (note: there are no validation attributes, so this rarely triggers).
- `[Route("vehicles")]` -- base path.
- `[Authorize(Policy = "FL")]` -- every action requires the `FL` JWT permission claim.
## Internal Logic
Each action is a one-liner: await the service, return `Created/Ok/NoContent`.
`Create` returns the persisted entity (including server-generated `Id`).
`Update`, `Get`, `GetAll` return entities directly (no DTO mapping -- the entity IS the response shape).
## Dependencies
- `Azaion.Missions.Services.VehicleService` (constructor-injected)
- `Azaion.Missions.DTOs` (request/query types)
- ASP.NET Core MVC: `ControllerBase`, `[ApiController]`, `[Route]`, `[Authorize]`, route-binding attributes.
## Consumers
- HTTP clients (frontend, other services, Swagger UI, integration tests).
## Data Models
Returns the `Vehicle` entity directly on the wire -- fields are serialized as PascalCase properties (`System.Text.Json` default; no camelCase configuration is set in `Program.cs`).
## Configuration
None directly.
## External Integrations
None directly -- service does the DB work.
## Security
- Every action gated by `Policy = "FL"` (JWT claim `permissions = FL`).
- No anti-CSRF (REST API, JWT auth -- typical).
- No rate limiting at this layer.
## Tests
None present.
## Notes / Smells
1. **Entity leakage on the wire** -- controllers return `Vehicle` entities. For `Vehicle` there are no associations, so no over-fetch happens. (Compare to `MissionsController` which returns `Mission` -- that DOES have `Vehicle` and `List<Waypoint>` associations; lazy-load behavior depends on LinqToDB defaults.)
2. **No HEAD / OPTIONS** explicit handlers -- relies on framework defaults.
3. **`PATCH` for SetDefault** is semantically a partial update -- appropriate. Body is a tiny `{ IsDefault: bool }` dedicated DTO.
4. **`Created` body includes the entity** -- consistent with REST best practice (avoids a follow-up GET).
+96
View File
@@ -0,0 +1,96 @@
# Module: `Azaion.Missions.Database` (connection + migrator)
**Files (2)**: `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal state. Today's source still has `Azaion.Flights.Database` namespace, exposes `Orthophotos` / `GpsCorrections` `ITable`s, and migrates 6 tables. After Jira AZ-EPIC children B5 (namespace), B7 (GPS-Denied removal), and B9 (DB migration) land, the shape described here is what stands.
## Purpose
- `AppDataConnection` -- single LinqToDB `DataConnection` that exposes one `ITable<T>` property per persisted entity. Acts as the unit-of-work + query root for all services.
- `DatabaseMigrator` -- startup-time idempotent schema bootstrap. Issues a single multi-statement SQL block of `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` against the connection.
## Public Interface
### AppDataConnection
```csharp
public class AppDataConnection(DataOptions options) : DataConnection(options) {
public ITable<Vehicle> Vehicles => GetTable<Vehicle>();
public ITable<Mission> Missions => GetTable<Mission>();
public ITable<Waypoint> Waypoints => GetTable<Waypoint>();
public ITable<MapObject> MapObjects => GetTable<MapObject>();
public ITable<Media> Media => GetTable<Media>(); // schema owned by `annotations`
public ITable<Annotation> Annotations => GetTable<Annotation>(); // schema owned by `annotations`
public ITable<Detection> Detections => GetTable<Detection>(); // schema owned by detection pipeline
}
```
### DatabaseMigrator
```csharp
public static class DatabaseMigrator {
public static void Migrate(AppDataConnection db); // synchronous; runs once at startup
}
```
## Internal Logic
### AppDataConnection
- Inherits LinqToDB's `DataConnection`. Constructor parameter `DataOptions` is built in `Program.cs` via `new DataOptions().UsePostgreSQL(connectionString)`.
- Each `ITable<T>` property is computed via `GetTable<T>()` on every access -- cheap query-root handles, not cached state.
- Lifetime is **scoped** (registered via `builder.Services.AddScoped`), so each HTTP request gets its own `DataConnection` (and underlying Npgsql connection from the pool).
### DatabaseMigrator
- `Migrate(db)` calls `db.Execute(Sql)` where `Sql` is a single string literal containing:
- 4 `CREATE TABLE IF NOT EXISTS` statements: `vehicles`, `missions`, `waypoints`, `map_objects`.
- 3 `CREATE INDEX IF NOT EXISTS` statements on the foreign-key columns: `ix_missions_vehicle_id`, `ix_waypoints_mission_id`, `ix_map_objects_mission_id`.
- Foreign-key constraints declared inline via `REFERENCES`:
- `missions.vehicle_id REFERENCES vehicles(id)`
- `waypoints.mission_id REFERENCES missions(id)`
- `map_objects.mission_id REFERENCES missions(id)`
- Defaults: enums default to `0`, decimals to `0`, booleans to `FALSE`, timestamps to `NOW()`.
- **Tables intentionally NOT in this migrator**: `media`, `annotations`, `detection`. These are exposed by `AppDataConnection` and consumed by services (delete cascades), but their schema is owned by other suite components (`annotations` migrates `media` + `annotations`; the detection pipeline owns `detection`). All edge-tier services share one local PostgreSQL on the device, so `missions` can read/delete from those tables without owning their DDL.
- **Tables removed from this migrator (per Jira B7 + B9)**: `orthophotos`, `gps_corrections`. These are now owned by the separate `gps-denied` service (per `../../suite/_docs/11_gps_denied.md`). Migration B9 includes a one-shot `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` for fielded edge devices that previously ran the legacy schema.
## Dependencies
- `LinqToDB`, `LinqToDB.Data`
- `Azaion.Missions.Database.Entities` (all 7 entity types)
No upward dependencies.
## Consumers
- `Program.cs` -- registers `AppDataConnection` as scoped, then resolves it once via `app.Services.CreateScope()` to call `DatabaseMigrator.Migrate(db)` before request handling starts.
- `Services.VehicleService`, `Services.MissionService`, `Services.WaypointService` -- all take `AppDataConnection` via primary-constructor injection.
## Data Models
See `modules/entities.md` for column-level shape; see the SQL block in `DatabaseMigrator.Sql` for the authoritative DDL of the 4 owned tables.
## Configuration
`AppDataConnection` itself reads no env vars; the connection string is supplied by the DI registration in `Program.cs`.
## External Integrations
- PostgreSQL via Npgsql `10.0.2`.
## Security
- No SQL injection surface -- all services use LINQ expression trees (parameterized).
- The startup migrator runs DDL using whatever permissions the connection has. Production deployments where the app's user lacks `CREATE TABLE` would fail at startup.
## Tests
None present.
## Notes / Smells
1. **Migrator is intentionally scoped to this service's owned tables** (4 mission-attached tables). The 3 cross-service tables (`media`, `annotations`, `detection`) are migrated by their owning services into the same shared local PostgreSQL. Confirmed against `../../suite/_docs/00_top_level_architecture.md` (Database Topology) and `../../suite/_docs/01_annotations.md`.
2. **No schema versioning** -- `IF NOT EXISTS` is forward-only, additive only. Column drops, type changes, or constraint changes require either manual SQL or a real migration tool. The B9 `DROP TABLE` block is a one-time exception for the GPS-Denied removal.
3. **Synchronous DDL at startup blocks the host until completion**. For an empty DB this is microseconds; for a contended DB it's negligible. Acceptable for a small service.
4. **No transaction wrapping** -- the multi-statement `Execute` runs in PostgreSQL's implicit autocommit per statement (LinqToDB doesn't open a transaction unless you ask). All `IF NOT EXISTS` statements are individually idempotent, so partial failure leaves a partially-created schema; next startup completes it.
5. **No `LOWER(...)` indexes** for case-insensitive name searches in `vehicles` / `missions`. Likely fine for current scale.
+158
View File
@@ -0,0 +1,158 @@
# Module: `Azaion.Missions.DTOs`
**Files (15)** -- grouped by concern:
| Group | Files |
|-------|-------|
| Shared value objects | `GeoPoint.cs` |
| Shared response wrappers | `PaginatedResponse.cs`, `ErrorResponse.cs` |
| Vehicle requests/queries | `CreateVehicleRequest.cs`, `UpdateVehicleRequest.cs`, `GetVehiclesQuery.cs`, `SetDefaultRequest.cs` |
| Mission requests/queries | `CreateMissionRequest.cs`, `UpdateMissionRequest.cs`, `GetMissionsQuery.cs` |
| Waypoint requests | `CreateWaypointRequest.cs`, `UpdateWaypointRequest.cs` |
> **NOTE (forward-looking)**: post-rename file names. Today's source has `CreateAircraftRequest.cs` / `CreateFlightRequest.cs` / etc. Renames tracked under Jira AZ-EPIC child B6.
## Purpose
HTTP request/response/query payloads for the controller layer. Plain POCOs with public mutable properties -- no validation attributes, no record types.
## Public Interface
### Shared
```csharp
public class GeoPoint {
public decimal? Lat { get; set; }
public decimal? Lon { get; set; }
public string? Mgrs { get; set; } // Military Grid Reference System
}
public class PaginatedResponse<T> {
public List<T> Items { get; set; } = [];
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
}
public class ErrorResponse {
public int StatusCode { get; set; }
public string Message { get; set; } = string.Empty;
public List<string>? Errors { get; set; }
}
```
### Vehicle
```csharp
public class CreateVehicleRequest {
public VehicleType Type { get; set; } // Plane | Copter | UGV | GuidedMissile
public string Model { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public FuelType FuelType { get; set; }
public decimal BatteryCapacity { get; set; }
public decimal EngineConsumption { get; set; }
public decimal EngineConsumptionIdle { get; set; }
public bool IsDefault { get; set; }
}
public class UpdateVehicleRequest {
// All properties nullable -- partial-update semantics, applied per non-null field
public VehicleType? Type;
public string? Model;
public string? Name;
public FuelType? FuelType;
public decimal? BatteryCapacity;
public decimal? EngineConsumption;
public decimal? EngineConsumptionIdle;
public bool? IsDefault;
}
public class GetVehiclesQuery {
public string? Name { get; set; }
public bool? IsDefault { get; set; }
}
public class SetDefaultRequest {
public bool IsDefault { get; set; }
}
```
### Mission
```csharp
public class CreateMissionRequest {
public Guid VehicleId { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime? CreatedDate { get; set; } // Defaults to UtcNow if null
}
public class UpdateMissionRequest {
public string? Name { get; set; }
public Guid? VehicleId { get; set; }
}
public class GetMissionsQuery {
public string? Name { get; set; }
public DateTime? FromDate { get; set; }
public DateTime? ToDate { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
}
```
### Waypoint
```csharp
public class CreateWaypointRequest {
public GeoPoint? GeoPoint { get; set; }
public WaypointSource WaypointSource { get; set; }
public WaypointObjective WaypointObjective { get; set; }
public int OrderNum { get; set; }
public decimal Height { get; set; }
}
public class UpdateWaypointRequest { // identical shape to Create
public GeoPoint? GeoPoint { get; set; }
public WaypointSource WaypointSource { get; set; }
public WaypointObjective WaypointObjective { get; set; }
public int OrderNum { get; set; }
public decimal Height { get; set; }
}
```
## Internal Logic
Pure data containers. No methods, no constructors beyond the implicit default.
## Dependencies
- `Azaion.Missions.Enums` -- for typed enum properties
## Consumers
- `Services.VehicleService`, `Services.MissionService`, `Services.WaypointService` -- request DTOs as method parameters; `PaginatedResponse<Mission>` returned from `MissionService.GetMissions`.
- `Controllers.VehiclesController`, `Controllers.MissionsController` -- `[FromBody]` / `[FromQuery]` binding.
## Data Models
Mirror the corresponding entity columns minus identity/timestamps (those are server-assigned).
## Configuration / External Integrations / Security
None directly -- all binding is provided by ASP.NET Core model binding with no custom validators or `[Required]` / `[Range]` attributes.
## Tests
None present.
## Notes / Smells
- **No validation**: nothing prevents `CreateVehicleRequest.Name = ""`, negative `BatteryCapacity`, `OrderNum < 0`, or out-of-range enum values (binding on int will accept any int and persist it). Carry to AC / restrictions in Step 6.
- **`UpdateWaypointRequest` is structurally identical to `CreateWaypointRequest`** but uses non-nullable enum/numeric fields, meaning every `PUT` overwrites all fields -- no partial-update semantics for waypoints (unlike vehicle, which uses `?` everywhere). Inconsistency between resources.
- **`PaginatedResponse<T>`** is only used for `Mission` listing; `GetVehicles` returns a plain `List<Vehicle>` (no pagination, no total count), even though `GetVehiclesQuery` exists. Inconsistent listing contract -- matches spec (vehicles are a small dataset).
- **`ErrorResponse`** is defined but not used by `ErrorHandlingMiddleware` (which writes an anonymous object literal). Dead code candidate.
- `GeoPoint` allows all-null (Lat, Lon, Mgrs all optional). No invariant ensures at least one location representation is populated.
- **Spec divergence (Geopoint)**: `../../suite/_docs/02_missions.md` and `../../suite/_docs/00_database_schema.md` define `Waypoints.GPS` as a single `string GPS` field with auto-conversion (`Lat <-> MGRS`). Code stores three separate columns (`lat NUMERIC`, `lon NUMERIC`, `mgrs TEXT`) and exposes them as three flat properties without conversion logic. Carry to verification log.
- **Spec divergence (ErrorResponse)**: spec `errors` is `object?` keyed by field name (per-field validation arrays). Code defines `List<string>? Errors` and the type is unused on the wire (middleware emits an anonymous object instead). Carry to verification log.
- **Spec divergence (PaginatedResponse case-style)**: suite-wide standard is camelCase (`items`, `totalCount`, `page`, `pageSize`). `PaginatedResponse<T>` declares PascalCase properties and System.Text.Json's defaults preserve them, so on-the-wire output is `{"Items":..., "TotalCount":..., ...}`. No `JsonNamingPolicy.CamelCase` is configured.
- **Spec partial conformance (ErrorResponse / error envelope)**: the static `ErrorResponse` DTO is **unused on the wire** -- `Middleware.ErrorHandlingMiddleware` writes an anonymous object literal instead. That anonymous object happens to use lowercase property names (`statusCode`, `message`), which `System.Text.Json` preserves, so the live error envelope IS camelCase (matching spec on case) but still missing the spec's `errors: object?` field. Were `ErrorResponse` ever used directly it would emit PascalCase (`StatusCode`, `Message`, `Errors`) and additionally have the wrong `Errors` shape (`List<string>?` vs spec's `object?`). Two carry-forward concerns: add the `errors` field to the live envelope, and either remove the dead `ErrorResponse` DTO or fix it to match spec.
+154
View File
@@ -0,0 +1,154 @@
# Module: `Azaion.Missions.Database.Entities`
**Files (7)**: `Vehicle.cs`, `Mission.cs`, `Waypoint.cs`, `MapObject.cs`, `Media.cs`, `Annotation.cs`, `Detection.cs`
> **NOTE (forward-looking)**: this doc reflects the post-rename state. Today's source still has `Aircraft.cs`, `Flight.cs`, `Orthophoto.cs`, `GpsCorrection.cs`. The renames + GPS-Denied removal are tracked under Jira AZ-EPIC children B6 (domain rename) and B7 (GPS-Denied removal).
## Purpose
LinqToDB row-mapping classes. Each entity uses `[Table("snake_case_name")]` + `[Column("snake_case")]` + `[PrimaryKey]` to map to a PostgreSQL table. Two entities (`Mission`, `Waypoint`) include `[Association]`-based navigation; the rest are flat row maps.
## Public Interface
### Vehicle (table `vehicles`)
```csharp
[Table("vehicles")]
public class Vehicle {
[PrimaryKey, Column("id")] public Guid Id;
[Column("type")] public VehicleType Type; // Plane | Copter | UGV | GuidedMissile
[Column("model")] public string Model = "";
[Column("name")] public string Name = "";
[Column("fuel_type")] public FuelType FuelType;
[Column("battery_capacity")] public decimal BatteryCapacity;
[Column("engine_consumption")] public decimal EngineConsumption;
[Column("engine_consumption_idle")] public decimal EngineConsumptionIdle;
[Column("is_default")] public bool IsDefault;
}
```
### Mission (table `missions`)
```csharp
[Table("missions")]
public class Mission {
[PrimaryKey, Column("id")] public Guid Id;
[Column("created_date")] public DateTime CreatedDate;
[Column("name")] public string Name = "";
[Column("vehicle_id")] public Guid VehicleId;
[Association(ThisKey=VehicleId, OtherKey=Vehicle.Id)] public Vehicle? Vehicle;
[Association(ThisKey=Id, OtherKey=Waypoint.MissionId)] public List<Waypoint> Waypoints = [];
}
```
### Waypoint (table `waypoints`)
```csharp
[Table("waypoints")]
public class Waypoint {
[PrimaryKey, Column("id")] public Guid Id;
[Column("mission_id")] public Guid MissionId;
[Column("lat")] public decimal? Lat;
[Column("lon")] public decimal? Lon;
[Column("mgrs")] public string? Mgrs;
[Column("waypoint_source")] public WaypointSource WaypointSource;
[Column("waypoint_objective")] public WaypointObjective WaypointObjective;
[Column("order_num")] public int OrderNum;
[Column("height")] public decimal Height;
[Association(ThisKey=MissionId, OtherKey=Mission.Id)] public Mission? Mission;
}
```
### MapObject (table `map_objects`)
```csharp
[Table("map_objects")]
public class MapObject {
[PrimaryKey, Column("id")] public Guid Id;
[Column("mission_id")] public Guid MissionId;
[Column("h3_index")] public string H3Index = ""; // Uber H3 hex grid
[Column("mgrs")] public string Mgrs = "";
[Column("lat")] public decimal? Lat;
[Column("lon")] public decimal? Lon;
[Column("class_num")] public int ClassNum;
[Column("label")] public string Label = "";
[Column("size_width_m")] public decimal SizeWidthM;
[Column("size_length_m")] public decimal SizeLengthM;
[Column("confidence")] public decimal Confidence;
[Column("object_status")] public ObjectStatus ObjectStatus;
[Column("first_seen_at")] public DateTime FirstSeenAt;
[Column("last_seen_at")] public DateTime LastSeenAt;
}
```
### Media / Annotation / Detection (cross-service stubs -- see "Notes / Smells")
```csharp
[Table("media")]
public class Media {
[PrimaryKey, Column("id")] public string Id = ""; // TEXT primary key
[Column("waypoint_id")] public Guid? WaypointId;
}
[Table("annotations")]
public class Annotation {
[PrimaryKey, Column("id")] public string Id = ""; // TEXT
[Column("media_id")] public string MediaId = ""; // TEXT FK to media.id
}
[Table("detection")] // SINGULAR table name -- diverges from every other entity (owned by detection pipeline)
public class Detection {
[PrimaryKey, Column("id")] public Guid Id;
[Column("annotation_id")] public string AnnotationId = "";
}
```
## Internal Logic
Pure POCOs. The only behavior comes from LinqToDB attribute mapping (`[Table]`, `[Column]`, `[PrimaryKey]`, `[Association]`).
## Dependencies
- `LinqToDB.Mapping` (NuGet)
- `Azaion.Missions.Enums` (for `Vehicle`, `Waypoint`, `MapObject`)
## Consumers
- `Database.AppDataConnection` -- exposes `ITable<T>` for each entity.
- `Database.DatabaseMigrator` -- implicitly (defines DDL for the same names; does NOT reference entity types).
- `Services.VehicleService`, `Services.MissionService`, `Services.WaypointService` -- entity types are returned/constructed.
## Data Model Highlights
```
vehicles --< missions --< waypoints --? media --< annotations --< detection
\--< map_objects
```
- `Mission` is the central aggregate root: most domain rows hang off `mission_id`.
- `Waypoint` is a sub-aggregate of `Mission` and is the join point for `Media`.
- `MapObject` is detection output (class_num + confidence + spatial index) tied to a mission, NOT to a specific waypoint. **Schema is owned by this service, but rows are written by `autopilot`** (per `../../suite/_docs/06_autopilot_design.md`).
## Cross-service stubs
`Media`, `Annotation`, `Detection` are intentional read-only stubs -- only `Id` and one foreign key each. They're queried/deleted by `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` but never written by this service. Schema-wise they are owned by other suite components (`annotations` for `media` + `annotations`, the detection pipeline for `detection`), per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/01_annotations.md`. The shared edge-PostgreSQL pattern means each service migrates its own tables but all services see the full schema.
These three are deliberately NOT in `DatabaseMigrator.Sql` -- their schema is created by the owning services on the same shared local PostgreSQL.
## Configuration / External Integrations / Security
None directly. Persistence is delegated to LinqToDB + Npgsql.
## Tests
None present.
## Notes / Smells
1. **`Detection` table singular** while `vehicles`, `missions`, `waypoints`, `map_objects`, `media`, `annotations` are plural. Owned by another service -- naming is THEIR call to make consistent.
2. **Mixed PK types**: `Vehicle`, `Mission`, `Waypoint`, `MapObject`, `Detection` use `Guid`; `Media`, `Annotation` use `string` (TEXT, XxHash64-based per `../../suite/_docs/00_database_schema.md`).
3. **No domain methods** -- all business rules (e.g., the "default vehicle" exclusivity in `VehicleService`) live in services, not in the entities. Consistent and intentional for a thin-data-model approach.
4. **`Media.WaypointId` is nullable** while every other foreign key here is not. Suggests `Media` can attach to a non-waypoint context (e.g., mission-level media); enforcement is on `annotations`'s side.
5. **Geopoint divergence (carry to verification log)**: spec stores `Waypoints.GPS` as a single `string GPS` field with `Lat <-> MGRS` auto-conversion (per `../../suite/_docs/02_missions.md` and `../../suite/_docs/00_database_schema.md`). Code splits it into 3 separate columns. Resolution lives outside the GPS-Denied removal scope -- carry forward.
+55
View File
@@ -0,0 +1,55 @@
# Module: `Azaion.Missions.Enums`
**Files (5)**: `Enums/VehicleType.cs`, `Enums/FuelType.cs`, `Enums/WaypointSource.cs`, `Enums/WaypointObjective.cs`, `Enums/ObjectStatus.cs`
> **NOTE (forward-looking)**: post-rename + post-multi-vehicle-support state. Today's source has `Enums/AircraftType.cs` with `Plane = 0`, `Copter = 1`. The expanded `VehicleType` is tracked under Jira AZ-EPIC child B6.
## Purpose
Domain enumerations used by entities and DTOs. All values are stored in PostgreSQL as `INTEGER NOT NULL DEFAULT 0` (per `DatabaseMigrator`).
## Public Interface
| Enum | Members (value) |
|------|-----------------|
| `VehicleType` | `Plane = 0`, `Copter = 1`, `UGV = 2`, `GuidedMissile = 3` |
| `FuelType` | `Electric = 0`, `Gasoline = 1`, `Diesel = 2` |
| `WaypointSource` | `Auto = 0`, `Manual = 1` |
| `WaypointObjective` | `Surveillance = 0`, `Strike = 1`, `Recon = 2` |
| `ObjectStatus` | `New = 0`, `Moved = 1`, `Removed = 2` |
## Internal Logic
None -- pure value lists. No `[Flags]`, no custom backing storage.
## Dependencies
None (no `using` of internal namespaces).
## Consumers
- `Database.Entities.Vehicle` -- `VehicleType`, `FuelType`
- `Database.Entities.Waypoint` -- `WaypointSource`, `WaypointObjective`
- `Database.Entities.MapObject` -- `ObjectStatus`
- `DTOs.CreateVehicleRequest`, `DTOs.UpdateVehicleRequest` -- `VehicleType`, `FuelType`
- `DTOs.CreateWaypointRequest`, `DTOs.UpdateWaypointRequest` -- `WaypointSource`, `WaypointObjective`
- `Services.WaypointService` -- `using` for symbol resolution; runtime use is via DTO/entity properties
## Data Models
The enums themselves; persisted as `INTEGER` columns (see `Database/DatabaseMigrator.cs`).
## Configuration / External Integrations / Security
None.
## Tests
None present.
## Notes / Smells
- **`VehicleType` covers UAV (Plane / Copter), UGV, and GuidedMissile** -- aligns with the broader fleet described in `../../../hardware/_standalone/target_acquisition/target_acquisition.md`. The previous `AircraftType` name was too narrow and excluded ground / loitering-munition vehicles.
- **`FuelType` may not fit `GuidedMissile`** -- a single-use missile has no battery/engine-consumption profile in the same sense. Carry forward as Phase C decision (Jira open: do we add `None` / make `FuelType` nullable?).
- The `WaypointObjective` set (`Surveillance`, `Strike`, `Recon`) covers the mission-level intent for any vehicle class.
- Integer-based persistence is positional; reordering or removing a member would silently corrupt existing rows. **Adding new members at the end (UGV = 2, GuidedMissile = 3) is safe** -- existing rows keep `Type = 0/1`.
+63
View File
@@ -0,0 +1,63 @@
# Module: `Azaion.Missions.Middleware`
> **NOTE (forward-looking)**: post-rename namespace. Today's source still lives under `Azaion.Flights.Middleware`. Renames tracked under Jira AZ-EPIC child B5.
**Files (1)**: `Middleware/ErrorHandlingMiddleware.cs`
## Purpose
Global exception → JSON error response mapper. Wraps the rest of the request pipeline and converts a fixed set of exception types to specific HTTP status codes; everything else becomes a 500.
## Public Interface
```csharp
public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger) {
public Task Invoke(HttpContext context);
}
```
Standard ASP.NET Core middleware shape (primary-constructor variant).
## Internal Logic
```text
try { await next(context); }
catch (KeyNotFoundException ex) → 404 NotFound, body: { statusCode, message = ex.Message }
catch (ArgumentException ex) → 400 BadRequest, body: { statusCode, message = ex.Message }
catch (InvalidOperationException ex) → 409 Conflict, body: { statusCode, message = ex.Message }
catch (Exception ex) → 500 InternalServerError, body: { statusCode, message = "Internal server error" }
PLUS: logger.LogError(ex, "Unhandled exception");
```
Body is serialized via `JsonSerializer.Serialize(new { statusCode, message })` — an **anonymous object literal**, NOT the `DTOs.ErrorResponse` type. The anonymous-type property names are written lowercase-first in code (`statusCode`, `message`); `System.Text.Json` preserves them as-is when no `JsonNamingPolicy` is configured, so the wire shape is `{"statusCode":..., "message":"..."}`**camelCase by accidental match with the suite spec**. (The unused `DTOs.ErrorResponse` type, by contrast, declares PascalCase properties and would serialize PascalCase if it were ever used directly.)
`Content-Type` is set to `application/json`. Response body is written via `WriteAsync` (string).
## Dependencies
- `System.Net` (`HttpStatusCode`)
- `System.Text.Json` (`JsonSerializer`)
- ASP.NET Core middleware abstractions (transitive via `Microsoft.NET.Sdk.Web`)
- `Microsoft.Extensions.Logging.ILogger<T>` (transitive)
No internal dependencies.
## Consumers
- `Program.cs``app.UseMiddleware<ErrorHandlingMiddleware>();` is called BEFORE `UseCors`, `UseAuthentication`, `UseAuthorization`, `UseSwagger`, `UseSwaggerUI`, `MapControllers`.
## Configuration / External Integrations / Security
None.
## Tests
None present.
## Notes / Smells
1. **`DTOs.ErrorResponse` is unused here** — middleware writes an anonymous object instead. **Partial spec divergence**: `../../suite/_docs/00_top_level_architecture.md` § Error Response Format mandates `{ "statusCode", "message", "errors": object? }` (camelCase, `errors` is an *object* of per-field arrays). The middleware's anonymous-object output IS already camelCase on case (`{"statusCode":..., "message":"..."}`) but missing the `errors` field; the static `ErrorResponse` DTO has the wrong shape (`List<string>? Errors` instead of `object?`) and is dead code.
2. **Entity / DTO body case-style** is PascalCase across the rest of the API (controller responses serialize entities and `PaginatedResponse<T>` via `System.Text.Json` defaults with no naming policy override). The error envelope's accidental camelCase match documented in point 1 does NOT extend to those responses — see `architecture.md` ADR-002.
3. **`InvalidOperationException` → 409 Conflict** is a non-standard mapping. The .NET BCL throws this for many "wrong state at the moment" conditions; in this codebase it is used by `VehicleService.DeleteVehicle` to report "vehicle is referenced by missions" (a true 409). But any third-party library throwing `InvalidOperationException` for an unrelated reason would also surface as 409, masking the real cause.
4. **Generic 500 swallows the message** (good for security — no internal detail leaked) and logs the exception (good for diagnostics). No correlation ID is included in the response, so production support has to grep logs by timestamp.
5. **Order of catches** matters: `ArgumentException` is base of `ArgumentNullException` / `ArgumentOutOfRangeException`, so those also become 400 (correct). `InvalidOperationException` ahead of generic `Exception` is correct.
+101
View File
@@ -0,0 +1,101 @@
# Module: `Program` (composition root) + `GlobalUsings`
**Files (2)**: `Program.cs`, `GlobalUsings.cs`
> **NOTE (forward-looking)**: post-rename. Today's source has `Azaion.Flights` namespace and `dotnet Azaion.Flights.dll` entrypoint. Renames + DLL/image changes tracked under Jira AZ-EPIC children B5 (namespace), B7 (drop GPS policy), B10 (Dockerfile + docker image rename).
## Purpose
Top-level statements that build the ASP.NET Core web host: read environment, register DI services (DB connection, services, auth, CORS, MVC, Swagger), run the migrator once, then `app.Run()`.
`GlobalUsings.cs` adds three project-wide `global using` directives so individual files don't need to repeat them:
```csharp
global using LinqToDB;
global using LinqToDB.Async;
global using LinqToDB.Data;
```
## Public Interface
`Program.cs` is a top-level program -- it is not a class with a public surface. Its observable contract is the resulting HTTP server:
- Listens on the Kestrel-default URL (typically `http://0.0.0.0:8080` in container per Dockerfile `EXPOSE 8080`).
- Exposes routes mapped by `MapControllers` (see `controller_vehicles.md`, `controller_missions.md`) plus `GET /health`.
- Serves Swagger UI at the default `/swagger` route (not gated on environment).
## Internal Logic
```text
1. WebApplicationBuilder = WebApplication.CreateBuilder(args)
2. Resolve DATABASE_URL (Configuration -> Env -> fallback)
If it begins with "postgresql://" -> ConvertPostgresUrl() to Npgsql key=value form.
3. Resolve JWT_SECRET (Configuration -> Env -> fallback)
4. Register services (scoped where applicable):
- AppDataConnection <- scoped, built via new DataOptions().UsePostgreSQL(connectionString)
- MissionService, WaypointService, VehicleService <- scoped
- AddJwtAuth(jwtSecret) -> JWT bearer + "FL" policy
- AddCors with default policy = AllowAnyOrigin/Method/Header
- AddControllers, AddEndpointsApiExplorer, AddSwaggerGen
5. Build the WebApplication.
6. Open a temp scope, resolve AppDataConnection, call DatabaseMigrator.Migrate(db).
7. Configure pipeline (order matters):
a. UseMiddleware<ErrorHandlingMiddleware>
b. UseCors
c. UseAuthentication
d. UseAuthorization
e. UseSwagger, UseSwaggerUI
f. MapControllers
g. MapGet("/health", () => Results.Ok({status:"healthy"}))
8. app.Run()
ConvertPostgresUrl(url):
parses postgresql://user[:pass]@host[:port]/db into
"Host={host};Port={port};Database={db};Username={user};Password={pass}"
(defaults port to 5432; absent password becomes empty)
```
## Dependencies
- All internal namespaces: `Azaion.Missions.{Auth, Database, Middleware, Services}`.
- ASP.NET Core, LinqToDB, Npgsql, Swashbuckle, JWT bearer (NuGet).
## Consumers
- The container runtime (`ENTRYPOINT ["dotnet", "Azaion.Missions.dll"]` in `Dockerfile` after B10).
- `dotnet run` for local development.
## Configuration
| Env / Config Key | Required? | Default |
|------------------|-----------|---------|
| `DATABASE_URL` | No (has dev fallback) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` |
| `JWT_SECRET` | No (has dev fallback) | `development-secret-key-min-32-chars!!` |
| `AZAION_REVISION` | Set by Dockerfile from `CI_COMMIT_SHA` | `unknown` (build arg default) |
There is **no `appsettings.json`** in this repo (per discovery) -- config comes from env / process variables only. Suite-wide env conventions live in `../../suite/_docs/00_top_level_architecture.md` (Edge compose excerpt).
## External Integrations
- PostgreSQL (read/write) via Npgsql.
- Identity provider: the suite's `admin` service mints JWTs against the central user PostgreSQL; `JWT_SECRET` is the shared HMAC secret. Local validation only -- no network round-trip per request.
## Security
- **Hardcoded fallbacks** for both `DATABASE_URL` and `JWT_SECRET` are dev-only. Production deployments MUST override them; failure to do so silently runs with weak/known credentials.
- **CORS is permissive** in all environments (`AllowAnyOrigin/Method/Header`). Combined with JWT auth this is not catastrophic (browser will send the bearer token only if the front-end opts in), but exposes the API to opportunistic browser-based scraping.
- **Swagger is unconditionally enabled** -- both the JSON document and the UI are served regardless of environment. Anyone reaching the host can enumerate the API surface.
- **No HTTPS redirection** middleware (`UseHttpsRedirection`) -- TLS is assumed to terminate at an upstream reverse proxy.
- **`app.UseMiddleware<ErrorHandlingMiddleware>` runs before `UseAuthentication`/`UseAuthorization`** -- auth failures still emit the framework's stock 401/403 (which is fine), but any auth-stage exceptions ALSO run through the global handler (which converts `KeyNotFoundException` -> 404, etc.; auth pipeline doesn't typically throw those).
## Tests
None present.
## Notes / Smells
1. **`DATABASE_URL` URL parsing**: `ConvertPostgresUrl` is a small ad-hoc parser. Fine for typical cases but does not URL-decode the user/password. A password containing `@`, `:`, `/`, `%` would break parsing or be interpreted wrong. Carry to verification log.
2. **No `IsDevelopment()` checks** anywhere in `Program.cs`. Dev/prod behaviors (Swagger, fallback secrets) are not gated.
3. **`AddSwaggerGen()` with no JWT bearer security definition** -- Swagger UI's "Authorize" button won't appear; users must supply tokens via `curl -H "Authorization: Bearer ..."`. Not a bug, but a usability issue.
4. **`DatabaseMigrator.Migrate` is fire-and-forget** -- if it throws (DB down at startup), the host process crashes. Acceptable for container orchestration that restarts on failure.
5. **`GlobalUsings.cs` imports `LinqToDB.Async`** but most async LINQ extensions used by the project (`AnyAsync`, `FirstOrDefaultAsync`, `ToListAsync`, etc.) actually live in the `LinqToDB` namespace already. Harmless redundancy.
6. **Service lifetime**: `AppDataConnection` is **scoped** (per-HTTP-request) -- correct, because `DataConnection` holds a backing Npgsql connection that should not be shared across requests. The three domain services share this scope, so all DB calls within one request go through the same physical connection (good for correctness, no implicit transactions though).
@@ -0,0 +1,112 @@
# Module: `Azaion.Missions.Services.MissionService`
**File**: `Services/MissionService.cs`
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's source is `Services/FlightService.cs` and its cascade still reaches into `orthophotos` + `gps_corrections`. Renames tracked under Jira AZ-EPIC child B6; cascade shrink under B7.
## Purpose
CRUD over the `missions` aggregate plus a manual cascade-delete chain that walks `mission -> waypoints -> media -> annotations -> detection` and clears the `map_objects` side table.
## Public Interface
```csharp
public class MissionService(AppDataConnection db) {
Task<Mission> CreateMission(CreateMissionRequest request);
Task<Mission> UpdateMission(Guid id, UpdateMissionRequest request);
Task<Mission> GetMission(Guid id);
Task<PaginatedResponse<Mission>> GetMissions(GetMissionsQuery query);
Task DeleteMission(Guid id);
}
```
## Internal Logic
### `CreateMission`
1. Existence check: `db.Vehicles.AnyAsync(v => v.Id == request.VehicleId)`. On miss -> throw `ArgumentException` (mapped to 400 by middleware).
2. Build a fresh `Mission`:
- `Id = Guid.NewGuid()`
- `CreatedDate = request.CreatedDate ?? DateTime.UtcNow`
- `Name`, `VehicleId` copied from request.
3. `db.InsertAsync(mission)` and return.
### `UpdateMission`
1. Load by id (404 on miss).
2. If `request.Name != null` -> assign.
3. If `request.VehicleId.HasValue`:
- Existence check on the new `VehicleId` (400 on miss).
- Assign.
4. `db.UpdateAsync(mission)`.
### `GetMission`
- `FirstOrDefaultAsync(m => m.Id == id)` -> 404 on miss.
### `GetMissions` -- paginated
1. Build query on `db.Missions`.
2. Filters:
- `Name`: case-insensitive `Contains` (`LOWER(name) LIKE %lower%`).
- `FromDate`: `created_date >= FromDate`.
- `ToDate`: `created_date <= ToDate`.
3. Compute `totalCount` via a `CountAsync()` call on the filtered query.
4. Fetch the page: `OrderByDescending(CreatedDate).Skip((Page-1)*PageSize).Take(PageSize).ToListAsync()`.
5. Return `PaginatedResponse<Mission> { Items, TotalCount, Page, PageSize }`.
### `DeleteMission` -- manual cascade
1. Load the mission (404 on miss).
2. Top-level scrub:
- `map_objects WHERE mission_id = id` -> `DeleteAsync`.
3. Resolve dependents through waypoints:
- `waypointIds = SELECT id FROM waypoints WHERE mission_id = id`.
- If any waypoints exist:
- `mediaIds = SELECT id FROM media WHERE waypoint_id IN (waypointIds)`.
- If any media exist:
- `annotationIds = SELECT id FROM annotations WHERE media_id IN (mediaIds)`.
- If any annotations exist: `DELETE FROM detection WHERE annotation_id IN (annotationIds)`.
- `DELETE FROM annotations WHERE media_id IN (mediaIds)`.
- `DELETE FROM media WHERE waypoint_id IN (waypointIds)`.
4. `DELETE FROM waypoints WHERE mission_id = id`.
5. `DELETE FROM missions WHERE id = id`.
**No transaction wraps the cascade** -- partial failure leaves orphan rows.
## Dependencies
- `LinqToDB` (extension methods like `AnyAsync`, `DeleteAsync`)
- `AppDataConnection`, `Database.Entities.Mission` + 6 other entities used in the cascade
- `DTOs.{CreateMissionRequest, UpdateMissionRequest, GetMissionsQuery, PaginatedResponse}`
## Consumers
- `Controllers.MissionsController` -- wraps each method 1:1.
## Data Models
Reads `vehicles` (existence check). Writes `missions`. On delete, also writes `map_objects`, `media`, `annotations`, `detection`, `waypoints`.
## Cross-service contract
`media` / `annotations` / `detection` are owned schema-wise by other edge components (`annotations`, detection pipeline) on the shared local PostgreSQL. `MissionService.DeleteMission` is the canonical place that knows the full mission ownership graph: when a mission is deleted, its derived rows in those tables are scrubbed by THIS service. If those tables are missing on the deployment target, the cascade throws `relation does not exist`; in normal edge deployment they always exist (annotations migrates them at startup, same DB).
**Removed from cascade in B7**: `orthophotos`, `gps_corrections`. Those tables are no longer in this DB after B9; if a fielded device still has them they'll just be dropped (cascade no longer touches them).
## Configuration / External Integrations
None.
## Security
- Behind `[Authorize(Policy = "FL")]` (controller-level).
- Manual cascade respects FK direction; no SQL injection surface.
## Tests
None present.
## Notes / Smells
1. **Manual cascade vs. database `ON DELETE CASCADE`**: the DB declares plain `REFERENCES` (no `ON DELETE` clause), so the service does the work. Deliberate (likely to permit partial denial / business rules), but it means schema and code are coupled -- any new mission-owned table must be added BOTH to the schema and to this method.
2. **No transaction** around the cascade -- failure mid-cascade orphans rows. For PostgreSQL, wrapping in `db.BeginTransactionAsync()` is one extra line and would make the cascade atomic. Opportunistic improvement candidate.
3. **`GetMissions` does a count + page query** -- two round trips. Standard. The count is unfiltered by pagination but filtered by the same predicates.
4. **`Name` filter and date filter are independent ANDs** -- no support for OR or full-text search.
5. **No soft-delete** -- deletes are physical. Audit/recovery would need DB-level WAL or external backup.
6. **`UpdateMission` allows changing `VehicleId`** but does NOT validate that any in-mission state (waypoints, etc.) is compatible with the new vehicle. Probably fine if vehicle-specific behavior is downstream (e.g., autopilot recomputes route on next read).
@@ -0,0 +1,102 @@
# Module: `Azaion.Missions.Services.VehicleService`
**File**: `Services/VehicleService.cs`
> **NOTE (forward-looking)**: post-rename. Today's source is `Services/AircraftService.cs` operating over `Aircraft` entities. Renames tracked under Jira AZ-EPIC child B6.
## Purpose
Encapsulates vehicle-related domain operations: CRUD plus the "is_default" exclusivity rule. This is the only place the "exactly one vehicle is default" invariant is enforced.
## Public Interface
```csharp
public class VehicleService(AppDataConnection db) {
Task<Vehicle> CreateVehicle(CreateVehicleRequest request);
Task<Vehicle> UpdateVehicle(Guid id, UpdateVehicleRequest request);
Task<Vehicle> GetVehicle(Guid id);
Task<List<Vehicle>> GetVehicles(GetVehiclesQuery query);
Task DeleteVehicle(Guid id);
Task SetDefault(Guid id, SetDefaultRequest request);
}
```
## Internal Logic
### `CreateVehicle`
1. If `request.IsDefault` is `true`, **clear `is_default` on every other vehicle first** (`UPDATE vehicles SET is_default = FALSE WHERE is_default = TRUE`).
2. Build a fresh `Vehicle` with `Id = Guid.NewGuid()` and copy every request field 1:1.
3. `db.InsertAsync(vehicle)` and return the persisted entity.
### `UpdateVehicle`
1. Load the row by id; throw `KeyNotFoundException` if not found (mapped to 404 by middleware).
2. For each property on `UpdateVehicleRequest`, apply only the non-null fields (partial update).
3. If `IsDefault.Value == true`, run the same exclusivity clear as `CreateVehicle` BEFORE setting the new default.
4. `db.UpdateAsync(vehicle)`.
### `GetVehicle`
- `FirstOrDefaultAsync(v => v.Id == id)` -> 404 on miss.
### `GetVehicles`
- Builds a LINQ query on `db.Vehicles`.
- Filters: `query.Name` -> case-insensitive `Contains` (uses `string.ToLower()` on both sides -- runs as `LOWER(name) LIKE %lower(input)%` server-side via LinqToDB translation); `query.IsDefault` -> exact equality.
- Orders by `Name` ascending.
- Returns the full list -- **no pagination** despite `GetVehiclesQuery` being a query object. Spec accepts this (vehicles are a small dataset).
### `DeleteVehicle`
1. Guard: `db.Missions.AnyAsync(m => m.VehicleId == id)` -> if any mission references the vehicle, throw `InvalidOperationException` ("Vehicle is referenced by missions") -> 409 Conflict.
2. Load by id (404 on miss).
3. `db.Vehicles.DeleteAsync(v => v.Id == id)`.
### `SetDefault`
- Loads the row (404 on miss).
- If `request.IsDefault == true`, clears the flag on every other vehicle, then sets it on the target.
- If `request.IsDefault == false`, simply clears the flag on the target -- **no guarantee remains that exactly one vehicle is default**. Behavior is "the user told me to clear the flag, so I clear it".
## "Exactly one default" rule -- spec vs code
This is the canonical surviving spec-vs-code divergence in the rename Epic. See Jira **B12** (decision-only ticket).
- **Spec** (`../../suite/_docs/02_missions.md`): `PATCH /vehicles/{id}/default` "toggles the default flag on a vehicle." Only the target row changes.
- **Code**: when setting `IsDefault = true`, the service first clears the flag on **every other vehicle** in the same connection. Two operations, no transaction.
Two real consequences:
1. **Code is stricter than spec** -- in code there can be 0 or 1 default vehicle, never 2+. Spec would allow N defaults if the UI sets multiple.
2. **Race window**: two concurrent "set default = true" calls on different rows could both clear-then-set, leaving two defaults. linq2db default behavior is autocommit per statement; no `BeginTransactionAsync` is called.
B12 either lifts the rule into spec + wraps in a transaction (preferred), or drops the side-effect from code and lets UI handle exclusivity. The race fix is part of either resolution.
## Dependencies
- `Azaion.Missions.Database.AppDataConnection`
- `Azaion.Missions.Database.Entities.Vehicle`
- `Azaion.Missions.Database.Entities.Mission` (queried via `db.Missions` in `DeleteVehicle`)
- `Azaion.Missions.DTOs` (request/query DTOs)
## Consumers
- `Controllers.VehiclesController` -- wraps every method 1:1 with an HTTP route.
## Data Models
Reads/writes only `vehicles` table; reads (existence check) `missions` table.
## Configuration / External Integrations
None.
## Security
- All endpoints in the controller carry `[Authorize(Policy = "FL")]`, so this service is unreachable without a JWT bearing `permissions=FL`.
- Exception messages include the supplied id (`"Vehicle {id} not found"`) -- no PII risk for GUIDs.
## Tests
None present.
## Notes / Smells
1. **`GetVehicles` ignores pagination** by design -- the dataset is small (a fleet, not a catalog of millions). Inconsistent listing contract with `MissionService.GetMissions` but intentional.
2. **"Exactly one default" race** -- see B12 above.
3. **Delete fails fast on referenced vehicle** but does NOT cascade-delete or null-out the `vehicle_id` -- strictly a 409 to the caller. Consistent with the schema: `missions.vehicle_id` is `NOT NULL REFERENCES vehicles(id)` with no `ON DELETE` clause (PostgreSQL defaults to `NO ACTION`).
4. **Case-insensitive search** uses `string.ToLower()` on both sides, which LinqToDB renders as `LOWER(...)` -- non-indexed; full-table scan on large datasets. Fine at fleet size.
@@ -0,0 +1,88 @@
# Module: `Azaion.Missions.Services.WaypointService`
**File**: `Services/WaypointService.cs`
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's source still uses `Flight`/`flightId` and the cascade reaches into `gps_corrections`. Renames tracked under Jira AZ-EPIC child B6; cascade shrink under B7.
## Purpose
CRUD over waypoints (sub-aggregate of `Mission`) plus a manual cascade-delete chain that walks `waypoint -> media -> annotations -> detection`. All operations are scoped by `missionId` -- no cross-mission waypoint addressing.
## Public Interface
```csharp
public class WaypointService(AppDataConnection db) {
Task<Waypoint> CreateWaypoint(Guid missionId, CreateWaypointRequest request);
Task<Waypoint> UpdateWaypoint(Guid missionId, Guid waypointId, UpdateWaypointRequest request);
Task<List<Waypoint>> GetWaypoints(Guid missionId);
Task DeleteWaypoint(Guid missionId, Guid waypointId);
}
```
## Internal Logic
### `CreateWaypoint`
1. Existence check: `db.Missions.AnyAsync(m => m.Id == missionId)` -> `KeyNotFoundException` ("Mission not found") on miss -> 404.
2. Build a fresh `Waypoint`:
- `Id = Guid.NewGuid()`, `MissionId = missionId`.
- `Lat`, `Lon`, `Mgrs` from `request.GeoPoint?` (all three null-passthrough; if `GeoPoint` is null, all three are null).
- `WaypointSource`, `WaypointObjective` copied as-is.
- `OrderNum`, `Height` copied as-is.
3. `db.InsertAsync(waypoint)`.
### `UpdateWaypoint`
1. Load by composite predicate `w.MissionId == missionId && w.Id == waypointId` -> 404 on miss (so updates with the wrong `missionId` get 404, not 403).
2. **Full overwrite** -- every field is set unconditionally from the request, including `null`-passthrough on `Lat/Lon/Mgrs`. There is no partial-update semantic here, unlike `UpdateVehicleRequest`.
3. `db.UpdateAsync(waypoint)`.
### `GetWaypoints`
- `db.Waypoints.Where(w.MissionId == missionId).OrderBy(w.OrderNum).ToListAsync()` -- no pagination.
### `DeleteWaypoint` -- manual cascade
1. Load by composite (404 on miss).
2. `mediaIds = SELECT id FROM media WHERE waypoint_id = waypointId`.
3. If any media exist:
- `annotationIds = SELECT id FROM annotations WHERE media_id IN (mediaIds)`.
- If any annotations exist: `DELETE FROM detection WHERE annotation_id IN (annotationIds)`.
- `DELETE FROM annotations WHERE media_id IN (mediaIds)`.
4. `DELETE FROM media WHERE waypoint_id = waypointId`.
5. `DELETE FROM waypoints WHERE id = waypointId`.
**Removed from cascade in B7**: `gps_corrections WHERE waypoint_id = waypointId`. Schema gone after B9.
**No transaction** wraps the cascade either.
## Dependencies
- `AppDataConnection`, entities `Waypoint`, `Mission` (existence check), and 3 dependent entities used in the cascade.
- `DTOs.GeoPoint`, `DTOs.CreateWaypointRequest`, `DTOs.UpdateWaypointRequest`.
- `Azaion.Missions.Enums` (`WaypointSource`, `WaypointObjective` -- typed properties).
## Consumers
- `Controllers.MissionsController` -- exposed under `missions/{id}/waypoints/*` routes.
## Data Models
Reads `missions` (existence). Writes `waypoints`. Cascade also writes `media`, `annotations`, `detection`.
## Configuration / External Integrations
None.
## Security
- Behind `[Authorize(Policy = "FL")]` via the controller.
- Composite-key load (`MissionId AND Id`) means a user cannot operate on a waypoint by guessing only its id -- they must also know the mission id (defense in depth, though both are GUIDs).
## Tests
None present.
## Notes / Smells
1. **`UpdateWaypoint` does a full overwrite** even though `UpdateWaypointRequest` is "partial-shaped". Any client sending `{}` would silently zero out `Lat/Lon/Mgrs/OrderNum/Height` and reset enums to `0`. Client contract is fragile.
2. **`waypoint_id` not found vs. `mission_id`/`waypoint_id` mismatch** both return 404 with the same message -- slight UX issue (you can't tell which id is wrong).
3. **No reorder endpoint** -- `OrderNum` is set in the request but there's no atomic "reorder this list" operation. Reordering N waypoints requires N PUTs and is racy.
4. **Same transaction gap** as `MissionService.DeleteMission` -- no `BeginTransactionAsync` around the cascade.
5. **`GeoPoint?` swallowing** -- sending a request with `GeoPoint = null` clears the location entirely. Likely intentional but combined with "no validation" means an empty waypoint can be created.
+67
View File
@@ -0,0 +1,67 @@
{
"current_step": "complete",
"completed_steps": [
"discovery",
"module-analysis",
"component-assembly",
"module-layout",
"system-synthesis",
"verification",
"glossary-vision",
"solution-extraction",
"problem-extraction",
"final-report"
],
"focus_dir": null,
"modules_total": 12,
"modules_documented": [
"enums", "dtos", "entities", "auth", "middleware",
"database", "service_vehicle", "service_mission", "service_waypoint",
"controller_vehicles", "controller_missions", "program"
],
"modules_remaining": [],
"module_batch": 3,
"components_written": [
"01_vehicle_catalog",
"02_mission_planning",
"04_persistence",
"05_identity",
"06_http_conventions",
"07_host"
],
"decomposition_revised": [
{
"trigger": "user feedback after suite-context review (2026-05-13)",
"old_components": ["01_host", "02_auth", "03_web_infrastructure", "04_persistence", "05_aircraft", "06_flight"],
"new_components_alignment": "matches ../suite/_docs/02_flights.md feature sections (Aircrafts / Flights / GPS-Denied)",
"spec_code_divergences_logged_in_discovery": 11
},
{
"trigger": "user-requested architectural refactor (2026-05-14): rename flights -> missions, drop GPS-Denied, add UGV + GuidedMissile vehicle types",
"old_components": ["01_aircraft_catalog", "02_mission_planning", "03_gps_denied", "04_persistence", "05_identity", "06_http_conventions", "07_host"],
"new_components_alignment": "matches ../suite/_docs/02_missions.md (renamed from 02_flights.md); GPS-Denied moved to a separate gps-denied service per ../suite/_docs/11_gps_denied.md; vehicle catalog now spans Plane / Copter / UGV / GuidedMissile",
"renames_doc_only_until_jira_lands": true,
"jira_epic": "AZ-EPIC (rename flights -> missions; multi-vehicle; drop GPS-Denied)",
"jira_children_in_plan": ["B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B9", "B10", "B11", "B12"]
}
],
"step_4_5_glossary_vision": "confirmed",
"step_4_5_artifacts": [
"_docs/02_document/glossary.md",
"_docs/02_document/architecture.md (## Architecture Vision section)"
],
"step_5_solution_artifact": "_docs/01_solution/solution.md",
"step_6_problem_artifacts": [
"_docs/00_problem/problem.md",
"_docs/00_problem/restrictions.md",
"_docs/00_problem/acceptance_criteria.md",
"_docs/00_problem/input_data/data_parameters.md",
"_docs/00_problem/security_approach.md"
],
"step_7_final_report": "_docs/02_document/FINAL_report.md",
"rename_status": "doc-only; code/DB/HTTP/repo refactor pending B-tickets",
"verification_log": "_docs/02_document/04_verification_log.md",
"verification_corrections_inline": 9,
"verification_drift_flagged": 2,
"last_updated": "2026-05-14T09:32:00Z"
}
+264
View File
@@ -0,0 +1,264 @@
# Azaion.Missions — System Flows
> **NOTE (forward-looking)**: route prefixes, identifiers, and cascade chains in this document reflect the **post-rename, post-GPS-Denied-removal** state. Today's source still uses `[Route("flights")]`, `[Route("aircrafts")]`, `Aircraft*` / `Flight*` filenames, and the cascade still touches `orthophotos` + `gps_corrections`. Renames + cascade shrink tracked under Jira AZ-EPIC children B5 / B6 / B7 / B8. Per-flow `.md` files under `diagrams/flows/` follow the same convention.
## Flow Inventory
| # | Flow Name | Trigger | Primary Components | Criticality |
|---|-----------|---------|--------------------|-------------|
| F1 | Vehicle CRUD | Operator UI HTTP | `01_vehicle_catalog``04_persistence` | High |
| F2 | Mission create / read / update | Operator UI HTTP | `02_mission_planning``04_persistence`, with `01_vehicle_catalog` existence check | High |
| F3 | Mission delete with cross-service cascade | Operator UI HTTP `DELETE /missions/{id}` | `02_mission_planning``04_persistence` (touches `map_objects`, `media`, `annotations`, `detection`, `waypoints`, `missions`) | **Critical** (data integrity; not transaction-wrapped today) |
| F4 | Waypoint create / read / update / delete | Operator UI HTTP | `02_mission_planning` (`WaypointService`) → `04_persistence` | High (delete is a cross-service cascade variant of F3) |
| F5 | JWT bearer validation | Every protected request | `05_identity` (pipeline middleware) | **Critical** (cross-cutting; applies to every authenticated route) |
| F6 | Service startup + schema migration | Process start | `07_host``04_persistence` (`DatabaseMigrator.Migrate`) | High (one-shot per restart; B9 `DROP` runs here on legacy devices) |
| F7 | Health probe | Container orchestration / reverse proxy | `07_host` (`MapGet("/health")`) | Medium |
## Flow Dependencies
| Flow | Depends on | Shares data with |
|------|------------|------------------|
| F1 | F6 (schema must exist) | F2 (mission references `vehicle_id`) |
| F2 | F1 (vehicle existence check on create / update), F5 (auth), F6 | F3 (deletion), F4 (waypoint owner) |
| F3 | F2 (a mission must exist to delete it), F5, F6 | F4 (waypoint cascade is a sub-walk), `annotations` + detection pipeline (cross-service tables) |
| F4 | F2 (mission must exist to nest waypoints under it), F5, F6 | F3 (mission delete also walks all waypoint sub-trees) |
| F5 | None | Every protected flow (F1F4) |
| F6 | None | Every flow (no flow can run before the schema is in place) |
| F7 | None | None |
## Cross-cutting concerns (apply to all HTTP flows)
These behaviors wrap every flow at the pipeline level. They are described once here rather than repeated in each flow:
1. **JWT bearer validation (F5)**. ASP.NET Core's `JwtBearerHandler` runs on every request marked `[Authorize]`. Validation is local (HMAC HS256, shared secret with `admin`) — no network call to the issuer. Failures surface as `401 Unauthorized` (no token / invalid signature / expired) or `403 Forbidden` (token valid but missing the `"FL"` permission claim). See `diagrams/flows/flow_jwt_validation.md` for the sequence.
2. **Permission gate**. Every controller action in `01_vehicle_catalog` and `02_mission_planning` carries `[Authorize(Policy = "FL")]`. The policy requirement is satisfied by a `permissions` claim equal to `"FL"`. The policy NAME is referenced as a raw string in feature controllers — a typo would silently turn into a permanent 403 (see `module-layout.md` § Verification Needed #4).
3. **Global exception → JSON middleware**. `ErrorHandlingMiddleware` (`06_http_conventions`) is registered FIRST in the pipeline. It maps `KeyNotFoundException → 404`, `ArgumentException → 400`, `InvalidOperationException → 409`; everything else → 500 with the stack trace logged. Wire shape: entity / DTO bodies are PascalCase (suite-spec divergence — see `architecture.md` ADR-002); the global error envelope is camelCase already (accidental match — anonymous object literal `new { statusCode, message }` uses lowercase property names) but still missing the spec's `errors` field.
4. **No correlation ID, no request-level audit trail**. Logs are timestamp-only; supporting a production incident requires grep-by-timestamp.
---
## Flow F1: Vehicle CRUD
See `diagrams/flows/flow_vehicle_crud.md`.
**Description**: Full CRUD over the vehicle catalog. Every endpoint requires the `"FL"` permission. The list endpoint is **unpaginated by spec**; create + update + setDefault contain the "exactly one default vehicle" exclusivity rule that is **stricter than spec** (see `01_vehicle_catalog` Caveats #1 and Jira AZ-551 / B12 for the resolution decision).
**Preconditions**: Service is running; schema is in place (F6); caller holds a JWT with `permissions=FL`.
**Key sequence steps** (happy path, create with `IsDefault=true`):
1. UI → `POST /vehicles { Name, VehicleType, IsDefault: true, ... }` (with `Authorization: Bearer <jwt>`).
2. Pipeline → JWT validation (F5) + policy `"FL"` check.
3. `VehiclesController.Create``VehicleService.CreateVehicle(req)`.
4. `VehicleService.CreateVehicle`:
- If `req.IsDefault == true`: `UPDATE vehicles SET is_default = FALSE WHERE is_default = TRUE` *(divergence from spec — clears every other row's default flag)*.
- `INSERT INTO vehicles VALUES (...)`.
5. `Vehicle` entity returned on the wire (PascalCase JSON).
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing or invalid JWT | Pipeline | `JwtBearerHandler` | `401 Unauthorized`; client refreshes the token at `admin` |
| JWT lacks `"FL"` permission | Pipeline | Policy evaluator | `403 Forbidden` |
| `DELETE` of a vehicle referenced by any mission | `VehicleService.DeleteVehicle` | `IsAny<Mission>(m => m.VehicleId == id)` check returns true | `InvalidOperationException``409 Conflict` |
| Vehicle not found by id | Get / Update / Delete / SetDefault | Entity lookup returns null | `KeyNotFoundException``404 Not Found` |
| Race on default-set | `VehicleService.CreateVehicle` / `UpdateVehicle` / `SetDefault` | None (no transaction) | Race window can leave 2+ `IsDefault=true` rows or zero defaults — see `01_vehicle_catalog` Caveats #1, tracked as B12 |
---
## Flow F2: Mission create / read / update
See `diagrams/flows/flow_mission_lifecycle.md`.
**Description**: Mission CRUD excluding delete (delete is F3 because of the cascade complexity). Create / update validate that the referenced `vehicle_id` exists; list is paginated (the only paginated endpoint in this service).
**Preconditions**: Service is running; schema is in place (F6); caller holds a JWT with `permissions=FL`; for create / update with `VehicleId`, the referenced vehicle must exist (F1).
**Key sequence steps** (create):
1. UI → `POST /missions { Name, VehicleId }`.
2. Pipeline → JWT + `"FL"` (F5).
3. `MissionsController.Create``MissionService.CreateMission(req)`.
4. `MissionService.CreateMission`:
- `SELECT 1 FROM vehicles WHERE id = @VehicleId` (existence check, no transaction with the insert below).
- If absent: throw `ArgumentException("VehicleId not found")``400` *(spec says `404`; minor divergence — see `02_mission_planning` Caveats #8)*.
- `INSERT INTO missions (id, name, vehicle_id, created_date) VALUES (...)`.
5. `Mission` entity returned (PascalCase; LinqToDB does NOT eager-load `[Association]` navigation, so `Mission.Vehicle` and `Mission.Waypoints` serialize as `null` / `[]`).
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `VehicleId` does not exist on create | `MissionService.CreateMission` | Existence check returns false | `ArgumentException``400 Bad Request` (spec wants `404`) |
| `VehicleId` deleted between existence check and insert (TOCTOU) | `MissionService.CreateMission` | FK constraint rejects insert | Npgsql `PostgresException``500 Internal Server Error` (UX gap — should map to `400`) |
| Mission not found on read / update | `MissionService.GetMission` / `UpdateMission` | Entity lookup returns null | `KeyNotFoundException``404` |
---
## Flow F3: Mission delete with cross-service cascade *(most critical)*
See `diagrams/flows/flow_mission_cascade_delete.md`.
**Description**: `DELETE /missions/{id}` walks the full ownership graph and tears down rows in tables this service does NOT own the schema for (`media`, `annotations`, `detection`) plus its own `map_objects`, `waypoints`, and `missions`. **NOT transaction-wrapped today** (`architecture.md` ADR-006); partial failure leaves orphans. After B7, the `orthophotos` + `gps_corrections` branches are gone — they belong to the separate `gps-denied` service which manages its own cleanup.
**Preconditions**: Mission exists (`KeyNotFoundException``404` otherwise); schema for the borrowed tables is present in `postgres-local` (in standard edge deployment, `annotations` and detection have run their migrations on the same DB).
**Cascade order** (strictly child-before-parent, FK-driven):
```
1. DELETE FROM map_objects WHERE mission_id = ? (autopilot writes; this service owns schema + cleanup)
2. SELECT id FROM waypoints WHERE mission_id = ? → waypointIds
3. If waypointIds.Any():
SELECT id FROM media WHERE waypoint_id IN waypointIds → mediaIds
SELECT id FROM annotations WHERE media_id IN mediaIds → annotationIds
DELETE FROM detection WHERE annotation_id IN annotationIds (cross-service: detection pipeline)
DELETE FROM annotations WHERE id IN annotationIds (cross-service: annotations)
DELETE FROM media WHERE id IN mediaIds (cross-service: annotations)
4. DELETE FROM waypoints WHERE mission_id = ?
5. DELETE FROM missions WHERE id = ?
```
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Mission not found | Step 0 (initial existence check) | Entity lookup | `KeyNotFoundException``404` |
| `relation does not exist` for `media` / `annotations` / `detection` | Step 3 | Npgsql `PostgresException` (`42P01`) | `500`. **Indicates `annotations` or detection pipeline never migrated on this device** — abnormal edge deployment. See `02_mission_planning` Caveats #6 |
| Partial failure mid-cascade (network blip, lock timeout) | Any step | Npgsql exception | `500`. **Orphan rows left behind**. Re-running the same DELETE is a partial fix — already-deleted children are no-ops, remaining children proceed (see ADR-006 carry-forward) |
| `autopilot` writes a `map_object` racing this delete | Step 1 vs. concurrent insert | None | The insert may succeed AFTER step 1 reads zero rows; the orphan row stays until the next mission delete or manual cleanup. Small race window in practice (single-operator workflow) |
**Performance expectations**:
| Metric | Target | Notes |
|--------|--------|-------|
| End-to-end latency | <50ms typical | 47 sequential round-trips against local PostgreSQL on the same device |
| Throughput | 1 op / mission delete; not load-tested | Operator-paced; not a hot path |
| Orphan rate | 0 once transaction-wrap lands (ADR-006 carry-forward) | Today: non-zero on any failure mid-cascade |
---
## Flow F4: Waypoint create / read / update / delete
See `diagrams/flows/flow_waypoint_lifecycle.md`.
**Description**: Waypoint CRUD nested under a mission (`/missions/{id}/waypoints/*`). Delete is a scoped variant of F3's cascade — it walks `media` / `annotations` / `detection` for **one** waypoint instead of all waypoints of a mission. Same NO-transaction caveat applies (ADR-006). `UpdateWaypoint` is a **full overwrite** of every field even though the request DTO looks "partial-shaped" (see `02_mission_planning` Caveats #2). List is **unpaginated by spec**, ordered by `OrderNum`.
**Preconditions**: Parent mission exists (`KeyNotFoundException``404` otherwise).
**Key sequence steps** (delete one waypoint):
1. UI → `DELETE /missions/{id}/waypoints/{wpId}`.
2. Pipeline → JWT + `"FL"` (F5).
3. `MissionsController.DeleteWaypoint``WaypointService.DeleteWaypoint(missionId, wpId)`.
4. `WaypointService.DeleteWaypoint`:
- Verify waypoint exists with `mission_id = @missionId AND id = @wpId` → 404 if not.
- Resolve `mediaIds` for this one waypoint, then `annotationIds`.
- `DELETE FROM detection WHERE annotation_id IN annotationIds`.
- `DELETE FROM annotations WHERE id IN annotationIds`.
- `DELETE FROM media WHERE id IN mediaIds`.
- `DELETE FROM waypoints WHERE id = @wpId`.
**Error scenarios**: identical to F3 scoped to one waypoint.
---
## Flow F5: JWT bearer validation
See `diagrams/flows/flow_jwt_validation.md`.
**Description**: The cross-cutting auth flow that runs on every `[Authorize]` request. Validation is **local** — this service never calls the `admin` service that issued the token.
**Preconditions**: `JWT_SECRET` is set (or the dev fallback applies — see `architecture.md` ADR-005); the JWT bearer middleware was registered by `AddJwtAuth` in `07_host`.
**Key sequence steps**:
1. Request arrives at the ASP.NET Core pipeline with `Authorization: Bearer <jwt>`.
2. `JwtBearerHandler`:
- Parse the token.
- Verify HMAC-SHA256 signature with `SymmetricSecurityKey(UTF-8(JWT_SECRET))`.
- Verify `lifetime` (`ClockSkew = 1 minute` — tighter than .NET's 5-minute default).
- **Skip** `iss` / `aud` validation (`ValidateIssuer = false`, `ValidateAudience = false` — known CMMC L2 finding, suite-tracked under AZ-487 / AZ-494, see `05_identity` § Implementation Details).
3. If signature or lifetime fails: `401 Unauthorized` (without ever invoking the controller).
4. If valid: parse claims into `ClaimsPrincipal`; attach to the request.
5. Authorization policy `"FL"` evaluator checks for a `permissions` claim with value `"FL"`.
6. If absent: `403 Forbidden`.
7. If present: forward to the controller action.
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| Missing `Authorization` header | Pipeline | `JwtBearerHandler` | `401` |
| Invalid signature | Pipeline | HMAC verify fails | `401` |
| Expired token | Pipeline | `ValidateLifetime` (with 1min skew) | `401`; client re-authenticates with `admin` |
| Token signed with old `JWT_SECRET` (rotation) | Pipeline | HMAC verify fails | `401`; coordinated re-deploy across all backends sharing the secret + UI re-login |
| `permissions` claim missing or not `"FL"` | Policy evaluator | Claim lookup | `403` |
| `JWT_SECRET` is the well-known dev fallback in production | n/a (silent) | None at runtime | **Security risk** — any party with the fallback can mint accepted tokens. ADR-005 carry-forward; suite-level remediation pending |
---
## Flow F6: Service startup + schema migration
See `diagrams/flows/flow_startup_migration.md`.
**Description**: One-time-per-process bootstrap. `Program.cs` builds the DI graph, runs `DatabaseMigrator.Migrate(db)` once, then starts serving HTTP. The migrator is idempotent (`CREATE ... IF NOT EXISTS`). After B9, the migrator additionally runs `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` once for fielded edge devices that previously ran the legacy schema.
**Preconditions**: `DATABASE_URL` resolves (env or hardcoded dev fallback); `postgres-local` is reachable; the `azaion` database exists.
**Key sequence steps**:
1. Container starts → entrypoint `dotnet Azaion.Missions.dll`.
2. `Program.cs` reads `DATABASE_URL``ConvertPostgresUrl` → Npgsql connection string.
3. Reads `JWT_SECRET``AddJwtAuth(jwt)` (DI registration; no network).
4. Registers controllers, middleware, scoped `AppDataConnection`, scoped service classes.
5. Builds the host. Opens a single startup scope and calls `DatabaseMigrator.Migrate(db)`:
- `CREATE TABLE IF NOT EXISTS vehicles (...)`.
- `CREATE TABLE IF NOT EXISTS missions (...)`.
- `CREATE TABLE IF NOT EXISTS waypoints (...)`.
- `CREATE TABLE IF NOT EXISTS map_objects (...)`.
- `CREATE INDEX IF NOT EXISTS ix_missions_vehicle_id ...` and similar.
- **B9 one-shot**: `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;`.
6. Registers `ErrorHandlingMiddleware` FIRST in the pipeline; mounts auth, controllers, `MapGet("/health")`, Swagger UI.
7. `app.Run()` — ready to serve HTTP on port 8080.
**Error scenarios**:
| Error | Where | Detection | Recovery |
|-------|-------|-----------|----------|
| `postgres-local` unreachable | Step 5 | Npgsql connection failure | Process exits non-zero. Watchtower restarts the container; `flight-gate` prevents restart mid-mission |
| `azaion` database does not exist | Step 5 | Npgsql `3D000` (`invalid_catalog_name`) | Process exits. Operator must create the database (provisioning concern, not this service) |
| `DROP TABLE IF EXISTS orthophotos` fails because the table is being read by `gps-denied` | Step 5 (B9 one-shot) | Lock timeout | Process exits. Restart loop until `gps-denied` releases the lock — should be moments. **Out-of-band ordering**: deploy `gps-denied` first so it has its own copy before `missions` drops the legacy tables |
| Migrator partial failure mid-statement | Step 5 | Npgsql exception | Process exits. Each statement is individually idempotent (`IF NOT EXISTS`) so the next startup retries safely |
---
## Flow F7: Health probe
See `diagrams/flows/flow_health_probe.md`.
**Description**: `GET /health` returns `{ "status": "healthy" }` with no auth. Used by container orchestration (Watchtower / docker compose healthcheck) and any reverse-proxy upstream check. Does NOT verify DB connectivity today — only confirms the process is up and the HTTP pipeline is serving.
**Preconditions**: HTTP pipeline is serving (i.e., `app.Run()` reached).
**Key sequence steps**:
1. Probe → `GET /health` (no `Authorization` header required).
2. `MapGet("/health")` returns `Results.Ok(new { status = "healthy" })`.
**Error scenarios**: none meaningful — if the pipeline is up the response is always 200. If the process is down, the probe fails at TCP-connect time and orchestration restarts it.
**Future improvement (carry-forward)**: gate `/health` on a DB ping so `flight-gate` and reverse-proxy checks reflect actual readiness rather than process liveness. Today the migrator runs at startup and crashes the process on DB failure, which is a coarse but workable substitute.
---
## Mermaid diagram conventions
Per the suite documentation conventions:
- **Participants** match `components/[##]_[name]` directories.
- **Node IDs** are camelCase, no spaces.
- **Decision nodes** use `{Question?}` format.
- **Start / End** stadia use `([label])`.
- **External systems** (`autopilot`, `annotations`, detection pipeline, `gps-denied`, `admin`, the React `ui`) use `[[label]]` subroutine shape and live in their own subgraphs.
- **No styling** — let the renderer theme handle it.
+608
View File
@@ -0,0 +1,608 @@
# Blackbox Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Pre-rename code path runs the same test against `/aircrafts`/`/flights`; tests will be RED until B5B8 land.
> **Cross-references**: every test traces to one or more AC IDs from `_docs/00_problem/acceptance_criteria.md`. Expected results are inline (quantifiable) with a pointer to the corresponding row in `_docs/00_problem/input_data/expected_results/results_report.md`.
## Positive Scenarios
### FT-P-01: Create non-default vehicle returns 201 with PascalCase body
**Summary**: Verifies vehicle CRUD create path and response wire shape.
**Traces to**: AC-1.1, AC-8.1
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_empty`
**Input data**: `data_parameters.md` § 2.1 — `POST /vehicles { Type:0, Model:"Bayraktar", Name:"BR-01", FuelType:1, BatteryCapacity:0, EngineConsumption:5, EngineConsumptionIdle:1, IsDefault:false }`, JWT `permissions=FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /vehicles` with body above | `201 Created`; body `Vehicle` with PascalCase keys `Id, Type, Model, Name, FuelType, BatteryCapacity, EngineConsumption, EngineConsumptionIdle, IsDefault`; `Id` parses as UUID |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={returnedId}` | `count == 1` |
**Expected outcome**: results_report.md AC-1 row 1.1.
**Max execution time**: 5s.
---
### FT-P-02: Create default vehicle demotes prior default
**Summary**: Verifies the clear-then-set pattern in `VehicleService.CreateVehicle` when `IsDefault=true`.
**Traces to**: AC-1.2
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_one_default_vehicle` (prior row `Id=P1`, `is_default=true`)
**Input data**: `POST /vehicles { ..., IsDefault:true }` (other fields valid)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /vehicles { ..., IsDefault:true }` | `201`; new row has `IsDefault:true` |
| 2 | Side-channel `SELECT id, is_default FROM vehicles ORDER BY is_default DESC` | New row has `is_default=true`; row `P1` has `is_default=false`; total `is_default=true` count == 1 |
**Expected outcome**: results_report.md AC-1 row 1.2.
**Max execution time**: 5s.
---
### FT-P-03: setDefault promotes existing vehicle
**Summary**: Verifies `POST /vehicles/{id}/setDefault` clear-then-set behavior.
**Traces to**: AC-1.2 (update branch), AC-1.4
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_one_default_vehicle` (default row `P1`); add a non-default row `P2`
**Input data**: `POST /vehicles/{P2}/setDefault { IsDefault:true }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `POST /vehicles/{P2}/setDefault { IsDefault:true }` | `200`; body `Vehicle` with `Id==P2`, `IsDefault==true` |
| 2 | Side-channel `SELECT id, is_default FROM vehicles` | `P2.is_default==true`, `P1.is_default==false`, default count == 1 |
**Expected outcome**: results_report.md AC-1 row 1.4.
**Max execution time**: 5s.
---
### FT-P-04: Vehicle list returns plain JSON array (no pagination)
**Summary**: Verifies `GET /vehicles` returns a non-paginated array — distinguishing it from `GET /missions`.
**Traces to**: AC-1.5
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_3_vehicles_2_default`
**Input data**: `GET /vehicles`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /vehicles` | `200`; body parses as JSON array (NOT object); `body.length == 3`; each element has PascalCase keys per FT-P-01 |
**Expected outcome**: results_report.md AC-1 row 1.5.
**Max execution time**: 2s.
---
### FT-P-05: Vehicle filter by name + isDefault
**Summary**: Verifies query-string filter (case-sensitive substring on `name`, exact on `isDefault`).
**Traces to**: AC-1.6
**Category**: Vehicle CRUD
**Preconditions**:
- `seed_3_vehicles_2_default` containing `BR-01` (default), `BR-02` (non-default), `MQ-9` (default)
**Input data**: `GET /vehicles?name=BR&isDefault=true`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /vehicles?name=BR&isDefault=true` | `200`; `body.length == 1`; `body[0].Name == "BR-01"` |
**Expected outcome**: results_report.md AC-1 row 1.6.
**Max execution time**: 2s.
---
### FT-P-06: Delete vehicle with no references
**Summary**: Verifies `DELETE /vehicles/{id}` returns 204 and removes the row.
**Traces to**: AC-1.10
**Category**: Vehicle CRUD
**Preconditions**:
- One vehicle row exists, no missions reference it
**Input data**: `DELETE /vehicles/{id}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `DELETE /vehicles/{id}` | `204 No Content`; empty body |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={id}` | `count == 0` |
**Expected outcome**: results_report.md AC-1 row 1.10.
**Max execution time**: 2s.
---
### FT-P-07: Create mission with default CreatedDate
**Summary**: Verifies mission create assigns `CreatedDate = UtcNow` when null.
**Traces to**: AC-2.1
**Category**: Mission CRUD
**Preconditions**:
- `seed_one_default_vehicle` (use its id as `VehicleId`)
**Input data**: `POST /missions { Name:"Recon-01", VehicleId:<id>, CreatedDate:null }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Capture `t0 = DateTime.UtcNow` | |
| 2 | `POST /missions` with body above | `201`; `body.CreatedDate` parses as UTC; `abs(body.CreatedDate - t0) ≤ 5s` |
**Expected outcome**: results_report.md AC-2 row 2.1.
**Max execution time**: 5s.
---
### FT-P-08: Mission list paginated default page
**Summary**: Verifies `GET /missions` returns `PaginatedResponse<Mission>` with default page size 20.
**Traces to**: AC-2.3, AC-8.7
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions`
**Input data**: `GET /missions`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /missions` | `200`; body parses as object with PascalCase keys `Items, TotalCount, Page, PageSize`; `Page==1`; `PageSize==20`; `TotalCount==25`; `Items.length==20` |
**Expected outcome**: results_report.md AC-2 row 2.3.
**Max execution time**: 2s.
---
### FT-P-09: Mission list page 2
**Summary**: Verifies pagination skips correctly to page 2.
**Traces to**: AC-2.3
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions`
**Input data**: `GET /missions?page=2&pageSize=20`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /missions?page=2&pageSize=20` | `200`; `Page==2`; `Items.length==5`; ids in `Items` are disjoint from those returned by FT-P-08 (per UUID set check) |
**Expected outcome**: results_report.md AC-2 row 2.4.
**Max execution time**: 2s.
---
### FT-P-10: Mission list date range
**Summary**: Verifies `?fromDate=&toDate=` filter inclusivity.
**Traces to**: AC-2.3
**Category**: Mission CRUD
**Preconditions**:
- `seed_25_missions` (5 January, 20 February)
**Input data**: `GET /missions?fromDate=2026-01-01T00:00:00Z&toDate=2026-01-31T23:59:59Z`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `TotalCount==5`; every `Items[i].CreatedDate` falls within January 2026 UTC |
**Expected outcome**: results_report.md AC-2 row 2.5.
**Max execution time**: 2s.
---
### FT-P-11: Mission partial update preserves null fields
**Summary**: Verifies `PUT /missions/{id}` only overwrites non-null fields in the request body.
**Traces to**: AC-2.5
**Category**: Mission CRUD
**Preconditions**:
- One mission row with known `Name`, `VehicleId`
**Input data**: `PUT /missions/{id} { Name:"Renamed", VehicleId:null }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.Name == "Renamed"`; `body.VehicleId == previous_value` (preserved) |
**Expected outcome**: results_report.md AC-2 row 2.7.
**Max execution time**: 2s.
---
### FT-P-12: Mission cascade delete walks every dependency table
**Summary**: Verifies `DELETE /missions/{id}` deletes every row in the seeded chain across `map_objects`, `detection`, `annotations`, `media`, `waypoints`, `missions`.
**Traces to**: AC-3.1
**Category**: Mission Cascade Delete (F3) — most critical
**Preconditions**:
- `fixture_cascade_F3` applied (seeded `mid` mission with full chain)
**Input data**: `DELETE /missions/{mid}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `204` |
| 2 | Side-channel `SELECT COUNT(*) FROM map_objects WHERE mission_id={mid}` | `count == 0` |
| 3 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (seeded ids)` | `count == 0` |
| 4 | Side-channel `SELECT COUNT(*) FROM annotations WHERE id IN (seeded ids)` | `count == 0` |
| 5 | Side-channel `SELECT COUNT(*) FROM media WHERE id IN (seeded ids)` | `count == 0` |
| 6 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE mission_id={mid}` | `count == 0` |
| 7 | Side-channel `SELECT COUNT(*) FROM missions WHERE id={mid}` | `count == 0` |
| 8 | Compare per-table counts against `expected_results/cascade_F3_walk.json` | `json_diff` matches all expected fields |
**Expected outcome**: results_report.md AC-3 row 3.1.
**Max execution time**: 10s.
---
### FT-P-13: Waypoint list ordered by OrderNum
**Summary**: Verifies `GET /missions/{id}/waypoints` returns waypoints sorted by `OrderNum` ASC, regardless of insert order.
**Traces to**: AC-4.3
**Category**: Waypoint CRUD
**Preconditions**:
- `seed_5_waypoints_unordered` under one mission
**Input data**: `GET /missions/{id}/waypoints`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; body parses as JSON array; `body.length == 5`; `[w.OrderNum for w in body] == [1, 2, 3, 4, 5]` |
**Expected outcome**: results_report.md AC-4 row 4.2.
**Max execution time**: 2s.
---
### FT-P-14: Waypoint create echoes geo fields
**Summary**: Verifies `POST /missions/{id}/waypoints` creates a row with the supplied `GeoPoint` and DOES NOT auto-convert lat/lon ↔ mgrs.
**Traces to**: AC-4 (data_parameters.md § 2.3 spec divergence)
**Category**: Waypoint CRUD
**Preconditions**:
- One mission row exists
**Input data**: `POST /missions/{id}/waypoints { GeoPoint:{Lat:50.45, Lon:30.52, Mgrs:null}, WaypointSource:0, WaypointObjective:0, OrderNum:1, Height:120 }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `201`; `body.GeoPoint.Lat == 50.45`; `body.GeoPoint.Lon == 30.52`; `body.GeoPoint.Mgrs == null` (NO auto-conversion today) |
**Expected outcome**: results_report.md AC-4 row 4.3.
**Max execution time**: 2s.
---
### FT-P-15: Waypoint update is full overwrite
**Summary**: Verifies `PUT /missions/{id}/waypoints/{wpId}` overwrites every field even though the DTO looks "partial" (non-nullable enums/numerics).
**Traces to**: AC-4.4
**Category**: Waypoint CRUD
**Preconditions**:
- One waypoint exists with `Height=120`, `OrderNum=1`, `GeoPoint=(Lat:50.45, ...)`
**Input data**: `PUT /missions/{id}/waypoints/{wpId} { GeoPoint:null, WaypointSource:1, WaypointObjective:1, OrderNum:2, Height:0 }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.Height == 0` (overwritten from 120); `body.OrderNum == 2`; `body.GeoPoint == null` |
**Expected outcome**: results_report.md AC-4 row 4.4.
**Max execution time**: 2s.
---
### FT-P-16: Health is 200 anonymous
**Summary**: Verifies `GET /health` requires no auth.
**Traces to**: AC-7.1, AC-7.2
**Category**: Health probe
**Preconditions**: any
**Input data**: `GET /health` (no `Authorization` header)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /health` with no `Authorization` header | `200`; body == `{ "status": "healthy" }` exactly (case-sensitive key) |
**Expected outcome**: results_report.md AC-7 row 7.1.
**Max execution time**: 2s.
---
### FT-P-17: Health is 200 with Postgres stopped (process-liveness only)
**Summary**: Verifies the health probe does NOT ping the DB.
**Traces to**: AC-7.2, AC-7.3
**Category**: Health probe
**Preconditions**: `missions` running; `postgres-test` `docker compose stop postgres-test`
**Input data**: `GET /health`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `GET /health` with PG stopped | `200`; body == `{ "status": "healthy" }` |
**Expected outcome**: results_report.md AC-7 row 7.2.
**Max execution time**: 5s (allow PG-stop time).
---
### FT-P-18: Waypoint cascade delete is scoped to one waypoint
**Summary**: Verifies `DELETE /missions/{mid}/waypoints/{wpId}` deletes only the chain rooted at `wpId`, leaves sibling waypoint chains intact.
**Traces to**: AC-4.5
**Category**: Waypoint Cascade Delete (F4)
**Preconditions**:
- `fixture_cascade_F4` (target waypoint `wp1` with chain; sibling waypoint `wp2` with chain)
**Input data**: `DELETE /missions/{mid}/waypoints/{wp1}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `204` |
| 2 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (wp1 chain)` | `count == 0` |
| 3 | Side-channel `SELECT COUNT(*) FROM annotations WHERE id IN (wp1 chain)` | `count == 0` |
| 4 | Side-channel `SELECT COUNT(*) FROM media WHERE id IN (wp1 chain)` | `count == 0` |
| 5 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp1}` | `count == 0` |
| 6 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp2}` | `count == 1` (sibling intact) |
| 7 | Side-channel counts on `media`, `annotations`, `detection` for `wp2` chain | all `> 0` (sibling intact) |
| 8 | Compare against `cascade_F4_walk.json` | `json_diff` matches |
**Expected outcome**: results_report.md AC-4 row 4.5.
**Max execution time**: 10s.
---
## Negative Scenarios
### FT-N-01: Vehicle name filter is case-sensitive
**Summary**: Verifies `name=br` does NOT match `BR-01` (case sensitivity).
**Traces to**: AC-1.6
**Category**: Vehicle CRUD (negative)
**Preconditions**:
- `seed_3_vehicles_2_default` (only contains `BR-*` names — no `br-*`)
**Input data**: `GET /vehicles?name=br`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `200`; `body.length == 0` |
**Expected outcome**: results_report.md AC-1 row 1.7.
**Max execution time**: 2s.
---
### FT-N-02: GET vehicle 404
**Summary**: Verifies `GET /vehicles/{random}` returns 404 with the documented envelope.
**Traces to**: AC-1.7, AC-8.2
**Category**: Vehicle CRUD (negative)
**Preconditions**: any
**Input data**: `GET /vehicles/{random uuid}`, JWT `FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `404`; body parses to JSON object with EXACTLY keys `statusCode, message` (camelCase by accidental match) |
**Expected outcome**: results_report.md AC-1 row 1.8.
**Max execution time**: 2s.
---
### FT-N-03: Delete vehicle in use returns 409
**Summary**: Verifies `DELETE /vehicles/{id}` returns 409 when at least one mission references the vehicle.
**Traces to**: AC-1.8, AC-8.5
**Category**: Vehicle CRUD (negative)
**Preconditions**:
- Vehicle row exists, ≥1 mission row references it
**Input data**: `DELETE /vehicles/{id}`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | `DELETE /vehicles/{id}` | `409`; envelope `{ statusCode:409, message:<non-empty> }` |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE id={id}` | `count == 1` (row NOT deleted) |
**Expected outcome**: results_report.md AC-1 row 1.9.
**Max execution time**: 2s.
---
### FT-N-04: Create mission with non-existent VehicleId returns 400 (today)
**Summary**: Verifies the existing `ArgumentException → 400` divergence (spec wants 404).
**Traces to**: AC-2.2 (carry-forward divergence), AC-8.5
**Category**: Mission CRUD (negative)
**Preconditions**:
- `seed_empty` (no vehicles exist)
**Input data**: `POST /missions { Name:"x", VehicleId:<random uuid>, CreatedDate:null }`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `400`; envelope `{ statusCode:400, message:<non-empty> }` |
| 2 | Side-channel `SELECT COUNT(*) FROM missions` | `count == 0` (no row written) |
**Expected outcome**: results_report.md AC-2 row 2.2.
**Note**: this test will FAIL once the spec divergence is closed (status will become 404). When that work lands, update the expected status here.
**Max execution time**: 2s.
---
### FT-N-05: GET mission 404
**Summary**: Verifies `GET /missions/{random}` returns 404.
**Traces to**: AC-2.4, AC-8.2
**Category**: Mission CRUD (negative)
**Preconditions**: any
**Input data**: `GET /missions/{random uuid}`, JWT `FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `404`; envelope `{ statusCode:404, message:<non-empty> }` |
**Expected outcome**: results_report.md AC-2 row 2.6.
**Max execution time**: 2s.
---
### FT-N-06: Cascade delete short-circuits on missing mission (404 before any DELETE)
**Summary**: Verifies that mission existence is checked BEFORE any dependency-table DELETE runs (AC-3.2).
**Traces to**: AC-3.2
**Category**: Cascade delete F3 (negative)
**Preconditions**:
- `fixture_cascade_F3` applied (seeded chain rooted at `mid`); test targets a DIFFERENT random `mid'` not present in the DB
**Input data**: `DELETE /missions/{mid'}` (random uuid)
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Enable Postgres `log_statement=all` (test orchestrator sets this on `postgres-test` startup) | |
| 2 | `DELETE /missions/{mid'}` | `404` |
| 3 | Side-channel `SELECT COUNT(*) FROM map_objects` | `count` unchanged from precondition |
| 4 | Side-channel inspect `pg_stat_statements` (or scrape `pg_log` lines logged after the request timestamp) | NO `DELETE FROM map_objects` / `... waypoints` / `... media` / `... annotations` / `... detection` issued by the request connection |
**Expected outcome**: results_report.md AC-3 row 3.2.
**Max execution time**: 5s.
---
### FT-N-07: Waypoint operation against missing mission returns 404
**Summary**: Verifies parent-mission existence check on every waypoint endpoint.
**Traces to**: AC-4.2
**Category**: Waypoint CRUD (negative)
**Preconditions**: any
**Input data**: `GET /missions/{random uuid}/waypoints`, JWT `FL`
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | request as above | `404`; envelope `{ statusCode:404, message:<non-empty> }` |
**Expected outcome**: results_report.md AC-4 row 4.1.
**Max execution time**: 2s.
---
### FT-N-08: Generic 500 returns redacted body, logs stack
**Summary**: Verifies `Exception` fallthrough — body is the generic message, full stack is in the log.
**Traces to**: AC-8.6, AC-10.3
**Category**: Wire shape (negative)
**Preconditions**:
- Inject a divide-by-zero or similar throw in any handler via a test-only middleware OR force the condition (e.g., AC-2.9 TOCTOU triggers `Npgsql.PostgresException` → 500). For deterministic execution, drop the `vehicles` table mid-test then call `GET /vehicles/{any}`.
**Input data**: as above
**Steps**:
| Step | Consumer Action | Expected System Response |
|------|----------------|------------------------|
| 1 | Side-channel `DROP TABLE vehicles CASCADE` | |
| 2 | `GET /vehicles/{any uuid}`, JWT `FL` | `500`; body == `{ "statusCode":500, "message":"Internal server error" }` exactly |
| 3 | `docker logs missions \| grep "Unhandled exception"` | At least one matching log line emitted within `≤ 2s` of the request, containing the exception type name |
**Expected outcome**: results_report.md AC-8 row 8.7 + AC-10 row 10.1.
**Max execution time**: 5s.
---
## Notes
- Tests that depend on the DB side-channel reading `pg_stat_statements` or query logs (FT-N-06) require a one-time test bootstrap to enable those features on `postgres-test`. That bootstrap is part of the docker compose for the test environment (`environment.md` § Docker Environment).
- Every test enforces `Max execution time` via xUnit `[Fact(Timeout = N*1000)]`. Default suite timeout (CI gate) is 15 minutes (per `environment.md`).
+128
View File
@@ -0,0 +1,128 @@
# Test Environment
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target — `missions` service, `Azaion.Missions.*` namespace, `/vehicles` + `/missions` + `/missions/{id}/waypoints` routes. Until B5B8 land, the `missions` service image is built from the existing `Azaion.Flights.csproj` source — tests will be RED until the rename converges. This is the autodev-aligned path: Step 8 (Refactor) closes the gap.
> **Hardware Assessment** section is filled by `phases/hardware-assessment.md` between Phase 3 and Phase 4.
## Overview
**System under test**: the `missions` .NET 10 REST service exposed on `http://missions:8080` inside the test network. Public surface = the HTTP endpoints documented in `_docs/00_problem/input_data/data_parameters.md` § 7.
**Consumer app purpose**: a standalone xUnit test project (`tests/Azaion.Missions.E2E.Tests.csproj`) that exercises the running service through HTTP only. No `Azaion.Missions.*` types are referenced; the consumer never opens a `DataConnection` to the system-under-test's runtime DB except via a side-channel `postgres-test` connection used to (a) seed fixtures and (b) assert DB side-effects (cascade row counts, default-vehicle invariants).
The side-channel DB access is allowed because the AC catalogue (AC-1.2, AC-1.4, AC-3.1, AC-3.3, AC-10.2) explicitly defines DB state as the verifiable observable. It is NEVER used to mutate state under-test that the API would normally own — only to (1) seed fixtures and (2) assert.
## Docker Environment
### Services
| Service | Image / Build | Purpose | Ports (host:container) |
|---------|--------------|---------|-------|
| `missions` | build context `./` (`Dockerfile`); image tag `azaion/missions:test` | System under test | `5002:8080` |
| `postgres-test` | `postgres:16-alpine` | Owned PostgreSQL for test isolation. Started fresh per test class via Testcontainers OR via `docker compose down -v && docker compose up -d` between scenarios that mutate startup-sensitive state (AC-6.5 legacy drop, AC-6.6 idempotency) | `5433:5432` |
| `e2e-consumer` | build context `tests/Azaion.Missions.E2E.Tests/`; runs `dotnet test` | xUnit test runner; produces `report.csv` | — |
| `pg-side` (optional) | reused `postgres-test` connection on a side port | Side-channel DB connection for fixture seeding + post-call assertions | shares `postgres-test` |
No external mock services are required:
- `admin` (JWT issuer): the test runner mints HS256 tokens itself using a known `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`.
- `annotations`, `detection`, `autopilot`: their tables (`media`, `annotations`, `detection`, `map_objects`) are seeded directly by the side-channel for cascade tests; the services themselves are not running.
- `flight-gate`, Watchtower, suite reverse proxy: not required for service-level e2e.
### Networks
| Network | Services | Purpose |
|---------|----------|---------|
| `e2e-net` | `missions`, `postgres-test`, `e2e-consumer` | Isolated bridge network; no host network access |
### Volumes
| Volume | Mounted to | Purpose |
|--------|-----------|---------|
| `pg-test-data` | `postgres-test:/var/lib/postgresql/data` | Ephemeral; recreated per scenario class (`docker compose down -v` between class boundaries when the test asserts startup behavior) |
| `e2e-results` | `e2e-consumer:/app/results` and host `./e2e-results/` | Output of `report.csv` |
### docker-compose structure
```yaml
# Outline only — not runnable code (the actual scripts/run-tests.sh wires this up)
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_DB: azaion
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres-test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d azaion"]
interval: 1s
timeout: 1s
retries: 30
missions:
build:
context: .
environment:
DATABASE_URL: postgresql://postgres:postgres-test@postgres-test:5432/azaion
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
depends_on:
postgres-test:
condition: service_healthy
e2e-consumer:
build:
context: tests/Azaion.Missions.E2E.Tests
environment:
MISSIONS_BASE_URL: http://missions:8080
DB_SIDE_CHANNEL: Host=postgres-test;Port=5432;Database=azaion;Username=postgres;Password=postgres-test
JWT_SECRET: test-secret-32-chars-min!!!!!!!!!
depends_on:
missions:
condition: service_started
volumes:
- ./e2e-results:/app/results
```
## Consumer Application
**Tech stack**: xUnit 2.x + `Microsoft.AspNetCore.Mvc.Testing` (HttpClient via `IClassFixture`) OR plain `HttpClient` against the dockerized service. Bogus 35.x for synthetic data. JWT minting via `System.IdentityModel.Tokens.Jwt`. PostgreSQL side-channel via Npgsql (NOT linq2db — keep the consumer free of system-under-test runtime libs).
**Entry point**: `dotnet test tests/Azaion.Missions.E2E.Tests/Azaion.Missions.E2E.Tests.csproj --logger "trx;LogFileName=results.trx"` followed by a small post-processor that converts trx → `report.csv`.
### Communication with system under test
| Interface | Protocol | Endpoint | Authentication |
|-----------|----------|----------|----------------|
| Vehicle API | HTTP/1.1 JSON | `http://missions:8080/vehicles[?name=&isDefault=]` and `/vehicles/{id}[/setDefault]` | `Authorization: Bearer <HS256, permissions=FL>` |
| Mission API | HTTP/1.1 JSON | `http://missions:8080/missions[?name=&fromDate=&toDate=&page=&pageSize=]` | same |
| Waypoint API | HTTP/1.1 JSON | `http://missions:8080/missions/{id}/waypoints[/{wpId}]` | same |
| Health | HTTP/1.1 JSON | `http://missions:8080/health` | anonymous |
| DB side-channel (assertions only) | TCP/Postgres wire | `postgres-test:5432` | `postgres:postgres-test` |
### What the consumer does NOT have access to
- No `using Azaion.Missions.*;` — the consumer is a separate csproj with no project reference to the system under test.
- No `AppDataConnection` instantiation; the side-channel uses raw Npgsql `NpgsqlCommand` only.
- No file-system overlap; `e2e-consumer` is a separate container.
- No environment variable shared in process; the system-under-test's `JWT_SECRET` is supplied through compose env, the consumer mints with the same value via its own env.
## CI/CD Integration
**When to run**: on every push to `dev` (Woodpecker pipeline `.woodpecker/test-arm.yml` and `.woodpecker/test-amd.yml` after the existing `build-arm.yml` job). Currently the repo has only `build-arm.yml` (per O4); the test runner pipeline is a follow-up artifact produced by Step 6 (Implement Tests) — see `scripts/run-tests.sh` (Phase 4).
**Pipeline stage**: post-build, pre-push (the test runner pulls the just-built `azaion/missions:test` tag).
**Gate behavior**: blocking on `dev` branch. Per O4, today's pipeline has no test stage; this gate is added by Step 6 implementation.
**Timeout**: max 15 minutes total wall-clock. Cascade-delete fixtures and the bootstrap-failure scenarios (AC-6.6, AC-6.7) dominate.
## Reporting
**Format**: CSV
**Columns**: `TestId, TestName, Category, Traces, ExecutionTimeMs, Result, ErrorMessage`
**Output path**: `./e2e-results/report.csv`
Categories: `BLACKBOX`, `PERF`, `RES`, `SEC`, `RES_LIM`. `Traces` is a comma-separated list of AC and restriction IDs from `traceability-matrix.md`.
## Hardware Assessment
To be filled by `phases/hardware-assessment.md` between Phase 3 and Phase 4. Today's expected outcome: no GPU, no specialised hardware, no model inference — this is a CRUD service. Test execution requires only a Postgres-capable container and the .NET 10 SDK image. AMD64 + ARM64 both supported (matches H2). Resource ceiling: 2 GB RAM total for `missions + postgres-test + e2e-consumer` is sufficient.
@@ -0,0 +1,103 @@
# Performance Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. The thresholds below are the documented expectations from `acceptance_criteria.md` and `architecture.md` § 6 — they reflect what the implemented design *aims* for on the edge devices (Jetson Orin / OrangePI / operator-PC). The test suite asserts these thresholds against the test environment defined in `environment.md` (Docker on developer laptop), with the caveat that production hardware may produce tighter numbers; CI tracks the test-environment numbers as the regression baseline.
> **Test execution mode**: every NFT-PERF-* runs `N` repetitions and computes the documented percentile (P50/P95). Cold-start passes are excluded — 5 warm-up calls precede every measured run.
---
### NFT-PERF-01: Mission cascade-delete latency target
**Summary**: Verifies the documented latency target for the F3 cascade walk against local PostgreSQL on the same device.
**Traces to**: AC-3.6
**Metric**: P50 wall-clock latency for `DELETE /missions/{id}` against a 1-waypoint, no-map_objects, no-media mission.
**Preconditions**:
- `missions` and `postgres-test` colocated on the same Docker network with no inter-host link
- `seed_one_default_vehicle` + 100 minimal missions (each with 1 waypoint, no media/annotations/detection/map_objects rows)
- 5 warm-up `DELETE` calls on missions outside the measured set (to warm Npgsql connection pool + JIT)
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 100 sequential `DELETE /missions/{id_i}` calls (one per seeded mission, 1 ≤ i ≤ 100) | Record per-call wall-clock latency in ms |
| 2 | Compute P50 across the 100 measurements | `median(latencies)` |
**Pass criteria**: `P50 ≤ 50ms`.
**Duration**: ~1030s of test wall-clock (each call <50ms on healthy local PG).
**Note**: P95 is *also* recorded for trend tracking but does NOT block — only P50 ≤ 50ms is the gate.
---
### NFT-PERF-02: Mission cascade-delete latency under full chain
**Summary**: Same as NFT-PERF-01 but with the full F3 chain (map_objects + media + annotations + detection rows present). No documented threshold; this test establishes a baseline that subsequent runs must not regress against by more than 50%.
**Traces to**: AC-3.1, AC-3.6 (related)
**Metric**: P50 wall-clock latency for `DELETE /missions/{id}` against a `fixture_cascade_F3`-shaped mission.
**Preconditions**:
- Same as NFT-PERF-01 but seed 50 missions each with the `fixture_cascade_F3` chain (3 map_objects, 2 waypoints, 2 media, 2 annotations, 2 detection)
- 5 warm-up calls on additional fixtures outside the measured set
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 50 sequential `DELETE /missions/{id_i}` calls | Record per-call wall-clock latency in ms |
| 2 | Compute P50, P95 | medians + 95th percentile |
**Pass criteria**: `P50 ≤ 200ms` (provisional baseline — 4× the minimal-chain target accounts for 3 extra DELETE statements + index updates). On first green run, lock the achieved P50 ± 50% as the regression gate for subsequent runs.
**Duration**: ~3060s of test wall-clock.
---
### NFT-PERF-03: Health endpoint latency
**Summary**: Verifies `GET /health` is the lightweight process-liveness probe.
**Traces to**: AC-7.3
**Metric**: P50 wall-clock latency.
**Preconditions**:
- `missions` running, no special seed required
- 5 warm-up `GET /health` calls
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 100 sequential `GET /health` calls (no `Authorization`) | Record per-call wall-clock latency in ms |
| 2 | Compute P50 | `median(latencies)` |
**Pass criteria**: `P50 ≤ 10ms`.
**Duration**: ~1s of test wall-clock.
---
### NFT-PERF-04: Mission list pagination throughput
**Summary**: No documented threshold for `GET /missions` list path — this test establishes a regression baseline so a future change cannot silently 10× the P95 latency.
**Traces to**: AC-2.3 (latency-related, no AC threshold)
**Metric**: P95 wall-clock latency for `GET /missions?page=1&pageSize=20` against a 1000-mission seed.
**Preconditions**:
- Seed 1000 missions referencing `seed_one_default_vehicle`
- 5 warm-up calls
**Steps**:
| Step | Consumer Action | Measurement |
|------|----------------|-------------|
| 1 | Issue 100 sequential `GET /missions?page=1&pageSize=20` calls | Record per-call wall-clock latency in ms |
| 2 | Compute P95 | `percentile(latencies, 95)` |
**Pass criteria**: on first green run, lock the achieved P95 ± 50% as the regression gate. Initial provisional gate: `P95 ≤ 100ms`. If first run exceeds this, raise the gate AND open a follow-up ticket — do NOT silently accept.
**Duration**: ~10s.
---
## Notes
- All NFT-PERF tests run sequentially (no concurrent client) to remove HTTP/1.1 connection-reuse variance from the measurement. Concurrency is exercised separately under NFT-RES (resilience) when needed for race scenarios.
- Per `restrictions.md` H6, container-level resource limits are NOT enforced inside the container today. Tests assume the test host has ≥ 2 CPU cores and ≥ 2 GB free RAM — hardware-assessment will lock this requirement.
- Latencies measured against the test environment (developer laptop / CI runner) WILL diverge from production edge hardware. The CI gate is the regression baseline; the AC-3.6 / AC-7.3 numerical thresholds are documented production targets that the test environment also satisfies because the test environment is faster, not slower (no PG-on-Jetson penalty).
+215
View File
@@ -0,0 +1,215 @@
# Resilience Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Resilience scenarios use the side-channel + container-orchestration tools to inject faults; the system-under-test is treated as a black box.
> **Critical scenarios**: AC-3.3 (cascade NOT transaction-wrapped) and AC-3.4 (orphan-row race) are the highest-impact resilience invariants — they intentionally encode current (sub-optimal) behavior so tests catch any silent change. Several other resilience tests verify documented operational behaviors (idempotent migrator, DB-down crash, JWT secret rotation).
---
### NFT-RES-01: Cascade is NOT transaction-wrapped — partial deletes survive mid-walk failure
**Summary**: Verifies the documented ADR-006 carry-forward — when the cascade walk fails mid-way (e.g., `media` table absent), already-committed deletes remain.
**Traces to**: AC-3.3, AC-3.4 (related), AC-10.2
**Preconditions**:
- `fixture_cascade_F3` applied (chain rooted at `mid`)
- `missions` running
**Fault injection**: side-channel `DROP TABLE media CASCADE;` BEFORE the request — this turns the second sub-step of the cascade walk into a `relation does not exist` failure.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `DROP TABLE media CASCADE` | succeeds |
| 2 | `DELETE /missions/{mid}` (JWT `FL`) | `500`; envelope `{ statusCode:500, message:"Internal server error" }` |
| 3 | Side-channel `SELECT COUNT(*) FROM map_objects WHERE mission_id={mid}` | `count == 0` (work BEFORE the failure point committed — non-zero pre-fault, zero post-fault) |
| 4 | Side-channel `SELECT COUNT(*) FROM missions WHERE id={mid}` | `count == 1` (work AFTER the failure point did NOT run — row remains) |
| 5 | `docker logs missions \| grep "Unhandled exception"` | At least one matching log line containing `relation` and `media` |
**Pass criteria**: `map_objects` count is 0 (deleted before failure) AND `missions` count is 1 (not deleted because failure short-circuited the walk) AND the response is 500 with the redacted body.
**Note**: this test will FAIL once a transaction wrap is added (ADR-006 closure) — at that point ALL deletes will roll back and `map_objects` count will be `>0`. When the transaction wrap lands, update this test.
**Max execution time**: 10s.
---
### NFT-RES-02: Waypoint cascade is NOT transaction-wrapped (same invariant as F3)
**Summary**: Same invariant as NFT-RES-01, scoped to F4 (waypoint cascade).
**Traces to**: AC-4.6, AC-3.3 (same root cause), AC-10.2
**Preconditions**:
- `fixture_cascade_F4` applied (waypoint `wp1` with chain)
**Fault injection**: side-channel `DROP TABLE media CASCADE` BEFORE the request.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `DROP TABLE media CASCADE` | succeeds |
| 2 | `DELETE /missions/{mid}/waypoints/{wp1}` (JWT `FL`) | `500` |
| 3 | Side-channel `SELECT COUNT(*) FROM detection WHERE annotation_id IN (wp1 chain)` | `count == 0` (deleted before failure) |
| 4 | Side-channel `SELECT COUNT(*) FROM waypoints WHERE id={wp1}` | `count == 1` (not deleted) |
**Pass criteria**: same shape as NFT-RES-01.
**Max execution time**: 10s.
---
### NFT-RES-03: Idempotent migrator — second startup is a no-op
**Summary**: Verifies the migrator can run twice in a row without `relation already exists` errors.
**Traces to**: AC-6.6, AC-6.4
**Preconditions**:
- `seed_empty` (schema migrated once via first `missions` startup)
**Fault injection**: container restart (NOT volume reset).
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `docker compose restart missions` | container exits cleanly + restarts |
| 2 | Wait for `GET /health` to return `200` | within ≤ 30s |
| 3 | `docker logs missions \| grep -E "(error\|Error\|exception)"` | no NEW error / exception lines after the restart timestamp |
| 4 | Side-channel `\d+ vehicles` | schema unchanged from after the first migrate |
**Pass criteria**: second start completes; no new error log lines; schema unchanged.
**Max execution time**: 60s.
---
### NFT-RES-04: B9 one-shot legacy table drop runs once and is idempotent
**Summary**: Verifies that the post-B9 `DROP TABLE IF EXISTS orthophotos / gps_corrections` block in the migrator is destructive on the FIRST run against a legacy device, and a no-op on subsequent runs.
**Traces to**: AC-6.5, AC-10.5
**Preconditions**:
- `seed_legacy_gps_tables` (schema includes `orthophotos` + `gps_corrections` with 1 row each)
- `missions` NOT yet started for this scenario
**Fault injection**: none — purely observe migrator behavior.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `SELECT to_regclass('orthophotos'), to_regclass('gps_corrections')` | both NON-NULL (legacy tables present) |
| 2 | `docker compose up -d missions` | container starts |
| 3 | Wait for `GET /health` to return `200` | |
| 4 | Side-channel re-query | both NULL (dropped) |
| 5 | `docker compose restart missions` | |
| 6 | Side-channel re-query | both still NULL (idempotent — no error) |
| 7 | `docker logs missions \| grep -i "does not exist"` | NO log line (because of `IF EXISTS`) |
**Pass criteria**: legacy tables absent after first start; subsequent restarts produce no errors and leave them absent.
**Note**: this test only meaningfully runs on a **post-B9 build**. Before B9 lands, the migrator has no DROP block; gate this scenario on a build-time flag or by inspecting the migrator source.
**Max execution time**: 60s.
---
### NFT-RES-05: DB unreachable at startup — process exits non-zero
**Summary**: Verifies AC-6.7 — DB unreachability causes process exit, NOT silent retry-forever.
**Traces to**: AC-6.7
**Preconditions**:
- `missions` NOT running
**Fault injection**: stop `postgres-test` (`docker compose stop postgres-test`) then start `missions`.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `docker compose stop postgres-test` | |
| 2 | `docker compose up -d missions` | |
| 3 | Poll `docker inspect --format '{{.State.ExitCode}}' missions` every 1s for ≤ 30s | At some point within 30s, the container has exited with non-zero exit code |
| 4 | `docker logs missions` | Contains an Npgsql connection error message (e.g., `Connection refused`) |
**Pass criteria**: container exits with non-zero code within 30s; logs contain a recognisable Npgsql error.
**Max execution time**: 60s.
---
### NFT-RES-06: DB missing (database does not exist) — process exits with Npgsql 3D000
**Summary**: Verifies AC-6.8 — when the `azaion` database does not exist, process exits with the documented PostgreSQL error code.
**Traces to**: AC-6.8
**Preconditions**:
- `postgres-test` running with the `azaion` database NOT yet created (use `POSTGRES_DB=postgres` instead, or `DROP DATABASE azaion`)
**Fault injection**: same as preconditions.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Side-channel `DROP DATABASE IF EXISTS azaion` | |
| 2 | `docker compose up -d missions` | |
| 3 | Poll exit code for ≤ 30s | non-zero |
| 4 | `docker logs missions \| grep "3D000"` | at least one match |
**Pass criteria**: container exits non-zero within 30s; logs contain `3D000`.
**Max execution time**: 60s.
---
### NFT-RES-07: JWT_SECRET rotation invalidates existing tokens
**Summary**: Verifies AC-5.7 — restarting the service with a different `JWT_SECRET` causes previously-valid tokens to fail validation.
**Traces to**: AC-5.7
**Preconditions**:
- `missions` running with `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`
- Token `T1` minted with the same secret, valid for 1h
**Fault injection**: restart `missions` with `JWT_SECRET=rotated-secret-32-chars-min!!!!!`.
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | `GET /vehicles` with `Authorization: Bearer T1` | `200` |
| 2 | `docker compose stop missions` | |
| 3 | `docker compose run -e JWT_SECRET=rotated-secret-32-chars-min!!!!! -d missions` | |
| 4 | Wait for `GET /health` 200 | |
| 5 | `GET /vehicles` with `Authorization: Bearer T1` (same token as step 1) | `401` |
| 6 | Mint token `T2` with the new secret, `GET /vehicles` with `T2` | `200` |
**Pass criteria**: `T1` works pre-rotation, fails post-rotation; `T2` works post-rotation.
**Max execution time**: 90s.
---
### NFT-RES-08: TOCTOU on default-vehicle exclusivity (race window)
**Summary**: Verifies AC-1.4 — the clear-then-set is NOT transaction-wrapped, so a concurrent INSERT can leave 2+ defaults.
**Traces to**: AC-1.4 (carry-forward)
**Preconditions**:
- `seed_one_default_vehicle` (default `P1`)
**Fault injection**: a second concurrent client issues `INSERT INTO vehicles (..., is_default=true)` directly to the side-channel DB at the same moment as the API client issues `POST /vehicles { IsDefault:true }`. Synchronisation: the test orchestrator pauses at `pg_advisory_lock(1)` after the service's `UPDATE vehicles SET is_default=FALSE` and BEFORE the service's `INSERT` — implemented via a Postgres function that the test installs and the service path traverses (this requires an instrumented test build; if not available, the test is best-effort by issuing 100 parallel INSERTs).
**Steps**:
| Step | Action | Expected Behavior |
|------|--------|------------------|
| 1 | Run 100 parallel iterations of `(POST /vehicles { IsDefault:true } + side-channel INSERT (..., is_default=true))` | each iteration completes |
| 2 | Side-channel `SELECT COUNT(*) FROM vehicles WHERE is_default=true` | count is `≥ 2` in at least one iteration |
**Pass criteria**: at least one iteration produces `default_count ≥ 2`. If 0 iterations produce the race, the test FAILS — either the race window has been closed (good news; rewrite the test to assert `default_count == 1` and update AC-1.4 to remove the race carry-forward), OR the test concurrency primitive is wrong (investigate).
**Note**: this test is intentionally PROBABILISTIC — it asserts the RACE EXISTS, not that the system is broken. A `PASS` here is bad news for the system but means the test is correctly observing the documented behavior.
**Max execution time**: 30s.
---
## Notes
- Tests that drop tables (NFT-RES-01, NFT-RES-02, NFT-RES-08) run in a per-class `IClassFixture<DbResetFixture>` that recreates the schema after each scenario.
- NFT-RES-03 through NFT-RES-07 require container-orchestration via `docker compose` from inside the test runner. The `e2e-consumer` container needs the `docker` CLI + a mounted Docker socket — alternatively, the test runs from the host with `docker compose` available there. Hardware-assessment will lock this preference.
- NFT-RES-08 is intentionally probabilistic and may be flaky on slow runners. CI marks this as `[Trait("Stability","probabilistic")]` and tolerates ≤ 1 failed run per 5; deterministic implementation (advisory lock + instrumented build) is a follow-up.
@@ -0,0 +1,89 @@
# Resource Limit Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target.
> **Note on scope**: per `restrictions.md` H6, container-level resource limits are NOT enforced inside the container today — they are set at the suite level (`_infra/_compose/`) per device type. As a service-level test suite, the resource-limit tests below establish *baseline observations* that downstream suite-level deployment planning can use to size the device-level cgroup limits, plus one upper-bound regression gate so future changes don't silently 10× memory or file-handle usage.
---
### NFT-RES-LIM-01: Steady-state memory ceiling under sustained load
**Summary**: Establishes a steady-state RSS memory ceiling for the `missions` container under realistic load. Future runs must not exceed this ceiling by more than 50%.
**Traces to**: H1, H6, O10
**Preconditions**:
- `missions` running with no host-side memory limit
- `seed_25_missions` + `seed_3_vehicles_2_default`
**Monitoring**:
- `docker stats --no-stream missions` polled every 5s for `MEM USAGE / LIMIT`
- Resident set size (RSS) extracted from the polled samples
**Duration**: 5 minutes of sustained load (mixed `GET /vehicles`, `GET /missions`, `GET /missions/{id}/waypoints` at ~50 RPS from a single concurrent client)
**Pass criteria**:
- P95 RSS over the 5-minute window `≤ 250 MiB` (provisional gate; lock the achieved value ± 50% as the regression gate after the first green run)
- Final RSS at `t=5min` is within ± 20% of P95 RSS (no sustained leak — RSS does not climb monotonically)
**Note**: this is a single-process .NET 10 service with a small in-memory footprint. 250 MiB is a generous initial gate — refine on first measured run.
---
### NFT-RES-LIM-02: Connection pool steady-state under sustained load
**Summary**: Verifies Npgsql connection pool does not grow unbounded under sustained load.
**Traces to**: O10 (one-instance-per-device + no pool tuning)
**Preconditions**:
- Same as NFT-RES-LIM-01
**Monitoring**:
- Side-channel `SELECT count(*) FROM pg_stat_activity WHERE application_name LIKE 'Npgsql%' OR usename = 'postgres'`
- Polled every 5s for 5 minutes
**Duration**: 5 minutes (same load profile as NFT-RES-LIM-01)
**Pass criteria**:
- Connection count stays `≤ 100` throughout the window (Npgsql default `Maximum Pool Size` = 100; any value lower is fine)
- Connection count at `t=5min` is within ± 30% of the steady-state observed in the first minute (no unbounded growth)
---
### NFT-RES-LIM-03: File descriptor steady-state
**Summary**: Verifies file descriptor count does not climb unbounded.
**Traces to**: H6, O10
**Preconditions**:
- Same as NFT-RES-LIM-01
**Monitoring**:
- Inside the `missions` container: `ls /proc/<pid>/fd \| wc -l` polled every 5s for 5 minutes
**Duration**: 5 minutes
**Pass criteria**:
- FD count stays `≤ 1024` (typical default `ulimit -n` ceiling); on a healthy run, count should sit in the low tens
- Count at `t=5min` within ± 30% of count at `t=1min`
---
### NFT-RES-LIM-04: Cold-start memory budget
**Summary**: Verifies the service's cold-start RSS sits within a budget that allows multiple sibling services to coexist on a Jetson Orin (8 GB total RAM, ~6 services targeted).
**Traces to**: H1, H3 (one container per device with siblings)
**Preconditions**:
- `missions` not running
**Monitoring**:
- `docker stats --no-stream missions` 30s after `GET /health` first returns 200
**Duration**: single measurement
**Pass criteria**:
- RSS `≤ 200 MiB` cold (no requests yet handled). If exceeded, open a follow-up ticket — the suite assumes each .NET edge service stays under 200 MiB so 6 services + Postgres + UI fit in 8 GB.
---
## Notes
- All resource-limit tests rely on `docker stats` and `/proc` reads from inside the container. The `e2e-consumer` needs `docker` CLI access (mounted Docker socket) OR the test runs from the host.
- No GPU / temperature / disk-I/O monitoring — this is a pure CRUD service with no model inference, no large file I/O, no specialised hardware (`hardware-assessment.md` will lock this).
- Per H6, resource limits are NOT enforced inside the container; these tests OBSERVE behavior so the suite-level deployment planning can SET the right cgroup limits. A failed test here means "the service is using more than expected" — possibly a leak, possibly a legitimate change. Investigation always required.
- Provisional gates marked above must be locked-in based on first measured numbers. If first measurement exceeds the provisional gate, raise the gate AND open a follow-up ticket — do NOT silently accept.
+166
View File
@@ -0,0 +1,166 @@
# Security Tests
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Security tests focus on the JWT bearer + Authz boundary defined in AC-5 and AC-9.
> **Out-of-scope (suite-tracked)**: the `iss` / `aud` validation gap (AC-5.3, CMMC L2 row 3, AZ-487 / AZ-494) is documented but NOT enforced today. Tests assert today's behaviour (AC-5.3 returns 200) — when the suite-wide remediation lands, update NFT-SEC-04.
---
### NFT-SEC-01: Missing Authorization header → 401
**Summary**: Verifies AC-5.4 — every protected endpoint rejects requests without an `Authorization` header.
**Traces to**: AC-5.4
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /vehicles` with no `Authorization` header | `401` |
| 2 | `GET /missions` with no `Authorization` header | `401` |
| 3 | `GET /missions/{any}/waypoints` with no `Authorization` header | `401` |
| 4 | `POST /vehicles` with no `Authorization` header + valid body | `401` (no row written — verify via side-channel `count` unchanged) |
**Pass criteria**: every protected endpoint returns 401; no DB side-effect.
---
### NFT-SEC-02: Invalid signature → 401
**Summary**: Verifies AC-5.5 — token signed with a different secret is rejected.
**Traces to**: AC-5.5
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token `T_bad` with `WRONG_SECRET=other-secret-32-chars-min!!!!!!`, otherwise valid (`exp = now + 1h`, `permissions=FL`) | |
| 2 | `GET /vehicles` with `Authorization: Bearer T_bad` | `401` |
**Pass criteria**: `401`.
---
### NFT-SEC-03: Expired token outside skew → 401; inside skew → 200
**Summary**: Verifies AC-5.6 + AC-5.2 (1-min skew tighter than .NET's 5-min default).
**Traces to**: AC-5.2, AC-5.6
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token `T_exp` with `exp = now - 120s` (outside 60s skew); `permissions=FL` | |
| 2 | `GET /vehicles` with `Authorization: Bearer T_exp` | `401` |
| 3 | Mint token `T_skew` with `exp = now - 30s` (inside 60s skew); `permissions=FL` | |
| 4 | `GET /vehicles` with `Authorization: Bearer T_skew` | `200` |
**Pass criteria**: `T_exp` rejected; `T_skew` accepted.
---
### NFT-SEC-04: Missing `iss` and `aud` claims accepted (today's behavior, AC-5.3)
**Summary**: Verifies the `ValidateIssuer = false` and `ValidateAudience = false` configuration. This test will FAIL once the suite-wide remediation (AZ-487 / AZ-494) lands — that's good news; update the test then.
**Traces to**: AC-5.3
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with NO `iss` and NO `aud` claim, valid signature + lifetime, `permissions=FL` | |
| 2 | `GET /vehicles` with that token | `200` |
**Pass criteria**: `200` today; will become `401` post-remediation.
---
### NFT-SEC-05: Missing `permissions` claim → 403
**Summary**: Verifies AC-5.8 — valid signature + lifetime is not enough; the `permissions=FL` claim is required.
**Traces to**: AC-5.8, AC-9.1
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with no `permissions` claim, valid otherwise | |
| 2 | `GET /vehicles` | `403` |
**Pass criteria**: `403`.
---
### NFT-SEC-06: Wrong `permissions` claim value → 403
**Summary**: Verifies AC-9.2 — the policy is exact-string match, hardcoded.
**Traces to**: AC-9.2
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Mint token with `permissions="ADMIN"`, valid otherwise | |
| 2 | `GET /vehicles` | `403` |
| 3 | Mint token with `permissions="fl"` (lowercase), valid otherwise | |
| 4 | `GET /vehicles` | `403` |
| 5 | Mint token with `permissions="FLight"`, valid otherwise | |
| 6 | `GET /vehicles` | `403` |
**Pass criteria**: `403` for every wrong-value case.
---
### NFT-SEC-07: Health endpoint exempt from auth
**Summary**: Verifies AC-7.1, AC-9.4 (contrast) — `/health` is anonymous.
**Traces to**: AC-7.1, AC-9.4
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /health` with no `Authorization` | `200` |
| 2 | `GET /health` with `Authorization: Bearer <expired token>` | `200` (auth not evaluated) |
**Pass criteria**: `200` in both cases.
---
### NFT-SEC-08: Stack trace not leaked in 500 body
**Summary**: Verifies AC-8.6 + AC-10.3 — internal exception details stay in the log, not the HTTP body.
**Traces to**: AC-8.6, AC-10.3
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | Force a `500` (drop `vehicles` table mid-test, then `GET /vehicles/{any}`) | |
| 2 | Inspect response body | `body == { "statusCode":500, "message":"Internal server error" }` exactly; NO key matching `stack`, `stackTrace`, `exception`, `inner`, `trace`; NO file path; NO type name in the body |
| 3 | `docker logs missions \| grep "Unhandled exception"` | At least one matching line; line contains the file path of the throw site OR the exception type name (the log-side info is private to operators) |
**Pass criteria**: response body contains only `statusCode`, `message`; log contains stack info.
---
### NFT-SEC-09: SQL injection guard via parameterised queries
**Summary**: Defensive — verifies linq2db's parameterised query path is in effect for filter strings.
**Traces to**: AC-1.6 (filter), AC-2.3 (filter), defensive
**Steps**:
| Step | Consumer Action | Expected Response |
|------|----------------|------------------|
| 1 | `GET /vehicles?name='%20OR%20'1'%3D'1` (URL-encoded `' OR '1'='1`) | `200`; `body.length == 0` (no row matches the literal `' OR '1'='1` string against `BR-01` etc.) |
| 2 | `GET /missions?name=%3B%20DROP%20TABLE%20vehicles%3B%20--` (URL-encoded `; DROP TABLE vehicles; --`) | `200`; `body.TotalCount == 0`; side-channel verifies `vehicles` table still exists |
**Pass criteria**: filter inputs are treated as literal strings; no SQL execution; no DDL side-effect.
---
## Notes
- Tests that drop tables (NFT-SEC-08) run in a per-class fixture that recreates the schema before subsequent tests.
- The CMMC L2 row 3 (`iss` / `aud`) gap is acknowledged but NOT remediated in this Epic; NFT-SEC-04 documents today's permissive behavior so a future enforcement change is detected.
- No fuzz testing today (recommended follow-up under a separate refactor cycle).
+93
View File
@@ -0,0 +1,93 @@
# Test Data Management
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Today's API surface is `/aircrafts`/`/flights`; tests will be RED until B5B8 land. Fixture column names in `expected_results/fixture_cascade_*.sql` use post-rename names; pre-rename code path runs the same DDL via the `Azaion.Flights.Database.DatabaseMigrator` against the test DB before the fixture INSERT — the test orchestrator invokes the service container's startup, then runs fixture SQL via the side channel.
## Seed Data Sets
| Data Set | Description | Used by Tests | How Loaded | Cleanup |
|----------|-------------|---------------|-----------|---------|
| `seed_empty` | Schema migrated, no rows in any table | bootstrap, unauth, 404 scenarios | `docker compose down -v && docker compose up -d` then wait for `missions` startup (which runs the migrator) | `down -v` between scenarios |
| `seed_one_default_vehicle` | Schema + 1 row in `vehicles` with `is_default=true` | AC-1.2 (default-clear), AC-1.3 (TOCTOU race), AC-1.4 setDefault, AC-2.1 mission create | side-channel SQL `INSERT INTO vehicles ...` after `seed_empty` | Within scenario class via per-class `IClassFixture<DbResetFixture>` |
| `seed_3_vehicles_2_default` | 3 rows in `vehicles`, 2 with `is_default=true` (illegal under AC-1.2) | AC-1.5 list, AC-1.6 filter (with deterministic ordering) | side-channel SQL | per-class fixture |
| `seed_25_missions` | 25 `missions` rows (5 in January 2026, 20 in February 2026) referencing one vehicle | AC-2.3, AC-2.4, AC-2.5 pagination + date filter | side-channel SQL with deterministic UUIDs | per-class fixture |
| `fixture_cascade_F3` | One mission with full dependency chain: 1 mission → 2 waypoints → 2 media → 2 annotations → 2 detection rows + 3 map_objects | AC-3.1, AC-3.3, AC-3.4, AC-10.2 | `expected_results/fixture_cascade_F3.sql` (referenced from `results_report.md`) — applied via side-channel after `seed_empty` | `down -v` after each scenario in this class |
| `fixture_cascade_F4` | One mission with one waypoint that has 1 media → 1 annotation → 1 detection chain; sibling waypoint with NO chain (must remain after delete) | AC-4.5, AC-4.6 | `expected_results/fixture_cascade_F4.sql` | `down -v` after each scenario in this class |
| `seed_5_waypoints_unordered` | 5 waypoints with `order_num` `[3, 1, 2, 5, 4]` under one mission | AC-4.3 unpaginated ordering | side-channel SQL | per-class fixture |
| `seed_legacy_gps_tables` | Pre-B7 schema: `vehicles`, `missions`, `waypoints` PLUS `orthophotos` and `gps_corrections` populated with 1 row each | AC-3.5 (post-B7 absence), AC-6.5 (one-shot drop), AC-10.5 (legacy device migration) | side-channel SQL `CREATE TABLE` + `INSERT` in a fresh `seed_empty` | `down -v` between scenarios |
## Data Isolation Strategy
Three isolation tiers, by scenario type:
- **Class-scoped DB reset** (`IClassFixture<DbResetFixture>`): for scenarios that share the same seed within a test class but must not leak to other classes. Used for AC-1, AC-2, AC-4 read paths.
- **Scenario-scoped container restart** (`docker compose down -v && up -d`): for scenarios that assert startup-time behavior or migrator side-effects. Used for AC-6.3, AC-6.4 (idempotency), AC-6.5 (legacy drop), AC-6.6 (idempotent re-run), AC-6.7 (DB unreachable), AC-5.7 (JWT_SECRET rotation).
- **Per-test transaction roll-back is NOT used** — the system under test is a separate process and its `DataConnection` is not in the test transaction.
## Input Data Mapping
| Input Data File | Source Location | Description | Covers Scenarios |
|-----------------|----------------|-------------|-----------------|
| `data_parameters.md` § 7 (HTTP table) | `_docs/00_problem/input_data/data_parameters.md` | Documentation of every endpoint + DTO shape; the consumer constructs requests from these shapes | every FT-* and NFT-* scenario |
| `fixture_cascade_F3.sql` | `_docs/00_problem/input_data/expected_results/fixture_cascade_F3.sql` | Cascade chain seed for AC-3 | FT-P-12, FT-N-04, NFT-RES-01, NFT-PERF-01 |
| `fixture_cascade_F4.sql` | `_docs/00_problem/input_data/expected_results/fixture_cascade_F4.sql` | Cascade chain seed for AC-4 | FT-P-18, NFT-RES-02 |
| `cascade_F3_walk.json` | `_docs/00_problem/input_data/expected_results/cascade_F3_walk.json` | Per-table delete-count expectations | FT-P-12 |
| `cascade_F4_walk.json` | `_docs/00_problem/input_data/expected_results/cascade_F4_walk.json` | Per-table delete-count expectations | FT-P-18 |
## Expected Results Mapping
| Test Scenario ID | Input Data | Expected Result | Comparison Method | Tolerance | Expected Result Source |
|-----------------|------------|-----------------|-------------------|-----------|----------------------|
| FT-P-01 | `POST /vehicles` body per `data_parameters.md` § 2.1 | `201 Created`, `Vehicle` body, DB row exists | exact + db_query | N/A | `results_report.md` AC-1 row 1.1 |
| FT-P-02 | `POST /vehicles` body with `IsDefault:true` against `seed_one_default_vehicle` | `201`; new row default; prior row not default; default count == 1 | exact + db_query | N/A | AC-1 row 1.2 |
| FT-P-03 | `POST /vehicles/{id}/setDefault {IsDefault:true}` | `200`; default count == 1; only target row default | exact + db_query | N/A | AC-1 row 1.4 |
| FT-P-04 | `GET /vehicles` against `seed_3_vehicles_2_default` | `200`; body length == 3; PascalCase keys | exact + schema | N/A | AC-1 row 1.5 |
| FT-P-05 | `GET /vehicles?name=BR&isDefault=true` | `200`; body length == 1; `body[0].Name == "BR-01"` | exact | N/A | AC-1 row 1.6 |
| FT-N-01 | `GET /vehicles?name=br` (case mismatch) | `200`; body length == 0 | exact | N/A | AC-1 row 1.7 |
| FT-N-02 | `GET /vehicles/{random uuid}` | `404`; envelope `{ statusCode:404, message }` | exact + schema | N/A | AC-1 row 1.8 |
| FT-N-03 | `DELETE /vehicles/{id}` against vehicle referenced by ≥1 mission | `409`; row not deleted | exact + db_query | N/A | AC-1 row 1.9 |
| FT-P-06 | `DELETE /vehicles/{id}` against vehicle with 0 missions | `204`; row deleted | exact + db_query | N/A | AC-1 row 1.10 |
| FT-P-07 | `POST /missions` body per `data_parameters.md` § 2.2 | `201`; `CreatedDate ± 5s` of `now` | exact + numeric_tolerance | ±5s | AC-2 row 2.1 |
| FT-N-04 | `POST /missions {VehicleId:<random>}` | `400` (today, divergent from spec's 404) | exact | N/A | AC-2 row 2.2 |
| FT-P-08 | `GET /missions` against `seed_25_missions` | `200`; `PaginatedResponse<Mission>`; Page=1, PageSize=20, TotalCount=25, Items.length=20 | exact + schema | N/A | AC-2 row 2.3 |
| FT-P-09 | `GET /missions?page=2&pageSize=20` | `Page=2, Items.length=5` | exact | N/A | AC-2 row 2.4 |
| FT-P-10 | `GET /missions?fromDate=...&toDate=...` | `TotalCount=3` against the 5-row seed | exact | N/A | AC-2 row 2.5 |
| FT-P-11 | `PUT /missions/{id} {Name, VehicleId:null}` | `200`; Name updated, VehicleId preserved | exact | N/A | AC-2 row 2.7 |
| FT-N-05 | `GET /missions/{random}` | `404` | exact | N/A | AC-2 row 2.6 |
| FT-P-12 | `DELETE /missions/{id}` against `fixture_cascade_F3` | `204`; per-table counts == 0 per `cascade_F3_walk.json` | exact + db_query + file_reference | N/A | AC-3 row 3.1 |
| FT-N-06 | `DELETE /missions/{random}` | `404`; no DELETE issued against dependency tables (instrument SQL log) | exact + log_assertion | N/A | AC-3 row 3.2 |
| FT-P-13 | `GET /missions/{id}/waypoints` against `seed_5_waypoints_unordered` | `200`; ordered `OrderNum [1..5]` ASC | exact | N/A | AC-4 row 4.2 |
| FT-N-07 | `GET /missions/{random}/waypoints` | `404` | exact | N/A | AC-4 row 4.1 |
| FT-P-14 | `POST /missions/{id}/waypoints` body per `data_parameters.md` § 2.3 with non-null GeoPoint | `201`; `Lat,Lon` echoed; `Mgrs == null` (today, divergent — see §2.3 note) | exact | N/A | AC-4 row 4.3 |
| FT-P-15 | `PUT /missions/{id}/waypoints/{wpId}` body resetting Height to 0 | `200`; `Height==0` (full overwrite) | exact | N/A | AC-4 row 4.4 |
| FT-P-18 | `DELETE /missions/{id}/waypoints/{wpId}` against `fixture_cascade_F4` | `204`; only target waypoint's chain deleted; sibling chain intact | exact + db_query + file_reference | N/A | AC-4 row 4.5 |
| FT-P-16 | `GET /health` no auth | `200 { "status": "healthy" }` | exact | N/A | AC-7 row 7.1 |
| FT-P-17 | `GET /health` with PG stopped | `200 { "status": "healthy" }` (no DB ping) | exact | N/A | AC-7 row 7.2 |
NFT-* mappings (perf, resilience, security, resource-limit) are inline in the respective test files.
## External Dependency Mocks
| External Service | Mock/Stub | How Provided | Behavior |
|-----------------|-----------|-------------|----------|
| `admin` (JWT issuer) | In-process token mint | `System.IdentityModel.Tokens.Jwt` in the consumer using `JWT_SECRET=test-secret-32-chars-min!!!!!!!!!`, HS256 | Mints valid / expired / wrong-secret / claim-missing / claim-typo tokens on demand for AC-5 + AC-9 scenarios |
| `annotations` table owner | DB-only stub | Side-channel `CREATE TABLE annotations (id text PRIMARY KEY, media_id text)` then `INSERT` | Provides rows the cascade walk reads + deletes; no service running |
| `detection` table owner | DB-only stub | Side-channel `CREATE TABLE detection (id uuid PRIMARY KEY, annotation_id text)` + INSERT | Same as above |
| `media` table owner | DB-only stub | Side-channel `CREATE TABLE media (id text PRIMARY KEY, waypoint_id uuid)` + INSERT | Same |
| `autopilot` writer of `map_objects` | DB-only stub + race injector | Side-channel; for AC-3.4 race, a parallel goroutine-equivalent inserts a `map_objects` row immediately after the service's first `SELECT` (instrumented via test-only proxy) | One scenario only |
| `flight-gate`, Watchtower, suite reverse proxy, suite UI | NOT mocked | n/a | Out of scope for service-level e2e |
## Data Validation Rules
| Data Type | Validation today (per AC-* notes) | Invalid Examples | Expected System Behavior |
|-----------|-----------------------------------|-----------------|------------------------|
| Vehicle.Name | NONE (per data_parameters.md § 2.1 note) | `""` (empty) | accepted; row created; tests assert this is the current state, not the desirable one (carry-forward) |
| Vehicle.BatteryCapacity | NONE | `-1` | accepted; carry-forward |
| Vehicle.Type | NONE (any int accepted) | `99` | accepted; carry-forward |
| Mission.Page / PageSize | NONE | `-1`, `999999` | accepted by binding; carry-forward |
| Waypoint.GeoPoint | NONE; all-null accepted | `{Lat:null, Lon:null, Mgrs:null}` | accepted (`OrderNum + Height` still required-by-shape) |
| JWT lifetime | `ValidateLifetime=true` with 1-min skew | `exp = now-2min` | `401` |
| JWT signature | HS256 + shared secret | wrong secret / tampered payload | `401` |
| JWT claim `permissions` | exact string match `"FL"` | `"fl"`, `"ADMIN"`, missing | `403` |
| `Authorization` header | required on all `/vehicles/*`, `/missions/*` | absent | `401` |
| `DATABASE_URL` shape | `postgresql://...` URL OR raw Npgsql connection string | unparseable | process exits with error before HTTP server binds |
@@ -0,0 +1,228 @@
# Traceability Matrix
> **Status**: produced by autodev `/test-spec` Phase 2 (2026-05-14).
> **Naming**: post-rename target. Tests written for the post-rename API surface — RED-status until B5B8 land. The traceability matrix below treats the documented spec as the source of truth.
## Acceptance Criteria Coverage
### AC-1 — Vehicle CRUD
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-1.1 | Create vehicle | FT-P-01 | Covered |
| AC-1.2 | Default-clear on create/update/setDefault | FT-P-02, FT-P-03 | Covered |
| AC-1.3 | "Exactly one default" stricter than spec (B12 pending) | covered indirectly via FT-P-02, FT-P-03 (assertions on `count == 1`) | Covered (carry-forward) |
| AC-1.4 | Default-clear NOT transaction-wrapped → race | NFT-RES-08 | Covered (probabilistic) |
| AC-1.5 | GET /vehicles is plain array (NO pagination) | FT-P-04 | Covered |
| AC-1.6 | Filter case-sensitive on `name`, exact on `isDefault` | FT-P-05, FT-N-01 | Covered |
| AC-1.7 | GET /vehicles/{id} 404 | FT-N-02 | Covered |
| AC-1.8 | DELETE /vehicles/{id} 409 if referenced | FT-N-03 | Covered |
| AC-1.9 | All `/vehicles/*` require `Policy="FL"` | NFT-SEC-01, NFT-SEC-05, NFT-SEC-06 | Covered |
### AC-2 — Mission CRUD
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-2.1 | Create mission, default `CreatedDate = UtcNow` | FT-P-07 | Covered |
| AC-2.2 | Non-existent VehicleId → 400 (today; spec wants 404) | FT-N-04 | Covered (carry-forward) |
| AC-2.3 | GET /missions paginated `PaginatedResponse<Mission>` | FT-P-08, FT-P-09, FT-P-10, NFT-PERF-04 | Covered |
| AC-2.4 | GET /missions/{id} 404 | FT-N-05 | Covered |
| AC-2.5 | PUT partial update (Name update only) | FT-P-11 | Covered |
| AC-2.6 | LinqToDB does NOT eager-load `[Association]` | covered indirectly via FT-P-07/FT-P-11 (body shape assertion checks `Vehicle == null`, `Waypoints == null/[]`) | Covered |
| AC-2.7 | All `/missions/*` require `Policy="FL"` | NFT-SEC-01 | Covered |
| AC-2.8 | TOCTOU on FK → 500 | NOT directly covered as a separate test (deterministic reproduction is hard); falls under NFT-RES-08-style probabilistic family | NOT COVERED — see Uncovered Items §1 |
### AC-3 — Mission cascade delete F3 (most critical)
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-3.1 | Cascade walks `map_objects → detection → annotations → media → waypoints → missions` | FT-P-12 | Covered |
| AC-3.2 | Mission missing → 404 BEFORE any cascade DELETE | FT-N-06 | Covered |
| AC-3.3 | Cascade NOT transaction-wrapped → orphans | NFT-RES-01, NFT-RES-02 | Covered |
| AC-3.4 | `relation does not exist` → 500 + log | NFT-RES-01 (uses `media` drop) | Covered |
| AC-3.5 | After B7 cascade does NOT touch `orthophotos` / `gps_corrections` | covered via NFT-RES-04 (post-B9 build asserts tables absent); cascade does not reference them by construction (verified by code-level absence at Step 8) | Partially covered |
| AC-3.6 | <50ms typical (P50) | NFT-PERF-01 | Covered |
| AC-3.7 | autopilot race after step 1 → orphan | NFT-RES-08-style (orphan race, on `map_objects` insert) — design spec'd; probabilistic implementation deferred | NOT COVERED — see Uncovered Items §2 |
### AC-4 — Waypoint CRUD F4
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-4.1 | Routes nested under `/missions/{id}/waypoints[/{wpId}]` | FT-P-13, FT-P-14, FT-P-15, FT-P-18, FT-N-07 (every endpoint exercised) | Covered |
| AC-4.2 | Parent missing → 404 | FT-N-07 | Covered |
| AC-4.3 | GET unpaginated, ordered by `OrderNum` ASC | FT-P-13 | Covered |
| AC-4.4 | PUT is full overwrite | FT-P-15 | Covered |
| AC-4.5 | Scoped cascade (detection → annotations → media → waypoints) | FT-P-18 | Covered |
| AC-4.6 | Same NO-transaction caveat | NFT-RES-02 | Covered |
| AC-4.7 | Require `Policy="FL"` | NFT-SEC-01, NFT-SEC-05 | Covered |
### AC-5 — JWT validation
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-5.1 | HS256 + `SymmetricSecurityKey(UTF-8(JWT_SECRET))` | covered indirectly via NFT-SEC-02 (different secret rejected) and NFT-SEC-03 (correct secret accepted) | Covered |
| AC-5.2 | `ValidateLifetime=true`, `ClockSkew=1min` | NFT-SEC-03 | Covered |
| AC-5.3 | `ValidateIssuer=false`, `ValidateAudience=false` (today) | NFT-SEC-04 | Covered (locks today's behavior) |
| AC-5.4 | Missing header → 401 | NFT-SEC-01 | Covered |
| AC-5.5 | Invalid signature → 401 | NFT-SEC-02 | Covered |
| AC-5.6 | Expired token (outside skew) → 401 | NFT-SEC-03 | Covered |
| AC-5.7 | Old `JWT_SECRET` after rotation → 401 | NFT-RES-07 | Covered |
| AC-5.8 | Missing `permissions=FL` claim → 403 | NFT-SEC-05 | Covered |
| AC-5.9 | Local validator never calls `admin` | NOT directly observable from outside the process; covered indirectly by `admin` not running in the test env (NFT-SEC-* still pass) | Partially covered |
### AC-6 — Startup + migration
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-6.1 | DATABASE_URL URL form converted via `ConvertPostgresUrl` | covered indirectly via every test that depends on a working DB connection (compose env uses URL form) | Covered |
| AC-6.2 | DATABASE_URL raw form accepted | NOT directly covered — the test environment uses URL form; can be added by an extra startup scenario with the raw form | NOT COVERED — see Uncovered Items §3 |
| AC-6.3 | Migrator runs ONCE at startup, inside scope | NFT-RES-03 (idempotency assertion implies single-run + safe-restart) | Partially covered |
| AC-6.4 | 4 owned tables + 3 indexes created | NFT-RES-03 (asserts schema via `\d+` after first start) | Covered |
| AC-6.5 | Post-B9 one-shot legacy `DROP TABLE IF EXISTS` | NFT-RES-04 | Covered |
| AC-6.6 | Migrator idempotent | NFT-RES-03 | Covered |
| AC-6.7 | DB unreachable → process exits non-zero | NFT-RES-05 | Covered |
| AC-6.8 | DB missing (3D000) → process exits | NFT-RES-06 | Covered |
| AC-6.9 | `ErrorHandlingMiddleware` registered FIRST | covered indirectly via FT-N-08 + NFT-SEC-08 (any unhandled exception produces the documented envelope) | Covered |
| AC-6.10 | Listens on port 8080; edge maps host `5002:8080` | covered by every test that connects to port 5002→8080 | Covered |
### AC-7 — Health probe
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-7.1 | `GET /health` anonymous | FT-P-16, NFT-SEC-07 | Covered |
| AC-7.2 | `200 { "status": "healthy" }` | FT-P-16, FT-P-17 | Covered |
| AC-7.3 | <10ms typical | NFT-PERF-03 | Covered |
| AC-7.4 | If pipeline down, TCP connect fails (Watchtower restarts) | container-lifecycle behavior outside the service; out-of-scope at the service test level | Out of scope — see Uncovered Items §4 |
### AC-8 — Wire shape
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-8.1 | Entity bodies PascalCase | FT-P-01, FT-P-04 (key-set assertion) | Covered |
| AC-8.2 | Error envelope camelCase by accidental match | FT-N-02, FT-N-05 (key-set assertion includes `statusCode`, `message`) | Covered |
| AC-8.3 | Envelope MUST NOT include `errors` field | FT-N-02 (key-set excludes `errors`), FT-N-05, NFT-SEC-08 | Covered |
| AC-8.4 | KeyNotFoundException → 404 | FT-N-02, FT-N-05, FT-N-07 | Covered |
| AC-8.5 | ArgumentException → 400, InvalidOperationException → 409 | FT-N-04 (400), FT-N-03 (409) | Covered |
| AC-8.6 | 500 body redacted, stack only in log | FT-N-08, NFT-SEC-08 | Covered |
| AC-8.7 | `PaginatedResponse<T>` PascalCase keys | FT-P-08 (key-set assertion) | Covered |
### AC-9 — Authorization
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-9.1 | Policy `"FL"` registered, satisfied by `permissions == "FL"` | every protected-endpoint test | Covered |
| AC-9.2 | Hardcoded string mismatch ("fl", "FLight") → 403 | NFT-SEC-06 | Covered |
| AC-9.3 | Policy NAME `"FL"` retains legacy wording (deferred) | not testable at runtime — documentation-only | Documentation only |
| AC-9.4 | No per-method authz beyond `[Authorize(Policy="FL")]` | covered by NFT-SEC-01 + NFT-SEC-07 (every endpoint gets the same gate; health is the only exception) | Covered |
### AC-10 — Operational invariants (API-observable subset)
| AC ID | Acceptance Criterion (short) | Test IDs | Coverage |
|-------|------------------------------|----------|----------|
| AC-10.1 | One container per device | container-orchestration constraint, not API-observable; tracked under H1 + H3 | Out of scope (suite-level) |
| AC-10.2 | RTO ≈ container restart, RPO = device-local backup | suite-level operational concern | Out of scope |
| AC-10.3 | Unhandled 500 logged with stack trace via `LogError` | FT-N-08, NFT-SEC-08 | Covered |
| AC-10.4 | No correlation id, no per-user audit | absence-of-feature; NOT directly testable; documented carry-forward | Documentation only |
| AC-10.5 | B9 DROP block ordered AFTER `gps-denied` migrated | suite-level deploy ordering, NOT enforced by this service | Out of scope (suite-level) |
| AC-10.6 | Cross-service cascade requires sibling tables present | NFT-RES-01 covers the failure mode (table dropped) — passes when failure produces 500 + partial deletes | Covered |
## Restrictions Coverage
### Hardware (H1H6)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| H1 | Edge-device, one container per device | NFT-RES-LIM-01 through NFT-RES-LIM-04 (resource budget aligned with device assumption) | Indirectly covered |
| H2 | Multi-arch (ARM64 + AMD64) | NOT testable at the test-spec level — covered by suite-level CI matrix (`.woodpecker/build-arm.yml` + future `build-amd.yml`) | Out of scope |
| H3 | Vertical scale only | implicit in test environment (single `missions` container) | Implicitly covered |
| H4 | No managed cloud | architectural constraint; not testable | Documentation only |
| H5 | Watchtower + flight-gate | suite-level orchestration | Out of scope |
| H6 | No container-internal resource limits | NFT-RES-LIM-0104 (observe baseline so suite-level cgroups can be sized correctly) | Covered |
### Software (S1S15)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| S1 | C# / .NET 10 | implicit in test environment | Implicitly covered |
| S2 | ASP.NET Core | implicit | Implicitly covered |
| S3S5 | Library versions | csproj / lockfile concern; NOT a behavioral test | Out of scope (build-time check) |
| S6 | Swagger NOT gated on `IsDevelopment()` | NOT directly tested; could add a single `GET /swagger` test that asserts 200 in production-like env. Carry-forward divergence | NOT COVERED — see Uncovered Items §5 |
| S7 | PostgreSQL only | implicit | Implicitly covered |
| S8 | One csproj, one root namespace | csproj structure; NOT a behavioral test | Out of scope (code organization) |
| S9 | No `src/` directory | repo layout; NOT a behavioral test | Out of scope |
| S10 | Layer-organized | code organization; NOT a behavioral test | Out of scope |
| S11 | No automated tests today | this entire spec converts S11 from a constraint into a goal | Resolved by this spec |
| S12 | No migration tool | NFT-RES-03 + NFT-RES-04 (idempotency observed) | Covered |
| S13 | No in-process MQ / event bus | architectural constraint | Out of scope (architecture) |
| S14 | Owned + borrowed tables | covered by FT-P-12, FT-P-18 (cascade walks both owned and borrowed) | Covered |
| S15 | `gps-denied` decoupled | covered indirectly by NFT-RES-04 (legacy tables absent post-B9) + AC-3.5 absence of cascade reference | Covered |
### Environment (E1E10)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| E1 | Two required env vars | implicit; NFT-RES-05 (DB unreachable) + NFT-SEC-* (JWT_SECRET behavior) | Covered |
| E2 | DATABASE_URL accepts URL or raw form | URL form covered via NFT-RES-05 path; raw form NOT covered | NOT COVERED — see Uncovered Items §3 |
| E3 | Hardcoded dev fallbacks NOT gated on IsDevelopment() | a startup test with NO env vars set could verify fallback boot — security risk gate; carry-forward | NOT COVERED — see Uncovered Items §6 |
| E4 | JWT_SECRET shared across services | suite-level concern | Out of scope |
| E5 | Container EXPOSE 8080; edge maps 5002:8080 | implicit | Implicitly covered |
| E6 | Image tag post-B10 | build-time concern, not behavior | Out of scope |
| E7 | Entrypoint post-B5 | build-time concern | Out of scope |
| E8 | No appsettings env-specific overrides | code organization; NOT a behavioral test | Out of scope |
| E9 | CORS `AllowAnyOrigin/Method/Header` | could add a single CORS preflight test that asserts the documented permissive behavior | NOT COVERED — see Uncovered Items §7 |
| E10 | TLS termination is suite reverse proxy | suite-level concern | Out of scope |
### Operational (O1O10)
| Restriction ID | Restriction (short) | Test IDs | Coverage |
|---------------|---------------------|----------|----------|
| O1 | Migrator at every start, idempotent | NFT-RES-03, NFT-RES-04 | Covered |
| O2 | flight-gate prevents restart mid-mission | suite-level orchestration | Out of scope |
| O3 | No version table | covered indirectly by NFT-RES-03 (no version-table query observed) | Implicitly covered |
| O4 | Single Woodpecker job, no test/security stage | this spec adds a test stage as a follow-up artifact | Resolved by this spec |
| O5 | No structured logging | absence-of-feature; NOT testable directly | Documentation only |
| O6 | No correlation id, no audit | absence-of-feature | Documentation only |
| O7 | Health is process-liveness only | FT-P-17 (PG stopped, health still 200) | Covered |
| O8 | Cascade NOT transaction-wrapped | NFT-RES-01, NFT-RES-02 | Covered |
| O9 | Sibling table absent → cascade fails on `relation does not exist` | NFT-RES-01 (uses `media` drop) | Covered |
| O10 | One-instance-per-device → no cluster awareness | architectural constraint | Documentation only |
## Coverage Summary
| Category | Total Items | Covered | Partially / Implicit | Not Covered | Out of Scope / Doc-only | Coverage % (Covered + Partial of in-scope) |
|----------|-----------|---------|--------------------|-------------|------------------------|-------------------------------------------|
| AC-1 Vehicle CRUD | 9 | 8 | 1 (carry-forward) | 0 | 0 | 100% |
| AC-2 Mission CRUD | 8 | 7 | 0 | 1 (AC-2.8 TOCTOU) | 0 | 87% |
| AC-3 Cascade F3 | 7 | 5 | 1 | 1 (AC-3.7 race) | 0 | 86% |
| AC-4 Waypoint CRUD F4 | 7 | 7 | 0 | 0 | 0 | 100% |
| AC-5 JWT | 9 | 8 | 1 | 0 | 0 | 100% |
| AC-6 Startup + migration | 10 | 8 | 1 | 1 (AC-6.2 raw conn) | 0 | 90% |
| AC-7 Health | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
| AC-8 Wire shape | 7 | 7 | 0 | 0 | 0 | 100% |
| AC-9 Authz | 4 | 3 | 0 | 0 | 1 | 100% (in-scope) |
| AC-10 Operational | 6 | 1 | 0 | 0 | 5 | 100% (in-scope) |
| Restrictions H | 6 | 1 | 2 | 0 | 3 | 100% (in-scope) |
| Restrictions S | 15 | 4 | 2 | 0 | 9 | 100% (in-scope) |
| Restrictions E | 10 | 1 | 1 | 3 (E2, E3, E9) | 5 | 60% (in-scope) |
| Restrictions O | 10 | 4 | 2 | 0 | 4 | 100% (in-scope) |
| **Total** | 112 | 67 | 11 | 6 | 28 | **93%** in-scope |
## Uncovered Items Analysis
| # | Item | Reason Not Covered | Risk | Mitigation |
|---|------|-------------------|------|-----------|
| 1 | AC-2.8 — TOCTOU on FK between existence check and insert | Deterministic reproduction requires controllable concurrency primitive that doesn't exist today (instrumented test build with `pg_advisory_lock`) | Low — failure mode is well-documented and produces a 500 (loud failure, not silent corruption); occurs only when admin races with create | Add probabilistic test (similar to NFT-RES-08) under a follow-up ticket. Document as known carry-forward. |
| 2 | AC-3.7 — autopilot orphan race on `map_objects` insert after step-1 read | Same as #1 — needs controllable concurrency | Low — leaves at most one orphan row per race; cleanup on next mission delete or via manual sweep | Same mitigation as #1; add to follow-up. |
| 3 | AC-6.2 / E2 — `DATABASE_URL` raw form path | Test env uses URL form; raw form is the alternate adapter branch | Low — branch is small, well-localised in `ConvertPostgresUrl` | Add a single startup scenario with raw form. Single-line config change in test compose. |
| 4 | AC-7.4 — TCP connect fails on container down (Watchtower restarts) | Container lifecycle outside service surface | None at service level — testable at suite e2e level | Cover at suite e2e (`monorepo-e2e` skill scope) |
| 5 | S6 — Swagger NOT gated on `IsDevelopment()` | Carry-forward security finding; not part of AC | Medium — production deploy with Swagger exposed | Add a single test `GET /swagger/index.html` returns 200 in test env, with explicit comment that this LOCKS the carry-forward divergence (will fail when remediated). Suggest as follow-up. |
| 6 | E3 — Hardcoded dev fallbacks NOT gated | Carry-forward security finding | Medium — production deploy without env vars boots with well-known secret | Add a startup test with NO env vars set, assert `JWT_SECRET` claim ladder still works (locks the divergence). Suggest as follow-up. |
| 7 | E9 — CORS `AllowAnyOrigin/Method/Header` | Carry-forward; assumed safe behind reverse proxy | Low — assumed deployment topology mitigates | Add CORS preflight test that locks current behavior. Suggest as follow-up. |
**Recommendation**: items 1, 2 are deterministic-test improvements to land alongside a future `transaction-wrap` refactor (closes the carry-forward at the same time as the test improvement). Items 3, 5, 6, 7 are 1-test additions each — add them in Step 5 (Decompose Tests) under a "blackbox-lock-carry-forward" task.
## Phase 3 Coverage Gate
**Threshold**: ≥ 75% (per `cursor-meta.mdc` Quality Thresholds + `phases/03-data-validation-gate.md`).
**Achieved**: 93% in-scope.
**Verdict**: **PASS** — Phase 3 gate cleared on first iteration. The 6 uncovered items above are all low-medium risk with documented mitigations.
+17
View File
@@ -0,0 +1,17 @@
# Autodev State
## Current Step
flow: existing-code
step: 3
name: Test Spec
status: in_progress
sub_step:
phase: 2
name: test-scenario-specification
detail: ""
retry_count: 0
cycle: 1
tracker: jira
## Rename tracking (Jira AZ-EPIC + child stories B1B12)
See `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` for the leftover entry; it is intentionally retained until B4B12 ship.
@@ -0,0 +1,87 @@
# Leftover: rename flights -> missions (and related code/DB/repo work)
**Created**: 2026-05-14T01:00:00Z
**Plan**: `.cursor/plans/rename_flights_to_missions_65322306.plan.md`
**Tracker**: Jira project `AZ`. One Epic + 12 child Stories/Tasks.
## Why this is a leftover
Phase A (documentation) is complete in this turn — both local `_docs/02_document/` and the parent suite `../_docs/` reflect the post-rename, post-GPS-Denied-removal target. Phase B (Jira tickets) was attempted at the end of this turn; the result of that attempt is recorded in the "Jira ticket creation status" section below.
The rest of the work — code rename, DB migration, HTTP route rename, repo rename, consumer updates, Dockerfile / image / compose updates, default-vehicle-rule decision — lives entirely in the Jira children listed below. None of it has been executed in this workspace yet (and per the plan, will not be — only the tickets are created here; the implementation lands in subsequent /autodev cycles or directly off Jira).
## What was done
- Phase A1: rewrote 18 files under `_docs/02_document/` (modules / components / diagrams / layout / discovery), deleted the `03_gps_denied` component and the `01_aircraft_catalog` directory, created `01_vehicle_catalog` and renamed module/controller files. Each file carries an explicit "forward-looking" note so readers understand the doc IS the spec for B5/B6/B7/B8.
- Phase A2: rewrote 8 files under `../_docs/` (renamed `02_flights.md` -> `02_missions.md`, updated top-level architecture, DB schema, repo-config, GPS-Denied clarification, roles/permissions, README index, glossary, dataset-explorer, settings, autopilot-design, system-design-clarifications, admin, annotations). The `_repo-config.yaml` `confirmed_by_user` flag was flipped to `false` since the components.flights -> components.missions rename is documentation-first.
- Local `README.md` rewritten from "Azaion.Flights .NET 8" stub to a forward-looking "Azaion.Missions .NET 10" overview that points at the new docs.
## What remains (Jira AZ-EPIC + B-tickets)
- **B1 — DOCS-LOCAL** (3 SP, Task) — done in this turn (Phase A1).
- **B2 — DOCS-SUITE-MISSIONS** (3 SP, Task) — done in this turn (Phase A2).
- **B3 — DOCS-SUITE-CROSS-REFS** (3 SP, Task) — done in this turn (Phase A2 cross-ref sweep).
- **B4 — REPO-RENAME** (3 SP, Task) — Gitea repo rename + suite `.gitmodules` update + `git mv flights missions`.
- **B5 — CODE-NAMESPACE** (3 SP, Story) — `Azaion.Flights.*` -> `Azaion.Missions.*`.
- **B6 — CODE-DOMAIN-RENAME** (5 SP, Story) — `Aircraft` -> `Vehicle`, `Flight` -> `Mission`, `AircraftType` -> `VehicleType { Plane, Copter, UGV, GuidedMissile }`.
- **B7 — CODE-GPS-DENIED-REMOVAL** (3 SP, Story) — delete `Database/Entities/Orthophoto.cs`, `Database/Entities/GpsCorrection.cs`, the `"GPS"` policy, the cascade branches, and the migrator entries.
- **B8 — CODE-HTTP-ROUTES** (3 SP, Story) — `[Route("flights")]` -> `[Route("missions")]`, `[Route("aircrafts")]` -> `[Route("vehicles")]`.
- **B9 — CODE-DB-MIGRATION** (5 SP, Story) — `ALTER TABLE` rename + `DROP TABLE IF EXISTS orthophotos / gps_corrections`.
- **B10 — CODE-INFRASTRUCTURE** (2 SP, Task) — Dockerfile entrypoint + Woodpecker image tag + suite compose service block.
- **B11 — CONSUMER-UPDATES** (5 SP, Story) — autopilot + ui + any other consumer of `/flights/` or `/aircrafts/`.
- **B12 — DECISION-DEFAULT-VEHICLE-RULE** (2 SP, Task) — resolve the spec-vs-code "exactly one default vehicle" divergence (lift into spec + transaction-wrap, OR drop from code).
## Jira ticket creation status
**status: tickets-created + local-task-files-written** (2026-05-14)
Local task files were created under the project convention `<repo>/_docs/tasks/{todo,done}/AZ-<n>_<slug>.md` so the existing habit of mirroring tracker tickets as reviewable markdown is preserved. Per-repo work lives in the repo it touches; suite-level / multi-repo work stays in `azaion-suite/_docs/tasks/`.
In this `flights/` repo (`flights/_docs/tasks/`):
- `done/AZ-540_missions_rename_b1_local_docs.md` (B1, done in this turn — local docs)
- `done/AZ-542_missions_rename_b3_state_bookkeeping.md` (B3, done in this turn — local state files)
- `todo/AZ-544_missions_rename_b5_csproj_namespace.md` (B5 — .csproj + namespace)
- `todo/AZ-545_missions_rename_b6_domain_rename.md` (B6 — Aircraft -> Vehicle, Flight -> Mission)
- `todo/AZ-546_missions_rename_b7_drop_gps_denied.md` (B7 — drop GPS-Denied surface)
- `todo/AZ-547_missions_rename_b8_http_routes.md` (B8 — HTTP routes)
- `todo/AZ-548_missions_rename_b9_db_migration.md` (B9 — DB migration)
- `todo/AZ-551_missions_rename_b12_default_vehicle_rule.md` (B12 — default-vehicle rule decision)
In the suite repo (`azaion-suite/_docs/tasks/`):
- `todo/AZ-539_missions_rename_epic.md` (Epic — coordinates work across multiple repos)
- `done/AZ-541_missions_rename_b2_suite_docs.md` (B2, done in this turn — suite docs)
- `todo/AZ-543_missions_rename_b4_repo_rename.md` (B4 — Gitea repo rename + `.gitmodules` + suite checkout `git mv`)
- `todo/AZ-549_missions_rename_b10_image_tag.md` (B10 — image tag, Woodpecker pipeline + `_infra/` compose)
- `todo/AZ-550_missions_rename_b11_consumer_cutover.md` (B11 — autopilot + ui + suite e2e cutover)
| Plan ID | Jira | Type | SP | Status |
|---------|------|------|----|--------|
| Epic | [AZ-539](https://denyspopov.atlassian.net/browse/AZ-539) | Epic | -- | To Do |
| B1 | [AZ-540](https://denyspopov.atlassian.net/browse/AZ-540) | Task | 3 | **Done** (this turn) |
| B2 | [AZ-541](https://denyspopov.atlassian.net/browse/AZ-541) | Task | 3 | **Done** (this turn) |
| B3 | [AZ-542](https://denyspopov.atlassian.net/browse/AZ-542) | Task | 3 | **Done** (this turn) |
| B4 | [AZ-543](https://denyspopov.atlassian.net/browse/AZ-543) | Task | 3 | To Do |
| B5 | [AZ-544](https://denyspopov.atlassian.net/browse/AZ-544) | Story | 3 | To Do |
| B6 | [AZ-545](https://denyspopov.atlassian.net/browse/AZ-545) | Story | 5 | To Do |
| B7 | [AZ-546](https://denyspopov.atlassian.net/browse/AZ-546) | Story | 3 | To Do |
| B8 | [AZ-547](https://denyspopov.atlassian.net/browse/AZ-547) | Story | 3 | To Do |
| B9 | [AZ-548](https://denyspopov.atlassian.net/browse/AZ-548) | Story | 5 | To Do |
| B10 | [AZ-549](https://denyspopov.atlassian.net/browse/AZ-549) | Task | 2 | To Do |
| B11 | [AZ-550](https://denyspopov.atlassian.net/browse/AZ-550) | Story | 5 | To Do |
| B12 | [AZ-551](https://denyspopov.atlassian.net/browse/AZ-551) | Task | 2 | To Do |
Total: 1 Epic + 12 child tickets, 35 SP across remaining 9 tickets.
## Replay obligation
At the start of every `/autodev` invocation in this repo (and in `../suite/`), check this leftover:
1. All 13 tickets exist (above). No retry needed for ticket creation.
2. The leftover is NOT deleted yet — it stays as the index from doc state to Jira state until the per-component code work (B4-B11) lands. Once all child tickets reach Done, the autodev replay can mark `status: tickets-completed` and the leftover can be archived.
## Cross-cutting doc-only state
- `_docs/_autodev_state.md` was updated in this turn to reflect `sub_step.phase=rename-paused` so the previously in-progress "system-synthesis" work can resume after Phase B completes.
- `_docs/02_document/state.json` was updated to reflect the new component shape (7 -> 6 components, gps_denied removed, aircraft_catalog renamed to vehicle_catalog) and to record the rename event in `decomposition_revised`.
@@ -0,0 +1,34 @@
# [Missions rename B1] Rewrite local _docs/02_document/ for missions/vehicle
**Task**: AZ-540_missions_rename_b1_local_docs
**Name**: Rewrite local `flights/_docs/02_document/` for missions/vehicle terminology, drop GPS-Denied, clarify identity
**Description**: Update every module / component / diagram / layout file under `flights/_docs/02_document/` to reflect the post-rename target (`Aircraft` -> `Vehicle`, `Flight` -> `Mission`, `VehicleType { Plane, Copter, UGV, GuidedMissile }`, GPS-Denied removed, identity model framed as suite-wide JWT). Each forward-looking file carries an explicit "NOTE (forward-looking)" header pointing at the relevant code-rename ticket so readers know the doc is the spec, not the current ground truth.
**Complexity**: 3 points
**Dependencies**: None (doc-only, doesn't depend on any other ticket)
**Component**: `flights/_docs/02_document/` (modules, components, diagrams, layout, discovery)
**Tracker**: AZ-540
**Epic**: AZ-539
**Status**: Done (2026-05-14)
## Outcome (DONE)
- 12 module docs rewritten under `flights/_docs/02_document/modules/`:
- `entities.md`, `dtos.md`, `enums.md`, `database.md`, `auth.md`, `middleware.md`, `program.md`, `service_waypoint.md` updated in place.
- `service_aircraft.md` deleted, `service_vehicle.md` created.
- `service_flight.md` deleted, `service_mission.md` created.
- `controller_aircrafts.md` deleted, `controller_vehicles.md` created.
- `controller_flights.md` deleted, `controller_missions.md` created.
- 6 component docs under `flights/_docs/02_document/components/`:
- `01_aircraft_catalog/` directory deleted; `01_vehicle_catalog/` directory created with rewritten `description.md`.
- `02_mission_planning/description.md` rewritten (terminology + cascade shrink).
- `03_gps_denied/` directory deleted entirely.
- `04_persistence/description.md` rewritten (drops orthophotos + gps_corrections from owned tables).
- `05_identity/description.md` rewritten (drops `"GPS"` policy, adds suite-wide JWT subsection).
- `06_http_conventions/description.md` and `07_host/description.md` updated for terminology.
- `module-layout.md`, `00_discovery.md`, and `diagrams/components.md` rewritten end-to-end.
## Acceptance criteria (verified)
- `rg -i 'aircraft|orthophoto|gps[-_.]?correction' flights/_docs/02_document/` returns ONLY hits inside deliberate "NOTE (forward-looking)" / divergence-table / Jira-link strings — verified post-edit.
- Component count is 6 (was 7 with the gps_denied placeholder); `03_gps_denied/` directory does not exist; `01_vehicle_catalog/` directory exists.
- Every component / module file says explicitly which Jira child ticket the rename / removal is tracked under.
@@ -0,0 +1,29 @@
# [Missions rename B3] Update autodev state + create rename leftover entry
**Task**: AZ-542_missions_rename_b3_state_bookkeeping
**Name**: Update local autodev state + write `_process_leftovers/` rename-tracking entry
**Description**: Record the doc-only rename and the pending code/DB/repo work in the local autodev state machine + the leftovers replay log so that the next `/autodev` invocation knows exactly where to resume and which child tickets remain.
**Complexity**: 3 points
**Dependencies**: B1 + B2 must land first so the state file can describe a coherent post-doc world
**Component**: `flights/_docs/_autodev_state.md`, `flights/_docs/02_document/state.json`, `flights/_docs/_process_leftovers/`
**Tracker**: AZ-542
**Epic**: AZ-539
**Status**: Done (2026-05-14)
## Outcome (DONE)
- `flights/_docs/_autodev_state.md` updated:
- `sub_step.phase: rename-paused`, `sub_step.name: missions-rename-doc-rewrite`, descriptive `detail` summarising A1+A2 done and where to resume after B-tickets.
- "Pending re-confirmation" + "Rename tracking" sections added so the resume path is explicit.
- `flights/_docs/02_document/state.json` updated:
- `modules_documented` reflects new module names (`service_vehicle`, `service_mission`, `controller_vehicles`, `controller_missions`).
- `components_written` reflects the 6 surviving components (no `03_gps_denied` placeholder).
- `decomposition_revised[]` extended with a 2026-05-14 entry that records the user-requested refactor, the old/new component lists, the Jira Epic + child IDs, and the doc-only-vs-code-pending status.
- `rename_status: "doc-only; code/DB/HTTP/repo refactor pending B-tickets"` added.
- `flights/_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` created with the full Jira ticket table, status, replay obligation, and links back to the plan and to this Epic.
## Acceptance criteria (verified)
- The next `/autodev` resume reads `_autodev_state.md` and finds an explicit pause marker pointing at the Epic, not a half-completed doc step.
- `_process_leftovers/` contains the rename entry and the file is dated 2026-05-14.
- `state.json` is internally consistent: every entry in `components_written` corresponds to an existing directory under `_docs/02_document/components/`, and every entry in `modules_documented` corresponds to an existing file under `_docs/02_document/modules/`.
@@ -0,0 +1,54 @@
# [Missions rename B5] Rename .NET project + namespace Azaion.Flights -> Azaion.Missions
**Task**: AZ-544_missions_rename_b5_csproj_namespace
**Name**: Rename `Azaion.Flights.csproj` to `Azaion.Missions.csproj`; rename root namespace `Azaion.Flights` -> `Azaion.Missions`
**Description**: Single mechanical refactor inside the (now `missions/`) repo: rename the .csproj file, the root namespace, and every `using Azaion.Flights...;` import. Pure code-rename — no behavioural change, no DB change, no HTTP route change. Splits cleanly from the domain rename in B6 so reviewers can verify B5 in isolation as "just a global rename" before B6 starts touching `Aircraft` -> `Vehicle`.
**Complexity**: 3 points
**Dependencies**: AZ-543 (B4) — easier to do this on the renamed disk path so the `.csproj` filename and the directory name agree
**Component**: `missions/Azaion.Flights.csproj`, `missions/Program.cs`, every C# file with `namespace Azaion.Flights...` or `using Azaion.Flights...`, `missions/.cursor/skills` if any reference the namespace, the suite-level docker-compose `image:` field stays as-is until B10
**Tracker**: AZ-544
**Epic**: AZ-539
## Outcome
- File renamed: `Azaion.Flights.csproj` -> `Azaion.Missions.csproj`. The `<AssemblyName>` and `<RootNamespace>` properties (if present) are updated accordingly; if absent, the implicit assembly name follows the file name.
- Every `namespace Azaion.Flights[...]` declaration becomes `namespace Azaion.Missions[...]`.
- Every `using Azaion.Flights[...]` import becomes `using Azaion.Missions[...]`.
- `dotnet build` is green at the same warning-level baseline as before. `dotnet test` is green.
- Per-component sub-namespaces (e.g. `Azaion.Flights.Domain`, `Azaion.Flights.Auth`) are preserved with the new prefix — no collateral re-org.
- README.md "NOTE (forward-looking)" tag added by B1 is amended once: drop the line that says "the .NET project file is still `Azaion.Flights.csproj`" because it isn't anymore.
## Scope
### Included
- `.csproj` rename + property updates.
- Global namespace rename across `Controllers/`, `Services/`, `Domain/`, `Auth/`, `Middleware/`, `DataLayer/` (or whatever the actual current directories are at HEAD when B5 starts).
- Test project (if any) — its references to the production namespace.
- IDE solution file (`.sln`) project entries.
### Out of scope (explicit)
- Renaming `Aircraft` -> `Vehicle` (B6).
- Renaming `Flight` -> `Mission` (B6).
- Removing GPS-Denied (B7).
- Renaming HTTP routes (B8).
- Renaming the Docker image tag (B10).
- DB schema changes (B9).
## Acceptance criteria
- `rg -F 'Azaion.Flights' missions/` returns ZERO hits inside `.cs` and `.csproj` files. Hits inside the README "NOTE (forward-looking)" historical-context paragraph are acceptable.
- `dotnet build missions/Azaion.Missions.csproj` is green.
- All existing tests continue to pass at the same baseline as the pre-rename `dev` HEAD.
- Diff is review-friendly: file rename + a wide but mechanical edit set. No substantive code change should be in the same commit; if the rename surfaces a separate bug, file a follow-up rather than fix it inline.
## Risks & Mitigation
**Risk 1: Two-rename ambiguity**
- *Risk*: A reviewer can't tell whether a removed `using Azaion.Flights.Domain;` is the namespace rename (B5) or the domain rename (B6).
- *Mitigation*: B5 ships before B6 and is committed as a single mechanical-rename commit. B6 commits then show only the domain rename on top.
**Risk 2: Stale tooling caches**
- *Risk*: A developer or CI cache still references the old assembly path and breaks after pull.
- *Mitigation*: `bin/` and `obj/` are in `.gitignore`. CI builds clean. Document the expected `dotnet clean` in the commit message.
@@ -0,0 +1,62 @@
# [Missions rename B6] Rename Aircraft -> Vehicle, Flight -> Mission; expand VehicleType enum
**Task**: AZ-545_missions_rename_b6_domain_rename
**Name**: Rename domain types `Aircraft` -> `Vehicle` and `Flight` -> `Mission`; expand `AircraftType` -> `VehicleType { Plane, Copter, UGV, GuidedMissile }`
**Description**: The substantive in-code rename. Touches every entity, DTO, service, controller, validation, error-mapping, claim check, table mapping (linq2db `Table("flights")` -> `Table("missions")`, etc.), and every internal foreign key (`AircraftId` -> `VehicleId`, `FlightId` -> `MissionId`). Adds two new variants to the existing 0/1 enum (`UGV = 2`, `GuidedMissile = 3`); existing rows are unaffected because the on-disk values 0/1 don't change. Decision on `FuelType` for `GuidedMissile` (extend enum, mark nullable, or defer) is made inside this ticket and recorded in `_docs/02_document/components/01_vehicle_catalog/description.md`.
**Complexity**: 5 points
**Dependencies**: AZ-544 (B5) — namespace rename lands first so this commit shows only the domain rename
**Component**: `missions/Domain/`, `missions/Services/`, `missions/Controllers/`, `missions/Auth/` (claim names stay; only domain types rename), `missions/DataLayer/` (linq2db `[Table]` attribute strings AND foreign-key column names — but actual SQL migration is B9)
**Tracker**: AZ-545
**Epic**: AZ-539
## Problem
The fleet now includes UGVs (per `hardware/_standalone/target_acquisition/target_acquisition.md`) and GuidedMissile loitering munitions. Two existing types (`Plane`, `Copter`) of the `AircraftType` enum can't represent them. And the abstraction "Flight" doesn't read sensibly for a UGV recce or a missile loiter — operators need a vehicle-agnostic word: "mission". This ticket performs the rename across the in-code domain so the service can finally accept the wider fleet.
## Outcome
- `Aircraft` (entity, DTOs, table mapping) -> `Vehicle`. `AircraftId` -> `VehicleId` everywhere it appears as a foreign key, claim subject, or function parameter.
- `Flight` (entity, DTOs, table mapping) -> `Mission`. `FlightId` -> `MissionId` everywhere.
- `AircraftType { Plane = 0, Copter = 1 }` -> `VehicleType { Plane = 0, Copter = 1, UGV = 2, GuidedMissile = 3 }`. The numeric values are stable; existing rows remain valid.
- linq2db table mappings: `Table("aircrafts")` -> `Table("vehicles")`, `Table("flights")` -> `Table("missions")`, `Table("waypoints")` keeps its name but its FK column attribute switches from `flight_id` to `mission_id` IN THE C# MAPPING. The actual SQL `ALTER TABLE` happens in B9; until B9 ships, this mapping is wrong against the live DB and the staging environment in B11 is the first one where the renamed code talks to a renamed schema.
- `FuelType` for `GuidedMissile`: decision recorded in the component description doc. Default position: `FuelType` becomes nullable on `Vehicle`, and `GuidedMissile` rows persist with `null`. If a reviewer disagrees, file a follow-up ticket — do not block this Epic.
- "Exactly one default vehicle" rule: NOT changed in this ticket. B12 owns the decision.
## Scope
### Included
- All in-code domain renames listed above.
- Validation messages, exception messages, log lines that say "aircraft"/"flight" -> "vehicle"/"mission".
- Test fixtures (if the test project survives B5/B6 unchanged) — fixture builders rename their factory methods; fixture data updates `Plane`/`Copter` rows to also include a `UGV` row so tests cover the new variant.
- linq2db column-name attributes in the C# mapping layer — these reference the future column names. Until B9 lands, the mapping won't match the live DB and the renamed code can't run against the legacy schema.
### Out of scope (explicit)
- The SQL `ALTER TABLE` statements (B9).
- HTTP route renames (B8).
- The `"GPS"` policy and `Orthophoto` / `GpsCorrection` entities (B7).
- The Docker image tag (B10).
- Renaming the `FL` claim/policy string (out of the entire Epic — would invalidate every JWT in the fleet).
## Acceptance criteria
- `rg -i '\baircraft\b' missions/` (after B7) returns ZERO hits in production code; hits inside historical doc-context strings are acceptable.
- `rg -wi flight missions/` returns ZERO hits in production code (same caveat).
- `dotnet build` is green; tests are green where they don't depend on the live DB schema. Tests that DO talk to a real DB will fail until B9 — that's expected and is documented in B11's stage-deploy plan.
- The `VehicleType` enum has exactly 4 variants in the order specified (`Plane = 0, Copter = 1, UGV = 2, GuidedMissile = 3`).
- Component doc `01_vehicle_catalog/description.md` has the recorded `FuelType` decision committed in the same change-set.
## Risks & Mitigation
**Risk 1: Renamed code can't talk to legacy DB (intentional, but trips someone)**
- *Risk*: Developer pulls B6, runs `dotnet run` against a local DB seeded with the legacy schema, and gets cryptic linq2db errors.
- *Mitigation*: README amendment in B6 says explicitly: "the renamed code requires the migrated DB; until B9 lands, you must run B9's migration locally first OR run a pre-B6 build". Also recorded in the component description doc and in the rename leftover entry.
**Risk 2: `VehicleType` order shifts**
- *Risk*: Someone "fixes" the enum to alphabetical, shifting numeric values 0/1/2/3, and corrupts every persisted row.
- *Mitigation*: Inline comment on the enum: `// numeric values are persisted; do not reorder`. B6 reviewer must check this comment is present.
**Risk 3: `FuelType=null` for `GuidedMissile` breaks existing UI**
- *Risk*: UI assumes FuelType is non-null and renders an empty cell or crashes.
- *Mitigation*: UI cutover (B11) tests this scenario explicitly. If the current UI breaks, file a follow-up rather than block B6.
@@ -0,0 +1,66 @@
# [Missions rename B7] Remove GPS-Denied surface from this service (entities, policy, cascades)
**Task**: AZ-546_missions_rename_b7_drop_gps_denied
**Name**: Delete `Orthophoto` + `GpsCorrection` entities, the `"GPS"` policy, and every cascade-delete branch that touches `orthophotos` or `gps_corrections`
**Description**: Drop-side of the GPS-Denied move. Removes from this service every C# type, controller, service method, claim policy, and cascade branch that exists today only to serve orthophoto upload, live-GPS SSE, and GPS corrections. The land-side (a new `gps-denied-api` service or an extension of the existing Cython `gps-denied-*` services) is a separate Epic. The corresponding SQL `DROP TABLE` for `orthophotos` + `gps_corrections` is in B9, not here — this ticket leaves the tables in place so a rollback to pre-B7 code keeps working until B9 actually drops them.
**Complexity**: 3 points
**Dependencies**: AZ-541 (B2) — suite docs already declare GPS-Denied lives elsewhere; AZ-545 (B6) is recommended-not-required (rename first, then drop, so the diffs don't fight each other)
**Component**: `missions/Domain/` (Orthophoto, GpsCorrection), `missions/Controllers/` (orthophoto + live-gps + gps-correction endpoints), `missions/Services/` (corresponding service methods), `missions/Auth/` (the `"GPS"` policy), `missions/Services/MissionService` (cascade-delete branches)
**Tracker**: AZ-546
**Epic**: AZ-539
## Problem
Three feature areas live in the Missions service today that are conceptually owned by the GPS-Denied domain:
1. **Orthophoto upload + listing + delete** — the `Orthophoto` entity, its controller, the storage path resolution, the cascade-on-mission-delete branch.
2. **Live-GPS SSE**`/flights/{id}/live-gps` (becoming `/missions/{id}/live-gps` in B8 in parallel) is here, but the producer is the onboard `gps-denied-onboard` service. Hosting the SSE endpoint here couples Mission Planning to a streaming concern it doesn't own.
3. **GPS corrections** — the `GpsCorrection` entity, its CRUD endpoints, and the `"GPS"` claim policy that authorizes them. The corrections feed the visual-odometry pipeline; that's the GPS-Denied service's job.
Until those leave, this service has two unrelated permission boundaries (`FL`, `GPS`), two unrelated workflows, and two unrelated DB-table groups, which makes it harder to reason about and harder to own.
## Outcome
- `Orthophoto` + `GpsCorrection` entities deleted from `Domain/`. linq2db `[Table]` mappings deleted with them.
- Controllers and service methods that handle orthophoto upload, orthophoto listing, orthophoto delete, live-GPS SSE, and GPS-correction CRUD are all deleted.
- The `"GPS"` policy registration in `Program.cs` (or wherever auth policies are configured) is deleted.
- `MissionService.DeleteMission(...)` (post-B6) no longer cascades into `orthophotos` or `gps_corrections`. The cascade still covers `waypoints` (which is owned by this service).
- `_docs/02_document/components/05_identity/description.md` is updated to remove the `"GPS"` policy from the active list (the doc was already prepared in B1; this is the source-of-truth catch-up).
- The `orthophotos` + `gps_corrections` SQL tables remain in the DB until B9 drops them, so a hot rollback to pre-B7 binaries keeps working.
## Scope
### Included
- All deletions listed above.
- linq2db query helpers / extension methods that returned `IQueryable<Orthophoto>` or `IQueryable<GpsCorrection>` — deleted.
- Tests that covered the deleted endpoints — deleted with the production code in the same change-set. Do NOT silently leave them as `[Skip]`.
- README + module docs that still mention orthophoto / gps-correction endpoints — updated. (B1 already did the forward-looking version; B7 catches up the source-of-truth marker.)
### Out of scope (explicit)
- The actual SQL `DROP TABLE orthophotos`, `DROP TABLE gps_corrections` (B9).
- Building the new `gps-denied-api` service. The endpoints land somewhere — that "where" is a separate Epic.
- Touching the Cython `gps-denied-onboard` / `gps-denied-desktop` services that produce the data.
- Any change to onboard storage layout (this service did not own that storage; it only stored relative paths).
## Acceptance criteria
- `rg -i 'orthophoto|gps[-_.]?correction|"GPS"' missions/` (production code) returns ZERO hits. Hits in test fixtures that B7 itself deletes are acceptable up until the same commit lands.
- `Program.cs` (or equivalent) registers exactly one policy in the `FL` family: no `"GPS"`.
- `dotnet build` is green; tests are green (the deleted tests are gone, not skipped).
- A hot rollback to the pre-B7 image still passes the orthophoto-upload smoke test against the still-present DB tables (validates the rollback story before B9 drops the tables).
## Risks & Mitigation
**Risk 1: Operators are still using GPS-Denied endpoints in production**
- *Risk*: B7 ships, the new `gps-denied-api` doesn't exist yet, operators lose the workflow.
- *Mitigation*: Confirm with stakeholders BEFORE B7 ships that the GPS-Denied workflow is either not in active production use, or its new host is on the imminent roadmap. This confirmation is the gate for B7.
**Risk 2: Cascade delete now leaves orphan rows in `orthophotos` / `gps_corrections`**
- *Risk*: Until B9 drops the tables, deleting a mission that had orthophotos leaves orphaned rows pointing at a missing `mission_id`.
- *Mitigation*: Acceptable — rows are stranded but harmless because no code reads them anymore. B9's `DROP TABLE` cleans them out for good. Documented in B9.
**Risk 3: `"GPS"` policy was used by an endpoint we missed**
- *Risk*: A code path silently authorizes against `"GPS"` and returns 403 after B7 because the policy is gone.
- *Mitigation*: After deletion, `dotnet build` won't catch this (policies are string-named at runtime). Manual grep + a smoke test of the `FL`-only endpoints is part of B7 acceptance.
@@ -0,0 +1,50 @@
# [Missions rename B8] Rename HTTP routes /flights -> /missions and /aircrafts -> /vehicles
**Task**: AZ-547_missions_rename_b8_http_routes
**Name**: Rename public HTTP routes: `/flights/*` -> `/missions/*` and `/aircrafts/*` -> `/vehicles/*`
**Description**: Public-API rename. Touches every `[HttpGet("flights/...")]` / `[HttpPost("aircrafts/...")]` etc. Coordinated with the consumer cutovers (B11): renamed routes ship + consumers cut over to them in the same stage-deploy window. No legacy-route shim — the suite is internally consistent at HEAD, deploys are staged, and a rollback is a binary rollback (not a route shim). The route paths in OpenAPI / Swagger are regenerated.
**Complexity**: 3 points
**Dependencies**: AZ-545 (B6) — domain rename gives us the matching C# parameter names; AZ-546 (B7) — GPS-Denied endpoints already deleted, so this ticket only touches what's left
**Component**: `missions/Controllers/` (route attributes), OpenAPI spec generation pipeline (whatever the project uses today), Postman / curl examples in `_docs/02_document/components/06_http_conventions/description.md` and `azaion-suite/_docs/02_missions.md`
**Tracker**: AZ-547
**Epic**: AZ-539
## Outcome
- Every controller route attribute referencing `flights` becomes `missions`. Every route referencing `aircrafts` becomes `vehicles`.
- Path parameter names in routes match: `{flightId}` -> `{missionId}`, `{aircraftId}` -> `{vehicleId}` (consistency with B6).
- Swagger / OpenAPI doc regenerates cleanly with the new paths. The `azaion-suite/_docs/02_missions.md` spec was already written against the new paths — this ticket makes the live API match.
- Suite e2e harness, autopilot client, and UI client still target the old routes at HEAD of B8; the cutover is the next ticket (B9 schema → B10 image → B11 stage deploy + consumer cutover).
- No legacy `/flights/*` shim. We do NOT add a redirect / dual-mount. Rollback is a binary rollback to the pre-B8 image.
## Scope
### Included
- All `[Route]`, `[HttpGet]`, `[HttpPost]`, `[HttpPut]`, `[HttpPatch]`, `[HttpDelete]` attributes in the surviving controllers (post-B7).
- Route parameter naming consistency (`{flightId}` -> `{missionId}`, `{aircraftId}` -> `{vehicleId}`).
- Swagger / OpenAPI regeneration step in CI.
- Examples and route docs in `_docs/02_document/components/06_http_conventions/description.md` (already prepared in B1; B8 catches up the source-of-truth marker if any was deferred).
### Out of scope (explicit)
- Consumer-side cutover (`autopilot` + `ui` + suite e2e) — that's part of B11.
- Adding any kind of legacy-route compatibility layer.
- DB schema (B9). Image tag (B10).
## Acceptance criteria
- `rg -i '"\s*(/?flights|/?aircrafts)\b' missions/Controllers/` returns ZERO hits.
- Generated OpenAPI doc has zero `/flights` or `/aircrafts` paths.
- A consumer that targets `/flights/*` against the new image gets a clean 404 — no silent redirect, no soft-deprecation log line.
- The suite-level rename spec `azaion-suite/_docs/02_missions.md` matches the live OpenAPI exactly (paths, parameters, status codes).
## Risks & Mitigation
**Risk 1: Stale consumer cached the OpenAPI client**
- *Risk*: A consumer regenerated its client before B11 cutover and now talks to renamed routes against the legacy image (404), or kept the legacy client and talks to legacy routes against the renamed image (404).
- *Mitigation*: B11 sequencing is: deploy renamed image to stage -> regen + deploy consumers to stage -> green-light prod. No partial states.
**Risk 2: Reverse-proxy / API gateway has hardcoded route patterns**
- *Risk*: A `_infra/` reverse-proxy config matches `/flights/*` and routes it to the missions service; after B8, the gateway 404s before the request reaches the service.
- *Mitigation*: B8 includes a grep across `_infra/` for `/flights` / `/aircrafts` route patterns and updates them in the same change-set.
@@ -0,0 +1,64 @@
# [Missions rename B9] Database migration: rename tables/columns and drop GPS-Denied tables
**Task**: AZ-548_missions_rename_b9_db_migration
**Name**: SQL migration to rename `flights` -> `missions`, `aircrafts` -> `vehicles`, the FK columns `flight_id` -> `mission_id` and `aircraft_id` -> `vehicle_id`, and drop `orthophotos` + `gps_corrections`
**Description**: One forward-only migration script (per the suite's existing migration convention). Runs at startup of the renamed Missions container. Renames tables and columns to match the C# mapping that B6 set up; drops the two tables that B7 deleted from code. The migration is idempotent (safe to re-run on already-migrated DBs, used for "did this device migrate yet?" checks). On a fresh DB, the `init` SQL is also updated so it produces the post-migration schema directly without needing the rename step.
**Complexity**: 5 points
**Dependencies**: AZ-545 (B6) — C# mapping must already point at the new names so the renamed code can talk to the renamed DB; AZ-546 (B7) — cascade branches that touched `orthophotos` / `gps_corrections` must be deleted before this DROP can run safely
**Component**: `missions/DataLayer/Migrations/` (or whatever the project's actual migration directory is at HEAD), the `init` SQL seed for fresh-install devices
**Tracker**: AZ-548
**Epic**: AZ-539
## Outcome
- Forward-only migration script committed under the project's migration convention. Renames:
- `flights` -> `missions` (table)
- `aircrafts` -> `vehicles` (table)
- `flight_id` -> `mission_id` (FK column on `waypoints` and on every other dependent table this service owns)
- `aircraft_id` -> `vehicle_id` (FK column on `flights`/`missions`)
- Drops `orthophotos` and `gps_corrections`. Their data is not preserved — see Risks below.
- Indexes / constraints are renamed to match (Postgres auto-renames index objects when the table is renamed but constraint names tied to the old names are explicitly renamed).
- `init` SQL for fresh-install edge devices is regenerated so brand-new deployments skip the migration step entirely and start with the renamed schema.
- Migration is idempotent: a second run on an already-migrated DB is a no-op (uses `IF EXISTS` / `IF NOT EXISTS` guards).
## Scope
### Included
- The migration SQL file.
- Updated `init` SQL.
- Migration runner registration so it executes at container startup before the API begins serving requests.
- A short note in `_docs/02_document/components/04_persistence/description.md` listing the migration's filename + checksum so future readers can find the canonical record.
### Out of scope (explicit)
- Migrating data out of `orthophotos` / `gps_corrections` to the new `gps-denied-api` service. That's the new service's onboarding job, not a Missions ticket. (See Risk 1.)
- Cross-service migrations (annotations, detection) — their tables are unaffected by this rename. They referenced `flight_id` only via the live-loaded relationship in their own ORM mappings, which they will update independently.
- Rolling back from B9. There is no down-migration — the suite's migration convention is forward-only.
## Acceptance criteria
- A pre-B9 Postgres DB, after running the new migration, has tables `missions`, `vehicles`, `waypoints` with renamed FK columns; tables `orthophotos`, `gps_corrections` no longer exist.
- A fresh-install device using the updated `init` SQL produces the same schema directly.
- Re-running the migration on an already-migrated DB is a no-op (no errors, no schema changes).
- The renamed Missions container starts cleanly against both: (a) a freshly migrated DB, (b) a fresh-init DB, (c) an already-migrated DB.
- Documented in the rename leftover entry: which CI job runs the migration smoke test, and on which Postgres versions.
## Risks & Mitigation
**Risk 1: Operators expected orthophoto data to survive the move**
- *Risk*: Operators planned to keep historical orthophotos for a fielded device but B9 drops them.
- *Mitigation*: The drop is irreversible. Confirm with stakeholders BEFORE B9 ships. If preservation is required, the new `gps-denied-api` service must run a one-time data export pre-B9 and ingest post-B9. That coordination is OUT OF SCOPE for this ticket but its existence as a precondition is recorded here.
**Risk 2: Migration applied while a device has live in-flight mission**
- *Risk*: Watchtower respects the `flight-gate`, but a manual ops intervention could pull the renamed image while a mission is mid-flight; the container restart applies the migration, the renamed schema cuts over, the autopilot momentarily can't read the mission, the mission aborts.
- *Mitigation*: Document explicitly in B9 that this migration MUST be deployed via the normal Watchtower / `flight-gate` path, never via a manual intervention while a mission is active. Stage rollout (B11) verifies the gate logic.
**Risk 3: An old binary tries to read the renamed schema after a partial pull**
- *Risk*: Container image pulled, migration runs, but somehow the binary inside is still pre-B6 (broken pull, manual swap).
- *Mitigation*: The migration and the binary ship in the same image — they cannot diverge through the supported deployment path. A manual swap is out of contract; the operator on call would see linq2db `relation "flights" does not exist` immediately.
## Constraints
- Forward-only migrations (suite convention).
- The migration must succeed on the largest production DB in under 60 seconds (per the suite's edge-device migration budget). Table renames in Postgres are O(1) on metadata, so the budget is comfortable for this set of operations; the only concern would be the `DROP TABLE`s on a heavily-loaded `orthophotos` table — drop times are bounded by file deletion, which is fast on the edge devices' SSDs.
@@ -0,0 +1,69 @@
# [Missions rename B12] Resolve "exactly one default vehicle" spec-vs-code divergence
**Task**: AZ-551_missions_rename_b12_default_vehicle_rule
**Name**: Decide whether the "exactly one default vehicle" rule is enforced (spec it, transaction-wrap it) or dropped (remove the code), and ship that decision
**Description**: Today's source code on `dev` enforces "at most one default vehicle" inside `AircraftService.SetDefault(...)`: it clears `IsDefault` on every other row first, then sets the target row's `IsDefault = true`. The suite spec (`02_missions.md`) just toggles the flag and is silent about uniqueness. The code is stricter than spec AND is race-prone (two concurrent toggles can leave zero defaults or two defaults). This ticket picks one of two outcomes and ships it. Decision is a 2-SP code change either way, but the decision itself is what's meaningful.
**Complexity**: 2 points
**Dependencies**: AZ-545 (B6) — service is renamed `VehicleService.SetDefault(...)`; this ticket targets the post-rename name. Can land any time after B6.
**Component**: `missions/Services/VehicleService.cs`, `missions/DataLayer/` (if a partial unique index is added), `azaion-suite/_docs/02_missions.md` (spec catch-up if the rule stays)
**Tracker**: AZ-551
**Epic**: AZ-539
## Problem
`SetDefault(vehicleId)` today does:
```
update vehicles set is_default = false where is_default = true and id <> vehicleId;
update vehicles set is_default = true where id = vehicleId;
```
without a transaction. Two concurrent `SetDefault(A)` and `SetDefault(B)` calls can interleave such that:
- Both clears run, both sets run -> **two** defaults (A and B).
- Both clears run, only one set runs (the other crashed) -> **zero** defaults.
The spec says "the operator picks a default", silent on uniqueness. The UI relies on "the default" being singular; if it's zero or two, the UI shows ambiguous results. So the system today has an unwritten invariant the code tries to enforce racily.
## Outcome
Pick ONE of the two options and ship it.
### Option A (recommended): keep the rule, make it correct
- Wrap the two updates in a single linq2db transaction with `IsolationLevel.Serializable` (or use Postgres advisory lock keyed on the table).
- Add a partial unique index in the DB: `CREATE UNIQUE INDEX vehicles_one_default ON vehicles (is_default) WHERE is_default = true;` — DB-level guarantee even if a future code path forgets the transaction.
- Add the rule explicitly to `azaion-suite/_docs/02_missions.md` under "Vehicles": "exactly one vehicle has `is_default = true` at any time; toggle on a non-default unsets the previous default in the same transaction."
### Option B: drop the rule from code
- Remove the "clear all others" step. `SetDefault(...)` becomes a single-row update.
- UI becomes responsible for displaying multiple defaults if they exist, or for picking one consistently (e.g., most recent).
- Spec stays silent — current behaviour is preserved.
The default position is **Option A** because the UI assumption "there is a default" is the user-visible truth; the race today is the bug. Implementer of B12 confirms with stakeholders before landing if there's any reason to prefer B.
## Acceptance criteria (Option A path)
- A two-thread integration test that calls `SetDefault(A)` and `SetDefault(B)` concurrently leaves the table with exactly one of the two as default — never zero, never both.
- The partial unique index exists post-B9 (B12 ships its own follow-up migration if needed).
- Spec catch-up landed in `02_missions.md`.
## Acceptance criteria (Option B path)
- `SetDefault(...)` is a single-row update.
- The "uniqueness" line stays absent from `02_missions.md`.
- A documented note in the UI consumer ticket (filed by B12 implementer) about handling the multi-default case.
## Risks & Mitigation
**Risk 1: Choosing Option A surfaces a concurrent-write bottleneck**
- *Risk*: Serializable transactions on `vehicles` block during heavy load.
- *Mitigation*: Realistic load on this table is single-digit writes per minute (operator picks a default; fleet has a few vehicles). The bottleneck is purely theoretical.
**Risk 2: Choosing Option B silently breaks an unstated UI invariant**
- *Risk*: A code path in the UI assumes `vehicles.find(v => v.isDefault)` returns one and crashes on multi-default.
- *Mitigation*: B12 implementer (Option B path) audits UI usages before landing and files the consumer-side follow-up.
## Constraints
- This ticket is small (2 SP) but the decision is the work. It can land any time after B6 — it's not on the critical path of the rename Epic.